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
507fn latest_prompt_bind_path() -> std::path::PathBuf {
508 runtime_dir().join("latest-prompt.bind")
509}
510
511fn mtime_nanos(path: &std::path::Path) -> Option<u128> {
512 std::fs::metadata(path)
513 .ok()?
514 .modified()
515 .ok()?
516 .duration_since(std::time::UNIX_EPOCH)
517 .ok()
518 .map(|age| age.as_nanos())
519}
520
521fn bind_session(session: Option<&str>) -> String {
522 match session
523 .map(str::trim)
524 .filter(|s| !s.is_empty() && !s.contains(['\n', '\r']))
525 {
526 Some(s) => s.to_string(),
527 None => "-".to_string(),
528 }
529}
530
531fn write_prompt_bind(
541 path: &std::path::Path,
542 bytes: &[u8],
543 bind: &Bind,
544 sessions: &[String],
545 full_hash: &str,
546) -> std::io::Result<()> {
547 use std::io::Write as _;
548 use std::os::unix::fs::OpenOptionsExt as _;
549 let Some(mtime) = mtime_nanos(path) else {
550 return Err(std::io::Error::other("prompt mtime"));
551 };
552 let hash = hex::encode(sha2::Sha256::digest(bytes));
553 let mut ids: Vec<String> = Vec::new();
554 for s in sessions {
555 let s = bind_session(Some(s));
556 if s != "-" && !ids.contains(&s) {
557 ids.push(s);
558 }
559 }
560 if ids.is_empty() {
561 ids.push(bind_session(None));
562 }
563 let body = format!(
564 "3\n{hash}\n{mtime}\n{}\n{}\n{}\n{}\nfull {full_hash}\n",
565 bind.runner,
566 bind.at,
567 bind.transcript,
568 ids.join("\n")
569 );
570 let mut f = std::fs::OpenOptions::new()
571 .write(true)
572 .create(true)
573 .truncate(true)
574 .mode(0o600)
575 .open(latest_prompt_bind_path())?;
576 f.write_all(body.as_bytes())
577}
578
579enum Kept {
581 Yes,
583 No,
585}
586
587fn kept_prompt(path: &std::path::Path) -> Kept {
602 let Ok(bytes) = std::fs::read(path) else {
603 return Kept::No;
604 };
605 let Some(mtime) = mtime_nanos(path) else {
606 return Kept::No;
607 };
608 let bind_path = latest_prompt_bind_path();
609 let Ok(bind) = std::fs::read_to_string(&bind_path) else {
610 return Kept::No;
611 };
612 let mut lines = bind.lines();
613 let (Some("3"), Some(hash), Some(stored), Some(spawner), Some(at), Some(transcript)) = (
614 lines.next(),
615 lines.next(),
616 lines.next(),
617 lines.next(),
618 lines.next().and_then(|t| t.parse::<u128>().ok()),
619 lines.next(),
620 ) else {
621 return Kept::No;
622 };
623 let late = |p: &std::path::Path| {
624 changed_nanos(p).is_none_or(|c| c > at.saturating_add(BIND_WRITE_NANOS))
625 };
626 if late(path) || late(&bind_path) {
627 return Kept::No;
628 }
629 let (bound, full_hash) = bind_tail(lines);
630 let runner = crate::ballot_sig::runner_sessions();
631 let above = spawner != "-"
632 && crate::proc_tree::ancestors()
633 .iter()
634 .any(|p| crate::proc_tree::token(p) == spawner);
635 if !(above || bound.iter().any(|s| runner.contains(s))) {
636 return Kept::No;
637 }
638 if let Some(call) = crate::ballot_sig::call_session() {
639 let within = if runner.is_empty() { &bound } else { &runner };
640 if !within.contains(&call) {
641 return Kept::No;
642 }
643 }
644 if hash != hex::encode(sha2::Sha256::digest(&bytes)) || stored != mtime.to_string() {
645 return Kept::No;
646 }
647 let _ = (transcript, runner, full_hash);
649 Kept::Yes
650}
651
652fn words_only(text: &str) -> String {
655 text.split(|c: char| !c.is_alphanumeric() && c != '\'')
656 .filter(|w| !w.is_empty())
657 .map(str::to_lowercase)
658 .collect::<Vec<_>>()
659 .join(" ")
660}
661
662struct Word {
667 word: String,
668 start: usize,
669 end: usize,
670 sentence_before: bool,
671 stop_before: bool,
672 clause_before: bool,
673}
674
675fn prompt_words(text: &str) -> Vec<Word> {
676 let mut out: Vec<Word> = Vec::new();
677 let (mut sentence, mut stop, mut clause) = (true, true, true);
678 let mut line_ended = false;
679 let mut at = None;
680 let close =
681 |out: &mut Vec<Word>, from: usize, to: usize, s: &mut bool, p: &mut bool, c: &mut bool| {
682 let raw = &text[from..to];
683 let lead = raw.len() - raw.trim_start_matches('\'').len();
684 let word = raw.trim_matches('\'');
685 if !word.is_empty() {
686 out.push(Word {
687 word: word.to_lowercase(),
688 start: from + lead,
689 end: from + lead + word.len(),
690 sentence_before: std::mem::take(s),
691 stop_before: std::mem::take(p),
692 clause_before: std::mem::take(c),
693 });
694 }
695 };
696 for (i, ch) in text.char_indices() {
697 if ch.is_alphanumeric() || ch == '\'' {
698 at.get_or_insert(i);
699 continue;
700 }
701 if let Some(from) = at.take() {
702 close(&mut out, from, i, &mut sentence, &mut stop, &mut clause);
703 line_ended = false;
704 }
705 if matches!(ch, '.' | '!' | '?' | ';' | '\n') {
706 sentence = true;
707 }
708 if matches!(ch, '.' | '!' | '?' | ';') || (ch == '\n' && line_ended) {
709 stop = true;
710 }
711 if ch == '\n' {
712 line_ended = true;
713 }
714 if matches!(
715 ch,
716 ',' | ':'
717 | '('
718 | ')'
719 | '['
720 | ']'
721 | '"'
722 | '\u{201c}'
723 | '\u{201d}'
724 | '\u{2013}'
725 | '\u{2014}'
726 ) {
727 clause = true;
728 }
729 }
730 if let Some(from) = at {
731 close(
732 &mut out,
733 from,
734 text.len(),
735 &mut sentence,
736 &mut stop,
737 &mut clause,
738 );
739 }
740 out
741}
742
743fn turns_it_around(word: &str) -> bool {
746 matches!(
747 word,
748 "not"
749 | "never"
750 | "no"
751 | "nor"
752 | "dont"
753 | "cannot"
754 | "without"
755 | "avoid"
756 | "unless"
757 | "except"
758 | "if"
759 | "only"
760 | "until"
761 ) || word.ends_with("n't")
762}
763
764fn marks_kept(text: &str) -> String {
767 text.chars()
768 .filter(|c| {
769 !matches!(
770 c,
771 '.' | ','
772 | ';'
773 | ':'
774 | '!'
775 | '?'
776 | '\''
777 | '"'
778 | '('
779 | ')'
780 | '-'
781 | '\u{2018}'
782 | '\u{2019}'
783 | '\u{201c}'
784 | '\u{201d}'
785 | '\u{2013}'
786 | '\u{2014}'
787 )
788 })
789 .collect::<String>()
790 .split_whitespace()
791 .map(str::to_lowercase)
792 .collect::<Vec<_>>()
793 .join(" ")
794}
795
796fn plain(text: &str) -> String {
799 use unicode_normalization::UnicodeNormalization as _;
800 text.nfkc()
801 .map(|c| match c {
802 '\u{2019}' | '\u{02bc}' | '\u{ff07}' => '\'',
803 c => c,
804 })
805 .collect()
806}
807
808fn quotes(prompt: &str, quote: &str) -> bool {
814 let (prompt, quote) = (&plain(prompt), &plain(quote));
815 let words = prompt_words(prompt);
816 let want: Vec<String> = prompt_words(quote).into_iter().map(|w| w.word).collect();
817 let n = want.len();
818 if n == 0 || n > words.len() {
819 return false;
820 }
821 let ends = |i: usize| {
822 words
823 .get(i)
824 .is_none_or(|w| w.clause_before || w.sentence_before)
825 };
826 (0..=words.len() - n).any(|i| {
827 let j = i + n;
828 if !ends(i) || !ends(j) || words[i..j].iter().zip(&want).any(|(w, q)| w.word != *q) {
829 return false;
830 }
831 let from = (0..=i).rev().find(|&k| words[k].stop_before).unwrap_or(0);
832 let to = (j..words.len())
833 .find(|&k| words[k].stop_before)
834 .unwrap_or(words.len());
835 let dropped = words[from..i].iter().chain(&words[j..to]);
836 !dropped.into_iter().any(|w| turns_it_around(&w.word))
837 && marks_kept(&prompt[words[i].start..words[j - 1].end]) == marks_kept(quote)
838 })
839}
840
841enum Latest {
844 Said,
845 No,
846}
847
848fn latest_words(text: &str) -> Latest {
849 let want = words_only(text);
850 if want.split(' ').count() < 4 {
851 return Latest::No;
852 }
853 let path = latest_prompt_path();
854 let fresh = std::fs::metadata(&path)
855 .and_then(|m| m.modified())
856 .ok()
857 .and_then(|t| t.elapsed().ok())
858 .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
859 if !fresh {
860 return Latest::No;
861 }
862 let kept = kept_prompt(&path);
863 if matches!(kept, Kept::No) {
864 return Latest::No;
865 }
866 let Ok(prompt) = std::fs::read_to_string(&path) else {
867 return Latest::No;
868 };
869 if !quotes(&prompt, text) {
870 return Latest::No;
871 }
872 match kept {
873 Kept::Yes => Latest::Said,
874 Kept::No => Latest::No,
875 }
876}
877
878#[must_use]
885pub fn said_in_latest_prompt(text: &str) -> bool {
886 matches!(latest_words(text), Latest::Said)
887}
888
889fn capture_key(text: &str) -> String {
891 use std::hash::{Hash, Hasher};
892 let mut h = std::collections::hash_map::DefaultHasher::new();
893 words_only(text).hash(&mut h);
894 format!("capture:{:016x}", h.finish())
895}
896
897pub const CAPTURE_BUDGET_MS: u64 = 3000;
901
902fn save(stated: &Stated) -> Option<Value> {
905 with_pack_timeout(CAPTURE_BUDGET_MS, || {
906 let client = pack().ok()?;
907 let workspace = client.workspace();
908 let mut atom = atom_body(stated.kind, &stated.text, &workspace);
909 stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
910 add_entities(&mut atom, [super::SAID_ENTITY.to_string()]);
913 admit::stamp_person(&mut atom);
914 let posted = admit::post_kept(&client, &atom).ok()?;
915 admit::satisfy_text(&stated.text);
916 Some(posted)
917 })
918}
919
920fn bind_tail<'a>(lines: impl Iterator<Item = &'a str>) -> (Vec<String>, String) {
923 let mut sessions = Vec::new();
924 let mut full = String::new();
925 for line in lines {
926 if let Some(hash) = full_prompt_hash(line) {
927 full = hash;
928 continue;
929 }
930 let session = crate::ballot_sig::field(line);
931 if session != "-" {
932 sessions.push(session);
933 }
934 }
935 (sessions, full)
936}
937
938fn full_prompt_hash(line: &str) -> Option<String> {
939 let hash = line.strip_prefix("full ")?.trim();
940 (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
941}
942
943#[must_use]
949pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
950 if call.event != "UserPromptSubmit" {
951 return None;
952 }
953 let mode = capture_mode();
954 if mode == CaptureMode::Off {
955 return None;
956 }
957 let stated = stated_preference(&call.cue)?;
958 let key = capture_key(&stated.text);
959 let session = call.session.as_deref();
960 if seen_ids(session).contains(&key) {
961 return None;
962 }
963 let verb = if stated.kind == "lesson" {
964 "remember"
965 } else {
966 "prefer"
967 };
968 if mode == CaptureMode::Nudge {
969 return Some((
970 format!(
971 "This prompt states a standing choice. Write it in the person's words with `ljos {verb} \"{}\"` so the hook raises it next time.",
972 stated.text.replace('"', "'")
973 ),
974 vec![key],
975 ));
976 }
977 let Some(posted) = save(&stated) else {
978 return Some((
979 format!(
980 "This prompt states a standing choice and the pack did not take it. Write it with `ljos {verb} \"{}\"`.",
981 stated.text.replace('"', "'")
982 ),
983 vec![key],
984 ));
985 };
986 let id = posted["id"].as_str().unwrap_or("").to_string();
987 let mut now = vec![key.clone()];
988 if !id.is_empty() {
989 now.push(id.clone());
990 }
991 if let Some(cue) = super::correction_cue(&call.cue) {
994 now.push(format!("correction:{cue}"));
995 now.push(format!("correction-stored:{cue}"));
996 }
997 mark_seen(session, &now);
998 let what = if stated.kind == "lesson" {
999 "lesson"
1000 } else {
1001 "preference"
1002 };
1003 let undo = if id.is_empty() {
1004 String::new()
1005 } else {
1006 format!(" `ljos forget {id}` takes it back.")
1007 };
1008 let persons = posted.pointer("/source/basis").and_then(Value::as_str) == Some("person");
1009 let line = if persons {
1010 format!(
1011 "Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
1012 stated.text
1013 )
1014 } else {
1015 format!(
1016 "Held that sentence for review, not as the person's {what}: \"{}\". \
1017 `ljos memories review` keeps it.",
1018 stated.text
1019 )
1020 };
1021 Some((line, vec![key]))
1022}
1023
1024const PROMPT_EXCERPT_CHARS: usize = 160;
1026
1027#[must_use]
1032pub fn latest_prompt_excerpt() -> Option<String> {
1033 let path = latest_prompt_path();
1034 let fresh = std::fs::metadata(&path)
1035 .and_then(|m| m.modified())
1036 .ok()
1037 .and_then(|t| t.elapsed().ok())
1038 .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
1039 if !fresh {
1040 return None;
1041 }
1042 let prompt = std::fs::read_to_string(&path).ok()?;
1043 let out = excerpt_of(&prompt);
1044 (!out.is_empty()).then_some(out)
1045}
1046
1047#[must_use]
1049pub fn excerpt_of(prompt: &str) -> String {
1050 let words: Vec<String> = prompt
1051 .split_whitespace()
1052 .map(|w| {
1053 if secret_shaped(w) {
1054 "[redacted]".to_string()
1055 } else {
1056 w.to_string()
1057 }
1058 })
1059 .collect();
1060 let joined = words.join(" ");
1061 if joined.chars().count() <= PROMPT_EXCERPT_CHARS {
1062 return joined;
1063 }
1064 let mut cut: String = joined.chars().take(PROMPT_EXCERPT_CHARS - 1).collect();
1065 cut.push('…');
1066 cut
1067}
1068
1069fn secret_shaped(word: &str) -> bool {
1072 let lower = word.to_ascii_lowercase();
1073 if [
1074 "key=",
1075 "token=",
1076 "secret=",
1077 "password=",
1078 "passwd=",
1079 "key:",
1080 "token:",
1081 "secret:",
1082 "password:",
1083 ]
1084 .iter()
1085 .any(|p| lower.contains(p))
1086 {
1087 return true;
1088 }
1089 if [
1090 "sk-",
1091 "ghp_",
1092 "gho_",
1093 "github_pat_",
1094 "xox",
1095 "akia",
1096 "glpat-",
1097 ]
1098 .iter()
1099 .any(|p| lower.starts_with(p))
1100 {
1101 return true;
1102 }
1103 let core: String = word.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
1104 core.len() >= 24
1105 && core.chars().any(|c| c.is_ascii_digit())
1106 && core.chars().any(|c| c.is_ascii_alphabetic())
1107 && !word.contains('/')
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::*;
1113
1114 const SAVES: &[&str] = &[
1115 "From now on always use bun, never yarn, in this repo",
1116 "Always run cargo fmt before committing.",
1117 "Never push directly to main.",
1118 "Going forward, write commit messages in the imperative mood.",
1119 "Please always use pnpm for installs.",
1120 "Remember that the staging database is read-only on Fridays.",
1121 "Use bun, never yarn.",
1122 "From now on, run the linter before every push.",
1123 "Don't ever commit the .env file.",
1124 "Never use force push on shared branches.",
1125 "ok, from now on use tabs in Go files",
1126 "We always squash merge pull requests here.",
1127 "Always ask before running a database migration.",
1128 "In future, put new tests next to the code they cover.",
1129 "Stop using unwrap in library code.",
1130 "I prefer small commits with one change each.",
1131 "Henceforth all scripts use bash strict mode.",
1132 "Remember to bump the version in Cargo.toml before tagging.",
1133 "Never deploy on a Friday afternoon.",
1134 "Keep it short, and from now on skip the summary at the end.",
1135 "Always write `set -euo pipefail` at the top of shell scripts.",
1136 "Make sure you always run the tests first.",
1137 "We never edit an applied migration.",
1138 "Thanks. From now on use uv instead of pip.",
1139 "Use rg instead of grep going forward.",
1140 "Always prefer rtrash over rm.",
1141 "Never commit secrets to the repo.",
1142 "Please never run npm install in this project.",
1143 "From here on, write docs one sentence per line.",
1144 "Also, always pin action versions by SHA.",
1145 ];
1146
1147 const SAVES_NOTHING: &[&str] = &[
1148 "Why do you always use yarn?",
1149 "Should we always run the full suite?",
1150 "Does this ever fail on CI?",
1151 "Fix the failing test in parser.rs",
1152 "You never listen to me",
1153 "It always fails on the second run",
1154 "Never mind, that was my mistake.",
1155 "```\nalways_on = true\nnever_retry = false\n```",
1156 "> Never push directly to main\nwhat does this rule mean",
1157 "The doc says \"always use bun, never yarn, for this repo\" but is that right",
1158 "error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
1159 "This could break in the future.",
1160 "Run the tests and tell me what fails",
1161 "I always forget how this works, can you explain it",
1162 "Never seen this error before, what is it",
1163 "Always the same problem with this build",
1164 "What's the best way to always keep the cache warm?",
1165 "Add a flag `--always-retry` to the CLI",
1166 "Rename never_called to unused in lib.rs",
1167 "Can you remember that function we wrote yesterday?",
1168 "It never worked on macOS anyway",
1169 "Remember when the build broke last week",
1170 "Is it true that git never forgets a commit",
1171 "Please look at the failing job",
1172 "Traceback (most recent call last):\n File \"a.py\", line 3\nerror: always broken\nwarning: never set",
1173 "Always?",
1174 "never",
1175 "The upstream README says to always use yarn",
1176 "Explain why we never got the webhook",
1177 "Going forward is the only option for the migration",
1178 ];
1179
1180 #[test]
1183 fn the_full_prompt_line_is_not_a_session() {
1184 let hash = "ab".repeat(32);
1185 let (sessions, full) = bind_tail(
1186 format!("sess-1\nfull {hash}\n-")
1187 .lines()
1188 .collect::<Vec<_>>()
1189 .into_iter(),
1190 );
1191 assert_eq!(sessions, ["sess-1"]);
1192 assert_eq!(full, hash);
1193 let (sessions, full) = bind_tail("sess-1\n".lines());
1194 assert_eq!(sessions, ["sess-1"]);
1195 assert!(full.is_empty());
1196 assert!(full_prompt_hash("full not-a-hash").is_none());
1197 }
1198
1199 #[test]
1203 fn a_subagent_brief_or_unattended_run_is_not_the_person() {
1204 let _g = crate::tests::env_guard();
1205 unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1206 let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1207 let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
1208 let codex = |extra: &str, thread: &str| {
1209 format!(
1210 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}}}"#
1211 )
1212 };
1213 assert_eq!(not_the_person(&codex("", root)), None);
1214 assert_eq!(
1215 not_the_person(&codex(
1216 r#","agent_id":"019a-sub","agent_type":"worker""#,
1217 root
1218 )),
1219 Some("a subagent's brief")
1220 );
1221 assert_eq!(
1222 not_the_person(&codex("", other)),
1223 Some("another thread's prompt")
1224 );
1225 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"}"#;
1226 assert_eq!(
1227 not_the_person(claude_main),
1228 None,
1229 "--agent alone is the main thread"
1230 );
1231 let claude_sub =
1232 claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
1233 assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
1234 unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
1235 assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
1236 unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
1237 assert_eq!(not_the_person(claude_main), None);
1238 unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1239 }
1240
1241 #[test]
1242 fn a_rollout_name_gives_its_thread() {
1243 let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1244 assert_eq!(
1245 rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
1246 Some(id)
1247 );
1248 assert_eq!(
1249 rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
1250 Some(id)
1251 );
1252 assert_eq!(rollout_thread("/x/abc.jsonl"), None);
1253 assert_eq!(
1254 rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
1255 None
1256 );
1257 }
1258
1259 #[test]
1260 fn thirty_stated_choices_are_read() {
1261 assert_eq!(SAVES.len(), 30);
1262 let missed: Vec<&&str> = SAVES
1263 .iter()
1264 .filter(|p| stated_preference(p).is_none())
1265 .collect();
1266 assert!(
1267 missed.is_empty(),
1268 "not read as a standing choice: {missed:#?}"
1269 );
1270 }
1271
1272 #[test]
1273 fn thirty_other_prompts_save_nothing() {
1274 assert_eq!(SAVES_NOTHING.len(), 30);
1275 let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
1276 .iter()
1277 .filter_map(|p| stated_preference(p).map(|s| (p, s)))
1278 .collect();
1279 assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
1280 }
1281
1282 #[test]
1283 fn the_saved_words_are_the_persons_sentence() {
1284 let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
1285 assert_eq!(s.kind, "preference");
1286 assert_eq!(
1287 s.text,
1288 "From now on always use bun, never yarn, in this repo"
1289 );
1290 let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
1291 assert_eq!(s.text, "From now on use tabs in Go files");
1292 let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
1293 assert_eq!(s.text, "From now on use uv instead of pip.");
1294 let s = stated_preference("Remember that the staging database is read-only on Fridays.")
1295 .unwrap();
1296 assert_eq!(s.kind, "lesson");
1297 assert_eq!(s.text, "The staging database is read-only on Fridays.");
1298 }
1299
1300 #[test]
1301 fn the_mode_reads_ljos_capture() {
1302 let _g = crate::tests::env_guard();
1303 let prev = std::env::var_os("LJOS_CAPTURE");
1304 for (v, want) in [
1305 ("", CaptureMode::Save),
1306 ("save", CaptureMode::Save),
1307 ("nudge", CaptureMode::Nudge),
1308 ("off", CaptureMode::Off),
1309 ("bogus", CaptureMode::Save),
1310 ] {
1311 unsafe { std::env::set_var("LJOS_CAPTURE", v) };
1312 assert_eq!(capture_mode(), want, "{v}");
1313 }
1314 match prev {
1315 Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
1316 None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
1317 }
1318 }
1319
1320 #[test]
1321 fn words_compare_whatever_the_punctuation() {
1322 assert_eq!(words_only("Use bun, never yarn!"), "use bun never yarn");
1323 }
1324
1325 #[test]
1326 fn a_typographic_apostrophe_still_turns_the_clause_around() {
1327 let cut = "always paste the release key into the deploy form";
1328 for apostrophe in ['\u{2019}', '\u{02bc}', '\u{ff07}'] {
1329 let prompt = format!(
1330 "Don{apostrophe}t do this: always paste the release key into the deploy form."
1331 );
1332 assert!(!quotes(&prompt, cut), "accepted under {apostrophe:?}");
1333 assert!(
1334 quotes(
1335 &prompt,
1336 "Don't do this: always paste the release key into the deploy form."
1337 ),
1338 "refused the whole sentence under {apostrophe:?}"
1339 );
1340 }
1341 assert!(quotes(
1342 "Use bun. Always paste the release key into the deploy form.",
1343 cut
1344 ));
1345 }
1346
1347 #[test]
1348 fn a_dangling_negation_is_joined_before_the_rule_is_read() {
1349 assert!(stated_preference("Do not\nalways use npm").is_none());
1350 assert!(stated_preference("Do not do this:\nalways use npm").is_none());
1351 for prompt in ["Don\u{2019}t\nalways use npm", "Don't\nalways use npm"] {
1352 if let Some(saved) = stated_preference(prompt) {
1353 let text = saved.text.to_lowercase();
1354 assert!(
1355 text.contains("not") || text.contains("n't"),
1356 "{prompt:?} saved {text}"
1357 );
1358 assert_ne!(text, "always use npm", "{prompt:?}");
1359 }
1360 }
1361 let saved = stated_preference("Never\npush directly to main.").unwrap();
1362 assert_eq!(saved.text, "Never push directly to main.");
1363 let saved = stated_preference("Always use npm.").unwrap();
1364 assert_eq!(saved.text, "Always use npm.");
1365 let saved = stated_preference("Do not\n\nAlways use npm.").unwrap();
1366 assert_eq!(saved.text, "Always use npm.");
1367 let saved = stated_preference("Always use npm.\nNever push directly to main.").unwrap();
1368 assert_eq!(saved.text, "Always use npm.");
1369 }
1370
1371 #[test]
1372 fn a_negation_on_the_line_before_still_governs_the_quote() {
1373 let cut = "always paste the release key into the deploy form";
1374 for prompt in [
1375 "Do not\nalways paste the release key into the deploy form.",
1376 "Do not do this:\nalways paste the release key into the deploy form.",
1377 "Please never:\n\talways paste the release key into the deploy form",
1378 "Always paste the release key into the deploy form\nunless asked.",
1379 ] {
1380 assert!(!quotes(prompt, cut), "accepted from {prompt:?}");
1381 }
1382 assert!(quotes(
1383 "Do not\nalways paste the release key into the deploy form.",
1384 "Do not always paste the release key into the deploy form."
1385 ));
1386 for prompt in [
1387 "Use bun.\nAlways paste the release key into the deploy form.",
1388 "Do not use yarn\n\nAlways paste the release key into the deploy form.",
1389 ] {
1390 assert!(quotes(prompt, cut), "refused from {prompt:?}");
1391 }
1392 }
1393}