Skip to content

Commit 657c5e2

Browse files
authored
Merge pull request #36 from Digital-Threads/feat/memory-consolidation-p32
feat(memory): consolidation via direct Haiku API — Pillar C complete (0.19.0)
2 parents 274114b + b8ba7ea commit 657c5e2

11 files changed

Lines changed: 593 additions & 7 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.19.0] - 2026-06-13
11+
12+
### Added
13+
- **Consolidation — Pillar C complete.** `task-journal consolidate` distils a
14+
project's recurring decisions and constraints into a handful of durable
15+
**semantic** / **procedural** facts ("refunds always go through the ledger",
16+
"PR into main, squash-merge"), stored as events in a per-project
17+
*"Project conventions (consolidated)"* task with provenance
18+
(`derived_from`), and surfaced in `ask`/`recall`.
19+
- **Manual and opt-in.** It makes exactly **one direct Anthropic Haiku API
20+
call per run, only when you run it** — never wired to a hook, so it can
21+
never spend automatically. Roughly 1¢ per run.
22+
- The **direct API** (not `claude -p`) is used on purpose: post-2026-06-15
23+
both bill as extra usage, but `claude -p` also boots the whole environment
24+
(~tens of k tokens) per call; the direct API sends only the ~7k-token
25+
prompt.
26+
- **No `ANTHROPIC_API_KEY` → it skips cleanly** with a message and writes
27+
nothing. There is no heuristic fallback (it would manufacture low-trust
28+
facts). Re-running de-duplicates.
29+
- `--max-facts N` caps output; `TJ_CONSOLIDATE_MODEL` overrides the model.
30+
31+
### Internal
32+
- `tj-core::consolidate` (prompt, parse, direct-API call, mockito-tested) +
33+
`db::high_signal_events` / `find_task_by_title` / `task_event_texts`. CLI
34+
`consolidate`.
35+
1036
## [0.18.0] - 2026-06-12
1137

1238
### Added

Cargo.lock

