kimetsu-brain 0.5.1

Project + user-scope memory, hybrid retrieval (lexical + cosine), ambient context, secret redaction at ingest for kimetsu.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
//! Embedding backfill (`kimetsu brain reindex`) for v0.4.3.
//!
//! Iterates active memories whose stored `embedding_model` doesn't
//! match the active embedder's `model_id` (including NULL — that's
//! how pre-v0.4.2 rows look) and computes + persists fresh
//! embeddings for them. Walks BOTH the per-project brain.db AND
//! `~/.kimetsu/brain.db` so the user-scope capsules introduced in
//! v0.4.1 get the same treatment.
//!
//! `--dry-run` reports how many rows would be re-embedded without
//! writing.
//! `--force` re-embeds even rows that already have the current
//! model — useful after an OS-level model update where the bytes
//! change but the model_id doesn't.
//!
//! The default Cargo build (no `embeddings` feature) ships
//! `NoopEmbedder` — `reindex_all` returns Ok with `EmbedderNoop`
//! status so the CLI can print a friendly hint rather than
//! silently doing nothing.

use std::path::Path;

use kimetsu_core::KimetsuResult;
use rusqlite::Connection;

use crate::embeddings::{self, Embedder, EmbedderError, encode_embedding};
use crate::project::load_project;
use crate::user_brain::open_user_brain;

/// Per-scope reindex result.
#[derive(Debug, Clone)]
pub struct ScopeReport {
    pub scope: &'static str,
    /// `None` if this DB wasn't opened (e.g. user brain disabled).
    pub opened: bool,
    /// Total active memory rows in this DB.
    pub total: usize,
    /// Rows that need re-embedding (NULL embedding OR
    /// embedding_model != active model OR --force was set).
    pub candidates: usize,
    /// Rows actually updated. `0` in `--dry-run`.
    pub updated: usize,
    /// Rows that failed to re-embed (kept their previous state).
    pub failed: usize,
}

impl ScopeReport {
    fn skipped(scope: &'static str) -> Self {
        Self {
            scope,
            opened: false,
            total: 0,
            candidates: 0,
            updated: 0,
            failed: 0,
        }
    }
}

/// Aggregate result over project + user scopes.
#[derive(Debug, Clone)]
pub struct ReindexReport {
    pub project: ScopeReport,
    pub user: ScopeReport,
    pub embedder_model_id: String,
    pub embedder_noop: bool,
}

impl ReindexReport {
    pub fn updated_total(&self) -> usize {
        self.project.updated + self.user.updated
    }
    pub fn candidates_total(&self) -> usize {
        self.project.candidates + self.user.candidates
    }
}

