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