Skip to content

Commit d722d7a

Browse files
Shahinyanmclaude
andcommitted
feat(memory): proactive cross-project recall — opt-in injection hook (P2.2, 0.16.0)
Completes Pillar B (bd claude-memory-9z9). - tj-core::memory: FTS5 mirror on the global index + keyword_search — a fast, embedding-free lookup cheap enough to run on every prompt. - tj-cli `recall-hook`: opt-in UserPromptSubmit injector. Reads the prompt, keyword-searches the global index for relevant prior decisions/rejections/ constraints across all projects, and emits a budgeted additionalContext block — a guardrail before the agent re-decides or repeats a dead-end. Never blocks the prompt (silent on miss/error). Gated by TJ_PROACTIVE_RECALL=0; budget via TJ_RECALL_BUDGET_CHARS / TJ_RECALL_K; reuses the classifier recursion guard. - install-hooks --proactive-recall wires it into UserPromptSubmit alongside the nudge; OFF by default (no behaviour change unless opted in). is_tj uninstall filter updated so re-install stays idempotent. Tests: keyword_search match/no-match (core), recall-hook injects + is gated (CLI), install-hooks --proactive-recall wires it and default does not (CLI). Green on default and --no-default-features. Version 0.16.0; inter-crate version reqs bumped to resolve the release. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 81bc955 commit d722d7a

9 files changed

Lines changed: 371 additions & 8 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.16.0] - 2026-06-12
11+
12+
### Added
13+
- **Cross-project memory — Pillar B.** The journal now recalls relevant prior
14+
reasoning across your *entire* history, not just the current repo — something
15+
single-project memory tools can't do.
16+
- `task-journal recall "<query>"` — semantic search over **every** project's
17+
decisions, rejections and constraints. Surfaces prior choices and
18+
dead-ends from anywhere you've worked.
19+
- A global index (`data_dir/memory.sqlite`) mirrors high-signal events +
20+
embeddings from all projects; `ask`/`embed` keep it current automatically
21+
(best-effort, never failing the command). Contradicted (superseded)
22+
decisions are down-ranked.
23+
- **Opt-in proactive recall** (`install-hooks --proactive-recall`): a
24+
UserPromptSubmit hook that injects a budgeted block of relevant prior
25+
decisions/rejections/constraints **before you act** — a guardrail against
26+
re-deciding or repeating a dead-end. Off by default; uses a fast keyword
27+
path (no model load on the prompt path); gated by `TJ_PROACTIVE_RECALL=0`,
28+
budgeted by `TJ_RECALL_BUDGET_CHARS` / `TJ_RECALL_K`.
29+
30+
### Internal
31+
- `tj-core::memory` — global index schema (+ FTS5), `sync_from_project`,
32+
semantic `search`, fast `keyword_search`. `paths::memory_db()`. CLI
33+
`recall` / `recall-hook`; `install-hooks --proactive-recall` wiring.
34+
1035
## [0.15.0] - 2026-06-12
1136

1237
### Added

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.15.0"
10+
version = "0.16.0"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ default = ["embed"]
2323
embed = ["tj-core/embed"]
2424

