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] = &[
25    "lesson",
26    "preference",
27    "rule",
28    "fact",
29    "summary",
30    "note",
31    "episode",
32];
33
34/// Why an agent wrote a memory now: `LJOS_MEMORY_WHY` when the runner or
35/// the agent set it, else the opening of the person's latest prompt.
36#[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/// One memory as a review shows it.
46#[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    /// RFC 3339 UTC, the write's time.
57    pub at: String,
58    pub reason: Option<String>,
59    /// Times recall graded it recalled.
60    pub recalls: u64,
61    /// The last verdict on it, if any.
62    pub verdict: Option<String>,
63}
64
65fn s(v: &Value) -> String {
66    v.as_str().unwrap_or("").to_string()
67}
68
69impl Memory {
70    /// The memory an atom is, when it is a kind a review holds.
71    #[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    /// The date part of [`Self::at`].
122    #[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/// One verdict on a memory.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Review {
136    pub id: String,
137    /// `keep`, `drop`, `edit` or `merge`.
138    pub verdict: String,
139    pub at: String,
140    pub by: String,
141    /// The claim an edit or a merge wrote in its place.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub into: Option<String>,
144}
145
146/// The file the verdicts are kept in.
147#[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/// The latest verdict per id.
157#[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
171/// Append one verdict.
172///
173/// # Errors
174///
175/// The state directory cannot be written.
176pub 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/// What a listing shows.
198#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct Filter {
200    pub seat: Option<String>,
201    /// `YYYY-MM-DD`: written on or after this day.
202    pub since: Option<String>,
203    /// Reviewed ones too, not only those still unreviewed.
204    pub reviewed: bool,
205    /// The person's own claims too, not only the agents'.
206    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/// Times search or the hook handed each atom back. The pack's `review.reps`
220/// moves only when a review is graded. This count is the uses, and it does
221/// not rewrite the atom.
222#[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/// Uses recorded for each atom id.
231#[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
239/// Count one recall of each id. A pack that is down still records the use
240/// locally, so the next `ljos memories list` shows it.
241pub 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/// The memories in `atoms` that `filter` admits, newest first, each with
262/// its last verdict from `reviews`.
263#[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
282/// The live atoms of this seat's workspace.
283///
284/// # Errors
285///
286/// The pack does not answer.
287pub fn live_atoms() -> Result<Vec<Value>> {
288    let client = crate::pack()?;
289    let ws = client.workspace();
290    crate::atoms_lean(&client, &ws)
291}
292
293/// The full id of the one memory `short` starts.
294///
295/// # Errors
296///
297/// None or more than one memory starts with it.
298pub 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
318/// Keep a memory as it stands.
319///
320/// # Errors
321///
322/// An id that names no memory, or the state directory.
323pub 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
329/// Drop a memory: the pack tombstones it.
330///
331/// # Errors
332///
333/// An id that names no memory, or the pack refusing.
334pub 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
341/// Write `text` in place of `olds`, carrying the first one's provenance
342/// and naming what it replaced, then retire the old claims.
343fn 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
388/// Rewrite one memory's text.
389///
390/// # Errors
391///
392/// An id that names no memory, an empty text, or the pack refusing.
393pub fn edit(id: &str, text: &str, all: &[Memory]) -> Result<String> {
394    let m = find(id, all)?;
395    replace(&[m], text, "edited")
396}
397
398/// Merge memories into one. Without `text` the merged text is theirs,
399/// joined in the order given.
400///
401/// # Errors
402///
403/// Fewer than two ids, an id that names no memory, or the pack refusing.
404pub 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/// The memory as the review prints it: id, kind, text, then provenance.
424#[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/// One stale memory a prune would drop, and why.
455#[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/// Memories a prune would drop: a later one with the same words (the
473/// newest is kept), and one never recalled that is older than `days` as
474/// of `today` (`YYYY-MM-DD`). A kept verdict protects a memory.
475#[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// ---- the terminal viewer -------------------------------------------------
522
523/// What the viewer shows: the pure state its frame is drawn from.
524#[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    /// The rows the filters admit, in listing order.
555    #[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    /// The seats present, sorted.
581    #[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    /// Step the seat filter: all, then each seat in turn.
592    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    /// Note a verdict on `id` in the view, as the file now says.
613    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/// The frame for `view` in a `width` by `height` terminal, as plain lines:
633/// a header with the filters, the list (the cursor row starts with `>`, a
634/// marked row with `*`), the selected memory's provenance, and the key line.
635#[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    // Header 2, detail 4, status 2.
657    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/// One key the viewer acts on.
719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
720pub enum Key {
721    Up,
722    Down,
723    Char(char),
724    Other,
725}
726
727/// What a key asks the loop to do beyond changing the view.
728#[derive(Debug, Clone, PartialEq, Eq)]
729pub enum Act {
730    None,
731    Quit,
732    Keep(String),
733    Drop(String),
734    /// Ask for a new text, then edit.
735    Edit(String),
736    Merge(Vec<String>),
737    /// Ask for a date.
738    Since,
739}
740
741/// Apply one key to the view; the side effect, if any, is returned.
742pub 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
769/// The terminal in raw mode until dropped.
770struct Raw {
771    saved: libc::termios,
772}
773
774impl Raw {
775    fn on() -> Result<Self> {
776        // SAFETY: tcgetattr/tcsetattr on stdin with a zeroed struct the
777        // call fills; the saved state is restored on drop.
778        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        // SAFETY: restores the attributes read in `on`.
796        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    // SAFETY: TIOCGWINSZ fills a winsize on a terminal fd.
806    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    // SAFETY: back to the saved (cooked) mode for one line of input.
837    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    // SAFETY: raw again, as `Raw::on` set it.
846    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
868/// Run the viewer over the live pack until `q`.
869///
870/// # Errors
871///
872/// No terminal, or the pack not answering.
873pub 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
932/// The question-by-question review over stdin: each unreviewed memory,
933/// newest first, then `k`eep, `e`dit, `m`erge with another id, `d`rop,
934/// `s`kip or `q`uit.
935///
936/// # Errors
937///
938/// The pack not answering.
939pub 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}