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