Skip to content

Commit 81aca5e

Browse files
Shahinyanmclaude
andauthored
feat: ask --json + clean resume packs (0.26.2) (#50)
* feat(cli): ask --json for tooling / Loom host (additive) Add --json to `ask` (semantic search, current project), mirroring recall --json: emits a JSON array [{task_id, project_hash, event_type, text, score}]. Default (no flag) behaviour unchanged. 119 cli tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pack): drop compaction noise + dedupe so the dossier reads clean The pack's Active decisions / Rejected / Recent events sections were polluted with machine-generated markers (Claude Code's 'This session is being continued…' / 'Conversation compacted at…') that the classifier filed as decisions, plus exact duplicates — so a task's reasoning read as repeated essays. Add a conservative is_noise() filter for those known prefixes and de-duplicate by text within each section. Additive: same pack structure, just cleaner content; existing behavior for noise-free journals is unchanged. 291 tests green (+ golden packs). * fix(pack): rustfmt + update precompact test for clean-pack behavior; release 0.26.2 - cargo fmt on pack.rs noise tests (CI fmt gate was red) - precompact_hook_appends_marker_decision_to_open_task now asserts the marker is recorded in the append-only journal but filtered OUT of the rendered pack (the new is_noise behavior), instead of expecting it in the pack - bump workspace to 0.26.2 + CHANGELOG (ask --json, clean pack) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cf1a068 commit 81aca5e

6 files changed

