Skip to main content

ljos_cli/
memories.rs

1//! An agent's memories, written first and reviewed afterwards.
2//!
3//! An agent's `ljos remember`, `ljos finish --lesson` and the prompt hook's
4//! correction write at once, with origin `agent-derived` and a `source`
5//! naming the seat, the harness, the session, the time and, when there is
6//! one, the reason. Nobody approves a write. A person, or a seat they ask,
7//! reviews afterwards: `ljos memories review` lists the unreviewed ones
8//! newest first and keeps, edits, merges or drops each, and
9//! `ljos memories tui` browses and prunes them in the terminal.
10//!
11//! A verdict is a line in `$XDG_STATE_HOME/ljos/memory-reviews.jsonl`. A
12//! drop, an edit and a merge also retire the claim in the pack, which
13//! tombstones it, so `ljos forget` history still shows it was held.
14
15use 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
23/// Kinds a review holds: the claims recall shows as lessons or rules.
24pub const REVIEW_KINDS: &[&str] = &["lesson", "preference", "rule", "fact", "note", "episode"];
25
26/// Why an agent wrote a memory now: `LJOS_MEMORY_WHY` when the runner or
27/// the agent set it, else the opening of the person's latest prompt.
28#[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/// One memory as a review shows it.
38#[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    /// RFC 3339 UTC, the write's time.
49    pub at: String,
50    pub reason: Option<String>,
51    /// Times recall graded it recalled.
52    pub recalls: u64,
53    /// The last verdict on it, if any.
54    pub verdict: Option<String>,
55}
56
57fn s(v: &Value) -> String {
58    v.as_str().unwrap_or("").to_string()
59}
60
61impl Memory {
62    /// The memory an atom is, when it is a kind a review holds.
63    #[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    /// The date part of [`Self::at`].
114    #[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/// One verdict on a memory.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct Review {
128    pub id: String,
129    /// `keep`, `drop`, `edit` or `merge`.
130    pub verdict: String,
131    pub at: String,
132    pub by: String,
133    /// The claim an edit or a merge wrote in its place.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub into: Option<String>,
136}
137
138/// The file the verdicts are kept in.
139#[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/// The latest verdict per id.
149#[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
163/// Append one verdict.
164///
165/// # Errors
166///
167/// The state directory cannot be written.
168pub 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/// What a listing shows.
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct Filter {
192    pub seat: Option<String>,
193    /// `YYYY-MM-DD`: written on or after this day.
194    pub since: Option<String>,
195    /// Reviewed ones too, not only those still unreviewed.
196    pub reviewed: bool,
197    /// The person's own claims too, not only the agents'.
198    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/// Times search or the hook handed each atom back. The pack's `review.reps`
212/// moves only when a review is graded. This count is the uses, and it does
213/// not rewrite the atom.
214#[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/// Uses recorded for each atom id.
223#[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
231/// Count one recall of each id. A pack that is down still records the use
232/// locally, so the next `ljos memories list` shows it.
233pub 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/// The memories in `atoms` that `filter` admits, newest first, each with
254/// its last verdict from `reviews`.
255#[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
274/// The live atoms of this seat's workspace.
275///
276/// # Errors
277///
278/// The pack does not answer.
279pub fn live_atoms() -> Result<Vec<Value>> {
280    let client = crate::pack()?;
281    let ws = client.workspace();
282    crate::atoms_lean(&client, &ws)
283}
284
285/// The full id of the one memory `short` starts.
286///
287/// # Errors
288///
289/// None or more than one memory starts with it.
290pub 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
310/// Keep a memory as it stands.
311///
312/// # Errors
313///
314/// An id that names no memory, or the state directory.
315pub 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
321/// Drop a memory: the pack tombstones it.
322///
323/// # Errors
324///
325/// An id that names no memory, or the pack refusing.
326pub 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
333/// Write `text` in place of `olds`, carrying the first one's provenance
334/// and naming what it replaced, then retire the old claims.
335fn 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
380/// Rewrite one memory's text.
381///
382/// # Errors
383///
384/// An id that names no memory, an empty text, or the pack refusing.
385pub fn edit(id: &str, text: &str, all: &[Memory]) -> Result<String> {
386    let m = find(id, all)?;
387    replace(&[m], text, "edited")
388}
389
390/// Merge memories into one. Without `text` the merged text is theirs,
391/// joined in the order given.
392///
393/// # Errors
394///
395/// Fewer than two ids, an id that names no memory, or the pack refusing.
396pub 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/// The memory as the review prints it: id, kind, text, then provenance.
416#[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/// One stale memory a prune would drop, and why.
447#[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/// Memories a prune would drop: a later one with the same words (the
465/// newest is kept), and one never recalled that is older than `days` as
466/// of `today` (`YYYY-MM-DD`). A kept verdict protects a memory.
467#[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// ---- the terminal viewer -------------------------------------------------
514
515/// What the viewer shows: the pure state its frame is drawn from.
516#[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    /// The rows the filters admit, in listing order.
547    #[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    /// The seats present, sorted.
573    #[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    /// Step the seat filter: all, then each seat in turn.
584    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    /// Note a verdict on `id` in the view, as the file now says.
605    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/// The frame for `view` in a `width` by `height` terminal, as plain lines:
625/// a header with the filters, the list (the cursor row starts with `>`, a
626/// marked row with `*`), the selected memory's provenance, and the key line.
627#[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    // Header 2, detail 4, status 2.
649    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/// One key the viewer acts on.
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum Key {
713    Up,
714    Down,
715    Char(char),
716    Other,
717}
718
719/// What a key asks the loop to do beyond changing the view.
720#[derive(Debug, Clone, PartialEq, Eq)]
721pub enum Act {
722    None,
723    Quit,
724    Keep(String),
725    Drop(String),
726    /// Ask for a new text, then edit.
727    Edit(String),
728    Merge(Vec<String>),
729    /// Ask for a date.
730    Since,
731}
732
733/// Apply one key to the view; the side effect, if any, is returned.
734pub 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
761/// The terminal in raw mode until dropped.
762struct Raw {
763    saved: libc::termios,
764}
765
766impl Raw {
767    fn on() -> Result<Self> {
768        // SAFETY: tcgetattr/tcsetattr on stdin with a zeroed struct the
769        // call fills; the saved state is restored on drop.
770        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        // SAFETY: restores the attributes read in `on`.
788        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    // SAFETY: TIOCGWINSZ fills a winsize on a terminal fd.
798    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    // SAFETY: back to the saved (cooked) mode for one line of input.
829    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    // SAFETY: raw again, as `Raw::on` set it.
838    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
860/// Run the viewer over the live pack until `q`.
861///
862/// # Errors
863///
864/// No terminal, or the pack not answering.
865pub 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
924/// The question-by-question review over stdin: each unreviewed memory,
925/// newest first, then `k`eep, `e`dit, `m`erge with another id, `d`rop,
926/// `s`kip or `q`uit.
927///
928/// # Errors
929///
930/// The pack not answering.
931pub 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}