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