use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::Utc;
use netsky_core::Result;
use serde_json::{Value, json};
const SESSION_PROJECT_DIR: &str = "-Users-cody-netsky";
const MAX_PER_BUCKET: usize = 50;
const MAX_LINE_CHARS: usize = 200;
pub fn salvage(target: &str, json_out: bool) -> Result<()> {
let jsonl = resolve_session_path(target)?;
let salvage = read_salvage(&jsonl)?;
let notes_path = notes_path_for(&salvage.first_ts);
let appended = append_retro(¬es_path, &salvage)?;
if json_out {
let envelope = json!({
"command": "session",
"status": "green",
"summary": format!(
"salvaged {} line(s) from {}", appended.lines, jsonl.display()
),
"generated_at": Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"data": {
"subcommand": "salvage",
"session_id": salvage.session_id,
"first_ts": salvage.first_ts,
"last_ts": salvage.last_ts,
"source_jsonl": jsonl.display().to_string(),
"notes_path": notes_path.display().to_string(),
"appended_lines": appended.lines,
"directives": salvage.directives.len(),
"dispatches": salvage.dispatches.len(),
"commits": salvage.commits.len(),
},
});
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else {
println!(
"salvaged {} lines from {} ({} directives, {} dispatches, {} commits) -> {}",
appended.lines,
jsonl.display(),
salvage.directives.len(),
salvage.dispatches.len(),
salvage.commits.len(),
notes_path.display()
);
}
Ok(())
}
fn resolve_session_path(target: &str) -> Result<PathBuf> {
if target.contains('/') {
let p = PathBuf::from(target);
if !p.exists() {
netsky_core::bail!("session file not found: {}", p.display());
}
return Ok(p);
}
let dir = session_dir()?;
if target == "latest" {
let latest = most_recent_jsonl(&dir)?;
return latest.ok_or_else(|| {
netsky_core::Error::Message(format!("no *.jsonl sessions under {}", dir.display()))
});
}
let candidate = dir.join(format!("{target}.jsonl"));
if !candidate.exists() {
netsky_core::bail!("session {target} not found at {}", candidate.display());
}
Ok(candidate)
}
fn session_dir() -> Result<PathBuf> {
let home = netsky_core::paths::home();
Ok(home
.join(".claude")
.join("projects")
.join(SESSION_PROJECT_DIR))
}
fn most_recent_jsonl(dir: &Path) -> Result<Option<PathBuf>> {
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
let entries = match fs::read_dir(dir) {
Ok(iter) => iter,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(mtime) = meta.modified() else { continue };
if best.as_ref().is_none_or(|(ts, _)| mtime > *ts) {
best = Some((mtime, path));
}
}
Ok(best.map(|(_, p)| p))
}
#[derive(Debug, Default)]
pub struct Salvage {
pub session_id: String,
pub first_ts: String,
pub last_ts: String,
pub directives: Vec<String>,
pub dispatches: Vec<String>,
pub commits: Vec<String>,
pub tail: Vec<String>,
}
fn read_salvage(path: &Path) -> Result<Salvage> {
let data = fs::read_to_string(path)?;
Ok(extract_from_lines(&data))
}
pub fn extract_from_lines(data: &str) -> Salvage {
let mut s = Salvage::default();
let mut tail_ring: Vec<String> = Vec::new();
for raw in data.lines() {
let Ok(v) = serde_json::from_str::<Value>(raw) else {
continue;
};
let kind = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
let ts = v.get("timestamp").and_then(|x| x.as_str()).unwrap_or("");
if !ts.is_empty() {
if s.first_ts.is_empty() {
s.first_ts = ts.to_string();
}
s.last_ts = ts.to_string();
}
if s.session_id.is_empty()
&& let Some(sid) = v.get("sessionId").and_then(|x| x.as_str())
{
s.session_id = sid.to_string();
}
match kind {
"user" => collect_user(&v, &mut s, &mut tail_ring),
"assistant" => collect_assistant(&v, &mut s, &mut tail_ring),
_ => {}
}
}
s.tail = tail_ring;
cap_buckets(&mut s);
s
}
fn collect_user(v: &Value, s: &mut Salvage, tail: &mut Vec<String>) {
let Some(content) = v.pointer("/message/content") else {
return;
};
let text = content_to_text(content);
let trimmed = text.trim();
if trimmed.is_empty() {
return;
}
if trimmed.starts_with("<command-message>")
|| trimmed.starts_with("<command-name>")
|| trimmed.starts_with("Base directory for this skill:")
|| trimmed.starts_with("<system-reminder>")
|| trimmed.starts_with("[SYSTEM NOTIFICATION")
{
return;
}
if trimmed.starts_with("[{") || trimmed.starts_with("{\"tool_use_id") {
return;
}
s.directives.push(truncate(trimmed, MAX_LINE_CHARS));
push_tail(tail, format!("user: {}", truncate(trimmed, MAX_LINE_CHARS)));
}
fn collect_assistant(v: &Value, s: &mut Salvage, tail: &mut Vec<String>) {
let Some(content) = v.get("message").and_then(|m| m.get("content")) else {
return;
};
let Some(arr) = content.as_array() else {
return;
};
for block in arr {
match block.get("type").and_then(|x| x.as_str()) {
Some("tool_use") => collect_tool_use(block, s, tail),
Some("text") => {
if let Some(t) = block.get("text").and_then(|x| x.as_str()) {
let trimmed = t.trim();
if !trimmed.is_empty() {
push_tail(
tail,
format!("agent: {}", truncate(trimmed, MAX_LINE_CHARS)),
);
}
}
}
_ => {}
}
}
}
fn collect_tool_use(block: &Value, s: &mut Salvage, tail: &mut Vec<String>) {
let name = block.get("name").and_then(|x| x.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(Value::Null);
match name {
"Bash" => {
let cmd = input
.get("command")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim();
if cmd.is_empty() {
return;
}
if is_commit_command(cmd) {
s.commits.push(truncate(cmd, MAX_LINE_CHARS));
}
if cmd.contains("netsky agent ") || cmd.contains("netsky codex ") {
s.dispatches
.push(truncate(&format!("spawn: {cmd}"), MAX_LINE_CHARS));
}
push_tail(tail, truncate(&format!("bash: {cmd}"), MAX_LINE_CHARS));
}
"mcp__agent__reply" => {
let chat_id = input
.get("chat_id")
.and_then(|x| x.as_str())
.unwrap_or("agent0");
let text = input.get("text").and_then(|x| x.as_str()).unwrap_or("");
s.dispatches.push(truncate(
&format!("reply -> {chat_id}: {text}"),
MAX_LINE_CHARS,
));
}
_ => {}
}
}
fn is_commit_command(cmd: &str) -> bool {
let cleaned = cmd.replace('\n', " ");
if cleaned.contains("git commit --help") || cleaned.contains("git commit -h ") {
return false;
}
cleaned.contains("git commit ")
|| cleaned.ends_with("git commit")
|| cleaned.starts_with("git commit")
}
fn content_to_text(v: &Value) -> String {
if let Some(s) = v.as_str() {
return s.to_string();
}
if let Some(arr) = v.as_array() {
let mut buf = String::new();
for item in arr {
if let Some(t) = item.get("text").and_then(|x| x.as_str()) {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(t);
}
}
return buf;
}
String::new()
}
fn push_tail(tail: &mut Vec<String>, line: String) {
const TAIL_SIZE: usize = 20;
tail.push(line);
if tail.len() > TAIL_SIZE {
tail.remove(0);
}
}
fn cap_buckets(s: &mut Salvage) {
if s.directives.len() > MAX_PER_BUCKET {
s.directives.drain(0..s.directives.len() - MAX_PER_BUCKET);
}
if s.dispatches.len() > MAX_PER_BUCKET {
s.dispatches.drain(0..s.dispatches.len() - MAX_PER_BUCKET);
}
if s.commits.len() > MAX_PER_BUCKET {
s.commits.drain(0..s.commits.len() - MAX_PER_BUCKET);
}
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.replace('\n', " ").trim().to_string();
}
let head: String = s.chars().take(n).collect();
format!("{}...", head.trim().replace('\n', " "))
}
fn notes_path_for(first_ts: &str) -> PathBuf {
let (y, m, d) = date_parts(first_ts);
PathBuf::from("notes")
.join(y)
.join(m)
.join(d)
.join("agent0.md")
}
fn date_parts(ts: &str) -> (String, String, String) {
let date_part = ts.split('T').next().unwrap_or(ts);
let mut split = date_part.split('-');
let y = split.next().unwrap_or("unknown").to_string();
let m = split.next().unwrap_or("unknown").to_string();
let d = split.next().unwrap_or("unknown").to_string();
(y, m, d)
}
struct AppendStats {
lines: usize,
}
fn append_retro(path: &Path, s: &Salvage) -> Result<AppendStats> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let block = render_block(s);
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
if path.metadata().map(|m| m.len()).unwrap_or(0) == 0 {
writeln!(f, "# notes")?;
writeln!(f)?;
}
f.write_all(block.as_bytes())?;
Ok(AppendStats {
lines: block.lines().count(),
})
}
pub fn render_block(s: &Salvage) -> String {
let mut out = String::new();
out.push_str(&format!(
"## session salvage ({} -> {} UTC)\n\n",
short_ts(&s.first_ts),
short_ts(&s.last_ts)
));
out.push_str(&format!("- session id: {}\n", s.session_id));
out.push_str("- source: post-hoc from claude JSONL (agent0 died before /notes)\n\n");
if !s.directives.is_empty() {
out.push_str("### directives\n\n");
for d in &s.directives {
out.push_str(&format!("- {d}\n"));
}
out.push('\n');
}
if !s.dispatches.is_empty() {
out.push_str("### dispatches\n\n");
for d in &s.dispatches {
out.push_str(&format!("- {d}\n"));
}
out.push('\n');
}
if !s.commits.is_empty() {
out.push_str("### commits\n\n");
for c in &s.commits {
out.push_str(&format!("- {c}\n"));
}
out.push('\n');
}
if !s.tail.is_empty() {
out.push_str("### tail (last 20 events, unfinished work)\n\n");
for t in &s.tail {
out.push_str(&format!("- {t}\n"));
}
out.push('\n');
}
out
}
fn short_ts(ts: &str) -> &str {
ts.split('.').next().unwrap_or(ts)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample_jsonl() -> String {
[
r#"{"type":"user","message":{"role":"user","content":"<command-message>up</command-message>\n<command-name>/up</command-name>"},"timestamp":"2026-04-17T20:00:00.000Z","sessionId":"abc-123"}"#,
r#"{"type":"user","message":{"role":"user","content":"please salvage item 3 — the session-salvage subcommand"},"timestamp":"2026-04-17T20:01:00.000Z","sessionId":"abc-123"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git commit -m 'wip'"}}]},"timestamp":"2026-04-17T20:02:00.000Z","sessionId":"abc-123"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"mcp__agent__reply","input":{"chat_id":"agent0","text":"done: SHA eb91d34"}}]},"timestamp":"2026-04-17T20:03:00.000Z","sessionId":"abc-123"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"netsky agent 11 --type codex --prompt briefs/x.md"}}]},"timestamp":"2026-04-17T20:04:00.000Z","sessionId":"abc-123"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"all done — wave landed"}]},"timestamp":"2026-04-17T20:05:00.000Z","sessionId":"abc-123"}"#,
]
.join("\n")
}
#[test]
fn extract_captures_all_buckets() {
let s = extract_from_lines(&sample_jsonl());
assert_eq!(s.session_id, "abc-123");
assert_eq!(s.first_ts, "2026-04-17T20:00:00.000Z");
assert_eq!(s.last_ts, "2026-04-17T20:05:00.000Z");
assert_eq!(s.directives.len(), 1, "got {:?}", s.directives);
assert!(s.directives[0].contains("salvage item 3"));
assert_eq!(s.commits.len(), 1);
assert!(s.commits[0].contains("git commit"));
assert_eq!(s.dispatches.len(), 2, "reply + netsky agent spawn");
assert!(!s.tail.is_empty());
}
#[test]
fn skips_command_scaffolding_and_tool_results() {
let s = extract_from_lines(&sample_jsonl());
assert!(
s.directives.iter().all(|d| !d.contains("command-message")),
"command scaffolding must be filtered"
);
}
#[test]
fn render_block_has_stable_shape() {
let s = extract_from_lines(&sample_jsonl());
let out = render_block(&s);
assert!(out.starts_with("## session salvage"));
assert!(out.contains("### directives"));
assert!(out.contains("### dispatches"));
assert!(out.contains("### commits"));
assert!(out.contains("### tail"));
assert!(out.contains("abc-123"));
}
#[test]
fn append_retro_creates_notes_path_and_header() {
let dir = tempdir().unwrap();
let path = dir.path().join("agent0.md");
let s = extract_from_lines(&sample_jsonl());
let stats = append_retro(&path, &s).unwrap();
assert!(stats.lines > 0);
let body = fs::read_to_string(&path).unwrap();
assert!(body.starts_with("# notes\n"), "seeds header on empty file");
assert!(body.contains("## session salvage"));
append_retro(&path, &s).unwrap();
let body2 = fs::read_to_string(&path).unwrap();
assert_eq!(
body2.matches("# notes\n").count(),
1,
"header seeded once, subsequent appends add below"
);
assert_eq!(body2.matches("## session salvage").count(), 2);
}
#[test]
fn notes_path_for_uses_first_ts() {
let p = notes_path_for("2026-04-17T19:14:05.123Z");
assert_eq!(p, PathBuf::from("notes/2026/04/17/agent0.md"), "got {p:?}");
}
#[test]
fn commit_detector_matches_real_shapes() {
assert!(is_commit_command("git commit -m 'x'"));
assert!(is_commit_command("git add -A && git commit -m 'x'"));
assert!(is_commit_command("git commit"));
assert!(!is_commit_command("git log -5"));
assert!(!is_commit_command("git commit --help"));
}
#[test]
fn truncate_respects_char_cap() {
let long = "a".repeat(500);
let out = truncate(&long, MAX_LINE_CHARS);
assert!(out.len() <= MAX_LINE_CHARS + 3, "len {}", out.len());
assert!(out.ends_with("..."));
}
#[test]
fn caps_buckets_at_max_per_bucket() {
let mut data = String::new();
for i in 0..(MAX_PER_BUCKET + 10) {
data.push_str(&format!(
r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","name":"Bash","input":{{"command":"git commit -m 'n{i}'"}}}}]}},"timestamp":"2026-04-17T20:00:0{}.000Z","sessionId":"x"}}"#,
i % 10
));
data.push('\n');
}
let s = extract_from_lines(&data);
assert_eq!(s.commits.len(), MAX_PER_BUCKET);
}
#[test]
fn latest_returns_none_for_empty_dir() {
let dir = tempdir().unwrap();
assert!(most_recent_jsonl(dir.path()).unwrap().is_none());
}
#[test]
fn latest_picks_most_recent_mtime() {
let dir = tempdir().unwrap();
let a = dir.path().join("a.jsonl");
let b = dir.path().join("b.jsonl");
fs::write(&a, "").unwrap();
std::thread::sleep(std::time::Duration::from_millis(30));
fs::write(&b, "").unwrap();
let latest = most_recent_jsonl(dir.path()).unwrap().unwrap();
assert_eq!(latest, b);
}
}