Lines changed: 4 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.18.0"
10+
version = "0.19.0"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 2 additions & 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.18.0", path = "../tj-core", default-features = false }
26+
tj-core = { package = "task-journal-core", version = "0.19.0", path = "../tj-core", default-features = false }
2727
anyhow = { workspace = true }
2828
clap = { workspace = true }
2929
tracing = { workspace = true }
@@ -45,3 +45,4 @@ assert_fs = { workspace = true }
4545
predicates = { workspace = true }
4646
assert_cmd = ">=2, <2.2.1"
4747
rusqlite = { workspace = true }
48+
mockito = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,16 @@ enum Commands {
652652
},
653653
/// List your stored user preferences.
654654
Preferences,
655+
/// Distil this project's recurring decisions and constraints into durable
656+
/// semantic/procedural facts (Pillar C). MANUAL and opt-in — it makes ONE
657+
/// direct Haiku API call per run (needs ANTHROPIC_API_KEY; ~1c/run) and is
658+
/// never wired to a hook, so it can't spend automatically. Facts are stored
659+
/// as events in a per-project "conventions" task and surface in ask/recall.
660+
Consolidate {
661+
/// Maximum number of facts to produce.
662+
#[arg(long, default_value_t = 8)]
663+
max_facts: usize,
664+
},
655665
/// Render and print the resume pack for a task.
656666
Pack {
657667
/// Task id (e.g. tj-7f3a).
@@ -1284,6 +1294,9 @@ fn main() -> Result<()> {
12841294
}
12851295
}
12861296
}
1297+
Commands::Consolidate { max_facts } => {
1298+
run_consolidate(max_facts)?;
1299+
}
12871300
Commands::Event {
12881301
task_id,
12891302
r#type,
@@ -3904,6 +3917,112 @@ fn emit_session_context(ctx: &str) {
39043917
println!("{env}");
39053918
}
39063919

3920+
const CONSOLIDATE_TASK_TITLE: &str = "Project conventions (consolidated)";
3921+
3922+
/// Manual consolidation: read this project's recurring decisions/constraints,
3923+
/// distil them into durable facts via one direct Haiku API call, and store the
3924+
/// facts as events in a per-project conventions task. Skips cleanly (no spend)
3925+
/// when ANTHROPIC_API_KEY is absent.
3926+
fn run_consolidate(max_facts: usize) -> anyhow::Result<()> {
3927+
let cwd = std::env::current_dir()?;
3928+
let project_hash = tj_core::project_hash::from_path(&cwd)?;
3929+
let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
3930+
let state_path = tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite"));
3931+
if !events_path.exists() {
3932+
anyhow::bail!("no events file at {events_path:?}");
3933+
}
3934+
let conn = tj_core::db::open(&state_path)?;
3935+
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
3936+
3937+
let sources = tj_core::db::high_signal_events(&conn, 200)?;
3938+
if sources.is_empty() {
3939+
println!("nothing to consolidate — no decisions/constraints/rejections recorded yet");
3940+
return Ok(());
3941+
}
3942+
let texts: Vec<String> = sources.iter().map(|(_, t)| t.clone()).collect();
3943+
let source_ids: Vec<String> = sources.iter().map(|(id, _)| id.clone()).collect();
3944+
3945+
let consolidator = match tj_core::consolidate::Consolidator::from_env(max_facts) {
3946+
Ok(c) => c,
3947+
Err(e) => {
3948+
println!("skipped: {e}. Set ANTHROPIC_API_KEY to enable consolidation (~1c/run).");
3949+
return Ok(());
3950+
}
3951+
};
3952+
eprintln!(
3953+
"consolidating {} high-signal event(s) via {} …",
3954+
texts.len(),
3955+
consolidator.model
3956+
);
3957+
let facts = consolidator.consolidate(&texts)?;
3958+
if facts.is_empty() {
3959+
println!("no durable facts found");
3960+
return Ok(());
3961+
}
3962+
3963+
// Reuse the per-project conventions task, or create it.
3964+
let task_id = match tj_core::db::find_task_by_title(&conn, CONSOLIDATE_TASK_TITLE)? {
3965+
Some(id) => id,
3966+
None => {
3967+
let id = tj_core::new_task_id();
3968+
let mut ev = tj_core::event::Event::new(
3969+
id.clone(),
3970+
tj_core::event::EventType::Open,
3971+
tj_core::event::Author::User,
3972+
tj_core::event::Source::Cli,
3973+
CONSOLIDATE_TASK_TITLE.to_string(),
3974+
);
3975+
ev.meta = serde_json::json!({ "title": CONSOLIDATE_TASK_TITLE });
3976+
let mut w = tj_core::storage::JsonlWriter::open(&events_path)?;
3977+
w.append(&ev)?;
3978+
w.flush_durable()?;
3979+
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
3980+
id
3981+
}
3982+
};
3983+
3984+
// De-dup against facts already stored in the conventions task.
3985+
let existing: std::collections::HashSet<String> =
3986+
tj_core::db::task_event_texts(&conn, &task_id)?
3987+
.into_iter()
3988+
.collect();
3989+
3990+
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
3991+
let mut written = 0usize;
3992+
for f in &facts {
3993+
if existing.contains(&f.text) {
3994+
continue;
3995+
}
3996+
let mut ev = tj_core::event::Event::new(
3997+
task_id.clone(),
3998+
tj_core::event::EventType::Finding,
3999+
tj_core::event::Author::Agent,
4000+
tj_core::event::Source::Cli,
4001+
f.text.clone(),
4002+
);
4003+
ev.meta = serde_json::json!({
4004+
"memory_tier": f.tier,
4005+
"consolidated": true,
4006+
"derived_from": source_ids,
4007+
});
4008+
writer.append(&ev)?;
4009+
written += 1;
4010+
}
4011+
writer.flush_durable()?;
4012+
4013+
// Index the new facts and push them to the global recall index.
4014+
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
4015+
let embedder = tj_core::embed::default_embedder();
4016+
let now = chrono::Utc::now().to_rfc3339();
4017+
tj_core::db::embed_pending(&conn, &project_hash, embedder.as_ref(), &now, 512)?;
4018+
sync_global_memory(&conn, &project_hash);
4019+
4020+
println!(
4021+
"consolidated {written} new fact(s) into task {task_id} (\"{CONSOLIDATE_TASK_TITLE}\")"
4022+
);
4023+
Ok(())
4024+
}
4025+
39074026
fn auto_open_task_from_prompt(
39084027
events_path: &std::path::Path,
39094028
project_hash: &str,

crates/tj-cli/tests/cli.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5209,3 +5209,154 @@ fn stats_reports_memory_preferences_count() {
52095209
.success()
52105210
.stdout(contains("preferences: 1"));
52115211
}
5212+
5213+
#[test]
5214+
fn consolidate_writes_facts_to_conventions_task_and_dedups() {
5215+
// Pillar C: `consolidate` distils decisions into durable facts via one
5216+
// (mocked) Haiku call and stores them in a per-project conventions task.
5217+
// Re-running de-dups. TJ_CONSOLIDATE_BASE_URL points at the mock; TJ_EMBED
5218+
// forces the deterministic embedder.
5219+
let mut server = mockito::Server::new();
5220+
let mock = server
5221+
.mock("POST", "/v1/messages")
5222+
.with_status(200)
5223+
.with_header("content-type", "application/json")
5224+
.with_body(
5225+
serde_json::json!({
5226+
"id": "m", "type": "message", "role": "assistant",
5227+
"content": [{"type": "text",
5228+
"text": "[semantic] Refunds always route through the idempotent ledger\n[procedural] PR into main, squash-merge"}]
5229+
})
5230+
.to_string(),
5231+
)
5232+
.expect_at_least(1)
5233+
.create();
5234+
5235+
let xdg = assert_fs::TempDir::new().unwrap();
5236+
let proj = assert_fs::TempDir::new().unwrap();
5237+
5238+
let tid = String::from_utf8(
5239+
Command::cargo_bin("task-journal")
5240+
.unwrap()
5241+
.current_dir(proj.path())
5242+
.env("XDG_DATA_HOME", xdg.path())
5243+
.args(["create", "Payments"])
5244+
.assert()
5245+
.success()
5246+
.get_output()
5247+
.stdout
5248+
.clone(),
5249+
)
5250+
.unwrap()
5251+
.trim()
5252+
.to_string();
5253+
Command::cargo_bin("task-journal")
5254+
.unwrap()
5255+
.current_dir(proj.path())
5256+
.env("XDG_DATA_HOME", xdg.path())
5257+
.args([
5258+
"event",
5259+
&tid,
5260+
"--type",
5261+
"decision",
5262+
"--text",
5263+
"chose the idempotent ledger for refunds",
5264+
])
5265+
.assert()
5266+
.success();
5267+
5268+
let run = || {
5269+
Command::cargo_bin("task-journal")
5270+
.unwrap()
5271+
.current_dir(proj.path())
5272+
.env("XDG_DATA_HOME", xdg.path())
5273+
.env("ANTHROPIC_API_KEY", "test-key")
5274+
.env("TJ_CONSOLIDATE_BASE_URL", server.url())
5275+
.env("TJ_EMBED", "hash")
5276+
.args(["consolidate"])
5277+
.assert()
5278+
.success()
5279+
.get_output()
5280+
.stdout
5281+
.clone()
5282+
};
5283+
5284+
let first = String::from_utf8(run()).unwrap();
5285+
assert!(
5286+
first.contains("consolidated 2 new fact(s)"),
5287+
"first run must store 2 facts; got: {first:?}"
5288+
);
5289+
// Second run: same facts already present -> de-duped to 0.
5290+
let second = String::from_utf8(run()).unwrap();
5291+
assert!(
5292+
second.contains("consolidated 0 new fact(s)"),
5293+
"second run must de-dup; got: {second:?}"
5294+
);
5295+
mock.assert();
5296+
5297+
// The fact is now recallable.
5298+
let recall = String::from_utf8(
5299+
Command::cargo_bin("task-journal")
5300+
.unwrap()
5301+
.env("XDG_DATA_HOME", xdg.path())
5302+
.env("TJ_EMBED", "hash")
5303+
.args(["recall", "refund ledger idempotent", "--k", "3"])
5304+
.assert()
5305+
.success()
5306+
.get_output()
5307+
.stdout
5308+
.clone(),
5309+
)
5310+
.unwrap();
5311+
assert!(
5312+
recall.contains("ledger"),
5313+
"consolidated fact must surface in cross-project recall; got: {recall:?}"
5314+
);
5315+
}
5316+
5317+
#[test]
5318+
fn consolidate_skips_without_api_key_and_spends_nothing() {
5319+
// Safety: with no ANTHROPIC_API_KEY, consolidate makes no call and creates
5320+
// no facts — it can never spend automatically.
5321+
let xdg = assert_fs::TempDir::new().unwrap();
5322+
let proj = assert_fs::TempDir::new().unwrap();
5323+
let tid = String::from_utf8(
5324+
Command::cargo_bin("task-journal")
5325+
.unwrap()
5326+
.current_dir(proj.path())
5327+
.env("XDG_DATA_HOME", xdg.path())
5328+
.args(["create", "Scheduler"])
5329+
.assert()
5330+
.success()
5331+
.get_output()
5332+
.stdout
5333+
.clone(),
5334+
)
5335+
.unwrap()
5336+
.trim()
5337+
.to_string();
5338+
Command::cargo_bin("task-journal")
5339+
.unwrap()
5340+
.current_dir(proj.path())
5341+
.env("XDG_DATA_HOME", xdg.path())
5342+
.args([
5343+
"event",
5344+
&tid,
5345+
"--type",
5346+
"decision",
5347+
"--text",
5348+
"use postgres advisory locks for cron",
5349+
])
5350+
.assert()
5351+
.success();
5352+
5353+
Command::cargo_bin("task-journal")
5354+
.unwrap()
5355+
.current_dir(proj.path())
5356+
.env("XDG_DATA_HOME", xdg.path())
5357+
.env_remove("ANTHROPIC_API_KEY")
5358+
.args(["consolidate"])
5359+
.assert()
5360+
.success()
5361+
.stdout(contains("skipped"));
5362+
}

0 commit comments

Comments
 (0)