Lines changed: 117 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.26.2] - 2026-06-16
11+
12+
### Added
13+
- **`ask --json`**`task-journal ask "<query>" --json` emits the semantic
14+
search hits as a JSON array (task_id, project_hash, event_type, text, score)
15+
for machine consumers like the Loom host; no matches yields `[]`. Mirrors the
16+
existing `recall --json`.
17+
18+
### Changed
19+
- **Resume packs read clean.** Auto-recorded compaction / session-continuation
20+
markers (e.g. "This session is being continued…", "Conversation compacted
21+
at…") are machine noise the classifier sometimes files as decisions. The pack
22+
now drops them from the recent-events, rejected and active-decisions sections
23+
and de-duplicates exact repeats, so the dossier reads as crisp reasoning. The
24+
append-only journal still records every marker — only the rendered pack hides
25+
them.
26+
1027
## [0.26.1] - 2026-06-14
1128

1229
### Fixed

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.26.1"
10+
version = "0.26.2"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/src/main.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,9 @@ enum Commands {
631631
/// Maximum number of results.
632632
#[arg(long, default_value_t = 5)]
633633
k: usize,
634+
/// Emit a JSON array instead of human lines (for tooling / the Loom host).
635+
#[arg(long)]
636+
json: bool,
634637
},
635638
/// Cross-project recall (Pillar B): search EVERY project's decisions,
636639
/// rejections and constraints for reasoning relevant to the query —
@@ -1277,7 +1280,7 @@ fn real_main() -> Result<()> {
12771280
embedder.dim()
12781281
);
12791282
}
1280-
Commands::Ask { query, k } => {
1283+
Commands::Ask { query, k, json } => {
12811284
let cwd = std::env::current_dir()?;
12821285
let project_hash = tj_core::project_hash::from_path(&cwd)?;
12831286
let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
@@ -1298,7 +1301,21 @@ fn real_main() -> Result<()> {
12981301
let qv = embedder.embed_one(&query)?;
12991302
let hits =
13001303
tj_core::db::semantic_search(&conn, &project_hash, &qv, embedder.model_id(), k)?;
1301-
if hits.is_empty() {
1304+
if json {
1305+
let arr: Vec<serde_json::Value> = hits
1306+
.iter()
1307+
.map(|h| {
1308+
serde_json::json!({
1309+
"task_id": h.task_id,
1310+
"project_hash": project_hash,
1311+
"event_type": h.event_type,
1312+
"text": h.text,
1313+
"score": h.score,
1314+
})
1315+
})
1316+
.collect();
1317+
println!("{}", serde_json::to_string(&arr)?);
1318+
} else if hits.is_empty() {
13021319
println!("no matches");
13031320
} else {
13041321
for h in hits {

crates/tj-cli/tests/cli.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2660,6 +2660,32 @@ fn precompact_hook_appends_marker_decision_to_open_task() {
26602660
.assert()
26612661
.success();
26622662

2663+
// The hook still appends the marker decision to the append-only journal…
2664+
let events_glob = dir.path().join("task-journal").join("events");
2665+
let mut marker_lines = 0;
2666+
for entry in std::fs::read_dir(&events_glob).unwrap() {
2667+
let p = entry.unwrap().path();
2668+
if p.extension().and_then(|e| e.to_str()) == Some("jsonl") {
2669+
let body = std::fs::read_to_string(&p).unwrap();
2670+
for line in body.lines() {
2671+
let v: serde_json::Value = serde_json::from_str(line).unwrap();
2672+
if v["type"] == "decision"
2673+
&& v["text"]
2674+
.as_str()
2675+
.unwrap_or("")
2676+
.contains("Conversation compacted at")
2677+
{
2678+
marker_lines += 1;
2679+
}
2680+
}
2681+
}
2682+
}
2683+
assert_eq!(
2684+
marker_lines, 1,
2685+
"marker decision must be recorded in the journal"
2686+
);
2687+
2688+
// …but the pack filters it out as machine noise so the dossier reads clean.
26632689
Command::cargo_bin("task-journal")
26642690
.unwrap()
26652691
.env("XDG_DATA_HOME", dir.path())
@@ -2668,9 +2694,9 @@ fn precompact_hook_appends_marker_decision_to_open_task() {
26682694
.assert()
26692695
.success()
26702696
.stdout(
2671-
contains("[decision]")
2672-
.and(contains("Conversation compacted at"))
2673-
.and(contains("single reasoning unit")),
2697+
contains("Conversation compacted at")
2698+
.not()
2699+
.and(contains("single reasoning unit").not()),
26742700
);
26752701
}
26762702

crates/tj-core/src/pack.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,20 @@ pub struct PackMetadata {
2727

2828
use anyhow::Context;
2929
use rusqlite::Connection;
30+
use std::collections::HashSet;
31+
32+
/// Auto-recorded compaction / session-continuation markers are not real
33+
/// reasoning — Claude Code injects them when a conversation is compacted or
34+
/// resumed, and the classifier sometimes files them as decisions. Drop them
35+
/// from the pack so the reasoning reads clean. Conservative: only well-known
36+
/// machine-generated prefixes.
37+
pub(crate) fn is_noise(text: &str) -> bool {
38+
let t = text.trim_start();
39+
t.starts_with("This session is being continued from a previous conversation")
40+
|| t.starts_with("Conversation compacted at")
41+
|| t.starts_with("[Conversation compacted")
42+
|| t.starts_with("Caveat: The messages below were generated by the user")
43+
}
3044

3145
fn render_recent_events(conn: &Connection, task_id: &str, limit: usize) -> anyhow::Result<String> {
3246
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
4458
})?;
4559
for row in rows {
4660
let (ts, ty, st, txt) = row?;
61+
if is_noise(&txt) {
62+
continue;
63+
}
4764
let one_line = txt
4865
.lines()
4966
.next()
@@ -97,8 +114,12 @@ fn render_rejected(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
97114
.query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))?
98115
.collect::<Result<_, _>>()?;
99116
let mut count = 0;
117+
let mut seen: HashSet<String> = HashSet::new();
100118
for eid in event_ids {
101119
let text: String = text_stmt.query_row(rusqlite::params![eid], |r| r.get(0))?;
120+
if is_noise(&text) || !seen.insert(text.trim().to_string()) {
121+
continue;
122+
}
102123
out.push_str(&format!("- {text}\n"));
103124
count += 1;
104125
}
@@ -122,8 +143,14 @@ fn render_active_decisions(conn: &Connection, task_id: &str) -> anyhow::Result<S
122143
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
123144
})?;
124145
let mut count = 0;
146+
let mut seen: HashSet<String> = HashSet::new();
125147
for row in rows {
126148
let (text, alternatives) = row?;
149+
// Skip machine noise (compaction markers) and exact duplicates so the
150+
// section reads as crisp decisions, not repeated essays.
151+
if is_noise(&text) || !seen.insert(text.trim().to_string()) {
152+
continue;
153+
}
127154
out.push_str(&format!("- {text}\n"));
128155
// v0.12.0: structured alternatives render under the decision so the
129156
// pack shows "considered A/B/C, chose X" without reconstructing it
@@ -451,6 +478,27 @@ mod tests {
451478
assert_eq!(s, "\"Compact\"");
452479
}
453480

481+
#[test]
482+
fn is_noise_flags_machine_markers_not_real_reasoning() {
483+
assert!(is_noise(
484+
"This session is being continued from a previous conversation that ran out of context."
485+
));
486+
assert!(is_noise(
487+
"Conversation compacted at 2026-06-07T11:47:33Z; preceding events…"
488+
));
489+
assert!(is_noise(" [Conversation compacted]"));
490+
assert!(is_noise(
491+
"Caveat: The messages below were generated by the user while running local commands."
492+
));
493+
// real reasoning is kept
494+
assert!(!is_noise(
495+
"Use axum for the server because it has better middleware."
496+
));
497+
assert!(!is_noise(
498+
"Rejected: per-stage sessions lose accumulated context."
499+
));
500+
}
501+
454502
#[test]
455503
fn parent_pack_contains_subtasks_section() {
456504
let d = tempfile::TempDir::new().unwrap();

0 commit comments

Comments
 (0)