1use std::fs::{self, OpenOptions};
39use std::io::{BufRead, BufReader, Write};
40use std::path::{Path, PathBuf};
41
42use anyhow::{Context, Result};
43use serde::{Deserialize, Serialize};
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum MemoryKind {
49 Episodic,
50 Semantic,
51 Procedural,
52 Counterfactual,
53}
54
55impl MemoryKind {
56 pub fn filename(&self) -> &'static str {
57 match self {
58 MemoryKind::Episodic => "episodic.jsonl",
59 MemoryKind::Semantic => "semantic.jsonl",
60 MemoryKind::Procedural => "procedural.jsonl",
61 MemoryKind::Counterfactual => "counterfactual.jsonl",
62 }
63 }
64
65 pub fn all() -> [MemoryKind; 4] {
66 [
67 MemoryKind::Episodic,
68 MemoryKind::Semantic,
69 MemoryKind::Procedural,
70 MemoryKind::Counterfactual,
71 ]
72 }
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum Outcome {
79 Succeeded,
80 Failed,
81 Abandoned,
84 Unobserved,
86}
87
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "body", rename_all = "snake_case")]
91pub enum MemoryBody {
92 Episode {
94 what: String,
95 outcome: Outcome,
96 evidence: Vec<String>,
97 },
98 Belief {
104 claim: String,
105 support: u32,
106 contradiction: u32,
107 },
108 Procedure {
110 name: String,
111 steps: Vec<String>,
112 successes: u32,
113 failures: u32,
114 },
115 Counterfactual {
121 decision: String,
122 hypothesis: String,
123 statement: String,
124 projected: f64,
125 reason: String,
127 },
128 Realisation {
130 decision: String,
131 hypothesis: String,
132 realised: f64,
133 note: String,
134 },
135}
136
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139pub struct MemoryRecord {
140 pub id: String,
141 pub kind: MemoryKind,
142 pub at: i64,
144 pub subject: String,
147 pub tags: Vec<String>,
148 pub body: MemoryBody,
149 pub source: String,
151}
152
153impl MemoryRecord {
154 pub fn new(
155 id: impl Into<String>,
156 kind: MemoryKind,
157 at: i64,
158 subject: impl Into<String>,
159 body: MemoryBody,
160 source: impl Into<String>,
161 ) -> Self {
162 MemoryRecord {
163 id: id.into(),
164 kind,
165 at,
166 subject: subject.into(),
167 tags: vec![],
168 body,
169 source: source.into(),
170 }
171 }
172
173 pub fn tagged(mut self, tag: impl Into<String>) -> Self {
174 self.tags.push(tag.into());
175 self
176 }
177}
178
179#[derive(Clone, Debug, Default)]
181pub struct Recall {
182 pub subject: Option<String>,
184 pub tag: Option<String>,
185 pub since: Option<i64>,
187 pub limit: Option<usize>,
189}
190
191impl Recall {
192 pub fn about(subject: impl Into<String>) -> Self {
193 Recall { subject: Some(subject.into()), ..Default::default() }
194 }
195
196 pub fn limit(mut self, n: usize) -> Self {
197 self.limit = Some(n);
198 self
199 }
200
201 fn matches(&self, r: &MemoryRecord) -> bool {
202 if let Some(s) = &self.subject {
203 if !r.subject.to_lowercase().contains(&s.to_lowercase()) {
204 return false;
205 }
206 }
207 if let Some(t) = &self.tag {
208 if !r.tags.iter().any(|x| x == t) {
209 return false;
210 }
211 }
212 if let Some(since) = self.since {
213 if r.at < since {
214 return false;
215 }
216 }
217 true
218 }
219}
220
221#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
223pub struct Calibration {
224 pub recorded: usize,
226 pub resolved: usize,
228 pub unresolved: usize,
230 pub mean_abs_error: Option<f64>,
234}
235
236pub fn self_ignore(root: &Path) {
258 let marker = root.join(".gitignore");
259 if marker.exists() {
260 return;
261 }
262 let _ = fs::write(
263 &marker,
264 "# Machine-local agent state: decision records cite absolute paths, memory is a\n # per-checkout history, and omnid.token is a secret. None of it belongs in a commit.\n *\n",
265 );
266}
267
268pub struct MemoryStore {
270 root: PathBuf,
271}
272
273impl MemoryStore {
274 pub fn new(root: impl Into<PathBuf>) -> Self {
276 MemoryStore { root: root.into() }
277 }
278
279 fn dir(&self) -> PathBuf {
280 self.root.join("memory")
281 }
282
283 fn path(&self, kind: MemoryKind) -> PathBuf {
284 self.dir().join(kind.filename())
285 }
286
287 pub fn remember(&self, record: &MemoryRecord) -> Result<()> {
293 let dir = self.dir();
294 fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
295 self_ignore(&self.root);
296 let path = self.path(record.kind);
297 let mut line = serde_json::to_string(record)?;
298 line.push('\n');
299 let mut f = OpenOptions::new()
300 .create(true)
301 .append(true)
302 .open(&path)
303 .with_context(|| format!("opening {}", path.display()))?;
304 f.write_all(line.as_bytes())
305 .with_context(|| format!("appending to {}", path.display()))?;
306 Ok(())
307 }
308
309 pub fn read_all(&self, kind: MemoryKind) -> Result<(Vec<MemoryRecord>, usize)> {
315 let path = self.path(kind);
316 if !path.exists() {
317 return Ok((vec![], 0));
318 }
319 let f = fs::File::open(&path).with_context(|| format!("opening {}", path.display()))?;
320 let mut out = Vec::new();
321 let mut corrupt = 0usize;
322 for line in BufReader::new(f).lines() {
323 let line = line?;
324 if line.trim().is_empty() {
325 continue;
326 }
327 match serde_json::from_str::<MemoryRecord>(&line) {
328 Ok(r) => out.push(r),
329 Err(_) => corrupt += 1,
330 }
331 }
332 Ok((out, corrupt))
333 }
334
335 pub fn recall(&self, kind: MemoryKind, query: &Recall) -> Result<Vec<MemoryRecord>> {
337 let (all, _) = self.read_all(kind)?;
338 let mut hits: Vec<MemoryRecord> = all.into_iter().filter(|r| query.matches(r)).collect();
339 hits.sort_by_key(|r| std::cmp::Reverse(r.at));
340 if let Some(n) = query.limit {
341 hits.truncate(n);
342 }
343 Ok(hits)
344 }
345
346 pub fn calibration(&self) -> Result<Calibration> {
352 let (records, _) = self.read_all(MemoryKind::Counterfactual)?;
353 let mut projected: Vec<(String, String, f64)> = Vec::new();
354 let mut realised: Vec<(String, String, f64)> = Vec::new();
355 for r in &records {
356 match &r.body {
357 MemoryBody::Counterfactual { decision, hypothesis, projected: p, .. } => {
358 projected.push((decision.clone(), hypothesis.clone(), *p))
359 }
360 MemoryBody::Realisation { decision, hypothesis, realised: v, .. } => {
361 realised.push((decision.clone(), hypothesis.clone(), *v))
362 }
363 _ => {}
364 }
365 }
366 let mut errors: Vec<f64> = Vec::new();
367 for (d, h, p) in &projected {
368 if let Some((_, _, v)) = realised.iter().find(|(rd, rh, _)| rd == d && rh == h) {
369 errors.push((p - v).abs());
370 }
371 }
372 let recorded = projected.len();
373 let resolved = errors.len();
374 Ok(Calibration {
375 recorded,
376 resolved,
377 unresolved: recorded - resolved,
378 mean_abs_error: if errors.is_empty() {
379 None
380 } else {
381 Some(errors.iter().sum::<f64>() / errors.len() as f64)
382 },
383 })
384 }
385
386 pub fn counts(&self) -> Result<Vec<(MemoryKind, usize, usize)>> {
388 MemoryKind::all()
389 .iter()
390 .map(|k| {
391 let (rs, corrupt) = self.read_all(*k)?;
392 Ok((*k, rs.len(), corrupt))
393 })
394 .collect()
395 }
396
397 pub fn root(&self) -> &Path {
398 &self.root
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 fn tmp() -> PathBuf {
407 let p = std::env::temp_dir().join(format!(
408 "scema-omni-mem-{}-{}",
409 std::process::id(),
410 std::time::SystemTime::now()
411 .duration_since(std::time::UNIX_EPOCH)
412 .unwrap()
413 .as_nanos()
414 ));
415 fs::create_dir_all(&p).unwrap();
416 p
417 }
418
419 fn cf(store: &MemoryStore, id: &str, decision: &str, hypothesis: &str, projected: f64) {
420 store
421 .remember(&MemoryRecord::new(
422 id,
423 MemoryKind::Counterfactual,
424 1,
425 decision,
426 MemoryBody::Counterfactual {
427 decision: decision.into(),
428 hypothesis: hypothesis.into(),
429 statement: "s".into(),
430 projected,
431 reason: "outranked".into(),
432 },
433 "test",
434 ))
435 .unwrap();
436 }
437
438 #[test]
439 fn untaken_branches_are_counted_and_never_scored() {
440 let dir = tmp();
443 let s = MemoryStore::new(&dir);
444 cf(&s, "m1", "d1", "h2", 0.31);
445 cf(&s, "m2", "d1", "h3", 0.11);
446 let c = s.calibration().unwrap();
447 assert_eq!(c.recorded, 2);
448 assert_eq!(c.resolved, 0);
449 assert_eq!(c.unresolved, 2);
450 assert_eq!(c.mean_abs_error, None, "no evidence must not print as perfect accuracy");
451 fs::remove_dir_all(&dir).ok();
452 }
453
454 #[test]
455 fn a_realisation_resolves_exactly_its_own_branch() {
456 let dir = tmp();
457 let s = MemoryStore::new(&dir);
458 cf(&s, "m1", "d1", "h2", 0.30);
459 cf(&s, "m2", "d1", "h3", 0.10);
460 s.remember(&MemoryRecord::new(
461 "m3",
462 MemoryKind::Counterfactual,
463 2,
464 "d1",
465 MemoryBody::Realisation {
466 decision: "d1".into(),
467 hypothesis: "h2".into(),
468 realised: 0.20,
469 note: "ran it later".into(),
470 },
471 "test",
472 ))
473 .unwrap();
474 let c = s.calibration().unwrap();
475 assert_eq!(c.resolved, 1);
476 assert_eq!(c.unresolved, 1);
477 assert!((c.mean_abs_error.unwrap() - 0.10).abs() < 1e-9);
478 fs::remove_dir_all(&dir).ok();
479 }
480
481 #[test]
482 fn a_realisation_for_a_different_decision_does_not_resolve_anything() {
483 let dir = tmp();
484 let s = MemoryStore::new(&dir);
485 cf(&s, "m1", "d1", "h2", 0.30);
486 s.remember(&MemoryRecord::new(
487 "m2",
488 MemoryKind::Counterfactual,
489 2,
490 "d9",
491 MemoryBody::Realisation {
492 decision: "d9".into(),
493 hypothesis: "h2".into(),
494 realised: 0.9,
495 note: "different decision entirely".into(),
496 },
497 "test",
498 ))
499 .unwrap();
500 assert_eq!(s.calibration().unwrap().resolved, 0);
501 fs::remove_dir_all(&dir).ok();
502 }
503
504 #[test]
505 fn a_corrupt_line_is_skipped_and_counted_rather_than_fatal() {
506 let dir = tmp();
507 let s = MemoryStore::new(&dir);
508 s.remember(&MemoryRecord::new(
509 "m1",
510 MemoryKind::Episodic,
511 1,
512 "x",
513 MemoryBody::Episode {
514 what: "did a thing".into(),
515 outcome: Outcome::Succeeded,
516 evidence: vec![],
517 },
518 "test",
519 ))
520 .unwrap();
521 let path = dir.join("memory").join("episodic.jsonl");
522 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
523 f.write_all(b"{ this is not json\n").unwrap();
524
525 let (records, corrupt) = s.read_all(MemoryKind::Episodic).unwrap();
526 assert_eq!(records.len(), 1, "one bad line must not make the agent amnesiac");
527 assert_eq!(corrupt, 1, "and it must not be swallowed either");
528 fs::remove_dir_all(&dir).ok();
529 }
530
531 #[test]
532 fn recall_returns_most_recent_first() {
533 let dir = tmp();
534 let s = MemoryStore::new(&dir);
535 for (id, at) in [("a", 10), ("b", 30), ("c", 20)] {
536 s.remember(&MemoryRecord::new(
537 id,
538 MemoryKind::Semantic,
539 at,
540 "rpc",
541 MemoryBody::Belief { claim: id.into(), support: 1, contradiction: 0 },
542 "test",
543 ))
544 .unwrap();
545 }
546 let hits = s.recall(MemoryKind::Semantic, &Recall::about("rpc").limit(2)).unwrap();
547 assert_eq!(hits.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), vec!["b", "c"]);
548 fs::remove_dir_all(&dir).ok();
549 }
550
551 #[test]
552 fn reading_a_store_that_was_never_written_is_empty_not_an_error() {
553 let s = MemoryStore::new(tmp().join("nope"));
554 assert!(s.recall(MemoryKind::Episodic, &Recall::default()).unwrap().is_empty());
555 assert_eq!(s.calibration().unwrap().recorded, 0);
556 }
557}