Skip to main content

kimetsu_brain/
schema.rs

1use rusqlite::Connection;
2
3use kimetsu_core::{KIMETSU_SCHEMA_VERSION, KimetsuResult};
4
5pub fn initialize(conn: &Connection) -> KimetsuResult<()> {
6    conn.pragma_update(None, "journal_mode", "WAL")?;
7    conn.pragma_update(None, "busy_timeout", 5_000)?;
8
9    conn.execute_batch(
10        "
11        CREATE TABLE IF NOT EXISTS schema_info (
12            key TEXT PRIMARY KEY,
13            value INTEGER NOT NULL
14        );
15
16        INSERT OR IGNORE INTO schema_info (key, value)
17        VALUES ('kimetsu_schema_version', 1);
18
19        CREATE TABLE IF NOT EXISTS runs (
20            run_id TEXT PRIMARY KEY,
21            project_id TEXT NOT NULL,
22            task TEXT NOT NULL,
23            started_at TEXT NOT NULL,
24            ended_at TEXT,
25            terminal_kind TEXT,
26            model TEXT,
27            total_cost_usd REAL NOT NULL DEFAULT 0
28        );
29
30        CREATE TABLE IF NOT EXISTS events (
31            event_id TEXT PRIMARY KEY,
32            run_id TEXT NOT NULL,
33            ts TEXT NOT NULL,
34            kind TEXT NOT NULL,
35            schema_version INTEGER NOT NULL,
36            payload_json TEXT NOT NULL
37        );
38
39        CREATE INDEX IF NOT EXISTS idx_events_run_ts ON events (run_id, ts);
40        CREATE INDEX IF NOT EXISTS idx_events_kind_ts ON events (kind, ts);
41
42        CREATE TABLE IF NOT EXISTS sources (
43            source_id TEXT PRIMARY KEY,
44            kind TEXT NOT NULL,
45            ref TEXT NOT NULL,
46            hash TEXT,
47            added_at TEXT NOT NULL
48        );
49
50        CREATE TABLE IF NOT EXISTS memories (
51            memory_id TEXT PRIMARY KEY,
52            scope TEXT NOT NULL,
53            kind TEXT NOT NULL,
54            text TEXT NOT NULL,
55            normalized_text TEXT NOT NULL,
56            confidence REAL NOT NULL,
57            source_event_id TEXT,
58            provenance_snapshot_json TEXT NOT NULL,
59            created_at TEXT NOT NULL,
60            last_used_at TEXT,
61            use_count INTEGER NOT NULL DEFAULT 0,
62            usefulness_score REAL NOT NULL DEFAULT 0.0,
63            invalidated_at TEXT,
64            invalidated_reason TEXT
65        );
66
67        CREATE INDEX IF NOT EXISTS idx_memories_scope_kind_norm
68            ON memories (scope, kind, normalized_text);
69        CREATE TABLE IF NOT EXISTS memory_proposals (
70            proposal_id TEXT PRIMARY KEY,
71            run_id TEXT NOT NULL,
72            scope TEXT NOT NULL,
73            kind TEXT NOT NULL,
74            text TEXT NOT NULL,
75            rationale TEXT NOT NULL,
76            proposed_confidence REAL NOT NULL,
77            source_event_ids_json TEXT NOT NULL,
78            status TEXT NOT NULL,
79            decided_at TEXT,
80            decided_by TEXT,
81            decided_reason TEXT
82        );
83
84        CREATE INDEX IF NOT EXISTS idx_memory_proposals_status_run
85            ON memory_proposals (status, run_id);
86
87        CREATE TABLE IF NOT EXISTS repo_files (
88            repo_root TEXT NOT NULL,
89            path TEXT NOT NULL,
90            hash TEXT NOT NULL,
91            size INTEGER NOT NULL,
92            mtime TEXT NOT NULL,
93            language_guess TEXT NOT NULL,
94            snippet TEXT NOT NULL,
95            PRIMARY KEY (repo_root, path)
96        );
97
98        CREATE INDEX IF NOT EXISTS idx_repo_files_language
99            ON repo_files (repo_root, language_guess);
100
101        CREATE TABLE IF NOT EXISTS repo_manifests (
102            repo_root TEXT NOT NULL,
103            manifest_path TEXT NOT NULL,
104            manifest_kind TEXT NOT NULL,
105            parsed_summary_json TEXT NOT NULL,
106            hash TEXT NOT NULL,
107            mtime TEXT NOT NULL,
108            PRIMARY KEY (repo_root, manifest_path)
109        );
110
111        CREATE VIRTUAL TABLE IF NOT EXISTS repo_files_fts
112            USING fts5(repo_root, path, snippet, language_guess);
113
114        CREATE VIRTUAL TABLE IF NOT EXISTS repo_manifests_fts
115            USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
116
117        CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
118            USING fts5(memory_id UNINDEXED, text, kind, scope);
119        ",
120    )?;
121
122    // In-place column additions for v0.1 brain.db files predating each
123    // column. Each ALTER is idempotent: we ignore the duplicate-column error
124    // so an upgraded binary opens an older brain.db without forcing a
125    // `kimetsu brain rebuild`.
126    add_column_if_missing(conn, "memory_proposals", "decided_reason TEXT")?;
127    // MP-4a: usefulness_score tracks the net outcome correlation of each
128    // memory. Incremented when a memory was in the context of a run.finished
129    // event; decremented for run.failed with category != "Gate". Used by the
130    // broker (MP-4b) to bias retrieval and by auto-accept (MP-4c) to shadow
131    // re-acceptance of low-usefulness patterns.
132    add_column_if_missing(
133        conn,
134        "memories",
135        "usefulness_score REAL NOT NULL DEFAULT 0.0",
136    )?;
137    // MP-4d: invalidated_at is set by `kimetsu brain memory invalidate` so
138    // the human reviewer can permanently retire a memory without rewriting
139    // the trace. The broker excludes invalidated rows from retrieval.
140    add_column_if_missing(conn, "memories", "invalidated_at TEXT")?;
141    add_column_if_missing(conn, "memories", "invalidated_reason TEXT")?;
142    // v0.4.2: hybrid retrieval scaffolding.
143    //   * `embedding`        — little-endian f32 BLOB, NULL on pre-v0.4.2 rows
144    //   * `embedding_model`  — opaque model id ("bge-small-en-v1.5",
145    //                          "stub-d8", "noop"), NULL when no embedding
146    //                          was produced (e.g. NoopEmbedder).
147    // Retrieval reads both: when `embedding` is non-NULL AND
148    // `embedding_model` matches the active embedder's id, the cosine
149    // score contributes to ranking. Otherwise the row is scored
150    // lexical-only (FTS) — exact v0.4.1 behavior, no regression.
151    add_column_if_missing(conn, "memories", "embedding BLOB")?;
152    add_column_if_missing(conn, "memories", "embedding_model TEXT")?;
153    // v0.5.1: timestamp of the most recent time this memory was
154    // cited AND the citing run ended in run.finished. Used by the
155    // broker's decay term: `effective = base * exp(-ln(2) *
156    // age_days / half_life)` so a memory that helped 6 months ago
157    // doesn't outvote one that helped yesterday.
158    //
159    // Distinct from `last_used_at` (bumped on every retrieval) —
160    // `last_useful_at` only tracks confirmed successful uses
161    // attributed via the v0.5.0 cite_memory tool.
162    //
163    // NULL on pre-v0.5.1 rows + on memories that have never been
164    // cited successfully. Retrieval falls back to `created_at` for
165    // the decay reference timestamp so brand-new memories don't
166    // get penalized for never having been cited yet.
167    add_column_if_missing(conn, "memories", "last_useful_at TEXT")?;
168    conn.execute_batch(
169        "
170        CREATE INDEX IF NOT EXISTS idx_memories_active_created
171            ON memories (invalidated_at, created_at);
172        ",
173    )?;
174    // v0.5.1: per-run, per-turn memory citation log.
175    //
176    // The model emits a `memory.cited` event (via the `cite_memory`
177    // tool) when it consciously leveraged a retrieved capsule. The
178    // projector mirrors each event into this table so the
179    // `kimetsu brain memory blame <run-id>` CLI + MCP tool can
180    // walk attribution without re-scanning the `events` table.
181    //
182    // Multiple citations per turn are allowed (a turn can use
183    // several memories). The PK includes `turn` so re-cites of the
184    // same memory across turns don't collide.
185    //
186    // Usefulness scoring upgrade (v0.5.1 sibling change in
187    // `projector::apply_run_finished` / `apply_run_failed`): cited
188    // memories get the full +/-1 delta; retrieved-but-not-cited
189    // memories get a weaker +/-0.1 — the strong signal goes to
190    // memories the model actually reasoned with, the weak signal
191    // stays for the silent passengers.
192    conn.execute_batch(
193        "
194        CREATE TABLE IF NOT EXISTS memory_citations (
195            run_id     TEXT NOT NULL,
196            memory_id  TEXT NOT NULL,
197            turn       INTEGER NOT NULL,
198            cited_at   TEXT NOT NULL,
199            rationale  TEXT,
200            PRIMARY KEY (run_id, memory_id, turn)
201        );
202        CREATE INDEX IF NOT EXISTS idx_citations_run
203            ON memory_citations (run_id);
204        CREATE INDEX IF NOT EXISTS idx_citations_memory
205            ON memory_citations (memory_id);
206        ",
207    )?;
208    // v0.5.2: conflict-detection log. When `add_memory` (or
209    // `add_user_memory`) inserts a new capsule whose embedding is
210    // close to an existing capsule in the same scope but whose
211    // normalized text differs, the conflict is logged here for
212    // operator review via `kimetsu brain memory conflicts`.
213    //
214    // We use `INSERT OR IGNORE` on (new_memory_id,
215    // existing_memory_id) so a re-scan over the same pair stays
216    // idempotent. `resolved_at` IS NULL marks an open conflict;
217    // `resolution` stores `'kept_new'`, `'kept_existing'`, or
218    // `'kept_both'` after operator decision.
219    //
220    // Embedder-only: conflict detection runs ONLY when a real
221    // embedder is available (cosine math requires it). NoopEmbedder
222    // builds silently skip the scan and never write to this table,
223    // so pre-v0.5.2 brain.db files opened by a lean build see no
224    // new rows.
225    conn.execute_batch(
226        "
227        CREATE TABLE IF NOT EXISTS memory_conflicts (
228            conflict_id        TEXT PRIMARY KEY,
229            new_memory_id      TEXT NOT NULL,
230            existing_memory_id TEXT NOT NULL,
231            scope              TEXT NOT NULL,
232            kind               TEXT NOT NULL,
233            similarity         REAL NOT NULL,
234            detected_at        TEXT NOT NULL,
235            resolved_at        TEXT,
236            resolution         TEXT,
237            UNIQUE (new_memory_id, existing_memory_id)
238        );
239        CREATE INDEX IF NOT EXISTS idx_conflicts_unresolved
240            ON memory_conflicts (resolved_at, detected_at);
241        CREATE INDEX IF NOT EXISTS idx_conflicts_new_memory
242            ON memory_conflicts (new_memory_id);
243        ",
244    )?;
245    ensure_memories_fts_shape(conn)?;
246    ensure_repo_manifests_fts_shape(conn)?;
247
248    let schema_version: i64 = conn.query_row(
249        "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
250        [],
251        |row| row.get(0),
252    )?;
253
254    if schema_version != KIMETSU_SCHEMA_VERSION {
255        return Err(format!(
256            "brain.db schema version {schema_version} does not match expected {KIMETSU_SCHEMA_VERSION}; run `kimetsu brain rebuild`"
257        )
258        .into());
259    }
260
261    Ok(())
262}
263
264pub fn validate(conn: &Connection) -> KimetsuResult<()> {
265    let schema_version: i64 = conn.query_row(
266        "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
267        [],
268        |row| row.get(0),
269    )?;
270
271    if schema_version != KIMETSU_SCHEMA_VERSION {
272        return Err(format!(
273            "brain.db schema version {schema_version} does not match expected {KIMETSU_SCHEMA_VERSION}; run `kimetsu brain rebuild`"
274        )
275        .into());
276    }
277
278    Ok(())
279}
280
281fn add_column_if_missing(conn: &Connection, table: &str, column_def: &str) -> KimetsuResult<()> {
282    let column_name = column_def
283        .split_whitespace()
284        .next()
285        .ok_or("empty column definition")?;
286    let exists: bool = {
287        let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
288        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
289        let mut found = false;
290        for row in rows {
291            if row? == column_name {
292                found = true;
293                break;
294            }
295        }
296        found
297    };
298    if !exists {
299        conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column_def};"))?;
300    }
301    Ok(())
302}
303
304fn ensure_memories_fts_shape(conn: &Connection) -> KimetsuResult<()> {
305    if table_has_column(conn, "memories_fts", "memory_id")? {
306        return Ok(());
307    }
308    conn.execute_batch(
309        "
310        DROP TABLE IF EXISTS memories_fts;
311        CREATE VIRTUAL TABLE memories_fts
312            USING fts5(memory_id UNINDEXED, text, kind, scope);
313        INSERT INTO memories_fts (memory_id, text, kind, scope)
314            SELECT memory_id, text, kind, scope FROM memories;
315        ",
316    )?;
317    Ok(())
318}
319
320fn ensure_repo_manifests_fts_shape(conn: &Connection) -> KimetsuResult<()> {
321    if table_has_column(conn, "repo_manifests_fts", "parsed_summary_json")? {
322        return Ok(());
323    }
324    conn.execute_batch(
325        "
326        DROP TABLE IF EXISTS repo_manifests_fts;
327        CREATE VIRTUAL TABLE repo_manifests_fts
328            USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
329        INSERT INTO repo_manifests_fts (
330            repo_root, manifest_path, manifest_kind, parsed_summary_json
331        )
332            SELECT repo_root, manifest_path, manifest_kind, parsed_summary_json
333            FROM repo_manifests;
334        ",
335    )?;
336    Ok(())
337}
338
339fn table_has_column(conn: &Connection, table: &str, column: &str) -> KimetsuResult<bool> {
340    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
341    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
342    for row in rows {
343        if row? == column {
344            return Ok(true);
345        }
346    }
347    Ok(false)
348}