From 7aa3b1f01935fffcf74374c0eba8e7524f62cd1a Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 14 Jun 2026 11:16:16 +0400 Subject: [PATCH 1/2] feat(hook): SessionEnd(clear) catch-up so /clear keeps the last segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /clear discards the conversation and orphans the transcript, so the final segment can be lost. Add a SessionEnd handler (wired by install-hooks --auto-capture) that runs the same transcript catch-up as Stop when reason==clear — the last chance to capture before the transcript orphans. Gated to clear so it doesn't re-process what Stop already handled on other exits. Also bundles the recall --json release (#45, for the Loom host). claude-memory-mgc Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 ++++++++ Cargo.lock | 6 ++-- Cargo.toml | 2 +- crates/tj-cli/Cargo.toml | 2 +- crates/tj-cli/src/main.rs | 53 ++++++++++++++++++++++++++++++- crates/tj-cli/tests/cli.rs | 27 ++++++++++++++++ crates/tj-mcp/Cargo.toml | 2 +- plugin/.claude-plugin/plugin.json | 2 +- 8 files changed, 99 insertions(+), 8 deletions(-) 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..e1ea112 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,57 @@ 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. Same catch-up as Stop (enqueue chunks newer + // than the active task's last event), gated to `clear` — other end + // reasons (logout/exit) leave the transcript on disk for the next + // session to handle, and Stop already fired at the prior turn, so + // running here too would just risk re-enqueuing the same range. + if kind == "SessionEnd" { + 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.as_deref(), + ) + .unwrap_or(0); + if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() { + let _ = spawn_classify_worker(&backend); + } + } + } + return Ok(()); + } + // Drain any pending entries first (Task 10 fills the real-classifier branch). drain_pending( &events_path, 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" From c472d582f2f58ec59b170aacff8d99b6a5e21060 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 14 Jun 2026 11:30:01 +0400 Subject: [PATCH 2/2] fix(hook): extract SessionEnd catch-up out of main (Windows stack overflow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline SessionEnd block pushed main()'s already-huge stack frame past Windows' 1MiB main-thread stack — the binary crashed at startup (STATUS_STACK_OVERFLOW, code -1073741571) on EVERY command, failing all Windows CI. Move the logic into run_session_end_catchup() so its locals get their own frame and main's shrinks back under the limit. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tj-cli/src/main.rs | 108 ++++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 46 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index e1ea112..3c5a0ca 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -2495,53 +2495,17 @@ runs in the background and won't block you; it only fills gaps and never closes // SessionEnd with reason "clear": /clear discards the conversation // and the transcript orphans, so this is the LAST chance to capture - // the final segment. Same catch-up as Stop (enqueue chunks newer - // than the active task's last event), gated to `clear` — other end - // reasons (logout/exit) leave the transcript on disk for the next - // session to handle, and Stop already fired at the prior turn, so - // running here too would just risk re-enqueuing the same range. + // 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" { - 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.as_deref(), - ) - .unwrap_or(0); - if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() { - let _ = spawn_classify_worker(&backend); - } - } - } - return Ok(()); + 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). @@ -3975,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,