2525
[dependencies]
26-
tj-core = { package = "task-journal-core", version = "0.15.0", path = "../tj-core", default-features = false }
26+
tj-core = { package = "task-journal-core", version = "0.16.0", path = "../tj-core", default-features = false }
2727
anyhow = { workspace = true }
2828
clap = { workspace = true }
2929
tracing = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,13 @@ enum Commands {
780780
/// the classifier, honoring `--backend`).
781781
#[arg(long)]
782782
auto_capture: bool,
783+
/// Opt in to proactive cross-project recall (Pillar B). Adds a
784+
/// UserPromptSubmit hook that injects relevant prior decisions/
785+
/// rejections/constraints from any project before you act. Off by
786+
/// default (it surfaces extra context on every prompt). Fast keyword
787+
/// path, no model; gated at runtime by TJ_PROACTIVE_RECALL=0.
788+
#[arg(long)]
789+
proactive_recall: bool,
783790
},
784791
/// Show local classifier and journal statistics.
785792
Stats,
@@ -930,6 +937,14 @@ enum Commands {
930937
/// default. Hidden from --help; not a human command.
931938
#[command(hide = true)]
932939
Nudge,
940+
/// Opt-in proactive recall hook (Pillar B). On UserPromptSubmit, injects a
941+
/// budgeted additionalContext block of prior decisions/rejections/
942+
/// constraints from ANY project relevant to the prompt — a guardrail
943+
/// against re-deciding or repeating a dead-end. Fast keyword path, no
944+
/// model. Wired only by `install-hooks --proactive-recall`. Gated by
945+
/// TJ_PROACTIVE_RECALL=0. Hidden from --help; not a human command.
946+
#[command(hide = true)]
947+
RecallHook,
933948
/// Cross-task search for `rejection` events matching a topic. Helpful
934949
/// when the agent is about to repeat a path that was already turned
935950
/// down — query the topic, see the prior rejection.
@@ -1529,6 +1544,7 @@ fn main() -> Result<()> {
15291544
backfill,
15301545
backend,
15311546
auto_capture,
1547+
proactive_recall,
15321548
} => {
15331549
let settings_path = match scope.as_str() {
15341550
"user" => {
@@ -1676,13 +1692,35 @@ fn main() -> Result<()> {
16761692
);
16771693
}
16781694
}
1695+
if proactive_recall {
1696+
// Append the recall injector to the UserPromptSubmit hooks,
1697+
// keeping whatever is already there (nudge, and ingest when
1698+
// --auto-capture is also set).
1699+
let obj = entries.as_object_mut().expect("entries is an object");
1700+
let ups = obj
1701+
.entry("UserPromptSubmit")
1702+
.or_insert_with(|| serde_json::json!([{ "matcher": "", "hooks": [] }]));
1703+
if let Some(hooks) = ups
1704+
.as_array_mut()
1705+
.and_then(|a| a.get_mut(0))
1706+
.and_then(|e| e.get_mut("hooks"))
1707+
.and_then(|h| h.as_array_mut())
1708+
{
1709+
hooks.push(serde_json::json!({
1710+
"type": "command",
1711+
"command": "task-journal recall-hook || true",
1712+
}));
1713+
}
1714+
}
16791715
// MERGE our entries into the existing `hooks` block — touch ONLY
16801716
// task-journal hooks, never clobber other plugins' hooks. For each
16811717
// event we (a) strip any prior task-journal entry (idempotent
16821718
// re-install) then (b) append ours, leaving foreign hooks and
16831719
// untouched events intact.
16841720
let is_tj = |c: &str| {
1685-
c.contains("task-journal ingest-hook") || c.contains("task-journal nudge")
1721+
c.contains("task-journal ingest-hook")
1722+
|| c.contains("task-journal nudge")
1723+
|| c.contains("task-journal recall-hook")
16861724
};
16871725
let hooks_block = hooks_obj
16881726
.entry("hooks".to_string())
@@ -3073,6 +3111,9 @@ fn main() -> Result<()> {
30733111
});
30743112
print!("{env}");
30753113
}
3114+
Commands::RecallHook => {
3115+
run_recall_hook()?;
3116+
}
30763117
Commands::Rejected {
30773118
topic,
30783119
all_projects,
@@ -3692,6 +3733,82 @@ fn sync_global_memory(project_conn: &rusqlite::Connection, project_hash: &str) {
36923733
}
36933734
}
36943735

