diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d5374..ddc68d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.0] - 2026-06-14 + +### Added +- **`/clear` no longer drops the last segment.** A new `SessionEnd` hook (wired + by `install-hooks --auto-capture`) runs the same transcript catch-up as `Stop` + when the session ends with reason `clear` — the last chance to capture the + final segment before `/clear` orphans the transcript. Gated to `clear` so it + doesn't re-process what `Stop` already handled on other exits. +- **`recall --json`** — `task-journal recall "" --json` emits the + cross-project memory hits as a JSON array (task_id, project_hash, event_type, + text, score) for machine consumers like the Loom host; empty/missing memory + yields `[]`. + ## [0.25.1] - 2026-06-14 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index bba8867..9f1a6ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,7 +2572,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.25.1" +version = "0.26.0" dependencies = [ "anyhow", "assert_cmd", @@ -2596,7 +2596,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.25.1" +version = "0.26.0" dependencies = [ "anyhow", "chrono", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.25.1" +version = "0.26.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 58b3319..4a7e783 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.25.1" +version = "0.26.0" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index 37a3129..3e01243 100644 --- a/crates/tj-cli/Cargo.toml +++ b/crates/tj-cli/Cargo.toml @@ -23,7 +23,7 @@ default = ["embed"] embed = ["tj-core/embed"] [dependencies] -tj-core = { package = "task-journal-core", version = "0.25.1", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.26.0", path = "../tj-core", default-features = false } anyhow = { workspace = true } clap = { workspace = true } tracing = { workspace = true } diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 992bd4a..3c5a0ca 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1809,7 +1809,7 @@ fn main() -> Result<()> { { "type": "command", "command": cmd }, ]}]), ); - for ev in ["PostToolUse", "Stop", "PreCompact"] { + for ev in ["PostToolUse", "Stop", "PreCompact", "SessionEnd"] { obj.insert( ev.to_string(), serde_json::json!([{ "matcher": "", "hooks": [{ "type": "command", "command": cmd }] }]), @@ -2493,6 +2493,21 @@ runs in the background and won't block you; it only fills gaps and never closes return Ok(()); } + // SessionEnd with reason "clear": /clear discards the conversation + // and the transcript orphans, so this is the LAST chance to capture + // the final segment. Extracted to its own function so its locals do + // NOT bloat `main`'s already-huge stack frame — inlining it here + // overflowed the 1 MiB Windows main-thread stack on every command. + if kind == "SessionEnd" { + return run_session_end_catchup( + &payload, + &events_path, + &project_hash, + &backend, + live_session_id.as_deref(), + ); + } + // Drain any pending entries first (Task 10 fills the real-classifier branch). drain_pending( &events_path, @@ -3924,6 +3939,58 @@ fn run_nudge() -> anyhow::Result<()> { } /// Proactive recall injector (opt-in hook). Reads the UserPromptSubmit payload +/// SessionEnd(reason=clear) catch-up: enqueue transcript chunks newer than the +/// active task's last event, then spawn the classify-worker. Kept OUT of `main` +/// so its locals don't grow `main`'s already-huge stack frame — inlining it +/// overflowed the 1 MiB Windows main-thread stack on every command. +fn run_session_end_catchup( + payload: &serde_json::Value, + events_path: &std::path::Path, + project_hash: &str, + backend: &str, + live_session_id: Option<&str>, +) -> anyhow::Result<()> { + let reason = payload.get("reason").and_then(|x| x.as_str()).unwrap_or(""); + if reason != "clear" || !events_path.exists() { + return Ok(()); + } + let state_path = tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); + let conn = tj_core::db::open(&state_path)?; + tj_core::db::ingest_new_events(&conn, events_path, project_hash)?; + let Some(tc) = recent_task_contexts(&conn, 1)?.into_iter().next() else { + return Ok(()); + }; + let last_event_ts: Option = conn + .query_row( + "SELECT timestamp FROM events_index WHERE task_id=?1 ORDER BY timestamp DESC LIMIT 1", + rusqlite::params![&tc.task_id], + |r| r.get::<_, String>(0), + ) + .ok(); + let transcript_path = payload + .get("transcript_path") + .and_then(|x| x.as_str()) + .map(std::path::PathBuf::from); + if let Some(tp) = transcript_path.as_ref() { + if tp.exists() { + let enq = enqueue_transcript_chunks_since_last_event( + tp, + events_path, + project_hash, + backend, + last_event_ts.as_deref(), + "SessionEndChunk", + live_session_id, + ) + .unwrap_or(0); + if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() { + let _ = spawn_classify_worker(backend); + } + } + } + Ok(()) +} + /// from stdin, keyword-searches the global index for relevant prior /// decisions/rejections/constraints across all projects, and emits a budgeted /// `additionalContext` block. Never blocks the prompt: any miss, empty result, diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 564a5ed..174e0b8 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -884,11 +884,38 @@ fn install_hooks_auto_capture_wires_all_events() { "PostToolUse", "Stop", "PreCompact", + "SessionEnd", ] { assert!(content.contains(ev), "--auto-capture must wire {ev}"); } } +#[test] +fn session_end_hook_is_clean_noop_without_journal() { + // SessionEnd(clear) with no journal yet must exit cleanly (it's the + // last-chance catch-up; nothing to catch when there's no project journal). + let dir = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + for reason in ["clear", "logout"] { + let payload = serde_json::json!({ + "hook_event_name": "SessionEnd", + "reason": reason, + "session_id": "s-end", + "transcript_path": "/nonexistent/x.jsonl", + "cwd": proj.path().to_string_lossy(), + }) + .to_string(); + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["ingest-hook", "--backend", "hybrid"]) + .write_stdin(payload) + .assert() + .success(); + } +} + #[test] fn install_hooks_merges_and_preserves_third_party_hooks() { let dir = assert_fs::TempDir::new().unwrap(); diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index 68f928a..892f2f0 100644 --- a/crates/tj-mcp/Cargo.toml +++ b/crates/tj-mcp/Cargo.toml @@ -17,7 +17,7 @@ path = "src/main.rs" [dependencies] # Lean: the MCP server doesn't embed yet, so it skips the model2vec backend. -tj-core = { package = "task-journal-core", version = "0.25.1", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.26.0", path = "../tj-core", default-features = false } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 286f3de..28edd76 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.25.1", + "version": "0.26.0", "description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.", "author": { "name": "Mher Shahinyan"