diff --git a/CHANGELOG.md b/CHANGELOG.md index ea20a56..0272312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.2] - 2026-06-16 + +### Added +- **`ask --json`** — `task-journal ask "" --json` emits the semantic + search hits as a JSON array (task_id, project_hash, event_type, text, score) + for machine consumers like the Loom host; no matches yields `[]`. Mirrors the + existing `recall --json`. + +### Changed +- **Resume packs read clean.** Auto-recorded compaction / session-continuation + markers (e.g. "This session is being continued…", "Conversation compacted + at…") are machine noise the classifier sometimes files as decisions. The pack + now drops them from the recent-events, rejected and active-decisions sections + and de-duplicates exact repeats, so the dossier reads as crisp reasoning. The + append-only journal still records every marker — only the rendered pack hides + them. + ## [0.26.1] - 2026-06-14 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 92c22ed..c695268 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,7 +2572,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.26.1" +version = "0.26.2" dependencies = [ "anyhow", "assert_cmd", @@ -2596,7 +2596,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.26.1" +version = "0.26.2" dependencies = [ "anyhow", "chrono", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.26.1" +version = "0.26.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 25733a8..215a8f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.26.1" +version = "0.26.2" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 3b92702..ffc93d1 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -631,6 +631,9 @@ enum Commands { /// Maximum number of results. #[arg(long, default_value_t = 5)] k: usize, + /// Emit a JSON array instead of human lines (for tooling / the Loom host). + #[arg(long)] + json: bool, }, /// Cross-project recall (Pillar B): search EVERY project's decisions, /// rejections and constraints for reasoning relevant to the query — @@ -1277,7 +1280,7 @@ fn real_main() -> Result<()> { embedder.dim() ); } - Commands::Ask { query, k } => { + Commands::Ask { query, k, json } => { let cwd = std::env::current_dir()?; let project_hash = tj_core::project_hash::from_path(&cwd)?; let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); @@ -1298,7 +1301,21 @@ fn real_main() -> Result<()> { let qv = embedder.embed_one(&query)?; let hits = tj_core::db::semantic_search(&conn, &project_hash, &qv, embedder.model_id(), k)?; - if hits.is_empty() { + if json { + let arr: Vec = hits + .iter() + .map(|h| { + serde_json::json!({ + "task_id": h.task_id, + "project_hash": project_hash, + "event_type": h.event_type, + "text": h.text, + "score": h.score, + }) + }) + .collect(); + println!("{}", serde_json::to_string(&arr)?); + } else if hits.is_empty() { println!("no matches"); } else { for h in hits { diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 174e0b8..341fbab 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -2660,6 +2660,32 @@ fn precompact_hook_appends_marker_decision_to_open_task() { .assert() .success(); + // The hook still appends the marker decision to the append-only journal… + let events_glob = dir.path().join("task-journal").join("events"); + let mut marker_lines = 0; + for entry in std::fs::read_dir(&events_glob).unwrap() { + let p = entry.unwrap().path(); + if p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + let body = std::fs::read_to_string(&p).unwrap(); + for line in body.lines() { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + if v["type"] == "decision" + && v["text"] + .as_str() + .unwrap_or("") + .contains("Conversation compacted at") + { + marker_lines += 1; + } + } + } + } + assert_eq!( + marker_lines, 1, + "marker decision must be recorded in the journal" + ); + + // …but the pack filters it out as machine noise so the dossier reads clean. Command::cargo_bin("task-journal") .unwrap() .env("XDG_DATA_HOME", dir.path()) @@ -2668,9 +2694,9 @@ fn precompact_hook_appends_marker_decision_to_open_task() { .assert() .success() .stdout( - contains("[decision]") - .and(contains("Conversation compacted at")) - .and(contains("single reasoning unit")), + contains("Conversation compacted at") + .not() + .and(contains("single reasoning unit").not()), ); } diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index 19d524c..5b67d78 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -27,6 +27,20 @@ pub struct PackMetadata { use anyhow::Context; use rusqlite::Connection; +use std::collections::HashSet; + +/// Auto-recorded compaction / session-continuation markers are not real +/// reasoning — Claude Code injects them when a conversation is compacted or +/// resumed, and the classifier sometimes files them as decisions. Drop them +/// from the pack so the reasoning reads clean. Conservative: only well-known +/// machine-generated prefixes. +pub(crate) fn is_noise(text: &str) -> bool { + let t = text.trim_start(); + t.starts_with("This session is being continued from a previous conversation") + || t.starts_with("Conversation compacted at") + || t.starts_with("[Conversation compacted") + || t.starts_with("Caveat: The messages below were generated by the user") +} fn render_recent_events(conn: &Connection, task_id: &str, limit: usize) -> anyhow::Result { let mut out = format!("## Recent events (last {limit})\n"); @@ -44,6 +58,9 @@ fn render_recent_events(conn: &Connection, task_id: &str, limit: usize) -> anyho })?; for row in rows { let (ts, ty, st, txt) = row?; + if is_noise(&txt) { + continue; + } let one_line = txt .lines() .next() @@ -97,8 +114,12 @@ fn render_rejected(conn: &Connection, task_id: &str) -> anyhow::Result { .query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))? .collect::>()?; let mut count = 0; + let mut seen: HashSet = HashSet::new(); for eid in event_ids { let text: String = text_stmt.query_row(rusqlite::params![eid], |r| r.get(0))?; + if is_noise(&text) || !seen.insert(text.trim().to_string()) { + continue; + } out.push_str(&format!("- {text}\n")); count += 1; } @@ -122,8 +143,14 @@ fn render_active_decisions(conn: &Connection, task_id: &str) -> anyhow::Result(0)?, r.get::<_, Option>(1)?)) })?; let mut count = 0; + let mut seen: HashSet = HashSet::new(); for row in rows { let (text, alternatives) = row?; + // Skip machine noise (compaction markers) and exact duplicates so the + // section reads as crisp decisions, not repeated essays. + if is_noise(&text) || !seen.insert(text.trim().to_string()) { + continue; + } out.push_str(&format!("- {text}\n")); // v0.12.0: structured alternatives render under the decision so the // pack shows "considered A/B/C, chose X" without reconstructing it @@ -451,6 +478,27 @@ mod tests { assert_eq!(s, "\"Compact\""); } + #[test] + fn is_noise_flags_machine_markers_not_real_reasoning() { + assert!(is_noise( + "This session is being continued from a previous conversation that ran out of context." + )); + assert!(is_noise( + "Conversation compacted at 2026-06-07T11:47:33Z; preceding events…" + )); + assert!(is_noise(" [Conversation compacted]")); + assert!(is_noise( + "Caveat: The messages below were generated by the user while running local commands." + )); + // real reasoning is kept + assert!(!is_noise( + "Use axum for the server because it has better middleware." + )); + assert!(!is_noise( + "Rejected: per-stage sessions lose accumulated context." + )); + } + #[test] fn parent_pack_contains_subtasks_section() { let d = tempfile::TempDir::new().unwrap();