Status: approved by product, in implementation
Branch: feature/memory-claude-mem-parity
Date: 2026-04-28
Bring CCE's cross-session memory to feature parity with claude-mem's
production-grade capture pipeline, while keeping CCE's per-project,
local-first, no-required-external-services posture.
Today CCE relies on a single SessionStart hook plus manual MCP
record_decision / record_code_area calls. Sessions are written to
per-project JSON files. Effectively, memory only grows when an agent
explicitly chooses to record — which means most sessions leave no trace.
Six design decisions were locked during brainstorming:
- Capture model. Auto-capture from hooks plus explicit MCP tools.
A
sourcecolumn (manual|auto|migrated) distinguishes them so recall can rank manual entries higher. - Compression timing. A background worker inside
cce serve(the long-running per-project MCP server process) drains apending_compressionsqueue on a 5–10 s tick. Hooks themselves stay thin appenders. - Summary granularity. Per-turn summaries plus a per-session rollup. Maps directly onto the three retrieval layers.
- Recall MCP surface. Extend the existing
session_recall(topic)to return compact-index hits (backward compatible with this project'sCLAUDE.mddocumentation), and add two new toolssession_timeline(session_id)andsession_event(event_id)for layer-2 and layer-3 drill-down. - Migration of existing JSON sessions. A one-shot
cce sessions migrateCLI command. Idempotent. User-invoked, not auto-triggered on startup. - Dashboard scope (v1). Three panels: sessions list, session
timeline (drill-into one session), decisions search (FTS5, faceted by
source). Hot-files / queue-health views deferred to a follow-up.
Per the user's "no Ollama on this machine, keep things small" preference:
Tier 1 (default): extractive summarisation using BAAI/bge-small-en-v1.5.
The model is already loaded in cce serve for the index;
reusing it adds zero new dependencies, zero extra RAM.
Algorithm: sentence-split the turn, embed each sentence
with bge-small, pick the top-K closest to the turn's
centroid, concatenate.
Tier 2 (optional): Ollama if running. Existing compressor.py path. Opt-in.
Tier 3 (fallback): truncation. Used if the embedder isn't ready yet
(e.g. very early SessionStart before the model loads).
Extractive output is always real source text — no hallucination. That matters for a memory system whose summaries survive across sessions.
┌─────────────────────────────────────┐
│ Claude Code (per-project) │
5 hooks ───────────────► │ SessionStart │
(settings.json) │ UserPromptSubmit │
│ PostToolUse │ thin shell:
│ Stop │ ~50ms each, ─────► HTTP POST
│ SessionEnd │ no LLM │ to local
└─────────────────────────────────────┘ │ serve_http
▼
┌───────────────────────────────────────────────────────────────────────────────┐
│ cce serve (already running per-project — adds 2 things) │
│ │
│ ┌─ HTTP /hooks/<name> ─► append raw event to memory.db (1-2 ms) │
│ │ │
│ └─ async tick loop (5–10s) ─► drain pending_compressions queue │
│ ┌──────────────────────────────┐ │
│ │ extractive (bge-small) │ │
│ │ → Ollama (opt-in) │ │
│ │ → truncation fallback │ │
│ └──────────────────────────────┘ │
│ │
│ MCP tools: session_recall (extended), session_timeline (new), │
│ session_event (new), record_decision, record_code_area │
└───────────────────────────────────────────────────────────────────────────────┘
│
▼
~/.cce/projects/<name>/memory.db (per-project SQLite, FTS5)
│
▼
Dashboard /memory pages (3 panels)
- Hooks are thin shells; zero latency from LLM work.
- Compression is decoupled from hooks. If it crashes, capture continues.
- Per-project SQLite means two projects cannot collide. Backup = copy one file.
- The existing
compressor.pyabstraction is reused.
Each Claude Code hook becomes a thin shell that POSTs JSON to
http://127.0.0.1:<port>/hooks/<name>. The port is written by
cce serve to ~/.cce/projects/<name>/serve.port on startup.
Hook script (~/.cce/hooks/cce_hook.sh, ~15 lines):
#!/bin/sh
# Claude Code hooks pipe JSON on stdin. Forward to cce serve.
# Failure is silent — memory is best-effort, never breaks the user's flow.
PORT=$(cat ~/.cce/projects/$(basename "$PWD")/serve.port 2>/dev/null) || exit 0
curl -sf -m 1 -X POST -H "Content-Type: application/json" \
--data-binary @- "http://127.0.0.1:${PORT}/hooks/$1" >/dev/null 2>&1 \
|| true| Hook | Payload | Action in cce serve |
|---|---|---|
SessionStart |
{session_id, project, started_at} |
Insert row in sessions. Show CCE status (existing behaviour preserved). |
UserPromptSubmit |
{session_id, prompt_number, prompt_text, timestamp} |
Insert into prompts. Enqueue compression for the previous turn (if any). |
PostToolUse |
{session_id, prompt_number, tool_name, tool_input_json, tool_output_json, timestamp} |
Insert into tool_events + tool_event_payloads. |
Stop |
{session_id, prompt_number, ended_at} |
Mark turn complete. Enqueue compression for the just-ended turn. |
SessionEnd |
{session_id, ended_at, exit_reason} |
Mark session complete. Enqueue session-rollup compression. |
cce servenot running: curl fails fast (-m 1), hook returns OK, no capture for that event.cce statussurfaces "memory capture: offline".- Port file missing: hook is a no-op.
- DB write fails inside
cce serve: log to stderr, increment ahook_errorscounter exposed in dashboard. - Tool payloads can be huge: stored uncompressed at hook time; compression worker later replaces the row's raw JSON with a summary and moves originals to a separate retention-bound table.
Eight tables in ~/.cce/projects/<name>/memory.db. Timestamps stored as
both ISO text and epoch int (text for humans, int for sorting). FTS5
virtual tables shown last.
-- A session = one Claude Code invocation in this project.
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
project TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at_epoch INTEGER,
ended_at TEXT,
exit_reason TEXT,
prompt_count INTEGER DEFAULT 0,
status TEXT CHECK(status IN ('active','completed','failed')) NOT NULL DEFAULT 'active',
rollup_summary TEXT,
rollup_summary_at_epoch INTEGER
);
CREATE INDEX idx_sessions_started ON sessions(started_at_epoch DESC);
-- One row per UserPromptSubmit.
CREATE TABLE prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
prompt_number INTEGER NOT NULL,
prompt_text TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(session_id, prompt_number)
);
CREATE INDEX idx_prompts_session ON prompts(session_id, prompt_number);
-- One row per PostToolUse.
CREATE TABLE tool_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
prompt_number INTEGER NOT NULL,
tool_name TEXT NOT NULL,
payload_id INTEGER,
summary TEXT,
created_at_epoch INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_events_session_turn ON tool_events(session_id, prompt_number);
-- Sidecar table holding raw tool input/output. Read on layer-3 drill-down.
CREATE TABLE tool_event_payloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_input TEXT NOT NULL,
raw_output TEXT,
size_bytes INTEGER NOT NULL
);
-- One row per turn after compression. Layer-2 of progressive disclosure.
CREATE TABLE turn_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
prompt_number INTEGER NOT NULL,
summary TEXT NOT NULL,
tier TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
UNIQUE(session_id, prompt_number)
);
-- Manual decisions (record_decision MCP tool).
CREATE TABLE decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
decision TEXT NOT NULL,
reason TEXT NOT NULL,
source TEXT NOT NULL CHECK(source IN ('manual','migrated','auto')) DEFAULT 'manual',
created_at_epoch INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_decisions_created ON decisions(created_at_epoch DESC);
CREATE INDEX idx_decisions_source ON decisions(source);
-- Manual code-area annotations.
CREATE TABLE code_areas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
file_path TEXT NOT NULL,
description TEXT NOT NULL,
source TEXT NOT NULL CHECK(source IN ('manual','migrated','auto')) DEFAULT 'manual',
created_at_epoch INTEGER NOT NULL
);
CREATE INDEX idx_code_areas_file ON code_areas(file_path);
-- Compression queue.
CREATE TABLE pending_compressions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('turn','session_rollup')),
session_id TEXT NOT NULL,
prompt_number INTEGER,
enqueued_at_epoch INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
UNIQUE(kind, session_id, prompt_number)
);
-- FTS5 over prompts, decisions, summaries.
CREATE VIRTUAL TABLE prompts_fts USING fts5(
prompt_text, content='prompts', content_rowid='id'
);
CREATE VIRTUAL TABLE decisions_fts USING fts5(
decision, reason, content='decisions', content_rowid='id'
);
CREATE VIRTUAL TABLE turn_summaries_fts USING fts5(
summary, content='turn_summaries', content_rowid='id'
);
-- Schema version for forward-compatible migrations.
CREATE TABLE schema_versions (
version INTEGER PRIMARY KEY,
applied_at_epoch INTEGER NOT NULL
);
INSERT INTO schema_versions (version, applied_at_epoch) VALUES (1, strftime('%s','now'));tool_events.payload_idis nullable so the compression worker can null it out (and delete the payload row) for events older than the retention window — keeping only thesummary. Default retention: 30 days for raw payloads. Tunable.- No
touched_filestable. The view is computed on demand fromtool_events.tool_input_jsonfor tools whose input contains afile_path. Cheaper than maintaining a counter table. - Three FTS tables, not one combined. Lets
session_recallweight prompts vs decisions vs summaries differently (decisions outrank prompts outrank summaries by default). source='migrated'on decisions/code_areas is whatcce sessions migratewrites when it imports JSON.
Runs inside cce serve's asyncio loop. Pseudocode:
async def compression_loop():
while not shutting_down:
try:
row = await db.fetch_oldest_pending()
if row is None:
await asyncio.sleep(5)
continue
try:
if row.kind == "turn":
await compress_turn(row.session_id, row.prompt_number)
else:
await compress_session_rollup(row.session_id)
await db.delete_pending(row.id)
except Exception as exc:
await db.bump_attempts(row.id, str(exc))
except Exception:
log.exception("compression loop iteration failed")
await asyncio.sleep(10)Input: all rows in prompts + tool_events for (session_id, prompt_number)
1. Build candidate sentence list:
- The user prompt (split into sentences)
- For each tool_event:
- Tool name + brief input descriptor (e.g. "Edit cli.py")
- First N sentences of tool_output (capped)
2. Embed each candidate with the bge-small model already loaded.
3. Compute centroid = mean of all candidate embeddings.
4. Pick top-K (default 3) sentences by cosine similarity to centroid.
5. Concatenate in original order, prefixed by "[turn N]".
6. Write to turn_summaries with tier='extractive'.
Concatenate all turn_summaries for the session, run the same
extractive algorithm against that text with K=5. Write to
sessions.rollup_summary.
If at any point the embedder isn't loaded yet (rare; only on extreme cold start), fall back to truncation: first 200 chars of each item, joined.
| Tool | Behaviour |
|---|---|
session_recall(topic) |
Extended. FTS5 search across decisions_fts, prompts_fts, turn_summaries_fts. Decisions weighted highest. Returns top-N hits with {layer: 'index'|'timeline'|'event', id, snippet, session_id}. Backward compatible — the tool name + topic argument are unchanged; the return shape is a strict superset. |
session_timeline(session_id, limit=20) |
New. Returns the session's turn_summaries in order, plus session metadata. Layer 2. |
session_event(event_id) |
New. Returns the raw payload for a single tool_events row (input + output JSON) if still within retention. Layer 3. |
record_decision(decision, reason) |
Unchanged. Writes to decisions with source='manual'. |
record_code_area(file_path, description) |
Unchanged. Writes to code_areas with source='manual'. |
cce sessions migrate (idempotent):
- Locate the per-project DB. If absent, create it (
memory/db.pybootstrap). - Walk
~/.cce/projects/<name>/sessions/*.jsonand the legacy~/.claude-context-engine/projects/<name>/sessions/*.json. - For each JSON file, parse and import:
- decisions →
decisionswithsource='migrated' - code_areas →
code_areaswithsource='migrated' - touched_files counts → ignored (replaced by computed view)
- decisions →
- Skip files already imported (track by source filename in a
migrated_filestable). - After successful import, archive consumed JSON to
~/.cce/projects/<name>/sessions/migrated.zipand remove the source files. Idempotent rerun is a no-op.
Integrated into the existing dashboard at dashboard/_page.py and
dashboard/server.py.
- Sessions list (
/memory). Table: started, ended, prompts, tool-uses, status, rollup summary. Click a row → session timeline. - Session timeline (
/memory/sessions/<id>). Header: session metadata + rollup. Body: ordered turn summaries; each turn expandable to its tool events. Tool events expandable to raw payload (if still within retention). - Decisions search (
/memory/decisions). FTS5 search box, results list withsourcefacet (manual / auto / migrated). Each result links back to its session.
Hot-files panel and queue-health panel are deferred — they're observability nice-to-haves that we'd add once the core lands and we know what fails.
This work ships as 5 sequential PRs off
feature/memory-claude-mem-parity. Each PR is independently reviewable
and leaves the tree in a working state.
| PR | Scope | Notes |
|---|---|---|
| 1. Foundation | memory/db.py schema + memory/migrate.py + cce sessions migrate CLI + tests. |
No behaviour change to existing capture path. |
| 2. Capture | 5 hooks + cce_hook.sh + HTTP endpoints in serve_http.py. Old JSON path retired. |
New writes land in memory.db. |
| 3. Compress | Background asyncio worker in cce serve. Extractive summariser using bge-small. |
Truncation final fallback. |
| 4. Recall | Extended session_recall; new session_timeline, session_event MCP tools. |
Backward compatible at the topic-argument level. |
| 5. Dashboard | 3 panels (sessions list, timeline, decisions search). | Builds on existing dashboard scaffolding. |
- Unit tests for the schema bootstrap (PR 1), migration importer (PR 1), extractive summariser (PR 3), MCP tool routing (PR 4).
- Integration test in PR 2: post a payload to each
/hooks/<name>endpoint, assert the row lands in the correct table. - End-to-end in PR 3: drive a fake session through hooks, wait for the compression worker to drain, assert turn_summaries + rollup are populated with the expected tiers.
- The existing 301-test suite must stay green at every commit.
- Cross-project memory views. Per-project DBs by design; inspecting multiple projects at once means opening multiple dashboards.
- Encrypted memory-at-rest. Defer until/unless a concrete user need.
- Auto-extracting decisions from transcripts (vs. just summarising turns). The compressor only summarises; decisions remain manual MCP input. Auto-decision extraction is a follow-up if extractive turn summaries prove rich enough to mine.
- Web viewer as a separate process. Integrated into the existing CCE dashboard.
- Embedded abstractive LLM (e.g. flan-t5-small). Extractive with bge-small is sufficient for v1. Revisit if turn summaries prove too shallow.