1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CaptureMode {
28 Save,
30 Nudge,
32 Off,
34}
35
36#[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#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Stated {
55 pub kind: &'static str,
57 pub text: String,
58}
59
60pub const STATED_MAX_CHARS: usize = 200;
63
64const 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
86const COMMA_LEADS: &[&str] = &["going forward,", "in future,", "in the future,"];
89
90const ANYWHERE: &[&str] = &["from now on", "from here on", "henceforth"];
92
93const ENDINGS: &[&str] = &["from now on", "going forward", "in future", "from here on"];
96
97const 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
106const FILLER: &[&str] = &[
108 "ok", "okay", "so", "also", "and", "btw", "oh", "hey", "note", "fyi", "please", "but",
109];
110
111const 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
117fn 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
128fn 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
153fn 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
196fn 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
214fn 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
235fn 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
253fn 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
282fn 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#[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#[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
329fn 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 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#[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
385pub const LATEST_PROMPT_SECS: u64 = 3600;
387
388const 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
396pub fn note_latest_prompt(prompt: &str) {
404 note_latest_prompt_for(prompt, None);
405}
406
407pub 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
413pub fn note_latest_prompt_as(prompt: &str, sessions: &[String]) {
417 note_latest_prompt_from(prompt, sessions, None, None);
418}
419
420pub 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
428pub 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
467struct 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#[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
494const BIND_WRITE_NANOS: u128 = 500_000_000;
496
497fn 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
532fn 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
580enum Kept {
582 Yes,
584 No,
586}
587
588fn 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 let _ = (transcript, runner, full_hash);
650 Kept::Yes
651}
652
653fn 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
663struct 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
744fn 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
765fn 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
797fn 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
809fn 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
842enum 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#[must_use]
886pub fn said_in_latest_prompt(text: &str) -> bool {
887 matches!(latest_words(text), Latest::Said)
888}
889
890fn 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
898pub const CAPTURE_BUDGET_MS: u64 = 3000;
902
903thread_local! {
904 static PERSONS_PROMPT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
906}
907
908pub fn set_persons_prompt(persons: bool) {
912 PERSONS_PROMPT.with(|cell| cell.set(persons));
913}
914
915#[must_use]
917pub fn persons_prompt() -> bool {
918 PERSONS_PROMPT.with(std::cell::Cell::get)
919}
920
921fn 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
942fn 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#[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 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
1047const PROMPT_EXCERPT_CHARS: usize = 160;
1049
1050#[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#[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
1092fn 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 #[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 #[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}