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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<text>"` — 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
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.16.0"
version = "0.17.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.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 }
Expand Down
84 changes: 83 additions & 1 deletion crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions crates/tj-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
52 changes: 52 additions & 0 deletions crates/tj-core/src/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -228,6 +233,36 @@ pub fn count(conn: &Connection) -> anyhow::Result<usize> {
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<bool> {
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<Vec<String>> {
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::*;
Expand Down Expand Up @@ -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();
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.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 }
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.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"
Expand Down
Loading