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