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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.16.0] - 2026-06-12

### Added
- **Cross-project memory — Pillar B.** The journal now recalls relevant prior
reasoning across your *entire* history, not just the current repo — something
single-project memory tools can't do.
- `task-journal recall "<query>"` — semantic search over **every** project's
decisions, rejections and constraints. Surfaces prior choices and
dead-ends from anywhere you've worked.
- A global index (`data_dir/memory.sqlite`) mirrors high-signal events +
embeddings from all projects; `ask`/`embed` keep it current automatically
(best-effort, never failing the command). Contradicted (superseded)
decisions are down-ranked.
- **Opt-in proactive recall** (`install-hooks --proactive-recall`): a
UserPromptSubmit hook that injects a budgeted block of relevant prior
decisions/rejections/constraints **before you act** — a guardrail against
re-deciding or repeating a dead-end. Off by default; uses a fast keyword
path (no model load on the prompt path); gated by `TJ_PROACTIVE_RECALL=0`,
budgeted by `TJ_RECALL_BUDGET_CHARS` / `TJ_RECALL_K`.

### Internal
- `tj-core::memory` — global index schema (+ FTS5), `sync_from_project`,
semantic `search`, fast `keyword_search`. `paths::memory_db()`. CLI
`recall` / `recall-hook`; `install-hooks --proactive-recall` wiring.

## [0.15.0] - 2026-06-12

