Skip to content

Commit e130a6d

Browse files
authored
Merge pull request #34 from Digital-Threads/feat/memory-p3-preferences
feat(memory): Pillar C pt.1 — user preferences + per-session injection (0.17.0)
2 parents 618f799 + 331db0a commit e130a6d

9 files changed

Lines changed: 212 additions & 8 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.17.0] - 2026-06-12
11+
12+
### Added
13+
- **User preferences — Pillar C (part 1).** The journal now has user-level
14+
memory: durable preferences that persist across every project and session —
15+
the "remember me" parity with mem0/claude-mem.
16+
- `task-journal remember "<text>"` — store a preference ("respond in Russian,
17+
terse", "run the full test suite before tagging"). De-duplicated.
18+
- `task-journal preferences` — list them.
19+
- Preferences are injected into **every session** via the SessionStart hook —
20+
even in a fresh project with no events of its own — so the agent works the
21+
way you want without being re-told. Capped so it never floods the prompt.
22+
- Stored in the global `memory.sqlite` (`preferences` table), so they're
23+
shared across all your projects.
24+
25+
### Internal
26+
- `tj-core::memory`: `add_preference` / `list_preferences`. CLI
27+
`remember` / `preferences`; SessionStart preference injection.
28+
1029
## [0.16.0] - 2026-06-12
1130

1231
### 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.16.0"
10+
version = "0.17.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.16.0", path = "../tj-core", default-features = false }
26+
tj-core = { package = "task-journal-core", version = "0.17.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: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,16 @@ enum Commands {
642642
#[arg(long, default_value_t = 5)]
643643
k: usize,
644644
},
645+
/// Record a durable user preference (Pillar C) — e.g. "prefer terse output",
646+
/// "respond in Russian", "always run the full test suite before tagging".
647+
/// Stored user-level (across all projects) and injected into every session
648+
/// so the agent remembers how you work without being re-told.
649+
Remember {
650+
/// The preference text to remember.
651+
text: String,
652+
},
653+
/// List your stored user preferences.
654+
Preferences,
645655
/// Render and print the resume pack for a task.
646656
Pack {
647657
/// Task id (e.g. tj-7f3a).
@@ -1250,6 +1260,30 @@ fn main() -> Result<()> {
12501260
}
12511261
}
12521262
}
1263+
Commands::Remember { text } => {
1264+
let global = tj_core::memory::open(tj_core::paths::memory_db()?)?;
1265+
let now = chrono::Utc::now().to_rfc3339();
1266+
if tj_core::memory::add_preference(&global, &text, &now)? {
1267+
println!("remembered: {}", text.trim());
1268+
} else {
1269+
println!("already remembered");
1270+
}
1271+
}
1272+
Commands::Preferences => {
1273+
let path = tj_core::paths::memory_db()?;
1274+
let prefs = if path.exists() {
1275+
tj_core::memory::list_preferences(&tj_core::memory::open(&path)?)?
1276+
} else {
1277+
Vec::new()
1278+
};
1279+
if prefs.is_empty() {
1280+
println!("no preferences yet — add one with `task-journal remember \"...\"`");
1281+
} else {
1282+
for p in prefs {
1283+
println!("- {p}");
1284+
}
1285+
}
1286+
}
12531287
Commands::Event {
12541288
task_id,
12551289
r#type,
@@ -1965,10 +1999,17 @@ fn main() -> Result<()> {
19651999
// manually each session. Empty stdout when no open tasks → no
19662000
// injection, keeps system prompt clean for fresh projects.
19672001
if kind == "SessionStart" {
2002+
// User preferences are global, so they surface even in a fresh
2003+
// project with no events of its own (Pillar C "remember me").
2004+
let prefs_block = session_preferences_block();
19682005
// Skip early on a clean machine: nothing to surface, and we
19692006
// don't want SessionStart to spawn empty SQLite files in
1970-
// every project Claude Code is opened in.
2007+
// every project Claude Code is opened in. Preferences still go
2008+
// out if there are any.
19712009
if !events_path.exists() {
2010+
if !prefs_block.is_empty() {
2011+
emit_session_context(&prefs_block);
2012+
}
19722013
return Ok(());
19732014
}
19742015
let state_path =
@@ -1977,6 +2018,9 @@ fn main() -> Result<()> {
19772018
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
19782019
let recent = recent_task_contexts(&conn, 3)?;
19792020
if recent.is_empty() {
2021+
if !prefs_block.is_empty() {
2022+
emit_session_context(&prefs_block);
2023+
}
19802024
return Ok(());
19812025
}
19822026
// After a compaction (source=="compact"), re-inject the
@@ -1985,6 +2029,12 @@ fn main() -> Result<()> {
19852029
// any error → no reminder, never abort SessionStart.
19862030
let source = payload.get("source").and_then(|v| v.as_str()).unwrap_or("");
19872031
let mut bundle = String::new();
2032+
// Preferences lead the bundle — they're the smallest, most
2033+
// durable signal about how the user wants to be worked with.
2034+
if !prefs_block.is_empty() {
2035+
bundle.push_str(&prefs_block);
2036+
bundle.push_str("\n\n");
2037+
}
19882038
if source == "compact" {
19892039
if let Ok(Some(reminder)) = tj_core::reminder::active_task_reminder(&conn) {
19902040
bundle.push_str(&reminder);
@@ -3809,6 +3859,38 @@ fn run_recall_hook() -> anyhow::Result<()> {
38093859
Ok(())
38103860
}
38113861

3862+
/// Render the user's standing preferences as a SessionStart context block, or
3863+
/// "" when there are none. Capped so it never floods the system prompt.
3864+
fn session_preferences_block() -> String {
3865+
let prefs = match tj_core::paths::memory_db()
3866+
.and_then(tj_core::memory::open)
3867+
.and_then(|c| tj_core::memory::list_preferences(&c))
3868+
{
3869+
Ok(p) if !p.is_empty() => p,
3870+
_ => return String::new(),
3871+
};
3872+
let mut s = String::from("## Your standing preferences (remember these across sessions):\n");
3873+
for p in prefs {
3874+
let line = format!("- {p}\n");
3875+
if s.len() + line.len() > 800 {
3876+
break;
3877+
}
3878+
s.push_str(&line);
3879+
}
3880+
s.trim_end().to_string()
3881+
}
3882+
3883+
/// Emit a SessionStart `additionalContext` envelope and nothing else.
3884+
fn emit_session_context(ctx: &str) {
3885+
let env = serde_json::json!({
3886+
"hookSpecificOutput": {
3887+
"hookEventName": "SessionStart",
3888+
"additionalContext": ctx.trim_end(),
3889+
}
3890+
});
3891+
println!("{env}");
3892+
}
3893+
38123894
fn auto_open_task_from_prompt(
38133895
events_path: &std::path::Path,
38143896
project_hash: &str,

crates/tj-cli/tests/cli.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5139,3 +5139,54 @@ fn recall_hook_injects_relevant_prior_reasoning() {
51395139
"TJ_PROACTIVE_RECALL=0 must suppress injection; got: {gated:?}"
51405140
);
51415141
}
5142+
5143+
#[test]
5144+
fn remembered_preference_lists_and_injects_at_session_start() {
5145+
// Pillar C: a user preference is stored cross-project and injected into
5146+
// every session — even a fresh project with no events of its own.
5147+
let dir = assert_fs::TempDir::new().unwrap();
5148+
5149+
Command::cargo_bin("task-journal")
5150+
.unwrap()
5151+
.env("XDG_DATA_HOME", dir.path())
5152+
.args(["remember", "respond in Russian, terse"])
5153+
.assert()
5154+
.success()
5155+
.stdout(contains("remembered"));
5156+
5157+
// Duplicate is a no-op.
5158+
Command::cargo_bin("task-journal")
5159+
.unwrap()
5160+
.env("XDG_DATA_HOME", dir.path())
5161+
.args(["remember", "respond in Russian, terse"])
5162+
.assert()
5163+
.success()
5164+
.stdout(contains("already remembered"));
5165+
5166+
Command::cargo_bin("task-journal")
5167+
.unwrap()
5168+
.env("XDG_DATA_HOME", dir.path())
5169+
.args(["preferences"])
5170+
.assert()
5171+
.success()
5172+
.stdout(contains("respond in Russian, terse"));
5173+
5174+
// SessionStart injects the preference with no project events present.
5175+
let body = String::from_utf8(
5176+
Command::cargo_bin("task-journal")
5177+
.unwrap()
5178+
.env("XDG_DATA_HOME", dir.path())
5179+
.args(["ingest-hook", "--kind", "SessionStart", "--text", ""])
5180+
.assert()
5181+
.success()
5182+
.get_output()
5183+
.stdout
5184+
.clone(),
5185+
)
5186+
.unwrap();
5187+
assert!(
5188+
body.contains("respond in Russian, terse"),
5189+
"SessionStart must inject standing preferences; got: {body:?}"
5190+
);
5191+
assert!(body.contains("additionalContext"));
5192+
}

crates/tj-core/src/memory.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ CREATE TABLE IF NOT EXISTS global_memory (
3535
CREATE INDEX IF NOT EXISTS idx_gm_type ON global_memory(type);
3636
CREATE INDEX IF NOT EXISTS idx_gm_model ON global_memory(model);
3737
CREATE VIRTUAL TABLE IF NOT EXISTS global_fts USING fts5(event_id UNINDEXED, text);
38+
CREATE TABLE IF NOT EXISTS preferences (
39+
id INTEGER PRIMARY KEY AUTOINCREMENT,
40+
text TEXT NOT NULL UNIQUE,
41+
created_at TEXT NOT NULL
42+
);
3843
"#;
3944

4045
/// Open (creating + migrating) the global memory database at `path`.
@@ -228,6 +233,36 @@ pub fn count(conn: &Connection) -> anyhow::Result<usize> {
228233
Ok(n as usize)
229234
}
230235

236+
// ---------------------------------------------------------------------------
237+
// Preference tier (Pillar C): user-level, cross-project memory injected every
238+
// session — "I prefer terse output", "always use X here", etc.
239+
// ---------------------------------------------------------------------------
240+
241+
/// Record a durable user preference. De-duplicated on text (a repeat is a
242+
/// no-op). Returns whether a new preference was stored.
243+
pub fn add_preference(conn: &Connection, text: &str, created_at: &str) -> anyhow::Result<bool> {
244+
let trimmed = text.trim();
245+
if trimmed.is_empty() {
246+
anyhow::bail!("preference text is empty");
247+
}
248+
let changed = conn.execute(
249+
"INSERT OR IGNORE INTO preferences(text, created_at) VALUES (?1, ?2)",
250+
rusqlite::params![trimmed, created_at],
251+
)?;
252+
Ok(changed > 0)
253+
}
254+
255+
/// All stored preferences, oldest first.
256+
pub fn list_preferences(conn: &Connection) -> anyhow::Result<Vec<String>> {
257+
let mut stmt = conn.prepare("SELECT text FROM preferences ORDER BY id")?;
258+
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
259+
let mut out = Vec::new();
260+
for r in rows {
261+
out.push(r?);
262+
}
263+
Ok(out)
264+
}
265+
231266
#[cfg(test)]
232267
mod tests {
233268
use super::*;
@@ -306,6 +341,23 @@ mod tests {
306341
.is_empty());
307342
}
308343

344+
#[test]
345+
fn preferences_store_dedup_and_list_in_order() {
346+
let d = tempfile::TempDir::new().unwrap();
347+
let g = open(d.path().join("memory.sqlite")).unwrap();
348+
assert!(add_preference(&g, "prefer terse output", "t1").unwrap());
349+
assert!(add_preference(&g, "respond in Russian", "t2").unwrap());
350+
// Duplicate is a no-op.
351+
assert!(!add_preference(&g, "prefer terse output", "t3").unwrap());
352+
assert_eq!(
353+
list_preferences(&g).unwrap(),
354+
vec![
355+
"prefer terse output".to_string(),
356+
"respond in Russian".to_string()
357+
]
358+
);
359+
}
360+
309361
#[test]
310362
fn search_filters_by_model() {
311363
let d = tempfile::TempDir::new().unwrap();

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ path = "src/main.rs"
1717

1818
[dependencies]
1919
# Lean: the MCP server doesn't embed yet, so it skips the model2vec backend.
20-
tj-core = { package = "task-journal-core", version = "0.16.0", path = "../tj-core", default-features = false }
20+
tj-core = { package = "task-journal-core", version = "0.17.0", path = "../tj-core", default-features = false }
2121
anyhow = { workspace = true }
2222
tokio = { workspace = true }
2323
tracing = { workspace = true }

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"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.",
55
"author": {
66
"name": "Mher Shahinyan"

0 commit comments

Comments
 (0)