- 1
//! vak-store: SQLite FTS5 rebuildable index over JSONL session ledgers. - 2
//! - 3
//! JSONL files remain the source of truth. This crate builds a persistent - 4
//! query index (FTS5 for full-text search, normalized table for structured - 5
//! queries) that can be fully reconstructed from the JSONL at any time. - 6
//! - 7
//! Design: `docs/design/23-memory.md` — rebuildable index layer. - 8
- 9
use std::path::{Path, PathBuf}; - 10
use std::sync::{Arc, Mutex}; - 11
- 12
use rusqlite::Connection; - 13
use serde::{Deserialize, Serialize}; - 14
- 15
pub mod index; - 16
pub mod presentation; - 17
pub mod query; - 18
- 19
/// The database file name within the sessions home directory. - 20
pub const DB_NAME: &str = "store.db"; - 21
- 22
/// Version tag stored in the `meta` table; bump when the schema changes. - 23
const SCHEMA_VERSION: u32 = 1; - 24
- 25
// --------------------------------------------------------------------------- - 26
// Entry metadata (written to the `entries` table alongside FTS rows) - 27
// --------------------------------------------------------------------------- - 28
- 29
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 30
pub enum EntryKind { - 31
Header, - 32
Message, - 33
Compaction, - 34
Receipt, - 35
Goal, - 36
Activity, - 37
Work, - 38
Intent, - 39
} - 40
- 41
impl EntryKind { - 42
pub fn as_str(&self) -> &'static str { - 43
match self { - 44
Self::Header => "header", - 45
Self::Message => "message", - 46
Self::Compaction => "compaction", - 47
Self::Receipt => "receipt", - 48
Self::Goal => "goal", - 49
Self::Activity => "activity", - 50
Self::Work => "work", - 51
Self::Intent => "intent", - 52
} - 53
} - 54
- 55
pub fn parse_str(s: &str) -> Option<Self> { - 56
match s { - 57
"header" => Some(Self::Header), - 58
"message" => Some(Self::Message), - 59
"compaction" => Some(Self::Compaction), - 60
"receipt" => Some(Self::Receipt), - 61
"goal" => Some(Self::Goal), - 62
"activity" => Some(Self::Activity), - 63
"intent" => Some(Self::Intent), - 64
"work" => Some(Self::Work), - 65
_ => None, - 66
} - 67
} - 68
} - 69
- 70
#[derive(Debug, Clone, Serialize, Deserialize)] - 71
pub struct IndexedEntry { - 72
pub entry_id: String, - 73
pub session_id: String, - 74
pub project_hash: String, - 75
pub parent_id: Option<String>, - 76
pub ts: String, - 77
pub kind: EntryKind, - 78
pub role: Option<String>, - 79
pub provider: Option<String>, - 80
pub model: Option<String>, - 81
pub tool_name: Option<String>, - 82
pub content_text: String, - 83
pub is_error: bool, - 84
} - 85
- 86
#[derive(Debug, thiserror::Error)] - 87
pub enum StoreError { - 88
#[error("database error: {0}")] - 89
Db(#[from] rusqlite::Error), - 90
#[error("io error: {0}")] - 91
Io(#[from] std::io::Error), - 92
#[error("json error: {0}")] - 93
Json(#[from] serde_json::Error), - 94
#[error("schema mismatch: expected {expected}, found {found}")] - 95
SchemaMismatch { expected: u32, found: u32 }, - 96
} - 97
- 98
// --------------------------------------------------------------------------- - 99
// Store handle (thread-safe, cheap to clone) - 100
// --------------------------------------------------------------------------- - 101
- 102
#[derive(Clone)] - 103
pub struct Store { - 104
inner: Arc<StoreInner>, - 105
} - 106
- 107
struct StoreInner { - 108
db_path: PathBuf, - 109
conn: Mutex<Connection>, - 110
} - 111
- 112
impl Store { - 113
/// Open or create the FTS5 index at `<sessions_home>/store.db`. - 114
/// WAL mode is enabled automatically. Idempotent. - 115
pub fn open(sessions_home: &Path) -> Result<Self, StoreError> { - 116
std::fs::create_dir_all(sessions_home)?; - 117
let db_path = sessions_home.join(DB_NAME); - 118
let conn = Connection::open(&db_path)?; - 119
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?; - 120
Self::ensure_schema(&conn)?; - 121
Ok(Store { - 122
inner: Arc::new(StoreInner { - 123
db_path, - 124
conn: Mutex::new(conn), - 125
}), - 126
}) - 127
} - 128
- 129
/// Path to the database file. - 130
pub fn db_path(&self) -> &Path { - 131
&self.inner.db_path - 132
} - 133
- 134
/// Rebuild the entire index from JSONL files under `sessions_home`. - 135
/// This drops all existing data and re-imports — safe because the - 136
/// index is fully derivable from JSONL. - 137
pub fn rebuild(&self, sessions_home: &Path) -> Result<RebuildStats, StoreError> { - 138
let conn = self.inner.conn.lock().unwrap_or_else(|p| p.into_inner()); - 139
conn.execute_batch("DELETE FROM entries; DELETE FROM entries_fts;")?; - 140
let stats = self.import_all(&conn, sessions_home)?; - 141
conn.execute( - 142
"INSERT OR REPLACE INTO meta(key, value) VALUES ('version', ?1)", - 143
[SCHEMA_VERSION.to_string()], - 144
)?; - 145
Ok(stats) - 146
} - 147
- 148
/// Import a single JSONL file into the index. Idempotent (skips - 149
/// already-indexed entry IDs). - 150
pub fn import_session( - 151
&self, - 152
sessions_home: &Path, - 153
jsonl_path: &Path, - 154
) -> Result<ImportStats, StoreError> { - 155
let conn = self.inner.conn.lock().unwrap_or_else(|p| p.into_inner()); - 156
self.import_file(&conn, sessions_home, jsonl_path) - 157
} - 158
- 159
/// Append a single entry (real-time update path). Called from - 160
/// `SessionLog::append` when the store is wired in. - 161
pub fn append_entry( - 162
&self, - 163
session_id: &str, - 164
entry: &vak_session::Entry, - 165
) -> Result<(), StoreError> { - 166
let conn = self.inner.conn.lock().unwrap_or_else(|p| p.into_inner()); - 167
if let Some(meta) = self.extract_meta(session_id, entry) { - 168
Self::insert_meta(&conn, &meta)?; - 169
} - 170
Ok(()) - 171
} - 172
- 173
fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { - 174
self.inner.conn.lock().unwrap_or_else(|p| p.into_inner()) - 175
} - 176
} - 177
- 178
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] - 179
pub struct RebuildStats { - 180
pub files_scanned: usize, - 181
pub entries_indexed: usize, - 182
pub fts_rows: usize, - 183
} - 184
- 185
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] - 186
pub struct ImportStats { - 187
pub entries_indexed: usize, - 188
pub fts_rows: usize, - 189
pub skipped: usize, - 190
} - 191
- 192
// --------------------------------------------------------------------------- - 193
// Schema - 194
// --------------------------------------------------------------------------- - 195
- 196
impl Store { - 197
fn ensure_schema(conn: &Connection) -> Result<(), StoreError> { - 198
conn.execute_batch( - 199
"CREATE TABLE IF NOT EXISTS meta ( - 200
key TEXT PRIMARY KEY, - 201
value TEXT NOT NULL - 202
); - 203
- 204
CREATE TABLE IF NOT EXISTS entries ( - 205
entry_id TEXT PRIMARY KEY, - 206
session_id TEXT NOT NULL, - 207
project_hash TEXT NOT NULL, - 208
parent_id TEXT, - 209
ts TEXT NOT NULL, - 210
kind TEXT NOT NULL, - 211
role TEXT, - 212
provider TEXT, - 213
model TEXT, - 214
tool_name TEXT, - 215
content_text TEXT NOT NULL DEFAULT '', - 216
is_error INTEGER NOT NULL DEFAULT 0 - 217
); - 218
- 219
CREATE INDEX IF NOT EXISTS idx_entries_session - 220
ON entries(session_id); - 221
CREATE INDEX IF NOT EXISTS idx_entries_project - 222
ON entries(project_hash); - 223
CREATE INDEX IF NOT EXISTS idx_entries_kind - 224
ON entries(kind); - 225
CREATE INDEX IF NOT EXISTS idx_entries_ts - 226
ON entries(ts); - 227
CREATE INDEX IF NOT EXISTS idx_entries_role - 228
ON entries(role); - 229
- 230
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5( - 231
entry_id UNINDEXED, - 232
session_id UNINDEXED, - 233
project_hash UNINDEXED, - 234
ts UNINDEXED, - 235
kind UNINDEXED, - 236
role UNINDEXED, - 237
content, - 238
tokenize='porter unicode61' - 239
);", - 240
)?; - 241
Ok(()) - 242
} - 243
} - 244
- 245
// --------------------------------------------------------------------------- - 246
// Extraction logic: JSONL Entry → IndexedEntry - 247
// --------------------------------------------------------------------------- - 248
- 249
impl Store { - 250
/// Extract search metadata from a single JSONL entry. - 251
fn extract_meta(&self, session_id: &str, entry: &vak_session::Entry) -> Option<IndexedEntry> { - 252
use vak_session::EntryPayload; - 253
- 254
let kind = match &entry.payload { - 255
EntryPayload::Header(_) => EntryKind::Header, - 256
EntryPayload::Message(_) => EntryKind::Message, - 257
EntryPayload::Compaction(_) => EntryKind::Compaction, - 258
EntryPayload::Receipt(_) => EntryKind::Receipt, - 259
EntryPayload::Goal(_) => EntryKind::Goal, - 260
EntryPayload::GoalUpdate(_) => EntryKind::Goal, - 261
EntryPayload::Activity(_) => EntryKind::Activity, - 262
EntryPayload::Work(_) => EntryKind::Work, - 263
EntryPayload::Intent(_) => EntryKind::Intent, - 264
// A Presentation entry is display-channel/model-history - 265
// data (docs/design/68-context-engine.md §10), not free text to - 266
// full-text index today; its own TurnIndex/recall search - 267
// (design §3) is separate future work. A TurnCard is derived, - 268
// never-model-visible audit data with its own in-memory BM25 - 269
// index (`TurnIndex::search`) — not this store's concern either. - 270
// An evidence body repeats a tool result the Message entry - 271
// already indexes the window of; `recall` reaches the rest. - 272
EntryPayload::TurnCapabilitiesBound(_) - 273
| EntryPayload::TurnCapabilitiesRef(_) - 274
| EntryPayload::ChildRun { .. } - 275
| EntryPayload::Presentation(_) - 276
| EntryPayload::TurnCard(_) - 277
| EntryPayload::EvidenceBody(_) => return None, - 278
}; - 279
- 280
match &entry.payload { - 281
EntryPayload::Message(record) => { - 282
// A runtime-authored nudge is indexed under its own role so - 283
// it is never returned as something the user said. - 284
let role = match record.message.role { - 285
_ if record.control_kind().is_some() => "control", - 286
vak_llm::Role::User => "user", - 287
vak_llm::Role::Assistant => "assistant", - 288
}; - 289
let (text, tool_name, is_error) = extract_message_text(&record.message.content); - 290
let model = record.meta.as_ref().and_then(|m| m.model.clone()); - 291
Some(IndexedEntry { - 292
entry_id: entry.id.clone(), - 293
session_id: session_id.to_string(), - 294
project_hash: String::new(), - 295
parent_id: entry.parent_id.clone(), - 296
ts: entry.ts.to_rfc3339(), - 297
kind, - 298
role: Some(role.to_string()), - 299
provider: None, - 300
model, - 301
tool_name, - 302
content_text: text, - 303
is_error, - 304
}) - 305
} - 306
EntryPayload::Header(header) => Some(IndexedEntry { - 307
entry_id: entry.id.clone(), - 308
session_id: session_id.to_string(), - 309
project_hash: String::new(), - 310
parent_id: entry.parent_id.clone(), - 311
ts: entry.ts.to_rfc3339(), - 312
kind, - 313
role: None, - 314
provider: Some(header.contract.provider.clone()), - 315
model: Some(header.contract.model.clone()), - 316
tool_name: None, - 317
content_text: String::new(), - 318
is_error: false, - 319
}), - 320
EntryPayload::Compaction(c) => Some(IndexedEntry { - 321
entry_id: entry.id.clone(), - 322
session_id: session_id.to_string(), - 323
project_hash: String::new(), - 324
parent_id: entry.parent_id.clone(), - 325
ts: entry.ts.to_rfc3339(), - 326
kind, - 327
role: None, - 328
provider: None, - 329
model: None, - 330
tool_name: None, - 331
content_text: c.summary.clone(), - 332
is_error: false, - 333
}), - 334
EntryPayload::Receipt(r) => Some(IndexedEntry { - 335
entry_id: entry.id.clone(), - 336
session_id: session_id.to_string(), - 337
project_hash: String::new(), - 338
parent_id: entry.parent_id.clone(), - 339
ts: entry.ts.to_rfc3339(), - 340
kind, - 341
role: None, - 342
provider: Some(r.provider.clone()), - 343
model: Some(r.model.clone()), - 344
tool_name: None, - 345
content_text: String::new(), - 346
is_error: false, - 347
}), - 348
EntryPayload::Goal(g) => Some(IndexedEntry { - 349
entry_id: entry.id.clone(), - 350
session_id: session_id.to_string(), - 351
project_hash: String::new(), - 352
parent_id: entry.parent_id.clone(), - 353
ts: entry.ts.to_rfc3339(), - 354
kind, - 355
role: None, - 356
provider: None, - 357
model: None, - 358
tool_name: None, - 359
content_text: format!("{} {}", g.objective, g.criteria.join(" ")), - 360
is_error: false, - 361
}), - 362
EntryPayload::GoalUpdate(update) => Some(IndexedEntry { - 363
entry_id: entry.id.clone(), - 364
session_id: session_id.to_string(), - 365
project_hash: String::new(), - 366
parent_id: entry.parent_id.clone(), - 367
ts: entry.ts.to_rfc3339(), - 368
kind, - 369
role: Some("system".into()), - 370
provider: None, - 371
model: None, - 372
tool_name: None, - 373
content_text: format!("{:?} {}", update.relation, update.request), - 374
is_error: false, - 375
}), - 376
EntryPayload::Activity(activity) => Some(IndexedEntry { - 377
entry_id: entry.id.clone(), - 378
session_id: session_id.to_string(), - 379
project_hash: String::new(), - 380
parent_id: entry.parent_id.clone(), - 381
ts: entry.ts.to_rfc3339(), - 382
kind, - 383
role: Some("system".into()), - 384
provider: None, - 385
model: None, - 386
tool_name: activity.data.get("tool").cloned(), - 387
content_text: format!( - 388
"{} {}", - 389
activity.label, - 390
activity.detail.as_deref().unwrap_or_default() - 391
), - 392
is_error: matches!( - 393
activity.status, - 394
vak_session::ActivityStatus::Failed | vak_session::ActivityStatus::Denied - 395
), - 396
}), - 397
EntryPayload::Work(work) => Some(IndexedEntry { - 398
entry_id: entry.id.clone(), - 399
session_id: session_id.to_string(), - 400
project_hash: String::new(), - 401
parent_id: entry.parent_id.clone(), - 402
ts: entry.ts.to_rfc3339(), - 403
kind, - 404
role: Some("system".into()), - 405
provider: None, - 406
model: None, - 407
tool_name: None, - 408
content_text: serde_json::to_string(&work.kind).unwrap_or_default(), - 409
is_error: false, - 410
}), - 411
// Indexed on the reading rather than the note, so "every - 412
// irreversible thing this agent did in March" is a search rather - 413
// than a ledger crawl. That query is the whole point of recording - 414
// the axes next to the decision. - 415
EntryPayload::Intent(record) => Some(IndexedEntry { - 416
entry_id: entry.id.clone(), - 417
session_id: session_id.to_string(), - 418
project_hash: String::new(), - 419
parent_id: entry.parent_id.clone(), - 420
ts: entry.ts.to_rfc3339(), - 421
kind, - 422
role: Some("system".into()), - 423
provider: None, - 424
model: record.provenance.model.clone(), - 425
tool_name: None, - 426
content_text: format!( - 427
"{} {} {} {} {} {} {}", - 428
record.reading.act.as_str(), - 429
record.reading.horizon.as_str(), - 430
record.reading.stakes.as_str(), - 431
record.reading.evidence.as_str(), - 432
record.reading.attendance.as_str(), - 433
record.provenance.tier.as_str(), - 434
record.model_visible.as_deref().unwrap_or_default() - 435
), - 436
is_error: false, - 437
}), - 438
EntryPayload::TurnCapabilitiesBound(_) - 439
| EntryPayload::TurnCapabilitiesRef(_) - 440
| EntryPayload::ChildRun { .. } - 441
| EntryPayload::Presentation(_) - 442
| EntryPayload::TurnCard(_) - 443
| EntryPayload::EvidenceBody(_) => None, - 444
} - 445
} - 446
- 447
fn insert_meta(conn: &Connection, meta: &IndexedEntry) -> Result<(), StoreError> { - 448
conn.execute( - 449
"INSERT OR IGNORE INTO entries - 450
(entry_id, session_id, project_hash, parent_id, ts, kind, role, - 451
provider, model, tool_name, content_text, is_error) - 452
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - 453
rusqlite::params![ - 454
meta.entry_id, - 455
meta.session_id, - 456
meta.project_hash, - 457
meta.parent_id, - 458
meta.ts, - 459
meta.kind.as_str(), - 460
meta.role, - 461
meta.provider, - 462
meta.model, - 463
meta.tool_name, - 464
meta.content_text, - 465
meta.is_error as i32, - 466
], - 467
)?; - 468
// FTS row — only if there is searchable content. - 469
if !meta.content_text.trim().is_empty() { - 470
conn.execute( - 471
"INSERT INTO entries_fts(entry_id, session_id, project_hash, ts, kind, role, content) - 472
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - 473
rusqlite::params![ - 474
meta.entry_id, - 475
meta.session_id, - 476
meta.project_hash, - 477
meta.ts, - 478
meta.kind.as_str(), - 479
meta.role.as_deref().unwrap_or(""), - 480
meta.content_text, - 481
], - 482
)?; - 483
} - 484
Ok(()) - 485
} - 486
} - 487
- 488
/// Extract searchable text and metadata from message content blocks. - 489
/// Returns (text, tool_name, is_error). - 490
fn extract_message_text( - 491
content: &[vak_llm::types::ContentBlock], - 492
) -> (String, Option<String>, bool) { - 493
use vak_llm::types::ContentBlock; - 494
- 495
let mut text_parts = Vec::new(); - 496
let mut tool_name: Option<String> = None; - 497
let mut is_error = false; - 498
- 499
for block in content { - 500
match block { - 501
ContentBlock::Text { text } => { - 502
text_parts.push(text.clone()); - 503
} - 504
ContentBlock::Thinking { text, .. } => { - 505
text_parts.push(format!("[thinking] {text}")); - 506
} - 507
ContentBlock::ToolUse { name, input, .. } => { - 508
tool_name = Some(name.clone()); - 509
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { - 510
text_parts.push(format!("[tool:{name}] {cmd}")); - 511
} else { - 512
text_parts.push(format!("[tool:{name}]")); - 513
} - 514
} - 515
ContentBlock::ToolResult { - 516
content, - 517
is_error: err, - 518
.. - 519
} => { - 520
is_error = *err; - 521
if !content.trim().is_empty() { - 522
text_parts.push(format!("[result] {content}")); - 523
} - 524
} - 525
ContentBlock::Image { .. } => {} - 526
ContentBlock::Provider { .. } => {} - 527
} - 528
} - 529
(text_parts.join("\n"), tool_name, is_error) - 530
} - 531
- 532
// --------------------------------------------------------------------------- - 533
// Tests - 534
// --------------------------------------------------------------------------- - 535
- 536
#[cfg(test)] - 537
mod tests { - 538
#![allow(clippy::unwrap_used, clippy::expect_used)] - 539
- 540
use super::*; - 541
use std::io::Write; - 542
use vak_llm::types::{ContentBlock, Message}; - 543
use vak_session::log::SessionPath; - 544
use vak_session::types::{Entry, EntryPayload, FrozenContract, MessageRecord, SessionHeader}; - 545
- 546
fn test_header(id: &str) -> SessionHeader { - 547
SessionHeader { - 548
agent: None, - 549
session_id: id.to_string(), - 550
created_at: chrono::Utc::now(), - 551
cwd: std::env::current_dir().unwrap(), - 552
parent_session_id: None, - 553
contract_id: None, - 554
work_item_id: None, - 555
conversation: None, - 556
contract: FrozenContract { - 557
app_version: "test".into(), - 558
provider: "openai".into(), - 559
model: "gpt-4o".into(), - 560
route_ladder: vec![], - 561
route_objective: String::new(), - 562
route_annotations: vec![], - 563
system_prompt: String::new(), - 564
permission_mode: "workspace-write".into(), - 565
capabilities: Vec::new(), - 566
prompt_layers: Vec::new(), - 567
}, - 568
} - 569
} - 570
- 571
fn user_msg(text: &str) -> MessageRecord { - 572
MessageRecord { - 573
message: Message { - 574
role: vak_llm::Role::User, - 575
content: vec![ContentBlock::text(text)], - 576
}, - 577
meta: None, - 578
} - 579
} - 580
- 581
fn assistant_msg(text: &str) -> MessageRecord { - 582
MessageRecord { - 583
message: Message { - 584
role: vak_llm::Role::Assistant, - 585
content: vec![ContentBlock::text(text)], - 586
}, - 587
meta: None, - 588
} - 589
} - 590
- 591
fn write_session(home: &Path, cwd: &Path, id: &str, msgs: &[MessageRecord]) { - 592
let path = SessionPath::new_session_file(home, cwd, id); - 593
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 594
let file = std::fs::File::create(&path).unwrap(); - 595
let mut w = std::io::BufWriter::new(file); - 596
let header = Entry::new(None, EntryPayload::Header(test_header(id))); - 597
serde_json::to_writer(&mut w, &header).unwrap(); - 598
w.write_all(b"\n").unwrap(); - 599
let mut parent = Some(header.id.clone()); - 600
for m in msgs { - 601
let entry = Entry { - 602
prev_hash: None, - 603
id: uuid::Uuid::now_v7().to_string(), - 604
parent_id: parent.clone(), - 605
ts: chrono::Utc::now(), - 606
payload: EntryPayload::Message(m.clone()), - 607
}; - 608
parent = Some(entry.id.clone()); - 609
serde_json::to_writer(&mut w, &entry).unwrap(); - 610
w.write_all(b"\n").unwrap(); - 611
} - 612
w.flush().unwrap(); - 613
} - 614
- 615
#[test] - 616
fn open_creates_db_and_schema() { - 617
let dir = tempfile::tempdir().unwrap(); - 618
let store = Store::open(dir.path()).unwrap(); - 619
assert!(store.db_path().exists()); - 620
// Second open is idempotent. - 621
let store2 = Store::open(dir.path()).unwrap(); - 622
assert_eq!(store.db_path(), store2.db_path()); - 623
} - 624
- 625
#[test] - 626
fn rebuild_indexes_entries_and_fts() { - 627
let dir = tempfile::tempdir().unwrap(); - 628
let home = dir.path(); - 629
let cwd = dir.path(); - 630
write_session( - 631
home, - 632
cwd, - 633
"sess-001", - 634
&[ - 635
user_msg("the deploy pipeline handles rollbacks gracefully"), - 636
assistant_msg("confirmed — rollback windows pause before deploy"), - 637
], - 638
); - 639
write_session( - 640
home, - 641
cwd, - 642
"sess-002", - 643
&[user_msg("pizza toppings are irrelevant to this query")], - 644
); - 645
- 646
let store = Store::open(home).unwrap(); - 647
let stats = store.rebuild(home).unwrap(); - 648
assert_eq!(stats.files_scanned, 2); - 649
// sess-001: 1 header + 2 msgs, sess-002: 1 header + 1 msg = 5 entries - 650
assert_eq!(stats.entries_indexed, 5); - 651
// 3 messages with text → 3 FTS rows - 652
assert_eq!(stats.fts_rows, 3); - 653
} - 654
- 655
#[test] - 656
fn import_skips_already_indexed() { - 657
let dir = tempfile::tempdir().unwrap(); - 658
let home = dir.path(); - 659
let cwd = dir.path(); - 660
write_session( - 661
home, - 662
cwd, - 663
"sess-aaa", - 664
&[user_msg("unique search term zanzibar")], - 665
); - 666
- 667
let store = Store::open(home).unwrap(); - 668
let s1 = store - 669
.import_session(home, &SessionPath::new_session_file(home, cwd, "sess-aaa")) - 670
.unwrap(); - 671
assert_eq!(s1.entries_indexed, 2); // header + message - 672
let s2 = store - 673
.import_session(home, &SessionPath::new_session_file(home, cwd, "sess-aaa")) - 674
.unwrap(); - 675
assert_eq!(s2.skipped, 2, "second import skips all"); - 676
} - 677
- 678
#[test] - 679
fn extract_message_text_captures_tools() { - 680
use vak_llm::types::ContentBlock; - 681
- 682
let blocks = vec![ - 683
ContentBlock::text("look at this"), - 684
ContentBlock::ToolUse { - 685
id: "tc-1".into(), - 686
name: "bash".into(), - 687
input: serde_json::json!({"command": "cargo test"}), - 688
}, - 689
ContentBlock::ToolResult { - 690
tool_use_id: "tc-1".into(), - 691
content: "2 passed".into(), - 692
is_error: false, - 693
}, - 694
]; - 695
let (text, tool, is_err) = extract_message_text(&blocks); - 696
assert!(text.contains("look at this")); - 697
assert!(text.contains("[tool:bash] cargo test")); - 698
assert!(text.contains("[result] 2 passed")); - 699
assert_eq!(tool.as_deref(), Some("bash")); - 700
assert!(!is_err); - 701
} - 702
} - 703
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.