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
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 const SAVES: &[&str] = &[
507 "From now on always use bun, never yarn, in this repo",
508 "Always run cargo fmt before committing.",
509 "Never push directly to main.",
510 "Going forward, write commit messages in the imperative mood.",
511 "Please always use pnpm for installs.",
512 "Remember that the staging database is read-only on Fridays.",
513 "Use bun, never yarn.",
514 "From now on, run the linter before every push.",
515 "Don't ever commit the .env file.",
516 "Never use force push on shared branches.",
517 "ok, from now on use tabs in Go files",
518 "We always squash merge pull requests here.",
519 "Always ask before running a database migration.",
520 "In future, put new tests next to the code they cover.",
521 "Stop using unwrap in library code.",
522 "I prefer small commits with one change each.",
523 "Henceforth all scripts use bash strict mode.",
524 "Remember to bump the version in Cargo.toml before tagging.",
525 "Never deploy on a Friday afternoon.",
526 "Keep it short, and from now on skip the summary at the end.",
527 "Always write `set -euo pipefail` at the top of shell scripts.",
528 "Make sure you always run the tests first.",
529 "We never edit an applied migration.",
530 "Thanks. From now on use uv instead of pip.",
531 "Use rg instead of grep going forward.",
532 "Always prefer rtrash over rm.",
533 "Never commit secrets to the repo.",
534 "Please never run npm install in this project.",
535 "From here on, write docs one sentence per line.",
536 "Also, always pin action versions by SHA.",
537 ];
538
539 const SAVES_NOTHING: &[&str] = &[
540 "Why do you always use yarn?",
541 "Should we always run the full suite?",
542 "Does this ever fail on CI?",
543 "Fix the failing test in parser.rs",
544 "You never listen to me",
545 "It always fails on the second run",
546 "Never mind, that was my mistake.",
547 "```\nalways_on = true\nnever_retry = false\n```",
548 "> Never push directly to main\nwhat does this rule mean",
549 "The doc says \"always use bun, never yarn, for this repo\" but is that right",
550 "error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
551 "This could break in the future.",
552 "Run the tests and tell me what fails",
553 "I always forget how this works, can you explain it",
554 "Never seen this error before, what is it",
555 "Always the same problem with this build",
556 "What's the best way to always keep the cache warm?",
557 "Add a flag `--always-retry` to the CLI",
558 "Rename never_called to unused in lib.rs",
559 "Can you remember that function we wrote yesterday?",
560 "It never worked on macOS anyway",
561 "Remember when the build broke last week",
562 "Is it true that git never forgets a commit",
563 "Please look at the failing job",
564 "Traceback (most recent call last):\n File \"a.py\", line 3\nerror: always broken\nwarning: never set",
565 "Always?",
566 "never",
567 "The upstream README says to always use yarn",
568 "Explain why we never got the webhook",
569 "Going forward is the only option for the migration",
570 ];
571
572 #[test]
576 fn a_subagent_brief_or_unattended_run_is_not_the_person() {
577 let _g = crate::tests::env_guard();
578 unsafe { std::env::remove_var("LJOS_UNATTENDED") };
579 let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
580 let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
581 let codex = |extra: &str, thread: &str| {
582 format!(
583 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}}}"#
584 )
585 };
586 assert_eq!(not_the_person(&codex("", root)), None);
587 assert_eq!(
588 not_the_person(&codex(
589 r#","agent_id":"019a-sub","agent_type":"worker""#,
590 root
591 )),
592 Some("a subagent's brief")
593 );
594 assert_eq!(
595 not_the_person(&codex("", other)),
596 Some("another thread's prompt")
597 );
598 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"}"#;
599 assert_eq!(
600 not_the_person(claude_main),
601 None,
602 "--agent alone is the main thread"
603 );
604 let claude_sub =
605 claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
606 assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
607 unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
608 assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
609 unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
610 assert_eq!(not_the_person(claude_main), None);
611 unsafe { std::env::remove_var("LJOS_UNATTENDED") };
612 }
613
614 #[test]
615 fn a_rollout_name_gives_its_thread() {
616 let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
617 assert_eq!(
618 rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
619 Some(id)
620 );
621 assert_eq!(
622 rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
623 Some(id)
624 );
625 assert_eq!(rollout_thread("/x/abc.jsonl"), None);
626 assert_eq!(
627 rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
628 None
629 );
630 }
631
632 #[test]
633 fn thirty_stated_choices_are_read() {
634 assert_eq!(SAVES.len(), 30);
635 let missed: Vec<&&str> = SAVES
636 .iter()
637 .filter(|p| stated_preference(p).is_none())
638 .collect();
639 assert!(
640 missed.is_empty(),
641 "not read as a standing choice: {missed:#?}"
642 );
643 }
644
645 #[test]
646 fn thirty_other_prompts_save_nothing() {
647 assert_eq!(SAVES_NOTHING.len(), 30);
648 let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
649 .iter()
650 .filter_map(|p| stated_preference(p).map(|s| (p, s)))
651 .collect();
652 assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
653 }
654
655 #[test]
656 fn the_saved_words_are_the_persons_sentence() {
657 let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
658 assert_eq!(s.kind, "preference");
659 assert_eq!(
660 s.text,
661 "From now on always use bun, never yarn, in this repo"
662 );
663 let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
664 assert_eq!(s.text, "From now on use tabs in Go files");
665 let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
666 assert_eq!(s.text, "From now on use uv instead of pip.");
667 let s = stated_preference("Remember that the staging database is read-only on Fridays.")
668 .unwrap();
669 assert_eq!(s.kind, "lesson");
670 assert_eq!(s.text, "The staging database is read-only on Fridays.");
671 }
672
673 #[test]
674 fn the_mode_reads_ljos_capture() {
675 let _g = crate::tests::env_guard();
676 let prev = std::env::var_os("LJOS_CAPTURE");
677 for (v, want) in [
678 ("", CaptureMode::Save),
679 ("save", CaptureMode::Save),
680 ("nudge", CaptureMode::Nudge),
681 ("off", CaptureMode::Off),
682 ("bogus", CaptureMode::Save),
683 ] {
684 unsafe { std::env::set_var("LJOS_CAPTURE", v) };
685 assert_eq!(capture_mode(), want, "{v}");
686 }
687 match prev {
688 Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
689 None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
690 }
691 }
692
693 #[test]
694 fn words_compare_whatever_the_punctuation() {
695 assert_eq!(words_only("Use bun, never yarn!"), "use bun never yarn");
696 }
697}