1use serde_json::Value;
16
17use super::{
18 add_entities, admit, atom_body, mark_seen, pack, pasted_or_quoted, runtime_dir, seen_ids,
19 stamp_horizon, with_pack_timeout, HookCall,
20};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum CaptureMode {
25 Save,
27 Nudge,
29 Off,
31}
32
33#[must_use]
35pub fn capture_mode() -> CaptureMode {
36 match std::env::var("LJOS_CAPTURE")
37 .unwrap_or_default()
38 .trim()
39 .to_ascii_lowercase()
40 .as_str()
41 {
42 "nudge" => CaptureMode::Nudge,
43 "off" | "0" | "no" | "false" => CaptureMode::Off,
44 _ => CaptureMode::Save,
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Stated {
52 pub kind: &'static str,
54 pub text: String,
55}
56
57pub const STATED_MAX_CHARS: usize = 200;
60
61const LEADS: &[&str] = &[
63 "from now on",
64 "from here on",
65 "henceforth",
66 "always ",
67 "never ",
68 "remember that ",
69 "remember to ",
70 "do not ever ",
71 "don't ever ",
72 "dont ever ",
73 "make sure to always ",
74 "make sure you always ",
75 "make sure you never ",
76 "i prefer ",
77 "we prefer ",
78 "we always ",
79 "we never ",
80 "stop using ",
81];
82
83const COMMA_LEADS: &[&str] = &["going forward,", "in future,", "in the future,"];
86
87const ANYWHERE: &[&str] = &["from now on", "from here on", "henceforth"];
89
90const ENDINGS: &[&str] = &["from now on", "going forward", "in future", "from here on"];
93
94const NOT_A_RULE: &[&str] = &[
97 "mind", "again", "the", "a", "an", "been", "seen", "heard", "was", "were", "is", "are", "had",
98 "did", "worked", "works", "work", "happened", "happens", "thought", "knew", "got", "gets",
99 "fails", "failed", "breaks", "broke", "on", "in", "at", "for", "with", "by", "when", "since",
100 "before", "after", "really", "quite", "sure", "ever", "once", "seems", "seemed",
101];
102
103const FILLER: &[&str] = &[
105 "ok", "okay", "so", "also", "and", "btw", "oh", "hey", "note", "fyi", "please", "but",
106];
107
108const QUESTION_OPENINGS: &[&str] = &[
110 "what", "why", "how", "can", "could", "should", "would", "do", "does", "did", "is", "are",
111 "will", "which", "who", "where", "when", "was", "were", "shall",
112];
113
114fn sentences(prompt: &str) -> Vec<String> {
117 let mut out = Vec::new();
118 for line in prompt.lines() {
119 let mut cur = String::new();
120 let chars: Vec<char> = line.chars().collect();
121 for (i, c) in chars.iter().enumerate() {
122 cur.push(*c);
123 let ends =
124 matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace());
125 if ends {
126 let s = cur.trim().to_string();
127 if !s.is_empty() {
128 out.push(s);
129 }
130 cur.clear();
131 }
132 }
133 let s = cur.trim().to_string();
134 if !s.is_empty() {
135 out.push(s);
136 }
137 }
138 out
139}
140
141fn outside_code(text: &str) -> String {
144 let mut out = String::with_capacity(text.len());
145 let mut inside = false;
146 for c in text.chars() {
147 if c == '`' {
148 inside = !inside;
149 out.push(' ');
150 } else if inside {
151 out.push('x');
152 } else {
153 out.push(c);
154 }
155 }
156 out
157}
158
159fn strip_filler(s: &str) -> &str {
162 let mut rest = s.trim_start();
163 loop {
164 let word_end = rest
165 .find(|c: char| !c.is_alphanumeric() && c != '\'')
166 .unwrap_or(rest.len());
167 let word = rest[..word_end].to_ascii_lowercase();
168 if word.is_empty() || !FILLER.contains(&word.as_str()) {
169 return rest;
170 }
171 let after = &rest[word_end..];
172 let after = after.trim_start_matches(|c: char| c == ',' || c == ':' || c.is_whitespace());
173 if after.len() == rest.len() || after.is_empty() {
174 return rest;
175 }
176 rest = after;
177 }
178}
179
180fn clause_leads(clause: &str) -> bool {
182 let c = clause.trim_start();
183 LEADS.iter().any(|lead| {
184 let Some(rest) = c.strip_prefix(lead) else {
185 return false;
186 };
187 if matches!(*lead, "always " | "never ") {
188 let next = rest
189 .split(|ch: char| !ch.is_alphanumeric() && ch != '\'')
190 .next()
191 .unwrap_or("");
192 return !next.is_empty() && !NOT_A_RULE.contains(&next);
193 }
194 true
195 })
196}
197
198fn states_a_rule(sentence: &str) -> bool {
200 let words = sentence.split_whitespace().count();
201 if !(3..=40).contains(&words) || sentence.chars().count() > STATED_MAX_CHARS {
202 return false;
203 }
204 if sentence.trim_end().ends_with('?') {
205 return false;
206 }
207 let plain = outside_code(strip_filler(sentence)).to_lowercase();
208 let first = plain
209 .split(|c: char| !c.is_alphanumeric() && c != '\'')
210 .next()
211 .unwrap_or("");
212 if QUESTION_OPENINGS.contains(&first) || first == "you" {
213 return false;
214 }
215 let bare = plain.trim_end_matches(|c: char| !c.is_alphanumeric());
216 if ANYWHERE.iter().any(|p| plain.contains(p))
217 || ENDINGS.iter().any(|p| bare.ends_with(p))
218 || COMMA_LEADS
219 .iter()
220 .any(|p| plain.trim_start().starts_with(p))
221 {
222 return true;
223 }
224 plain.split([',', ';']).any(clause_leads)
225}
226
227fn saved_words(sentence: &str) -> Stated {
230 let s = strip_filler(sentence).trim();
231 let lower = s.to_lowercase();
232 let (kind, body) = match lower.strip_prefix("remember that ") {
233 Some(_) => ("lesson", &s["remember that ".len()..]),
234 None => ("preference", s),
235 };
236 let mut chars = body.trim().chars();
237 let text = match chars.next() {
238 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
239 None => String::new(),
240 };
241 Stated { kind, text }
242}
243
244#[must_use]
250pub fn stated_preference(prompt: &str) -> Option<Stated> {
251 if pasted_or_quoted(prompt) {
252 return None;
253 }
254 sentences(prompt)
255 .into_iter()
256 .find(|s| states_a_rule(s))
257 .map(|s| saved_words(&s))
258 .filter(|s| s.text.split_whitespace().count() >= 2)
259}
260
261#[must_use]
265pub fn unattended() -> bool {
266 std::env::var("LJOS_UNATTENDED").is_ok_and(|v| {
267 matches!(
268 v.trim().to_ascii_lowercase().as_str(),
269 "1" | "true" | "yes" | "on"
270 )
271 })
272}
273
274fn rollout_thread(path: &str) -> Option<String> {
278 let name = path.rsplit(['/', '\\']).next()?;
279 let rest = name.strip_prefix("rollout-")?;
280 let stem = &rest[..rest.find(".jsonl")?];
281 let id = stem.get(20..56)?;
283 let tail = &stem[56..];
284 let uuid_shape = id.char_indices().all(|(i, c)| {
285 if matches!(i, 8 | 13 | 18 | 23) {
286 c == '-'
287 } else {
288 c.is_ascii_hexdigit()
289 }
290 });
291 (uuid_shape && (tail.is_empty() || tail.starts_with('_'))).then(|| id.to_ascii_lowercase())
292}
293
294#[must_use]
302pub fn not_the_person(input: &str) -> Option<&'static str> {
303 if unattended() {
304 return Some("LJOS_UNATTENDED is set");
305 }
306 let v: Value = serde_json::from_str(input.trim()).ok()?;
307 let named = |key: &str| {
308 v.get(key)
309 .and_then(Value::as_str)
310 .is_some_and(|s| !s.trim().is_empty())
311 };
312 if named("agent_id") || named("agentId") {
313 return Some("a subagent's brief");
314 }
315 let session = v
316 .get("session_id")
317 .and_then(Value::as_str)
318 .map(|s| s.trim().to_ascii_lowercase())
319 .filter(|s| !s.is_empty());
320 let thread = v
321 .get("transcript_path")
322 .and_then(Value::as_str)
323 .and_then(rollout_thread);
324 match (session, thread) {
325 (Some(session), Some(thread)) if session != thread => Some("another thread's prompt"),
326 _ => None,
327 }
328}
329
330pub const LATEST_PROMPT_SECS: u64 = 3600;
332
333const LATEST_PROMPT_CHARS: usize = 8000;
335
336fn latest_prompt_path() -> std::path::PathBuf {
337 runtime_dir().join("latest-prompt")
338}
339
340pub fn note_latest_prompt(prompt: &str) {
345 if capture_mode() == CaptureMode::Off || pasted_or_quoted(prompt) {
346 return;
347 }
348 let path = latest_prompt_path();
349 if let Some(dir) = path.parent() {
350 let _ = std::fs::create_dir_all(dir);
351 }
352 let text: String = prompt.chars().take(LATEST_PROMPT_CHARS).collect();
353 use std::io::Write as _;
354 use std::os::unix::fs::OpenOptionsExt as _;
355 if let Ok(mut f) = std::fs::OpenOptions::new()
356 .write(true)
357 .create(true)
358 .truncate(true)
359 .mode(0o600)
360 .open(&path)
361 {
362 let _ = f.write_all(text.as_bytes());
363 }
364}
365
366fn words_only(text: &str) -> String {
369 text.split(|c: char| !c.is_alphanumeric() && c != '\'')
370 .filter(|w| !w.is_empty())
371 .map(str::to_lowercase)
372 .collect::<Vec<_>>()
373 .join(" ")
374}
375
376#[must_use]
380pub fn said_in_latest_prompt(text: &str) -> bool {
381 let want = words_only(text);
382 if want.split(' ').count() < 4 {
383 return false;
384 }
385 let path = latest_prompt_path();
386 let fresh = std::fs::metadata(&path)
387 .and_then(|m| m.modified())
388 .ok()
389 .and_then(|t| t.elapsed().ok())
390 .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
391 if !fresh {
392 return false;
393 }
394 let Ok(prompt) = std::fs::read_to_string(&path) else {
395 return false;
396 };
397 format!(" {} ", words_only(&prompt)).contains(&format!(" {want} "))
398}
399
400fn capture_key(text: &str) -> String {
402 use std::hash::{Hash, Hasher};
403 let mut h = std::collections::hash_map::DefaultHasher::new();
404 words_only(text).hash(&mut h);
405 format!("capture:{:016x}", h.finish())
406}
407
408pub const CAPTURE_BUDGET_MS: u64 = 3000;
412
413fn save(stated: &Stated) -> Option<Value> {
416 with_pack_timeout(CAPTURE_BUDGET_MS, || {
417 let client = pack().ok()?;
418 let workspace = client.workspace();
419 let mut atom = atom_body(stated.kind, &stated.text, &workspace);
420 add_entities(&mut atom, ["via:prompt".to_string()]);
421 stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
422 admit::stamp_origin(&mut atom, admit::ORIGIN_USER);
423 let posted = admit::post_kept(&client, &atom).ok()?;
424 admit::satisfy_text(&stated.text);
425 Some(posted)
426 })
427}
428
429#[must_use]
434pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
435 if call.event != "UserPromptSubmit" {
436 return None;
437 }
438 let mode = capture_mode();
439 if mode == CaptureMode::Off {
440 return None;
441 }
442 let stated = stated_preference(&call.cue)?;
443 let key = capture_key(&stated.text);
444 let session = call.session.as_deref();
445 if seen_ids(session).contains(&key) {
446 return None;
447 }
448 let verb = if stated.kind == "lesson" {
449 "remember"
450 } else {
451 "prefer"
452 };
453 if mode == CaptureMode::Nudge {
454 return Some((
455 format!(
456 "This prompt states a standing choice. Write it in the person's words with `ljos {verb} \"{}\"` so the hook raises it next time.",
457 stated.text.replace('"', "'")
458 ),
459 vec![key],
460 ));
461 }
462 let Some(posted) = save(&stated) else {
463 return Some((
464 format!(
465 "This prompt states a standing choice and the pack did not take it. Write it with `ljos {verb} \"{}\"`.",
466 stated.text.replace('"', "'")
467 ),
468 vec![key],
469 ));
470 };
471 let id = posted["id"].as_str().unwrap_or("").to_string();
472 let mut now = vec![key.clone()];
473 if !id.is_empty() {
474 now.push(id.clone());
475 }
476 if let Some(cue) = super::correction_cue(&call.cue) {
479 now.push(format!("correction:{cue}"));
480 now.push(format!("correction-stored:{cue}"));
481 }
482 mark_seen(session, &now);
483 let what = if stated.kind == "lesson" {
484 "lesson"
485 } else {
486 "preference"
487 };
488 let undo = if id.is_empty() {
489 String::new()
490 } else {
491 format!(" `ljos forget {id}` takes it back.")
492 };
493 Some((
494 format!(
495 "Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
496 stated.text
497 ),
498 vec![key],
499 ))
500}
501
502const PROMPT_EXCERPT_CHARS: usize = 160;
504
505#[must_use]
510pub fn latest_prompt_excerpt() -> Option<String> {
511 let path = latest_prompt_path();
512 let fresh = std::fs::metadata(&path)
513 .and_then(|m| m.modified())
514 .ok()
515 .and_then(|t| t.elapsed().ok())
516 .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
517 if !fresh {
518 return None;
519 }
520 let prompt = std::fs::read_to_string(&path).ok()?;
521 let out = excerpt_of(&prompt);
522 (!out.is_empty()).then_some(out)
523}
524
525#[must_use]
527pub fn excerpt_of(prompt: &str) -> String {
528 let words: Vec<String> = prompt
529 .split_whitespace()
530 .map(|w| {
531 if secret_shaped(w) {
532 "[redacted]".to_string()
533 } else {
534 w.to_string()
535 }
536 })
537 .collect();
538 let joined = words.join(" ");
539 if joined.chars().count() <= PROMPT_EXCERPT_CHARS {
540 return joined;
541 }
542 let mut cut: String = joined.chars().take(PROMPT_EXCERPT_CHARS - 1).collect();
543 cut.push('…');
544 cut
545}
546
547fn secret_shaped(word: &str) -> bool {
550 let lower = word.to_ascii_lowercase();
551 if [
552 "key=",
553 "token=",
554 "secret=",
555 "password=",
556 "passwd=",
557 "key:",
558 "token:",
559 "secret:",
560 "password:",
561 ]
562 .iter()
563 .any(|p| lower.contains(p))
564 {
565 return true;
566 }
567 if [
568 "sk-",
569 "ghp_",
570 "gho_",
571 "github_pat_",
572 "xox",
573 "akia",
574 "glpat-",
575 ]
576 .iter()
577 .any(|p| lower.starts_with(p))
578 {
579 return true;
580 }
581 let core: String = word.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
582 core.len() >= 24
583 && core.chars().any(|c| c.is_ascii_digit())
584 && core.chars().any(|c| c.is_ascii_alphabetic())
585 && !word.contains('/')
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 const SAVES: &[&str] = &[
593 "From now on always use bun, never yarn, in this repo",
594 "Always run cargo fmt before committing.",
595 "Never push directly to main.",
596 "Going forward, write commit messages in the imperative mood.",
597 "Please always use pnpm for installs.",
598 "Remember that the staging database is read-only on Fridays.",
599 "Use bun, never yarn.",
600 "From now on, run the linter before every push.",
601 "Don't ever commit the .env file.",
602 "Never use force push on shared branches.",
603 "ok, from now on use tabs in Go files",
604 "We always squash merge pull requests here.",
605 "Always ask before running a database migration.",
606 "In future, put new tests next to the code they cover.",
607 "Stop using unwrap in library code.",
608 "I prefer small commits with one change each.",
609 "Henceforth all scripts use bash strict mode.",
610 "Remember to bump the version in Cargo.toml before tagging.",
611 "Never deploy on a Friday afternoon.",
612 "Keep it short, and from now on skip the summary at the end.",
613 "Always write `set -euo pipefail` at the top of shell scripts.",
614 "Make sure you always run the tests first.",
615 "We never edit an applied migration.",
616 "Thanks. From now on use uv instead of pip.",
617 "Use rg instead of grep going forward.",
618 "Always prefer rtrash over rm.",
619 "Never commit secrets to the repo.",
620 "Please never run npm install in this project.",
621 "From here on, write docs one sentence per line.",
622 "Also, always pin action versions by SHA.",
623 ];
624
625 const SAVES_NOTHING: &[&str] = &[
626 "Why do you always use yarn?",
627 "Should we always run the full suite?",
628 "Does this ever fail on CI?",
629 "Fix the failing test in parser.rs",
630 "You never listen to me",
631 "It always fails on the second run",
632 "Never mind, that was my mistake.",
633 "```\nalways_on = true\nnever_retry = false\n```",
634 "> Never push directly to main\nwhat does this rule mean",
635 "The doc says \"always use bun, never yarn, for this repo\" but is that right",
636 "error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
637 "This could break in the future.",
638 "Run the tests and tell me what fails",
639 "I always forget how this works, can you explain it",
640 "Never seen this error before, what is it",
641 "Always the same problem with this build",
642 "What's the best way to always keep the cache warm?",
643 "Add a flag `--always-retry` to the CLI",
644 "Rename never_called to unused in lib.rs",
645 "Can you remember that function we wrote yesterday?",
646 "It never worked on macOS anyway",
647 "Remember when the build broke last week",
648 "Is it true that git never forgets a commit",
649 "Please look at the failing job",
650 "Traceback (most recent call last):\n File \"a.py\", line 3\nerror: always broken\nwarning: never set",
651 "Always?",
652 "never",
653 "The upstream README says to always use yarn",
654 "Explain why we never got the webhook",
655 "Going forward is the only option for the migration",
656 ];
657
658 #[test]
662 fn a_subagent_brief_or_unattended_run_is_not_the_person() {
663 let _g = crate::tests::env_guard();
664 unsafe { std::env::remove_var("LJOS_UNATTENDED") };
665 let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
666 let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
667 let codex = |extra: &str, thread: &str| {
668 format!(
669 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}}}"#
670 )
671 };
672 assert_eq!(not_the_person(&codex("", root)), None);
673 assert_eq!(
674 not_the_person(&codex(
675 r#","agent_id":"019a-sub","agent_type":"worker""#,
676 root
677 )),
678 Some("a subagent's brief")
679 );
680 assert_eq!(
681 not_the_person(&codex("", other)),
682 Some("another thread's prompt")
683 );
684 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"}"#;
685 assert_eq!(
686 not_the_person(claude_main),
687 None,
688 "--agent alone is the main thread"
689 );
690 let claude_sub =
691 claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
692 assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
693 unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
694 assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
695 unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
696 assert_eq!(not_the_person(claude_main), None);
697 unsafe { std::env::remove_var("LJOS_UNATTENDED") };
698 }
699
700 #[test]
701 fn a_rollout_name_gives_its_thread() {
702 let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
703 assert_eq!(
704 rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
705 Some(id)
706 );
707 assert_eq!(
708 rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
709 Some(id)
710 );
711 assert_eq!(rollout_thread("/x/abc.jsonl"), None);
712 assert_eq!(
713 rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
714 None
715 );
716 }
717
718 #[test]
719 fn thirty_stated_choices_are_read() {
720 assert_eq!(SAVES.len(), 30);
721 let missed: Vec<&&str> = SAVES
722 .iter()
723 .filter(|p| stated_preference(p).is_none())
724 .collect();
725 assert!(
726 missed.is_empty(),
727 "not read as a standing choice: {missed:#?}"
728 );
729 }
730
731 #[test]
732 fn thirty_other_prompts_save_nothing() {
733 assert_eq!(SAVES_NOTHING.len(), 30);
734 let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
735 .iter()
736 .filter_map(|p| stated_preference(p).map(|s| (p, s)))
737 .collect();
738 assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
739 }
740
741 #[test]
742 fn the_saved_words_are_the_persons_sentence() {
743 let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
744 assert_eq!(s.kind, "preference");
745 assert_eq!(
746 s.text,
747 "From now on always use bun, never yarn, in this repo"
748 );
749 let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
750 assert_eq!(s.text, "From now on use tabs in Go files");
751 let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
752 assert_eq!(s.text, "From now on use uv instead of pip.");
753 let s = stated_preference("Remember that the staging database is read-only on Fridays.")
754 .unwrap();
755 assert_eq!(s.kind, "lesson");
756 assert_eq!(s.text, "The staging database is read-only on Fridays.");
757 }
758
759 #[test]
760 fn the_mode_reads_ljos_capture() {
761 let _g = crate::tests::env_guard();
762 let prev = std::env::var_os("LJOS_CAPTURE");
763 for (v, want) in [
764 ("", CaptureMode::Save),
765 ("save", CaptureMode::Save),
766 ("nudge", CaptureMode::Nudge),
767 ("off", CaptureMode::Off),
768 ("bogus", CaptureMode::Save),
769 ] {
770 unsafe { std::env::set_var("LJOS_CAPTURE", v) };
771 assert_eq!(capture_mode(), want, "{v}");
772 }
773 match prev {
774 Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
775 None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
776 }
777 }
778
779 #[test]
780 fn words_compare_whatever_the_punctuation() {
781 assert_eq!(words_only("Use bun, never yarn!"), "use bun never yarn");
782 }
783}