Wave A — Functional ingest fix (root cause of empty Sleep reports):
- Rewrote TraceLine struct to match real Claude Code trace JSONL:
type (was kind), timestamp ISO8601 (was epoch ts), message Object,
cwd / gitBranch / parentUuid / uuid / subtype / toolUseID / toolUseResult
- New src/extract.rs: extract_tool_uses + extract_tool_result walks
message.content[] for nested tool_use / tool_result blocks
- New src/classifier.rs: explicit table classifier (tool_error, user_correction,
retry_loop, permission_denied, tool_use:<name>, ...) replaces shallow heuristic
- New src/error.rs: KeiMemoryError enum (IO/Parse/Db) replaces semantic
mismatch where IO error was wrapped as rusqlite::InvalidParameterName
- New src/trace_line.rs: TraceLine + helpers (cube extraction)
- Schema migration v3: events.cwd column + 3 hot-query indices
(events.tool, events.file_path, events.ts) + UNIQUE on patterns
- New tests/ingest_real_trace.rs: synth-fixture asserts tool/file/cwd/class extraction
Wave B — Lib crate split:
- Cargo.toml: [lib] target added alongside existing [[bin]]
- src/lib.rs: pub re-export of all 18 modules
- src/main.rs: 11 mod declarations replaced by single use kei_memory::{…}
- tests/integration.rs: #[path] hack replaced by use kei_memory::{…}
Wave C — TF-IDF dedup + single-JOIN + filter_map fix:
- Schema migration v2: tokens.idf_dirty column + flag-based dedup
- index_document no longer triggers per-call recompute_idf rebuild
- top_similar uses single JOIN via vectors_for_overlapping_sessions helper
(was N round-trips, one session_vector per candidate)
- All filter_map(|r| r.ok()) row-error swallowing replaced with ? propagation
- New tests/tfidf_idf_dedup.rs: 4 tests covering dedup behaviour, IDF emptiness,
JOIN-pruning, empty-query safety
Wave D — Commands split + nits:
- New src/dump.rs (43 LOC) + src/stats.rs (33 LOC):
CLI renderers extracted from commands.rs (was inline SQL + format)
- src/commands.rs: thin wrappers, -42 LOC
- src/injection_guard.rs: inline tests removed (-26 LOC), file under 200 LOC threshold
- tests/injection_guard_unit.rs (new): 4 tests in proper integration crate
- src/patterns.rs: INSERT replaced with INSERT...ON CONFLICT...DO UPDATE
(idempotent re-ingest, uses Wave A's UNIQUE index)
- src/analyze.rs + src/coaccess.rs: filter_map row-error fixes
- src/coaccess.rs: misleading PK comment rewritten
Verify-before-commit (RULE 0.13 §"Verify-before-commit"):
- cargo check --all-targets: PASS (1 unrelated dead-code warning)
- cargo test: 42 passed, 0 failed across 9 test binaries
- STATUS-TRUTH markers aggregated at .claude/agents/_merge/kei-memory-2026-05-01/
Architect-spotted ARCH-MAJOR + ARCH-MINOR + ARCH-NIT findings addressed:
- ARCH-MAJOR Cargo.toml binary-only (Wave B)
- ARCH-MAJOR schema missing indices (Wave A v3)
- ARCH-MAJOR ingest_jsonl choke point (Wave A — extract.rs + classifier.rs)
- ARCH-MAJOR idf O(N·V) per-call rebuild (Wave C)
- ARCH-MINOR patterns no UPSERT (Wave D)
- ARCH-MINOR commands.rs houses dump+stats (Wave D)
- ARCH-MINOR classifier silent contract (Wave A)
- ARCH-MINOR IO error wrapped as rusqlite (Wave A)
- ARCH-MINOR injection_guard inline tests (Wave D)
- ARCH-MINOR tfidf top_similar N round-trips (Wave C)
- ARCH-NIT 3× filter_map(|r| r.ok()) sites (Wave C + D)
- ARCH-NIT coaccess misleading comment (Wave D)
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
cargo-test: PASS (42 tests, 0 failures)
behaviour-verified: yes
follow-up-required:
- tests/ingest_guard_tests.rs + tests/guard_test_corpus.rs still on #[path] hack (Wave B follow-up note, ~5 LOC)
- dead_code warning Severity::Warn unused (pre-existing, not blocking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
3.5 KiB
Rust
117 lines
3.5 KiB
Rust
//! kei-memory — offline session analyzer (binary entrypoint).
|
|
//!
|
|
//! Constructor Pattern: main.rs only dispatches; work lives in cubes.
|
|
//! Storage: `~/.claude/memory/kei-memory.sqlite` (or $KEI_MEMORY_DB).
|
|
//! RULE 0.14 — session self-audit, silent-first until 10 sessions ingested.
|
|
|
|
use kei_memory::{backlog, commands, schema};
|
|
|
|
use clap::{Parser, Subcommand};
|
|
use rusqlite::Connection;
|
|
use std::path::PathBuf;
|
|
use std::process::ExitCode;
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "kei-memory", version, about = "Offline session retrospective (RULE 0.14)")]
|
|
struct Cli {
|
|
/// Override DB path (default: $KEI_MEMORY_DB or ~/.claude/memory/kei-memory.sqlite)
|
|
#[arg(long)]
|
|
db: Option<PathBuf>,
|
|
#[command(subcommand)]
|
|
cmd: Cmd,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Cmd {
|
|
/// Read a JSONL transcript and insert session + events.
|
|
Ingest {
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
transcript: PathBuf,
|
|
#[arg(long)]
|
|
prompt: Option<String>,
|
|
},
|
|
/// Print a retrospective for a session or the last N sessions.
|
|
Analyze {
|
|
#[arg(long)]
|
|
session: Option<String>,
|
|
#[arg(long, default_value_t = 1)]
|
|
last: usize,
|
|
#[arg(long)]
|
|
summary: bool,
|
|
},
|
|
/// List recurring event-class patterns.
|
|
Patterns {
|
|
#[arg(long)]
|
|
cross_session: bool,
|
|
#[arg(long)]
|
|
session: Option<String>,
|
|
},
|
|
/// Top-k past sessions by TF-IDF cosine similarity to the query text.
|
|
Similar {
|
|
prompt: String,
|
|
#[arg(long, default_value_t = 5)]
|
|
limit: usize,
|
|
},
|
|
/// Dump a session's events as markdown to stdout.
|
|
Dump { session_id: String },
|
|
/// N sessions, N events, top tools.
|
|
Stats,
|
|
/// Manage the silent-first audit backlog items.
|
|
Backlog {
|
|
#[arg(long)]
|
|
add: Option<String>,
|
|
#[arg(long)]
|
|
list: bool,
|
|
#[arg(long)]
|
|
clear: bool,
|
|
},
|
|
}
|
|
|
|
fn db_path(cli_db: Option<PathBuf>) -> PathBuf {
|
|
if let Some(p) = cli_db {
|
|
return p;
|
|
}
|
|
if let Ok(e) = std::env::var("KEI_MEMORY_DB") {
|
|
return PathBuf::from(e);
|
|
}
|
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
|
PathBuf::from(home).join(".claude/memory/kei-memory.sqlite")
|
|
}
|
|
|
|
fn open_db(path: &PathBuf) -> rusqlite::Result<Connection> {
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let conn = Connection::open(path)?;
|
|
schema::migrate(&conn)?;
|
|
Ok(conn)
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
let cli = Cli::parse();
|
|
let path = db_path(cli.db);
|
|
let conn = match open_db(&path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("kei-memory: open {}: {e}", path.display());
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
match cli.cmd {
|
|
Cmd::Ingest { session_id, transcript, prompt } => {
|
|
commands::cmd_ingest(&conn, &session_id, &transcript, prompt)
|
|
}
|
|
Cmd::Analyze { session, last, summary } => {
|
|
commands::cmd_analyze(&conn, session, last, summary)
|
|
}
|
|
Cmd::Patterns { cross_session, session } => {
|
|
commands::cmd_patterns(&conn, cross_session, session)
|
|
}
|
|
Cmd::Similar { prompt, limit } => commands::cmd_similar(&conn, &prompt, limit),
|
|
Cmd::Dump { session_id } => commands::cmd_dump(&conn, &session_id),
|
|
Cmd::Stats => commands::cmd_stats(&conn),
|
|
Cmd::Backlog { add, list, clear } => backlog::cmd_backlog(&conn, add, list, clear),
|
|
}
|
|
}
|