- 1
//! `session_search` — model-visible cross-session recall - 2
//! (docs/design/23-memory.md). Injected by `Core::run_turn_with` next to - 3
//! the task and MCP tools; results are ordinary tool results, so invariant - 4
//! 1 (model-visible ⇒ logged) holds by construction. - 5
- 6
use std::path::PathBuf; - 7
- 8
use serde_json::Value; - 9
- 10
use vak_session::{DEFAULT_LIMIT, ExternalDoc, search_extended}; - 11
- 12
pub struct SessionSearchTool { - 13
pub sessions_home: PathBuf, - 14
/// The shared data home, where the trash is kept: a session in the - 15
/// trash is never a hit. - 16
pub trash_home: PathBuf, - 17
pub cwd: PathBuf, - 18
/// Usually the running session: its content is already in context. - 19
pub exclude_session_id: String, - 20
/// Optional audience scope. When set, transcript hits must carry the same - 21
/// frozen Agent and conversation audience in their header; an unscoped or - 22
/// legacy ledger is rejected rather than treated as shared data. - 23
pub agent_id: Option<String>, - 24
pub audience_id: Option<String>, - 25
} - 26
- 27
fn session_in_scope( - 28
home: &std::path::Path, - 29
cwd: &std::path::Path, - 30
session_id: &str, - 31
agent_id: Option<&str>, - 32
audience_id: Option<&str>, - 33
) -> bool { - 34
if agent_id.is_none() && audience_id.is_none() { - 35
return true; - 36
} - 37
let mut path = vak_session::SessionPath::new_session_file(home, cwd, session_id); - 38
if !path.exists() { - 39
if let Some(parent) = home.parent().and_then(|p| p.parent()) { - 40
let p = vak_session::SessionPath::new_session_file(parent, cwd, session_id); - 41
if p.exists() { - 42
path = p; - 43
} - 44
} - 45
if !path.exists() { - 46
let agents_dir = home.join("agents"); - 47
if let Ok(entries) = std::fs::read_dir(&agents_dir) { - 48
for entry in entries.flatten() { - 49
let p = entry.path(); - 50
if p.is_dir() { - 51
let candidate = - 52
vak_session::SessionPath::new_session_file(&p, cwd, session_id); - 53
if candidate.exists() { - 54
path = candidate; - 55
break; - 56
} - 57
} - 58
} - 59
} - 60
} - 61
} - 62
let Ok(file) = std::fs::File::open(path) else { - 63
return false; - 64
}; - 65
use std::io::BufRead; - 66
let Some(Ok(line)) = std::io::BufReader::new(file).lines().next() else { - 67
return false; - 68
}; - 69
let Ok(entry) = serde_json::from_str::<vak_session::Entry>(&line) else { - 70
return false; - 71
}; - 72
let vak_session::EntryPayload::Header(header) = entry.payload else { - 73
return false; - 74
}; - 75
let agent_matches = agent_id.is_none_or(|wanted| { - 76
header - 77
.agent - 78
.as_ref() - 79
.is_some_and(|agent| agent.id == wanted) - 80
}); - 81
let audience_matches = audience_id.is_none_or(|wanted| { - 82
header - 83
.conversation - 84
.as_ref() - 85
.is_some_and(|context| context.audience_id == wanted) - 86
}); - 87
agent_matches && audience_matches - 88
} - 89
- 90
fn tag_suffix(tag: &str) -> String { - 91
if tag.is_empty() { - 92
String::new() - 93
} else { - 94
format!(" {tag}") - 95
} - 96
} - 97
- 98
#[async_trait::async_trait] - 99
impl vak_tools::Tool for SessionSearchTool { - 100
fn name(&self) -> &str { - 101
"session_search" - 102
} - 103
- 104
fn serves(&self) -> &'static [&'static str] { - 105
&["memory"] - 106
} - 107
- 108
fn always_loaded(&self) -> bool { - 109
true - 110
} - 111
- 112
fn description(&self) -> &str { - 113
"Search past conversations and sessions (user requests and assistant answers) \ - 114
plus your durable memory notes and the user profile. Use when the user \ - 115
references earlier work ('that script we wrote', 'the bug from Tuesday') or when \ - 116
prior decisions or stated preferences would help. Returns ranked snippets \ - 117
with the source id and date. Read-only; current conversation is \ - 118
excluded." - 119
} - 120
- 121
fn schema(&self) -> Value { - 122
serde_json::json!({ - 123
"type": "object", - 124
"properties": { - 125
"query": { - 126
"type": "string", - 127
"description": "Keywords or an exact phrase to look for across past sessions" - 128
}, - 129
"limit": { - 130
"type": "integer", - 131
"description": format!("Max hits to return (default {DEFAULT_LIMIT}, max 50)") - 132
} - 133
}, - 134
"required": ["query"] - 135
}) - 136
} - 137
- 138
async fn execute(&self, args: &Value, _ctx: &vak_tools::ToolContext) -> vak_tools::ToolOutput { - 139
let Some(query) = args.get("query").and_then(Value::as_str) else { - 140
return vak_tools::ToolOutput::error("missing required argument 'query'"); - 141
}; - 142
if query.trim().is_empty() { - 143
return vak_tools::ToolOutput::error("'query' must not be empty"); - 144
} - 145
let limit = args - 146
.get("limit") - 147
.and_then(Value::as_u64) - 148
.map(|l| l as usize) - 149
.unwrap_or(DEFAULT_LIMIT); - 150
- 151
let home = self.sessions_home.clone(); - 152
let cwd = self.cwd.clone(); - 153
let query = query.to_string(); - 154
let exclude = self.exclude_session_id.clone(); - 155
let trash_home = self.trash_home.clone(); - 156
let agent_id = self.agent_id.clone(); - 157
let audience_id = self.audience_id.clone(); - 158
// Curated memory participates in recall and outranks transcripts - 159
// (docs/design/26-learning.md). The global profile tier joins the - 160
// same extras ranking so user-level memories follow them across - 161
// projects (docs/design/29-personal-os.md P1). - 162
let notes = crate::memory::list_notes(&home, &cwd) - 163
.into_iter() - 164
.filter(|note| { - 165
session_in_scope( - 166
&home, - 167
&cwd, - 168
¬e.session_id, - 169
agent_id.as_deref(), - 170
audience_id.as_deref(), - 171
) - 172
}) - 173
.collect::<Vec<_>>(); - 174
let mut extras: Vec<ExternalDoc> = notes - 175
.iter() - 176
.map(|n| { - 177
let key = if n.tag.is_empty() { - 178
n.kind.clone() - 179
} else { - 180
n.tag.clone() - 181
}; - 182
ExternalDoc { - 183
id: key, - 184
text: format!("[{}{}] {}", n.kind, tag_suffix(&n.tag), n.text), - 185
ts: Some(n.ts), - 186
role: Some("memory".into()), - 187
} - 188
}) - 189
.collect(); - 190
// A remote audience does not inherit the local user's global profile - 191
// merely because it selected the same Agent. Account linking must - 192
// explicitly grant that scope; local sessions (including the new - 193
// explicit `audience_id = "local"` admission) may receive these entries. - 194
let profile_note_ids: std::collections::HashSet<String> = if audience_id - 195
.as_deref() - 196
.is_none_or(|audience| audience == "local") - 197
{ - 198
crate::memory::list_profile_notes(&home) - 199
.iter() - 200
.map(|n| { - 201
let key = if n.tag.is_empty() { - 202
n.kind.clone() - 203
} else { - 204
n.tag.clone() - 205
}; - 206
let id = format!("profile/{key}"); - 207
extras.push(ExternalDoc { - 208
id: id.clone(), - 209
text: format!("[{}{}] {}", n.kind, tag_suffix(&n.tag), n.text), - 210
ts: Some(n.ts), - 211
role: Some("profile".into()), - 212
}); - 213
id - 214
}) - 215
.collect() - 216
} else { - 217
std::collections::HashSet::new() - 218
}; - 219
for entity in crate::entities::list_entities(&home, Some(&cwd)) { - 220
extras.push(ExternalDoc { - 221
id: format!("entity/{}", entity.id), - 222
text: format!( - 223
"[entity:{}] {} — {}{}", - 224
entity.entity_type, - 225
entity.name, - 226
entity.summary, - 227
if entity.attributes.is_empty() { - 228
String::new() - 229
} else { - 230
format!( - 231
" ({})", - 232
entity - 233
.attributes - 234
.iter() - 235
.map(|(k, v)| format!("{k}: {v}")) - 236
.collect::<Vec<_>>() - 237
.join(", ") - 238
) - 239
} - 240
), - 241
ts: Some(entity.updated_at), - 242
role: Some("entity".into()), - 243
}); - 244
} - 245
let result = tokio::task::spawn_blocking(move || { - 246
let mut hits = search_extended( - 247
&home, - 248
&cwd, - 249
&query, - 250
// Filter after a larger ranked window so an unrelated Agent's - 251
// hits cannot consume the caller's small result limit. - 252
limit.clamp(DEFAULT_LIMIT, 50).saturating_mul(2).min(50), - 253
&crate::trash::search_exclusions(&trash_home, Some(&exclude)), - 254
&extras, - 255
)?; - 256
if agent_id.is_some() || audience_id.is_some() { - 257
hits.retain(|hit| { - 258
hit.entry_id.is_empty() - 259
|| session_in_scope( - 260
&home, - 261
&cwd, - 262
&hit.session_id, - 263
agent_id.as_deref(), - 264
audience_id.as_deref(), - 265
) - 266
}); - 267
hits.truncate(limit.clamp(1, 50)); - 268
} - 269
Ok::<Vec<vak_session::SessionHit>, vak_session::SearchError>(hits) - 270
}) - 271
.await; - 272
- 273
match result { - 274
Ok(Ok(hits)) if hits.is_empty() => { - 275
vak_tools::ToolOutput::ok("No past session matches that query.".to_string()) - 276
} - 277
Ok(Ok(mut hits)) => { - 278
// search_extended labels every curated extra "memory"; - 279
// re-tag the profile-tier subset so surfaces can tell - 280
// global profile recall apart from workspace memory. - 281
for h in &mut hits { - 282
if profile_note_ids.contains(&h.session_id) { - 283
h.role = "profile".into(); - 284
} - 285
} - 286
let mut out = String::with_capacity(256 * hits.len()); - 287
out.push_str(&format!("{} hit(s), most relevant first:\n", hits.len())); - 288
for (i, h) in hits.iter().enumerate() { - 289
out.push_str(&format!( - 290
"\n[{}] {} · {} · {} (score {:.2})\n \"{}\"\n", - 291
i + 1, - 292
h.session_id, - 293
h.ts.format("%Y-%m-%d"), - 294
h.role, - 295
h.score, - 296
h.snippet - 297
)); - 298
} - 299
vak_tools::ToolOutput::ok(out) - 300
} - 301
Ok(Err(e)) => vak_tools::ToolOutput::error(format!("session search failed: {e}")), - 302
Err(e) => vak_tools::ToolOutput::error(format!("search task failed: {e}")), - 303
} - 304
} - 305
- 306
fn claims(&self, _args: &Value) -> vak_tools::ResourceClaims { - 307
vak_tools::ResourceClaims { - 308
exclusive: false, - 309
read_only: true, - 310
paths: vec![], - 311
} - 312
} - 313
} - 314
- 315
#[cfg(test)] - 316
mod tests { - 317
#![allow(clippy::unwrap_used, clippy::expect_used)] - 318
use super::*; - 319
use vak_tools::Tool; - 320
- 321
#[tokio::test] - 322
async fn profile_tier_recalled_as_profile_role_alongside_memory() { - 323
let dir = tempfile::tempdir().unwrap(); - 324
let home = dir.path(); - 325
let cwd = home.join("ws"); - 326
std::fs::create_dir_all(&cwd).unwrap(); - 327
- 328
crate::memory::append_note( - 329
home, - 330
&cwd, - 331
"decision", - 332
"deploys", - 333
"s1", - 334
"the deploy script lives in scripts/deploy.sh", - 335
) - 336
.unwrap(); - 337
crate::memory::append_profile_note( - 338
home, - 339
"preference", - 340
"editor", - 341
"user prefers vim keybindings everywhere", - 342
"su", - 343
) - 344
.unwrap(); - 345
- 346
let tool = SessionSearchTool { - 347
sessions_home: home.to_path_buf(), - 348
trash_home: home.to_path_buf(), - 349
cwd: cwd.clone(), - 350
exclude_session_id: "current".into(), - 351
agent_id: None, - 352
audience_id: None, - 353
}; - 354
let ctx = vak_tools::ToolContext { - 355
cwd, - 356
cancel: tokio_util::sync::CancellationToken::new(), - 357
sandbox: None, - 358
sandbox_sink: None, - 359
agent_id: None, - 360
new_documents: Vec::new(), - 361
}; - 362
- 363
let out = tool - 364
.execute(&serde_json::json!({"query": "deploy script"}), &ctx) - 365
.await; - 366
assert!(!out.is_error, "{}", out.content); - 367
assert!(out.content.contains("· memory"), "{}", out.content); - 368
assert!(!out.content.contains("· profile"), "{}", out.content); - 369
- 370
let out = tool - 371
.execute(&serde_json::json!({"query": "vim keybindings"}), &ctx) - 372
.await; - 373
assert!(!out.is_error, "{}", out.content); - 374
// Profile-tier hits carry the profile/ id prefix AND role. - 375
assert!(out.content.contains("profile/editor · "), "{}", out.content); - 376
assert!(out.content.contains("· profile (score "), "{}", out.content); - 377
assert!(out.content.contains("vim keybindings"), "{}", out.content); - 378
} - 379
- 380
#[tokio::test] - 381
async fn empty_stores_still_answer_cleanly() { - 382
let dir = tempfile::tempdir().unwrap(); - 383
let cwd = dir.path().join("ws"); - 384
std::fs::create_dir_all(&cwd).unwrap(); - 385
let tool = SessionSearchTool { - 386
sessions_home: dir.path().to_path_buf(), - 387
trash_home: dir.path().to_path_buf(), - 388
cwd: cwd.clone(), - 389
exclude_session_id: String::new(), - 390
agent_id: None, - 391
audience_id: None, - 392
}; - 393
let ctx = vak_tools::ToolContext { - 394
cwd, - 395
cancel: tokio_util::sync::CancellationToken::new(), - 396
sandbox: None, - 397
sandbox_sink: None, - 398
agent_id: None, - 399
new_documents: Vec::new(), - 400
}; - 401
let out = tool - 402
.execute(&serde_json::json!({"query": "anything at all"}), &ctx) - 403
.await; - 404
assert!(!out.is_error); - 405
assert!(out.content.contains("No past session matches")); - 406
} - 407
- 408
#[tokio::test] - 409
async fn transcript_recall_is_scoped_to_agent_and_audience() { - 410
let dir = tempfile::tempdir().unwrap(); - 411
let home = dir.path().join("home"); - 412
let cwd = dir.path().join("workspace"); - 413
std::fs::create_dir_all(&cwd).unwrap(); - 414
for (id, agent, audience) in [ - 415
("one", "researcher", "telegram:one"), - 416
("two", "writer", "telegram:two"), - 417
] { - 418
let path = vak_session::SessionPath::new_session_file(&home, &cwd, id); - 419
let header = vak_session::SessionHeader { - 420
agent: Some(vak_session::types::AgentIdentity { - 421
id: agent.into(), - 422
revision: 1, - 423
name: agent.into(), - 424
character: "vak".into(), - 425
personality: String::new(), - 426
animation: "subtle".into(), - 427
voice: "default".into(), - 428
behaviour: String::new(), - 429
responsibilities: String::new(), - 430
instructions: String::new(), - 431
}), - 432
session_id: id.into(), - 433
created_at: chrono::Utc::now(), - 434
cwd: cwd.clone(), - 435
parent_session_id: None, - 436
contract_id: None, - 437
work_item_id: None, - 438
conversation: Some(vak_session::ConversationContext { - 439
conversation_id: id.into(), - 440
audience_id: audience.into(), - 441
origin: None, - 442
}), - 443
contract: vak_session::FrozenContract { - 444
app_version: "test".into(), - 445
provider: "test".into(), - 446
model: "test".into(), - 447
route_ladder: Vec::new(), - 448
route_objective: String::new(), - 449
route_annotations: Vec::new(), - 450
system_prompt: String::new(), - 451
permission_mode: "read-only".into(), - 452
capabilities: Vec::new(), - 453
prompt_layers: Vec::new(), - 454
}, - 455
}; - 456
let mut log = vak_session::SessionLog::create(path, header).unwrap(); - 457
log.append_message(vak_session::MessageRecord { - 458
message: vak_llm::Message::user_text("private launch plan"), - 459
meta: None, - 460
}) - 461
.unwrap(); - 462
} - 463
let tool = SessionSearchTool { - 464
sessions_home: home.clone(), - 465
trash_home: home, - 466
cwd, - 467
exclude_session_id: String::new(), - 468
agent_id: Some("researcher".into()), - 469
audience_id: Some("telegram:one".into()), - 470
}; - 471
let ctx = vak_tools::ToolContext { - 472
cwd: dir.path().join("workspace"), - 473
cancel: tokio_util::sync::CancellationToken::new(), - 474
sandbox: None, - 475
sandbox_sink: None, - 476
agent_id: None, - 477
new_documents: Vec::new(), - 478
}; - 479
let out = tool - 480
.execute( - 481
&serde_json::json!({"query": "private launch plan", "limit": 10}), - 482
&ctx, - 483
) - 484
.await; - 485
assert!(!out.is_error, "{}", out.content); - 486
assert!(out.content.contains("one"), "{}", out.content); - 487
assert!(!out.content.contains("two"), "{}", out.content); - 488
} - 489
- 490
#[tokio::test] - 491
async fn entities_recalled_via_session_search() { - 492
let dir = tempfile::tempdir().unwrap(); - 493
let home = dir.path().join("home"); - 494
let cwd = dir.path().join("workspace"); - 495
std::fs::create_dir_all(&home).unwrap(); - 496
std::fs::create_dir_all(&cwd).unwrap(); - 497
- 498
// Create an entity in this workspace. - 499
crate::entities::upsert_entity( - 500
&home, - 501
Some(&cwd), - 502
crate::entities::EntityRecord { - 503
id: "ent-apollo-11".into(), - 504
name: "Project Apollo".into(), - 505
entity_type: "mission".into(), - 506
summary: "Lunar landing mission targeting Sea of Tranquility".into(), - 507
attributes: Default::default(), - 508
relations: Default::default(), - 509
updated_at: chrono::Utc::now(), - 510
}, - 511
) - 512
.unwrap(); - 513
- 514
let tool = SessionSearchTool { - 515
sessions_home: home.clone(), - 516
trash_home: home, - 517
cwd: cwd.clone(), - 518
exclude_session_id: String::new(), - 519
agent_id: None, - 520
audience_id: None, - 521
}; - 522
let ctx = vak_tools::ToolContext { - 523
cwd, - 524
cancel: tokio_util::sync::CancellationToken::new(), - 525
sandbox: None, - 526
sandbox_sink: None, - 527
agent_id: None, - 528
new_documents: Vec::new(), - 529
}; - 530
let out = tool - 531
.execute( - 532
&serde_json::json!({"query": "Sea of Tranquility", "limit": 5}), - 533
&ctx, - 534
) - 535
.await; - 536
assert!(!out.is_error, "{}", out.content); - 537
assert!( - 538
out.content.contains("Project Apollo") && out.content.contains("mission"), - 539
"{}", - 540
out.content - 541
); - 542
} - 543
} - 544
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.