1use 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
26pub const REVIEW_KINDS: &[&str] = &[
28 "lesson",
29 "preference",
30 "rule",
31 "fact",
32 "summary",
33 "note",
34 "episode",
35];
36
37#[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#[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 pub basis: String,
57 pub seat: String,
58 pub harness: String,
59 pub session: String,
60 pub host: String,
61 pub at: String,
63 pub reason: Option<String>,
64 pub recalls: u64,
66 pub verdict: Option<String>,
68 pub aliases: Vec<String>,
70}
71
72fn s(v: &Value) -> String {
73 v.as_str().unwrap_or("").to_string()
74}
75
76impl Memory {
77 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct Review {
145 pub id: String,
146 pub verdict: String,
148 pub at: String,
149 pub by: String,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub into: Option<String>,
153}
154
155#[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#[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
175pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
201pub struct Filter {
202 pub seat: Option<String>,
203 pub since: Option<String>,
205 pub reviewed: bool,
207 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#[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#[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
241pub 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#[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
284pub fn live_atoms() -> Result<Vec<Value>> {
290 let client = crate::pack()?;
291 let ws = client.workspace();
292 crate::atoms_lean(&client, &ws)
293}
294
295pub 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
320pub 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
331pub fn drop_memory(id: &str, all: &[Memory]) -> Result<String> {
337 let m = find(id, all)?;
338 record(&m.id, "drop", None)?;
342 crate::packset_forget(&m.id, None)?;
343 Ok(format!("dropped {}\n", m.short_id()))
344}
345
346fn 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(&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
403pub 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
418pub 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#[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#[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#[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
559const SAME_LESSON: f64 = 2.0 / 3.0;
566
567const 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
575const 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
585const 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
601fn 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#[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
656pub 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 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#[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#[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 #[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 #[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 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 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
920pub enum Key {
921 Up,
922 Down,
923 Char(char),
924 Other,
925}
926
927#[derive(Debug, Clone, PartialEq, Eq)]
929pub enum Act {
930 None,
931 Quit,
932 Keep(String),
933 Drop(String),
934 Edit(String),
936 Merge(Vec<String>),
937 Since,
939}
940
941pub 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
969struct Raw {
971 saved: libc::termios,
972}
973
974impl Raw {
975 fn on() -> Result<Self> {
976 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 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 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 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 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
1068pub 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
1132pub 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 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 #[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 #[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 #[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 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 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 #[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 #[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}