/// Reindex options. Mirrors the CLI flags 1:1.
#[derive(Debug, Clone, Copy)]
pub struct ReindexOptions {
    pub scope: ReindexScope,
    pub dry_run: bool,
    pub force: bool,
    /// Stop after this many rows. `None` means no cap.
    pub limit: Option<usize>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReindexScope {
    Project,
    User,
    All,
}

impl ReindexScope {
    pub fn parse(value: &str) -> Result<Self, String> {
        match value.trim().to_ascii_lowercase().as_str() {
            "" | "all" => Ok(Self::All),
            "project" | "repo" => Ok(Self::Project),
            "user" | "global" => Ok(Self::User),
            other => Err(format!("unknown reindex scope `{other}`")),
        }
    }
}

impl Default for ReindexOptions {
    fn default() -> Self {
        Self {
            scope: ReindexScope::All,
            dry_run: false,
            force: false,
            limit: None,
        }
    }
}

/// Walk the project DB (if `--scope` includes it) and the user DB
/// (if `--scope` includes it AND user-brain is enabled), re-embedding
/// rows whose stored `embedding_model` doesn't match the active
/// embedder.
pub fn reindex_all(repo_start: &Path, opts: ReindexOptions) -> KimetsuResult<ReindexReport> {
    let embedder = embeddings::open_default_embedder();
    let model_id = embedder.model_id().to_string();
    let noop = embedder.is_noop();

    let mut remaining = opts.limit;
    let mut project_report = ScopeReport::skipped("project");
    let mut user_report = ScopeReport::skipped("user");

    if matches!(opts.scope, ReindexScope::Project | ReindexScope::All) {
        let (_paths, _config, conn) = load_project(repo_start)?;
        project_report = reindex_one_conn(&conn, "project", embedder, &opts, &mut remaining)?;
    }

    if matches!(opts.scope, ReindexScope::User | ReindexScope::All)
        && let Some(user_conn) = open_user_brain()?
    {
        user_report = reindex_one_conn(&user_conn, "user", embedder, &opts, &mut remaining)?;
    }

    Ok(ReindexReport {
        project: project_report,
        user: user_report,
        embedder_model_id: model_id,
        embedder_noop: noop,
    })
}

fn reindex_one_conn(
    conn: &Connection,
    scope: &'static str,
    embedder: &(dyn Embedder + Send + Sync),
    opts: &ReindexOptions,
    remaining: &mut Option<usize>,
) -> KimetsuResult<ScopeReport> {
    // Total active rows (for the friendly "x of y" output).
    let total: i64 = conn.query_row(
        "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
        [],
        |row| row.get(0),
    )?;
    let total = total.max(0) as usize;

    // Find rows that need re-embedding. With NoopEmbedder there's
    // nothing meaningful to do, so we return zeros and let the CLI
    // print a hint.
    if embedder.is_noop() {
        return Ok(ScopeReport {
            scope,
            opened: true,
            total,
            candidates: 0,
            updated: 0,
            failed: 0,
        });
    }

    // Candidate predicate:
    //   force          -> every active row
    //   default        -> rows where embedding_model is NULL OR != active model
    // NULL captures both "never embedded" and "embedded with a model
    // that didn't bother to record an id".
    let model_id = embedder.model_id().to_string();
    let mut stmt = if opts.force {
        conn.prepare(
            "
            SELECT memory_id, text
            FROM memories
            WHERE invalidated_at IS NULL
            ORDER BY created_at ASC
            ",
        )?
    } else {
        conn.prepare(
            "
            SELECT memory_id, text
            FROM memories
            WHERE invalidated_at IS NULL
              AND (embedding_model IS NULL OR embedding_model != ?1)
            ORDER BY created_at ASC
            ",
        )?
    };

    // SQLite prepared statements bind by index; build the row
    // iterator with the matching params signature.
    let mut rows = if opts.force {
        stmt.query([])?
    } else {
        stmt.query(rusqlite::params![model_id])?
    };

    let mut candidates = 0usize;
    let mut updated = 0usize;
    let mut failed = 0usize;
    while let Some(row) = rows.next()? {
        if remaining.map(|r| r == 0).unwrap_or(false) {
            break;
        }
        candidates += 1;
        let memory_id: String = row.get(0)?;
        let text: String = row.get(1)?;
        if opts.dry_run {
            continue;
        }
        match embedder.embed(&text) {
            Ok(vec) if vec.len() == embedder.dim() => {
                conn.execute(
                    "UPDATE memories SET embedding = ?1, embedding_model = ?2 WHERE memory_id = ?3",
                    rusqlite::params![encode_embedding(&vec), embedder.model_id(), memory_id],
                )?;
                updated += 1;
                if let Some(r) = remaining {
                    *r = r.saturating_sub(1);
                }
            }
            Ok(_) => {
                failed += 1;
            }
            Err(EmbedderError::NotImplemented) => {
                // Embedder degraded to noop mid-run (unusual but
                // possible if the model unloaded). Record as failed
                // so the caller can surface the partial-progress.
                failed += 1;
            }
            Err(_) => {
                failed += 1;
            }
        }
    }

    Ok(ScopeReport {
        scope,
        opened: true,
        total,
        candidates,
        updated,
        failed,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::embeddings::{StubEmbedder, encode_embedding};
    use crate::user_brain::with_user_brain_disabled;

    #[test]
    fn reindex_scope_parser_accepts_aliases() {
        assert_eq!(ReindexScope::parse("project").unwrap(), ReindexScope::Project);
        assert_eq!(ReindexScope::parse("repo").unwrap(), ReindexScope::Project);
        assert_eq!(ReindexScope::parse("user").unwrap(), ReindexScope::User);
        assert_eq!(ReindexScope::parse("global").unwrap(), ReindexScope::User);
        assert_eq!(ReindexScope::parse("all").unwrap(), ReindexScope::All);
        assert_eq!(ReindexScope::parse("").unwrap(), ReindexScope::All);
        assert!(ReindexScope::parse("nope").is_err());
    }

    /// v0.4.3: `reindex_one_conn` with an explicit StubEmbedder
    /// finds NULL-embedding rows and back-fills them. Tests the
    /// SQL + walker logic directly without going through the
    /// process-static `open_default_embedder` cache.
    #[test]
    fn reindex_one_conn_backfills_null_embeddings() {
        with_user_brain_disabled(|| {
            let conn = rusqlite::Connection::open_in_memory().expect("open");
            crate::schema::initialize(&conn).expect("init");

            // Insert two memories: one without embedding, one with
            // a stale model id. Both should be candidates.
            conn.execute(
                "
                INSERT INTO memories (
                    memory_id, scope, kind, text, normalized_text, confidence,
                    source_event_id, provenance_snapshot_json, created_at,
                    use_count, usefulness_score
                )
                VALUES ('m_a', 'repo', 'fact', 'use rg', 'use rg', 1.0,
                        NULL, '{}', '2026-05-01T00:00:00Z', 0, 0.0)
                ",
                [],
            )
            .expect("insert m_a");
            conn.execute(
                "
                INSERT INTO memories (
                    memory_id, scope, kind, text, normalized_text, confidence,
                    source_event_id, provenance_snapshot_json, created_at,
                    use_count, usefulness_score, embedding, embedding_model
                )
                VALUES ('m_b', 'repo', 'fact', 'use ripgrep', 'use ripgrep', 1.0,
                        NULL, '{}', '2026-05-02T00:00:00Z', 0, 0.0,
                        ?1, 'old-model-id')
                ",
                rusqlite::params![encode_embedding(&[0.0f32; 4])],
            )
            .expect("insert m_b");

            let stub = StubEmbedder::new();
            let mut remaining = None;
            let report = reindex_one_conn(
                &conn,
                "project",
                &stub,
                &ReindexOptions::default(),
                &mut remaining,
            )
            .expect("reindex");

            assert_eq!(report.total, 2);
            assert_eq!(report.candidates, 2, "both rows should be candidates");
            assert_eq!(report.updated, 2, "both should be updated");
            assert_eq!(report.failed, 0);

            // Confirm rows now carry the stub's model id and a
            // properly-sized blob.
            for memory_id in ["m_a", "m_b"] {
                let model: String = conn
                    .query_row(
                        "SELECT embedding_model FROM memories WHERE memory_id = ?1",
                        rusqlite::params![memory_id],
                        |row| row.get(0),
                    )
                    .expect("fetch model");
                assert_eq!(model, stub.model_id());
                let blob: Vec<u8> = conn
                    .query_row(
                        "SELECT embedding FROM memories WHERE memory_id = ?1",
                        rusqlite::params![memory_id],
                        |row| row.get(0),
                    )
                    .expect("fetch blob");
                assert_eq!(blob.len(), stub.dim() * 4, "stub-d8 -> 8 floats -> 32 bytes");
            }
        });
    }

    /// `--dry-run` reports candidates without writing.
    #[test]
    fn reindex_one_conn_dry_run_does_not_mutate() {
        with_user_brain_disabled(|| {
            let conn = rusqlite::Connection::open_in_memory().expect("open");
            crate::schema::initialize(&conn).expect("init");
            conn.execute(
                "
                INSERT INTO memories (
                    memory_id, scope, kind, text, normalized_text, confidence,
                    source_event_id, provenance_snapshot_json, created_at,
                    use_count, usefulness_score
                )
                VALUES ('m_a', 'repo', 'fact', 'use rg', 'use rg', 1.0,
                        NULL, '{}', '2026-05-01T00:00:00Z', 0, 0.0)
                ",
                [],
            )
            .expect("insert");

            let stub = StubEmbedder::new();
            let mut remaining = None;
            let report = reindex_one_conn(
                &conn,
                "project",
                &stub,
                &ReindexOptions {
                    dry_run: true,
                    ..ReindexOptions::default()
                },
                &mut remaining,
            )
            .expect("dry-run");

            assert_eq!(report.candidates, 1);
            assert_eq!(report.updated, 0, "dry-run must not write");
            let model: Option<String> = conn
                .query_row(
                    "SELECT embedding_model FROM memories WHERE memory_id = 'm_a'",
                    [],
                    |row| row.get(0),
                )
                .expect("fetch");
            assert!(model.is_none(), "embedding_model should still be NULL");
        });
    }

    /// With a NoopEmbedder the walker returns candidates=0 + a clear
    /// note via the `embedder_noop` flag — lets the CLI print a
    /// hint instead of silently doing nothing.
    #[test]
    fn reindex_one_conn_with_noop_embedder_returns_zero_candidates() {
        with_user_brain_disabled(|| {
            let conn = rusqlite::Connection::open_in_memory().expect("open");
            crate::schema::initialize(&conn).expect("init");
            conn.execute(
                "
                INSERT INTO memories (
                    memory_id, scope, kind, text, normalized_text, confidence,
                    source_event_id, provenance_snapshot_json, created_at,
                    use_count, usefulness_score
                )
                VALUES ('m_a', 'repo', 'fact', 'use rg', 'use rg', 1.0,
                        NULL, '{}', '2026-05-01T00:00:00Z', 0, 0.0)
                ",
                [],
            )
            .expect("insert");

            let noop = embeddings::NoopEmbedder;
            let mut remaining = None;
            let report = reindex_one_conn(
                &conn,
                "project",
                &noop,
                &ReindexOptions::default(),
                &mut remaining,
            )
            .expect("noop reindex");

            assert_eq!(report.total, 1);
            assert_eq!(report.candidates, 0, "noop should walk zero candidates");
            assert_eq!(report.updated, 0);
        });
    }

    /// `--force` re-embeds even rows that already carry the active
    /// model id.
    #[test]
    fn reindex_one_conn_force_reembeds_current_model_rows() {
        with_user_brain_disabled(|| {
            let conn = rusqlite::Connection::open_in_memory().expect("open");
            crate::schema::initialize(&conn).expect("init");
            let stub = StubEmbedder::new();
            // Pre-populate a row with the stub's current id AND a
            // bogus zero-vector embedding so we can verify `--force`
            // overwrites it.
            conn.execute(
                "
                INSERT INTO memories (
                    memory_id, scope, kind, text, normalized_text, confidence,
                    source_event_id, provenance_snapshot_json, created_at,
                    use_count, usefulness_score, embedding, embedding_model
                )
                VALUES ('m_a', 'repo', 'fact', 'use rg', 'use rg', 1.0,
                        NULL, '{}', '2026-05-01T00:00:00Z', 0, 0.0,
                        ?1, ?2)
                ",
                rusqlite::params![encode_embedding(&[0.0f32; 8]), stub.model_id()],
            )
            .expect("insert pre-stamped row");

            let mut remaining = None;
            // Without --force: zero candidates (already on current model).
            let plain = reindex_one_conn(
                &conn,
                "project",
                &stub,
                &ReindexOptions::default(),
                &mut remaining,
            )
            .expect("plain reindex");
            assert_eq!(plain.candidates, 0);

            // With --force: one candidate, gets re-embedded.
            let forced = reindex_one_conn(
                &conn,
                "project",
                &stub,
                &ReindexOptions {
                    force: true,
                    ..ReindexOptions::default()
                },
                &mut remaining,
            )
            .expect("forced reindex");
            assert_eq!(forced.candidates, 1);
            assert_eq!(forced.updated, 1);

            // The embedding must be non-zero now (stub produces a
            // hash-bucket vector for "use rg").
            let blob: Vec<u8> = conn
                .query_row(
                    "SELECT embedding FROM memories WHERE memory_id = 'm_a'",
                    [],
                    |row| row.get(0),
                )
                .expect("fetch blob");
            assert!(
                blob.iter().any(|&b| b != 0),
                "force should have overwritten the zero embedding"
            );
        });
    }
}