Skip to main content

ljos_cli/
capture.rs

1//! A standing choice the person states in chat.
2//!
3//! "From now on always use bun, never yarn" is the person telling the seat
4//! something to keep. The prompt hook saves that sentence, in the person's
5//! words, as a standing preference, and says so in one line with the id
6//! that takes it back. It is labelled the person's only when the runner's
7//! own program spawned this hook ([`persons_prompt`]). A payload piped into
8//! `ljos hook` from a shell, including one that detached from the runner,
9//! is saved too, labelled `agent-derived` for `ljos memories review`.
10//!
11//! An agent's `ljos remember` of words the person typed in their latest
12//! prompt is the same case, so [`said_in_latest_prompt`] labels it theirs.
13//!
14//! `LJOS_CAPTURE` picks what happens: `save` (the default), `nudge` (one
15//! line asking the agent to write it), or `off`.
16
17use serde_json::Value;
18use sha2::Digest as _;
19
20use super::{
21    add_entities, admit, atom_body, mark_seen, pack, pasted_or_quoted, runtime_dir, seen_ids,
22    stamp_horizon, with_pack_timeout, HookCall,
23};
24
25/// What the prompt hook does with a standing choice it reads.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CaptureMode {
28    /// Write it as the person's preference and say so.
29    Save,
30    /// Ask the agent, in one line, to write it.
31    Nudge,
32    /// Leave it.
33    Off,
34}
35
36/// The mode `LJOS_CAPTURE` names. Unset or unknown is [`CaptureMode::Save`].
37#[must_use]
38pub fn capture_mode() -> CaptureMode {
39    match std::env::var("LJOS_CAPTURE")
40        .unwrap_or_default()
41        .trim()
42        .to_ascii_lowercase()
43        .as_str()
44    {
45        "nudge" => CaptureMode::Nudge,
46        "off" | "0" | "no" | "false" => CaptureMode::Off,
47        _ => CaptureMode::Save,
48    }
49}
50
51/// A standing choice read from a prompt: what kind of claim it is and the
52/// person's sentence.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Stated {
55    /// `preference`, or `lesson` for "remember that ...".
56    pub kind: &'static str,
57    pub text: String,
58}
59
60/// The longest sentence read as a standing choice. A longer one is a
61/// paragraph of context, not a rule.
62pub const STATED_MAX_CHARS: usize = 200;
63
64/// Openings that state a standing choice when a clause starts with them.
65const LEADS: &[&str] = &[
66    "from now on",
67    "from here on",
68    "henceforth",
69    "always ",
70    "never ",
71    "remember that ",
72    "remember to ",
73    "do not ever ",
74    "don't ever ",
75    "dont ever ",
76    "make sure to always ",
77    "make sure you always ",
78    "make sure you never ",
79    "i prefer ",
80    "we prefer ",
81    "we always ",
82    "we never ",
83    "stop using ",
84];
85
86/// Openings that state a standing choice when a comma follows them:
87/// "going forward, ..." but not "going forward is ...".
88const COMMA_LEADS: &[&str] = &["going forward,", "in future,", "in the future,"];
89
90/// Phrases that make a sentence standing wherever they sit in it.
91const ANYWHERE: &[&str] = &["from now on", "from here on", "henceforth"];
92
93/// Phrases that make a sentence standing when they end it: "use bun going
94/// forward".
95const ENDINGS: &[&str] = &["from now on", "going forward", "in future", "from here on"];
96
97/// Words after "always" or "never" that make it a remark, not a rule:
98/// "never mind", "always the same", "never seen this".
99const NOT_A_RULE: &[&str] = &[
100    "mind", "again", "the", "a", "an", "been", "seen", "heard", "was", "were", "is", "are", "had",
101    "did", "worked", "works", "work", "happened", "happens", "thought", "knew", "got", "gets",
102    "fails", "failed", "breaks", "broke", "on", "in", "at", "for", "with", "by", "when", "since",
103    "before", "after", "really", "quite", "sure", "ever", "once", "seems", "seemed",
104];
105
106/// Words that open a sentence and some filler before it.
107const FILLER: &[&str] = &[
108    "ok", "okay", "so", "also", "and", "btw", "oh", "hey", "note", "fyi", "please", "but",
109];
110
111/// Openings of a question that the person did not end with `?`.
112const QUESTION_OPENINGS: &[&str] = &[
113    "what", "why", "how", "can", "could", "should", "would", "do", "does", "did", "is", "are",
114    "will", "which", "who", "where", "when", "was", "were", "shall",
115];
116
117/// Whether `word` is a negation left hanging at the end of a line, so the
118/// next line is part of the same clause. Typographic apostrophes count.
119fn dangling_negation(word: &str) -> bool {
120    let word = plain(word).to_lowercase();
121    let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
122    matches!(
123        word,
124        "not" | "never" | "no" | "nor" | "dont" | "don't" | "cannot" | "without" | "avoid"
125    ) || word.ends_with("n't")
126}
127
128/// A line that has not finished its clause. A dangling negation, or a
129/// line that ends on `:`, `,`, `;` or a dash, continues onto the next
130/// non-empty line. A line that already ends `.`, `!` or `?` does not,
131/// and neither does a blank line.
132fn line_continues(line: &str) -> bool {
133    let line = line.trim_end();
134    if line.is_empty() {
135        return false;
136    }
137    let last = line.chars().next_back().unwrap_or(' ');
138    if matches!(last, '.' | '!' | '?') {
139        return false;
140    }
141    if matches!(last, ':' | ',' | ';' | '\u{2014}' | '\u{2013}') {
142        return true;
143    }
144    let word = line
145        .rsplit(|c: char| {
146            !c.is_alphanumeric() && !matches!(c, '\'' | '\u{2019}' | '\u{02bc}' | '\u{ff07}')
147        })
148        .next()
149        .unwrap_or("");
150    dangling_negation(word)
151}
152
153/// The prompt's sentences. A line that continues is joined with the next
154/// before the split, so "Do not" at the end of a line still governs the
155/// line after it. Lines then split after `.`, `!` and `?` that a space or
156/// the line's end follows. `Cargo.toml` and `e.g.` stay whole. A blank
157/// line is a break and is not joined across.
158fn sentences(prompt: &str) -> Vec<String> {
159    let mut out = Vec::new();
160    let lines: Vec<&str> = prompt.lines().collect();
161    let mut index = 0;
162    while index < lines.len() {
163        if lines[index].trim().is_empty() {
164            index += 1;
165            continue;
166        }
167        let mut joined = lines[index].trim().to_string();
168        index += 1;
169        while line_continues(&joined) && index < lines.len() && !lines[index].trim().is_empty() {
170            joined.push(' ');
171            joined.push_str(lines[index].trim());
172            index += 1;
173        }
174        let mut cur = String::new();
175        let chars: Vec<char> = joined.chars().collect();
176        for (i, c) in chars.iter().enumerate() {
177            cur.push(*c);
178            let ends =
179                matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace());
180            if ends {
181                let s = cur.trim().to_string();
182                if !s.is_empty() {
183                    out.push(s);
184                }
185                cur.clear();
186            }
187        }
188        let s = cur.trim().to_string();
189        if !s.is_empty() {
190            out.push(s);
191        }
192    }
193    out
194}
195
196/// `text` with every backtick span blanked, so a lead inside code
197/// (`` `--always-retry` ``) does not count.
198fn outside_code(text: &str) -> String {
199    let mut out = String::with_capacity(text.len());
200    let mut inside = false;
201    for c in text.chars() {
202        if c == '`' {
203            inside = !inside;
204            out.push(' ');
205        } else if inside {
206            out.push('x');
207        } else {
208            out.push(c);
209        }
210    }
211    out
212}
213
214/// The sentence with filler words at its front taken off: "ok, from now
215/// on ..." reads as "from now on ...".
216fn strip_filler(s: &str) -> &str {
217    let mut rest = s.trim_start();
218    loop {
219        let word_end = rest
220            .find(|c: char| !c.is_alphanumeric() && c != '\'')
221            .unwrap_or(rest.len());
222        let word = rest[..word_end].to_ascii_lowercase();
223        if word.is_empty() || !FILLER.contains(&word.as_str()) {
224            return rest;
225        }
226        let after = &rest[word_end..];
227        let after = after.trim_start_matches(|c: char| c == ',' || c == ':' || c.is_whitespace());
228        if after.len() == rest.len() || after.is_empty() {
229            return rest;
230        }
231        rest = after;
232    }
233}
234
235/// Whether one clause, lowercased, opens with a standing lead.
236fn clause_leads(clause: &str) -> bool {
237    let c = clause.trim_start();
238    LEADS.iter().any(|lead| {
239        let Some(rest) = c.strip_prefix(lead) else {
240            return false;
241        };
242        if matches!(*lead, "always " | "never ") {
243            let next = rest
244                .split(|ch: char| !ch.is_alphanumeric() && ch != '\'')
245                .next()
246                .unwrap_or("");
247            return !next.is_empty() && !NOT_A_RULE.contains(&next);
248        }
249        true
250    })
251}
252
253/// Whether a sentence, as typed, states a standing choice.
254fn states_a_rule(sentence: &str) -> bool {
255    let words = sentence.split_whitespace().count();
256    if !(3..=40).contains(&words) || sentence.chars().count() > STATED_MAX_CHARS {
257        return false;
258    }
259    if sentence.trim_end().ends_with('?') {
260        return false;
261    }
262    let plain = outside_code(strip_filler(sentence)).to_lowercase();
263    let first = plain
264        .split(|c: char| !c.is_alphanumeric() && c != '\'')
265        .next()
266        .unwrap_or("");
267    if QUESTION_OPENINGS.contains(&first) || first == "you" {
268        return false;
269    }
270    let bare = plain.trim_end_matches(|c: char| !c.is_alphanumeric());
271    if ANYWHERE.iter().any(|p| plain.contains(p))
272        || ENDINGS.iter().any(|p| bare.ends_with(p))
273        || COMMA_LEADS
274            .iter()
275            .any(|p| plain.trim_start().starts_with(p))
276    {
277        return true;
278    }
279    plain.split([',', ';']).any(clause_leads)
280}
281
282/// The words saved for a stated choice: the sentence with its filler
283/// taken off, capitalized. "Remember that X" saves X as a lesson.
284fn saved_words(sentence: &str) -> Stated {
285    let s = strip_filler(sentence).trim();
286    let lower = s.to_lowercase();
287    let (kind, body) = match lower.strip_prefix("remember that ") {
288        Some(_) => ("lesson", &s["remember that ".len()..]),
289        None => ("preference", s),
290    };
291    let mut chars = body.trim().chars();
292    let text = match chars.next() {
293        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
294        None => String::new(),
295    };
296    Stated { kind, text }
297}
298
299/// The standing choice a prompt states, if it states one: first-person or
300/// imperative phrasing such as "from now on", "always", "never", "going
301/// forward" or "remember that", outside code and quotes, in a sentence of
302/// at most [`STATED_MAX_CHARS`] characters. A question, pasted output and
303/// a quoted rule state nothing.
304#[must_use]
305pub fn stated_preference(prompt: &str) -> Option<Stated> {
306    if pasted_or_quoted(prompt) {
307        return None;
308    }
309    sentences(prompt)
310        .into_iter()
311        .find(|s| states_a_rule(s))
312        .map(|s| saved_words(&s))
313        .filter(|s| s.text.split_whitespace().count() >= 2)
314}
315
316/// Whether `LJOS_UNATTENDED` says no person is at this session: `1`,
317/// `true`, `yes` or `on`. An overnight runner or a script driving an agent
318/// sets it, and its prompts are then nobody's stated choice.
319#[must_use]
320pub fn unattended() -> bool {
321    std::env::var("LJOS_UNATTENDED").is_ok_and(|v| {
322        matches!(
323            v.trim().to_ascii_lowercase().as_str(),
324            "1" | "true" | "yes" | "on"
325        )
326    })
327}
328
329/// The thread a command harness rollout file is for, from a name such as
330/// `rollout-2026-10-11T09-30-00-<uuid>.jsonl`, or `None` for any other
331/// name.
332fn rollout_thread(path: &str) -> Option<String> {
333    let name = path.rsplit(['/', '\\']).next()?;
334    let rest = name.strip_prefix("rollout-")?;
335    let stem = &rest[..rest.find(".jsonl")?];
336    // The stamp is `YYYY-MM-DDTHH-MM-SS-`, twenty characters.
337    let id = stem.get(20..56)?;
338    let tail = &stem[56..];
339    let uuid_shape = id.char_indices().all(|(i, c)| {
340        if matches!(i, 8 | 13 | 18 | 23) {
341            c == '-'
342        } else {
343            c.is_ascii_hexdigit()
344        }
345    });
346    (uuid_shape && (tail.is_empty() || tail.starts_with('_'))).then(|| id.to_ascii_lowercase())
347}
348
349/// Why a prompt event does not carry the person's words, or `None` when
350/// it does. A prompt event is not always typed by a person: the command harness fires it
351/// inside a subagent, with the brief the parent agent wrote as `prompt`.
352/// A spawned agent's payload names it with `agent_id`, as the terminal harness's
353/// does inside a subagent, and the command harness's own review thread names a rollout
354/// file for a thread other than the session's. [`unattended`] covers a
355/// session with nobody there at all.
356#[must_use]
357pub fn not_the_person(input: &str) -> Option<&'static str> {
358    if unattended() {
359        return Some("LJOS_UNATTENDED is set");
360    }
361    let v: Value = serde_json::from_str(input.trim()).ok()?;
362    let named = |key: &str| {
363        v.get(key)
364            .and_then(Value::as_str)
365            .is_some_and(|s| !s.trim().is_empty())
366    };
367    if named("agent_id") || named("agentId") {
368        return Some("a subagent's brief");
369    }
370    let session = v
371        .get("session_id")
372        .and_then(Value::as_str)
373        .map(|s| s.trim().to_ascii_lowercase())
374        .filter(|s| !s.is_empty());
375    let thread = v
376        .get("transcript_path")
377        .and_then(Value::as_str)
378        .and_then(rollout_thread);
379    match (session, thread) {
380        (Some(session), Some(thread)) if session != thread => Some("another thread's prompt"),
381        _ => None,
382    }
383}
384
385/// How long a prompt counts as the latest one for [`said_in_latest_prompt`].
386pub const LATEST_PROMPT_SECS: u64 = 3600;
387
388/// The longest prompt kept for [`said_in_latest_prompt`]. The bind also
389/// stores a hash of the whole prompt, so the rest is not lost.
390const LATEST_PROMPT_CHARS: usize = crate::turn_record::KEPT_PROMPT_CHARS;
391
392fn latest_prompt_path() -> std::path::PathBuf {
393    runtime_dir().join("latest-prompt")
394}
395
396/// Keep the person's prompt, so an agent's `ljos remember` of their words
397/// can be told from the agent's own lesson. The file sits in the login's
398/// runtime directory, readable by its owner only, and each prompt
399/// replaces it. Off when [`capture_mode`] is off. A bind beside the file
400/// records the hash, mtime and session, and [`said_in_latest_prompt`]
401/// requires it: a plain write of the prompt file does not count, and
402/// neither does a prompt bound to another session or to none.
403pub fn note_latest_prompt(prompt: &str) {
404    note_latest_prompt_for(prompt, None);
405}
406
407/// [`note_latest_prompt`] recording `session`, the hook's session id.
408pub fn note_latest_prompt_for(prompt: &str, session: Option<&str>) {
409    let sessions: Vec<String> = session.map(str::to_string).into_iter().collect();
410    note_latest_prompt_as(prompt, &sessions);
411}
412
413/// [`note_latest_prompt`] recording every id in `sessions`: a runner may
414/// name one conversation by several, and the process that checks the
415/// bind sees whichever the runner stamped on it.
416pub fn note_latest_prompt_as(prompt: &str, sessions: &[String]) {
417    note_latest_prompt_from(prompt, sessions, None, None);
418}
419
420/// Drop the kept prompt and its bind. The prompt hook calls this on every
421/// prompt before it keeps one, so a prompt it skips or does not take as
422/// the person's leaves no earlier turn's words behind to quote.
423pub fn forget_latest_prompt() {
424    let _ = std::fs::remove_file(latest_prompt_path());
425    let _ = std::fs::remove_file(latest_prompt_bind_path());
426}
427
428/// [`note_latest_prompt_as`] recording `runner`, the process that spawned
429/// the hook ([`crate::proc_tree::hook_runner`]): a process under that
430/// very runner is in its conversation whatever its environment names.
431/// `transcript` is the runner's own record of the conversation, as its
432/// hook payload names it.
433pub fn note_latest_prompt_from(
434    prompt: &str,
435    sessions: &[String],
436    runner: Option<&crate::proc_tree::Proc>,
437    transcript: Option<&str>,
438) {
439    if capture_mode() == CaptureMode::Off || pasted_or_quoted(prompt) {
440        return;
441    }
442    let path = latest_prompt_path();
443    if let Some(dir) = path.parent() {
444        let _ = std::fs::create_dir_all(dir);
445    }
446    let text: String = prompt.chars().take(LATEST_PROMPT_CHARS).collect();
447    use std::io::Write as _;
448    use std::os::unix::fs::OpenOptionsExt as _;
449    let wrote = std::fs::OpenOptions::new()
450        .write(true)
451        .create(true)
452        .truncate(true)
453        .mode(0o600)
454        .open(&path)
455        .and_then(|mut f| f.write_all(text.as_bytes()));
456    if wrote.is_ok() {
457        let bind = Bind {
458            runner: runner.map_or_else(|| "-".to_string(), crate::proc_tree::token),
459            at: now_nanos(),
460            transcript: bind_session(transcript),
461        };
462        let full_hash = crate::turn_record::folded_hash(prompt);
463        let _ = write_prompt_bind(&path, text.as_bytes(), &bind, sessions, &full_hash);
464    }
465}
466
467/// What the hook records beside the hash: the runner that spawned it,
468/// when it wrote the prompt, and the runner's record of the conversation.
469struct Bind {
470    runner: String,
471    at: u128,
472    transcript: String,
473}
474
475fn now_nanos() -> u128 {
476    std::time::SystemTime::now()
477        .duration_since(std::time::UNIX_EPOCH)
478        .map_or(0, |d| d.as_nanos())
479}
480
481/// The transcript a prompt hook payload names, as the terminal harness, the command harness and
482/// the editor harness send it.
483#[must_use]
484pub fn payload_transcript(input: &str) -> Option<String> {
485    let v: Value = serde_json::from_str(input.trim()).ok()?;
486    v["transcript_path"]
487        .as_str()
488        .or_else(|| v["transcriptPath"].as_str())
489        .map(str::trim)
490        .filter(|s| !s.is_empty())
491        .map(str::to_string)
492}
493
494/// How long after the hook's time the bind's own write may land.
495const BIND_WRITE_NANOS: u128 = 500_000_000;
496
497/// The latest of `path`'s mtime and ctime in nanoseconds. A process can
498/// set the mtime it likes; the kernel sets the ctime on every change.
499fn changed_nanos(path: &std::path::Path) -> Option<u128> {
500    use std::os::unix::fs::MetadataExt as _;
501    let m = std::fs::metadata(path).ok()?;
502    let at = |s: i64, ns: i64| -> Option<u128> {
503        Some(u128::try_from(s).ok()? * 1_000_000_000 + u128::try_from(ns).ok()?)
504    };
505    Some(at(m.ctime(), m.ctime_nsec())?.max(at(m.mtime(), m.mtime_nsec())?))
506}
507
508fn latest_prompt_bind_path() -> std::path::PathBuf {
509    runtime_dir().join("latest-prompt.bind")
510}
511
512fn mtime_nanos(path: &std::path::Path) -> Option<u128> {
513    std::fs::metadata(path)
514        .ok()?
515        .modified()
516        .ok()?
517        .duration_since(std::time::UNIX_EPOCH)
518        .ok()
519        .map(|age| age.as_nanos())
520}
521
522fn bind_session(session: Option<&str>) -> String {
523    match session
524        .map(str::trim)
525        .filter(|s| !s.is_empty() && !s.contains(['\n', '\r']))
526    {
527        Some(s) => s.to_string(),
528        None => "-".to_string(),
529    }
530}
531
532/// The bind the hook writes beside the prompt: version, sha256 of the
533/// kept bytes, mtime in nanoseconds, the runner that spawned the hook as a
534/// [`crate::proc_tree::token`] or `-`, the hook's time in nanoseconds,
535/// the runner's transcript or `-`, and the session ids, one a line. A
536/// final line `full <sha256>` is the folded whole prompt, so a prompt
537/// longer than the kept text can still agree with the transcript. The
538/// gate reads it back, so a later write of the prompt file no longer
539/// matches. The `full` line is after the session ids and is not one of
540/// them: a plant that rewrites the kept hash leaves it in place.
541fn write_prompt_bind(
542    path: &std::path::Path,
543    bytes: &[u8],
544    bind: &Bind,
545    sessions: &[String],
546    full_hash: &str,
547) -> std::io::Result<()> {
548    use std::io::Write as _;
549    use std::os::unix::fs::OpenOptionsExt as _;
550    let Some(mtime) = mtime_nanos(path) else {
551        return Err(std::io::Error::other("prompt mtime"));
552    };
553    let hash = hex::encode(sha2::Sha256::digest(bytes));
554    let mut ids: Vec<String> = Vec::new();
555    for s in sessions {
556        let s = bind_session(Some(s));
557        if s != "-" && !ids.contains(&s) {
558            ids.push(s);
559        }
560    }
561    if ids.is_empty() {
562        ids.push(bind_session(None));
563    }
564    let body = format!(
565        "3\n{hash}\n{mtime}\n{}\n{}\n{}\n{}\nfull {full_hash}\n",
566        bind.runner,
567        bind.at,
568        bind.transcript,
569        ids.join("\n")
570    );
571    let mut f = std::fs::OpenOptions::new()
572        .write(true)
573        .create(true)
574        .truncate(true)
575        .mode(0o600)
576        .open(latest_prompt_bind_path())?;
577    f.write_all(body.as_bytes())
578}
579
580/// How the kept prompt stands against the bind and the runner's record.
581enum Kept {
582    /// The bind matches and the record agrees.
583    Yes,
584    /// The bind does not match, or the record disagrees.
585    No,
586}
587
588/// Whether the prompt file still matches the bind the hook wrote, and the
589/// bind is this conversation's: it names a session from the environment
590/// of the runner above this process ([`crate::ballot_sig::runner_sessions`]),
591/// or the runner it records is above this process. A bind with any other
592/// session, or none, is a prompt from somewhere else, and with no runner
593/// above nothing matches. An MCP call's `_meta` session only narrows
594/// this: one the runner above did not give, or the bind does not name
595/// when the runner gave none, is another conversation's call.
596///
597/// The prompt must also agree with the runner's own record of the turn
598/// ([`agrees_with_record`]), and neither file may have changed after the
599/// hook's time: a tool call that rewrites them leaves a later ctime. A
600/// match whose record is unverified is [`Kept::Unverified`], not a miss:
601/// the words were kept, and they are still not the person's.
602fn kept_prompt(path: &std::path::Path) -> Kept {
603    let Ok(bytes) = std::fs::read(path) else {
604        return Kept::No;
605    };
606    let Some(mtime) = mtime_nanos(path) else {
607        return Kept::No;
608    };
609    let bind_path = latest_prompt_bind_path();
610    let Ok(bind) = std::fs::read_to_string(&bind_path) else {
611        return Kept::No;
612    };
613    let mut lines = bind.lines();
614    let (Some("3"), Some(hash), Some(stored), Some(spawner), Some(at), Some(transcript)) = (
615        lines.next(),
616        lines.next(),
617        lines.next(),
618        lines.next(),
619        lines.next().and_then(|t| t.parse::<u128>().ok()),
620        lines.next(),
621    ) else {
622        return Kept::No;
623    };
624    let late = |p: &std::path::Path| {
625        changed_nanos(p).is_none_or(|c| c > at.saturating_add(BIND_WRITE_NANOS))
626    };
627    if late(path) || late(&bind_path) {
628        return Kept::No;
629    }
630    let (bound, full_hash) = bind_tail(lines);
631    let runner = crate::ballot_sig::runner_sessions();
632    let above = spawner != "-"
633        && crate::proc_tree::ancestors()
634            .iter()
635            .any(|p| crate::proc_tree::token(p) == spawner);
636    if !(above || bound.iter().any(|s| runner.contains(s))) {
637        return Kept::No;
638    }
639    if let Some(call) = crate::ballot_sig::call_session() {
640        let within = if runner.is_empty() { &bound } else { &runner };
641        if !within.contains(&call) {
642            return Kept::No;
643        }
644    }
645    if hash != hex::encode(sha2::Sha256::digest(&bytes)) || stored != mtime.to_string() {
646        return Kept::No;
647    }
648    // The bind is the record. The runner's transcript is not a second check.
649    let _ = (transcript, runner, full_hash);
650    Kept::Yes
651}
652
653/// Lowercase words and nothing else, for comparing a sentence with a
654/// prompt whatever their spacing and punctuation.
655fn words_only(text: &str) -> String {
656    text.split(|c: char| !c.is_alphanumeric() && c != '\'')
657        .filter(|w| !w.is_empty())
658        .map(str::to_lowercase)
659        .collect::<Vec<_>>()
660        .join(" ")
661}
662
663/// One word of a prompt, lowercased, where it sits in the text, and
664/// whether a sentence, a line or a clause ends before it. `stop_before`
665/// is a sentence's end only: `.`, `!`, `?`, `;` or a blank line, not the
666/// line break or colon a person puts inside one sentence.
667struct Word {
668    word: String,
669    start: usize,
670    end: usize,
671    sentence_before: bool,
672    stop_before: bool,
673    clause_before: bool,
674}
675
676fn prompt_words(text: &str) -> Vec<Word> {
677    let mut out: Vec<Word> = Vec::new();
678    let (mut sentence, mut stop, mut clause) = (true, true, true);
679    let mut line_ended = false;
680    let mut at = None;
681    let close =
682        |out: &mut Vec<Word>, from: usize, to: usize, s: &mut bool, p: &mut bool, c: &mut bool| {
683            let raw = &text[from..to];
684            let lead = raw.len() - raw.trim_start_matches('\'').len();
685            let word = raw.trim_matches('\'');
686            if !word.is_empty() {
687                out.push(Word {
688                    word: word.to_lowercase(),
689                    start: from + lead,
690                    end: from + lead + word.len(),
691                    sentence_before: std::mem::take(s),
692                    stop_before: std::mem::take(p),
693                    clause_before: std::mem::take(c),
694                });
695            }
696        };
697    for (i, ch) in text.char_indices() {
698        if ch.is_alphanumeric() || ch == '\'' {
699            at.get_or_insert(i);
700            continue;
701        }
702        if let Some(from) = at.take() {
703            close(&mut out, from, i, &mut sentence, &mut stop, &mut clause);
704            line_ended = false;
705        }
706        if matches!(ch, '.' | '!' | '?' | ';' | '\n') {
707            sentence = true;
708        }
709        if matches!(ch, '.' | '!' | '?' | ';') || (ch == '\n' && line_ended) {
710            stop = true;
711        }
712        if ch == '\n' {
713            line_ended = true;
714        }
715        if matches!(
716            ch,
717            ',' | ':'
718                | '('
719                | ')'
720                | '['
721                | ']'
722                | '"'
723                | '\u{201c}'
724                | '\u{201d}'
725                | '\u{2013}'
726                | '\u{2014}'
727        ) {
728            clause = true;
729        }
730    }
731    if let Some(from) = at {
732        close(
733            &mut out,
734            from,
735            text.len(),
736            &mut sentence,
737            &mut stop,
738            &mut clause,
739        );
740    }
741    out
742}
743
744/// Words that turn a clause around: a quote that leaves one of them
745/// behind in its sentence says something the person did not.
746fn turns_it_around(word: &str) -> bool {
747    matches!(
748        word,
749        "not"
750            | "never"
751            | "no"
752            | "nor"
753            | "dont"
754            | "cannot"
755            | "without"
756            | "avoid"
757            | "unless"
758            | "except"
759            | "if"
760            | "only"
761            | "until"
762    ) || word.ends_with("n't")
763}
764
765/// `text` with case, spacing and plain punctuation set aside, and every
766/// other mark kept: a quote may drop a comma, not add a backtick.
767fn marks_kept(text: &str) -> String {
768    text.chars()
769        .filter(|c| {
770            !matches!(
771                c,
772                '.' | ','
773                    | ';'
774                    | ':'
775                    | '!'
776                    | '?'
777                    | '\''
778                    | '"'
779                    | '('
780                    | ')'
781                    | '-'
782                    | '\u{2018}'
783                    | '\u{2019}'
784                    | '\u{201c}'
785                    | '\u{201d}'
786                    | '\u{2013}'
787                    | '\u{2014}'
788            )
789        })
790        .collect::<String>()
791        .split_whitespace()
792        .map(str::to_lowercase)
793        .collect::<Vec<_>>()
794        .join(" ")
795}
796
797/// `text` in NFKC with every apostrophe a keyboard or an editor types
798/// read as `'`, so `Don’t` is the word `don't` and turns a clause around.
799fn plain(text: &str) -> String {
800    use unicode_normalization::UnicodeNormalization as _;
801    text.nfkc()
802        .map(|c| match c {
803            '\u{2019}' | '\u{02bc}' | '\u{ff07}' => '\'',
804            c => c,
805        })
806        .collect()
807}
808
809/// Whether `quote` is whole clauses of `prompt`, starting and ending where
810/// a clause, a line or a sentence does, with no word in the rest of its
811/// sentences that turns it around, and no mark the person did not write.
812/// A sentence here runs on across a line break or a colon, so "do not:"
813/// on one line governs the line after it. Both are read [`plain`].
814fn quotes(prompt: &str, quote: &str) -> bool {
815    let (prompt, quote) = (&plain(prompt), &plain(quote));
816    let words = prompt_words(prompt);
817    let want: Vec<String> = prompt_words(quote).into_iter().map(|w| w.word).collect();
818    let n = want.len();
819    if n == 0 || n > words.len() {
820        return false;
821    }
822    let ends = |i: usize| {
823        words
824            .get(i)
825            .is_none_or(|w| w.clause_before || w.sentence_before)
826    };
827    (0..=words.len() - n).any(|i| {
828        let j = i + n;
829        if !ends(i) || !ends(j) || words[i..j].iter().zip(&want).any(|(w, q)| w.word != *q) {
830            return false;
831        }
832        let from = (0..=i).rev().find(|&k| words[k].stop_before).unwrap_or(0);
833        let to = (j..words.len())
834            .find(|&k| words[k].stop_before)
835            .unwrap_or(words.len());
836        let dropped = words[from..i].iter().chain(&words[j..to]);
837        !dropped.into_iter().any(|w| turns_it_around(&w.word))
838            && marks_kept(&prompt[words[i].start..words[j - 1].end]) == marks_kept(quote)
839    })
840}
841
842/// How `text` stands in the latest prompt: the person's words, words the
843/// hook kept but whose runner has no record that holds them, or neither.
844enum Latest {
845    Said,
846    No,
847}
848
849fn latest_words(text: &str) -> Latest {
850    let want = words_only(text);
851    if want.split(' ').count() < 4 {
852        return Latest::No;
853    }
854    let path = latest_prompt_path();
855    let fresh = std::fs::metadata(&path)
856        .and_then(|m| m.modified())
857        .ok()
858        .and_then(|t| t.elapsed().ok())
859        .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
860    if !fresh {
861        return Latest::No;
862    }
863    let kept = kept_prompt(&path);
864    if matches!(kept, Kept::No) {
865        return Latest::No;
866    }
867    let Ok(prompt) = std::fs::read_to_string(&path) else {
868        return Latest::No;
869    };
870    if !quotes(&prompt, text) {
871        return Latest::No;
872    }
873    match kept {
874        Kept::Yes => Latest::Said,
875        Kept::No => Latest::No,
876    }
877}
878
879/// Whether `text` is the person's words: whole clauses of the prompt they
880/// sent last ([`quotes`]), within [`LATEST_PROMPT_SECS`], and the file
881/// still matches the bind the hook wrote for this runner session. Four
882/// words or more, so "use bun" alone is not taken as a quote. A file
883/// written without that bind does not count. A runner with no positive
884/// record of the prompt is not this: see [`unverified_latest_prompt`].
885#[must_use]
886pub fn said_in_latest_prompt(text: &str) -> bool {
887    matches!(latest_words(text), Latest::Said)
888}
889
890/// The session key that keeps one stated choice from being saved twice.
891fn capture_key(text: &str) -> String {
892    use std::hash::{Hash, Hasher};
893    let mut h = std::collections::hash_map::DefaultHasher::new();
894    words_only(text).hash(&mut h);
895    format!("capture:{:016x}", h.finish())
896}
897
898/// How long the prompt hook waits for the pack to take a stated choice.
899/// Runners cut a hook off at 10 to 20 s, and the search behind it needs
900/// its own share.
901pub const CAPTURE_BUDGET_MS: u64 = 3000;
902
903thread_local! {
904    /// Whether the prompt this hook call carries is the person's own.
905    static PERSONS_PROMPT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
906}
907
908/// Say whose prompt this hook call carries: the person's when the runner's
909/// own program spawned the hook and the payload is not a subagent's brief
910/// or another thread's. What the hook writes from the prompt is labelled so.
911pub fn set_persons_prompt(persons: bool) {
912    PERSONS_PROMPT.with(|cell| cell.set(persons));
913}
914
915/// Whether this hook call's prompt is the person's ([`set_persons_prompt`]).
916#[must_use]
917pub fn persons_prompt() -> bool {
918    PERSONS_PROMPT.with(std::cell::Cell::get)
919}
920
921/// Write a stated choice, standing: the person's own claim, origin user
922/// and tagged `via:prompt`, when the prompt is theirs ([`persons_prompt`]),
923/// else an `agent-derived` one. The atom the pack answered with.
924fn save(stated: &Stated) -> Option<Value> {
925    let persons = persons_prompt();
926    with_pack_timeout(CAPTURE_BUDGET_MS, || {
927        let client = pack().ok()?;
928        let workspace = client.workspace();
929        let mut atom = atom_body(stated.kind, &stated.text, &workspace);
930        stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
931        if !persons {
932            return admit::write_agent(&client, atom).ok();
933        }
934        add_entities(&mut atom, [super::SAID_ENTITY.to_string()]);
935        admit::stamp_person(&mut atom);
936        let posted = admit::post_kept(&client, &atom).ok()?;
937        admit::satisfy_text(&stated.text);
938        Some(posted)
939    })
940}
941
942/// Session ids, and the `full <64 hex>` line when the bind has one.
943/// Anything else in the tail is a session. An older bind has no full line.
944fn bind_tail<'a>(lines: impl Iterator<Item = &'a str>) -> (Vec<String>, String) {
945    let mut sessions = Vec::new();
946    let mut full = String::new();
947    for line in lines {
948        if let Some(hash) = full_prompt_hash(line) {
949            full = hash;
950            continue;
951        }
952        let session = crate::ballot_sig::field(line);
953        if session != "-" {
954            sessions.push(session);
955        }
956    }
957    (sessions, full)
958}
959
960fn full_prompt_hash(line: &str) -> Option<String> {
961    let hash = line.strip_prefix("full ")?.trim();
962    (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
963}
964
965/// On a prompt that states a standing choice, save it (or nudge, per
966/// [`capture_mode`]) and return the one line the note carries and the
967/// keys to mark seen when the note is delivered. The saved atom's id is
968/// marked seen at once, so the same prompt's note does not repeat it.
969/// A prompt that is not the person's ([`persons_prompt`]) is saved as an
970/// agent's.
971#[must_use]
972pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
973    if call.event != "UserPromptSubmit" {
974        return None;
975    }
976    let mode = capture_mode();
977    if mode == CaptureMode::Off {
978        return None;
979    }
980    let stated = stated_preference(&call.cue)?;
981    let key = capture_key(&stated.text);
982    let session = call.session.as_deref();
983    if seen_ids(session).contains(&key) {
984        return None;
985    }
986    let verb = if stated.kind == "lesson" {
987        "remember"
988    } else {
989        "prefer"
990    };
991    if mode == CaptureMode::Nudge {
992        return Some((
993            format!(
994                "This prompt states a standing choice. Write it in the person's words with `ljos {verb} \"{}\"` so the hook raises it next time.",
995                stated.text.replace('"', "'")
996            ),
997            vec![key],
998        ));
999    }
1000    let Some(posted) = save(&stated) else {
1001        return Some((
1002            format!(
1003                "This prompt states a standing choice and the pack did not take it. Write it with `ljos {verb} \"{}\"`.",
1004                stated.text.replace('"', "'")
1005            ),
1006            vec![key],
1007        ));
1008    };
1009    let id = posted["id"].as_str().unwrap_or("").to_string();
1010    let mut now = vec![key.clone()];
1011    if !id.is_empty() {
1012        now.push(id.clone());
1013    }
1014    // The correction hook would store the same words again and say so;
1015    // they are already saved.
1016    if let Some(cue) = super::correction_cue(&call.cue) {
1017        now.push(format!("correction:{cue}"));
1018        now.push(format!("correction-stored:{cue}"));
1019    }
1020    mark_seen(session, &now);
1021    let what = if stated.kind == "lesson" {
1022        "lesson"
1023    } else {
1024        "preference"
1025    };
1026    let undo = if id.is_empty() {
1027        String::new()
1028    } else {
1029        format!(" `ljos forget {id}` takes it back.")
1030    };
1031    let persons = posted.pointer("/source/basis").and_then(Value::as_str) == Some("person");
1032    let line = if persons {
1033        format!(
1034            "Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
1035            stated.text
1036        )
1037    } else {
1038        format!(
1039            "Stored that sentence as an agent-derived {what}, not as the person's: \"{}\". \
1040             `ljos memories review` keeps or drops it.{undo}",
1041            stated.text
1042        )
1043    };
1044    Some((line, vec![key]))
1045}
1046
1047/// How much of the person's latest prompt a memory's provenance keeps.
1048const PROMPT_EXCERPT_CHARS: usize = 160;
1049
1050/// The opening of the person's latest prompt, as the reason an agent wrote
1051/// a memory now: whitespace folded, at most [`PROMPT_EXCERPT_CHARS`], and
1052/// any word shaped like a key, token or password replaced by `[redacted]`.
1053/// `None` when no prompt was kept in the last few minutes.
1054#[must_use]
1055pub fn latest_prompt_excerpt() -> Option<String> {
1056    let path = latest_prompt_path();
1057    let fresh = std::fs::metadata(&path)
1058        .and_then(|m| m.modified())
1059        .ok()
1060        .and_then(|t| t.elapsed().ok())
1061        .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
1062    if !fresh {
1063        return None;
1064    }
1065    let prompt = std::fs::read_to_string(&path).ok()?;
1066    let out = excerpt_of(&prompt);
1067    (!out.is_empty()).then_some(out)
1068}
1069
1070/// [`latest_prompt_excerpt`] of one prompt's text.
1071#[must_use]
1072pub fn excerpt_of(prompt: &str) -> String {
1073    let words: Vec<String> = prompt
1074        .split_whitespace()
1075        .map(|w| {
1076            if secret_shaped(w) {
1077                "[redacted]".to_string()
1078            } else {
1079                w.to_string()
1080            }
1081        })
1082        .collect();
1083    let joined = words.join(" ");
1084    if joined.chars().count() <= PROMPT_EXCERPT_CHARS {
1085        return joined;
1086    }
1087    let mut cut: String = joined.chars().take(PROMPT_EXCERPT_CHARS - 1).collect();
1088    cut.push('…');
1089    cut
1090}
1091
1092/// A word that may be a credential: a `key=`/`token:` style assignment, a
1093/// known key prefix, or a long run of mixed letters and digits.
1094fn secret_shaped(word: &str) -> bool {
1095    let lower = word.to_ascii_lowercase();
1096    if [
1097        "key=",
1098        "token=",
1099        "secret=",
1100        "password=",
1101        "passwd=",
1102        "key:",
1103        "token:",
1104        "secret:",
1105        "password:",
1106    ]
1107    .iter()
1108    .any(|p| lower.contains(p))
1109    {
1110        return true;
1111    }
1112    if [
1113        "sk-",
1114        "ghp_",
1115        "gho_",
1116        "github_pat_",
1117        "xox",
1118        "akia",
1119        "glpat-",
1120    ]
1121    .iter()
1122    .any(|p| lower.starts_with(p))
1123    {
1124        return true;
1125    }
1126    let core: String = word.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
1127    core.len() >= 24
1128        && core.chars().any(|c| c.is_ascii_digit())
1129        && core.chars().any(|c| c.is_ascii_alphabetic())
1130        && !word.contains('/')
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136
1137    const SAVES: &[&str] = &[
1138        "From now on always use bun, never yarn, in this repo",
1139        "Always run cargo fmt before committing.",
1140        "Never push directly to main.",
1141        "Going forward, write commit messages in the imperative mood.",
1142        "Please always use pnpm for installs.",
1143        "Remember that the staging database is read-only on Fridays.",
1144        "Use bun, never yarn.",
1145        "From now on, run the linter before every push.",
1146        "Don't ever commit the .env file.",
1147        "Never use force push on shared branches.",
1148        "ok, from now on use tabs in Go files",
1149        "We always squash merge pull requests here.",
1150        "Always ask before running a database migration.",
1151        "In future, put new tests next to the code they cover.",
1152        "Stop using unwrap in library code.",
1153        "I prefer small commits with one change each.",
1154        "Henceforth all scripts use bash strict mode.",
1155        "Remember to bump the version in Cargo.toml before tagging.",
1156        "Never deploy on a Friday afternoon.",
1157        "Keep it short, and from now on skip the summary at the end.",
1158        "Always write `set -euo pipefail` at the top of shell scripts.",
1159        "Make sure you always run the tests first.",
1160        "We never edit an applied migration.",
1161        "Thanks. From now on use uv instead of pip.",
1162        "Use rg instead of grep going forward.",
1163        "Always prefer rtrash over rm.",
1164        "Never commit secrets to the repo.",
1165        "Please never run npm install in this project.",
1166        "From here on, write docs one sentence per line.",
1167        "Also, always pin action versions by SHA.",
1168    ];
1169
1170    const SAVES_NOTHING: &[&str] = &[
1171        "Why do you always use yarn?",
1172        "Should we always run the full suite?",
1173        "Does this ever fail on CI?",
1174        "Fix the failing test in parser.rs",
1175        "You never listen to me",
1176        "It always fails on the second run",
1177        "Never mind, that was my mistake.",
1178        "```\nalways_on = true\nnever_retry = false\n```",
1179        "> Never push directly to main\nwhat does this rule mean",
1180        "The doc says \"always use bun, never yarn, for this repo\" but is that right",
1181        "error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
1182        "This could break in the future.",
1183        "Run the tests and tell me what fails",
1184        "I always forget how this works, can you explain it",
1185        "Never seen this error before, what is it",
1186        "Always the same problem with this build",
1187        "What's the best way to always keep the cache warm?",
1188        "Add a flag `--always-retry` to the CLI",
1189        "Rename never_called to unused in lib.rs",
1190        "Can you remember that function we wrote yesterday?",
1191        "It never worked on macOS anyway",
1192        "Remember when the build broke last week",
1193        "Is it true that git never forgets a commit",
1194        "Please look at the failing job",
1195        "Traceback (most recent call last):\n  File \"a.py\", line 3\nerror: always broken\nwarning: never set",
1196        "Always?",
1197        "never",
1198        "The upstream README says to always use yarn",
1199        "Explain why we never got the webhook",
1200        "Going forward is the only option for the migration",
1201    ];
1202
1203    /// The full-prompt hash sits after the session ids and is not a session.
1204    /// An older bind, with no such line, still yields its sessions.
1205    #[test]
1206    fn the_full_prompt_line_is_not_a_session() {
1207        let hash = "ab".repeat(32);
1208        let (sessions, full) = bind_tail(
1209            format!("sess-1\nfull {hash}\n-")
1210                .lines()
1211                .collect::<Vec<_>>()
1212                .into_iter(),
1213        );
1214        assert_eq!(sessions, ["sess-1"]);
1215        assert_eq!(full, hash);
1216        let (sessions, full) = bind_tail("sess-1\n".lines());
1217        assert_eq!(sessions, ["sess-1"]);
1218        assert!(full.is_empty());
1219        assert!(full_prompt_hash("full not-a-hash").is_none());
1220    }
1221
1222    /// A command harness subagent's brief, the command harness's review thread and an unattended
1223    /// run are not the person; the root thread's prompt and the terminal harness's
1224    /// main thread are.
1225    #[test]
1226    fn a_subagent_brief_or_unattended_run_is_not_the_person() {
1227        let _g = crate::tests::env_guard();
1228        unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1229        let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1230        let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
1231        let codex = |extra: &str, thread: &str| {
1232            format!(
1233                r#"{{"hook_event_name":"UserPromptSubmit","session_id":"{root}","turn_id":"t-1","prompt":"Always run the tests with --release","transcript_path":"/home/u/.codex/sessions/2026/10/11/rollout-2026-10-11T09-30-00-{thread}.jsonl"{extra}}}"#
1234            )
1235        };
1236        assert_eq!(not_the_person(&codex("", root)), None);
1237        assert_eq!(
1238            not_the_person(&codex(
1239                r#","agent_id":"019a-sub","agent_type":"worker""#,
1240                root
1241            )),
1242            Some("a subagent's brief")
1243        );
1244        assert_eq!(
1245            not_the_person(&codex("", other)),
1246            Some("another thread's prompt")
1247        );
1248        let claude_main = r#"{"hook_event_name":"UserPromptSubmit","session_id":"abc","prompt":"From now on use uv","transcript_path":"/home/u/.claude/projects/x/abc.jsonl","agent_type":"reviewer"}"#;
1249        assert_eq!(
1250            not_the_person(claude_main),
1251            None,
1252            "--agent alone is the main thread"
1253        );
1254        let claude_sub =
1255            claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
1256        assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
1257        unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
1258        assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
1259        unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
1260        assert_eq!(not_the_person(claude_main), None);
1261        unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1262    }
1263
1264    #[test]
1265    fn a_rollout_name_gives_its_thread() {
1266        let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1267        assert_eq!(
1268            rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
1269            Some(id)
1270        );
1271        assert_eq!(
1272            rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
1273            Some(id)
1274        );
1275        assert_eq!(rollout_thread("/x/abc.jsonl"), None);
1276        assert_eq!(
1277            rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
1278            None
1279        );
1280    }
1281
1282    #[test]
1283    fn thirty_stated_choices_are_read() {
1284        assert_eq!(SAVES.len(), 30);
1285        let missed: Vec<&&str> = SAVES
1286            .iter()
1287            .filter(|p| stated_preference(p).is_none())
1288            .collect();
1289        assert!(
1290            missed.is_empty(),
1291            "not read as a standing choice: {missed:#?}"
1292        );
1293    }
1294
1295    #[test]
1296    fn thirty_other_prompts_save_nothing() {
1297        assert_eq!(SAVES_NOTHING.len(), 30);
1298        let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
1299            .iter()
1300            .filter_map(|p| stated_preference(p).map(|s| (p, s)))
1301            .collect();
1302        assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
1303    }
1304
1305    #[test]
1306    fn the_saved_words_are_the_persons_sentence() {
1307        let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
1308        assert_eq!(s.kind, "preference");
1309        assert_eq!(
1310            s.text,
1311            "From now on always use bun, never yarn, in this repo"
1312        );
1313        let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
1314        assert_eq!(s.text, "From now on use tabs in Go files");
1315        let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
1316        assert_eq!(s.text, "From now on use uv instead of pip.");
1317        let s = stated_preference("Remember that the staging database is read-only on Fridays.")
1318            .unwrap();
1319        assert_eq!(s.kind, "lesson");
1320        assert_eq!(s.text, "The staging database is read-only on Fridays.");
1321    }
1322
1323    #[test]
1324    fn the_mode_reads_ljos_capture() {
1325        let _g = crate::tests::env_guard();
1326        let prev = std::env::var_os("LJOS_CAPTURE");
1327        for (v, want) in [
1328            ("", CaptureMode::Save),
1329            ("save", CaptureMode::Save),
1330            ("nudge", CaptureMode::Nudge),
1331            ("off", CaptureMode::Off),
1332            ("bogus", CaptureMode::Save),
1333        ] {
1334            unsafe { std::env::set_var("LJOS_CAPTURE", v) };
1335            assert_eq!(capture_mode(), want, "{v}");
1336        }
1337        match prev {
1338            Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
1339            None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
1340        }
1341    }
1342
1343    #[test]
1344    fn words_compare_whatever_the_punctuation() {
1345        assert_eq!(words_only("Use bun,  never   yarn!"), "use bun never yarn");
1346    }
1347
1348    #[test]
1349    fn a_typographic_apostrophe_still_turns_the_clause_around() {
1350        let cut = "always paste the release key into the deploy form";
1351        for apostrophe in ['\u{2019}', '\u{02bc}', '\u{ff07}'] {
1352            let prompt = format!(
1353                "Don{apostrophe}t do this: always paste the release key into the deploy form."
1354            );
1355            assert!(!quotes(&prompt, cut), "accepted under {apostrophe:?}");
1356            assert!(
1357                quotes(
1358                    &prompt,
1359                    "Don't do this: always paste the release key into the deploy form."
1360                ),
1361                "refused the whole sentence under {apostrophe:?}"
1362            );
1363        }
1364        assert!(quotes(
1365            "Use bun. Always paste the release key into the deploy form.",
1366            cut
1367        ));
1368    }
1369
1370    #[test]
1371    fn a_dangling_negation_is_joined_before_the_rule_is_read() {
1372        assert!(stated_preference("Do not\nalways use npm").is_none());
1373        assert!(stated_preference("Do not do this:\nalways use npm").is_none());
1374        for prompt in ["Don\u{2019}t\nalways use npm", "Don't\nalways use npm"] {
1375            if let Some(saved) = stated_preference(prompt) {
1376                let text = saved.text.to_lowercase();
1377                assert!(
1378                    text.contains("not") || text.contains("n't"),
1379                    "{prompt:?} saved {text}"
1380                );
1381                assert_ne!(text, "always use npm", "{prompt:?}");
1382            }
1383        }
1384        let saved = stated_preference("Never\npush directly to main.").unwrap();
1385        assert_eq!(saved.text, "Never push directly to main.");
1386        let saved = stated_preference("Always use npm.").unwrap();
1387        assert_eq!(saved.text, "Always use npm.");
1388        let saved = stated_preference("Do not\n\nAlways use npm.").unwrap();
1389        assert_eq!(saved.text, "Always use npm.");
1390        let saved = stated_preference("Always use npm.\nNever push directly to main.").unwrap();
1391        assert_eq!(saved.text, "Always use npm.");
1392    }
1393
1394    #[test]
1395    fn a_negation_on_the_line_before_still_governs_the_quote() {
1396        let cut = "always paste the release key into the deploy form";
1397        for prompt in [
1398            "Do not\nalways paste the release key into the deploy form.",
1399            "Do not do this:\nalways paste the release key into the deploy form.",
1400            "Please never:\n\talways paste the release key into the deploy form",
1401            "Always paste the release key into the deploy form\nunless asked.",
1402        ] {
1403            assert!(!quotes(prompt, cut), "accepted from {prompt:?}");
1404        }
1405        assert!(quotes(
1406            "Do not\nalways paste the release key into the deploy form.",
1407            "Do not always paste the release key into the deploy form."
1408        ));
1409        for prompt in [
1410            "Use bun.\nAlways paste the release key into the deploy form.",
1411            "Do not use yarn\n\nAlways paste the release key into the deploy form.",
1412        ] {
1413            assert!(quotes(prompt, cut), "refused from {prompt:?}");
1414        }
1415    }
1416}