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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<context>" --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
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.25.1"
version = "0.26.0"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion crates/tj-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
69 changes: 68 additions & 1 deletion crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }] }]),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> = 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,
Expand Down
27 changes: 27 additions & 0 deletions crates/tj-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/tj-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading