1use serde_json::Value;
19use std::collections::BTreeSet;
20
21pub const MAX_PER_SESSION: usize = 5;
23pub const MAX_TRANSCRIPT_BYTES: usize = 2 * 1024 * 1024;
25
26#[must_use]
30pub fn read_tail(path: &std::path::Path, cap: usize) -> Option<String> {
31 use std::io::{Read, Seek, SeekFrom};
32 let mut file = std::fs::File::open(path).ok()?;
33 let len = file.metadata().ok()?.len();
34 let start = len.saturating_sub(cap as u64);
35 file.seek(SeekFrom::Start(start)).ok()?;
36 let mut buf = Vec::new();
37 file.take(len - start).read_to_end(&mut buf).ok()?;
38 Some(String::from_utf8_lossy(&buf).into_owned())
39}
40
41const CUES: &[&str] = &[
42 "always ",
43 "never ",
44 " must ",
45 "root cause",
46 "turns out",
47 "turned out",
48 "the fix ",
49 "fixed by",
50 " because ",
51 " requires ",
52 " instead of ",
53 "does not work",
54 "doesn't work",
55 "is broken",
56 "the reason ",
57 "make sure ",
58 "remember that",
59];
60
61#[must_use]
63pub fn normal(s: &str) -> String {
64 s.to_lowercase()
65 .split(|c: char| !c.is_alphanumeric())
66 .filter(|w| !w.is_empty())
67 .collect::<Vec<_>>()
68 .join(" ")
69}
70
71fn texts_of(v: &Value, out: &mut Vec<String>) {
72 match v {
73 Value::String(s) => out.push(s.clone()),
74 Value::Array(a) => a.iter().for_each(|x| texts_of(x, out)),
75 Value::Object(o) => {
76 if matches!(
78 o.get("type").and_then(Value::as_str),
79 Some("tool_use" | "tool_result" | "thinking" | "image")
80 ) {
81 return;
82 }
83 for key in ["text", "content", "message"] {
84 if let Some(x) = o.get(key) {
85 texts_of(x, out);
86 }
87 }
88 }
89 _ => {}
90 }
91}
92
93#[must_use]
96pub fn prose(transcript: &str) -> Vec<String> {
97 let mut out = Vec::new();
98 for line in transcript.lines() {
99 match serde_json::from_str::<Value>(line) {
100 Ok(v) => {
101 let role = v["type"]
102 .as_str()
103 .or_else(|| v["role"].as_str())
104 .or_else(|| v["message"]["role"].as_str())
105 .unwrap_or("");
106 if matches!(role, "user" | "assistant" | "") {
107 texts_of(&v, &mut out);
108 }
109 }
110 Err(_) => out.push(line.to_string()),
111 }
112 }
113 out
114}
115
116fn sentences(text: &str) -> Vec<String> {
117 let mut out = Vec::new();
118 let mut in_fence = false;
119 for line in text.lines() {
120 if line.trim_start().starts_with("```") {
121 in_fence = !in_fence;
122 continue;
123 }
124 if in_fence {
125 continue;
126 }
127 let mut cur = String::new();
128 let mut chars = line.chars().peekable();
129 while let Some(c) = chars.next() {
130 cur.push(c);
131 if matches!(c, '.' | '!' | '?') && chars.peek().is_none_or(|n| n.is_whitespace()) {
134 out.push(std::mem::take(&mut cur));
135 }
136 }
137 if !cur.trim().is_empty() {
138 out.push(cur);
139 }
140 }
141 out.into_iter()
142 .map(|s| {
143 s.trim()
144 .trim_start_matches(['-', '*', '>', ' '])
145 .trim()
146 .to_string()
147 })
148 .collect()
149}
150
151#[must_use]
153pub fn is_fact(s: &str) -> bool {
154 let n = s.chars().count();
155 if !(30..=240).contains(&n) || s.ends_with('?') || s.contains('`') || s.contains("://") {
156 return false;
157 }
158 let low = format!(" {} ", s.to_lowercase());
159 if low.starts_with(" can you") || low.starts_with(" please") || low.contains(" i will ") {
161 return false;
162 }
163 CUES.iter().any(|c| low.contains(c))
164}
165
166#[must_use]
169pub fn facts(transcript: &str, kept: &BTreeSet<String>) -> Vec<String> {
170 let mut seen = kept.clone();
171 let mut out: Vec<String> = Vec::new();
172 for text in prose(transcript) {
173 for s in sentences(&text) {
174 if !is_fact(&s) {
175 continue;
176 }
177 let key = normal(&s);
178 if seen.insert(key) {
179 out.push(s);
180 }
181 }
182 }
183 let skip = out.len().saturating_sub(MAX_PER_SESSION);
185 out.into_iter().skip(skip).collect()
186}
187
188fn ledger() -> Option<std::path::PathBuf> {
189 let base = std::env::var_os("XDG_STATE_HOME")
190 .map(std::path::PathBuf::from)
191 .or_else(|| {
192 std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/state"))
193 })?;
194 Some(base.join("ljos").join("extracted"))
195}
196
197#[must_use]
199pub fn kept() -> BTreeSet<String> {
200 ledger()
201 .and_then(|p| std::fs::read_to_string(p).ok())
202 .map(|t| t.lines().map(str::to_string).collect())
203 .unwrap_or_default()
204}
205
206fn plain_failure(err: &anyhow::Error) -> String {
209 let text = format!("{err:#}");
210 let parts: Vec<&str> = text.split(": ").collect();
211 for (i, part) in parts.iter().enumerate() {
212 let status = part.trim().parse::<u16>();
213 if status.is_ok_and(|code| (400..=599).contains(&code)) {
214 let rest = parts[i + 1..].join(": ");
215 if !rest.is_empty() {
216 return rest;
217 }
218 }
219 }
220 text
221}
222
223#[must_use]
225pub fn asked_for() -> bool {
226 asked_by(std::env::var("LJOS_EXTRACT").ok().as_deref())
227}
228
229#[must_use]
231pub fn asked_by(set: Option<&str>) -> bool {
232 !matches!(set.map(str::trim), Some("off"))
233}
234
235pub fn at_session_end(input: &str, session: Option<&str>) -> usize {
240 schedule_finish(input, session);
241 0
242}
243
244pub fn schedule_finish(input: &str, session: Option<&str>) {
248 let dir = crate::runtime_dir();
249 let _ = std::fs::create_dir_all(&dir);
250 let _ = std::fs::remove_file(dir.join("session-finish.done"));
251 let stamp = std::time::SystemTime::now()
252 .duration_since(std::time::UNIX_EPOCH)
253 .map_or(0, |d| d.as_nanos());
254 let payload = dir.join(format!("session-finish-{stamp}.json"));
255 let body = serde_json::json!({
256 "session": session,
257 "input": input,
258 });
259 if std::fs::write(&payload, body.to_string()).is_err() {
260 eprintln!("ljos extract: the session finish could not be queued");
261 return;
262 }
263 let bin = std::env::current_exe()
264 .map(|p| p.display().to_string())
265 .unwrap_or_else(|_| "ljos".to_string());
266 let args = vec![
267 "hook".to_string(),
268 "--finish-session".to_string(),
269 payload.display().to_string(),
270 ];
271 let log = dir.join("session-finish.log");
272 if crate::detach(&bin, &args, &log).is_err() {
273 let _ = std::fs::remove_file(&payload);
274 eprintln!("ljos extract: the session finish could not be started");
275 }
276}
277
278pub fn run_finish_job(path: &std::path::Path) -> anyhow::Result<()> {
284 unsafe { std::env::remove_var("LJOS_IN_HOOK") };
287 let raw = std::fs::read_to_string(path)
288 .map_err(|err| anyhow::anyhow!("session finish: {} ({err})", path.display()))?;
289 let _ = std::fs::remove_file(path);
290 let job: Value = serde_json::from_str(&raw).unwrap_or(Value::Null);
291 let input = job["input"].as_str().unwrap_or("").to_string();
292 let session = job["session"].as_str().map(str::to_string);
293 let _ = crate::session_end(session.as_deref());
294 let lines = write_notes(&input, session.as_deref());
295 let _ = crate::drain_tracker_pushes();
296 mark_done(&lines);
297 Ok(())
298}
299
300fn mark_done(lines: &[String]) {
301 let path = crate::runtime_dir().join("session-finish.done");
302 let body = if lines.is_empty() {
303 "ok\n".to_string()
304 } else {
305 format!("{}\n", lines.join("\n"))
306 };
307 let _ = std::fs::write(path, body);
308}
309
310fn write_notes(input: &str, session: Option<&str>) -> Vec<String> {
317 if !asked_for() {
318 return Vec::new();
319 }
320 let Ok(v) = serde_json::from_str::<Value>(input) else {
321 return Vec::new();
322 };
323 let Some(path) = v["transcript_path"]
324 .as_str()
325 .or_else(|| v["transcriptPath"].as_str())
326 else {
327 return Vec::new();
328 };
329 let Some(text) = read_tail(std::path::Path::new(path), MAX_TRANSCRIPT_BYTES) else {
330 return Vec::new();
331 };
332 let mut lines = Vec::new();
333 let say = |lines: &mut Vec<String>, line: String| {
334 eprintln!("{line}");
335 lines.push(line);
336 };
337 let found = facts(&text, &kept());
338 let debug = std::env::var_os("LJOS_EXTRACT_DEBUG").is_some();
339 if debug {
340 say(
341 &mut lines,
342 format!("ljos extract: {} facts from {path}", found.len()),
343 );
344 }
345 if found.is_empty() {
346 return lines;
347 }
348 if crate::live_held_issue().is_none() {
352 say(
353 &mut lines,
354 format!("ljos extract: {}", crate::NOTE_WITHOUT_TICKET),
355 );
356 return lines;
357 }
358 let mut written = 0;
359 let mut keys = Vec::new();
360 for f in found {
361 let mut tags = vec!["via:session-end".to_string()];
362 if let Some(s) = session {
363 tags.push(format!("session:{s}"));
364 }
365 match crate::packset_write_agent_note_tagged(&f, &tags) {
366 Ok(_) => {
367 written += 1;
368 keys.push(normal(&f));
369 }
370 Err(e) => {
371 if debug {
372 say(
373 &mut lines,
374 format!("ljos extract: {f:?} not written: {}", plain_failure(&e)),
375 );
376 }
377 }
378 }
379 }
380 if debug {
381 say(&mut lines, format!("ljos extract: wrote {written}"));
382 }
383 if let (Some(p), false) = (ledger(), keys.is_empty()) {
384 if let Some(dir) = p.parent() {
385 let _ = std::fs::create_dir_all(dir);
386 }
387 let mut all: Vec<String> = kept().into_iter().collect();
388 all.extend(keys);
389 let skip = all.len().saturating_sub(5000);
391 let _ = std::fs::write(&p, all[skip..].join("\n") + "\n");
392 }
393 lines
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 const T: &str = r#"{"type":"user","message":{"role":"user","content":"Can you fix the flaky build?"}}
401{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Looking now."},{"type":"tool_use","name":"Bash","input":{"command":"cargo test because x"}}]}}
402{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"The root cause was a shared temp dir between tests. The fix is to give each test its own tempdir. Always run the suite with --test-threads=1 on this box. Done!"}]}}
403{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"The root cause was a shared temp dir between tests."}]}}"#;
404
405 #[test]
406 fn a_transcript_yields_its_durable_sentences_once() {
407 let got = facts(T, &BTreeSet::new());
408 assert_eq!(got.len(), 3, "{got:?}");
409 assert!(got[0].starts_with("The root cause was"));
410 assert!(got.iter().all(|f| !f.contains("Can you")));
411 assert!(
412 got.iter().all(|f| !f.contains("cargo test")),
413 "tool calls are not facts"
414 );
415 }
416
417 #[test]
418 fn already_kept_facts_are_skipped_and_the_count_is_bounded() {
419 let kept: BTreeSet<String> = [normal(
420 "The root cause was a shared temp dir between tests.",
421 )]
422 .into();
423 assert_eq!(facts(T, &kept).len(), 2);
424 let many: String = (0..20)
425 .map(|i| format!("Always pin tool number {i} to its tested version.\n"))
426 .collect();
427 let got = facts(&many, &BTreeSet::new());
428 assert_eq!(got.len(), MAX_PER_SESSION);
429 assert!(got.last().unwrap().contains("19"), "the newest are kept");
430 }
431
432 #[test]
434 fn extraction_runs_unless_turned_off() {
435 assert!(asked_by(None));
436 assert!(asked_by(Some("")));
437 assert!(asked_by(Some("on")));
438 assert!(asked_by(Some(" on ")));
439 assert!(asked_by(Some("1")));
440 assert!(!asked_by(Some("off")));
441 assert!(!asked_by(Some(" off ")));
442 }
443
444 #[test]
445 fn a_long_transcript_is_read_from_its_tail() {
446 let dir = std::env::temp_dir().join(format!("ljos-tail-{}", std::process::id()));
447 let _ = std::fs::remove_dir_all(&dir);
448 std::fs::create_dir_all(&dir).unwrap();
449 let path = dir.join("t.jsonl");
450 let mut body =
451 String::from("Always keep this sentence that sits at the head of the file.\n");
452 let pad = format!("{}\n", "x".repeat(200));
453 while body.len() < 8_000 {
454 body.push_str(&pad);
455 }
456 body.push_str("Never push to main without a green CI run on the branch.\n");
457 std::fs::write(&path, &body).unwrap();
458 let got = read_tail(&path, 800).unwrap();
459 assert!(got.contains("Never push"), "{got}");
460 assert!(!got.contains("sits at the head"), "{got}");
461 assert!(got.len() <= 800, "{}", got.len());
462 let _ = std::fs::remove_dir_all(&dir);
463 }
464
465 #[test]
466 fn questions_code_and_short_lines_are_not_facts() {
467 assert!(!is_fact("Should we always do this before a release?"));
468 assert!(!is_fact("Always."));
469 assert!(!is_fact(
470 "Always run `rm -rf target` before a build here, it helps."
471 ));
472 assert!(is_fact(
473 "Never push to main without a green CI run on the branch."
474 ));
475 }
476}