1use std::collections::{BTreeMap, BTreeSet};
16use std::io::{BufRead, Write};
17use std::path::PathBuf;
18
19use anyhow::{bail, Context, Result};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23pub const REVIEW_KINDS: &[&str] = &["lesson", "preference", "rule", "fact", "note", "episode"];
25
26#[must_use]
29pub fn write_reason() -> Option<String> {
30 std::env::var("LJOS_MEMORY_WHY")
31 .ok()
32 .map(|w| crate::capture::excerpt_of(&w))
33 .filter(|w| !w.is_empty())
34 .or_else(crate::capture::latest_prompt_excerpt)
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct Memory {
40 pub id: String,
41 pub text: String,
42 pub kind: String,
43 pub origin: String,
44 pub seat: String,
45 pub harness: String,
46 pub session: String,
47 pub host: String,
48 pub at: String,
50 pub reason: Option<String>,
51 pub recalls: u64,
53 pub verdict: Option<String>,
55}
56
57fn s(v: &Value) -> String {
58 v.as_str().unwrap_or("").to_string()
59}
60
61impl Memory {
62 #[must_use]
64 pub fn of(atom: &Value) -> Option<Self> {
65 let kind = s(&atom["kind"]);
66 if !REVIEW_KINDS.contains(&kind.as_str()) || atom["tombstone"] == Value::Bool(true) {
67 return None;
68 }
69 let id = s(&atom["id"]);
70 if id.is_empty() {
71 return None;
72 }
73 let src = &atom["source"];
74 let entity_seat = atom["entities"].as_array().and_then(|e| {
75 e.iter()
76 .filter_map(Value::as_str)
77 .find_map(|t| t.strip_prefix(crate::SEAT_ENTITY))
78 .map(str::to_string)
79 });
80 let seat = Some(s(&src["seat"]))
81 .filter(|x| !x.is_empty())
82 .or(entity_seat)
83 .unwrap_or_else(|| s(&src["harness"]));
84 let at = Some(s(&src["at"]))
85 .filter(|x| !x.is_empty())
86 .unwrap_or_else(|| s(&atom["ts"]));
87 let origin = Some(s(&atom["origin"]))
88 .filter(|x| !x.is_empty())
89 .unwrap_or_else(|| crate::admit::ORIGIN_USER.to_string());
90 Some(Self {
91 id,
92 text: s(&atom["text"]),
93 kind,
94 origin,
95 harness: Some(s(&src["harness"]))
96 .filter(|x| !x.is_empty())
97 .unwrap_or_else(|| seat.clone()),
98 seat,
99 session: s(&src["session"]),
100 host: s(&src["host"]),
101 at,
102 reason: src["reason"].as_str().map(str::to_string),
103 recalls: atom["review"]["reps"].as_u64().unwrap_or(0),
104 verdict: None,
105 })
106 }
107
108 #[must_use]
109 pub fn short_id(&self) -> &str {
110 self.id.get(..8).unwrap_or(&self.id)
111 }
112
113 #[must_use]
115 pub fn day(&self) -> &str {
116 self.at.get(..10).unwrap_or(&self.at)
117 }
118
119 #[must_use]
120 pub fn is_agents(&self) -> bool {
121 self.origin != crate::admit::ORIGIN_USER
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct Review {
128 pub id: String,
129 pub verdict: String,
131 pub at: String,
132 pub by: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub into: Option<String>,
136}
137
138#[must_use]
140pub fn reviews_path() -> PathBuf {
141 std::env::var_os("XDG_STATE_HOME")
142 .map(PathBuf::from)
143 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
144 .unwrap_or_else(|| PathBuf::from(".local/state"))
145 .join("ljos/memory-reviews.jsonl")
146}
147
148#[must_use]
150pub fn reviews() -> BTreeMap<String, Review> {
151 let mut out = BTreeMap::new();
152 let Ok(f) = std::fs::File::open(reviews_path()) else {
153 return out;
154 };
155 for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
156 if let Ok(r) = serde_json::from_str::<Review>(&line) {
157 out.insert(r.id.clone(), r);
158 }
159 }
160 out
161}
162
163pub fn record(id: &str, verdict: &str, into: Option<&str>) -> Result<Review> {
169 let r = Review {
170 id: id.to_string(),
171 verdict: verdict.to_string(),
172 at: crate::now_utc(),
173 by: crate::seat_name(),
174 into: into.map(str::to_string),
175 };
176 let path = reviews_path();
177 if let Some(dir) = path.parent() {
178 std::fs::create_dir_all(dir)?;
179 }
180 let mut f = std::fs::OpenOptions::new()
181 .create(true)
182 .append(true)
183 .open(&path)
184 .with_context(|| format!("memories: cannot write {}", path.display()))?;
185 writeln!(f, "{}", serde_json::to_string(&r)?)?;
186 Ok(r)
187}
188
189#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct Filter {
192 pub seat: Option<String>,
193 pub since: Option<String>,
195 pub reviewed: bool,
197 pub user: bool,
199}
200
201impl Filter {
202 #[must_use]
203 pub fn admits(&self, m: &Memory) -> bool {
204 (self.user || m.is_agents())
205 && (self.reviewed || m.verdict.is_none())
206 && self.seat.as_deref().is_none_or(|seat| m.seat == seat)
207 && self.since.as_deref().is_none_or(|d| m.day() >= d)
208 }
209}
210
211#[must_use]
215pub fn recall_uses_path() -> PathBuf {
216 reviews_path()
217 .parent()
218 .map(|dir| dir.join("recall-uses.json"))
219 .unwrap_or_else(|| PathBuf::from("recall-uses.json"))
220}
221
222#[must_use]
224pub fn recall_uses() -> BTreeMap<String, u64> {
225 let Ok(text) = std::fs::read_to_string(recall_uses_path()) else {
226 return BTreeMap::new();
227 };
228 serde_json::from_str(&text).unwrap_or_default()
229}
230
231pub fn bump_recalls(ids: &[String]) {
234 if ids.is_empty() {
235 return;
236 }
237 let path = recall_uses_path();
238 if let Some(dir) = path.parent() {
239 let _ = std::fs::create_dir_all(dir);
240 }
241 let mut uses = recall_uses();
242 for id in ids {
243 if id.is_empty() {
244 continue;
245 }
246 *uses.entry(id.clone()).or_default() += 1;
247 }
248 if let Ok(text) = serde_json::to_string(&uses) {
249 let _ = std::fs::write(path, text);
250 }
251}
252
253#[must_use]
256pub fn list(atoms: &[Value], reviews: &BTreeMap<String, Review>, filter: &Filter) -> Vec<Memory> {
257 let uses = recall_uses();
258 let mut out: Vec<Memory> = atoms
259 .iter()
260 .filter_map(Memory::of)
261 .map(|mut m| {
262 m.verdict = reviews.get(&m.id).map(|r| r.verdict.clone());
263 m.recalls = m
264 .recalls
265 .saturating_add(uses.get(&m.id).copied().unwrap_or(0));
266 m
267 })
268 .filter(|m| filter.admits(m))
269 .collect();
270 out.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
271 out
272}
273
274pub fn live_atoms() -> Result<Vec<Value>> {
280 let client = crate::pack()?;
281 let ws = client.workspace();
282 crate::atoms_lean(&client, &ws)
283}
284
285pub fn resolve(short: &str, all: &[Memory]) -> Result<String> {
291 let short = short.trim();
292 let hits: Vec<&Memory> = all.iter().filter(|m| m.id.starts_with(short)).collect();
293 match hits.as_slice() {
294 [one] => Ok(one.id.clone()),
295 [] => bail!("memories: no memory starts with {short}"),
296 _ => bail!(
297 "memories: {short} starts {} memories; give more of the id",
298 hits.len()
299 ),
300 }
301}
302
303fn find<'a>(id: &str, all: &'a [Memory]) -> Result<&'a Memory> {
304 let full = resolve(id, all)?;
305 all.iter()
306 .find(|m| m.id == full)
307 .context("memories: the id vanished")
308}
309
310pub fn keep(id: &str, all: &[Memory]) -> Result<String> {
316 let m = find(id, all)?;
317 record(&m.id, "keep", None)?;
318 Ok(format!("kept {}\n", m.short_id()))
319}
320
321pub fn drop_memory(id: &str, all: &[Memory]) -> Result<String> {
327 let m = find(id, all)?;
328 crate::packset_forget(&m.id, None)?;
329 record(&m.id, "drop", None)?;
330 Ok(format!("dropped {}\n", m.short_id()))
331}
332
333fn replace(olds: &[&Memory], text: &str, verdict: &str) -> Result<String> {
336 let text = text.trim();
337 if text.is_empty() {
338 bail!("memories: {verdict} needs a text");
339 }
340 let first = olds.first().context("memories: nothing to replace")?;
341 let client = crate::pack()?;
342 let ws = client.workspace();
343 let mut atom = crate::atom_body(&first.kind, text, &ws);
344 atom["source"]["seat"] = Value::String(first.seat.clone());
345 atom["source"]["harness"] = Value::String(first.harness.clone());
346 atom["source"]["session"] = Value::String(first.session.clone());
347 atom["source"]["reviewed_by"] = Value::String(crate::seat_name());
348 atom["source"]["replaces"] =
349 Value::Array(olds.iter().map(|m| Value::String(m.id.clone())).collect());
350 if let Some(reason) = &first.reason {
351 atom["source"]["reason"] = Value::String(reason.clone());
352 }
353 crate::add_entities(&mut atom, ["horizon:standing".to_string()]);
354 let posted = if first.is_agents() {
355 crate::admit::write_agent(&client, atom)?
356 } else {
357 crate::admit::stamp_origin(&mut atom, crate::admit::ORIGIN_USER);
358 crate::admit::post_kept(&client, &atom)?
359 };
360 let new_id = s(&posted["id"]);
361 for m in olds {
362 if m.id != new_id {
363 crate::packset_forget(&m.id, None)?;
364 }
365 record(&m.id, verdict, Some(&new_id))?;
366 }
367 if !new_id.is_empty() {
368 record(&new_id, "keep", None)?;
369 }
370 Ok(format!(
371 "{verdict} {} into {}\n",
372 olds.iter()
373 .map(|m| m.short_id())
374 .collect::<Vec<_>>()
375 .join(", "),
376 new_id.get(..8).unwrap_or(&new_id)
377 ))
378}
379
380pub fn edit(id: &str, text: &str, all: &[Memory]) -> Result<String> {
386 let m = find(id, all)?;
387 replace(&[m], text, "edited")
388}
389
390pub fn merge(ids: &[String], text: Option<&str>, all: &[Memory]) -> Result<String> {
397 if ids.len() < 2 {
398 bail!("memories: merge takes two ids or more");
399 }
400 let olds: Vec<&Memory> = ids.iter().map(|id| find(id, all)).collect::<Result<_>>()?;
401 let joined;
402 let text = if let Some(t) = text {
403 t
404 } else {
405 joined = olds
406 .iter()
407 .map(|m| m.text.trim())
408 .collect::<Vec<_>>()
409 .join(" ");
410 &joined
411 };
412 replace(&olds, text, "merged")
413}
414
415#[must_use]
417pub fn describe(m: &Memory) -> String {
418 let mut out = format!("{} {} {}\n", m.short_id(), m.kind, m.text);
419 out.push_str(&format!(
420 " seat {} harness {} session {} at {} origin {} recalled {}x{}\n",
421 dash(&m.seat),
422 dash(&m.harness),
423 dash(&m.session),
424 dash(&m.at),
425 m.origin,
426 m.recalls,
427 m.verdict
428 .as_deref()
429 .map(|v| format!(" verdict {v}"))
430 .unwrap_or_default()
431 ));
432 if let Some(reason) = &m.reason {
433 out.push_str(&format!(" why: {reason}\n"));
434 }
435 out
436}
437
438fn dash(x: &str) -> &str {
439 if x.is_empty() {
440 "-"
441 } else {
442 x
443 }
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
448pub struct Stale {
449 pub id: String,
450 pub text: String,
451 pub why: String,
452}
453
454fn fold(text: &str) -> String {
455 text.to_lowercase()
456 .chars()
457 .map(|c| if c.is_alphanumeric() { c } else { ' ' })
458 .collect::<String>()
459 .split_whitespace()
460 .collect::<Vec<_>>()
461 .join(" ")
462}
463
464#[must_use]
468pub fn stale(all: &[Memory], days: i64, today: &str) -> Vec<Stale> {
469 let mut out = Vec::new();
470 let mut seen: BTreeMap<String, &Memory> = BTreeMap::new();
471 let mut newest_first: Vec<&Memory> = all.iter().collect();
472 newest_first.sort_by(|a, b| b.at.cmp(&a.at));
473 let mut dropped = BTreeSet::new();
474 for m in &newest_first {
475 let key = format!("{}\n{}", m.kind, fold(&m.text));
476 if let Some(newer) = seen.get(&key) {
477 if m.verdict.as_deref() != Some("keep") {
478 out.push(Stale {
479 id: m.id.clone(),
480 text: m.text.clone(),
481 why: format!("duplicate of {}", newer.short_id()),
482 });
483 dropped.insert(m.id.clone());
484 }
485 } else {
486 seen.insert(key, m);
487 }
488 }
489 let cutoff = chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d")
490 .ok()
491 .map(|d| {
492 (d - chrono::Duration::days(days))
493 .format("%Y-%m-%d")
494 .to_string()
495 });
496 if let Some(cutoff) = cutoff {
497 for m in &newest_first {
498 if dropped.contains(&m.id) || m.verdict.as_deref() == Some("keep") {
499 continue;
500 }
501 if m.recalls == 0 && !m.at.is_empty() && m.day() < cutoff.as_str() {
502 out.push(Stale {
503 id: m.id.clone(),
504 text: m.text.clone(),
505 why: format!("never recalled since {}", m.day()),
506 });
507 }
508 }
509 }
510 out
511}
512
513#[derive(Debug, Clone, Default)]
517pub struct View {
518 pub all: Vec<Memory>,
519 pub seat: Option<String>,
520 pub since: Option<String>,
521 pub show_reviewed: bool,
522 pub cursor: usize,
523 pub marked: BTreeSet<String>,
524 pub status: String,
525}
526
527impl View {
528 #[must_use]
529 pub fn new(all: Vec<Memory>) -> Self {
530 Self {
531 all,
532 status: "j/k move a keep d drop e edit m mark M merge s seat f since v reviewed q quit".into(),
533 ..Self::default()
534 }
535 }
536
537 fn filter(&self) -> Filter {
538 Filter {
539 seat: self.seat.clone(),
540 since: self.since.clone(),
541 reviewed: self.show_reviewed,
542 user: true,
543 }
544 }
545
546 #[must_use]
548 pub fn rows(&self) -> Vec<&Memory> {
549 let f = self.filter();
550 self.all.iter().filter(|m| f.admits(m)).collect()
551 }
552
553 #[must_use]
554 pub fn current(&self) -> Option<&Memory> {
555 self.rows().get(self.cursor).copied()
556 }
557
558 pub fn clamp(&mut self) {
559 let n = self.rows().len();
560 self.cursor = self.cursor.min(n.saturating_sub(1));
561 }
562
563 pub fn down(&mut self) {
564 self.cursor += 1;
565 self.clamp();
566 }
567
568 pub fn up(&mut self) {
569 self.cursor = self.cursor.saturating_sub(1);
570 }
571
572 #[must_use]
574 pub fn seats(&self) -> Vec<String> {
575 self.all
576 .iter()
577 .map(|m| m.seat.clone())
578 .collect::<BTreeSet<_>>()
579 .into_iter()
580 .collect()
581 }
582
583 pub fn cycle_seat(&mut self) {
585 let seats = self.seats();
586 self.seat = match &self.seat {
587 None => seats.first().cloned(),
588 Some(cur) => seats
589 .iter()
590 .position(|s| s == cur)
591 .and_then(|i| seats.get(i + 1).cloned()),
592 };
593 self.cursor = 0;
594 }
595
596 pub fn toggle_mark(&mut self) {
597 if let Some(id) = self.current().map(|m| m.id.clone()) {
598 if !self.marked.remove(&id) {
599 self.marked.insert(id);
600 }
601 }
602 }
603
604 pub fn set_verdict(&mut self, id: &str, verdict: &str) {
606 for m in &mut self.all {
607 if m.id == id {
608 m.verdict = Some(verdict.to_string());
609 }
610 }
611 self.clamp();
612 }
613}
614
615fn clip(text: &str, width: usize) -> String {
616 if text.chars().count() <= width {
617 return text.to_string();
618 }
619 let mut out: String = text.chars().take(width.saturating_sub(1)).collect();
620 out.push('…');
621 out
622}
623
624#[must_use]
628pub fn render(view: &View, width: usize, height: usize) -> Vec<String> {
629 let width = width.max(20);
630 let height = height.max(8);
631 let rows = view.rows();
632 let mut out = vec![clip(
633 &format!(
634 "ljos memories {} shown of {} seat {} since {} {}",
635 rows.len(),
636 view.all.len(),
637 view.seat.as_deref().unwrap_or("all"),
638 view.since.as_deref().unwrap_or("any"),
639 if view.show_reviewed {
640 "all verdicts"
641 } else {
642 "unreviewed"
643 }
644 ),
645 width,
646 )];
647 out.push("─".repeat(width));
648 let list_h = height.saturating_sub(8).max(1);
650 let start = view.cursor.saturating_sub(list_h.saturating_sub(1));
651 for (i, m) in rows.iter().enumerate().skip(start).take(list_h) {
652 let pointer = if i == view.cursor { '>' } else { ' ' };
653 let mark = if view.marked.contains(&m.id) {
654 '*'
655 } else {
656 ' '
657 };
658 let verdict = m.verdict.as_deref().unwrap_or("new");
659 out.push(clip(
660 &format!(
661 "{pointer}{mark} {} {} {:<12} {:<5} {}",
662 m.short_id(),
663 m.day(),
664 clip(&m.seat, 12),
665 clip(verdict, 5),
666 m.text
667 ),
668 width,
669 ));
670 }
671 if rows.is_empty() {
672 out.push(" nothing to review".into());
673 }
674 while out.len() < 2 + list_h {
675 out.push(String::new());
676 }
677 out.push("─".repeat(width));
678 match view.current() {
679 Some(m) => {
680 out.push(clip(&m.text, width));
681 out.push(clip(
682 &format!(
683 "seat {} harness {} session {} host {}",
684 dash(&m.seat),
685 dash(&m.harness),
686 dash(&m.session),
687 dash(&m.host)
688 ),
689 width,
690 ));
691 out.push(clip(
692 &format!(
693 "at {} origin {} recalled {}x why: {}",
694 dash(&m.at),
695 m.origin,
696 m.recalls,
697 m.reason.as_deref().unwrap_or("-")
698 ),
699 width,
700 ));
701 }
702 None => out.extend([String::new(), String::new(), String::new()]),
703 }
704 out.push("─".repeat(width));
705 out.push(clip(&view.status, width));
706 out.truncate(height);
707 out
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum Key {
713 Up,
714 Down,
715 Char(char),
716 Other,
717}
718
719#[derive(Debug, Clone, PartialEq, Eq)]
721pub enum Act {
722 None,
723 Quit,
724 Keep(String),
725 Drop(String),
726 Edit(String),
728 Merge(Vec<String>),
729 Since,
731}
732
733pub fn on_key(view: &mut View, key: Key) -> Act {
735 let current = view.current().map(|m| m.id.clone());
736 match key {
737 Key::Up | Key::Char('k') => view.up(),
738 Key::Down | Key::Char('j') => view.down(),
739 Key::Char('q') => return Act::Quit,
740 Key::Char('s') => view.cycle_seat(),
741 Key::Char('v') => {
742 view.show_reviewed = !view.show_reviewed;
743 view.cursor = 0;
744 }
745 Key::Char('m') => view.toggle_mark(),
746 Key::Char('f') => return Act::Since,
747 Key::Char('a') => return current.map_or(Act::None, Act::Keep),
748 Key::Char('d') => return current.map_or(Act::None, Act::Drop),
749 Key::Char('e') => return current.map_or(Act::None, Act::Edit),
750 Key::Char('M') => {
751 if view.marked.len() >= 2 {
752 return Act::Merge(view.marked.iter().cloned().collect());
753 }
754 view.status = "mark two memories or more with m, then M".into();
755 }
756 _ => {}
757 }
758 Act::None
759}
760
761struct Raw {
763 saved: libc::termios,
764}
765
766impl Raw {
767 fn on() -> Result<Self> {
768 unsafe {
771 let mut t: libc::termios = std::mem::zeroed();
772 if libc::tcgetattr(0, &mut t) != 0 {
773 bail!("memories tui: stdin is not a terminal");
774 }
775 let saved = t;
776 t.c_lflag &= !(libc::ICANON | libc::ECHO);
777 t.c_cc[libc::VMIN] = 1;
778 t.c_cc[libc::VTIME] = 0;
779 libc::tcsetattr(0, libc::TCSANOW, &t);
780 Ok(Self { saved })
781 }
782 }
783}
784
785impl Drop for Raw {
786 fn drop(&mut self) {
787 unsafe {
789 libc::tcsetattr(0, libc::TCSANOW, &self.saved);
790 }
791 print!("\x1b[?25h\x1b[?1049l");
792 let _ = std::io::stdout().flush();
793 }
794}
795
796fn term_size() -> (usize, usize) {
797 unsafe {
799 let mut ws: libc::winsize = std::mem::zeroed();
800 if libc::ioctl(1, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 {
801 return (usize::from(ws.ws_col), usize::from(ws.ws_row));
802 }
803 }
804 (100, 30)
805}
806
807fn read_key() -> Key {
808 use std::io::Read;
809 let mut b = [0u8; 1];
810 if std::io::stdin().read(&mut b).unwrap_or(0) == 0 {
811 return Key::Char('q');
812 }
813 if b[0] == 0x1b {
814 let mut seq = [0u8; 2];
815 if std::io::stdin().read_exact(&mut seq).is_ok() && seq[0] == b'[' {
816 return match seq[1] {
817 b'A' => Key::Up,
818 b'B' => Key::Down,
819 _ => Key::Other,
820 };
821 }
822 return Key::Other;
823 }
824 Key::Char(char::from(b[0]))
825}
826
827fn ask_line(raw: &Raw, prompt: &str) -> String {
828 unsafe {
830 libc::tcsetattr(0, libc::TCSANOW, &raw.saved);
831 }
832 let (_, h) = term_size();
833 print!("\x1b[{h};1H\x1b[2K\x1b[?25h{prompt}");
834 let _ = std::io::stdout().flush();
835 let mut line = String::new();
836 let _ = std::io::stdin().read_line(&mut line);
837 unsafe {
839 let mut t = raw.saved;
840 t.c_lflag &= !(libc::ICANON | libc::ECHO);
841 libc::tcsetattr(0, libc::TCSANOW, &t);
842 }
843 line.trim().to_string()
844}
845
846fn draw(view: &View) {
847 let (w, h) = term_size();
848 let mut frame = String::from("\x1b[?25l\x1b[H\x1b[2J");
849 for (i, line) in render(view, w, h).iter().enumerate() {
850 if line.starts_with('>') {
851 frame.push_str(&format!("\x1b[{};1H\x1b[7m{line}\x1b[0m", i + 1));
852 } else {
853 frame.push_str(&format!("\x1b[{};1H{line}", i + 1));
854 }
855 }
856 print!("{frame}");
857 let _ = std::io::stdout().flush();
858}
859
860pub fn tui(filter: &Filter) -> Result<()> {
866 let atoms = live_atoms()?;
867 let mut view = View::new(list(
868 &atoms,
869 &reviews(),
870 &Filter {
871 reviewed: true,
872 user: filter.user,
873 ..Filter::default()
874 },
875 ));
876 view.seat.clone_from(&filter.seat);
877 view.since.clone_from(&filter.since);
878 view.show_reviewed = filter.reviewed;
879 let raw = Raw::on()?;
880 print!("\x1b[?1049h");
881 loop {
882 draw(&view);
883 let act = on_key(&mut view, read_key());
884 let all = view.all.clone();
885 let done = match act {
886 Act::None => continue,
887 Act::Quit => break,
888 Act::Keep(id) => keep(&id, &all).map(|l| (vec![id], "keep", l)),
889 Act::Drop(id) => drop_memory(&id, &all).map(|l| (vec![id], "drop", l)),
890 Act::Edit(id) => {
891 let text = ask_line(&raw, "new text (empty cancels): ");
892 if text.is_empty() {
893 continue;
894 }
895 edit(&id, &text, &all).map(|l| (vec![id], "edit", l))
896 }
897 Act::Merge(ids) => {
898 let text = ask_line(&raw, "merged text (empty joins them): ");
899 let text = (!text.is_empty()).then_some(text);
900 merge(&ids, text.as_deref(), &all).map(|l| (ids, "merge", l))
901 }
902 Act::Since => {
903 let d = ask_line(&raw, "since YYYY-MM-DD (empty clears): ");
904 view.since = (!d.is_empty()).then_some(d);
905 view.cursor = 0;
906 continue;
907 }
908 };
909 match done {
910 Ok((ids, verdict, line)) => {
911 for id in &ids {
912 view.set_verdict(id, verdict);
913 }
914 view.marked.clear();
915 view.status = line.trim().to_string();
916 }
917 Err(e) => view.status = format!("{e:#}"),
918 }
919 }
920 drop(raw);
921 Ok(())
922}
923
924pub fn review_interactive(all: &[Memory]) -> Result<String> {
932 let mut out = String::new();
933 let stdin = std::io::stdin();
934 for m in all.iter().filter(|m| m.verdict.is_none()) {
935 print!(
936 "{}[k]eep [e]dit [m]erge [d]rop [s]kip [q]uit? ",
937 describe(m)
938 );
939 std::io::stdout().flush()?;
940 let mut answer = String::new();
941 if stdin.lock().read_line(&mut answer)? == 0 {
942 break;
943 }
944 let said = match answer.trim() {
945 "k" | "keep" => keep(&m.id, all),
946 "d" | "drop" => drop_memory(&m.id, all),
947 "e" | "edit" => {
948 print!("new text: ");
949 std::io::stdout().flush()?;
950 let mut t = String::new();
951 stdin.lock().read_line(&mut t)?;
952 edit(&m.id, &t, all)
953 }
954 "m" | "merge" => {
955 print!("merge with id: ");
956 std::io::stdout().flush()?;
957 let mut other = String::new();
958 stdin.lock().read_line(&mut other)?;
959 merge(&[m.id.clone(), other.trim().to_string()], None, all)
960 }
961 "q" | "quit" => break,
962 _ => continue,
963 };
964 match said {
965 Ok(line) => {
966 print!("{line}");
967 out.push_str(&line);
968 }
969 Err(e) => println!("{e:#}"),
970 }
971 }
972 Ok(out)
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use serde_json::json;
979
980 fn atom(id: &str, text: &str, seat: &str, at: &str, origin: &str) -> Value {
981 json!({
982 "id": id, "kind": "lesson", "text": text, "origin": origin,
983 "entities": [format!("seat:{seat}")],
984 "source": {"seat": seat, "harness": "codex", "session": format!("{seat}@s1"),
985 "host": "box", "at": at, "reason": "the user said so", "via": "ljos"},
986 })
987 }
988
989 fn sample() -> Vec<Memory> {
990 let atoms = vec![
991 atom(
992 "a1aaaaaaaaaaaaaa",
993 "Run the linter first.",
994 "codex",
995 "2026-10-12T10:00:00Z",
996 "agent-derived",
997 ),
998 atom(
999 "b2bbbbbbbbbbbbbb",
1000 "Use uv for scripts.",
1001 "claude-code",
1002 "2026-10-14T09:00:00Z",
1003 "agent-derived",
1004 ),
1005 atom(
1006 "c3cccccccccccccc",
1007 "Typed by the person.",
1008 "rohit",
1009 "2026-10-13T09:00:00Z",
1010 "user-declared",
1011 ),
1012 json!({"id": "d4", "kind": "trust", "text": "x"}),
1013 ];
1014 list(
1015 &atoms,
1016 &BTreeMap::new(),
1017 &Filter {
1018 user: true,
1019 ..Filter::default()
1020 },
1021 )
1022 }
1023
1024 #[test]
1025 fn provenance_is_read_from_the_source() {
1026 let m = Memory::of(&atom(
1027 "a1aaaaaaaaaaaaaa",
1028 "t",
1029 "codex",
1030 "2026-10-12T10:00:00Z",
1031 "agent-derived",
1032 ))
1033 .unwrap();
1034 assert_eq!(m.seat, "codex");
1035 assert_eq!(m.harness, "codex");
1036 assert_eq!(m.session, "codex@s1");
1037 assert_eq!(m.at, "2026-10-12T10:00:00Z");
1038 assert_eq!(m.reason.as_deref(), Some("the user said so"));
1039 assert!(m.is_agents());
1040 }
1041
1042 #[test]
1043 fn the_listing_is_newest_first_agents_only_by_default_and_hides_reviewed() {
1044 let atoms: Vec<Value> = vec![
1045 atom(
1046 "a1aaaaaaaaaaaaaa",
1047 "old",
1048 "codex",
1049 "2026-10-12T10:00:00Z",
1050 "agent-derived",
1051 ),
1052 atom(
1053 "b2bbbbbbbbbbbbbb",
1054 "new",
1055 "codex",
1056 "2026-10-14T10:00:00Z",
1057 "agent-derived",
1058 ),
1059 atom(
1060 "c3cccccccccccccc",
1061 "mine",
1062 "rohit",
1063 "2026-10-15T10:00:00Z",
1064 "user-declared",
1065 ),
1066 ];
1067 let got = list(&atoms, &BTreeMap::new(), &Filter::default());
1068 assert_eq!(
1069 got.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(),
1070 ["new", "old"]
1071 );
1072 let mut rev = BTreeMap::new();
1073 rev.insert(
1074 "b2bbbbbbbbbbbbbb".to_string(),
1075 Review {
1076 id: "b2bbbbbbbbbbbbbb".into(),
1077 verdict: "keep".into(),
1078 at: String::new(),
1079 by: String::new(),
1080 into: None,
1081 },
1082 );
1083 let got = list(&atoms, &rev, &Filter::default());
1084 assert_eq!(got.len(), 1);
1085 assert_eq!(got[0].text, "old");
1086 let since = Filter {
1087 since: Some("2026-10-13".into()),
1088 reviewed: true,
1089 ..Filter::default()
1090 };
1091 assert_eq!(list(&atoms, &rev, &since).len(), 1);
1092 }
1093
1094 #[test]
1095 fn keep_records_a_verdict_and_an_ambiguous_id_is_refused() {
1096 let _g = crate::tests::env_guard();
1097 let dir = tempfile::tempdir().unwrap();
1098 let old = std::env::var_os("XDG_STATE_HOME");
1099 std::env::set_var("XDG_STATE_HOME", dir.path());
1100 let all = sample();
1101 assert!(keep("a1", &all).unwrap().starts_with("kept a1aaaaaa"));
1102 let r = reviews();
1103 assert_eq!(r["a1aaaaaaaaaaaaaa"].verdict, "keep");
1104 assert!(keep("zz", &all).is_err());
1105 let mut twin = all.clone();
1106 twin.push(Memory {
1107 id: "a1aaaaaaaaaaaaab".into(),
1108 ..all[0].clone()
1109 });
1110 assert!(resolve("a1", &twin).is_err());
1111 match old {
1112 Some(v) => std::env::set_var("XDG_STATE_HOME", v),
1113 None => std::env::remove_var("XDG_STATE_HOME"),
1114 }
1115 }
1116
1117 #[test]
1118 fn stale_names_duplicates_and_old_never_recalled_ones_but_not_kept_ones() {
1119 let mut all = sample();
1120 let mut dup = all[1].clone();
1121 dup.id = "e5eeeeeeeeeeeeee".into();
1122 dup.text = "use UV for scripts".into();
1123 dup.at = "2026-10-01T00:00:00Z".into();
1124 all.push(dup);
1125 let got = stale(&all, 30, "2026-10-20");
1126 assert_eq!(got.len(), 1, "{got:?}");
1127 assert_eq!(got[0].id, "e5eeeeeeeeeeeeee");
1128 assert!(got[0].why.starts_with("duplicate of b2bbbbbb"));
1129 let got = stale(&all, 3, "2026-10-20");
1130 assert!(got
1131 .iter()
1132 .any(|s| s.id == "a1aaaaaaaaaaaaaa" && s.why.starts_with("never recalled")));
1133 for m in &mut all {
1134 if m.id == "a1aaaaaaaaaaaaaa" {
1135 m.verdict = Some("keep".into());
1136 }
1137 }
1138 assert!(stale(&all, 3, "2026-10-20")
1139 .iter()
1140 .all(|s| s.id != "a1aaaaaaaaaaaaaa"));
1141 }
1142
1143 #[test]
1144 fn the_viewer_frame_shows_rows_cursor_marks_and_provenance() {
1145 let mut view = View::new(sample());
1146 let frame = render(&view, 90, 14);
1147 assert_eq!(frame.len(), 14);
1148 assert!(
1149 frame[0].starts_with("ljos memories 3 shown of 3 seat all"),
1150 "{}",
1151 frame[0]
1152 );
1153 assert!(
1154 frame[2].starts_with("> ") && frame[2].contains("Use uv for scripts."),
1155 "{frame:#?}"
1156 );
1157 assert!(frame
1158 .iter()
1159 .any(|l| l.contains("harness codex") && l.contains("session claude-code@s1")));
1160 assert!(frame.iter().any(|l| l.contains("why: the user said so")));
1161 on_key(&mut view, Key::Down);
1162 on_key(&mut view, Key::Char('m'));
1163 let frame = render(&view, 90, 14);
1164 assert!(frame[3].starts_with(">*"), "{frame:#?}");
1165 assert_eq!(on_key(&mut view, Key::Char('M')), Act::None);
1166 on_key(&mut view, Key::Up);
1167 on_key(&mut view, Key::Char('m'));
1168 assert!(matches!(on_key(&mut view, Key::Char('M')), Act::Merge(ids) if ids.len() == 2));
1169 assert_eq!(
1170 on_key(&mut view, Key::Char('d')),
1171 Act::Drop("b2bbbbbbbbbbbbbb".into())
1172 );
1173 view.set_verdict("b2bbbbbbbbbbbbbb", "drop");
1174 assert_eq!(
1175 view.rows().len(),
1176 2,
1177 "a reviewed row leaves the unreviewed view"
1178 );
1179 on_key(&mut view, Key::Char('s'));
1180 assert_eq!(view.seat.as_deref(), Some("claude-code"));
1181 assert!(render(&view, 90, 14)
1182 .iter()
1183 .any(|l| l.contains("nothing to review")));
1184 on_key(&mut view, Key::Char('s'));
1185 assert_eq!(view.seat.as_deref(), Some("codex"));
1186 assert_eq!(view.rows().len(), 1);
1187 assert_eq!(on_key(&mut view, Key::Char('q')), Act::Quit);
1188 }
1189
1190 #[test]
1191 fn a_prompt_excerpt_redacts_keys() {
1192 let got = crate::capture::excerpt_of(
1193 "from now on use key=abc and sk-live123 then ghp_AAAAAAAAAAAAAAAAAAAAAAA1 ok",
1194 );
1195 assert!(
1196 !got.contains("abc") && !got.contains("sk-live") && !got.contains("ghp_"),
1197 "{got}"
1198 );
1199 assert!(got.starts_with("from now on use [redacted]"));
1200 }
1201
1202 #[test]
1203 fn a_recall_bumps_the_count_the_list_shows() {
1204 let _g = crate::test_env_lock();
1205 let dir = tempfile::tempdir().unwrap();
1206 std::env::set_var("XDG_STATE_HOME", dir.path());
1207 let atoms = vec![atom(
1208 "a1aaaaaaaaaaaaaa",
1209 "pin the fuse",
1210 "saddle",
1211 "2026-10-16T10:00:00Z",
1212 "agent-derived",
1213 )];
1214 let before = list(&atoms, &BTreeMap::new(), &Filter::default());
1215 assert_eq!(before[0].recalls, 0);
1216 bump_recalls(&["a1aaaaaaaaaaaaaa".into()]);
1217 let after = list(&atoms, &BTreeMap::new(), &Filter::default());
1218 assert_eq!(after[0].recalls, 1);
1219 assert!(
1220 describe(&after[0]).contains("recalled 1x"),
1221 "{}",
1222 describe(&after[0])
1223 );
1224 std::env::remove_var("XDG_STATE_HOME");
1225 }
1226}