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