1use std::path::Path;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::filter::mask_secrets;
9use kindling_provider::{retrieve_at, LocalFtsProvider};
10use kindling_store::{SqliteKindlingStore, StoreError, StoreOptions};
11use kindling_types::{
12 Capsule, CapsuleInput, CapsuleStatus, CapsuleType, Id, ListObservationsRequest,
13 ListObservationsResult, Observation, ObservationInput, Pin, PinInput, PinTargetType,
14 RetrieveOptions, RetrieveResult, ScopeIds, Summary, SummaryInput, Timestamp, ValidationError,
15};
16
17use crate::context::{PreCompactContext, ResolvedPin, SessionStartContext};
18use crate::error::{ServiceError, ServiceResult};
19use crate::validation;
20
21#[derive(Debug, Clone)]
25pub struct OpenCapsuleOptions {
26 pub kind: CapsuleType,
28 pub intent: String,
30 pub scope_ids: ScopeIds,
32 pub id: Option<Id>,
34}
35
36#[derive(Debug, Clone, Default)]
42pub struct CloseCapsuleOptions {
43 pub generate_summary: bool,
45 pub summary_content: Option<String>,
47 pub confidence: Option<f64>,
49}
50
51#[derive(Debug, Clone)]
56pub struct AppendObservationOptions {
57 pub capsule_id: Option<Id>,
59 pub validate: bool,
61}
62
63#[derive(Debug, Clone, PartialEq)]
76pub struct AppendOutcome {
77 pub observation: Observation,
79 pub deduplicated: bool,
81}
82
83impl Default for AppendObservationOptions {
84 fn default() -> Self {
85 Self {
86 capsule_id: None,
87 validate: true,
88 }
89 }
90}
91
92#[derive(Debug, Clone)]
97pub struct CreatePinOptions {
98 pub target_type: PinTargetType,
100 pub target_id: Id,
102 pub note: Option<String>,
104 pub ttl_ms: Option<i64>,
106 pub scope_ids: Option<ScopeIds>,
108}
109
110pub struct KindlingService {
115 store: SqliteKindlingStore,
116}
117
118impl KindlingService {
119 pub fn new(store: SqliteKindlingStore) -> Self {
121 Self { store }
122 }
123
124 pub fn open(path: &Path) -> ServiceResult<Self> {
126 Ok(Self::new(SqliteKindlingStore::open(path)?))
127 }
128
129 pub fn open_with_options(path: &Path, options: &StoreOptions) -> ServiceResult<Self> {
131 Ok(Self::new(SqliteKindlingStore::open_with_options(
132 path, options,
133 )?))
134 }
135
136 pub fn open_in_memory() -> ServiceResult<Self> {
138 Ok(Self::new(SqliteKindlingStore::open_in_memory()?))
139 }
140
141 pub fn store(&self) -> &SqliteKindlingStore {
143 &self.store
144 }
145
146 pub fn open_capsule(&self, options: OpenCapsuleOptions) -> ServiceResult<Capsule> {
152 self.open_capsule_at(options, now_ms())
153 }
154
155 pub fn open_capsule_at(
157 &self,
158 options: OpenCapsuleOptions,
159 now: Timestamp,
160 ) -> ServiceResult<Capsule> {
161 if options.kind == CapsuleType::Session {
163 if let Some(session_id) = options.scope_ids.session_id.as_deref() {
164 if let Some(existing) = self.store.get_open_capsule_for_session(session_id)? {
165 return Err(ServiceError::Conflict(format!(
166 "session {session_id} already has an open capsule ({})",
167 existing.id
168 )));
169 }
170 }
171 }
172
173 let capsule = validation::validate_capsule(
174 CapsuleInput {
175 id: options.id,
176 kind: options.kind,
177 intent: options.intent,
178 status: Some(CapsuleStatus::Open),
179 opened_at: None,
180 closed_at: None,
181 scope_ids: options.scope_ids,
182 observation_ids: None,
183 summary_id: None,
184 },
185 now,
186 )
187 .map_err(ServiceError::Validation)?;
188
189 self.store.create_capsule(&capsule)?;
190 Ok(capsule)
191 }
192
193 pub fn close_capsule(
197 &self,
198 capsule_id: &str,
199 options: CloseCapsuleOptions,
200 ) -> ServiceResult<Capsule> {
201 self.close_capsule_at(capsule_id, options, now_ms())
202 }
203
204 pub fn close_capsule_at(
206 &self,
207 capsule_id: &str,
208 options: CloseCapsuleOptions,
209 now: Timestamp,
210 ) -> ServiceResult<Capsule> {
211 let mut capsule = match self.store.get_capsule(capsule_id)? {
215 None => return Err(ServiceError::NotFound(capsule_id.to_string())),
216 Some(capsule) => capsule,
217 };
218 if capsule.status == CapsuleStatus::Closed {
219 return Err(ServiceError::AlreadyClosed(capsule_id.to_string()));
220 }
221
222 if options.generate_summary {
224 if let Some(content) = options.summary_content {
225 let summary = validation::validate_summary(
226 SummaryInput {
227 id: None,
228 capsule_id: capsule_id.to_string(),
229 content,
230 confidence: options.confidence.unwrap_or(1.0),
231 created_at: Some(now),
232 evidence_refs: Vec::new(),
233 },
234 now,
235 )
236 .map_err(ServiceError::Validation)?;
237 self.store.insert_summary(&summary)?;
238 }
239 }
240
241 match self.store.close_capsule(capsule_id, Some(now), None) {
244 Ok(()) => {}
245 Err(StoreError::CapsuleNotOpen(_)) => {
247 return Err(ServiceError::AlreadyClosed(capsule_id.to_string()))
248 }
249 Err(err) => return Err(err.into()),
250 }
251
252 capsule.status = CapsuleStatus::Closed;
253 capsule.closed_at = Some(now);
254 Ok(capsule)
255 }
256
257 pub fn append_observation(
265 &self,
266 input: ObservationInput,
267 options: AppendObservationOptions,
268 ) -> ServiceResult<AppendOutcome> {
269 self.append_observation_at(input, options, now_ms())
270 }
271
272 pub fn append_observation_at(
281 &self,
282 input: ObservationInput,
283 options: AppendObservationOptions,
284 now: Timestamp,
285 ) -> ServiceResult<AppendOutcome> {
286 let mut observation = if options.validate {
287 validation::validate_observation(input, now).map_err(ServiceError::Validation)?
288 } else {
289 validation::normalize_observation(input, now)
290 };
291
292 observation.content = mask_secrets(&observation.content);
297
298 let written = self.store.insert_observation(&observation)?;
299
300 let (observation, deduplicated) = if written {
304 (observation, false)
305 } else {
306 let existing = self
307 .store
308 .get_observation_by_id(&observation.id)?
309 .ok_or_else(|| {
310 ServiceError::Store(StoreError::ObservationNotFound(observation.id.clone()))
313 })?;
314 (existing, true)
315 };
316
317 if let Some(capsule_id) = options.capsule_id.as_deref() {
321 self.store
322 .attach_observation_to_capsule(capsule_id, &observation.id)?;
323 }
324
325 Ok(AppendOutcome {
326 observation,
327 deduplicated,
328 })
329 }
330
331 pub fn retrieve(&self, options: RetrieveOptions) -> ServiceResult<RetrieveResult> {
335 self.retrieve_at(options, now_ms())
336 }
337
338 pub fn retrieve_at(
340 &self,
341 options: RetrieveOptions,
342 now: Timestamp,
343 ) -> ServiceResult<RetrieveResult> {
344 let provider = LocalFtsProvider::from_store(&self.store);
345 Ok(retrieve_at(&self.store, &provider, &options, now)?)
346 }
347
348 pub fn list_observations(
357 &self,
358 request: ListObservationsRequest,
359 ) -> ServiceResult<ListObservationsResult> {
360 const DEFAULT_LIMIT: u32 = 100;
361 const MAX_LIMIT: u32 = 1000;
362
363 let limit = request.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
364
365 let cursor = match request.cursor.as_deref() {
366 Some(raw) => Some(cursor::decode(raw).map_err(|()| {
367 ServiceError::Validation(vec![ValidationError {
368 field: "cursor".to_string(),
369 message: "malformed pagination cursor".to_string(),
370 value: None,
371 }])
372 })?),
373 None => None,
374 };
375
376 let mut scope = request.scope_ids.clone();
378 scope.task_id = None;
379
380 let include_redacted = request.include_redacted.unwrap_or(false);
381 let cursor_ref = cursor.as_ref().map(|(ts, id)| (*ts, id.as_str()));
382
383 let fetch = limit.saturating_add(1);
385 let mut observations = self.store.list_observations(
386 Some(&scope),
387 &request.kinds,
388 request.since,
389 request.until,
390 cursor_ref,
391 include_redacted,
392 fetch,
393 )?;
394
395 let next_cursor = if observations.len() as u32 > limit {
396 observations.truncate(limit as usize);
397 observations.last().map(|o| cursor::encode(o.ts, &o.id))
398 } else {
399 None
400 };
401
402 Ok(ListObservationsResult {
403 observations,
404 next_cursor,
405 })
406 }
407
408 pub fn pin(&self, options: CreatePinOptions) -> ServiceResult<Pin> {
413 self.pin_at(options, now_ms())
414 }
415
416 pub fn pin_at(&self, options: CreatePinOptions, now: Timestamp) -> ServiceResult<Pin> {
418 let expires_at = options.ttl_ms.map(|ttl| now + ttl);
419 let pin = validation::validate_pin(
420 PinInput {
421 id: None,
422 target_type: options.target_type,
423 target_id: options.target_id,
424 reason: options.note,
425 created_at: Some(now),
426 expires_at,
427 scope_ids: options.scope_ids.unwrap_or_default(),
428 },
429 now,
430 )
431 .map_err(ServiceError::Validation)?;
432
433 self.store.insert_pin(&pin)?;
434 Ok(pin)
435 }
436
437 pub fn unpin(&self, pin_id: &str) -> ServiceResult<()> {
440 self.store.delete_pin(pin_id)?;
441 Ok(())
442 }
443
444 pub fn forget(&self, observation_id: &str) -> ServiceResult<()> {
448 self.store.redact_observation(observation_id)?;
449 Ok(())
450 }
451
452 pub fn get_capsule(&self, capsule_id: &str) -> ServiceResult<Option<Capsule>> {
454 Ok(self.store.get_capsule(capsule_id)?)
455 }
456
457 pub fn get_open_capsule(&self, session_id: &str) -> ServiceResult<Option<Capsule>> {
459 Ok(self.store.get_open_capsule_for_session(session_id)?)
460 }
461
462 pub fn get_observation(&self, observation_id: &str) -> ServiceResult<Option<Observation>> {
464 Ok(self.store.get_observation_by_id(observation_id)?)
465 }
466
467 pub fn get_summary(&self, summary_id: &str) -> ServiceResult<Option<Summary>> {
469 Ok(self.store.get_summary_by_id(summary_id)?)
470 }
471
472 pub fn get_latest_summary(&self, capsule_id: &str) -> ServiceResult<Option<Summary>> {
475 Ok(self.store.get_latest_summary_for_capsule(capsule_id)?)
476 }
477
478 pub fn list_pins(&self, scope: Option<&ScopeIds>) -> ServiceResult<Vec<Pin>> {
480 self.list_pins_at(scope, now_ms())
481 }
482
483 pub fn list_pins_at(
485 &self,
486 scope: Option<&ScopeIds>,
487 now: Timestamp,
488 ) -> ServiceResult<Vec<Pin>> {
489 Ok(self.store.list_active_pins(scope, Some(now))?)
490 }
491
492 pub fn session_start_context(
497 &self,
498 scope: &ScopeIds,
499 max_results: u32,
500 ) -> ServiceResult<SessionStartContext> {
501 self.session_start_context_at(scope, max_results, now_ms())
502 }
503
504 pub fn session_start_context_at(
512 &self,
513 scope: &ScopeIds,
514 max_results: u32,
515 now: Timestamp,
516 ) -> ServiceResult<SessionStartContext> {
517 let pins = self.resolved_active_pins(scope, now)?;
518 let recent = self
521 .store
522 .query_observations(Some(scope), None, None, max_results)?;
523 Ok(SessionStartContext { pins, recent })
524 }
525
526 pub fn pre_compact_context(&self, scope: &ScopeIds) -> ServiceResult<PreCompactContext> {
529 self.pre_compact_context_at(scope, now_ms())
530 }
531
532 pub fn pre_compact_context_at(
542 &self,
543 scope: &ScopeIds,
544 now: Timestamp,
545 ) -> ServiceResult<PreCompactContext> {
546 let pins = self.resolved_active_pins(scope, now)?;
547 let latest_summary = self
548 .store
549 .latest_summary_for_scope(Some(scope))?
550 .filter(|s| !s.content.is_empty());
551 Ok(PreCompactContext {
552 pins,
553 latest_summary,
554 })
555 }
556
557 fn resolved_active_pins(
563 &self,
564 scope: &ScopeIds,
565 now: Timestamp,
566 ) -> ServiceResult<Vec<ResolvedPin>> {
567 let pins = self.store.list_active_pins(Some(scope), Some(now))?;
568 pins.into_iter()
569 .map(|pin| {
570 let content = match pin.target_type {
571 PinTargetType::Observation => self
572 .store
573 .get_observation_by_id(&pin.target_id)?
574 .map(|o| o.content),
575 PinTargetType::Summary => self
576 .store
577 .get_summary_by_id(&pin.target_id)?
578 .map(|s| s.content),
579 };
580 Ok(ResolvedPin {
581 note: pin.reason,
582 content,
583 })
584 })
585 .collect()
586 }
587}
588
589fn now_ms() -> Timestamp {
591 SystemTime::now()
592 .duration_since(UNIX_EPOCH)
593 .expect("system clock before Unix epoch")
594 .as_millis() as Timestamp
595}
596
597mod cursor {
603 use kindling_types::Timestamp;
604
605 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
606
607 fn b64_encode(input: &[u8]) -> String {
608 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
609 for chunk in input.chunks(3) {
610 let b0 = chunk[0] as u32;
611 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
612 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
613 let n = (b0 << 16) | (b1 << 8) | b2;
614 out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
615 out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
616 if chunk.len() > 1 {
617 out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
618 }
619 if chunk.len() > 2 {
620 out.push(ALPHABET[(n & 0x3f) as usize] as char);
621 }
622 }
623 out
624 }
625
626 fn b64_decode(input: &str) -> Result<Vec<u8>, ()> {
627 fn val(c: u8) -> Result<u32, ()> {
628 match c {
629 b'A'..=b'Z' => Ok((c - b'A') as u32),
630 b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
631 b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
632 b'-' => Ok(62),
633 b'_' => Ok(63),
634 _ => Err(()),
635 }
636 }
637 let bytes = input.as_bytes();
638 let mut out = Vec::with_capacity(input.len() / 4 * 3);
639 for chunk in bytes.chunks(4) {
640 if chunk.len() < 2 {
641 return Err(());
642 }
643 let mut n = 0u32;
644 for (i, &c) in chunk.iter().enumerate() {
645 n |= val(c)? << (18 - 6 * i);
646 }
647 out.push((n >> 16) as u8);
648 if chunk.len() > 2 {
649 out.push((n >> 8) as u8);
650 }
651 if chunk.len() > 3 {
652 out.push(n as u8);
653 }
654 }
655 Ok(out)
656 }
657
658 pub(crate) fn encode(ts: Timestamp, id: &str) -> String {
660 b64_encode(format!("{ts}:{id}").as_bytes())
661 }
662
663 pub(crate) fn decode(raw: &str) -> Result<(Timestamp, String), ()> {
665 let s = String::from_utf8(b64_decode(raw)?).map_err(|_| ())?;
666 let (ts_str, id) = s.split_once(':').ok_or(())?;
667 let ts = ts_str.parse::<Timestamp>().map_err(|_| ())?;
668 if id.is_empty() {
669 return Err(());
670 }
671 Ok((ts, id.to_string()))
672 }
673
674 #[cfg(test)]
675 mod tests {
676 use super::*;
677
678 #[test]
679 fn round_trips_various_lengths() {
680 for id in [
681 "a",
682 "ab",
683 "abc",
684 "abcd",
685 "550e8400-e29b-41d4-a716-446655440000",
686 ] {
687 let token = encode(1_750_000_000_123, id);
688 assert_eq!(decode(&token).unwrap(), (1_750_000_000_123, id.to_string()));
689 }
690 }
691
692 #[test]
693 fn rejects_malformed() {
694 assert!(decode("").is_err()); assert!(decode("####").is_err()); assert!(decode(&b64_encode(b"no-colon-here")).is_err()); assert!(decode(&b64_encode(b"notanumber:abc")).is_err()); assert!(decode(&b64_encode(b"123:")).is_err()); }
700 }
701}