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}