1use serde_json::Value;
17use sha2::Digest as _;
18
19use super::{
20 add_entities, admit, atom_body, mark_seen, pack, pasted_or_quoted, runtime_dir, seen_ids,
21 stamp_horizon, with_pack_timeout, HookCall,
22};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CaptureMode {
27 Save,
29 Nudge,
31 Off,
33}
34
35#[must_use]
37pub fn capture_mode() -> CaptureMode {
38 match std::env::var("LJOS_CAPTURE")
39 .unwrap_or_default()
40 .trim()
41 .to_ascii_lowercase()
42 .as_str()
43 {
44 "nudge" => CaptureMode::Nudge,
45 "off" | "0" | "no" | "false" => CaptureMode::Off,
46 _ => CaptureMode::Save,
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Stated {
54 pub kind: &'static str,
56 pub text: String,
57}
58
59pub const STATED_MAX_CHARS: usize = 200;
62
63const LEADS: &[&str] = &[
65 "from now on",
66 "from here on",
67 "henceforth",
68 "always ",
69 "never ",
70 "remember that ",
71 "remember to ",
72 "do not ever ",
73 "don't ever ",
74 "dont ever ",
75 "make sure to always ",
76 "make sure you always ",
77 "make sure you never ",
78 "i prefer ",
79 "we prefer ",
80 "we always ",
81 "we never ",
82 "stop using ",
83];
84
85const COMMA_LEADS: &[&str] = &["going forward,", "in future,", "in the future,"];
88
89const ANYWHERE: &[&str] = &["from now on", "from here on", "henceforth"];
91
92const ENDINGS: &[&str] = &["from now on", "going forward", "in future", "from here on"];
95
96const NOT_A_RULE: &[&str] = &[
99 "mind", "again", "the", "a", "an", "been", "seen", "heard", "was", "were", "is", "are", "had",
100 "did", "worked", "works", "work", "happened", "happens", "thought", "knew", "got", "gets",
101 "fails", "failed", "breaks", "broke", "on", "in", "at", "for", "with", "by", "when", "since",
102 "before", "after", "really", "quite", "sure", "ever", "once", "seems", "seemed",
103];
104
105const FILLER: &[&str] = &[
107 "ok", "okay", "so", "also", "and", "btw", "oh", "hey", "note", "fyi", "please", "but",
108];
109
110const QUESTION_OPENINGS: &[&str] = &[
112 "what", "why", "how", "can", "could", "should", "would", "do", "does", "did", "is", "are",
113 "will", "which", "who", "where", "when", "was", "were", "shall",
114];
115
116fn dangling_negation(word: &str) -> bool {
119 let word = plain(word).to_lowercase();
120 let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
121 matches!(
122 word,
123 "not" | "never" | "no" | "nor" | "dont" | "don't" | "cannot" | "without" | "avoid"
124 ) || word.ends_with("n't")
125}
126
127fn line_continues(line: &str) -> bool {
132 let line = line.trim_end();
133 if line.is_empty() {
134 return false;
135 }
136 let last = line.chars().next_back().unwrap_or(' ');
137 if matches!(last, '.' | '!' | '?') {
138 return false;
139 }
140 if matches!(last, ':' | ',' | ';' | '\u{2014}' | '\u{2013}') {
141 return true;
142 }
143 let word = line
144 .rsplit(|c: char| {
145 !c.is_alphanumeric() && !matches!(c, '\'' | '\u{2019}' | '\u{02bc}' | '\u{ff07}')
146 })
147 .next()
148 .unwrap_or("");
149 dangling_negation(word)
150}
151
152fn sentences(prompt: &str) -> Vec<String> {
158 let mut out = Vec::new();
159 let lines: Vec<&str> = prompt.lines().collect();
160 let mut index = 0;
161 while index < lines.len() {
162 if lines[index].trim().is_empty() {
163 index += 1;
164 continue;
165 }
166 let mut joined = lines[index].trim().to_string();
167 index += 1;
168 while line_continues(&joined) && index < lines.len() && !lines[index].trim().is_empty() {
169 joined.push(' ');
170 joined.push_str(lines[index].trim());
171 index += 1;
172 }
173 let mut cur = String::new();
174 let chars: Vec<char> = joined.chars().collect();
175 for (i, c) in chars.iter().enumerate() {
176 cur.push(*c);
177 let ends =
178 matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace());
179 if ends {
180 let s = cur.trim().to_string();
181 if !s.is_empty() {
182 out.push(s);
183 }
184 cur.clear();
185 }
186 }
187 let s = cur.trim().to_string();
188 if !s.is_empty() {
189 out.push(s);
190 }
191 }
192 out
193}
194
195fn outside_code(text: &str) -> String {
198 let mut out = String::with_capacity(text.len());
199 let mut inside = false;
200 for c in text.chars() {
201 if c == '`' {
202 inside = !inside;
203 out.push(' ');
204 } else if inside {
205 out.push('x');
206 } else {
207 out.push(c);
208 }
209 }
210 out
211}
212
213fn strip_filler(s: &str) -> &str {
216 let mut rest = s.trim_start();
217 loop {
218 let word_end = rest
219 .find(|c: char| !c.is_alphanumeric() && c != '\'')
220 .unwrap_or(rest.len());
221 let word = rest[..word_end].to_ascii_lowercase();
222 if word.is_empty() || !FILLER.contains(&word.as_str()) {
223 return rest;
224 }
225 let after = &rest[word_end..];
226 let after = after.trim_start_matches(|c: char| c == ',' || c == ':' || c.is_whitespace());
227 if after.len() == rest.len() || after.is_empty() {
228 return rest;
229 }
230 rest = after;
231 }
232}
233
234fn clause_leads(clause: &str) -> bool {
236 let c = clause.trim_start();
237 LEADS.iter().any(|lead| {
238 let Some(rest) = c.strip_prefix(lead) else {
239 return false;
240 };
241 if matches!(*lead, "always " | "never ") {
242 let next = rest
243 .split(|ch: char| !ch.is_alphanumeric() && ch != '\'')
244 .next()
245 .unwrap_or("");
246 return !next.is_empty() && !NOT_A_RULE.contains(&next);
247 }
248 true
249 })
250}
251
252fn states_a_rule(sentence: &str) -> bool {
254 let words = sentence.split_whitespace().count();
255 if !(3..=40).contains(&words) || sentence.chars().count() > STATED_MAX_CHARS {
256 return false;
257 }
258 if sentence.trim_end().ends_with('?') {
259 return false;
260 }
261 let plain = outside_code(strip_filler(sentence)).to_lowercase();
262 let first = plain
263 .split(|c: char| !c.is_alphanumeric() && c != '\'')
264 .next()
265 .unwrap_or("");
266 if QUESTION_OPENINGS.contains(&first) || first == "you" {
267 return false;
268 }
269 let bare = plain.trim_end_matches(|c: char| !c.is_alphanumeric());
270 if ANYWHERE.iter().any(|p| plain.contains(p))
271 || ENDINGS.iter().any(|p| bare.ends_with(p))
272 || COMMA_LEADS
273 .iter()
274 .any(|p| plain.trim_start().starts_with(p))
275 {
276 return true;
277 }
278 plain.split([',', ';']).any(clause_leads)
279}
280
281fn saved_words(sentence: &str) -> Stated {
284 let s = strip_filler(sentence).trim();
285 let lower = s.to_lowercase();
286 let (kind, body) = match lower.strip_prefix("remember that ") {
287 Some(_) => ("lesson", &s["remember that ".len()..]),
288 None => ("preference", s),
289 };
290 let mut chars = body.trim().chars();
291 let text = match chars.next() {
292 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
293 None => String::new(),
294 };
295 Stated { kind, text }
296}
297
298#[must_use]
304pub fn stated_preference(prompt: &str) -> Option<Stated> {
305 if pasted_or_quoted(prompt) {
306 return None;
307 }
308 sentences(prompt)
309 .into_iter()
310 .find(|s| states_a_rule(s))
311 .map(|s| saved_words(&s))
312 .filter(|s| s.text.split_whitespace().count() >= 2)
313}
314
315#[must_use]
319pub fn unattended() -> bool {
320 std::env::var("LJOS_UNATTENDED").is_ok_and(|v| {
321 matches!(
322 v.trim().to_ascii_lowercase().as_str(),
323 "1" | "true" | "yes" | "on"
324 )
325 })
326}
327
328fn rollout_thread(path: &str) -> Option<String> {
332 let name = path.rsplit(['/', '\\']).next()?;
333 let rest = name.strip_prefix("rollout-")?;
334 let stem = &rest[..rest.find(".jsonl")?];
335 let id = stem.get(20..56)?;
337 let tail = &stem[56..];
338 let uuid_shape = id.char_indices().all(|(i, c)| {
339 if matches!(i, 8 | 13 | 18 | 23) {
340 c == '-'
341 } else {
342 c.is_ascii_hexdigit()
343 }
344 });
345 (uuid_shape && (tail.is_empty() || tail.starts_with('_'))).then(|| id.to_ascii_lowercase())
346}
347
348#[must_use]
356pub fn not_the_person(input: &str) -> Option<&'static str> {
357 if unattended() {
358 return Some("LJOS_UNATTENDED is set");
359 }
360 let v: Value = serde_json::from_str(input.trim()).ok()?;
361 let named = |key: &str| {
362 v.get(key)
363 .and_then(Value::as_str)
364 .is_some_and(|s| !s.trim().is_empty())
365 };
366 if named("agent_id") || named("agentId") {
367 return Some("a subagent's brief");
368 }
369 let session = v
370 .get("session_id")
371 .and_then(Value::as_str)
372 .map(|s| s.trim().to_ascii_lowercase())
373 .filter(|s| !s.is_empty());
374 let thread = v
375 .get("transcript_path")
376 .and_then(Value::as_str)
377 .and_then(rollout_thread);
378 match (session, thread) {
379 (Some(session), Some(thread)) if session != thread => Some("another thread's prompt"),
380 _ => None,
381 }
382}
383
384pub const LATEST_PROMPT_SECS: u64 = 3600;
386
387const LATEST_PROMPT_CHARS: usize = crate::turn_record::KEPT_PROMPT_CHARS;
390
391fn latest_prompt_path() -> std::path::PathBuf {
392 runtime_dir().join("latest-prompt")
393}
394
395pub fn note_latest_prompt(prompt: &str) {
403 note_latest_prompt_for(prompt, None);
404}
405
406pub fn note_latest_prompt_for(prompt: &str, session: Option<&str>) {
408 let sessions: Vec<String> = session.map(str::to_string).into_iter().collect();
409 note_latest_prompt_as(prompt, &sessions);
410}
411
412pub fn note_latest_prompt_as(prompt: &str, sessions: &[String]) {
416 note_latest_prompt_from(prompt, sessions, None, None);
417}
418
419pub fn forget_latest_prompt() {
423 let _ = std::fs::remove_file(latest_prompt_path());
424 let _ = std::fs::remove_file(latest_prompt_bind_path());
425}
426
427pub fn note_latest_prompt_from(
433 prompt: &str,
434 sessions: &[String],
435 runner: Option<&crate::proc_tree::Proc>,
436 transcript: Option<&str>,
437) {
438 if capture_mode() == CaptureMode::Off || pasted_or_quoted(prompt) {
439 return;
440 }
441 let path = latest_prompt_path();
442 if let Some(dir) = path.parent() {
443 let _ = std::fs::create_dir_all(dir);
444 }
445 let text: String = prompt.chars().take(LATEST_PROMPT_CHARS).collect();
446 use std::io::Write as _;
447 use std::os::unix::fs::OpenOptionsExt as _;
448 let wrote = std::fs::OpenOptions::new()
449 .write(true)
450 .create(true)
451 .truncate(true)
452 .mode(0o600)
453 .open(&path)
454 .and_then(|mut f| f.write_all(text.as_bytes()));
455 if wrote.is_ok() {
456 let bind = Bind {
457 runner: runner.map_or_else(|| "-".to_string(), crate::proc_tree::token),
458 at: now_nanos(),
459 transcript: bind_session(transcript),
460 };
461 let full_hash = crate::turn_record::folded_hash(prompt);
462 let _ = write_prompt_bind(&path, text.as_bytes(), &bind, sessions, &full_hash);
463 }
464}
465
466struct Bind {
469 runner: String,
470 at: u128,
471 transcript: String,
472}
473
474fn now_nanos() -> u128 {
475 std::time::SystemTime::now()
476 .duration_since(std::time::UNIX_EPOCH)
477 .map_or(0, |d| d.as_nanos())
478}
479
480#[must_use]
483pub fn payload_transcript(input: &str) -> Option<String> {
484 let v: Value = serde_json::from_str(input.trim()).ok()?;
485 v["transcript_path"]
486 .as_str()
487 .or_else(|| v["transcriptPath"].as_str())
488 .map(str::trim)
489 .filter(|s| !s.is_empty())
490 .map(str::to_string)
491}
492
493const BIND_WRITE_NANOS: u128 = 500_000_000;
495
496fn changed_nanos(path: &std::path::Path) -> Option<u128> {
499 use std::os::unix::fs::MetadataExt as _;
500 let m = std::fs::metadata(path).ok()?;
501 let at = |s: i64, ns: i64| -> Option<u128> {
502 Some(u128::try_from(s).ok()? * 1_000_000_000 + u128::try_from(ns).ok()?)
503 };
504 Some(at(m.ctime(), m.ctime_nsec())?.max(at(m.mtime(), m.mtime_nsec())?))
505}
506
507enum RecordWord {
509 Agrees,
513 Disagrees,
516 Unverified,
520}
521
522fn needs_positive_record(kind: &str) -> bool {
526 matches!(
527 kind,
528 "cursor" | "cursor-agent" | "grok" | "opencode" | "gemini" | "agy"
529 )
530}
531
532fn agrees_with_record(
541 kept: &[u8],
542 named: &str,
543 runner_ids: &[String],
544 full_hash: &str,
545) -> RecordWord {
546 use crate::turn_record;
547 let Some((runner, kind)) = crate::proc_tree::runner_above() else {
548 return RecordWord::Agrees;
549 };
550 let positive = needs_positive_record(kind);
551 let word = |ok: bool| {
552 if positive {
553 RecordWord::Unverified
554 } else if ok {
555 RecordWord::Agrees
556 } else {
557 RecordWord::Disagrees
558 }
559 };
560 let env = crate::proc_tree::environ(runner.pid).unwrap_or_default();
561 let roots = turn_record::roots(kind, &env);
562 if roots.is_empty() {
563 return word(true);
564 }
565 let record = if named == "-" {
566 roots
567 .iter()
568 .find_map(|r| turn_record::cursor_record(r, runner_ids))
569 .filter(|_| matches!(kind, "cursor" | "cursor-agent"))
570 } else {
571 turn_record::within(std::path::Path::new(named), &roots)
572 };
573 let Some(record) = record else {
574 return word(named == "-" && !turn_record::required(kind));
575 };
576 let Some(text) = crate::bookkeep::read_text_bounded(&record)
578 .filter(|t| !turn_record::latest_turn(t).is_empty())
579 .or_else(|| {
580 record
581 .is_file()
582 .then(|| std::fs::read_to_string(&record).ok())
583 .flatten()
584 })
585 else {
586 return word(false);
587 };
588 if !turn_record::required(kind) && turn_record::latest_turn(&text).is_empty() {
589 return word(true);
590 }
591 if turn_record::agrees_truncated(&text, &String::from_utf8_lossy(kept), full_hash) {
592 RecordWord::Agrees
593 } else {
594 word(false)
595 }
596}
597
598fn latest_prompt_bind_path() -> std::path::PathBuf {
599 runtime_dir().join("latest-prompt.bind")
600}
601
602fn mtime_nanos(path: &std::path::Path) -> Option<u128> {
603 std::fs::metadata(path)
604 .ok()?
605 .modified()
606 .ok()?
607 .duration_since(std::time::UNIX_EPOCH)
608 .ok()
609 .map(|age| age.as_nanos())
610}
611
612fn bind_session(session: Option<&str>) -> String {
613 match session
614 .map(str::trim)
615 .filter(|s| !s.is_empty() && !s.contains(['\n', '\r']))
616 {
617 Some(s) => s.to_string(),
618 None => "-".to_string(),
619 }
620}
621
622fn write_prompt_bind(
632 path: &std::path::Path,
633 bytes: &[u8],
634 bind: &Bind,
635 sessions: &[String],
636 full_hash: &str,
637) -> std::io::Result<()> {
638 use std::io::Write as _;
639 use std::os::unix::fs::OpenOptionsExt as _;
640 let Some(mtime) = mtime_nanos(path) else {
641 return Err(std::io::Error::other("prompt mtime"));
642 };
643 let hash = hex::encode(sha2::Sha256::digest(bytes));
644 let mut ids: Vec<String> = Vec::new();
645 for s in sessions {
646 let s = bind_session(Some(s));
647 if s != "-" && !ids.contains(&s) {
648 ids.push(s);
649 }
650 }
651 if ids.is_empty() {
652 ids.push(bind_session(None));
653 }
654 let body = format!(
655 "3\n{hash}\n{mtime}\n{}\n{}\n{}\n{}\nfull {full_hash}\n",
656 bind.runner,
657 bind.at,
658 bind.transcript,
659 ids.join("\n")
660 );
661 let mut f = std::fs::OpenOptions::new()
662 .write(true)
663 .create(true)
664 .truncate(true)
665 .mode(0o600)
666 .open(latest_prompt_bind_path())?;
667 f.write_all(body.as_bytes())
668}
669
670enum Kept {
672 Yes,
674 Unverified,
676 No,
678}
679
680fn kept_prompt(path: &std::path::Path) -> Kept {
695 let Ok(bytes) = std::fs::read(path) else {
696 return Kept::No;
697 };
698 let Some(mtime) = mtime_nanos(path) else {
699 return Kept::No;
700 };
701 let bind_path = latest_prompt_bind_path();
702 let Ok(bind) = std::fs::read_to_string(&bind_path) else {
703 return Kept::No;
704 };
705 let mut lines = bind.lines();
706 let (Some("3"), Some(hash), Some(stored), Some(spawner), Some(at), Some(transcript)) = (
707 lines.next(),
708 lines.next(),
709 lines.next(),
710 lines.next(),
711 lines.next().and_then(|t| t.parse::<u128>().ok()),
712 lines.next(),
713 ) else {
714 return Kept::No;
715 };
716 let late = |p: &std::path::Path| {
717 changed_nanos(p).is_none_or(|c| c > at.saturating_add(BIND_WRITE_NANOS))
718 };
719 if late(path) || late(&bind_path) {
720 return Kept::No;
721 }
722 let (bound, full_hash) = bind_tail(lines);
723 let runner = crate::ballot_sig::runner_sessions();
724 let above = spawner != "-"
725 && crate::proc_tree::ancestors()
726 .iter()
727 .any(|p| crate::proc_tree::token(p) == spawner);
728 if !(above || bound.iter().any(|s| runner.contains(s))) {
729 return Kept::No;
730 }
731 if let Some(call) = crate::ballot_sig::call_session() {
732 let within = if runner.is_empty() { &bound } else { &runner };
733 if !within.contains(&call) {
734 return Kept::No;
735 }
736 }
737 if hash != hex::encode(sha2::Sha256::digest(&bytes)) || stored != mtime.to_string() {
738 return Kept::No;
739 }
740 match agrees_with_record(&bytes, transcript, &runner, &full_hash) {
741 RecordWord::Agrees => Kept::Yes,
742 RecordWord::Unverified => Kept::Unverified,
743 RecordWord::Disagrees => Kept::No,
744 }
745}
746
747fn words_only(text: &str) -> String {
750 text.split(|c: char| !c.is_alphanumeric() && c != '\'')
751 .filter(|w| !w.is_empty())
752 .map(str::to_lowercase)
753 .collect::<Vec<_>>()
754 .join(" ")
755}
756
757struct Word {
762 word: String,
763 start: usize,
764 end: usize,
765 sentence_before: bool,
766 stop_before: bool,
767 clause_before: bool,
768}
769
770fn prompt_words(text: &str) -> Vec<Word> {
771 let mut out: Vec<Word> = Vec::new();
772 let (mut sentence, mut stop, mut clause) = (true, true, true);
773 let mut line_ended = false;
774 let mut at = None;
775 let close =
776 |out: &mut Vec<Word>, from: usize, to: usize, s: &mut bool, p: &mut bool, c: &mut bool| {
777 let raw = &text[from..to];
778 let lead = raw.len() - raw.trim_start_matches('\'').len();
779 let word = raw.trim_matches('\'');
780 if !word.is_empty() {
781 out.push(Word {
782 word: word.to_lowercase(),
783 start: from + lead,
784 end: from + lead + word.len(),
785 sentence_before: std::mem::take(s),
786 stop_before: std::mem::take(p),
787 clause_before: std::mem::take(c),
788 });
789 }
790 };
791 for (i, ch) in text.char_indices() {
792 if ch.is_alphanumeric() || ch == '\'' {
793 at.get_or_insert(i);
794 continue;
795 }
796 if let Some(from) = at.take() {
797 close(&mut out, from, i, &mut sentence, &mut stop, &mut clause);
798 line_ended = false;
799 }
800 if matches!(ch, '.' | '!' | '?' | ';' | '\n') {
801 sentence = true;
802 }
803 if matches!(ch, '.' | '!' | '?' | ';') || (ch == '\n' && line_ended) {
804 stop = true;
805 }
806 if ch == '\n' {
807 line_ended = true;
808 }
809 if matches!(
810 ch,
811 ',' | ':'
812 | '('
813 | ')'
814 | '['
815 | ']'
816 | '"'
817 | '\u{201c}'
818 | '\u{201d}'
819 | '\u{2013}'
820 | '\u{2014}'
821 ) {
822 clause = true;
823 }
824 }
825 if let Some(from) = at {
826 close(
827 &mut out,
828 from,
829 text.len(),
830 &mut sentence,
831 &mut stop,
832 &mut clause,
833 );
834 }
835 out
836}
837
838fn turns_it_around(word: &str) -> bool {
841 matches!(
842 word,
843 "not"
844 | "never"
845 | "no"
846 | "nor"
847 | "dont"
848 | "cannot"
849 | "without"
850 | "avoid"
851 | "unless"
852 | "except"
853 | "if"
854 | "only"
855 | "until"
856 ) || word.ends_with("n't")
857}
858
859fn marks_kept(text: &str) -> String {
862 text.chars()
863 .filter(|c| {
864 !matches!(
865 c,
866 '.' | ','
867 | ';'
868 | ':'
869 | '!'
870 | '?'
871 | '\''
872 | '"'
873 | '('
874 | ')'
875 | '-'
876 | '\u{2018}'
877 | '\u{2019}'
878 | '\u{201c}'
879 | '\u{201d}'
880 | '\u{2013}'
881 | '\u{2014}'
882 )
883 })
884 .collect::<String>()
885 .split_whitespace()
886 .map(str::to_lowercase)
887 .collect::<Vec<_>>()
888 .join(" ")
889}
890
891fn plain(text: &str) -> String {
894 use unicode_normalization::UnicodeNormalization as _;
895 text.nfkc()
896 .map(|c| match c {
897 '\u{2019}' | '\u{02bc}' | '\u{ff07}' => '\'',
898 c => c,
899 })
900 .collect()
901}
902
903fn quotes(prompt: &str, quote: &str) -> bool {
909 let (prompt, quote) = (&plain(prompt), &plain(quote));
910 let words = prompt_words(prompt);
911 let want: Vec<String> = prompt_words(quote).into_iter().map(|w| w.word).collect();
912 let n = want.len();
913 if n == 0 || n > words.len() {
914 return false;
915 }
916 let ends = |i: usize| {
917 words
918 .get(i)
919 .is_none_or(|w| w.clause_before || w.sentence_before)
920 };
921 (0..=words.len() - n).any(|i| {
922 let j = i + n;
923 if !ends(i) || !ends(j) || words[i..j].iter().zip(&want).any(|(w, q)| w.word != *q) {
924 return false;
925 }
926 let from = (0..=i).rev().find(|&k| words[k].stop_before).unwrap_or(0);
927 let to = (j..words.len())
928 .find(|&k| words[k].stop_before)
929 .unwrap_or(words.len());
930 let dropped = words[from..i].iter().chain(&words[j..to]);
931 !dropped.into_iter().any(|w| turns_it_around(&w.word))
932 && marks_kept(&prompt[words[i].start..words[j - 1].end]) == marks_kept(quote)
933 })
934}
935
936enum Latest {
939 Said,
940 Unverified,
941 No,
942}
943
944fn latest_words(text: &str) -> Latest {
945 let want = words_only(text);
946 if want.split(' ').count() < 4 {
947 return Latest::No;
948 }
949 let path = latest_prompt_path();
950 let fresh = std::fs::metadata(&path)
951 .and_then(|m| m.modified())
952 .ok()
953 .and_then(|t| t.elapsed().ok())
954 .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
955 if !fresh {
956 return Latest::No;
957 }
958 let kept = kept_prompt(&path);
959 if matches!(kept, Kept::No) {
960 return Latest::No;
961 }
962 let Ok(prompt) = std::fs::read_to_string(&path) else {
963 return Latest::No;
964 };
965 if !quotes(&prompt, text) {
966 return Latest::No;
967 }
968 match kept {
969 Kept::Yes => Latest::Said,
970 Kept::Unverified => Latest::Unverified,
971 Kept::No => Latest::No,
972 }
973}
974
975#[must_use]
982pub fn said_in_latest_prompt(text: &str) -> bool {
983 matches!(latest_words(text), Latest::Said)
984}
985
986#[must_use]
992pub fn unverified_latest_prompt(text: &str) -> bool {
993 matches!(latest_words(text), Latest::Unverified)
994}
995
996fn capture_key(text: &str) -> String {
998 use std::hash::{Hash, Hasher};
999 let mut h = std::collections::hash_map::DefaultHasher::new();
1000 words_only(text).hash(&mut h);
1001 format!("capture:{:016x}", h.finish())
1002}
1003
1004pub const CAPTURE_BUDGET_MS: u64 = 3000;
1008
1009fn save(stated: &Stated) -> Option<Value> {
1012 with_pack_timeout(CAPTURE_BUDGET_MS, || {
1013 let client = pack().ok()?;
1014 let workspace = client.workspace();
1015 let mut atom = atom_body(stated.kind, &stated.text, &workspace);
1016 stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
1017 add_entities(&mut atom, [super::SAID_ENTITY.to_string()]);
1020 admit::stamp_person(&mut atom);
1021 let posted = admit::post_kept(&client, &atom).ok()?;
1022 admit::satisfy_text(&stated.text);
1023 Some(posted)
1024 })
1025}
1026
1027fn bind_record_name() -> (String, Vec<String>) {
1029 let Ok(bind) = std::fs::read_to_string(latest_prompt_bind_path()) else {
1030 return ("-".to_string(), Vec::new());
1031 };
1032 let mut lines = bind.lines();
1033 if lines.next() != Some("3") {
1034 return ("-".to_string(), Vec::new());
1035 }
1036 let _hash = lines.next();
1037 let _mtime = lines.next();
1038 let _runner = lines.next();
1039 let _at = lines.next();
1040 let named = lines.next().filter(|s| !s.is_empty()).unwrap_or("-");
1041 let (sessions, _) = bind_tail(lines);
1042 (named.to_string(), sessions)
1043}
1044
1045fn bind_tail<'a>(lines: impl Iterator<Item = &'a str>) -> (Vec<String>, String) {
1048 let mut sessions = Vec::new();
1049 let mut full = String::new();
1050 for line in lines {
1051 if let Some(hash) = full_prompt_hash(line) {
1052 full = hash;
1053 continue;
1054 }
1055 let session = crate::ballot_sig::field(line);
1056 if session != "-" {
1057 sessions.push(session);
1058 }
1059 }
1060 (sessions, full)
1061}
1062
1063fn full_prompt_hash(line: &str) -> Option<String> {
1064 let hash = line.strip_prefix("full ")?.trim();
1065 (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
1066}
1067
1068fn capture_unverified(prompt: &str) -> bool {
1072 if crate::proc_tree::runner_above().is_none() {
1073 return false;
1074 }
1075 let (named, sessions) = bind_record_name();
1076 matches!(
1077 agrees_with_record(prompt.as_bytes(), &named, &sessions, ""),
1078 RecordWord::Unverified
1079 )
1080}
1081
1082#[must_use]
1088pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
1089 if call.event != "UserPromptSubmit" {
1090 return None;
1091 }
1092 if capture_unverified(&call.cue) {
1096 return None;
1097 }
1098 let mode = capture_mode();
1099 if mode == CaptureMode::Off {
1100 return None;
1101 }
1102 let stated = stated_preference(&call.cue)?;
1103 let key = capture_key(&stated.text);
1104 let session = call.session.as_deref();
1105 if seen_ids(session).contains(&key) {
1106 return None;
1107 }
1108 let verb = if stated.kind == "lesson" {
1109 "remember"
1110 } else {
1111 "prefer"
1112 };
1113 if mode == CaptureMode::Nudge {
1114 return Some((
1115 format!(
1116 "This prompt states a standing choice. Write it in the person's words with `ljos {verb} \"{}\"` so the hook raises it next time.",
1117 stated.text.replace('"', "'")
1118 ),
1119 vec![key],
1120 ));
1121 }
1122 let Some(posted) = save(&stated) else {
1123 return Some((
1124 format!(
1125 "This prompt states a standing choice and the pack did not take it. Write it with `ljos {verb} \"{}\"`.",
1126 stated.text.replace('"', "'")
1127 ),
1128 vec![key],
1129 ));
1130 };
1131 let id = posted["id"].as_str().unwrap_or("").to_string();
1132 let mut now = vec![key.clone()];
1133 if !id.is_empty() {
1134 now.push(id.clone());
1135 }
1136 if let Some(cue) = super::correction_cue(&call.cue) {
1139 now.push(format!("correction:{cue}"));
1140 now.push(format!("correction-stored:{cue}"));
1141 }
1142 mark_seen(session, &now);
1143 let what = if stated.kind == "lesson" {
1144 "lesson"
1145 } else {
1146 "preference"
1147 };
1148 let undo = if id.is_empty() {
1149 String::new()
1150 } else {
1151 format!(" `ljos forget {id}` takes it back.")
1152 };
1153 let persons = posted.pointer("/source/basis").and_then(Value::as_str) == Some("person");
1154 let line = if persons {
1155 format!(
1156 "Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
1157 stated.text
1158 )
1159 } else {
1160 format!(
1161 "Held that sentence for review, not as the person's {what}: \"{}\". \
1162 `ljos memories review` keeps it.",
1163 stated.text
1164 )
1165 };
1166 Some((line, vec![key]))
1167}
1168
1169const PROMPT_EXCERPT_CHARS: usize = 160;
1171
1172#[must_use]
1177pub fn latest_prompt_excerpt() -> Option<String> {
1178 let path = latest_prompt_path();
1179 let fresh = std::fs::metadata(&path)
1180 .and_then(|m| m.modified())
1181 .ok()
1182 .and_then(|t| t.elapsed().ok())
1183 .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
1184 if !fresh {
1185 return None;
1186 }
1187 let prompt = std::fs::read_to_string(&path).ok()?;
1188 let out = excerpt_of(&prompt);
1189 (!out.is_empty()).then_some(out)
1190}
1191
1192#[must_use]
1194pub fn excerpt_of(prompt: &str) -> String {
1195 let words: Vec<String> = prompt
1196 .split_whitespace()
1197 .map(|w| {
1198 if secret_shaped(w) {
1199 "[redacted]".to_string()
1200 } else {
1201 w.to_string()
1202 }
1203 })
1204 .collect();
1205 let joined = words.join(" ");
1206 if joined.chars().count() <= PROMPT_EXCERPT_CHARS {
1207 return joined;
1208 }
1209 let mut cut: String = joined.chars().take(PROMPT_EXCERPT_CHARS - 1).collect();
1210 cut.push('…');
1211 cut
1212}
1213
1214fn secret_shaped(word: &str) -> bool {
1217 let lower = word.to_ascii_lowercase();
1218 if [
1219 "key=",
1220 "token=",
1221 "secret=",
1222 "password=",
1223 "passwd=",
1224 "key:",
1225 "token:",
1226 "secret:",
1227 "password:",
1228 ]
1229 .iter()
1230 .any(|p| lower.contains(p))
1231 {
1232 return true;
1233 }
1234 if [
1235 "sk-",
1236 "ghp_",
1237 "gho_",
1238 "github_pat_",
1239 "xox",
1240 "akia",
1241 "glpat-",
1242 ]
1243 .iter()
1244 .any(|p| lower.starts_with(p))
1245 {
1246 return true;
1247 }
1248 let core: String = word.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
1249 core.len() >= 24
1250 && core.chars().any(|c| c.is_ascii_digit())
1251 && core.chars().any(|c| c.is_ascii_alphabetic())
1252 && !word.contains('/')
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258
1259 const SAVES: &[&str] = &[
1260 "From now on always use bun, never yarn, in this repo",
1261 "Always run cargo fmt before committing.",
1262 "Never push directly to main.",
1263 "Going forward, write commit messages in the imperative mood.",
1264 "Please always use pnpm for installs.",
1265 "Remember that the staging database is read-only on Fridays.",
1266 "Use bun, never yarn.",
1267 "From now on, run the linter before every push.",
1268 "Don't ever commit the .env file.",
1269 "Never use force push on shared branches.",
1270 "ok, from now on use tabs in Go files",
1271 "We always squash merge pull requests here.",
1272 "Always ask before running a database migration.",
1273 "In future, put new tests next to the code they cover.",
1274 "Stop using unwrap in library code.",
1275 "I prefer small commits with one change each.",
1276 "Henceforth all scripts use bash strict mode.",
1277 "Remember to bump the version in Cargo.toml before tagging.",
1278 "Never deploy on a Friday afternoon.",
1279 "Keep it short, and from now on skip the summary at the end.",
1280 "Always write `set -euo pipefail` at the top of shell scripts.",
1281 "Make sure you always run the tests first.",
1282 "We never edit an applied migration.",
1283 "Thanks. From now on use uv instead of pip.",
1284 "Use rg instead of grep going forward.",
1285 "Always prefer rtrash over rm.",
1286 "Never commit secrets to the repo.",
1287 "Please never run npm install in this project.",
1288 "From here on, write docs one sentence per line.",
1289 "Also, always pin action versions by SHA.",
1290 ];
1291
1292 const SAVES_NOTHING: &[&str] = &[
1293 "Why do you always use yarn?",
1294 "Should we always run the full suite?",
1295 "Does this ever fail on CI?",
1296 "Fix the failing test in parser.rs",
1297 "You never listen to me",
1298 "It always fails on the second run",
1299 "Never mind, that was my mistake.",
1300 "```\nalways_on = true\nnever_retry = false\n```",
1301 "> Never push directly to main\nwhat does this rule mean",
1302 "The doc says \"always use bun, never yarn, for this repo\" but is that right",
1303 "error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
1304 "This could break in the future.",
1305 "Run the tests and tell me what fails",
1306 "I always forget how this works, can you explain it",
1307 "Never seen this error before, what is it",
1308 "Always the same problem with this build",
1309 "What's the best way to always keep the cache warm?",
1310 "Add a flag `--always-retry` to the CLI",
1311 "Rename never_called to unused in lib.rs",
1312 "Can you remember that function we wrote yesterday?",
1313 "It never worked on macOS anyway",
1314 "Remember when the build broke last week",
1315 "Is it true that git never forgets a commit",
1316 "Please look at the failing job",
1317 "Traceback (most recent call last):\n File \"a.py\", line 3\nerror: always broken\nwarning: never set",
1318 "Always?",
1319 "never",
1320 "The upstream README says to always use yarn",
1321 "Explain why we never got the webhook",
1322 "Going forward is the only option for the migration",
1323 ];
1324
1325 #[test]
1328 fn the_full_prompt_line_is_not_a_session() {
1329 let hash = "ab".repeat(32);
1330 let (sessions, full) = bind_tail(
1331 format!("sess-1\nfull {hash}\n-")
1332 .lines()
1333 .collect::<Vec<_>>()
1334 .into_iter(),
1335 );
1336 assert_eq!(sessions, ["sess-1"]);
1337 assert_eq!(full, hash);
1338 let (sessions, full) = bind_tail("sess-1\n".lines());
1339 assert_eq!(sessions, ["sess-1"]);
1340 assert!(full.is_empty());
1341 assert!(full_prompt_hash("full not-a-hash").is_none());
1342 }
1343
1344 #[test]
1348 fn a_subagent_brief_or_unattended_run_is_not_the_person() {
1349 let _g = crate::tests::env_guard();
1350 unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1351 let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1352 let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
1353 let codex = |extra: &str, thread: &str| {
1354 format!(
1355 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}}}"#
1356 )
1357 };
1358 assert_eq!(not_the_person(&codex("", root)), None);
1359 assert_eq!(
1360 not_the_person(&codex(
1361 r#","agent_id":"019a-sub","agent_type":"worker""#,
1362 root
1363 )),
1364 Some("a subagent's brief")
1365 );
1366 assert_eq!(
1367 not_the_person(&codex("", other)),
1368 Some("another thread's prompt")
1369 );
1370 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"}"#;
1371 assert_eq!(
1372 not_the_person(claude_main),
1373 None,
1374 "--agent alone is the main thread"
1375 );
1376 let claude_sub =
1377 claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
1378 assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
1379 unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
1380 assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
1381 unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
1382 assert_eq!(not_the_person(claude_main), None);
1383 unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1384 }
1385
1386 #[test]
1387 fn a_rollout_name_gives_its_thread() {
1388 let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1389 assert_eq!(
1390 rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
1391 Some(id)
1392 );
1393 assert_eq!(
1394 rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
1395 Some(id)
1396 );
1397 assert_eq!(rollout_thread("/x/abc.jsonl"), None);
1398 assert_eq!(
1399 rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
1400 None
1401 );
1402 }
1403
1404 #[test]
1405 fn thirty_stated_choices_are_read() {
1406 assert_eq!(SAVES.len(), 30);
1407 let missed: Vec<&&str> = SAVES
1408 .iter()
1409 .filter(|p| stated_preference(p).is_none())
1410 .collect();
1411 assert!(
1412 missed.is_empty(),
1413 "not read as a standing choice: {missed:#?}"
1414 );
1415 }
1416
1417 #[test]
1418 fn thirty_other_prompts_save_nothing() {
1419 assert_eq!(SAVES_NOTHING.len(), 30);
1420 let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
1421 .iter()
1422 .filter_map(|p| stated_preference(p).map(|s| (p, s)))
1423 .collect();
1424 assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
1425 }
1426
1427 #[test]
1428 fn the_saved_words_are_the_persons_sentence() {
1429 let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
1430 assert_eq!(s.kind, "preference");
1431 assert_eq!(
1432 s.text,
1433 "From now on always use bun, never yarn, in this repo"
1434 );
1435 let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
1436 assert_eq!(s.text, "From now on use tabs in Go files");
1437 let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
1438 assert_eq!(s.text, "From now on use uv instead of pip.");
1439 let s = stated_preference("Remember that the staging database is read-only on Fridays.")
1440 .unwrap();
1441 assert_eq!(s.kind, "lesson");
1442 assert_eq!(s.text, "The staging database is read-only on Fridays.");
1443 }
1444
1445 #[test]
1446 fn the_mode_reads_ljos_capture() {
1447 let _g = crate::tests::env_guard();
1448 let prev = std::env::var_os("LJOS_CAPTURE");
1449 for (v, want) in [
1450 ("", CaptureMode::Save),
1451 ("save", CaptureMode::Save),
1452 ("nudge", CaptureMode::Nudge),
1453 ("off", CaptureMode::Off),
1454 ("bogus", CaptureMode::Save),
1455 ] {
1456 unsafe { std::env::set_var("LJOS_CAPTURE", v) };
1457 assert_eq!(capture_mode(), want, "{v}");
1458 }
1459 match prev {
1460 Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
1461 None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
1462 }
1463 }
1464
1465 #[test]
1466 fn words_compare_whatever_the_punctuation() {
1467 assert_eq!(words_only("Use bun, never yarn!"), "use bun never yarn");
1468 }
1469
1470 #[test]
1471 fn a_typographic_apostrophe_still_turns_the_clause_around() {
1472 let cut = "always paste the release key into the deploy form";
1473 for apostrophe in ['\u{2019}', '\u{02bc}', '\u{ff07}'] {
1474 let prompt = format!(
1475 "Don{apostrophe}t do this: always paste the release key into the deploy form."
1476 );
1477 assert!(!quotes(&prompt, cut), "accepted under {apostrophe:?}");
1478 assert!(
1479 quotes(
1480 &prompt,
1481 "Don't do this: always paste the release key into the deploy form."
1482 ),
1483 "refused the whole sentence under {apostrophe:?}"
1484 );
1485 }
1486 assert!(quotes(
1487 "Use bun. Always paste the release key into the deploy form.",
1488 cut
1489 ));
1490 }
1491
1492 #[test]
1493 fn a_dangling_negation_is_joined_before_the_rule_is_read() {
1494 assert!(stated_preference("Do not\nalways use npm").is_none());
1495 assert!(stated_preference("Do not do this:\nalways use npm").is_none());
1496 for prompt in ["Don\u{2019}t\nalways use npm", "Don't\nalways use npm"] {
1497 if let Some(saved) = stated_preference(prompt) {
1498 let text = saved.text.to_lowercase();
1499 assert!(
1500 text.contains("not") || text.contains("n't"),
1501 "{prompt:?} saved {text}"
1502 );
1503 assert_ne!(text, "always use npm", "{prompt:?}");
1504 }
1505 }
1506 let saved = stated_preference("Never\npush directly to main.").unwrap();
1507 assert_eq!(saved.text, "Never push directly to main.");
1508 let saved = stated_preference("Always use npm.").unwrap();
1509 assert_eq!(saved.text, "Always use npm.");
1510 let saved = stated_preference("Do not\n\nAlways use npm.").unwrap();
1511 assert_eq!(saved.text, "Always use npm.");
1512 let saved = stated_preference("Always use npm.\nNever push directly to main.").unwrap();
1513 assert_eq!(saved.text, "Always use npm.");
1514 }
1515
1516 #[test]
1517 fn a_negation_on_the_line_before_still_governs_the_quote() {
1518 let cut = "always paste the release key into the deploy form";
1519 for prompt in [
1520 "Do not\nalways paste the release key into the deploy form.",
1521 "Do not do this:\nalways paste the release key into the deploy form.",
1522 "Please never:\n\talways paste the release key into the deploy form",
1523 "Always paste the release key into the deploy form\nunless asked.",
1524 ] {
1525 assert!(!quotes(prompt, cut), "accepted from {prompt:?}");
1526 }
1527 assert!(quotes(
1528 "Do not\nalways paste the release key into the deploy form.",
1529 "Do not always paste the release key into the deploy form."
1530 ));
1531 for prompt in [
1532 "Use bun.\nAlways paste the release key into the deploy form.",
1533 "Do not use yarn\n\nAlways paste the release key into the deploy form.",
1534 ] {
1535 assert!(quotes(prompt, cut), "refused from {prompt:?}");
1536 }
1537 }
1538}