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 add_column_if_missing(conn, "memory_proposals", "decided_reason TEXT")?;
127 add_column_if_missing(
133 conn,
134 "memories",
135 "usefulness_score REAL NOT NULL DEFAULT 0.0",
136 )?;
137 add_column_if_missing(conn, "memories", "invalidated_at TEXT")?;
141 add_column_if_missing(conn, "memories", "invalidated_reason TEXT")?;
142 add_column_if_missing(conn, "memories", "embedding BLOB")?;
152 add_column_if_missing(conn, "memories", "embedding_model TEXT")?;
153 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 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 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}