- 1
//! Durable memory notes (docs/design/26-learning.md): one plain-markdown - 2
//! file per workspace under `<home>/memory/<hash>/MEMORY.md`, plus a global - 3
//! user-profile tier at `<home>/memory/user/USER.md` - 4
//! (docs/design/29-personal-os.md P1). The model appends through the - 5
//! `remember` tool; humans edit or prune the file directly — the parser - 6
//! tolerates hand-edits rather than fighting them. Forget/amend rewrite the - 7
//! markdown file in place; sessions stay append-only. - 8
- 9
use std::path::{Path, PathBuf}; - 10
use std::time::{Duration, Instant, SystemTime}; - 11
- 12
use chrono::{DateTime, Utc}; - 13
- 14
/// FNV-1a 64-bit, matching vak-session's cwd hashing so every per-workspace - 15
/// store keys off the same identity. - 16
pub fn hash_cwd(cwd: &Path) -> String { - 17
fnv1a(cwd.to_string_lossy().as_bytes()) - 18
} - 19
- 20
fn fnv1a(data: &[u8]) -> String { - 21
let mut h: u64 = 0xcbf2_9ce4_8422_2325; - 22
for b in data { - 23
h ^= u64::from(*b); - 24
h = h.wrapping_mul(0x0000_0100_0000_01b3); - 25
} - 26
format!("{h:016x}") - 27
} - 28
- 29
/// Stable note id: FNV-1a 64 of the full header line (without any trailing - 30
/// newline/carriage-return). Deterministic across processes and toolchains, - 31
/// so list/forget/amend agree without extra state; hand-editing a header - 32
/// deliberately changes its id. - 33
fn note_id(header_line: &str) -> String { - 34
fnv1a(header_line.as_bytes()) - 35
} - 36
- 37
#[derive(Debug, Clone, PartialEq)] - 38
pub struct NoteBlock { - 39
pub id: String, - 40
pub ts: DateTime<Utc>, - 41
pub kind: String, - 42
pub tag: String, - 43
pub session_id: String, - 44
pub text: String, - 45
} - 46
- 47
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] - 48
pub struct CleanupReport { - 49
pub removed_locks: usize, - 50
pub removed_temps: usize, - 51
pub removed_empty_dirs: usize, - 52
} - 53
- 54
/// Remove only abandoned write artifacts and empty workspace directories. - 55
/// Durable note files are never age-pruned: retention is an explicit - 56
/// forget/amend decision so an old fact cannot disappear silently. - 57
pub fn cleanup_artifacts(home: &Path, older_than: Duration) -> CleanupReport { - 58
let root = home.join("memory"); - 59
let Ok(entries) = std::fs::read_dir(&root) else { - 60
return CleanupReport::default(); - 61
}; - 62
let cutoff = SystemTime::now().checked_sub(older_than); - 63
let mut report = CleanupReport::default(); - 64
for entry in entries.flatten() { - 65
let path = entry.path(); - 66
if !path.is_dir() || path.file_name().is_some_and(|n| n == "user") { - 67
continue; - 68
} - 69
let Ok(files) = std::fs::read_dir(&path) else { - 70
continue; - 71
}; - 72
for file in files.flatten() { - 73
let file_path = file.path(); - 74
let name = file_path - 75
.file_name() - 76
.and_then(|n| n.to_str()) - 77
.unwrap_or_default(); - 78
let artifact = name.ends_with(".lock") || name.contains(".tmp."); - 79
let old = cutoff - 80
.zip( - 81
std::fs::metadata(&file_path) - 82
.and_then(|m| m.modified()) - 83
.ok(), - 84
) - 85
.is_some_and(|(cutoff, modified)| modified <= cutoff); - 86
if artifact && old && std::fs::remove_file(&file_path).is_ok() { - 87
if name.ends_with(".lock") { - 88
report.removed_locks += 1; - 89
} else { - 90
report.removed_temps += 1; - 91
} - 92
} - 93
} - 94
if std::fs::read_dir(&path) - 95
.map(|mut d| d.next().is_none()) - 96
.unwrap_or(false) - 97
&& std::fs::remove_dir(&path).is_ok() - 98
{ - 99
report.removed_empty_dirs += 1; - 100
} - 101
} - 102
report - 103
} - 104
- 105
fn memory_path(home: &Path, cwd: &Path) -> PathBuf { - 106
home.join("memory").join(hash_cwd(cwd)).join("MEMORY.md") - 107
} - 108
- 109
/// Global profile-tier store, keyed to the user rather than a workspace. - 110
pub fn profile_path(home: &Path) -> PathBuf { - 111
home.join("memory").join("user").join("USER.md") - 112
} - 113
- 114
pub fn append_note( - 115
home: &Path, - 116
cwd: &Path, - 117
kind: &str, - 118
tag: &str, - 119
session_id: &str, - 120
text: &str, - 121
) -> Result<NoteBlock, String> { - 122
append_block(&memory_path(home, cwd), kind, tag, session_id, text) - 123
} - 124
- 125
/// Append to the global USER.md profile tier (same grammar, same validation, - 126
/// lazy directory creation) — memories that follow the user across projects. - 127
pub fn append_profile_note( - 128
home: &Path, - 129
kind: &str, - 130
tag: &str, - 131
text: &str, - 132
session: &str, - 133
) -> Result<NoteBlock, String> { - 134
append_block(&profile_path(home), kind, tag, session, text) - 135
} - 136
- 137
fn append_block( - 138
path: &Path, - 139
kind: &str, - 140
tag: &str, - 141
session_id: &str, - 142
text: &str, - 143
) -> Result<NoteBlock, String> { - 144
if !matches!( - 145
kind, - 146
"fact" | "decision" | "preference" | "reference" | "note" | "invariant" | "procedural" - 147
) { - 148
return Err("unknown note kind; expected fact, decision, preference, reference, invariant, or procedural".into()); - 149
} - 150
validate_field("kind", kind, 32)?; - 151
validate_field("tag", tag, 96)?; - 152
validate_field("session", session_id, 160)?; - 153
let text = text.trim(); - 154
if text.is_empty() { - 155
return Err("note must not be empty".into()); - 156
} - 157
if text.len() > 128 * 1024 { - 158
return Err("note exceeds the 128 KiB limit".into()); - 159
} - 160
let ts = Utc::now(); - 161
if let Some(parent) = path.parent() { - 162
std::fs::create_dir_all(parent).map_err(|e| format!("create memory dir: {e}"))?; - 163
} - 164
let _lock = StoreLock::acquire(path)?; - 165
let header = format!( - 166
"## {} [{kind}] tag={tag} session={session_id}", - 167
ts.to_rfc3339() - 168
); - 169
let block = format!("{header}\n{text}\n"); - 170
use std::io::Write; - 171
let mut f = std::fs::OpenOptions::new() - 172
.create(true) - 173
.append(true) - 174
.open(path) - 175
.map_err(|e| format!("open {}: {e}", path.display()))?; - 176
f.write_all(format!("{block}\n").as_bytes()) - 177
.map_err(|e| format!("append {}: {e}", path.display()))?; - 178
f.sync_data() - 179
.map_err(|e| format!("flush {}: {e}", path.display()))?; - 180
Ok(NoteBlock { - 181
id: note_id(&header), - 182
ts, - 183
kind: kind.to_string(), - 184
tag: tag.to_string(), - 185
session_id: session_id.to_string(), - 186
text: text.to_string(), - 187
}) - 188
} - 189
- 190
fn validate_field(name: &str, value: &str, max: usize) -> Result<(), String> { - 191
if value.len() > max - 192
|| value - 193
.chars() - 194
.any(|c| c.is_control() || c == '\n' || c == '\r') - 195
{ - 196
return Err(format!("{name} contains invalid characters or is too long")); - 197
} - 198
Ok(()) - 199
} - 200
- 201
struct StoreLock { - 202
path: PathBuf, - 203
} - 204
- 205
impl StoreLock { - 206
fn acquire(store: &Path) -> Result<Self, String> { - 207
let path = store.with_extension("lock"); - 208
let deadline = Instant::now() + Duration::from_secs(10); - 209
loop { - 210
match std::fs::OpenOptions::new() - 211
.write(true) - 212
.create_new(true) - 213
.open(&path) - 214
{ - 215
Ok(mut file) => { - 216
use std::io::Write; - 217
let _ = writeln!(file, "pid={}", std::process::id()); - 218
return Ok(Self { path }); - 219
} - 220
Err(e) - 221
if e.kind() == std::io::ErrorKind::AlreadyExists - 222
&& Instant::now() < deadline => - 223
{ - 224
if std::fs::metadata(&path) - 225
.and_then(|m| m.modified()) - 226
.ok() - 227
.and_then(|t| t.elapsed().ok()) - 228
.is_some_and(|age| age > Duration::from_secs(60)) - 229
{ - 230
let _ = std::fs::remove_file(&path); - 231
continue; - 232
} - 233
std::thread::sleep(Duration::from_millis(10)); - 234
} - 235
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - 236
return Err(format!("timed out waiting for {}", path.display())); - 237
} - 238
Err(e) => return Err(format!("lock {}: {e}", path.display())), - 239
} - 240
} - 241
} - 242
} - 243
- 244
impl Drop for StoreLock { - 245
fn drop(&mut self) { - 246
let _ = std::fs::remove_file(&self.path); - 247
} - 248
} - 249
- 250
/// Parse MEMORY.md blocks. Tolerant by design: lines before the first - 251
/// heading and malformed headings attach to whatever precedes them, so a - 252
/// hand-edit never silently loses content. - 253
/// - 254
/// Reads exactly `home`'s memory. An Agent's memory is private - 255
/// (invariant 37); an empty home is empty, never a reason to read another - 256
/// Agent's notes. - 257
pub fn list_notes(home: &Path, cwd: &Path) -> Vec<NoteBlock> { - 258
blocks_at(&memory_path(home, cwd)) - 259
} - 260
- 261
/// Parse the global profile tier of exactly `home`. - 262
pub fn list_profile_notes(home: &Path) -> Vec<NoteBlock> { - 263
blocks_at(&profile_path(home)) - 264
} - 265
- 266
fn blocks_at(path: &Path) -> Vec<NoteBlock> { - 267
let Ok(raw) = std::fs::read_to_string(path) else { - 268
return Vec::new(); - 269
}; - 270
parse_blocks(&raw) - 271
} - 272
- 273
pub fn parse_blocks(raw: &str) -> Vec<NoteBlock> { - 274
struct Open { - 275
header_line: String, - 276
ts: DateTime<Utc>, - 277
kind: String, - 278
tag: String, - 279
session_id: String, - 280
} - 281
- 282
fn flush(open: &mut Option<Open>, body: &mut String, out: &mut Vec<NoteBlock>) { - 283
if let Some(o) = open.take() { - 284
let text = body.trim().to_string(); - 285
if !text.is_empty() { - 286
out.push(NoteBlock { - 287
id: note_id(&o.header_line), - 288
ts: o.ts, - 289
kind: o.kind, - 290
tag: o.tag, - 291
session_id: o.session_id, - 292
text, - 293
}); - 294
} - 295
} - 296
body.clear(); - 297
} - 298
- 299
let mut out: Vec<NoteBlock> = Vec::new(); - 300
let mut open: Option<Open> = None; - 301
let mut body = String::new(); - 302
- 303
for line in raw.lines() { - 304
if let Some(rest) = line.strip_prefix("## ") { - 305
match parse_header(rest) { - 306
Some((ts, kind, tag, sid)) => { - 307
flush(&mut open, &mut body, &mut out); - 308
open = Some(Open { - 309
header_line: line.to_string(), - 310
ts, - 311
kind, - 312
tag, - 313
session_id: sid, - 314
}); - 315
} - 316
None => { - 317
// Not our grammar: treat as continuation text of the - 318
// previous block (or drop leading junk). - 319
if open.is_some() { - 320
body.push_str(line); - 321
body.push('\n'); - 322
} - 323
} - 324
} - 325
} else if open.is_some() { - 326
body.push_str(line); - 327
body.push('\n'); - 328
} - 329
} - 330
flush(&mut open, &mut body, &mut out); - 331
out - 332
} - 333
- 334
/// Byte span of each parsed note in `raw`, mirroring parse_blocks exactly: - 335
/// a block runs from its valid `## ` header through the newline of its last - 336
/// non-blank body line; malformed headings count as body text; headers with - 337
/// empty bodies yield no span (they yield no note either). - 338
struct Span { - 339
id: String, - 340
start: usize, - 341
end: usize, - 342
} - 343
- 344
fn note_spans(raw: &str) -> Vec<Span> { - 345
let pieces: Vec<&str> = raw.split('\n').collect(); - 346
let mut starts = Vec::with_capacity(pieces.len()); - 347
let mut off = 0usize; - 348
for p in &pieces { - 349
starts.push(off); - 350
off += p.len() + 1; - 351
} - 352
- 353
fn close( - 354
open: &mut Option<(String, usize)>, - 355
last_content: &mut Option<usize>, - 356
starts: &[usize], - 357
pieces: &[&str], - 358
raw_len: usize, - 359
out: &mut Vec<Span>, - 360
) { - 361
if let Some((id, h)) = open.take() - 362
&& let Some(l) = last_content.take() - 363
{ - 364
let end = (starts[l] + pieces[l].len() + 1).min(raw_len); - 365
out.push(Span { - 366
id, - 367
start: starts[h], - 368
end, - 369
}); - 370
} - 371
} - 372
- 373
let mut out: Vec<Span> = Vec::new(); - 374
let mut open: Option<(String, usize)> = None; - 375
let mut last_content: Option<usize> = None; - 376
for (i, piece) in pieces.iter().enumerate() { - 377
let line = piece.strip_suffix('\r').unwrap_or(piece); - 378
if let Some(rest) = line.strip_prefix("## ") - 379
&& parse_header(rest).is_some() - 380
{ - 381
close( - 382
&mut open, - 383
&mut last_content, - 384
&starts, - 385
&pieces, - 386
raw.len(), - 387
&mut out, - 388
); - 389
open = Some((note_id(line), i)); - 390
} else if open.is_some() && !line.trim().is_empty() { - 391
last_content = Some(i); - 392
} - 393
} - 394
close( - 395
&mut open, - 396
&mut last_content, - 397
&starts, - 398
&pieces, - 399
raw.len(), - 400
&mut out, - 401
); - 402
out - 403
} - 404
- 405
fn load_raw(path: &Path) -> Result<String, String> { - 406
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display())) - 407
} - 408
- 409
fn persist_locked(path: &Path, contents: String) -> Result<(), String> { - 410
let tmp = path.with_extension(format!("tmp.{}", std::process::id())); - 411
let result = (|| { - 412
let mut f = - 413
std::fs::File::create(&tmp).map_err(|e| format!("rewrite {}: {e}", path.display()))?; - 414
use std::io::Write; - 415
f.write_all(contents.as_bytes()) - 416
.map_err(|e| format!("rewrite {}: {e}", path.display()))?; - 417
f.sync_all() - 418
.map_err(|e| format!("rewrite {}: {e}", path.display()))?; - 419
std::fs::rename(&tmp, path).map_err(|e| format!("replace {}: {e}", path.display())) - 420
})(); - 421
if result.is_err() { - 422
let _ = std::fs::remove_file(&tmp); - 423
} - 424
result - 425
} - 426
- 427
/// Rewrite-not-tombstone forget: removes exactly one block (the first whose - 428
/// id matches) and returns the number of bytes deleted. Everything outside - 429
/// the removed span — preambles, blank separators, hand-edited prose — is - 430
/// preserved byte-for-byte. - 431
pub fn forget_note(path: &Path, note_id: &str) -> Result<usize, String> { - 432
let _lock = StoreLock::acquire(path)?; - 433
let raw = load_raw(path)?; - 434
for span in note_spans(&raw) { - 435
if span.id != note_id { - 436
continue; - 437
} - 438
let mut out = String::with_capacity(raw.len() - (span.end - span.start)); - 439
out.push_str(&raw[..span.start]); - 440
out.push_str(&raw[span.end..]); - 441
persist_locked(path, out)?; - 442
return Ok(span.end - span.start); - 443
} - 444
Err(format!("no note '{note_id}' in {}", path.display())) - 445
} - 446
- 447
/// Replace one block's body while keeping its provenance header line - 448
/// verbatim — including any unknown/garbled tokens a hand-edit introduced. - 449
pub fn amend_note(path: &Path, note_id: &str, new_text: &str) -> Result<(), String> { - 450
let text = new_text.trim(); - 451
if text.is_empty() { - 452
return Err("note must not be empty".into()); - 453
} - 454
if text.len() > 128 * 1024 { - 455
return Err("note exceeds the 128 KiB limit".into()); - 456
} - 457
let _lock = StoreLock::acquire(path)?; - 458
let raw = load_raw(path)?; - 459
for span in note_spans(&raw) { - 460
if span.id != note_id { - 461
continue; - 462
} - 463
let old = &raw[span.start..span.end]; - 464
let header_end = old.find('\n').unwrap_or(old.len()); - 465
let rebuilt = format!("{}\n{text}\n", &old[..header_end]); - 466
let mut out = String::with_capacity(raw.len() - old.len() + rebuilt.len()); - 467
out.push_str(&raw[..span.start]); - 468
out.push_str(&rebuilt); - 469
out.push_str(&raw[span.end..]); - 470
return persist_locked(path, out); - 471
} - 472
Err(format!("no note '{note_id}' in {}", path.display())) - 473
} - 474
- 475
/// `2026-08-23T12:00:00+00:00 [decision] tag=x session=abc` → parts. - 476
fn parse_header(rest: &str) -> Option<(DateTime<Utc>, String, String, String)> { - 477
let mut parts = rest.splitn(2, char::is_whitespace); - 478
let ts_raw = parts.next()?.trim(); - 479
let ok_ts = DateTime::parse_from_rfc3339(ts_raw).ok()?; - 480
let tail = parts.next().unwrap_or("").trim(); - 481
- 482
let mut kind = "note".to_string(); - 483
let mut remainder = tail; - 484
if let Some(after) = tail.strip_prefix('[') - 485
&& let Some((inner, r)) = after.split_once(']') - 486
{ - 487
kind = inner.trim().to_string(); - 488
remainder = r.trim(); - 489
} - 490
- 491
let mut tag = String::new(); - 492
let mut session = String::new(); - 493
let mut leftover = String::new(); - 494
for token in remainder.split_whitespace() { - 495
if let Some(v) = token.strip_prefix("tag=") { - 496
tag = v.to_string(); - 497
} else if let Some(v) = token.strip_prefix("session=") { - 498
session = v.to_string(); - 499
} else if !token.is_empty() { - 500
leftover.push_str(token); - 501
leftover.push(' '); - 502
} - 503
} - 504
// Unknown trailing tokens are preserved as a pseudo-tag so nothing a - 505
// human typed disappears. - 506
if tag.is_empty() && !leftover.trim().is_empty() { - 507
tag = leftover.trim().to_string(); - 508
} - 509
Some((ok_ts.with_timezone(&Utc), kind, tag, session)) - 510
} - 511
- 512
#[cfg(test)] - 513
mod tests { - 514
#![allow(clippy::unwrap_used, clippy::expect_used)] - 515
use super::*; - 516
- 517
#[test] - 518
fn append_then_parse_roundtrips() { - 519
let dir = tempfile::tempdir().unwrap(); - 520
let home = dir.path(); - 521
let cwd = dir.path().join("ws"); - 522
std::fs::create_dir_all(&cwd).unwrap(); - 523
- 524
let b = append_note( - 525
home, - 526
&cwd, - 527
"decision", - 528
"deploy", - 529
"sess-1", - 530
"pause before rollbacks", - 531
) - 532
.unwrap(); - 533
let notes = list_notes(home, &cwd); - 534
assert_eq!(notes.len(), 1); - 535
assert_eq!(notes[0], b); - 536
assert_eq!(notes[0].kind, "decision"); - 537
assert_eq!(notes[0].tag, "deploy"); - 538
} - 539
- 540
#[test] - 541
fn parser_tolerates_hand_edits_and_junk() { - 542
let raw = "leading junk line\n\n## not a real header just text\nmore prose\n\n## 2026-08-23T10:00:00+00:00 [fact] session=abc\nthe deploy script lives in scripts/deploy.sh\ncustom user line\n"; - 543
let blocks = parse_blocks(raw); - 544
// The malformed heading becomes part of the leading-junk block? No: - 545
// it precedes any valid header, so only the valid block survives. - 546
assert_eq!(blocks.len(), 1); - 547
assert_eq!(blocks[0].kind, "fact"); - 548
assert_eq!(blocks[0].session_id, "abc"); - 549
assert!(blocks[0].text.contains("deploy.sh")); - 550
assert!(blocks[0].text.contains("custom user line")); - 551
} - 552
- 553
#[test] - 554
fn empty_note_rejected() { - 555
let dir = tempfile::tempdir().unwrap(); - 556
let err = append_note(dir.path(), dir.path(), "fact", "", "s", " "); - 557
assert!(err.is_err()); - 558
} - 559
- 560
#[test] - 561
fn header_metadata_rejects_control_characters() { - 562
let dir = tempfile::tempdir().unwrap(); - 563
assert!(append_note(dir.path(), dir.path(), "fact", "bad\ntag", "s", "x").is_err()); - 564
assert!(append_note(dir.path(), dir.path(), "fact", "ok", "s\n2", "x").is_err()); - 565
} - 566
- 567
#[test] - 568
fn malformed_heading_remains_in_preceding_note() { - 569
let raw = "## 2026-08-23T10:00:00+00:00 [fact] session=s\nfirst\n## not metadata\nmore\n"; - 570
let notes = parse_blocks(raw); - 571
assert_eq!(notes.len(), 1); - 572
assert!(notes[0].text.contains("## not metadata")); - 573
assert!(notes[0].text.contains("more")); - 574
} - 575
- 576
#[test] - 577
fn concurrent_appends_keep_every_block_intact() { - 578
let dir = tempfile::tempdir().unwrap(); - 579
let home = dir.path().to_path_buf(); - 580
let cwd = dir.path().join("ws"); - 581
std::fs::create_dir_all(&cwd).unwrap(); - 582
let mut joins = Vec::new(); - 583
for i in 0..16 { - 584
let home = home.clone(); - 585
let cwd = cwd.clone(); - 586
joins.push(std::thread::spawn(move || { - 587
append_note( - 588
&home, - 589
&cwd, - 590
"fact", - 591
"parallel", - 592
&format!("s{i}"), - 593
&format!("note-{i}"), - 594
) - 595
.unwrap(); - 596
})); - 597
} - 598
for join in joins { - 599
join.join().unwrap(); - 600
} - 601
assert_eq!(list_notes(&home, &cwd).len(), 16); - 602
} - 603
- 604
#[test] - 605
fn forget_removes_exactly_one_block_preserving_rest_byte_for_byte() { - 606
let dir = tempfile::tempdir().unwrap(); - 607
let home = dir.path(); - 608
let cwd = dir.path().join("ws"); - 609
- 610
let a = append_note(home, &cwd, "fact", "x", "s1", "alpha note").unwrap(); - 611
let b = append_note(home, &cwd, "decision", "y", "s2", "beta note").unwrap(); - 612
- 613
let block_a = format!( - 614
"## {} [fact] tag=x session=s1\nalpha note\n", - 615
a.ts.to_rfc3339() - 616
); - 617
let block_b = format!( - 618
"## {} [decision] tag=y session=s2\nbeta note\n", - 619
b.ts.to_rfc3339() - 620
); - 621
let path = home.join("memory").join(hash_cwd(&cwd)).join("MEMORY.md"); - 622
assert_eq!( - 623
std::fs::read_to_string(&path).unwrap(), - 624
format!("{block_a}\n{block_b}\n") - 625
); - 626
- 627
let removed = forget_note(&path, &a.id).unwrap(); - 628
assert_eq!(removed, block_a.len()); - 629
assert_eq!( - 630
std::fs::read_to_string(&path).unwrap(), - 631
format!("\n{block_b}\n") - 632
); - 633
- 634
let notes = list_notes(home, &cwd); - 635
assert_eq!(notes.len(), 1); - 636
assert_eq!(notes[0].id, b.id); - 637
- 638
assert!(forget_note(&path, &a.id).is_err()); - 639
assert_eq!( - 640
std::fs::read_to_string(&path).unwrap(), - 641
format!("\n{block_b}\n") - 642
); - 643
} - 644
- 645
#[test] - 646
fn amend_swaps_body_and_preserves_provenance_header() { - 647
let dir = tempfile::tempdir().unwrap(); - 648
let home = dir.path(); - 649
let cwd = dir.path().join("ws"); - 650
- 651
let n = append_note(home, &cwd, "fact", "", "s3", "original body").unwrap(); - 652
let header = format!("## {} [fact] tag= session=s3", n.ts.to_rfc3339()); - 653
let path = home.join("memory").join(hash_cwd(&cwd)).join("MEMORY.md"); - 654
- 655
amend_note(&path, &n.id, "revised body with more detail").unwrap(); - 656
- 657
assert_eq!( - 658
std::fs::read_to_string(&path).unwrap(), - 659
format!("{header}\nrevised body with more detail\n\n") - 660
); - 661
let notes = list_notes(home, &cwd); - 662
assert_eq!(notes.len(), 1); - 663
assert_eq!(notes[0].id, n.id); - 664
assert_eq!(notes[0].ts, n.ts); - 665
assert_eq!(notes[0].kind, n.kind); - 666
assert_eq!(notes[0].tag, n.tag); - 667
assert_eq!(notes[0].session_id, n.session_id); - 668
assert_eq!(notes[0].text, "revised body with more detail"); - 669
- 670
assert!(amend_note(&path, &n.id, " ").is_err()); - 671
assert!(amend_note(&path, "deadbeef", "x").is_err()); - 672
} - 673
- 674
#[test] - 675
fn amend_preserves_hand_edited_header_tokens() { - 676
let dir = tempfile::tempdir().unwrap(); - 677
let path = dir.path().join("USER.md"); - 678
let raw = "## 2026-08-23T10:00:00+00:00 [fact] tag=custom=foo session=abc mystery-token\ncold body\n"; - 679
std::fs::write(&path, raw).unwrap(); - 680
let id = parse_blocks(raw)[0].id.clone(); - 681
- 682
amend_note(&path, &id, "warm body").unwrap(); - 683
let after = std::fs::read_to_string(&path).unwrap(); - 684
assert!(after.starts_with( - 685
"## 2026-08-23T10:00:00+00:00 [fact] tag=custom=foo session=abc mystery-token\n" - 686
)); - 687
assert!(after.ends_with("warm body\n")); - 688
} - 689
- 690
#[test] - 691
fn forget_leaves_hand_edited_garbage_between_blocks_intact() { - 692
let dir = tempfile::tempdir().unwrap(); - 693
let home = dir.path(); - 694
let cwd = dir.path().join("ws"); - 695
std::fs::create_dir_all(&cwd).unwrap(); - 696
let path = home.join("memory").join(hash_cwd(&cwd)).join("MEMORY.md"); - 697
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 698
let raw = "prelude scribble\n\n## 2026-08-23T10:00:00+00:00 [fact] tag=a session=s1\nfirst note\nHUMAN GARBAGE ## fake header here\nmore scribble\n\n## 2026-08-23T11:00:00+00:00 [decision] tag=b session=s2\nsecond note"; - 699
std::fs::write(&path, raw).unwrap(); - 700
- 701
let notes = list_notes(home, &cwd); - 702
assert_eq!(notes.len(), 2); - 703
assert!(notes[0].text.contains("HUMAN GARBAGE")); - 704
- 705
let removed = forget_note(&path, ¬es[1].id).unwrap(); - 706
let after = std::fs::read_to_string(&path).unwrap(); - 707
assert_eq!(removed, raw.len() - after.len()); - 708
assert_eq!(after, &raw[..raw.len() - removed]); - 709
assert!(after.contains("HUMAN GARBAGE")); - 710
- 711
let removed2 = forget_note(&path, ¬es[0].id).unwrap(); - 712
assert_eq!( - 713
std::fs::read_to_string(&path).unwrap(), - 714
"prelude scribble\n\n\n" - 715
); - 716
assert!(list_notes(home, &cwd).is_empty()); - 717
assert_eq!( - 718
removed + removed2 + "prelude scribble\n\n\n".len(), - 719
raw.len() - 720
); - 721
} - 722
- 723
#[test] - 724
fn profile_tier_roundtrips() { - 725
let dir = tempfile::tempdir().unwrap(); - 726
let home = dir.path(); - 727
- 728
assert_eq!( - 729
profile_path(home), - 730
home.join("memory").join("user").join("USER.md") - 731
); - 732
assert!(list_profile_notes(home).is_empty()); - 733
- 734
let p = append_profile_note( - 735
home, - 736
"preference", - 737
"editor", - 738
"prefers vim bindings", - 739
"sess-u", - 740
) - 741
.unwrap(); - 742
let q = - 743
append_profile_note(home, "fact", "timezone", "works in UTC+5:30", "sess-u").unwrap(); - 744
- 745
let notes = list_profile_notes(home); - 746
assert_eq!(notes.len(), 2); - 747
assert_eq!(notes[0], p); - 748
assert_eq!(notes[1], q); - 749
assert_eq!(notes[0].tag, "editor"); - 750
- 751
let again = list_profile_notes(home); - 752
assert_eq!(again[0].id, p.id); - 753
assert_eq!(again[1].id, q.id); - 754
- 755
let cwd = dir.path().join("ws"); - 756
std::fs::create_dir_all(&cwd).unwrap(); - 757
assert!(list_notes(home, &cwd).is_empty()); - 758
- 759
assert!(append_profile_note(home, "fact", "", " ", "sess-u").is_err()); - 760
} - 761
- 762
#[test] - 763
fn load_matrix_scales_real_store_from_low_to_high() { - 764
let dir = tempfile::tempdir().unwrap(); - 765
let home = dir.path(); - 766
let cwd = home.join("load-workspace"); - 767
std::fs::create_dir_all(&cwd).unwrap(); - 768
let mut expected = 0; - 769
for (label, count) in [("low", 10usize), ("medium", 500), ("high", 4_096)] { - 770
for i in 0..count { - 771
append_note( - 772
home, - 773
&cwd, - 774
"fact", - 775
label, - 776
"load-test", - 777
&format!("{label} load item {i}: durable memory remains searchable"), - 778
) - 779
.unwrap(); - 780
} - 781
expected += count; - 782
let notes = list_notes(home, &cwd); - 783
assert_eq!(notes.len(), expected, "{label} load phase"); - 784
assert!(notes.iter().all(|n| n.session_id == "load-test")); - 785
} - 786
let raw = std::fs::read_to_string(memory_path(home, &cwd)).unwrap(); - 787
assert!(raw.len() > 500_000, "high-load store unexpectedly small"); - 788
} - 789
- 790
#[test] - 791
fn old_notes_are_retained_until_explicitly_forgotten() { - 792
let dir = tempfile::tempdir().unwrap(); - 793
let home = dir.path(); - 794
let cwd = home.join("retention-workspace"); - 795
let path = memory_path(home, &cwd); - 796
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 797
let raw = "## 2020-01-01T00:00:00+00:00 [fact] tag=old session=historical\nlong-lived knowledge\n"; - 798
std::fs::write(&path, raw).unwrap(); - 799
let notes = list_notes(home, &cwd); - 800
assert_eq!(notes.len(), 1); - 801
assert_eq!(notes[0].text, "long-lived knowledge"); - 802
forget_note(&path, ¬es[0].id).unwrap(); - 803
assert!(list_notes(home, &cwd).is_empty()); - 804
} - 805
- 806
#[test] - 807
fn cleanup_removes_only_abandoned_artifacts() { - 808
let dir = tempfile::tempdir().unwrap(); - 809
let home = dir.path(); - 810
let cwd = home.join("cleanup-workspace"); - 811
let path = memory_path(home, &cwd); - 812
append_note(home, &cwd, "fact", "keep", "s", "keep this note").unwrap(); - 813
let lock = path.with_extension("lock"); - 814
let tmp = path.with_file_name("MEMORY.tmp.crashed"); - 815
std::fs::write(&lock, "pid=dead\n").unwrap(); - 816
std::fs::write(&tmp, "partial\n").unwrap(); - 817
- 818
let report = cleanup_artifacts(home, Duration::ZERO); - 819
assert_eq!(report.removed_locks, 1); - 820
assert_eq!(report.removed_temps, 1); - 821
assert!(path.is_file()); - 822
assert_eq!(list_notes(home, &cwd).len(), 1); - 823
assert!(!lock.exists()); - 824
assert!(!tmp.exists()); - 825
} - 826
} - 827
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.