diff --git a/CHANGELOG.md b/CHANGELOG.md index 093423f..1e77387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.17.0] - 2026-06-12 + +### Added +- **User preferences — Pillar C (part 1).** The journal now has user-level + memory: durable preferences that persist across every project and session — + the "remember me" parity with mem0/claude-mem. + - `task-journal remember ""` — store a preference ("respond in Russian, + terse", "run the full test suite before tagging"). De-duplicated. + - `task-journal preferences` — list them. + - Preferences are injected into **every session** via the SessionStart hook — + even in a fresh project with no events of its own — so the agent works the + way you want without being re-told. Capped so it never floods the prompt. + - Stored in the global `memory.sqlite` (`preferences` table), so they're + shared across all your projects. + +### Internal +- `tj-core::memory`: `add_preference` / `list_preferences`. CLI + `remember` / `preferences`; SessionStart preference injection. + ## [0.16.0] - 2026-06-12 ### Added diff --git a/Cargo.lock b/Cargo.lock index be59017..9d0896b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,7 +2572,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "assert_cmd", @@ -2595,7 +2595,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "chrono", @@ -2620,7 +2620,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7ec2050..0c7381e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.16.0" +version = "0.17.0" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index ed8322e..bd220d2 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.16.0", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.17.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 196c251..fabcbc4 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -642,6 +642,16 @@ enum Commands { #[arg(long, default_value_t = 5)] k: usize, }, + /// Record a durable user preference (Pillar C) — e.g. "prefer terse output", + /// "respond in Russian", "always run the full test suite before tagging". + /// Stored user-level (across all projects) and injected into every session + /// so the agent remembers how you work without being re-told. + Remember { + /// The preference text to remember. + text: String, + }, + /// List your stored user preferences. + Preferences, /// Render and print the resume pack for a task. Pack { /// Task id (e.g. tj-7f3a). @@ -1250,6 +1260,30 @@ fn main() -> Result<()> { } } } + Commands::Remember { text } => { + let global = tj_core::memory::open(tj_core::paths::memory_db()?)?; + let now = chrono::Utc::now().to_rfc3339(); + if tj_core::memory::add_preference(&global, &text, &now)? { + println!("remembered: {}", text.trim()); + } else { + println!("already remembered"); + } + } + Commands::Preferences => { + let path = tj_core::paths::memory_db()?; + let prefs = if path.exists() { + tj_core::memory::list_preferences(&tj_core::memory::open(&path)?)? + } else { + Vec::new() + }; + if prefs.is_empty() { + println!("no preferences yet — add one with `task-journal remember \"...\"`"); + } else { + for p in prefs { + println!("- {p}"); + } + } + } Commands::Event { task_id, r#type, @@ -1965,10 +1999,17 @@ fn main() -> Result<()> { // manually each session. Empty stdout when no open tasks → no // injection, keeps system prompt clean for fresh projects. if kind == "SessionStart" { + // User preferences are global, so they surface even in a fresh + // project with no events of its own (Pillar C "remember me"). + let prefs_block = session_preferences_block(); // Skip early on a clean machine: nothing to surface, and we // don't want SessionStart to spawn empty SQLite files in - // every project Claude Code is opened in. + // every project Claude Code is opened in. Preferences still go + // out if there are any. if !events_path.exists() { + if !prefs_block.is_empty() { + emit_session_context(&prefs_block); + } return Ok(()); } let state_path = @@ -1977,6 +2018,9 @@ fn main() -> Result<()> { tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; let recent = recent_task_contexts(&conn, 3)?; if recent.is_empty() { + if !prefs_block.is_empty() { + emit_session_context(&prefs_block); + } return Ok(()); } // After a compaction (source=="compact"), re-inject the @@ -1985,6 +2029,12 @@ fn main() -> Result<()> { // any error → no reminder, never abort SessionStart. let source = payload.get("source").and_then(|v| v.as_str()).unwrap_or(""); let mut bundle = String::new(); + // Preferences lead the bundle — they're the smallest, most + // durable signal about how the user wants to be worked with. + if !prefs_block.is_empty() { + bundle.push_str(&prefs_block); + bundle.push_str("\n\n"); + } if source == "compact" { if let Ok(Some(reminder)) = tj_core::reminder::active_task_reminder(&conn) { bundle.push_str(&reminder); @@ -3809,6 +3859,38 @@ fn run_recall_hook() -> anyhow::Result<()> { Ok(()) } +/// Render the user's standing preferences as a SessionStart context block, or +/// "" when there are none. Capped so it never floods the system prompt. +fn session_preferences_block() -> String { + let prefs = match tj_core::paths::memory_db() + .and_then(tj_core::memory::open) + .and_then(|c| tj_core::memory::list_preferences(&c)) + { + Ok(p) if !p.is_empty() => p, + _ => return String::new(), + }; + let mut s = String::from("## Your standing preferences (remember these across sessions):\n"); + for p in prefs { + let line = format!("- {p}\n"); + if s.len() + line.len() > 800 { + break; + } + s.push_str(&line); + } + s.trim_end().to_string() +} + +/// Emit a SessionStart `additionalContext` envelope and nothing else. +fn emit_session_context(ctx: &str) { + let env = serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": ctx.trim_end(), + } + }); + println!("{env}"); +} + fn auto_open_task_from_prompt( events_path: &std::path::Path, project_hash: &str, diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 80f614d..379966e 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -5139,3 +5139,54 @@ fn recall_hook_injects_relevant_prior_reasoning() { "TJ_PROACTIVE_RECALL=0 must suppress injection; got: {gated:?}" ); } + +#[test] +fn remembered_preference_lists_and_injects_at_session_start() { + // Pillar C: a user preference is stored cross-project and injected into + // every session — even a fresh project with no events of its own. + let dir = assert_fs::TempDir::new().unwrap(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["remember", "respond in Russian, terse"]) + .assert() + .success() + .stdout(contains("remembered")); + + // Duplicate is a no-op. + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["remember", "respond in Russian, terse"]) + .assert() + .success() + .stdout(contains("already remembered")); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["preferences"]) + .assert() + .success() + .stdout(contains("respond in Russian, terse")); + + // SessionStart injects the preference with no project events present. + let body = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["ingest-hook", "--kind", "SessionStart", "--text", ""]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap(); + assert!( + body.contains("respond in Russian, terse"), + "SessionStart must inject standing preferences; got: {body:?}" + ); + assert!(body.contains("additionalContext")); +} diff --git a/crates/tj-core/src/memory.rs b/crates/tj-core/src/memory.rs index 3b399e5..6ae32bb 100644 --- a/crates/tj-core/src/memory.rs +++ b/crates/tj-core/src/memory.rs @@ -35,6 +35,11 @@ CREATE TABLE IF NOT EXISTS global_memory ( CREATE INDEX IF NOT EXISTS idx_gm_type ON global_memory(type); CREATE INDEX IF NOT EXISTS idx_gm_model ON global_memory(model); CREATE VIRTUAL TABLE IF NOT EXISTS global_fts USING fts5(event_id UNINDEXED, text); +CREATE TABLE IF NOT EXISTS preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); "#; /// Open (creating + migrating) the global memory database at `path`. @@ -228,6 +233,36 @@ pub fn count(conn: &Connection) -> anyhow::Result { Ok(n as usize) } +// --------------------------------------------------------------------------- +// Preference tier (Pillar C): user-level, cross-project memory injected every +// session — "I prefer terse output", "always use X here", etc. +// --------------------------------------------------------------------------- + +/// Record a durable user preference. De-duplicated on text (a repeat is a +/// no-op). Returns whether a new preference was stored. +pub fn add_preference(conn: &Connection, text: &str, created_at: &str) -> anyhow::Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + anyhow::bail!("preference text is empty"); + } + let changed = conn.execute( + "INSERT OR IGNORE INTO preferences(text, created_at) VALUES (?1, ?2)", + rusqlite::params![trimmed, created_at], + )?; + Ok(changed > 0) +} + +/// All stored preferences, oldest first. +pub fn list_preferences(conn: &Connection) -> anyhow::Result> { + let mut stmt = conn.prepare("SELECT text FROM preferences ORDER BY id")?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -306,6 +341,23 @@ mod tests { .is_empty()); } + #[test] + fn preferences_store_dedup_and_list_in_order() { + let d = tempfile::TempDir::new().unwrap(); + let g = open(d.path().join("memory.sqlite")).unwrap(); + assert!(add_preference(&g, "prefer terse output", "t1").unwrap()); + assert!(add_preference(&g, "respond in Russian", "t2").unwrap()); + // Duplicate is a no-op. + assert!(!add_preference(&g, "prefer terse output", "t3").unwrap()); + assert_eq!( + list_preferences(&g).unwrap(), + vec![ + "prefer terse output".to_string(), + "respond in Russian".to_string() + ] + ); + } + #[test] fn search_filters_by_model() { let d = tempfile::TempDir::new().unwrap(); diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index 81ca77e..f7a7d42 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.16.0", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.17.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 baa1c66..04c6e92 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.16.0", + "version": "0.17.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"