1use 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
25pub const REVIEW_KINDS: &[&str] = &[
27 "lesson",
28 "preference",
29 "rule",
30 "fact",
31 "summary",
32 "note",
33 "episode",
34];
35
36#[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#[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 pub basis: String,
56 pub seat: String,
57 pub harness: String,
58 pub session: String,
59 pub host: String,
60 pub at: String,
62 pub reason: Option<String>,
63 pub recalls: u64,
65 pub lookups: u64,
67 pub earlier: u64,
70 pub verdict: Option<String>,
72 pub aliases: Vec<String>,
74}
75
76fn s(v: &Value) -> String {
77 v.as_str().unwrap_or("").to_string()
78}
79
80#[must_use]
82pub fn short_id(id: &str) -> &str {
83 id.get(..8).unwrap_or(id)
84}
85
86impl Memory {
87 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct Review {
157 pub id: String,
158 pub verdict: String,
160 pub at: String,
161 pub by: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub into: Option<String>,
165}
166
167#[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#[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
187pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
213pub struct Filter {
214 pub seat: Option<String>,
215 pub since: Option<String>,
217 pub reviewed: bool,
219 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#[must_use]
237pub fn recall_uses_path() -> PathBuf {
238 state_file("recall-uses.json")
239}
240
241#[must_use]
243pub fn recall_counts_path() -> PathBuf {
244 state_file("recalls.json")
245}
246
247#[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#[must_use]
269pub fn recall_uses() -> BTreeMap<String, u64> {
270 read_counts(&recall_uses_path())
271}
272
273#[must_use]
275pub fn recall_counts() -> BTreeMap<String, u64> {
276 read_counts(&recall_counts_path())
277}
278
279#[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
304pub fn bump_recalls(ids: &[String]) {
308 bump_counts(&recall_counts_path(), ids);
309}
310
311pub fn bump_lookups(ids: &[String]) {
313 bump_counts(&lookup_counts_path(), ids);
314}
315
316#[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#[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
355pub fn live_atoms() -> Result<Vec<Value>> {
361 let client = crate::pack()?;
362 let ws = client.workspace();
363 crate::atoms_lean(&client, &ws)
364}
365
366pub 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
397pub 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
408pub fn drop_memory(id: &str, all: &[Memory]) -> Result<String> {
414 let m = find(id, all)?;
415 record(&m.id, "drop", None)?;
419 crate::packset_forget(&m.id, None)?;
420 Ok(format!("dropped {}\n", m.short_id()))
421}
422
423fn 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(&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
481pub fn edit(id: &str, text: &str, all: &[Memory]) -> Result<String> {
488 let m = find(id, all)?;
489 replace(&[m], text, "edited")
490}
491
492pub 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#[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#[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#[must_use]
573pub fn lesson_key(kind: &str, text: &str) -> String {
574 format!("{kind}\n{}", fold(text))
575}
576
577#[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
626const SAME_LESSON: f64 = 2.0 / 3.0;
633
634const 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
642const 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
652const 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
668fn 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#[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
723pub 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 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#[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#[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 #[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 #[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 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 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
987pub enum Key {
988 Up,
989 Down,
990 Char(char),
991 Other,
992}
993
994#[derive(Debug, Clone, PartialEq, Eq)]
996pub enum Act {
997 None,
998 Quit,
999 Keep(String),
1000 Drop(String),
1001 Edit(String),
1003 Merge(Vec<String>),
1004 Since,
1006}
1007
1008pub 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
1036struct Raw {
1038 saved: libc::termios,
1039}
1040
1041impl Raw {
1042 fn on() -> Result<Self> {
1043 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 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 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 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 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
1135pub 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
1199pub 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 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 #[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 #[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 #[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 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 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 #[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 #[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}