### Added
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.15.0"
version = "0.16.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.15.0", path = "../tj-core", default-features = false }
tj-core = { package = "task-journal-core", version = "0.16.0", path = "../tj-core", default-features = false }
anyhow = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
Expand Down
166 changes: 165 additions & 1 deletion crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,16 @@ enum Commands {
#[arg(long, default_value_t = 5)]
k: usize,
},
/// Cross-project recall (Pillar B): search EVERY project's decisions,
/// rejections and constraints for reasoning relevant to the query —
/// prior choices and dead-ends from your whole history, not just this repo.
Recall {
/// The topic / approach to check against prior reasoning.
query: String,
/// Maximum number of results.
#[arg(long, default_value_t = 5)]
k: usize,
},
/// Render and print the resume pack for a task.
Pack {
/// Task id (e.g. tj-7f3a).
Expand Down Expand Up @@ -770,6 +780,13 @@ enum Commands {
/// the classifier, honoring `--backend`).
#[arg(long)]
auto_capture: bool,
/// Opt in to proactive cross-project recall (Pillar B). Adds a
/// UserPromptSubmit hook that injects relevant prior decisions/
/// rejections/constraints from any project before you act. Off by
/// default (it surfaces extra context on every prompt). Fast keyword
/// path, no model; gated at runtime by TJ_PROACTIVE_RECALL=0.
#[arg(long)]
proactive_recall: bool,
},
/// Show local classifier and journal statistics.
Stats,
Expand Down Expand Up @@ -920,6 +937,14 @@ enum Commands {
/// default. Hidden from --help; not a human command.
#[command(hide = true)]
Nudge,
/// Opt-in proactive recall hook (Pillar B). On UserPromptSubmit, injects a
/// budgeted additionalContext block of prior decisions/rejections/
/// constraints from ANY project relevant to the prompt — a guardrail
/// against re-deciding or repeating a dead-end. Fast keyword path, no
/// model. Wired only by `install-hooks --proactive-recall`. Gated by
/// TJ_PROACTIVE_RECALL=0. Hidden from --help; not a human command.
#[command(hide = true)]
RecallHook,
/// Cross-task search for `rejection` events matching a topic. Helpful
/// when the agent is about to repeat a path that was already turned
/// down — query the topic, see the prior rejection.
Expand Down Expand Up @@ -1162,6 +1187,7 @@ fn main() -> Result<()> {
break;
}
}
sync_global_memory(&conn, &project_hash);
println!(
"embedded {total} event(s) with model {} ({} dim)",
embedder.model_id(),
Expand All @@ -1184,6 +1210,7 @@ fn main() -> Result<()> {
// latest events without the user running `embed` first.
let now = chrono::Utc::now().to_rfc3339();
tj_core::db::embed_pending(&conn, &project_hash, embedder.as_ref(), &now, 512)?;
sync_global_memory(&conn, &project_hash);

let qv = embedder.embed_one(&query)?;
let hits =
Expand All @@ -1200,6 +1227,29 @@ fn main() -> Result<()> {
}
}
}
Commands::Recall { query, k } => {
let global_path = tj_core::paths::memory_db()?;
if !global_path.exists() {
println!("global memory is empty — run `ask` or `embed` in a project first");
return Ok(());
}
let global = tj_core::memory::open(&global_path)?;
let embedder = tj_core::embed::default_embedder();
let qv = embedder.embed_one(&query)?;
let hits = tj_core::memory::search(&global, &qv, embedder.model_id(), k)?;
if hits.is_empty() {
println!("no relevant prior reasoning found");
} else {
for h in hits {
let snippet: String = h.text.chars().take(100).collect();
let proj: String = h.project_hash.chars().take(8).collect();
println!(
"{:.3} [{}] {} ({}/{})",
h.score, h.event_type, snippet, proj, h.task_id
);
}
}
}
Commands::Event {
task_id,
r#type,
Expand Down Expand Up @@ -1494,6 +1544,7 @@ fn main() -> Result<()> {
backfill,
backend,
auto_capture,
proactive_recall,
} => {
let settings_path = match scope.as_str() {
"user" => {
Expand Down Expand Up @@ -1641,13 +1692,35 @@ fn main() -> Result<()> {
);
}
}
if proactive_recall {
// Append the recall injector to the UserPromptSubmit hooks,
// keeping whatever is already there (nudge, and ingest when
// --auto-capture is also set).
let obj = entries.as_object_mut().expect("entries is an object");
let ups = obj
.entry("UserPromptSubmit")
.or_insert_with(|| serde_json::json!([{ "matcher": "", "hooks": [] }]));
if let Some(hooks) = ups
.as_array_mut()
.and_then(|a| a.get_mut(0))
.and_then(|e| e.get_mut("hooks"))
.and_then(|h| h.as_array_mut())
{
hooks.push(serde_json::json!({
"type": "command",
"command": "task-journal recall-hook || true",
}));
}
}
// MERGE our entries into the existing `hooks` block — touch ONLY
// task-journal hooks, never clobber other plugins' hooks. For each
// event we (a) strip any prior task-journal entry (idempotent
// re-install) then (b) append ours, leaving foreign hooks and
// untouched events intact.
let is_tj = |c: &str| {
c.contains("task-journal ingest-hook") || c.contains("task-journal nudge")
c.contains("task-journal ingest-hook")
|| c.contains("task-journal nudge")
|| c.contains("task-journal recall-hook")
};
let hooks_block = hooks_obj
.entry("hooks".to_string())
Expand Down Expand Up @@ -3038,6 +3111,9 @@ fn main() -> Result<()> {
});
print!("{env}");
}
Commands::RecallHook => {
run_recall_hook()?;
}
Commands::Rejected {
topic,
all_projects,
Expand Down Expand Up @@ -3645,6 +3721,94 @@ fn recent_task_contexts(
/// the first line trimmed to 80 chars; goal is the prompt trimmed to
/// 200 chars. Returns a TaskContext so the classifier has somewhere
/// to attach the same prompt as the first real event.
/// Best-effort sync of a project's high-signal events into the global
/// cross-project memory index. Never fails the caller — a slightly stale recall
/// index is fine; a broken `ask`/`embed` is not.
fn sync_global_memory(project_conn: &rusqlite::Connection, project_hash: &str) {
let result = tj_core::paths::memory_db()
.and_then(tj_core::memory::open)
.and_then(|g| tj_core::memory::sync_from_project(&g, project_conn, project_hash));
if let Err(e) = result {
tracing::debug!("global memory sync skipped: {e:#}");
}
}

/// Proactive recall injector (opt-in hook). Reads the UserPromptSubmit payload
/// 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,
/// or error exits silently with no output.
fn run_recall_hook() -> anyhow::Result<()> {
// Opt-out and recursion guard (never inject into our own classifier spawn).
if std::env::var("TJ_PROACTIVE_RECALL").as_deref() == Ok("0") {
return Ok(());
}
if std::env::var(tj_core::classifier::agent_sdk::IN_CLASSIFIER_ENV).is_ok() {
return Ok(());
}
let global_path = tj_core::paths::memory_db()?;
if !global_path.exists() {
return Ok(());
}

use std::io::Read;
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.trim().is_empty() {
return Ok(());
}
// The UserPromptSubmit payload carries the prompt under `prompt`; fall back
// to the raw stdin if it isn't JSON.
let prompt = serde_json::from_str::<serde_json::Value>(&buf)
.ok()
.and_then(|v| {
v.get("prompt")
.and_then(|p| p.as_str())
.map(|s| s.to_string())
})
.unwrap_or(buf);
if prompt.trim().is_empty() {
return Ok(());
}

let conn = tj_core::memory::open(&global_path)?;
let k: usize = std::env::var("TJ_RECALL_K")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let hits = tj_core::memory::keyword_search(&conn, &prompt, k)?;
if hits.is_empty() {
return Ok(());
}

let budget: usize = std::env::var("TJ_RECALL_BUDGET_CHARS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(900);
let mut ctx = String::from(
"📓 task-journal — relevant prior reasoning from your history (you may have decided this before):\n",
);
for h in &hits {
let snippet: String = h.text.chars().take(160).collect();
let proj: String = h.project_hash.chars().take(8).collect();
let line = format!(
"⚠ [{}] {} (project {proj}, {})\n",
h.event_type, snippet, h.task_id
);
if ctx.len() + line.len() > budget {
break;
}
ctx.push_str(&line);
}
let env = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": ctx.trim_end(),
}
});
print!("{env}");
Ok(())
}

fn auto_open_task_from_prompt(
events_path: &std::path::Path,
project_hash: &str,
Expand Down
Loading
Loading