Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<query>" --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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.26.1"
version = "0.26.2"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
21 changes: 19 additions & 2 deletions crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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"));
Expand All @@ -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<serde_json::Value> = 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 {
Expand Down
32 changes: 29 additions & 3 deletions crates/tj-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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()),
);
}

Expand Down
48 changes: 48 additions & 0 deletions crates/tj-core/src/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let mut out = format!("## Recent events (last {limit})\n");
Expand All @@ -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()
Expand Down Expand Up @@ -97,8 +114,12 @@ fn render_rejected(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
.query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))?
.collect::<Result<_, _>>()?;
let mut count = 0;
let mut seen: HashSet<String> = 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;
}
Expand All @@ -122,8 +143,14 @@ fn render_active_decisions(conn: &Connection, task_id: &str) -> anyhow::Result<S
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
})?;
let mut count = 0;
let mut seen: HashSet<String> = 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
Expand Down Expand Up @@ -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();
Expand Down
Loading