Skip to main content

kindling_client/
lib.rs

1//! Rust client for the kindling daemon.
2//!
3//! A thin, async HTTP/1-over-Unix-domain-socket client for the
4//! [`kindling-server`](../kindling_server/index.html) daemon. It speaks the v1
5//! wire contract exactly, sending the `X-Kindling-Project` header on every data
6//! endpoint, and auto-spawns `kindling serve --daemonize` on first call if the
7//! daemon is not running.
8//!
9//! The method surface mirrors `kindling-service` for ergonomic
10//! in-process / via-daemon interchangeability. To stay thin, the crate depends
11//! only on [`kindling_types`] for domain shapes — never on `kindling-service`
12//! or `kindling-store` (which pull `rusqlite`).
13//!
14//! # v1 wire contract
15//!
16//! ```text
17//! GET    /v1/health                  → 200 { version, schemaVersion, supportedKinds, storagePath, kindRegistry, projects: [...] }
18//! POST   /v1/capsules                → 201 Capsule
19//! GET    /v1/capsules/open?sessionId → 200 Capsule | null
20//! PATCH  /v1/capsules/:id/close      → 200 Capsule
21//! POST   /v1/observations            → 201 Observation
22//! POST   /v1/observations/:id/forget  → 204 (redact an observation)
23//! POST   /v1/retrieve                → 200 RetrieveResult
24//! POST   /v1/pins                    → 201 Pin
25//! DELETE /v1/pins/:id                → 204
26//! POST   /v1/context/session-start   → 200 { additionalContext: string | null }
27//! POST   /v1/context/pre-compact     → 200 { additionalContext: string | null }
28//! ```
29//!
30//! # Schema version
31//!
32//! [`Client::health`] checks the daemon's reported `schemaVersion` against
33//! [`ClientConfig::expected_schema_version`] (default
34//! [`EXPECTED_SCHEMA_VERSION`], sourced at compile time from the repo-root
35//! `schema/version.json`) and returns [`ClientError::SchemaMismatch`] on
36//! disagreement. See [`Spawner`] and the `config` notes for the `cargo publish`
37//! copy-step caveat.
38//!
39//! # Example
40//!
41//! ```no_run
42//! # async fn run() -> Result<(), kindling_client::ClientError> {
43//! // Everything you need is re-exported here — no need to depend on
44//! // `kindling-types` directly.
45//! use kindling_client::{Client, CapsuleType, ScopeIds};
46//!
47//! let client = Client::new()?;
48//! let health = client.health().await?;
49//! println!("daemon schema v{}", health.schema_version);
50//!
51//! let capsule = client
52//!     .open_capsule(CapsuleType::Session, "investigate bug", ScopeIds::default(), None)
53//!     .await?;
54//! # let _ = capsule;
55//! # Ok(())
56//! # }
57//! ```
58
59mod body;
60mod config;
61mod error;
62#[cfg(feature = "spool")]
63pub mod spool;
64mod transport;
65
66pub use body::{CloseCapsuleBody, CreatePinBody};
67pub use config::{
68    default_port_path, default_socket_path, ClientConfig, Spawner, Transport,
69    EXPECTED_SCHEMA_VERSION,
70};
71pub use error::ClientError;
72
73use hyper::StatusCode;
74use serde::de::DeserializeOwned;
75use serde::Deserialize;
76
77/// Domain types re-exported from [`kindling_types`] so the daemon client is a
78/// self-contained SDK: depend on `kindling-client` alone and reach every type
79/// the API sends or returns as `kindling_client::<Type>`. `kindling-types`
80/// stays an internal transitive dependency you never have to name.
81pub use kindling_types::{
82    build_capability, kind_registry, supported_kind_names, CandidateResult, Capability, Capsule,
83    CapsuleStatus, CapsuleType, Id, KindRegistryEntry, Observation, ObservationInput,
84    ObservationKind, Pin, PinResult, PinTargetType, ProviderSearchOptions, ProviderSearchResult,
85    RetrieveOptions, RetrieveProvenance, RetrieveResult, RetrievedEntity, ScopeIds, Summary,
86    Timestamp,
87};
88
89use body::{
90    AppendObservationBody, OpenCapsuleBody, PreCompactContextBody, SessionStartContextBody,
91};
92
93/// Header carrying the project root string for per-project DB routing. Mirrors
94/// `kindling_server::PROJECT_HEADER`.
95pub const PROJECT_HEADER: &str = "x-kindling-project";
96
97/// Result of `GET /v1/health`.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Health {
100    /// Daemon package version (`CARGO_PKG_VERSION` of `kindling-server`).
101    pub version: String,
102    /// Schema version the daemon's store reports.
103    pub schema_version: u32,
104    /// Snake-case observation kinds the daemon supports.
105    pub supported_kinds: Vec<String>,
106    /// Daemon kindling home root (global storage path).
107    pub storage_path: String,
108    /// Machine-readable kind registry (kinds + required fields).
109    pub kind_registry: Vec<KindRegistryEntry>,
110    /// Project ids the daemon has touched this session.
111    pub projects: Vec<String>,
112}
113
114/// Raw `/v1/health` JSON shape.
115#[derive(Debug, Deserialize)]
116#[serde(rename_all = "camelCase")]
117struct HealthBody {
118    version: String,
119    schema_version: u32,
120    supported_kinds: Vec<String>,
121    storage_path: String,
122    kind_registry: Vec<KindRegistryEntry>,
123    #[serde(default)]
124    projects: Vec<String>,
125}
126
127/// Daemon error body shape: `{ "error": "<msg>" }`.
128#[derive(Debug, Deserialize)]
129struct ErrorBody {
130    error: String,
131}
132
133/// `/v1/context/*` response shape: `{ "additionalContext": string | null }`.
134#[derive(Debug, Deserialize)]
135#[serde(rename_all = "camelCase")]
136struct ContextBody {
137    #[serde(default)]
138    additional_context: Option<String>,
139}
140
141/// A thin async client for the kindling daemon.
142///
143/// Cheap to clone-by-config; construct once and share a reference. Each call
144/// opens a fresh connection (no pooling), so the client itself holds no live
145/// socket and is `Send + Sync`.
146#[derive(Debug, Clone)]
147pub struct Client {
148    config: ClientConfig,
149}
150
151impl Client {
152    /// Build a client from the default [`ClientConfig`]: socket at
153    /// `~/.kindling/kindling.sock`, project root from the current directory,
154    /// the compiled schema version, a 1s connect budget, and the real binary
155    /// spawner.
156    pub fn new() -> Result<Self, ClientError> {
157        Ok(Self {
158            config: ClientConfig::defaults()?,
159        })
160    }
161
162    /// Build a client from an explicit [`ClientConfig`].
163    pub fn with_config(config: ClientConfig) -> Self {
164        Self { config }
165    }
166
167    /// The configuration this client was built with.
168    pub fn config(&self) -> &ClientConfig {
169        &self.config
170    }
171
172    /// `GET /v1/health` — version, schema version, and touched project ids.
173    ///
174    /// Verifies the daemon's `schemaVersion` matches
175    /// [`ClientConfig::expected_schema_version`]; returns
176    /// [`ClientError::SchemaMismatch`] if not (fail loud).
177    pub async fn health(&self) -> Result<Health, ClientError> {
178        let body: HealthBody = self
179            .call(
180                "GET",
181                "/v1/health",
182                /* project */ false,
183                None::<&()>,
184                &[StatusCode::OK],
185            )
186            .await?;
187
188        let expected = self.config.expected_schema_version;
189        if body.schema_version != expected {
190            return Err(ClientError::SchemaMismatch {
191                expected,
192                actual: body.schema_version,
193            });
194        }
195        Ok(Health {
196            version: body.version,
197            schema_version: body.schema_version,
198            supported_kinds: body.supported_kinds,
199            storage_path: body.storage_path,
200            kind_registry: body.kind_registry,
201            projects: body.projects,
202        })
203    }
204
205    /// `POST /v1/capsules` — open a capsule.
206    pub async fn open_capsule(
207        &self,
208        kind: CapsuleType,
209        intent: impl Into<String>,
210        scope_ids: ScopeIds,
211        id: Option<Id>,
212    ) -> Result<Capsule, ClientError> {
213        let body = OpenCapsuleBody {
214            kind,
215            intent: intent.into(),
216            scope_ids,
217            id,
218        };
219        self.call(
220            "POST",
221            "/v1/capsules",
222            true,
223            Some(&body),
224            &[StatusCode::CREATED],
225        )
226        .await
227    }
228
229    /// `GET /v1/capsules/open?sessionId=…` — the open session capsule for
230    /// `session_id`, or `None` when none is open.
231    ///
232    /// Each Claude Code hook is a fresh process holding only the session id, so
233    /// the Stop hook resolves the capsule it must close through this endpoint
234    /// rather than tracking it in-process.
235    pub async fn get_open_capsule(&self, session_id: &str) -> Result<Option<Capsule>, ClientError> {
236        let path = format!(
237            "/v1/capsules/open?sessionId={}",
238            percent_encode_query(session_id)
239        );
240        self.call("GET", &path, true, None::<&()>, &[StatusCode::OK])
241            .await
242    }
243
244    /// `PATCH /v1/capsules/:id/close` — close a capsule.
245    pub async fn close_capsule(
246        &self,
247        capsule_id: &str,
248        body: CloseCapsuleBody,
249    ) -> Result<Capsule, ClientError> {
250        let path = format!("/v1/capsules/{}/close", capsule_id);
251        self.call("PATCH", &path, true, Some(&body), &[StatusCode::OK])
252            .await
253    }
254
255    /// `POST /v1/observations` — append an observation, optionally attaching it
256    /// to `capsule_id` and toggling service-side `validate` (default true).
257    pub async fn append_observation(
258        &self,
259        input: ObservationInput,
260        capsule_id: Option<Id>,
261        validate: Option<bool>,
262    ) -> Result<Observation, ClientError> {
263        let body = AppendObservationBody {
264            input,
265            capsule_id,
266            validate,
267        };
268        self.call(
269            "POST",
270            "/v1/observations",
271            true,
272            Some(&body),
273            &[StatusCode::CREATED],
274        )
275        .await
276    }
277
278    /// `POST /v1/retrieve` — deterministic ranked retrieval.
279    pub async fn retrieve(&self, options: RetrieveOptions) -> Result<RetrieveResult, ClientError> {
280        self.call(
281            "POST",
282            "/v1/retrieve",
283            true,
284            Some(&options),
285            &[StatusCode::OK],
286        )
287        .await
288    }
289
290    /// `POST /v1/pins` — create a pin.
291    pub async fn pin(&self, body: CreatePinBody) -> Result<Pin, ClientError> {
292        self.call(
293            "POST",
294            "/v1/pins",
295            true,
296            Some(&body),
297            &[StatusCode::CREATED],
298        )
299        .await
300    }
301
302    /// `DELETE /v1/pins/:id` — remove a pin.
303    pub async fn unpin(&self, pin_id: &str) -> Result<(), ClientError> {
304        let path = format!("/v1/pins/{}", pin_id);
305        self.call_no_content("DELETE", &path, true, &[StatusCode::NO_CONTENT])
306            .await
307    }
308
309    /// `POST /v1/observations/:id/forget` — redact an observation (content
310    /// replaced with `[redacted]`, `redacted` flag set). Succeeds on `204`.
311    ///
312    /// A missing id surfaces as [`ClientError::Api`] with status `404` (the
313    /// daemon maps the store's `ObservationNotFound`). The `observation_id` must
314    /// be exact — prefix resolution is a higher-layer concern.
315    pub async fn forget(&self, observation_id: &str) -> Result<(), ClientError> {
316        let path = format!("/v1/observations/{}/forget", observation_id);
317        self.call_no_content("POST", &path, true, &[StatusCode::NO_CONTENT])
318            .await
319    }
320
321    /// `POST /v1/context/session-start` — the assembled + formatted SessionStart
322    /// injection markdown, or `None` when there is nothing to inject.
323    ///
324    /// The daemon owns the formatting (recency-ordered observations, pin
325    /// previews, and the `toLocaleString`-parity dates). `max_results` caps the
326    /// recent-observation count (default 10 when `None`). The project scope is
327    /// derived from this client's project root, reproducing the Node hook's
328    /// `{ repoId: <project root> }` filter within the project database.
329    pub async fn session_start_context(
330        &self,
331        max_results: Option<u32>,
332    ) -> Result<Option<String>, ClientError> {
333        let body = SessionStartContextBody {
334            max_results,
335            scope_ids: self.project_scope(),
336        };
337        let resp: ContextBody = self
338            .call(
339                "POST",
340                "/v1/context/session-start",
341                true,
342                Some(&body),
343                &[StatusCode::OK],
344            )
345            .await?;
346        Ok(resp.additional_context)
347    }
348
349    /// `POST /v1/context/pre-compact` — the assembled + formatted PreCompact
350    /// injection markdown (pinned items + latest session summary), or `None`
351    /// when there is nothing to inject.
352    ///
353    /// As with [`Self::session_start_context`], the project scope is derived
354    /// from this client's project root.
355    pub async fn pre_compact_context(&self) -> Result<Option<String>, ClientError> {
356        let body = PreCompactContextBody {
357            scope_ids: self.project_scope(),
358        };
359        let resp: ContextBody = self
360            .call(
361                "POST",
362                "/v1/context/pre-compact",
363                true,
364                Some(&body),
365                &[StatusCode::OK],
366            )
367            .await?;
368        Ok(resp.additional_context)
369    }
370
371    /// A repo scope built from this client's project root, mirroring the Node
372    /// hook's `{ repoId: getProjectRoot(cwd) }`.
373    fn project_scope(&self) -> ScopeIds {
374        ScopeIds {
375            repo_id: Some(self.config.project_root.clone()),
376            ..Default::default()
377        }
378    }
379
380    // ---- internal request plumbing ------------------------------------------
381
382    /// Send a request and decode a 2xx JSON body into `T`.
383    async fn call<B, T>(
384        &self,
385        method: &str,
386        path: &str,
387        project: bool,
388        body: Option<&B>,
389        expected: &[StatusCode],
390    ) -> Result<T, ClientError>
391    where
392        B: serde::Serialize,
393        T: DeserializeOwned,
394    {
395        let raw = self.send(method, path, project, body).await?;
396        ensure_status(&raw, expected)?;
397        serde_json::from_slice(&raw.body)
398            .map_err(|e| ClientError::Decode(format!("{e}: body was {:?}", raw.body)))
399    }
400
401    /// Send a request that returns no body on success.
402    async fn call_no_content(
403        &self,
404        method: &str,
405        path: &str,
406        project: bool,
407        expected: &[StatusCode],
408    ) -> Result<(), ClientError> {
409        let raw = self.send(method, path, project, None::<&()>).await?;
410        ensure_status(&raw, expected)?;
411        Ok(())
412    }
413
414    /// Serialize the body and dispatch through the transport.
415    async fn send<B>(
416        &self,
417        method: &str,
418        path: &str,
419        project: bool,
420        body: Option<&B>,
421    ) -> Result<transport::RawResponse, ClientError>
422    where
423        B: serde::Serialize,
424    {
425        let body_str = match body {
426            Some(b) => serde_json::to_string(b)
427                .map_err(|e| ClientError::Decode(format!("serializing request body: {e}")))?,
428            None => String::new(),
429        };
430        let project_header = if project {
431            Some(self.config.project_root.as_str())
432        } else {
433            None
434        };
435        transport::request(
436            &self.config,
437            transport::OutgoingRequest {
438                method,
439                path,
440                project: project_header,
441                body: body_str,
442            },
443        )
444        .await
445    }
446}
447
448/// Percent-encode a query-parameter value, escaping everything outside the
449/// RFC 3986 unreserved set (`A-Z a-z 0-9 - . _ ~`). Keeps the
450/// [`get_open_capsule`](Client::get_open_capsule) URL well-formed for arbitrary
451/// session ids; UUIDs pass through unchanged.
452fn percent_encode_query(value: &str) -> String {
453    let mut out = String::with_capacity(value.len());
454    for byte in value.bytes() {
455        match byte {
456            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
457                out.push(byte as char);
458            }
459            other => {
460                out.push('%');
461                out.push(
462                    char::from_digit((other >> 4) as u32, 16)
463                        .unwrap()
464                        .to_ascii_uppercase(),
465                );
466                out.push(
467                    char::from_digit((other & 0xf) as u32, 16)
468                        .unwrap()
469                        .to_ascii_uppercase(),
470                );
471            }
472        }
473    }
474    out
475}
476
477/// Map a response to an error if its status is not in `expected`, extracting the
478/// daemon's `{ "error": "<msg>" }` message when present.
479fn ensure_status(raw: &transport::RawResponse, expected: &[StatusCode]) -> Result<(), ClientError> {
480    if expected.contains(&raw.status) {
481        return Ok(());
482    }
483    let message = serde_json::from_slice::<ErrorBody>(&raw.body)
484        .map(|b| b.error)
485        .unwrap_or_else(|_| {
486            if raw.body.is_empty() {
487                raw.status
488                    .canonical_reason()
489                    .unwrap_or("unknown error")
490                    .to_string()
491            } else {
492                String::from_utf8_lossy(&raw.body).into_owned()
493            }
494        });
495    Err(ClientError::Api {
496        status: raw.status.as_u16(),
497        message,
498    })
499}