use serde_json::Value;
use super::{
add_entities, admit, atom_body, mark_seen, pack, pasted_or_quoted, runtime_dir, seen_ids,
stamp_horizon, with_pack_timeout, HookCall,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureMode {
Save,
Nudge,
Off,
}
#[must_use]
pub fn capture_mode() -> CaptureMode {
match std::env::var("LJOS_CAPTURE")
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"nudge" => CaptureMode::Nudge,
"off" | "0" | "no" | "false" => CaptureMode::Off,
_ => CaptureMode::Save,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stated {
pub kind: &'static str,
pub text: String,
}
pub const STATED_MAX_CHARS: usize = 200;
const LEADS: &[&str] = &[
"from now on",
"from here on",
"henceforth",
"always ",
"never ",
"remember that ",
"remember to ",
"do not ever ",
"don't ever ",
"dont ever ",
"make sure to always ",
"make sure you always ",
"make sure you never ",
"i prefer ",
"we prefer ",
"we always ",
"we never ",
"stop using ",
];
const COMMA_LEADS: &[&str] = &["going forward,", "in future,", "in the future,"];
const ANYWHERE: &[&str] = &["from now on", "from here on", "henceforth"];
const ENDINGS: &[&str] = &["from now on", "going forward", "in future", "from here on"];
const NOT_A_RULE: &[&str] = &[
"mind", "again", "the", "a", "an", "been", "seen", "heard", "was", "were", "is", "are", "had",
"did", "worked", "works", "work", "happened", "happens", "thought", "knew", "got", "gets",
"fails", "failed", "breaks", "broke", "on", "in", "at", "for", "with", "by", "when", "since",
"before", "after", "really", "quite", "sure", "ever", "once", "seems", "seemed",
];
const FILLER: &[&str] = &[
"ok", "okay", "so", "also", "and", "btw", "oh", "hey", "note", "fyi", "please", "but",
];
const QUESTION_OPENINGS: &[&str] = &[
"what", "why", "how", "can", "could", "should", "would", "do", "does", "did", "is", "are",
"will", "which", "who", "where", "when", "was", "were", "shall",
];
fn sentences(prompt: &str) -> Vec<String> {
let mut out = Vec::new();
for line in prompt.lines() {
let mut cur = String::new();
let chars: Vec<char> = line.chars().collect();
for (i, c) in chars.iter().enumerate() {
cur.push(*c);
let ends =
matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace());
if ends {
let s = cur.trim().to_string();
if !s.is_empty() {
out.push(s);
}
cur.clear();
}
}
let s = cur.trim().to_string();
if !s.is_empty() {
out.push(s);
}
}
out
}
fn outside_code(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut inside = false;
for c in text.chars() {
if c == '`' {
inside = !inside;
out.push(' ');
} else if inside {
out.push('x');
} else {
out.push(c);
}
}
out
}
fn strip_filler(s: &str) -> &str {
let mut rest = s.trim_start();
loop {
let word_end = rest
.find(|c: char| !c.is_alphanumeric() && c != '\'')
.unwrap_or(rest.len());
let word = rest[..word_end].to_ascii_lowercase();
if word.is_empty() || !FILLER.contains(&word.as_str()) {
return rest;
}
let after = &rest[word_end..];
let after = after.trim_start_matches(|c: char| c == ',' || c == ':' || c.is_whitespace());
if after.len() == rest.len() || after.is_empty() {
return rest;
}
rest = after;
}
}
fn clause_leads(clause: &str) -> bool {
let c = clause.trim_start();
LEADS.iter().any(|lead| {
let Some(rest) = c.strip_prefix(lead) else {
return false;
};
if matches!(*lead, "always " | "never ") {
let next = rest
.split(|ch: char| !ch.is_alphanumeric() && ch != '\'')
.next()
.unwrap_or("");
return !next.is_empty() && !NOT_A_RULE.contains(&next);
}
true
})
}
fn states_a_rule(sentence: &str) -> bool {
let words = sentence.split_whitespace().count();
if !(3..=40).contains(&words) || sentence.chars().count() > STATED_MAX_CHARS {
return false;
}
if sentence.trim_end().ends_with('?') {
return false;
}
let plain = outside_code(strip_filler(sentence)).to_lowercase();
let first = plain
.split(|c: char| !c.is_alphanumeric() && c != '\'')
.next()
.unwrap_or("");
if QUESTION_OPENINGS.contains(&first) || first == "you" {
return false;
}
let bare = plain.trim_end_matches(|c: char| !c.is_alphanumeric());
if ANYWHERE.iter().any(|p| plain.contains(p))
|| ENDINGS.iter().any(|p| bare.ends_with(p))
|| COMMA_LEADS
.iter()
.any(|p| plain.trim_start().starts_with(p))
{
return true;
}
plain.split([',', ';']).any(clause_leads)
}
fn saved_words(sentence: &str) -> Stated {
let s = strip_filler(sentence).trim();
let lower = s.to_lowercase();
let (kind, body) = match lower.strip_prefix("remember that ") {
Some(_) => ("lesson", &s["remember that ".len()..]),
None => ("preference", s),
};
let mut chars = body.trim().chars();
let text = match chars.next() {
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
};
Stated { kind, text }
}
#[must_use]
pub fn stated_preference(prompt: &str) -> Option<Stated> {
if pasted_or_quoted(prompt) {
return None;
}
sentences(prompt)
.into_iter()
.find(|s| states_a_rule(s))
.map(|s| saved_words(&s))
.filter(|s| s.text.split_whitespace().count() >= 2)
}
#[must_use]
pub fn unattended() -> bool {
std::env::var("LJOS_UNATTENDED").is_ok_and(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
fn rollout_thread(path: &str) -> Option<String> {
let name = path.rsplit(['/', '\\']).next()?;
let rest = name.strip_prefix("rollout-")?;
let stem = &rest[..rest.find(".jsonl")?];
let id = stem.get(20..56)?;
let tail = &stem[56..];
let uuid_shape = id.char_indices().all(|(i, c)| {
if matches!(i, 8 | 13 | 18 | 23) {
c == '-'
} else {
c.is_ascii_hexdigit()
}
});
(uuid_shape && (tail.is_empty() || tail.starts_with('_'))).then(|| id.to_ascii_lowercase())
}
#[must_use]
pub fn not_the_person(input: &str) -> Option<&'static str> {
if unattended() {
return Some("LJOS_UNATTENDED is set");
}
let v: Value = serde_json::from_str(input.trim()).ok()?;
let named = |key: &str| {
v.get(key)
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
};
if named("agent_id") || named("agentId") {
return Some("a subagent's brief");
}
let session = v
.get("session_id")
.and_then(Value::as_str)
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let thread = v
.get("transcript_path")
.and_then(Value::as_str)
.and_then(rollout_thread);
match (session, thread) {
(Some(session), Some(thread)) if session != thread => Some("another thread's prompt"),
_ => None,
}
}
pub const LATEST_PROMPT_SECS: u64 = 3600;
const LATEST_PROMPT_CHARS: usize = 8000;
fn latest_prompt_path() -> std::path::PathBuf {
runtime_dir().join("latest-prompt")
}
pub fn note_latest_prompt(prompt: &str) {
if capture_mode() == CaptureMode::Off || pasted_or_quoted(prompt) {
return;
}
let path = latest_prompt_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let text: String = prompt.chars().take(LATEST_PROMPT_CHARS).collect();
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
if let Ok(mut f) = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
{
let _ = f.write_all(text.as_bytes());
}
}
fn words_only(text: &str) -> String {
text.split(|c: char| !c.is_alphanumeric() && c != '\'')
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn said_in_latest_prompt(text: &str) -> bool {
let want = words_only(text);
if want.split(' ').count() < 4 {
return false;
}
let path = latest_prompt_path();
let fresh = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
if !fresh {
return false;
}
let Ok(prompt) = std::fs::read_to_string(&path) else {
return false;
};
format!(" {} ", words_only(&prompt)).contains(&format!(" {want} "))
}
fn capture_key(text: &str) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
words_only(text).hash(&mut h);
format!("capture:{:016x}", h.finish())
}
pub const CAPTURE_BUDGET_MS: u64 = 3000;
fn save(stated: &Stated) -> Option<Value> {
with_pack_timeout(CAPTURE_BUDGET_MS, || {
let client = pack().ok()?;
let workspace = client.workspace();
let mut atom = atom_body(stated.kind, &stated.text, &workspace);
add_entities(&mut atom, ["via:prompt".to_string()]);
stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
admit::stamp_origin(&mut atom, admit::ORIGIN_USER);
let posted = admit::post_kept(&client, &atom).ok()?;
admit::satisfy_text(&stated.text);
Some(posted)
})
}
#[must_use]
pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
if call.event != "UserPromptSubmit" {
return None;
}
let mode = capture_mode();
if mode == CaptureMode::Off {
return None;
}
let stated = stated_preference(&call.cue)?;
let key = capture_key(&stated.text);
let session = call.session.as_deref();
if seen_ids(session).contains(&key) {
return None;
}
let verb = if stated.kind == "lesson" {
"remember"
} else {
"prefer"
};
if mode == CaptureMode::Nudge {
return Some((
format!(
"This prompt states a standing choice. Write it in the person's words with `ljos {verb} \"{}\"` so the hook raises it next time.",
stated.text.replace('"', "'")
),
vec![key],
));
}
let Some(posted) = save(&stated) else {
return Some((
format!(
"This prompt states a standing choice and the pack did not take it. Write it with `ljos {verb} \"{}\"`.",
stated.text.replace('"', "'")
),
vec![key],
));
};
let id = posted["id"].as_str().unwrap_or("").to_string();
let mut now = vec![key.clone()];
if !id.is_empty() {
now.push(id.clone());
}
if let Some(cue) = super::correction_cue(&call.cue) {
now.push(format!("correction:{cue}"));
now.push(format!("correction-stored:{cue}"));
}
mark_seen(session, &now);
let what = if stated.kind == "lesson" {
"lesson"
} else {
"preference"
};
let undo = if id.is_empty() {
String::new()
} else {
format!(" `ljos forget {id}` takes it back.")
};
Some((
format!(
"Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
stated.text
),
vec![key],
))
}
#[cfg(test)]
mod tests {
use super::*;
const SAVES: &[&str] = &[
"From now on always use bun, never yarn, in this repo",
"Always run cargo fmt before committing.",
"Never push directly to main.",
"Going forward, write commit messages in the imperative mood.",
"Please always use pnpm for installs.",
"Remember that the staging database is read-only on Fridays.",
"Use bun, never yarn.",
"From now on, run the linter before every push.",
"Don't ever commit the .env file.",
"Never use force push on shared branches.",
"ok, from now on use tabs in Go files",
"We always squash merge pull requests here.",
"Always ask before running a database migration.",
"In future, put new tests next to the code they cover.",
"Stop using unwrap in library code.",
"I prefer small commits with one change each.",
"Henceforth all scripts use bash strict mode.",
"Remember to bump the version in Cargo.toml before tagging.",
"Never deploy on a Friday afternoon.",
"Keep it short, and from now on skip the summary at the end.",
"Always write `set -euo pipefail` at the top of shell scripts.",
"Make sure you always run the tests first.",
"We never edit an applied migration.",
"Thanks. From now on use uv instead of pip.",
"Use rg instead of grep going forward.",
"Always prefer rtrash over rm.",
"Never commit secrets to the repo.",
"Please never run npm install in this project.",
"From here on, write docs one sentence per line.",
"Also, always pin action versions by SHA.",
];
const SAVES_NOTHING: &[&str] = &[
"Why do you always use yarn?",
"Should we always run the full suite?",
"Does this ever fail on CI?",
"Fix the failing test in parser.rs",
"You never listen to me",
"It always fails on the second run",
"Never mind, that was my mistake.",
"```\nalways_on = true\nnever_retry = false\n```",
"> Never push directly to main\nwhat does this rule mean",
"The doc says \"always use bun, never yarn, for this repo\" but is that right",
"error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
"This could break in the future.",
"Run the tests and tell me what fails",
"I always forget how this works, can you explain it",
"Never seen this error before, what is it",
"Always the same problem with this build",
"What's the best way to always keep the cache warm?",
"Add a flag `--always-retry` to the CLI",
"Rename never_called to unused in lib.rs",
"Can you remember that function we wrote yesterday?",
"It never worked on macOS anyway",
"Remember when the build broke last week",
"Is it true that git never forgets a commit",
"Please look at the failing job",
"Traceback (most recent call last):\n File \"a.py\", line 3\nerror: always broken\nwarning: never set",
"Always?",
"never",
"The upstream README says to always use yarn",
"Explain why we never got the webhook",
"Going forward is the only option for the migration",
];
#[test]
fn a_subagent_brief_or_unattended_run_is_not_the_person() {
let _g = crate::tests::env_guard();
unsafe { std::env::remove_var("LJOS_UNATTENDED") };
let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
let codex = |extra: &str, thread: &str| {
format!(
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}}}"#
)
};
assert_eq!(not_the_person(&codex("", root)), None);
assert_eq!(
not_the_person(&codex(
r#","agent_id":"019a-sub","agent_type":"worker""#,
root
)),
Some("a subagent's brief")
);
assert_eq!(
not_the_person(&codex("", other)),
Some("another thread's prompt")
);
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"}"#;
assert_eq!(
not_the_person(claude_main),
None,
"--agent alone is the main thread"
);
let claude_sub =
claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
assert_eq!(not_the_person(claude_main), None);
unsafe { std::env::remove_var("LJOS_UNATTENDED") };
}
#[test]
fn a_rollout_name_gives_its_thread() {
let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
assert_eq!(
rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
Some(id)
);
assert_eq!(
rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
Some(id)
);
assert_eq!(rollout_thread("/x/abc.jsonl"), None);
assert_eq!(
rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
None
);
}
#[test]
fn thirty_stated_choices_are_read() {
assert_eq!(SAVES.len(), 30);
let missed: Vec<&&str> = SAVES
.iter()
.filter(|p| stated_preference(p).is_none())
.collect();
assert!(
missed.is_empty(),
"not read as a standing choice: {missed:#?}"
);
}
#[test]
fn thirty_other_prompts_save_nothing() {
assert_eq!(SAVES_NOTHING.len(), 30);
let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
.iter()
.filter_map(|p| stated_preference(p).map(|s| (p, s)))
.collect();
assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
}
#[test]
fn the_saved_words_are_the_persons_sentence() {
let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
assert_eq!(s.kind, "preference");
assert_eq!(
s.text,
"From now on always use bun, never yarn, in this repo"
);
let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
assert_eq!(s.text, "From now on use tabs in Go files");
let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
assert_eq!(s.text, "From now on use uv instead of pip.");
let s = stated_preference("Remember that the staging database is read-only on Fridays.")
.unwrap();
assert_eq!(s.kind, "lesson");
assert_eq!(s.text, "The staging database is read-only on Fridays.");
}
#[test]
fn the_mode_reads_ljos_capture() {
let _g = crate::tests::env_guard();
let prev = std::env::var_os("LJOS_CAPTURE");
for (v, want) in [
("", CaptureMode::Save),
("save", CaptureMode::Save),
("nudge", CaptureMode::Nudge),
("off", CaptureMode::Off),
("bogus", CaptureMode::Save),
] {
unsafe { std::env::set_var("LJOS_CAPTURE", v) };
assert_eq!(capture_mode(), want, "{v}");
}
match prev {
Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
}
}
#[test]
fn words_compare_whatever_the_punctuation() {
assert_eq!(words_only("Use bun, never yarn!"), "use bun never yarn");
}
}