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}