@@ -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+
36953812fn auto_open_task_from_prompt (
36963813 events_path : & std:: path:: Path ,
36973814 project_hash : & str ,
0 commit comments