1use std::path::Path;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rusqlite::types::Value as SqlValue;
12use rusqlite::{named_params, params_from_iter, Connection, OptionalExtension};
13
14use kindling_types::{
15 Capsule, CapsuleStatus, CapsuleType, Observation, ObservationKind, Pin, PinTargetType,
16 ScopeIds, Summary, Timestamp,
17};
18
19use crate::db::{open_database, open_in_memory, StoreOptions};
20use crate::error::{StoreError, StoreResult};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct EvidenceSnippet {
25 pub observation_id: String,
26 pub snippet: String,
27 pub kind: ObservationKind,
28}
29
30pub const DEFAULT_SNIPPET_MAX_CHARS: usize = 200;
33
34pub struct SqliteKindlingStore {
36 conn: Connection,
37}
38
39impl SqliteKindlingStore {
40 pub fn open(path: &Path) -> StoreResult<Self> {
42 Self::open_with_options(path, &StoreOptions::default())
43 }
44
45 pub fn open_with_options(path: &Path, options: &StoreOptions) -> StoreResult<Self> {
47 Ok(Self {
48 conn: open_database(path, options)?,
49 })
50 }
51
52 pub fn open_in_memory() -> StoreResult<Self> {
54 Ok(Self {
55 conn: open_in_memory()?,
56 })
57 }
58
59 pub fn from_connection(conn: Connection) -> Self {
61 Self { conn }
62 }
63
64 pub fn connection(&self) -> &Connection {
66 &self.conn
67 }
68
69 pub fn insert_observation(&self, observation: &Observation) -> StoreResult<bool> {
80 let changes = self.conn.execute(
81 "INSERT OR IGNORE INTO observations (
82 id, kind, content, provenance, ts, scope_ids, redacted,
83 session_id, repo_id, agent_id, user_id
84 ) VALUES (
85 :id, :kind, :content, :provenance, :ts, :scope_ids, :redacted,
86 :session_id, :repo_id, :agent_id, :user_id
87 )",
88 named_params! {
89 ":id": observation.id,
90 ":kind": observation_kind_to_str(observation.kind),
91 ":content": observation.content,
92 ":provenance": serde_json::to_string(&observation.provenance)?,
93 ":ts": observation.ts,
94 ":scope_ids": serde_json::to_string(&observation.scope_ids)?,
95 ":redacted": observation.redacted,
96 ":session_id": observation.scope_ids.session_id,
97 ":repo_id": observation.scope_ids.repo_id,
98 ":agent_id": observation.scope_ids.agent_id,
99 ":user_id": observation.scope_ids.user_id,
100 },
101 )?;
102 Ok(changes > 0)
103 }
104
105 pub fn create_capsule(&self, capsule: &Capsule) -> StoreResult<()> {
107 self.conn.execute(
108 "INSERT INTO capsules (
109 id, type, intent, status, opened_at, closed_at, scope_ids,
110 session_id, repo_id, agent_id, user_id
111 ) VALUES (
112 :id, :type, :intent, :status, :opened_at, :closed_at, :scope_ids,
113 :session_id, :repo_id, :agent_id, :user_id
114 )",
115 named_params! {
116 ":id": capsule.id,
117 ":type": capsule_type_to_str(capsule.kind),
118 ":intent": capsule.intent,
119 ":status": capsule_status_to_str(capsule.status),
120 ":opened_at": capsule.opened_at,
121 ":closed_at": capsule.closed_at,
122 ":scope_ids": serde_json::to_string(&capsule.scope_ids)?,
123 ":session_id": capsule.scope_ids.session_id,
124 ":repo_id": capsule.scope_ids.repo_id,
125 ":agent_id": capsule.scope_ids.agent_id,
126 ":user_id": capsule.scope_ids.user_id,
127 },
128 )?;
129 Ok(())
130 }
131
132 pub fn close_capsule(
137 &self,
138 capsule_id: &str,
139 closed_at: Option<Timestamp>,
140 summary_id: Option<&str>,
141 ) -> StoreResult<()> {
142 let changes = self.conn.execute(
143 "UPDATE capsules
144 SET status = 'closed', closed_at = :closed_at
145 WHERE id = :id AND status = 'open'",
146 named_params! {
147 ":id": capsule_id,
148 ":closed_at": closed_at.unwrap_or_else(now_ms),
149 },
150 )?;
151 if changes == 0 {
152 return Err(StoreError::CapsuleNotOpen(capsule_id.to_string()));
153 }
154
155 if let Some(summary_id) = summary_id {
156 let exists: Option<String> = self
157 .conn
158 .query_row(
159 "SELECT id FROM summaries WHERE id = ?1 AND capsule_id = ?2",
160 [summary_id, capsule_id],
161 |row| row.get(0),
162 )
163 .optional()?;
164 if exists.is_none() {
165 return Err(StoreError::SummaryNotFound {
166 summary_id: summary_id.to_string(),
167 capsule_id: capsule_id.to_string(),
168 });
169 }
170 }
171 Ok(())
172 }
173
174 pub fn attach_observation_to_capsule(
191 &self,
192 capsule_id: &str,
193 observation_id: &str,
194 ) -> StoreResult<()> {
195 let next_seq: i64 = self.conn.query_row(
196 "SELECT COALESCE(MAX(seq), -1) + 1 FROM capsule_observations WHERE capsule_id = ?1",
197 [capsule_id],
198 |row| row.get(0),
199 )?;
200 self.conn.execute(
201 "INSERT OR IGNORE INTO capsule_observations (capsule_id, observation_id, seq)
202 VALUES (?1, ?2, ?3)",
203 (capsule_id, observation_id, next_seq),
204 )?;
205 Ok(())
206 }
207
208 pub fn insert_summary(&self, summary: &Summary) -> StoreResult<()> {
210 self.conn.execute(
211 "INSERT INTO summaries (
212 id, capsule_id, content, confidence, created_at, evidence_refs
213 ) VALUES (:id, :capsule_id, :content, :confidence, :created_at, :evidence_refs)",
214 named_params! {
215 ":id": summary.id,
216 ":capsule_id": summary.capsule_id,
217 ":content": summary.content,
218 ":confidence": summary.confidence,
219 ":created_at": summary.created_at,
220 ":evidence_refs": serde_json::to_string(&summary.evidence_refs)?,
221 },
222 )?;
223 Ok(())
224 }
225
226 pub fn insert_pin(&self, pin: &Pin) -> StoreResult<()> {
228 self.conn.execute(
229 "INSERT INTO pins (
230 id, target_type, target_id, reason, created_at, expires_at, scope_ids,
231 session_id, repo_id, agent_id, user_id
232 ) VALUES (
233 :id, :target_type, :target_id, :reason, :created_at, :expires_at, :scope_ids,
234 :session_id, :repo_id, :agent_id, :user_id
235 )",
236 named_params! {
237 ":id": pin.id,
238 ":target_type": pin_target_type_to_str(pin.target_type),
239 ":target_id": pin.target_id,
240 ":reason": pin.reason,
241 ":created_at": pin.created_at,
242 ":expires_at": pin.expires_at,
243 ":scope_ids": serde_json::to_string(&pin.scope_ids)?,
244 ":session_id": pin.scope_ids.session_id,
245 ":repo_id": pin.scope_ids.repo_id,
246 ":agent_id": pin.scope_ids.agent_id,
247 ":user_id": pin.scope_ids.user_id,
248 },
249 )?;
250 Ok(())
251 }
252
253 pub fn delete_pin(&self, pin_id: &str) -> StoreResult<()> {
255 let changes = self
256 .conn
257 .execute("DELETE FROM pins WHERE id = ?1", [pin_id])?;
258 if changes == 0 {
259 return Err(StoreError::PinNotFound(pin_id.to_string()));
260 }
261 Ok(())
262 }
263
264 pub fn list_active_pins(
267 &self,
268 scope: Option<&ScopeIds>,
269 now: Option<Timestamp>,
270 ) -> StoreResult<Vec<Pin>> {
271 let mut sql = String::from(
272 "SELECT id, target_type, target_id, reason, created_at, expires_at, scope_ids
273 FROM pins
274 WHERE (expires_at IS NULL OR expires_at > ?)",
275 );
276 let mut params: Vec<SqlValue> = vec![SqlValue::Integer(now.unwrap_or_else(now_ms))];
277 if let Some(scope) = scope {
278 push_scope_filters(&mut sql, &mut params, scope);
279 }
280 sql.push_str(" ORDER BY created_at DESC");
281
282 let mut stmt = self.conn.prepare(&sql)?;
283 let rows = stmt.query_map(params_from_iter(params), |row| {
284 Ok(RawPin {
285 id: row.get(0)?,
286 target_type: row.get(1)?,
287 target_id: row.get(2)?,
288 reason: row.get(3)?,
289 created_at: row.get(4)?,
290 expires_at: row.get(5)?,
291 scope_ids: row.get(6)?,
292 })
293 })?;
294
295 rows.map(|row| row.map_err(StoreError::from).and_then(RawPin::into_pin))
296 .collect()
297 }
298
299 pub fn redact_observation(&self, observation_id: &str) -> StoreResult<()> {
302 let changes = self.conn.execute(
303 "UPDATE observations SET content = '[redacted]', redacted = 1 WHERE id = ?1",
304 [observation_id],
305 )?;
306 if changes == 0 {
307 return Err(StoreError::ObservationNotFound(observation_id.to_string()));
308 }
309 Ok(())
310 }
311
312 pub fn transaction<T>(&self, f: impl FnOnce(&Self) -> StoreResult<T>) -> StoreResult<T> {
314 let tx = self.conn.unchecked_transaction()?;
315 match f(self) {
316 Ok(value) => {
317 tx.commit()?;
318 Ok(value)
319 }
320 Err(err) => Err(err), }
322 }
323
324 pub fn get_open_capsule_for_session(&self, session_id: &str) -> StoreResult<Option<Capsule>> {
328 let raw = self
329 .conn
330 .query_row(
331 "SELECT id, type, intent, status, opened_at, closed_at, scope_ids
332 FROM capsules
333 WHERE status = 'open' AND session_id = ?1
334 ORDER BY opened_at DESC
335 LIMIT 1",
336 [session_id],
337 RawCapsule::from_row,
338 )
339 .optional()?;
340 match raw {
341 None => Ok(None),
342 Some(raw) => Ok(Some(self.hydrate_capsule(raw)?)),
343 }
344 }
345
346 pub fn get_capsule(&self, capsule_id: &str) -> StoreResult<Option<Capsule>> {
348 let raw = self
349 .conn
350 .query_row(
351 "SELECT id, type, intent, status, opened_at, closed_at, scope_ids
352 FROM capsules
353 WHERE id = ?1",
354 [capsule_id],
355 RawCapsule::from_row,
356 )
357 .optional()?;
358 match raw {
359 None => Ok(None),
360 Some(raw) => Ok(Some(self.hydrate_capsule(raw)?)),
361 }
362 }
363
364 pub fn get_latest_summary_for_capsule(&self, capsule_id: &str) -> StoreResult<Option<Summary>> {
366 self.conn
367 .query_row(
368 "SELECT id, capsule_id, content, confidence, created_at, evidence_refs
369 FROM summaries
370 WHERE capsule_id = ?1
371 ORDER BY created_at DESC
372 LIMIT 1",
373 [capsule_id],
374 RawSummary::from_row,
375 )
376 .optional()?
377 .map(RawSummary::into_summary)
378 .transpose()
379 }
380
381 pub fn latest_summary_for_scope(
399 &self,
400 scope: Option<&ScopeIds>,
401 ) -> StoreResult<Option<Summary>> {
402 let mut sql = String::from(
403 "SELECT s.id, s.capsule_id, s.content, s.confidence, s.created_at, s.evidence_refs
404 FROM summaries s
405 JOIN capsules c ON s.capsule_id = c.id
406 WHERE 1 = 1",
407 );
408 let mut params: Vec<SqlValue> = Vec::new();
409 if let Some(scope) = scope {
410 push_scope_filters_prefixed(&mut sql, &mut params, scope, "c.");
412 }
413 sql.push_str(" ORDER BY s.created_at DESC LIMIT 1");
414
415 let mut stmt = self.conn.prepare(&sql)?;
416 stmt.query_row(params_from_iter(params), RawSummary::from_row)
417 .optional()?
418 .map(RawSummary::into_summary)
419 .transpose()
420 }
421
422 pub fn get_summary_by_id(&self, summary_id: &str) -> StoreResult<Option<Summary>> {
424 self.conn
425 .query_row(
426 "SELECT id, capsule_id, content, confidence, created_at, evidence_refs
427 FROM summaries
428 WHERE id = ?1",
429 [summary_id],
430 RawSummary::from_row,
431 )
432 .optional()?
433 .map(RawSummary::into_summary)
434 .transpose()
435 }
436
437 pub fn get_observation_by_id(&self, observation_id: &str) -> StoreResult<Option<Observation>> {
439 self.conn
440 .query_row(
441 "SELECT id, kind, content, provenance, ts, scope_ids, redacted
442 FROM observations
443 WHERE id = ?1",
444 [observation_id],
445 RawObservation::from_row,
446 )
447 .optional()?
448 .map(RawObservation::into_observation)
449 .transpose()
450 }
451
452 pub fn query_observations(
455 &self,
456 scope: Option<&ScopeIds>,
457 from_ts: Option<Timestamp>,
458 to_ts: Option<Timestamp>,
459 limit: u32,
460 ) -> StoreResult<Vec<Observation>> {
461 let mut sql = String::from(
462 "SELECT id, kind, content, provenance, ts, scope_ids, redacted
463 FROM observations
464 WHERE redacted = 0",
465 );
466 let mut params: Vec<SqlValue> = Vec::new();
467 if let Some(scope) = scope {
468 push_scope_filters(&mut sql, &mut params, scope);
469 }
470 if let Some(from_ts) = from_ts {
471 sql.push_str(" AND ts >= ?");
472 params.push(SqlValue::Integer(from_ts));
473 }
474 if let Some(to_ts) = to_ts {
475 sql.push_str(" AND ts <= ?");
476 params.push(SqlValue::Integer(to_ts));
477 }
478 sql.push_str(" ORDER BY ts DESC LIMIT ?");
479 params.push(SqlValue::Integer(i64::from(limit)));
480
481 let mut stmt = self.conn.prepare(&sql)?;
482 let rows = stmt.query_map(params_from_iter(params), RawObservation::from_row)?;
483 rows.map(|row| {
484 row.map_err(StoreError::from)
485 .and_then(RawObservation::into_observation)
486 })
487 .collect()
488 }
489
490 #[allow(clippy::too_many_arguments)]
504 pub fn list_observations(
505 &self,
506 scope: Option<&ScopeIds>,
507 kinds: &[ObservationKind],
508 since: Option<Timestamp>,
509 until: Option<Timestamp>,
510 cursor: Option<(Timestamp, &str)>,
511 include_redacted: bool,
512 limit: u32,
513 ) -> StoreResult<Vec<Observation>> {
514 let mut sql = String::from(
515 "SELECT id, kind, content, provenance, ts, scope_ids, redacted
516 FROM observations
517 WHERE 1 = 1",
518 );
519 let mut params: Vec<SqlValue> = Vec::new();
520 if let Some(scope) = scope {
521 push_scope_filters(&mut sql, &mut params, scope);
522 }
523 if !include_redacted {
524 sql.push_str(" AND redacted = 0");
525 }
526 if !kinds.is_empty() {
527 sql.push_str(" AND kind IN (");
528 for (i, kind) in kinds.iter().enumerate() {
529 if i > 0 {
530 sql.push(',');
531 }
532 sql.push('?');
533 params.push(SqlValue::Text(observation_kind_to_str(*kind).to_string()));
534 }
535 sql.push(')');
536 }
537 if let Some(since) = since {
538 sql.push_str(" AND ts >= ?");
539 params.push(SqlValue::Integer(since));
540 }
541 if let Some(until) = until {
542 sql.push_str(" AND ts < ?");
543 params.push(SqlValue::Integer(until));
544 }
545 if let Some((cur_ts, cur_id)) = cursor {
546 sql.push_str(" AND (ts > ? OR (ts = ? AND id > ?))");
547 params.push(SqlValue::Integer(cur_ts));
548 params.push(SqlValue::Integer(cur_ts));
549 params.push(SqlValue::Text(cur_id.to_string()));
550 }
551 sql.push_str(" ORDER BY ts ASC, id ASC LIMIT ?");
552 params.push(SqlValue::Integer(i64::from(limit)));
553
554 let mut stmt = self.conn.prepare(&sql)?;
555 let rows = stmt.query_map(params_from_iter(params), RawObservation::from_row)?;
556 rows.map(|row| {
557 row.map_err(StoreError::from)
558 .and_then(RawObservation::into_observation)
559 })
560 .collect()
561 }
562
563 pub fn get_evidence_snippets(
567 &self,
568 observation_ids: &[String],
569 max_chars: usize,
570 ) -> StoreResult<Vec<EvidenceSnippet>> {
571 if observation_ids.is_empty() {
572 return Ok(Vec::new());
573 }
574
575 let placeholders = vec!["?"; observation_ids.len()].join(",");
576 let sql =
577 format!("SELECT id, kind, content FROM observations WHERE id IN ({placeholders})");
578 let mut stmt = self.conn.prepare(&sql)?;
579 let rows = stmt.query_map(params_from_iter(observation_ids), |row| {
580 Ok((
581 row.get::<_, String>(0)?,
582 row.get::<_, String>(1)?,
583 row.get::<_, String>(2)?,
584 ))
585 })?;
586
587 let mut by_id = std::collections::HashMap::new();
588 for row in rows {
589 let (id, kind, content) = row?;
590 by_id.insert(id, (kind, content));
591 }
592
593 observation_ids
594 .iter()
595 .filter_map(|id| by_id.remove(id).map(|found| (id, found)))
596 .map(|(id, (kind, content))| {
597 Ok(EvidenceSnippet {
598 observation_id: id.clone(),
599 kind: observation_kind_from_str(&kind)?,
600 snippet: truncate_snippet(&content, max_chars),
601 })
602 })
603 .collect()
604 }
605
606 pub fn export_observations(
617 &self,
618 scope: Option<&ScopeIds>,
619 include_redacted: bool,
620 limit: Option<u32>,
621 ) -> StoreResult<Vec<Observation>> {
622 let mut sql = String::from(
623 "SELECT id, kind, content, provenance, ts, scope_ids, redacted
624 FROM observations
625 WHERE 1 = 1",
626 );
627 let mut params: Vec<SqlValue> = Vec::new();
628 if let Some(scope) = scope {
629 push_scope_filters(&mut sql, &mut params, scope);
630 }
631 if !include_redacted {
632 sql.push_str(" AND redacted = 0");
633 }
634 sql.push_str(" ORDER BY ts ASC, id ASC");
635 if let Some(limit) = limit {
636 sql.push_str(" LIMIT ?");
637 params.push(SqlValue::Integer(i64::from(limit)));
638 }
639
640 let mut stmt = self.conn.prepare(&sql)?;
641 let rows = stmt.query_map(params_from_iter(params), RawObservation::from_row)?;
642 rows.map(|row| {
643 row.map_err(StoreError::from)
644 .and_then(RawObservation::into_observation)
645 })
646 .collect()
647 }
648
649 pub fn export_capsules(&self, scope: Option<&ScopeIds>) -> StoreResult<Vec<Capsule>> {
652 let mut sql = String::from(
653 "SELECT id, type, intent, status, opened_at, closed_at, scope_ids
654 FROM capsules
655 WHERE 1 = 1",
656 );
657 let mut params: Vec<SqlValue> = Vec::new();
658 if let Some(scope) = scope {
659 push_scope_filters(&mut sql, &mut params, scope);
660 }
661 sql.push_str(" ORDER BY opened_at ASC, id ASC");
662
663 let mut stmt = self.conn.prepare(&sql)?;
664 let raws = stmt
665 .query_map(params_from_iter(params), RawCapsule::from_row)?
666 .collect::<Result<Vec<_>, _>>()?;
667 raws.into_iter()
668 .map(|raw| self.hydrate_capsule(raw))
669 .collect()
670 }
671
672 pub fn export_summaries(&self, scope: Option<&ScopeIds>) -> StoreResult<Vec<Summary>> {
676 let mut sql = String::from(
677 "SELECT s.id, s.capsule_id, s.content, s.confidence, s.created_at, s.evidence_refs
678 FROM summaries s
679 JOIN capsules c ON s.capsule_id = c.id
680 WHERE 1 = 1",
681 );
682 let mut params: Vec<SqlValue> = Vec::new();
683 if let Some(scope) = scope {
684 push_scope_filters_prefixed(&mut sql, &mut params, scope, "c.");
685 }
686 sql.push_str(" ORDER BY s.created_at ASC, s.id ASC");
687
688 let mut stmt = self.conn.prepare(&sql)?;
689 let rows = stmt.query_map(params_from_iter(params), RawSummary::from_row)?;
690 rows.map(|row| {
691 row.map_err(StoreError::from)
692 .and_then(RawSummary::into_summary)
693 })
694 .collect()
695 }
696
697 pub fn export_pins(&self, scope: Option<&ScopeIds>) -> StoreResult<Vec<Pin>> {
701 let mut sql = String::from(
702 "SELECT id, target_type, target_id, reason, created_at, expires_at, scope_ids
703 FROM pins
704 WHERE 1 = 1",
705 );
706 let mut params: Vec<SqlValue> = Vec::new();
707 if let Some(scope) = scope {
708 push_scope_filters(&mut sql, &mut params, scope);
709 }
710 sql.push_str(" ORDER BY created_at ASC, id ASC");
711
712 let mut stmt = self.conn.prepare(&sql)?;
713 let rows = stmt.query_map(params_from_iter(params), |row| {
714 Ok(RawPin {
715 id: row.get(0)?,
716 target_type: row.get(1)?,
717 target_id: row.get(2)?,
718 reason: row.get(3)?,
719 created_at: row.get(4)?,
720 expires_at: row.get(5)?,
721 scope_ids: row.get(6)?,
722 })
723 })?;
724 rows.map(|row| row.map_err(StoreError::from).and_then(RawPin::into_pin))
725 .collect()
726 }
727
728 pub fn import_observation(&self, observation: &Observation) -> StoreResult<bool> {
731 let changes = self.conn.execute(
732 "INSERT OR IGNORE INTO observations (
733 id, kind, content, provenance, ts, scope_ids, redacted,
734 session_id, repo_id, agent_id, user_id
735 ) VALUES (
736 :id, :kind, :content, :provenance, :ts, :scope_ids, :redacted,
737 :session_id, :repo_id, :agent_id, :user_id
738 )",
739 named_params! {
740 ":id": observation.id,
741 ":kind": observation_kind_to_str(observation.kind),
742 ":content": observation.content,
743 ":provenance": serde_json::to_string(&observation.provenance)?,
744 ":ts": observation.ts,
745 ":scope_ids": serde_json::to_string(&observation.scope_ids)?,
746 ":redacted": observation.redacted,
747 ":session_id": observation.scope_ids.session_id,
748 ":repo_id": observation.scope_ids.repo_id,
749 ":agent_id": observation.scope_ids.agent_id,
750 ":user_id": observation.scope_ids.user_id,
751 },
752 )?;
753 Ok(changes > 0)
754 }
755
756 pub fn import_capsule(&self, capsule: &Capsule) -> StoreResult<bool> {
760 let changes = self.conn.execute(
761 "INSERT OR IGNORE INTO capsules (
762 id, type, intent, status, opened_at, closed_at, scope_ids,
763 session_id, repo_id, agent_id, user_id
764 ) VALUES (
765 :id, :type, :intent, :status, :opened_at, :closed_at, :scope_ids,
766 :session_id, :repo_id, :agent_id, :user_id
767 )",
768 named_params! {
769 ":id": capsule.id,
770 ":type": capsule_type_to_str(capsule.kind),
771 ":intent": capsule.intent,
772 ":status": capsule_status_to_str(capsule.status),
773 ":opened_at": capsule.opened_at,
774 ":closed_at": capsule.closed_at,
775 ":scope_ids": serde_json::to_string(&capsule.scope_ids)?,
776 ":session_id": capsule.scope_ids.session_id,
777 ":repo_id": capsule.scope_ids.repo_id,
778 ":agent_id": capsule.scope_ids.agent_id,
779 ":user_id": capsule.scope_ids.user_id,
780 },
781 )?;
782 if changes == 0 {
783 return Ok(false);
784 }
785 let mut stmt = self.conn.prepare(
786 "INSERT OR IGNORE INTO capsule_observations (capsule_id, observation_id, seq)
787 VALUES (?1, ?2, ?3)",
788 )?;
789 for (seq, obs_id) in capsule.observation_ids.iter().enumerate() {
790 stmt.execute((&capsule.id, obs_id, seq as i64))?;
791 }
792 Ok(true)
793 }
794
795 pub fn import_summary(&self, summary: &Summary) -> StoreResult<bool> {
797 let changes = self.conn.execute(
798 "INSERT OR IGNORE INTO summaries (
799 id, capsule_id, content, confidence, created_at, evidence_refs
800 ) VALUES (:id, :capsule_id, :content, :confidence, :created_at, :evidence_refs)",
801 named_params! {
802 ":id": summary.id,
803 ":capsule_id": summary.capsule_id,
804 ":content": summary.content,
805 ":confidence": summary.confidence,
806 ":created_at": summary.created_at,
807 ":evidence_refs": serde_json::to_string(&summary.evidence_refs)?,
808 },
809 )?;
810 Ok(changes > 0)
811 }
812
813 pub fn import_pin(&self, pin: &Pin) -> StoreResult<bool> {
815 let changes = self.conn.execute(
816 "INSERT OR IGNORE INTO pins (
817 id, target_type, target_id, reason, created_at, expires_at, scope_ids,
818 session_id, repo_id, agent_id, user_id
819 ) VALUES (
820 :id, :target_type, :target_id, :reason, :created_at, :expires_at, :scope_ids,
821 :session_id, :repo_id, :agent_id, :user_id
822 )",
823 named_params! {
824 ":id": pin.id,
825 ":target_type": pin_target_type_to_str(pin.target_type),
826 ":target_id": pin.target_id,
827 ":reason": pin.reason,
828 ":created_at": pin.created_at,
829 ":expires_at": pin.expires_at,
830 ":scope_ids": serde_json::to_string(&pin.scope_ids)?,
831 ":session_id": pin.scope_ids.session_id,
832 ":repo_id": pin.scope_ids.repo_id,
833 ":agent_id": pin.scope_ids.agent_id,
834 ":user_id": pin.scope_ids.user_id,
835 },
836 )?;
837 Ok(changes > 0)
838 }
839
840 pub fn database_stats(&self) -> StoreResult<DatabaseStats> {
844 let count =
845 |sql: &str| -> StoreResult<i64> { Ok(self.conn.query_row(sql, [], |row| row.get(0))?) };
846 let observations = count("SELECT COUNT(*) FROM observations")?;
847 let capsules = count("SELECT COUNT(*) FROM capsules")?;
848 let summaries = count("SELECT COUNT(*) FROM summaries")?;
849 let pins = count("SELECT COUNT(*) FROM pins")?;
850 let redacted = count("SELECT COUNT(*) FROM observations WHERE redacted = 1")?;
851 let open_capsules = count("SELECT COUNT(*) FROM capsules WHERE status = 'open'")?;
852 let latest_ts: Option<i64> = self
853 .conn
854 .query_row("SELECT MAX(ts) FROM observations", [], |row| row.get(0))
855 .optional()?
856 .flatten();
857
858 let page_count: i64 = self
859 .conn
860 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
861 let page_size: i64 = self
862 .conn
863 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
864
865 Ok(DatabaseStats {
866 observations,
867 capsules,
868 summaries,
869 pins,
870 redacted,
871 open_capsules,
872 latest_ts,
873 size_bytes: page_count * page_size,
874 })
875 }
876
877 fn hydrate_capsule(&self, raw: RawCapsule) -> StoreResult<Capsule> {
878 let mut stmt = self.conn.prepare(
879 "SELECT observation_id FROM capsule_observations WHERE capsule_id = ?1 ORDER BY seq",
880 )?;
881 let observation_ids = stmt
882 .query_map([&raw.id], |row| row.get::<_, String>(0))?
883 .collect::<Result<Vec<_>, _>>()?;
884 raw.into_capsule(observation_ids)
885 }
886}
887
888#[derive(Debug, Clone, PartialEq, Eq)]
891pub struct DatabaseStats {
892 pub observations: i64,
893 pub capsules: i64,
894 pub summaries: i64,
895 pub pins: i64,
896 pub redacted: i64,
897 pub open_capsules: i64,
898 pub latest_ts: Option<Timestamp>,
900 pub size_bytes: i64,
902}
903
904fn now_ms() -> Timestamp {
905 SystemTime::now()
906 .duration_since(UNIX_EPOCH)
907 .expect("system clock before Unix epoch")
908 .as_millis() as Timestamp
909}
910
911fn truncate_snippet(content: &str, max_chars: usize) -> String {
915 match content.char_indices().nth(max_chars) {
916 None => content.to_string(),
917 Some((byte_idx, _)) => format!("{}...", &content[..byte_idx]),
918 }
919}
920
921fn push_scope_filters(sql: &mut String, params: &mut Vec<SqlValue>, scope: &ScopeIds) {
925 push_scope_filters_prefixed(sql, params, scope, "");
926}
927
928fn push_scope_filters_prefixed(
931 sql: &mut String,
932 params: &mut Vec<SqlValue>,
933 scope: &ScopeIds,
934 prefix: &str,
935) {
936 let filters = [
937 ("session_id", &scope.session_id),
938 ("repo_id", &scope.repo_id),
939 ("agent_id", &scope.agent_id),
940 ("user_id", &scope.user_id),
941 ];
942 for (column, value) in filters {
943 if let Some(value) = value {
944 sql.push_str(" AND ");
945 sql.push_str(prefix);
946 sql.push_str(column);
947 sql.push_str(" = ?");
948 params.push(SqlValue::Text(value.clone()));
949 }
950 }
951}
952
953fn observation_kind_to_str(kind: ObservationKind) -> &'static str {
958 match kind {
959 ObservationKind::ToolCall => "tool_call",
960 ObservationKind::Command => "command",
961 ObservationKind::FileDiff => "file_diff",
962 ObservationKind::Error => "error",
963 ObservationKind::Message => "message",
964 ObservationKind::NodeStart => "node_start",
965 ObservationKind::NodeEnd => "node_end",
966 ObservationKind::NodeOutput => "node_output",
967 ObservationKind::NodeError => "node_error",
968 }
969}
970
971fn observation_kind_from_str(value: &str) -> StoreResult<ObservationKind> {
972 Ok(match value {
973 "tool_call" => ObservationKind::ToolCall,
974 "command" => ObservationKind::Command,
975 "file_diff" => ObservationKind::FileDiff,
976 "error" => ObservationKind::Error,
977 "message" => ObservationKind::Message,
978 "node_start" => ObservationKind::NodeStart,
979 "node_end" => ObservationKind::NodeEnd,
980 "node_output" => ObservationKind::NodeOutput,
981 "node_error" => ObservationKind::NodeError,
982 other => {
983 return Err(StoreError::UnexpectedRowValue {
984 column: "observations.kind",
985 value: other.to_string(),
986 })
987 }
988 })
989}
990
991fn capsule_type_to_str(kind: CapsuleType) -> &'static str {
992 match kind {
993 CapsuleType::Session => "session",
994 CapsuleType::PocketflowNode => "pocketflow_node",
995 }
996}
997
998fn capsule_type_from_str(value: &str) -> StoreResult<CapsuleType> {
999 Ok(match value {
1000 "session" => CapsuleType::Session,
1001 "pocketflow_node" => CapsuleType::PocketflowNode,
1002 other => {
1003 return Err(StoreError::UnexpectedRowValue {
1004 column: "capsules.type",
1005 value: other.to_string(),
1006 })
1007 }
1008 })
1009}
1010
1011fn capsule_status_to_str(status: CapsuleStatus) -> &'static str {
1012 match status {
1013 CapsuleStatus::Open => "open",
1014 CapsuleStatus::Closed => "closed",
1015 }
1016}
1017
1018fn capsule_status_from_str(value: &str) -> StoreResult<CapsuleStatus> {
1019 Ok(match value {
1020 "open" => CapsuleStatus::Open,
1021 "closed" => CapsuleStatus::Closed,
1022 other => {
1023 return Err(StoreError::UnexpectedRowValue {
1024 column: "capsules.status",
1025 value: other.to_string(),
1026 })
1027 }
1028 })
1029}
1030
1031fn pin_target_type_to_str(target: PinTargetType) -> &'static str {
1032 match target {
1033 PinTargetType::Observation => "observation",
1034 PinTargetType::Summary => "summary",
1035 }
1036}
1037
1038fn pin_target_type_from_str(value: &str) -> StoreResult<PinTargetType> {
1039 Ok(match value {
1040 "observation" => PinTargetType::Observation,
1041 "summary" => PinTargetType::Summary,
1042 other => {
1043 return Err(StoreError::UnexpectedRowValue {
1044 column: "pins.target_type",
1045 value: other.to_string(),
1046 })
1047 }
1048 })
1049}
1050
1051struct RawObservation {
1056 id: String,
1057 kind: String,
1058 content: String,
1059 provenance: String,
1060 ts: i64,
1061 scope_ids: String,
1062 redacted: i64,
1063}
1064
1065impl RawObservation {
1066 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1067 Ok(Self {
1068 id: row.get(0)?,
1069 kind: row.get(1)?,
1070 content: row.get(2)?,
1071 provenance: row.get(3)?,
1072 ts: row.get(4)?,
1073 scope_ids: row.get(5)?,
1074 redacted: row.get(6)?,
1075 })
1076 }
1077
1078 fn into_observation(self) -> StoreResult<Observation> {
1079 Ok(Observation {
1080 id: self.id,
1081 kind: observation_kind_from_str(&self.kind)?,
1082 content: self.content,
1083 provenance: serde_json::from_str(&self.provenance)?,
1084 ts: self.ts,
1085 scope_ids: serde_json::from_str(&self.scope_ids)?,
1086 redacted: self.redacted == 1,
1087 })
1088 }
1089}
1090
1091struct RawCapsule {
1092 id: String,
1093 kind: String,
1094 intent: String,
1095 status: String,
1096 opened_at: i64,
1097 closed_at: Option<i64>,
1098 scope_ids: String,
1099}
1100
1101impl RawCapsule {
1102 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1103 Ok(Self {
1104 id: row.get(0)?,
1105 kind: row.get(1)?,
1106 intent: row.get(2)?,
1107 status: row.get(3)?,
1108 opened_at: row.get(4)?,
1109 closed_at: row.get(5)?,
1110 scope_ids: row.get(6)?,
1111 })
1112 }
1113
1114 fn into_capsule(self, observation_ids: Vec<String>) -> StoreResult<Capsule> {
1115 Ok(Capsule {
1116 id: self.id,
1117 kind: capsule_type_from_str(&self.kind)?,
1118 intent: self.intent,
1119 status: capsule_status_from_str(&self.status)?,
1120 opened_at: self.opened_at,
1121 closed_at: self.closed_at,
1122 scope_ids: serde_json::from_str(&self.scope_ids)?,
1123 observation_ids,
1124 summary_id: None,
1127 })
1128 }
1129}
1130
1131struct RawSummary {
1132 id: String,
1133 capsule_id: String,
1134 content: String,
1135 confidence: f64,
1136 created_at: i64,
1137 evidence_refs: String,
1138}
1139
1140impl RawSummary {
1141 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1142 Ok(Self {
1143 id: row.get(0)?,
1144 capsule_id: row.get(1)?,
1145 content: row.get(2)?,
1146 confidence: row.get(3)?,
1147 created_at: row.get(4)?,
1148 evidence_refs: row.get(5)?,
1149 })
1150 }
1151
1152 fn into_summary(self) -> StoreResult<Summary> {
1153 Ok(Summary {
1154 id: self.id,
1155 capsule_id: self.capsule_id,
1156 content: self.content,
1157 confidence: self.confidence,
1158 created_at: self.created_at,
1159 evidence_refs: serde_json::from_str(&self.evidence_refs)?,
1160 })
1161 }
1162}
1163
1164struct RawPin {
1165 id: String,
1166 target_type: String,
1167 target_id: String,
1168 reason: Option<String>,
1169 created_at: i64,
1170 expires_at: Option<i64>,
1171 scope_ids: String,
1172}
1173
1174impl RawPin {
1175 fn into_pin(self) -> StoreResult<Pin> {
1176 Ok(Pin {
1177 id: self.id,
1178 target_type: pin_target_type_from_str(&self.target_type)?,
1179 target_id: self.target_id,
1180 reason: self.reason,
1181 created_at: self.created_at,
1182 expires_at: self.expires_at,
1183 scope_ids: serde_json::from_str(&self.scope_ids)?,
1184 })
1185 }
1186}