1use std::collections::{BTreeMap, BTreeSet};
19use std::io::{BufRead, Write};
20use std::path::PathBuf;
21
22use anyhow::{bail, Context, Result};
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26pub const REVIEW_KINDS: &[&str] = &[
28 "lesson",
29 "preference",
30 "rule",
31 "fact",
32 "summary",
33 "note",
34 "episode",
35];
36
37#[must_use]
40pub fn write_reason() -> Option<String> {
41 std::env::var("LJOS_MEMORY_WHY")
42 .ok()
43 .map(|w| crate::capture::excerpt_of(&w))
44 .filter(|w| !w.is_empty())
45 .or_else(crate::capture::latest_prompt_excerpt)
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct Memory {
51 pub id: String,
52 pub text: String,
53 pub kind: String,
54 pub origin: String,
55 pub basis: String,
57 pub seat: String,
58 pub harness: String,
59 pub session: String,
60 pub host: String,
61 pub at: String,
63 pub reason: Option<String>,
64 pub recalls: u64,
66 pub verdict: Option<String>,
68}
69
70fn s(v: &Value) -> String {
71 v.as_str().unwrap_or("").to_string()
72}
73
74impl Memory {
75 #[must_use]
77 pub fn of(atom: &Value) -> Option<Self> {
78 let kind = s(&atom["kind"]);
79 if !REVIEW_KINDS.contains(&kind.as_str()) || atom["tombstone"] == Value::Bool(true) {
80 return None;
81 }
82 let id = s(&atom["id"]);
83 if id.is_empty() {
84 return None;
85 }
86 let src = &atom["source"];
87 let entity_seat = atom["entities"].as_array().and_then(|e| {
88 e.iter()
89 .filter_map(Value::as_str)
90 .find_map(|t| t.strip_prefix(crate::SEAT_ENTITY))
91 .map(str::to_string)
92 });
93 let seat = Some(s(&src["seat"]))
94 .filter(|x| !x.is_empty())
95 .or(entity_seat)
96 .unwrap_or_else(|| s(&src["harness"]));
97 let at = Some(s(&src["at"]))
98 .filter(|x| !x.is_empty())
99 .unwrap_or_else(|| s(&atom["ts"]));
100 let origin = Some(s(&atom["origin"]))
101 .filter(|x| !x.is_empty())
102 .unwrap_or_else(|| crate::admit::ORIGIN_USER.to_string());
103 Some(Self {
104 id,
105 text: s(&atom["text"]),
106 kind,
107 origin,
108 basis: s(&src["basis"]),
109 harness: Some(s(&src["harness"]))
110 .filter(|x| !x.is_empty())
111 .unwrap_or_else(|| seat.clone()),
112 seat,
113 session: s(&src["session"]),
114 host: s(&src["host"]),
115 at,
116 reason: src["reason"].as_str().map(str::to_string),
117 recalls: atom["review"]["reps"].as_u64().unwrap_or(0),
118 verdict: None,
119 })
120 }
121
122 #[must_use]
123 pub fn short_id(&self) -> &str {
124 self.id.get(..8).unwrap_or(&self.id)
125 }
126
127 #[must_use]
129 pub fn day(&self) -> &str {
130 self.at.get(..10).unwrap_or(&self.at)
131 }
132
133 #[must_use]
134 pub fn is_agents(&self) -> bool {
135 self.origin != crate::admit::ORIGIN_USER
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct Review {
142 pub id: String,
143 pub verdict: String,
145 pub at: String,
146 pub by: String,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub into: Option<String>,
150}
151
152#[must_use]
154pub fn reviews_path() -> PathBuf {
155 std::env::var_os("XDG_STATE_HOME")
156 .map(PathBuf::from)
157 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
158 .unwrap_or_else(|| PathBuf::from(".local/state"))
159 .join("ljos/memory-reviews.jsonl")
160}
161
162#[must_use]
164pub fn reviews() -> BTreeMap<String, Review> {
165 let bytes = crate::read_state_file(&reviews_path()).unwrap_or_default();
166 crate::complete_rows::<Review>(&bytes)
167 .into_iter()
168 .map(|r| (r.id.clone(), r))
169 .collect()
170}
171
172pub fn record(id: &str, verdict: &str, into: Option<&str>) -> Result<Review> {
178 let r = Review {
179 id: id.to_string(),
180 verdict: verdict.to_string(),
181 at: crate::now_utc(),
182 by: crate::seat_name(),
183 into: into.map(str::to_string),
184 };
185 let path = reviews_path();
186 if let Some(dir) = path.parent() {
187 std::fs::create_dir_all(dir)?;
188 }
189 let mut line = serde_json::to_string(&r)?;
190 line.push('\n');
191 crate::append_locked_row(&path, line.as_bytes(), crate::Durability::Durable)
192 .with_context(|| format!("memories: cannot write {}", path.display()))?;
193 Ok(r)
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq)]
198pub struct Filter {
199 pub seat: Option<String>,
200 pub since: Option<String>,
202 pub reviewed: bool,
204 pub user: bool,
206}
207
208impl Filter {
209 #[must_use]
210 pub fn admits(&self, m: &Memory) -> bool {
211 (self.user || m.is_agents())
212 && (self.reviewed || m.verdict.is_none())
213 && self.seat.as_deref().is_none_or(|seat| m.seat == seat)
214 && self.since.as_deref().is_none_or(|d| m.day() >= d)
215 }
216}
217
218#[must_use]
222pub fn recall_uses_path() -> PathBuf {
223 reviews_path()
224 .parent()
225 .map(|dir| dir.join("recall-uses.json"))
226 .unwrap_or_else(|| PathBuf::from("recall-uses.json"))
227}
228
229#[must_use]
231pub fn recall_uses() -> BTreeMap<String, u64> {
232 let Ok(text) = std::fs::read_to_string(recall_uses_path()) else {
233 return BTreeMap::new();
234 };
235 serde_json::from_str(&text).unwrap_or_default()
236}
237
238pub fn bump_recalls(ids: &[String]) {
241 if ids.is_empty() {
242 return;
243 }
244 let path = recall_uses_path();
245 if let Some(dir) = path.parent() {
246 let _ = std::fs::create_dir_all(dir);
247 }
248 let mut uses = recall_uses();
249 for id in ids {
250 if id.is_empty() {
251 continue;
252 }
253 *uses.entry(id.clone()).or_default() += 1;
254 }
255 if let Ok(text) = serde_json::to_string(&uses) {
256 let _ = std::fs::write(path, text);
257 }
258}
259
260#[must_use]
263pub fn list(atoms: &[Value], reviews: &BTreeMap<String, Review>, filter: &Filter) -> Vec<Memory> {
264 let uses = recall_uses();
265 let mut out: Vec<Memory> = atoms
266 .iter()
267 .filter_map(Memory::of)
268 .map(|mut m| {
269 m.verdict = reviews.get(&m.id).map(|r| r.verdict.clone());
270 m.recalls = m
271 .recalls
272 .saturating_add(uses.get(&m.id).copied().unwrap_or(0));
273 m
274 })
275 .filter(|m| filter.admits(m))
276 .collect();
277 out.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
278 out
279}
280
281pub fn live_atoms() -> Result<Vec<Value>> {
287 let client = crate::pack()?;
288 let ws = client.workspace();
289 crate::atoms_lean(&client, &ws)
290}
291
292pub fn resolve(short: &str, all: &[Memory]) -> Result<String> {
298 let short = short.trim();
299 let hits: Vec<&Memory> = all.iter().filter(|m| m.id.starts_with(short)).collect();
300 match hits.as_slice() {
301 [one] => Ok(one.id.clone()),
302 [] => bail!("memories: no memory starts with {short}"),
303 _ => bail!(
304 "memories: {short} starts {} memories; give more of the id",
305 hits.len()
306 ),
307 }
308}
309
310fn find<'a>(id: &str, all: &'a [Memory]) -> Result<&'a Memory> {
311 let full = resolve(id, all)?;
312 all.iter()
313 .find(|m| m.id == full)
314 .context("memories: the id vanished")
315}
316
317pub fn keep(id: &str, all: &[Memory]) -> Result<String> {
323 let m = find(id, all)?;
324 record(&m.id, "keep", None)?;
325 Ok(format!("kept {}\n", m.short_id()))
326}
327
328pub fn drop_memory(id: &str, all: &[Memory]) -> Result<String> {
334 let m = find(id, all)?;
335 record(&m.id, "drop", None)?;
339 crate::packset_forget(&m.id, None)?;
340 Ok(format!("dropped {}\n", m.short_id()))
341}
342
343fn replace(olds: &[&Memory], text: &str, verdict: &str) -> Result<String> {
350 let text = text.trim();
351 if text.is_empty() {
352 bail!("memories: {verdict} needs a text");
353 }
354 let first = olds.first().context("memories: nothing to replace")?;
355 let client = crate::pack()?;
356 let ws = client.workspace();
357 let mut atom = crate::atom_body(&first.kind, text, &ws);
358 atom["source"]["seat"] = Value::String(first.seat.clone());
359 atom["source"]["harness"] = Value::String(first.harness.clone());
360 atom["source"]["session"] = Value::String(first.session.clone());
361 atom["source"]["reviewed_by"] = Value::String(crate::seat_name());
362 atom["source"]["replaces"] =
363 Value::Array(olds.iter().map(|m| Value::String(m.id.clone())).collect());
364 if let Some(reason) = &first.reason {
365 atom["source"]["reason"] = Value::String(reason.clone());
366 }
367 crate::add_entities(&mut atom, ["horizon:standing".to_string()]);
368 let posted = if first.is_agents() {
369 crate::admit::write_agent(&client, atom)?
370 } else {
371 atom["origin"] = Value::String(first.origin.clone());
372 if !first.basis.is_empty() {
373 atom["source"]["basis"] = Value::String(first.basis.clone());
374 }
375 crate::admit::post_kept(&client, &atom)?
376 };
377 let new_id = s(&posted["id"]);
378 for m in olds {
379 record(&m.id, verdict, Some(&new_id))?;
383 if m.id != new_id {
384 crate::packset_forget(&m.id, None)?;
385 }
386 }
387 if !new_id.is_empty() {
388 record(&new_id, "keep", None)?;
389 }
390 Ok(format!(
391 "{verdict} {} into {}\n",
392 olds.iter()
393 .map(|m| m.short_id())
394 .collect::<Vec<_>>()
395 .join(", "),
396 new_id.get(..8).unwrap_or(&new_id)
397 ))
398}
399
400pub fn edit(id: &str, text: &str, all: &[Memory]) -> Result<String> {
410 refuse_runner("edit")?;
411 let m = find(id, all)?;
412 replace(&[m], text, "edited")
413}
414
415pub fn merge(ids: &[String], text: Option<&str>, all: &[Memory]) -> Result<String> {
426 refuse_runner("merge")?;
427 if ids.len() < 2 {
428 bail!("memories: merge takes two ids or more");
429 }
430 let olds: Vec<&Memory> = ids.iter().map(|id| find(id, all)).collect::<Result<_>>()?;
431 let joined;
432 let text = if let Some(t) = text {
433 t
434 } else {
435 joined = olds
436 .iter()
437 .map(|m| m.text.trim())
438 .collect::<Vec<_>>()
439 .join(" ");
440 &joined
441 };
442 replace(&olds, text, "merged")
443}
444
445fn refuse_runner(verb: &str) -> Result<()> {
446 if crate::under_a_runner() {
447 bail!(
448 "memories: a runner cannot {verb} a memory. \
449 Run `ljos memories review` from a terminal of your own"
450 );
451 }
452 Ok(())
453}
454
455#[must_use]
457pub fn describe(m: &Memory) -> String {
458 let mut out = format!("{} {} {}\n", m.short_id(), m.kind, m.text);
459 out.push_str(&format!(
460 " seat {} harness {} session {} at {} origin {} recalled {}x{}\n",
461 dash(&m.seat),
462 dash(&m.harness),
463 dash(&m.session),
464 dash(&m.at),
465 m.origin,
466 m.recalls,
467 m.verdict
468 .as_deref()
469 .map(|v| format!(" verdict {v}"))
470 .unwrap_or_default()
471 ));
472 if let Some(reason) = &m.reason {
473 out.push_str(&format!(" why: {reason}\n"));
474 }
475 out
476}
477
478fn dash(x: &str) -> &str {
479 if x.is_empty() {
480 "-"
481 } else {
482 x
483 }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488pub struct Stale {
489 pub id: String,
490 pub text: String,
491 pub why: String,
492}
493
494fn fold(text: &str) -> String {
495 text.to_lowercase()
496 .chars()
497 .map(|c| if c.is_alphanumeric() { c } else { ' ' })
498 .collect::<String>()
499 .split_whitespace()
500 .collect::<Vec<_>>()
501 .join(" ")
502}
503
504#[must_use]
508pub fn stale(all: &[Memory], days: i64, today: &str) -> Vec<Stale> {
509 let mut out = Vec::new();
510 let mut seen: BTreeMap<String, &Memory> = BTreeMap::new();
511 let mut newest_first: Vec<&Memory> = all.iter().collect();
512 newest_first.sort_by(|a, b| b.at.cmp(&a.at));
513 let mut dropped = BTreeSet::new();
514 for m in &newest_first {
515 let key = format!("{}\n{}", m.kind, fold(&m.text));
516 if let Some(newer) = seen.get(&key) {
517 if m.verdict.as_deref() != Some("keep") {
518 out.push(Stale {
519 id: m.id.clone(),
520 text: m.text.clone(),
521 why: format!("duplicate of {}", newer.short_id()),
522 });
523 dropped.insert(m.id.clone());
524 }
525 } else {
526 seen.insert(key, m);
527 }
528 }
529 let cutoff = chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d")
530 .ok()
531 .map(|d| {
532 (d - chrono::Duration::days(days))
533 .format("%Y-%m-%d")
534 .to_string()
535 });
536 if let Some(cutoff) = cutoff {
537 for m in &newest_first {
538 if dropped.contains(&m.id) || m.verdict.as_deref() == Some("keep") {
539 continue;
540 }
541 if m.recalls == 0 && !m.at.is_empty() && m.day() < cutoff.as_str() {
542 out.push(Stale {
543 id: m.id.clone(),
544 text: m.text.clone(),
545 why: format!("never recalled since {}", m.day()),
546 });
547 }
548 }
549 }
550 out
551}
552
553#[derive(Debug, Clone, Default)]
557pub struct View {
558 pub all: Vec<Memory>,
559 pub seat: Option<String>,
560 pub since: Option<String>,
561 pub show_reviewed: bool,
562 pub cursor: usize,
563 pub marked: BTreeSet<String>,
564 pub status: String,
565}
566
567impl View {
568 #[must_use]
569 pub fn new(all: Vec<Memory>) -> Self {
570 Self {
571 all,
572 status: "j/k move a keep d drop e edit m mark M merge s seat f since v reviewed q quit".into(),
573 ..Self::default()
574 }
575 }
576
577 fn filter(&self) -> Filter {
578 Filter {
579 seat: self.seat.clone(),
580 since: self.since.clone(),
581 reviewed: self.show_reviewed,
582 user: true,
583 }
584 }
585
586 #[must_use]
588 pub fn rows(&self) -> Vec<&Memory> {
589 let f = self.filter();
590 self.all.iter().filter(|m| f.admits(m)).collect()
591 }
592
593 #[must_use]
594 pub fn current(&self) -> Option<&Memory> {
595 self.rows().get(self.cursor).copied()
596 }
597
598 pub fn clamp(&mut self) {
599 let n = self.rows().len();
600 self.cursor = self.cursor.min(n.saturating_sub(1));
601 }
602
603 pub fn down(&mut self) {
604 self.cursor += 1;
605 self.clamp();
606 }
607
608 pub fn up(&mut self) {
609 self.cursor = self.cursor.saturating_sub(1);
610 }
611
612 #[must_use]
614 pub fn seats(&self) -> Vec<String> {
615 self.all
616 .iter()
617 .map(|m| m.seat.clone())
618 .collect::<BTreeSet<_>>()
619 .into_iter()
620 .collect()
621 }
622
623 pub fn cycle_seat(&mut self) {
625 let seats = self.seats();
626 self.seat = match &self.seat {
627 None => seats.first().cloned(),
628 Some(cur) => seats
629 .iter()
630 .position(|s| s == cur)
631 .and_then(|i| seats.get(i + 1).cloned()),
632 };
633 self.cursor = 0;
634 }
635
636 pub fn toggle_mark(&mut self) {
637 if let Some(id) = self.current().map(|m| m.id.clone()) {
638 if !self.marked.remove(&id) {
639 self.marked.insert(id);
640 }
641 }
642 }
643
644 pub fn set_verdict(&mut self, id: &str, verdict: &str) {
646 for m in &mut self.all {
647 if m.id == id {
648 m.verdict = Some(verdict.to_string());
649 }
650 }
651 self.clamp();
652 }
653}
654
655fn clip(text: &str, width: usize) -> String {
656 if text.chars().count() <= width {
657 return text.to_string();
658 }
659 let mut out: String = text.chars().take(width.saturating_sub(1)).collect();
660 out.push('…');
661 out
662}
663
664#[must_use]
668pub fn render(view: &View, width: usize, height: usize) -> Vec<String> {
669 let width = width.max(20);
670 let height = height.max(8);
671 let rows = view.rows();
672 let mut out = vec![clip(
673 &format!(
674 "ljos memories {} shown of {} seat {} since {} {}",
675 rows.len(),
676 view.all.len(),
677 view.seat.as_deref().unwrap_or("all"),
678 view.since.as_deref().unwrap_or("any"),
679 if view.show_reviewed {
680 "all verdicts"
681 } else {
682 "unreviewed"
683 }
684 ),
685 width,
686 )];
687 out.push("─".repeat(width));
688 let list_h = height.saturating_sub(8).max(1);
690 let start = view.cursor.saturating_sub(list_h.saturating_sub(1));
691 for (i, m) in rows.iter().enumerate().skip(start).take(list_h) {
692 let pointer = if i == view.cursor { '>' } else { ' ' };
693 let mark = if view.marked.contains(&m.id) {
694 '*'
695 } else {
696 ' '
697 };
698 let verdict = m.verdict.as_deref().unwrap_or("new");
699 out.push(clip(
700 &format!(
701 "{pointer}{mark} {} {} {:<12} {:<5} {}",
702 m.short_id(),
703 m.day(),
704 clip(&m.seat, 12),
705 clip(verdict, 5),
706 m.text
707 ),
708 width,
709 ));
710 }
711 if rows.is_empty() {
712 out.push(" nothing to review".into());
713 }
714 while out.len() < 2 + list_h {
715 out.push(String::new());
716 }
717 out.push("─".repeat(width));
718 match view.current() {
719 Some(m) => {
720 out.push(clip(&m.text, width));
721 out.push(clip(
722 &format!(
723 "seat {} harness {} session {} host {}",
724 dash(&m.seat),
725 dash(&m.harness),
726 dash(&m.session),
727 dash(&m.host)
728 ),
729 width,
730 ));
731 out.push(clip(
732 &format!(
733 "at {} origin {} recalled {}x why: {}",
734 dash(&m.at),
735 m.origin,
736 m.recalls,
737 m.reason.as_deref().unwrap_or("-")
738 ),
739 width,
740 ));
741 }
742 None => out.extend([String::new(), String::new(), String::new()]),
743 }
744 out.push("─".repeat(width));
745 out.push(clip(&view.status, width));
746 out.truncate(height);
747 out
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
752pub enum Key {
753 Up,
754 Down,
755 Char(char),
756 Other,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
761pub enum Act {
762 None,
763 Quit,
764 Keep(String),
765 Drop(String),
766 Edit(String),
768 Merge(Vec<String>),
769 Since,
771}
772
773pub fn on_key(view: &mut View, key: Key) -> Act {
775 let current = view.current().map(|m| m.id.clone());
776 match key {
777 Key::Up | Key::Char('k') => view.up(),
778 Key::Down | Key::Char('j') => view.down(),
779 Key::Char('q') => return Act::Quit,
780 Key::Char('s') => view.cycle_seat(),
781 Key::Char('v') => {
782 view.show_reviewed = !view.show_reviewed;
783 view.cursor = 0;
784 }
785 Key::Char('m') => view.toggle_mark(),
786 Key::Char('f') => return Act::Since,
787 Key::Char('a') => return current.map_or(Act::None, Act::Keep),
788 Key::Char('d') => return current.map_or(Act::None, Act::Drop),
789 Key::Char('e') => return current.map_or(Act::None, Act::Edit),
790 Key::Char('M') => {
791 if view.marked.len() >= 2 {
792 return Act::Merge(view.marked.iter().cloned().collect());
793 }
794 view.status = "mark two memories or more with m, then M".into();
795 }
796 _ => {}
797 }
798 Act::None
799}
800
801struct Raw {
803 saved: libc::termios,
804}
805
806impl Raw {
807 fn on() -> Result<Self> {
808 unsafe {
811 let mut t: libc::termios = std::mem::zeroed();
812 if libc::tcgetattr(0, &mut t) != 0 {
813 bail!("memories tui: stdin is not a terminal");
814 }
815 let saved = t;
816 t.c_lflag &= !(libc::ICANON | libc::ECHO);
817 t.c_cc[libc::VMIN] = 1;
818 t.c_cc[libc::VTIME] = 0;
819 libc::tcsetattr(0, libc::TCSANOW, &t);
820 Ok(Self { saved })
821 }
822 }
823}
824
825impl Drop for Raw {
826 fn drop(&mut self) {
827 unsafe {
829 libc::tcsetattr(0, libc::TCSANOW, &self.saved);
830 }
831 print!("\x1b[?25h\x1b[?1049l");
832 let _ = std::io::stdout().flush();
833 }
834}
835
836fn term_size() -> (usize, usize) {
837 unsafe {
839 let mut ws: libc::winsize = std::mem::zeroed();
840 if libc::ioctl(1, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 {
841 return (usize::from(ws.ws_col), usize::from(ws.ws_row));
842 }
843 }
844 (100, 30)
845}
846
847fn read_key() -> Key {
848 use std::io::Read;
849 let mut b = [0u8; 1];
850 if std::io::stdin().read(&mut b).unwrap_or(0) == 0 {
851 return Key::Char('q');
852 }
853 if b[0] == 0x1b {
854 let mut seq = [0u8; 2];
855 if std::io::stdin().read_exact(&mut seq).is_ok() && seq[0] == b'[' {
856 return match seq[1] {
857 b'A' => Key::Up,
858 b'B' => Key::Down,
859 _ => Key::Other,
860 };
861 }
862 return Key::Other;
863 }
864 Key::Char(char::from(b[0]))
865}
866
867fn ask_line(raw: &Raw, prompt: &str) -> String {
868 unsafe {
870 libc::tcsetattr(0, libc::TCSANOW, &raw.saved);
871 }
872 let (_, h) = term_size();
873 print!("\x1b[{h};1H\x1b[2K\x1b[?25h{prompt}");
874 let _ = std::io::stdout().flush();
875 let mut line = String::new();
876 let _ = std::io::stdin().read_line(&mut line);
877 unsafe {
879 let mut t = raw.saved;
880 t.c_lflag &= !(libc::ICANON | libc::ECHO);
881 libc::tcsetattr(0, libc::TCSANOW, &t);
882 }
883 line.trim().to_string()
884}
885
886fn draw(view: &View) {
887 let (w, h) = term_size();
888 let mut frame = String::from("\x1b[?25l\x1b[H\x1b[2J");
889 for (i, line) in render(view, w, h).iter().enumerate() {
890 if line.starts_with('>') {
891 frame.push_str(&format!("\x1b[{};1H\x1b[7m{line}\x1b[0m", i + 1));
892 } else {
893 frame.push_str(&format!("\x1b[{};1H{line}", i + 1));
894 }
895 }
896 print!("{frame}");
897 let _ = std::io::stdout().flush();
898}
899
900pub fn tui(filter: &Filter) -> Result<()> {
906 let atoms = live_atoms()?;
907 let mut view = View::new(list(
908 &atoms,
909 &reviews(),
910 &Filter {
911 reviewed: true,
912 user: filter.user,
913 ..Filter::default()
914 },
915 ));
916 view.seat.clone_from(&filter.seat);
917 view.since.clone_from(&filter.since);
918 view.show_reviewed = filter.reviewed;
919 let raw = Raw::on()?;
920 print!("\x1b[?1049h");
921 loop {
922 draw(&view);
923 let act = on_key(&mut view, read_key());
924 let all = view.all.clone();
925 let done = match act {
926 Act::None => continue,
927 Act::Quit => break,
928 Act::Keep(id) => keep(&id, &all).map(|l| (vec![id], "keep", l)),
929 Act::Drop(id) => drop_memory(&id, &all).map(|l| (vec![id], "drop", l)),
930 Act::Edit(id) => {
931 let text = ask_line(&raw, "new text (empty cancels): ");
932 if text.is_empty() {
933 continue;
934 }
935 edit(&id, &text, &all).map(|l| (vec![id], "edit", l))
936 }
937 Act::Merge(ids) => {
938 let text = ask_line(&raw, "merged text (empty joins them): ");
939 let text = (!text.is_empty()).then_some(text);
940 merge(&ids, text.as_deref(), &all).map(|l| (ids, "merge", l))
941 }
942 Act::Since => {
943 let d = ask_line(&raw, "since YYYY-MM-DD (empty clears): ");
944 view.since = (!d.is_empty()).then_some(d);
945 view.cursor = 0;
946 continue;
947 }
948 };
949 match done {
950 Ok((ids, verdict, line)) => {
951 for id in &ids {
952 view.set_verdict(id, verdict);
953 }
954 view.marked.clear();
955 view.status = line.trim().to_string();
956 }
957 Err(e) => view.status = format!("{e:#}"),
958 }
959 }
960 drop(raw);
961 Ok(())
962}
963
964pub fn review_interactive(all: &[Memory]) -> Result<String> {
972 let mut out = String::new();
973 let stdin = std::io::stdin();
974 for m in all.iter().filter(|m| m.verdict.is_none()) {
975 print!(
976 "{}[k]eep [e]dit [m]erge [d]rop [s]kip [q]uit? ",
977 describe(m)
978 );
979 std::io::stdout().flush()?;
980 let mut answer = String::new();
981 if stdin.lock().read_line(&mut answer)? == 0 {
982 break;
983 }
984 let said = match answer.trim() {
985 "k" | "keep" => keep(&m.id, all),
986 "d" | "drop" => drop_memory(&m.id, all),
987 "e" | "edit" => {
988 print!("new text: ");
989 std::io::stdout().flush()?;
990 let mut t = String::new();
991 stdin.lock().read_line(&mut t)?;
992 edit(&m.id, &t, all)
993 }
994 "m" | "merge" => {
995 print!("merge with id: ");
996 std::io::stdout().flush()?;
997 let mut other = String::new();
998 stdin.lock().read_line(&mut other)?;
999 merge(&[m.id.clone(), other.trim().to_string()], None, all)
1000 }
1001 "q" | "quit" => break,
1002 _ => continue,
1003 };
1004 match said {
1005 Ok(line) => {
1006 print!("{line}");
1007 out.push_str(&line);
1008 }
1009 Err(e) => println!("{e:#}"),
1010 }
1011 }
1012 Ok(out)
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018 use serde_json::json;
1019
1020 fn atom(id: &str, text: &str, seat: &str, at: &str, origin: &str) -> Value {
1021 json!({
1022 "id": id, "kind": "lesson", "text": text, "origin": origin,
1023 "entities": [format!("seat:{seat}")],
1024 "source": {"seat": seat, "harness": "codex", "session": format!("{seat}@s1"),
1025 "host": "box", "at": at, "reason": "the user said so", "via": "ljos"},
1026 })
1027 }
1028
1029 fn sample() -> Vec<Memory> {
1030 let atoms = vec![
1031 atom(
1032 "a1aaaaaaaaaaaaaa",
1033 "Run the linter first.",
1034 "codex",
1035 "2026-10-12T10:00:00Z",
1036 "agent-derived",
1037 ),
1038 atom(
1039 "b2bbbbbbbbbbbbbb",
1040 "Use uv for scripts.",
1041 "claude-code",
1042 "2026-10-14T09:00:00Z",
1043 "agent-derived",
1044 ),
1045 atom(
1046 "c3cccccccccccccc",
1047 "Typed by the person.",
1048 "rohit",
1049 "2026-10-13T09:00:00Z",
1050 "user-declared",
1051 ),
1052 json!({"id": "d4", "kind": "trust", "text": "x"}),
1053 ];
1054 list(
1055 &atoms,
1056 &BTreeMap::new(),
1057 &Filter {
1058 user: true,
1059 ..Filter::default()
1060 },
1061 )
1062 }
1063
1064 #[test]
1065 fn provenance_is_read_from_the_source() {
1066 let m = Memory::of(&atom(
1067 "a1aaaaaaaaaaaaaa",
1068 "t",
1069 "codex",
1070 "2026-10-12T10:00:00Z",
1071 "agent-derived",
1072 ))
1073 .unwrap();
1074 assert_eq!(m.seat, "codex");
1075 assert_eq!(m.harness, "codex");
1076 assert_eq!(m.session, "codex@s1");
1077 assert_eq!(m.at, "2026-10-12T10:00:00Z");
1078 assert_eq!(m.reason.as_deref(), Some("the user said so"));
1079 assert!(m.is_agents());
1080 assert!(m.basis.is_empty());
1081 let mut stamped = atom(
1082 "a1aaaaaaaaaaaaaa",
1083 "t",
1084 "codex",
1085 "2026-10-12T10:00:00Z",
1086 "user-declared",
1087 );
1088 stamped["source"]["basis"] = json!("person");
1089 let stamped = Memory::of(&stamped).unwrap();
1090 assert_eq!(stamped.basis, "person");
1091 assert!(!stamped.is_agents());
1092 }
1093
1094 #[test]
1095 fn the_listing_is_newest_first_agents_only_by_default_and_hides_reviewed() {
1096 let atoms: Vec<Value> = vec![
1097 atom(
1098 "a1aaaaaaaaaaaaaa",
1099 "old",
1100 "codex",
1101 "2026-10-12T10:00:00Z",
1102 "agent-derived",
1103 ),
1104 atom(
1105 "b2bbbbbbbbbbbbbb",
1106 "new",
1107 "codex",
1108 "2026-10-14T10:00:00Z",
1109 "agent-derived",
1110 ),
1111 atom(
1112 "c3cccccccccccccc",
1113 "mine",
1114 "rohit",
1115 "2026-10-15T10:00:00Z",
1116 "user-declared",
1117 ),
1118 ];
1119 let got = list(&atoms, &BTreeMap::new(), &Filter::default());
1120 assert_eq!(
1121 got.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(),
1122 ["new", "old"]
1123 );
1124 let mut rev = BTreeMap::new();
1125 rev.insert(
1126 "b2bbbbbbbbbbbbbb".to_string(),
1127 Review {
1128 id: "b2bbbbbbbbbbbbbb".into(),
1129 verdict: "keep".into(),
1130 at: String::new(),
1131 by: String::new(),
1132 into: None,
1133 },
1134 );
1135 let got = list(&atoms, &rev, &Filter::default());
1136 assert_eq!(got.len(), 1);
1137 assert_eq!(got[0].text, "old");
1138 let since = Filter {
1139 since: Some("2026-10-13".into()),
1140 reviewed: true,
1141 ..Filter::default()
1142 };
1143 assert_eq!(list(&atoms, &rev, &since).len(), 1);
1144 }
1145
1146 #[test]
1147 fn keep_records_a_verdict_and_an_ambiguous_id_is_refused() {
1148 let _g = crate::tests::env_guard();
1149 let dir = tempfile::tempdir().unwrap();
1150 let old = std::env::var_os("XDG_STATE_HOME");
1151 std::env::set_var("XDG_STATE_HOME", dir.path());
1152 let all = sample();
1153 assert!(keep("a1", &all).unwrap().starts_with("kept a1aaaaaa"));
1154 let r = reviews();
1155 assert_eq!(r["a1aaaaaaaaaaaaaa"].verdict, "keep");
1156 assert!(keep("zz", &all).is_err());
1157 let mut twin = all.clone();
1158 twin.push(Memory {
1159 id: "a1aaaaaaaaaaaaab".into(),
1160 ..all[0].clone()
1161 });
1162 assert!(resolve("a1", &twin).is_err());
1163 match old {
1164 Some(v) => std::env::set_var("XDG_STATE_HOME", v),
1165 None => std::env::remove_var("XDG_STATE_HOME"),
1166 }
1167 }
1168
1169 #[test]
1170 fn stale_names_duplicates_and_old_never_recalled_ones_but_not_kept_ones() {
1171 let _env = crate::test_env_lock();
1174 let dir = tempfile::tempdir().unwrap();
1175 std::env::set_var("XDG_STATE_HOME", dir.path());
1176 let mut all = sample();
1177 let mut dup = all[1].clone();
1178 dup.id = "e5eeeeeeeeeeeeee".into();
1179 dup.text = "use UV for scripts".into();
1180 dup.at = "2026-10-01T00:00:00Z".into();
1181 all.push(dup);
1182 let got = stale(&all, 30, "2026-10-20");
1183 assert_eq!(got.len(), 1, "{got:?}");
1184 assert_eq!(got[0].id, "e5eeeeeeeeeeeeee");
1185 assert!(got[0].why.starts_with("duplicate of b2bbbbbb"));
1186 let got = stale(&all, 3, "2026-10-20");
1187 assert!(got
1188 .iter()
1189 .any(|s| s.id == "a1aaaaaaaaaaaaaa" && s.why.starts_with("never recalled")));
1190 for m in &mut all {
1191 if m.id == "a1aaaaaaaaaaaaaa" {
1192 m.verdict = Some("keep".into());
1193 }
1194 }
1195 assert!(stale(&all, 3, "2026-10-20")
1196 .iter()
1197 .all(|s| s.id != "a1aaaaaaaaaaaaaa"));
1198 }
1199
1200 #[test]
1201 fn the_viewer_frame_shows_rows_cursor_marks_and_provenance() {
1202 let mut view = View::new(sample());
1203 let frame = render(&view, 90, 14);
1204 assert_eq!(frame.len(), 14);
1205 assert!(
1206 frame[0].starts_with("ljos memories 3 shown of 3 seat all"),
1207 "{}",
1208 frame[0]
1209 );
1210 assert!(
1211 frame[2].starts_with("> ") && frame[2].contains("Use uv for scripts."),
1212 "{frame:#?}"
1213 );
1214 assert!(frame
1215 .iter()
1216 .any(|l| l.contains("harness codex") && l.contains("session claude-code@s1")));
1217 assert!(frame.iter().any(|l| l.contains("why: the user said so")));
1218 on_key(&mut view, Key::Down);
1219 on_key(&mut view, Key::Char('m'));
1220 let frame = render(&view, 90, 14);
1221 assert!(frame[3].starts_with(">*"), "{frame:#?}");
1222 assert_eq!(on_key(&mut view, Key::Char('M')), Act::None);
1223 on_key(&mut view, Key::Up);
1224 on_key(&mut view, Key::Char('m'));
1225 assert!(matches!(on_key(&mut view, Key::Char('M')), Act::Merge(ids) if ids.len() == 2));
1226 assert_eq!(
1227 on_key(&mut view, Key::Char('d')),
1228 Act::Drop("b2bbbbbbbbbbbbbb".into())
1229 );
1230 view.set_verdict("b2bbbbbbbbbbbbbb", "drop");
1231 assert_eq!(
1232 view.rows().len(),
1233 2,
1234 "a reviewed row leaves the unreviewed view"
1235 );
1236 on_key(&mut view, Key::Char('s'));
1237 assert_eq!(view.seat.as_deref(), Some("claude-code"));
1238 assert!(render(&view, 90, 14)
1239 .iter()
1240 .any(|l| l.contains("nothing to review")));
1241 on_key(&mut view, Key::Char('s'));
1242 assert_eq!(view.seat.as_deref(), Some("codex"));
1243 assert_eq!(view.rows().len(), 1);
1244 assert_eq!(on_key(&mut view, Key::Char('q')), Act::Quit);
1245 }
1246
1247 #[test]
1248 fn a_prompt_excerpt_redacts_keys() {
1249 let got = crate::capture::excerpt_of(
1250 "from now on use key=abc and sk-live123 then ghp_AAAAAAAAAAAAAAAAAAAAAAA1 ok",
1251 );
1252 assert!(
1253 !got.contains("abc") && !got.contains("sk-live") && !got.contains("ghp_"),
1254 "{got}"
1255 );
1256 assert!(got.starts_with("from now on use [redacted]"));
1257 }
1258
1259 #[test]
1263 fn a_torn_review_line_keeps_the_verdicts_after_it() {
1264 let _g = crate::test_env_lock();
1265 let dir = tempfile::tempdir().unwrap();
1266 std::env::set_var("XDG_STATE_HOME", dir.path());
1267 let path = reviews_path();
1268 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1269 let row = |id: &str| format!(r#"{{"id":"{id}","verdict":"keep","at":"t","by":"s"}}"#);
1270 let mut bytes = format!("{}\n", row("a")).into_bytes();
1271 bytes.extend_from_slice(b"{\"id\":\"b\",\"verdict\":\"caf\xc3\n");
1272 bytes.extend_from_slice(format!("{}\n", row("c")).as_bytes());
1273 bytes.extend_from_slice(b"{\"id\":\"d\",\"verdict\":\"caf\xc3");
1274 std::fs::write(&path, bytes).unwrap();
1275 let got: Vec<String> = reviews().into_keys().collect();
1276 assert_eq!(got, ["a", "c"]);
1277 }
1278
1279 #[test]
1282 fn concurrent_review_rows_each_parse() {
1283 use std::os::unix::fs::PermissionsExt;
1284 let _g = crate::test_env_lock();
1285 let dir = tempfile::tempdir().unwrap();
1286 std::env::set_var("XDG_STATE_HOME", dir.path());
1287 const THREADS: usize = 32;
1288 const EACH: usize = 4;
1289 let mut handles = Vec::with_capacity(THREADS);
1290 for thread in 0..THREADS {
1291 handles.push(std::thread::spawn(move || {
1292 for n in 0..EACH {
1293 record(&format!("id-{thread}-{n}"), "keep", None).unwrap();
1294 }
1295 }));
1296 }
1297 for handle in handles {
1298 handle.join().expect("review writer");
1299 }
1300 let path = reviews_path();
1301 let text = std::fs::read_to_string(&path).unwrap();
1302 let rows: Vec<Review> = text
1303 .lines()
1304 .map(|line| {
1305 serde_json::from_str(line).unwrap_or_else(|err| {
1306 panic!("review row does not parse ({err}): {line}");
1307 })
1308 })
1309 .collect();
1310 assert_eq!(rows.len(), THREADS * EACH, "{text}");
1311 let mode = std::fs::symlink_metadata(&path)
1312 .unwrap()
1313 .permissions()
1314 .mode()
1315 & 0o777;
1316 assert_eq!(mode, 0o600, "review log mode {mode:o}");
1317 }
1318
1319 #[test]
1323 fn a_durable_verdict_waits_for_a_held_lock() {
1324 use std::os::unix::io::AsRawFd;
1325 let _g = crate::test_env_lock();
1326 let dir = tempfile::tempdir().unwrap();
1327 std::env::set_var("XDG_STATE_HOME", dir.path());
1328 let path = reviews_path();
1329 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1330 let held = std::fs::OpenOptions::new()
1331 .create(true)
1332 .append(true)
1333 .open(&path)
1334 .unwrap();
1335 assert_eq!(unsafe { libc::flock(held.as_raw_fd(), libc::LOCK_EX) }, 0);
1337 let releaser = std::thread::spawn(move || {
1338 std::thread::sleep(std::time::Duration::from_millis(150));
1339 unsafe { libc::flock(held.as_raw_fd(), libc::LOCK_UN) };
1341 drop(held);
1342 });
1343 record("a1aaaaaaaaaaaaaa", "keep", None).unwrap();
1344 releaser.join().unwrap();
1345 assert_eq!(reviews()["a1aaaaaaaaaaaaaa"].verdict, "keep");
1346 }
1347
1348 #[test]
1352 fn a_drop_records_the_verdict_before_the_pack_change() {
1353 let _g = crate::test_env_lock();
1354 let dir = tempfile::tempdir().unwrap();
1355 std::env::set_var("XDG_STATE_HOME", dir.path());
1356 let old = std::env::var_os("PACKSET_URL");
1357 std::env::set_var("PACKSET_URL", "off");
1358 let all = sample();
1359 assert!(drop_memory("a1", &all).is_err());
1360 assert_eq!(reviews()["a1aaaaaaaaaaaaaa"].verdict, "drop");
1361 match old {
1362 Some(v) => std::env::set_var("PACKSET_URL", v),
1363 None => std::env::remove_var("PACKSET_URL"),
1364 }
1365 }
1366
1367 #[test]
1368 fn a_recall_bumps_the_count_the_list_shows() {
1369 let _g = crate::test_env_lock();
1370 let dir = tempfile::tempdir().unwrap();
1371 std::env::set_var("XDG_STATE_HOME", dir.path());
1372 let atoms = vec![atom(
1373 "a1aaaaaaaaaaaaaa",
1374 "pin the fuse",
1375 "saddle",
1376 "2026-10-16T10:00:00Z",
1377 "agent-derived",
1378 )];
1379 let before = list(&atoms, &BTreeMap::new(), &Filter::default());
1380 assert_eq!(before[0].recalls, 0);
1381 bump_recalls(&["a1aaaaaaaaaaaaaa".into()]);
1382 let after = list(&atoms, &BTreeMap::new(), &Filter::default());
1383 assert_eq!(after[0].recalls, 1);
1384 assert!(
1385 describe(&after[0]).contains("recalled 1x"),
1386 "{}",
1387 describe(&after[0])
1388 );
1389 std::env::remove_var("XDG_STATE_HOME");
1390 }
1391}