use serde_json::Value;
use sha2::Digest as _;
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 dangling_negation(word: &str) -> bool {
let word = plain(word).to_lowercase();
let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
matches!(
word,
"not" | "never" | "no" | "nor" | "dont" | "don't" | "cannot" | "without" | "avoid"
) || word.ends_with("n't")
}
fn line_continues(line: &str) -> bool {
let line = line.trim_end();
if line.is_empty() {
return false;
}
let last = line.chars().next_back().unwrap_or(' ');
if matches!(last, '.' | '!' | '?') {
return false;
}
if matches!(last, ':' | ',' | ';' | '\u{2014}' | '\u{2013}') {
return true;
}
let word = line
.rsplit(|c: char| {
!c.is_alphanumeric() && !matches!(c, '\'' | '\u{2019}' | '\u{02bc}' | '\u{ff07}')
})
.next()
.unwrap_or("");
dangling_negation(word)
}
fn sentences(prompt: &str) -> Vec<String> {
let mut out = Vec::new();
let lines: Vec<&str> = prompt.lines().collect();
let mut index = 0;
while index < lines.len() {
if lines[index].trim().is_empty() {
index += 1;
continue;
}
let mut joined = lines[index].trim().to_string();
index += 1;
while line_continues(&joined) && index < lines.len() && !lines[index].trim().is_empty() {
joined.push(' ');
joined.push_str(lines[index].trim());
index += 1;
}
let mut cur = String::new();
let chars: Vec<char> = joined.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) {
note_latest_prompt_for(prompt, None);
}
pub fn note_latest_prompt_for(prompt: &str, session: Option<&str>) {
let sessions: Vec<String> = session.map(str::to_string).into_iter().collect();
note_latest_prompt_as(prompt, &sessions);
}
pub fn note_latest_prompt_as(prompt: &str, sessions: &[String]) {
note_latest_prompt_from(prompt, sessions, None, None);
}
pub fn forget_latest_prompt() {
let _ = std::fs::remove_file(latest_prompt_path());
let _ = std::fs::remove_file(latest_prompt_bind_path());
}
pub fn note_latest_prompt_from(
prompt: &str,
sessions: &[String],
runner: Option<&crate::proc_tree::Proc>,
transcript: Option<&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 _;
let wrote = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.and_then(|mut f| f.write_all(text.as_bytes()));
if wrote.is_ok() {
let bind = Bind {
runner: runner.map_or_else(|| "-".to_string(), crate::proc_tree::token),
at: now_nanos(),
transcript: bind_session(transcript),
};
let _ = write_prompt_bind(&path, text.as_bytes(), &bind, sessions);
}
}
struct Bind {
runner: String,
at: u128,
transcript: String,
}
fn now_nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
}
#[must_use]
pub fn payload_transcript(input: &str) -> Option<String> {
let v: Value = serde_json::from_str(input.trim()).ok()?;
v["transcript_path"]
.as_str()
.or_else(|| v["transcriptPath"].as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
const BIND_WRITE_NANOS: u128 = 500_000_000;
fn changed_nanos(path: &std::path::Path) -> Option<u128> {
use std::os::unix::fs::MetadataExt as _;
let m = std::fs::metadata(path).ok()?;
let at = |s: i64, ns: i64| -> Option<u128> {
Some(u128::try_from(s).ok()? * 1_000_000_000 + u128::try_from(ns).ok()?)
};
Some(at(m.ctime(), m.ctime_nsec())?.max(at(m.mtime(), m.mtime_nsec())?))
}
enum RecordWord {
Agrees,
Disagrees,
Unverified,
}
fn needs_positive_record(kind: &str) -> bool {
matches!(
kind,
"cursor" | "cursor-agent" | "grok" | "opencode" | "gemini" | "agy"
)
}
fn agrees_with_record(kept: &[u8], named: &str, runner_ids: &[String]) -> RecordWord {
use crate::turn_record;
let Some((runner, kind)) = crate::proc_tree::runner_above() else {
return RecordWord::Agrees;
};
let positive = needs_positive_record(kind);
let word = |ok: bool| {
if positive {
RecordWord::Unverified
} else if ok {
RecordWord::Agrees
} else {
RecordWord::Disagrees
}
};
let env = crate::proc_tree::environ(runner.pid).unwrap_or_default();
let roots = turn_record::roots(kind, &env);
if roots.is_empty() {
return word(true);
}
let record = if named == "-" {
roots
.iter()
.find_map(|r| turn_record::cursor_record(r, runner_ids))
.filter(|_| matches!(kind, "cursor" | "cursor-agent"))
} else {
turn_record::within(std::path::Path::new(named), &roots)
};
let Some(record) = record else {
return word(named == "-" && !turn_record::required(kind));
};
let Some(text) = crate::bookkeep::read_text_bounded(&record)
.filter(|t| !turn_record::latest_turn(t).is_empty())
.or_else(|| {
record
.is_file()
.then(|| std::fs::read_to_string(&record).ok())
.flatten()
})
else {
return word(false);
};
if !turn_record::required(kind) && turn_record::latest_turn(&text).is_empty() {
return word(true);
}
if turn_record::agrees(&text, &String::from_utf8_lossy(kept)) {
RecordWord::Agrees
} else {
word(false)
}
}
fn latest_prompt_bind_path() -> std::path::PathBuf {
runtime_dir().join("latest-prompt.bind")
}
fn mtime_nanos(path: &std::path::Path) -> Option<u128> {
std::fs::metadata(path)
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|age| age.as_nanos())
}
fn bind_session(session: Option<&str>) -> String {
match session
.map(str::trim)
.filter(|s| !s.is_empty() && !s.contains(['\n', '\r']))
{
Some(s) => s.to_string(),
None => "-".to_string(),
}
}
fn write_prompt_bind(
path: &std::path::Path,
bytes: &[u8],
bind: &Bind,
sessions: &[String],
) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let Some(mtime) = mtime_nanos(path) else {
return Err(std::io::Error::other("prompt mtime"));
};
let hash = hex::encode(sha2::Sha256::digest(bytes));
let mut ids: Vec<String> = Vec::new();
for s in sessions {
let s = bind_session(Some(s));
if s != "-" && !ids.contains(&s) {
ids.push(s);
}
}
if ids.is_empty() {
ids.push(bind_session(None));
}
let body = format!(
"3\n{hash}\n{mtime}\n{}\n{}\n{}\n{}\n",
bind.runner,
bind.at,
bind.transcript,
ids.join("\n")
);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(latest_prompt_bind_path())?;
f.write_all(body.as_bytes())
}
enum Kept {
Yes,
Unverified,
No,
}
fn kept_prompt(path: &std::path::Path) -> Kept {
let Ok(bytes) = std::fs::read(path) else {
return Kept::No;
};
let Some(mtime) = mtime_nanos(path) else {
return Kept::No;
};
let bind_path = latest_prompt_bind_path();
let Ok(bind) = std::fs::read_to_string(&bind_path) else {
return Kept::No;
};
let mut lines = bind.lines();
let (Some("3"), Some(hash), Some(stored), Some(spawner), Some(at), Some(transcript)) = (
lines.next(),
lines.next(),
lines.next(),
lines.next(),
lines.next().and_then(|t| t.parse::<u128>().ok()),
lines.next(),
) else {
return Kept::No;
};
let late = |p: &std::path::Path| {
changed_nanos(p).is_none_or(|c| c > at.saturating_add(BIND_WRITE_NANOS))
};
if late(path) || late(&bind_path) {
return Kept::No;
}
let bound: Vec<String> = lines
.map(crate::ballot_sig::field)
.filter(|s| s != "-")
.collect();
let runner = crate::ballot_sig::runner_sessions();
let above = spawner != "-"
&& crate::proc_tree::ancestors()
.iter()
.any(|p| crate::proc_tree::token(p) == spawner);
if !(above || bound.iter().any(|s| runner.contains(s))) {
return Kept::No;
}
if let Some(call) = crate::ballot_sig::call_session() {
let within = if runner.is_empty() { &bound } else { &runner };
if !within.contains(&call) {
return Kept::No;
}
}
if hash != hex::encode(sha2::Sha256::digest(&bytes)) || stored != mtime.to_string() {
return Kept::No;
}
match agrees_with_record(&bytes, transcript, &runner) {
RecordWord::Agrees => Kept::Yes,
RecordWord::Unverified => Kept::Unverified,
RecordWord::Disagrees => Kept::No,
}
}
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(" ")
}
struct Word {
word: String,
start: usize,
end: usize,
sentence_before: bool,
stop_before: bool,
clause_before: bool,
}
fn prompt_words(text: &str) -> Vec<Word> {
let mut out: Vec<Word> = Vec::new();
let (mut sentence, mut stop, mut clause) = (true, true, true);
let mut line_ended = false;
let mut at = None;
let close =
|out: &mut Vec<Word>, from: usize, to: usize, s: &mut bool, p: &mut bool, c: &mut bool| {
let raw = &text[from..to];
let lead = raw.len() - raw.trim_start_matches('\'').len();
let word = raw.trim_matches('\'');
if !word.is_empty() {
out.push(Word {
word: word.to_lowercase(),
start: from + lead,
end: from + lead + word.len(),
sentence_before: std::mem::take(s),
stop_before: std::mem::take(p),
clause_before: std::mem::take(c),
});
}
};
for (i, ch) in text.char_indices() {
if ch.is_alphanumeric() || ch == '\'' {
at.get_or_insert(i);
continue;
}
if let Some(from) = at.take() {
close(&mut out, from, i, &mut sentence, &mut stop, &mut clause);
line_ended = false;
}
if matches!(ch, '.' | '!' | '?' | ';' | '\n') {
sentence = true;
}
if matches!(ch, '.' | '!' | '?' | ';') || (ch == '\n' && line_ended) {
stop = true;
}
if ch == '\n' {
line_ended = true;
}
if matches!(
ch,
',' | ':'
| '('
| ')'
| '['
| ']'
| '"'
| '\u{201c}'
| '\u{201d}'
| '\u{2013}'
| '\u{2014}'
) {
clause = true;
}
}
if let Some(from) = at {
close(
&mut out,
from,
text.len(),
&mut sentence,
&mut stop,
&mut clause,
);
}
out
}
fn turns_it_around(word: &str) -> bool {
matches!(
word,
"not"
| "never"
| "no"
| "nor"
| "dont"
| "cannot"
| "without"
| "avoid"
| "unless"
| "except"
| "if"
| "only"
| "until"
) || word.ends_with("n't")
}
fn marks_kept(text: &str) -> String {
text.chars()
.filter(|c| {
!matches!(
c,
'.' | ','
| ';'
| ':'
| '!'
| '?'
| '\''
| '"'
| '('
| ')'
| '-'
| '\u{2018}'
| '\u{2019}'
| '\u{201c}'
| '\u{201d}'
| '\u{2013}'
| '\u{2014}'
)
})
.collect::<String>()
.split_whitespace()
.map(str::to_lowercase)
.collect::<Vec<_>>()
.join(" ")
}
fn plain(text: &str) -> String {
use unicode_normalization::UnicodeNormalization as _;
text.nfkc()
.map(|c| match c {
'\u{2019}' | '\u{02bc}' | '\u{ff07}' => '\'',
c => c,
})
.collect()
}
fn quotes(prompt: &str, quote: &str) -> bool {
let (prompt, quote) = (&plain(prompt), &plain(quote));
let words = prompt_words(prompt);
let want: Vec<String> = prompt_words(quote).into_iter().map(|w| w.word).collect();
let n = want.len();
if n == 0 || n > words.len() {
return false;
}
let ends = |i: usize| {
words
.get(i)
.is_none_or(|w| w.clause_before || w.sentence_before)
};
(0..=words.len() - n).any(|i| {
let j = i + n;
if !ends(i) || !ends(j) || words[i..j].iter().zip(&want).any(|(w, q)| w.word != *q) {
return false;
}
let from = (0..=i).rev().find(|&k| words[k].stop_before).unwrap_or(0);
let to = (j..words.len())
.find(|&k| words[k].stop_before)
.unwrap_or(words.len());
let dropped = words[from..i].iter().chain(&words[j..to]);
!dropped.into_iter().any(|w| turns_it_around(&w.word))
&& marks_kept(&prompt[words[i].start..words[j - 1].end]) == marks_kept(quote)
})
}
enum Latest {
Said,
Unverified,
No,
}
fn latest_words(text: &str) -> Latest {
let want = words_only(text);
if want.split(' ').count() < 4 {
return Latest::No;
}
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 Latest::No;
}
let kept = kept_prompt(&path);
if matches!(kept, Kept::No) {
return Latest::No;
}
let Ok(prompt) = std::fs::read_to_string(&path) else {
return Latest::No;
};
if !quotes(&prompt, text) {
return Latest::No;
}
match kept {
Kept::Yes => Latest::Said,
Kept::Unverified => Latest::Unverified,
Kept::No => Latest::No,
}
}
#[must_use]
pub fn said_in_latest_prompt(text: &str) -> bool {
matches!(latest_words(text), Latest::Said)
}
#[must_use]
pub fn unverified_latest_prompt(text: &str) -> bool {
matches!(latest_words(text), Latest::Unverified)
}
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);
stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
add_entities(&mut atom, [super::SAID_ENTITY.to_string()]);
admit::stamp_person(&mut atom);
let posted = admit::post_kept(&client, &atom).ok()?;
admit::satisfy_text(&stated.text);
Some(posted)
})
}
fn bind_record_name() -> (String, Vec<String>) {
let Ok(bind) = std::fs::read_to_string(latest_prompt_bind_path()) else {
return ("-".to_string(), Vec::new());
};
let mut lines = bind.lines();
if lines.next() != Some("3") {
return ("-".to_string(), Vec::new());
}
let _hash = lines.next();
let _mtime = lines.next();
let _runner = lines.next();
let _at = lines.next();
let named = lines.next().filter(|s| !s.is_empty()).unwrap_or("-");
let sessions = lines
.map(crate::ballot_sig::field)
.filter(|s| s != "-")
.collect();
(named.to_string(), sessions)
}
fn capture_unverified(prompt: &str) -> bool {
if crate::proc_tree::runner_above().is_none() {
return false;
}
let (named, sessions) = bind_record_name();
matches!(
agrees_with_record(prompt.as_bytes(), &named, &sessions),
RecordWord::Unverified
)
}
#[must_use]
pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
if call.event != "UserPromptSubmit" {
return None;
}
if capture_unverified(&call.cue) {
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.")
};
let persons = posted.pointer("/source/basis").and_then(Value::as_str) == Some("person");
let line = if persons {
format!(
"Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
stated.text
)
} else {
format!(
"Held that sentence for review, not as the person's {what}: \"{}\". \
`ljos memories review` keeps it.",
stated.text
)
};
Some((line, vec![key]))
}
const PROMPT_EXCERPT_CHARS: usize = 160;
#[must_use]
pub fn latest_prompt_excerpt() -> Option<String> {
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 None;
}
let prompt = std::fs::read_to_string(&path).ok()?;
let out = excerpt_of(&prompt);
(!out.is_empty()).then_some(out)
}
#[must_use]
pub fn excerpt_of(prompt: &str) -> String {
let words: Vec<String> = prompt
.split_whitespace()
.map(|w| {
if secret_shaped(w) {
"[redacted]".to_string()
} else {
w.to_string()
}
})
.collect();
let joined = words.join(" ");
if joined.chars().count() <= PROMPT_EXCERPT_CHARS {
return joined;
}
let mut cut: String = joined.chars().take(PROMPT_EXCERPT_CHARS - 1).collect();
cut.push('…');
cut
}
fn secret_shaped(word: &str) -> bool {
let lower = word.to_ascii_lowercase();
if [
"key=",
"token=",
"secret=",
"password=",
"passwd=",
"key:",
"token:",
"secret:",
"password:",
]
.iter()
.any(|p| lower.contains(p))
{
return true;
}
if [
"sk-",
"ghp_",
"gho_",
"github_pat_",
"xox",
"akia",
"glpat-",
]
.iter()
.any(|p| lower.starts_with(p))
{
return true;
}
let core: String = word.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
core.len() >= 24
&& core.chars().any(|c| c.is_ascii_digit())
&& core.chars().any(|c| c.is_ascii_alphabetic())
&& !word.contains('/')
}
#[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");
}
#[test]
fn a_typographic_apostrophe_still_turns_the_clause_around() {
let cut = "always paste the release key into the deploy form";
for apostrophe in ['\u{2019}', '\u{02bc}', '\u{ff07}'] {
let prompt = format!(
"Don{apostrophe}t do this: always paste the release key into the deploy form."
);
assert!(!quotes(&prompt, cut), "accepted under {apostrophe:?}");
assert!(
quotes(
&prompt,
"Don't do this: always paste the release key into the deploy form."
),
"refused the whole sentence under {apostrophe:?}"
);
}
assert!(quotes(
"Use bun. Always paste the release key into the deploy form.",
cut
));
}
#[test]
fn a_dangling_negation_is_joined_before_the_rule_is_read() {
assert!(stated_preference("Do not\nalways use npm").is_none());
assert!(stated_preference("Do not do this:\nalways use npm").is_none());
for prompt in ["Don\u{2019}t\nalways use npm", "Don't\nalways use npm"] {
if let Some(saved) = stated_preference(prompt) {
let text = saved.text.to_lowercase();
assert!(
text.contains("not") || text.contains("n't"),
"{prompt:?} saved {text}"
);
assert_ne!(text, "always use npm", "{prompt:?}");
}
}
let saved = stated_preference("Never\npush directly to main.").unwrap();
assert_eq!(saved.text, "Never push directly to main.");
let saved = stated_preference("Always use npm.").unwrap();
assert_eq!(saved.text, "Always use npm.");
let saved = stated_preference("Do not\n\nAlways use npm.").unwrap();
assert_eq!(saved.text, "Always use npm.");
let saved = stated_preference("Always use npm.\nNever push directly to main.").unwrap();
assert_eq!(saved.text, "Always use npm.");
}
#[test]
fn a_negation_on_the_line_before_still_governs_the_quote() {
let cut = "always paste the release key into the deploy form";
for prompt in [
"Do not\nalways paste the release key into the deploy form.",
"Do not do this:\nalways paste the release key into the deploy form.",
"Please never:\n\talways paste the release key into the deploy form",
"Always paste the release key into the deploy form\nunless asked.",
] {
assert!(!quotes(prompt, cut), "accepted from {prompt:?}");
}
assert!(quotes(
"Do not\nalways paste the release key into the deploy form.",
"Do not always paste the release key into the deploy form."
));
for prompt in [
"Use bun.\nAlways paste the release key into the deploy form.",
"Do not use yarn\n\nAlways paste the release key into the deploy form.",
] {
assert!(quotes(prompt, cut), "refused from {prompt:?}");
}
}
}