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