3736+
/// Proactive recall injector (opt-in hook). Reads the UserPromptSubmit payload
3737+
/// from stdin, keyword-searches the global index for relevant prior
3738+
/// decisions/rejections/constraints across all projects, and emits a budgeted
3739+
/// `additionalContext` block. Never blocks the prompt: any miss, empty result,
3740+
/// or error exits silently with no output.
3741+
fn run_recall_hook() -> anyhow::Result<()> {
3742+
// Opt-out and recursion guard (never inject into our own classifier spawn).
3743+
if std::env::var("TJ_PROACTIVE_RECALL").as_deref() == Ok("0") {
3744+
return Ok(());
3745+
}
3746+
if std::env::var(tj_core::classifier::agent_sdk::IN_CLASSIFIER_ENV).is_ok() {
3747+
return Ok(());
3748+
}
3749+
let global_path = tj_core::paths::memory_db()?;
3750+
if !global_path.exists() {
3751+
return Ok(());
3752+
}
3753+
3754+
use std::io::Read;
3755+
let mut buf = String::new();
3756+
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.trim().is_empty() {
3757+
return Ok(());
3758+
}
3759+
// The UserPromptSubmit payload carries the prompt under `prompt`; fall back
3760+
// to the raw stdin if it isn't JSON.
3761+
let prompt = serde_json::from_str::<serde_json::Value>(&buf)
3762+
.ok()
3763+
.and_then(|v| {
3764+
v.get("prompt")
3765+
.and_then(|p| p.as_str())
3766+
.map(|s| s.to_string())
3767+
})
3768+
.unwrap_or(buf);
3769+
if prompt.trim().is_empty() {
3770+
return Ok(());
3771+
}
3772+
3773+
let conn = tj_core::memory::open(&global_path)?;
3774+
let k: usize = std::env::var("TJ_RECALL_K")
3775+
.ok()
3776+
.and_then(|s| s.parse().ok())
3777+
.unwrap_or(3);
3778+
let hits = tj_core::memory::keyword_search(&conn, &prompt, k)?;
3779+
if hits.is_empty() {
3780+
return Ok(());
3781+
}
3782+
3783+
let budget: usize = std::env::var("TJ_RECALL_BUDGET_CHARS")
3784+
.ok()
3785+
.and_then(|s| s.parse().ok())
3786+
.unwrap_or(900);
3787+
let mut ctx = String::from(
3788+
"📓 task-journal — relevant prior reasoning from your history (you may have decided this before):\n",
3789+
);
3790+
for h in &hits {
3791+
let snippet: String = h.text.chars().take(160).collect();
3792+
let proj: String = h.project_hash.chars().take(8).collect();
3793+
let line = format!(
3794+
"⚠ [{}] {} (project {proj}, {})\n",
3795+
h.event_type, snippet, h.task_id
3796+
);
3797+
if ctx.len() + line.len() > budget {
3798+
break;
3799+
}
3800+
ctx.push_str(&line);
3801+
}
3802+
let env = serde_json::json!({
3803+
"hookSpecificOutput": {
3804+
"hookEventName": "UserPromptSubmit",
3805+
"additionalContext": ctx.trim_end(),
3806+
}
3807+
});
3808+
print!("{env}");
3809+
Ok(())
3810+
}
3811+
36953812
fn auto_open_task_from_prompt(
36963813
events_path: &std::path::Path,
36973814
project_hash: &str,

crates/tj-cli/tests/cli.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5008,3 +5008,134 @@ fn recall_surfaces_decision_from_another_project() {
50085008
"cross-project recall must surface project A's refund decision first; got: {first:?}\nfull:\n{out}"
50095009
);
50105010
}
5011+
5012+
#[test]
5013+
fn install_hooks_proactive_recall_wires_recall_hook() {
5014+
// --proactive-recall adds the recall injector to UserPromptSubmit alongside
5015+
// the nudge; the default install must NOT wire it (off by default).
5016+
let dir = assert_fs::TempDir::new().unwrap();
5017+
Command::cargo_bin("task-journal")
5018+
.unwrap()
5019+
.env("HOME", dir.path())
5020+
.args(["install-hooks", "--scope", "user", "--proactive-recall"])
5021+
.assert()
5022+
.success();
5023+
let content =
5024+
std::fs::read_to_string(dir.path().join(".claude").join("settings.json")).unwrap();
5025+
assert!(
5026+
content.contains("task-journal recall-hook"),
5027+
"--proactive-recall must wire the recall-hook; got: {content}"
5028+
);
5029+
assert!(
5030+
content.contains("task-journal nudge"),
5031+
"nudge must remain alongside recall-hook"
5032+
);
5033+
5034+
let dir2 = assert_fs::TempDir::new().unwrap();
5035+
Command::cargo_bin("task-journal")
5036+
.unwrap()
5037+
.env("HOME", dir2.path())
5038+
.args(["install-hooks", "--scope", "user"])
5039+
.assert()
5040+
.success();
5041+
let c2 = std::fs::read_to_string(dir2.path().join(".claude").join("settings.json")).unwrap();
5042+
assert!(
5043+
!c2.contains("recall-hook"),
5044+
"default install must not wire proactive recall"
5045+
);
5046+
}
5047+
5048+
#[test]
5049+
fn recall_hook_injects_relevant_prior_reasoning() {
5050+
// Pillar B proactive injection: a decision recorded in a project must be
5051+
// surfaced as additionalContext when a later prompt (anywhere) shares its
5052+
// terms. Gated by TJ_PROACTIVE_RECALL=0.
5053+
let xdg = assert_fs::TempDir::new().unwrap();
5054+
let proj = assert_fs::TempDir::new().unwrap();
5055+
5056+
let tid = String::from_utf8(
5057+
Command::cargo_bin("task-journal")
5058+
.unwrap()
5059+
.current_dir(proj.path())
5060+
.env("XDG_DATA_HOME", xdg.path())
5061+
.args(["create", "Payments"])
5062+
.assert()
5063+
.success()
5064+
.get_output()
5065+
.stdout
5066+
.clone(),
5067+
)
5068+
.unwrap()
5069+
.trim()
5070+
.to_string();
5071+
Command::cargo_bin("task-journal")
5072+
.unwrap()
5073+
.current_dir(proj.path())
5074+
.env("XDG_DATA_HOME", xdg.path())
5075+
.args([
5076+
"event",
5077+
&tid,
5078+
"--type",
5079+
"decision",
5080+
"--text",
5081+
"chose the idempotent payment ledger for refunds",
5082+
])
5083+
.assert()
5084+
.success();
5085+
Command::cargo_bin("task-journal")
5086+
.unwrap()
5087+
.current_dir(proj.path())
5088+
.env("XDG_DATA_HOME", xdg.path())
5089+
.env("TJ_EMBED", "hash")
5090+
.args(["embed", "--backfill"])
5091+
.assert()
5092+
.success();
5093+
5094+
let payload = serde_json::json!({
5095+
"hook_event_name": "UserPromptSubmit",
5096+
"prompt": "should we add a refund ledger to billing?"
5097+
})
5098+
.to_string();
5099+
5100+
let body = String::from_utf8(
5101+
Command::cargo_bin("task-journal")
5102+
.unwrap()
5103+
.env("XDG_DATA_HOME", xdg.path())
5104+
.args(["recall-hook"])
5105+
.write_stdin(payload.clone())
5106+
.assert()
5107+
.success()
5108+
.get_output()
5109+
.stdout
5110+
.clone(),
5111+
)
5112+
.unwrap();
5113+
assert!(
5114+
body.contains("additionalContext"),
5115+
"recall-hook must emit additionalContext; got: {body:?}"
5116+
);
5117+
assert!(
5118+
body.contains("ledger"),
5119+
"must surface the ledger decision; got: {body}"
5120+
);
5121+
5122+
// Gate: TJ_PROACTIVE_RECALL=0 suppresses all output.
5123+
let gated = String::from_utf8(
5124+
Command::cargo_bin("task-journal")
5125+
.unwrap()
5126+
.env("XDG_DATA_HOME", xdg.path())
5127+
.env("TJ_PROACTIVE_RECALL", "0")
5128+
.args(["recall-hook"])
5129+
.write_stdin(payload)
5130+
.assert()
5131+
.success()
5132+
.get_output()
5133+
.stdout
5134+
.clone(),
5135+
)
5136+
.unwrap();
5137+
assert!(
5138+
gated.trim().is_empty(),
5139+
"TJ_PROACTIVE_RECALL=0 must suppress injection; got: {gated:?}"
5140+
);
5141+
}

0 commit comments

Comments
 (0)