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