@davesheffer/hunch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The JSON source of truth under .hunch/. One file per entity (human-reviewable
3
+ * in PRs, diffable, mergeable). This layer never touches SQLite — it is the
4
+ * authoritative read/write surface; SQLite is rebuilt from it.
5
+ */
6
+ import { mkdirSync, readdirSync, readFileSync, existsSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
9
+ import { migrateRaw, readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
10
+ import { writeFileAtomic } from "../core/io.js";
11
+ /** High-cardinality collections (symbols, edges) are stored as a single
12
+ * index.json array — there can be thousands, and one file per edge would create
13
+ * enormous git noise. Curated, low-volume entities (components, decisions, bugs,
14
+ * constraints) are one file per record so they're cleanly reviewable in PRs. */
15
+ const SINGLE_FILE = { symbols: "index.json", edges: "index.json" };
16
+ const encode = (v) => JSON.stringify(v, null, 2) + "\n";
17
+ export class JsonStore {
18
+ paths;
19
+ _warnedForward = false;
20
+ constructor(paths) {
21
+ this.paths = paths;
22
+ }
23
+ /** Create .hunch/<kind>/ directories. Stamp the manifest at the CURRENT version
24
+ * only when scaffolding a FRESH .hunch/ (so `hunch index`/`sync` on a brand-new
25
+ * repo records the version too, not just `init`). A pre-existing .hunch/ without
26
+ * a manifest is LEGACY — left unstamped so it defaults to the baseline and
27
+ * `hunch migrate` upgrades it. */
28
+ ensureDirs() {
29
+ const fresh = !existsSync(this.paths.hunch);
30
+ mkdirSync(this.paths.hunch, { recursive: true });
31
+ for (const kind of ENTITY_KINDS)
32
+ mkdirSync(this.paths.dir(kind), { recursive: true });
33
+ if (fresh && !existsSync(this.paths.manifest))
34
+ writeManifest(this.paths, SCHEMA_VERSION);
35
+ }
36
+ /** The on-disk schema version (from the manifest), read FRESH each call so a
37
+ * long-lived process (the MCP server) reflects an out-of-band `hunch migrate`. */
38
+ schemaVersion() {
39
+ const v = readManifest(this.paths).schema_version;
40
+ if (v > SCHEMA_VERSION && !this._warnedForward) {
41
+ this._warnedForward = true;
42
+ console.warn(`[hunch] .hunch/ was written by a newer schema (v${v} > v${SCHEMA_VERSION}); ` +
43
+ `records may not load correctly — upgrade hunch.`);
44
+ }
45
+ return v;
46
+ }
47
+ /** Migrate one raw record UP to the current schema before validation, so a
48
+ * schema bump never makes the loader silently skip (drop) old records. The
49
+ * on-disk `version` is read ONCE per load (not per record) by the caller. */
50
+ migrate(kind, raw, version) {
51
+ return migrateRaw(kind, raw, version);
52
+ }
53
+ fileFor(kind, id) {
54
+ const single = SINGLE_FILE[kind];
55
+ if (single)
56
+ return join(this.paths.dir(kind), single);
57
+ return join(this.paths.dir(kind), `${id}.json`);
58
+ }
59
+ /** Load every record of a kind, validated against its schema. Invalid records
60
+ * are skipped with a warning rather than crashing the whole load. */
61
+ loadAll(kind) {
62
+ const dir = this.paths.dir(kind);
63
+ if (!existsSync(dir))
64
+ return [];
65
+ const schema = SCHEMAS[kind];
66
+ const out = [];
67
+ const version = this.schemaVersion(); // read the manifest ONCE per load, not per record
68
+ const single = SINGLE_FILE[kind];
69
+ if (single) {
70
+ const f = join(dir, single);
71
+ if (!existsSync(f))
72
+ return [];
73
+ let arr;
74
+ try {
75
+ arr = JSON.parse(readFileSync(f, "utf8"));
76
+ }
77
+ catch (e) {
78
+ console.warn(`[hunch] skipping corrupt ${kind}/${single}: ${e.message}`);
79
+ return out;
80
+ }
81
+ for (const raw of Array.isArray(arr) ? arr : []) {
82
+ const r = schema.safeParse(this.migrate(kind, raw, version));
83
+ if (r.success)
84
+ out.push(r.data);
85
+ else
86
+ console.warn(`[hunch] skipping invalid ${kind} record: ${r.error.issues[0]?.message}`);
87
+ }
88
+ return out;
89
+ }
90
+ for (const name of readdirSync(dir)) {
91
+ if (!name.endsWith(".json"))
92
+ continue;
93
+ let raw;
94
+ try {
95
+ raw = JSON.parse(readFileSync(join(dir, name), "utf8"));
96
+ }
97
+ catch (e) {
98
+ console.warn(`[hunch] skipping corrupt ${kind}/${name}: ${e.message}`);
99
+ continue;
100
+ }
101
+ const r = schema.safeParse(this.migrate(kind, raw, version));
102
+ if (r.success)
103
+ out.push(r.data);
104
+ else
105
+ console.warn(`[hunch] skipping invalid ${kind}/${name}: ${r.error.issues[0]?.message}`);
106
+ }
107
+ return out;
108
+ }
109
+ /** Write a single record (validated) to its JSON file / into the index array. */
110
+ put(kind, record) {
111
+ const schema = SCHEMAS[kind];
112
+ const validated = schema.parse(record);
113
+ mkdirSync(this.paths.dir(kind), { recursive: true });
114
+ const single = SINGLE_FILE[kind];
115
+ if (single) {
116
+ // Operate on the RAW array (NOT the validating loadAll) so updating one
117
+ // record can't silently drop schema-invalid / future-schema siblings — the
118
+ // same reason delete() reads raw. Keep the index sorted by id (stable diff,
119
+ // and agrees with the merge driver so a re-index after a merge is a no-op).
120
+ const f = this.fileFor(kind, validated.id);
121
+ const arr = this.readRawArray(f).filter((r) => r?.id !== validated.id);
122
+ arr.push(validated);
123
+ arr.sort((a, b) => String(a?.id).localeCompare(String(b?.id)));
124
+ writeFileAtomic(f, encode(arr));
125
+ }
126
+ else {
127
+ writeFileAtomic(this.fileFor(kind, validated.id), encode(validated));
128
+ }
129
+ return validated;
130
+ }
131
+ /** Bulk replace all records of a kind (used by the extractor for symbols/edges). */
132
+ replaceAll(kind, records) {
133
+ const schema = SCHEMAS[kind];
134
+ const validated = records.map((r) => schema.parse(r));
135
+ mkdirSync(this.paths.dir(kind), { recursive: true });
136
+ const single = SINGLE_FILE[kind];
137
+ if (single) {
138
+ // Sorted by id so the index has ONE canonical order — re-indexing after a
139
+ // git merge (which the driver also id-sorts) doesn't churn the whole file.
140
+ validated.sort((a, b) => String(a.id).localeCompare(String(b.id)));
141
+ writeFileAtomic(this.fileFor(kind, "index"), encode(validated));
142
+ return;
143
+ }
144
+ // one file per record: clear stale files, then write
145
+ for (const name of existsSync(this.paths.dir(kind)) ? readdirSync(this.paths.dir(kind)) : []) {
146
+ if (name.endsWith(".json"))
147
+ rmSync(join(this.paths.dir(kind), name));
148
+ }
149
+ for (const r of validated) {
150
+ writeFileAtomic(this.fileFor(kind, r.id), encode(r));
151
+ }
152
+ }
153
+ /** Read a single-file index as a raw array (no validation). Missing/empty → [].
154
+ * A non-empty file that fails to parse THROWS — we must never silently treat a
155
+ * corrupt index as empty and then rewrite it, which would flatten every existing
156
+ * record. (`hunch index` rebuilds from scratch via replaceAll to recover.) */
157
+ readRawArray(f) {
158
+ if (!existsSync(f))
159
+ return [];
160
+ const text = readFileSync(f, "utf8");
161
+ if (!text.trim())
162
+ return [];
163
+ let v;
164
+ try {
165
+ v = JSON.parse(text);
166
+ }
167
+ catch (e) {
168
+ throw new Error(`refusing to rewrite ${f}: the existing index is not valid JSON (${e.message}). Fix or remove it, then re-run \`hunch index\`.`);
169
+ }
170
+ return Array.isArray(v) ? v : [];
171
+ }
172
+ get(kind, id) {
173
+ return this.loadAll(kind).find((r) => r.id === id);
174
+ }
175
+ /** Remove a record (used by the curate/reject flow). Returns true if removed.
176
+ * For single-file kinds we operate on the RAW JSON array (not the validating
177
+ * loader) so deleting one record can't silently drop schema-invalid siblings. */
178
+ delete(kind, id) {
179
+ const single = SINGLE_FILE[kind];
180
+ if (single) {
181
+ const f = this.fileFor(kind, "index");
182
+ if (!existsSync(f))
183
+ return false;
184
+ const arr = this.readRawArray(f);
185
+ const next = arr.filter((r) => r?.id !== id);
186
+ if (next.length === arr.length)
187
+ return false;
188
+ writeFileAtomic(f, encode(next));
189
+ return true;
190
+ }
191
+ const f = this.fileFor(kind, id);
192
+ if (!existsSync(f))
193
+ return false;
194
+ rmSync(f);
195
+ return true;
196
+ }
197
+ /** Persist a schema migration: rewrite every LOADABLE record in its current shape.
198
+ * A record that still fails validation after migration is kept untouched (never
199
+ * deleted) and counted as `skipped`, so migration can't lose data. The caller
200
+ * bumps the manifest afterward. */
201
+ persistMigration() {
202
+ let migrated = 0;
203
+ let skipped = 0;
204
+ const version = this.schemaVersion(); // read once; we're migrating FROM this
205
+ for (const kind of ENTITY_KINDS) {
206
+ const dir = this.paths.dir(kind);
207
+ if (!existsSync(dir))
208
+ continue;
209
+ const schema = SCHEMAS[kind];
210
+ const single = SINGLE_FILE[kind];
211
+ if (single) {
212
+ const f = join(dir, single);
213
+ if (!existsSync(f))
214
+ continue;
215
+ let arr;
216
+ try {
217
+ arr = JSON.parse(readFileSync(f, "utf8"));
218
+ }
219
+ catch {
220
+ skipped++;
221
+ continue;
222
+ }
223
+ if (!Array.isArray(arr)) {
224
+ skipped++;
225
+ continue;
226
+ }
227
+ const kept = [];
228
+ for (const raw of arr) {
229
+ const r = schema.safeParse(this.migrate(kind, raw, version));
230
+ if (r.success) {
231
+ kept.push(r.data);
232
+ migrated++;
233
+ }
234
+ else {
235
+ kept.push(raw); // preserve unmigratable records rather than drop them
236
+ skipped++;
237
+ }
238
+ }
239
+ writeFileAtomic(f, encode(kept));
240
+ }
241
+ else {
242
+ for (const name of readdirSync(dir)) {
243
+ if (!name.endsWith(".json"))
244
+ continue;
245
+ const p = join(dir, name);
246
+ let raw;
247
+ try {
248
+ raw = JSON.parse(readFileSync(p, "utf8"));
249
+ }
250
+ catch {
251
+ skipped++;
252
+ continue;
253
+ }
254
+ const r = schema.safeParse(this.migrate(kind, raw, version));
255
+ if (r.success) {
256
+ writeFileAtomic(p, encode(r.data));
257
+ migrated++;
258
+ }
259
+ else {
260
+ skipped++; // leave the file as-is
261
+ }
262
+ }
263
+ }
264
+ }
265
+ return { migrated, skipped };
266
+ }
267
+ }
268
+ //# sourceMappingURL=jsonStore.js.map
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Structured three-way merge of `.hunch/` JSON for TEAM workflows.
3
+ *
4
+ * Concurrent branches both writing the Hunch conflict on `git merge`: the
5
+ * single-file symbols/edges index is rewritten wholesale by `hunch index`, and two
6
+ * people can touch the same decision/bug/constraint. A registered git merge driver
7
+ * (`hunch merge-driver`, wired up by `hunch init`) calls mergeHunchJson per
8
+ * conflicted file so records merge BY ID instead of leaving conflict markers.
9
+ *
10
+ * Scope: git only invokes a content merge driver when the SAME path differs on both
11
+ * sides — so this resolves (a) the symbols/edges index ARRAY and (b) edits to the
12
+ * same id in the same per-record file. Per-record ADD/DELETE across branches are
13
+ * distinct files and git handles them at the tree level (the driver isn't called).
14
+ *
15
+ * Resolution for a record changed on BOTH sides: human-confirmed beats auto, then
16
+ * higher provenance.confidence, then the more recently verified, then a deterministic
17
+ * content tiebreak (so both developers' merges converge on the same result). Records
18
+ * are pure data here — no filesystem access; the CLI reads/writes the files.
19
+ */
20
+ /** Merge three versions of one `.hunch` JSON file (an index array OR a single
21
+ * record object). Returns the merged text, or conflict=true to fall back. */
22
+ export function mergeHunchJson(baseText, oursText, theirsText) {
23
+ const ours = parseSide(oursText);
24
+ const theirs = parseSide(theirsText);
25
+ const base = parseSide(baseText);
26
+ // Can't structurally merge non-JSON or id-less records → let git handle it.
27
+ if (!ours.ok || !theirs.ok)
28
+ return { text: oursText, conflict: true };
29
+ if (!hasIds(ours.records) || !hasIds(theirs.records))
30
+ return { text: oursText, conflict: true };
31
+ const merged = mergeRecordsById(base.ok ? base.records : [], ours.records, theirs.records);
32
+ merged.sort((a, b) => String(a.id).localeCompare(String(b.id)));
33
+ // An index file stays an array; a per-record file stays a single object.
34
+ const asArray = ours.isArray || theirs.isArray;
35
+ if (asArray)
36
+ return { text: serialize(merged), conflict: false };
37
+ if (merged.length === 0)
38
+ return { text: "", conflict: false }; // both deleted the record
39
+ // A single-record file that produced >1 record means a side rewrote the `id`
40
+ // (a logical rename). Don't silently drop one — fall back so git surfaces it.
41
+ if (merged.length > 1)
42
+ return { text: oursText, conflict: true };
43
+ return { text: serialize(merged[0]), conflict: false };
44
+ }
45
+ /** Three-way merge of record arrays keyed by `id`. Additions on either side are
46
+ * kept; a record changed on one side only takes that side; a delete is honored
47
+ * only if the other side left the record unchanged (a modification beats a delete);
48
+ * a both-sides change is resolved by `pickWinner`. */
49
+ export function mergeRecordsById(base, ours, theirs) {
50
+ const b = byId(base);
51
+ const o = byId(ours);
52
+ const t = byId(theirs);
53
+ const ids = new Set([...o.keys(), ...t.keys(), ...b.keys()]);
54
+ const out = [];
55
+ for (const id of ids) {
56
+ const bv = b.get(id);
57
+ const ov = o.get(id);
58
+ const tv = t.get(id);
59
+ if (ov && tv) {
60
+ if (canon(ov) === canon(tv))
61
+ out.push(ov);
62
+ else if (bv && canon(ov) === canon(bv))
63
+ out.push(tv); // only theirs changed
64
+ else if (bv && canon(tv) === canon(bv))
65
+ out.push(ov); // only ours changed
66
+ else
67
+ out.push(pickWinner(ov, tv)); // both changed (or both added differently)
68
+ }
69
+ else if (ov && !tv) {
70
+ // theirs lacks it: a delete (bv present & ours unchanged) loses to a keep/modify
71
+ if (bv && canon(ov) === canon(bv))
72
+ continue; // theirs deleted, ours unchanged → drop
73
+ out.push(ov); // ours added, or ours modified vs a theirs-delete → keep ours
74
+ }
75
+ else if (!ov && tv) {
76
+ if (bv && canon(tv) === canon(bv))
77
+ continue; // ours deleted, theirs unchanged → drop
78
+ out.push(tv);
79
+ }
80
+ // neither side has it → both deleted → drop
81
+ }
82
+ return out;
83
+ }
84
+ /** Both sides changed the same record: pick the one to keep. */
85
+ export function pickWinner(ours, theirs) {
86
+ const oc = humanConfirmed(ours);
87
+ const tc = humanConfirmed(theirs);
88
+ if (oc !== tc)
89
+ return oc ? ours : theirs; // human-confirmed beats auto
90
+ const od = confidence(ours);
91
+ const td = confidence(theirs);
92
+ if (od !== td)
93
+ return od > td ? ours : theirs;
94
+ const orr = recency(ours);
95
+ const tr = recency(theirs);
96
+ if (orr !== tr)
97
+ return orr > tr ? ours : theirs;
98
+ // Deterministic, side-independent tiebreak so A-merges-B and B-merges-A agree.
99
+ return canon(ours) >= canon(theirs) ? ours : theirs;
100
+ }
101
+ function parseSide(text) {
102
+ const trimmed = (text ?? "").trim();
103
+ if (!trimmed)
104
+ return { ok: true, isArray: false, records: [] }; // empty (e.g. deleted) side
105
+ let v;
106
+ try {
107
+ v = JSON.parse(trimmed);
108
+ }
109
+ catch {
110
+ return { ok: false, isArray: false, records: [] };
111
+ }
112
+ if (Array.isArray(v))
113
+ return { ok: true, isArray: true, records: v.filter(isRec) };
114
+ if (isRec(v))
115
+ return { ok: true, isArray: false, records: [v] };
116
+ return { ok: false, isArray: false, records: [] };
117
+ }
118
+ function isRec(v) {
119
+ return v !== null && typeof v === "object" && !Array.isArray(v);
120
+ }
121
+ function hasIds(records) {
122
+ return records.every((r) => typeof r.id === "string" && r.id.length > 0);
123
+ }
124
+ function byId(records) {
125
+ const m = new Map();
126
+ for (const r of records)
127
+ if (typeof r.id === "string")
128
+ m.set(r.id, r);
129
+ return m;
130
+ }
131
+ function humanConfirmed(r) {
132
+ return /human_confirmed/.test(provSource(r));
133
+ }
134
+ function provSource(r) {
135
+ const p = r.provenance;
136
+ return isRec(p) && typeof p.source === "string" ? p.source : "";
137
+ }
138
+ function confidence(r) {
139
+ const p = r.provenance;
140
+ return isRec(p) && typeof p.confidence === "number" ? p.confidence : 0;
141
+ }
142
+ /** Most recent timestamp on the record (verified / decided / updated), epoch ms. */
143
+ function recency(r) {
144
+ const p = r.provenance;
145
+ const cands = [
146
+ isRec(p) ? p.last_verified : undefined,
147
+ r.date,
148
+ r.updated_at,
149
+ ];
150
+ let best = 0;
151
+ for (const c of cands) {
152
+ if (typeof c === "string") {
153
+ const t = Date.parse(c);
154
+ if (!Number.isNaN(t) && t > best)
155
+ best = t;
156
+ }
157
+ }
158
+ return best;
159
+ }
160
+ /** Stable JSON with recursively SORTED keys, so equality/compare ignore key order. */
161
+ export function canon(v) {
162
+ return JSON.stringify(sortKeys(v));
163
+ }
164
+ function sortKeys(v) {
165
+ if (Array.isArray(v))
166
+ return v.map(sortKeys);
167
+ if (v !== null && typeof v === "object") {
168
+ const out = {};
169
+ for (const k of Object.keys(v).sort())
170
+ out[k] = sortKeys(v[k]);
171
+ return out;
172
+ }
173
+ return v;
174
+ }
175
+ /** Match the store's on-disk format (2-space indent + trailing newline). */
176
+ function serialize(v) {
177
+ return JSON.stringify(v, null, 2) + "\n";
178
+ }
179
+ //# sourceMappingURL=merge.js.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * SQLite schema for the DERIVED index (DESIGN.md §2.1 / §6).
3
+ *
4
+ * The JSON files under .hunch/ are the source of truth; this database is rebuilt
5
+ * from them by `hunch index`. JSON-array/object fields are stored as TEXT (JSON)
6
+ * — we only need them indexed where we query them. Search is a single unified
7
+ * FTS5 table; the graph is plain tables walked with recursive CTEs.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ /** Canonical content hash of the exact title+body that fed both FTS and the
11
+ * embedding for a doc. Stored in `embeddings.doc_hash` so reindex can tell, with
12
+ * NO model loaded, whether a stored vector is stale (its source text changed).
13
+ * The NUL separator keeps the title/body boundary unambiguous. */
14
+ export function embedHash(title, body) {
15
+ return createHash("sha1").update(title).update("\x00").update(body ?? "").digest("hex").slice(0, 16);
16
+ }
17
+ export const SCHEMA_SQL = /* sql */ `
18
+ PRAGMA journal_mode = WAL;
19
+ PRAGMA foreign_keys = OFF;
20
+
21
+ CREATE TABLE IF NOT EXISTS components (
22
+ id TEXT PRIMARY KEY,
23
+ kind TEXT, name TEXT, responsibility TEXT,
24
+ paths TEXT, status TEXT, owners TEXT,
25
+ fragility REAL,
26
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
27
+ created_at TEXT, updated_at TEXT
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS edges (
31
+ id TEXT PRIMARY KEY,
32
+ "from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
33
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
34
+ );
35
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges("from");
36
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges("to");
37
+ CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
38
+
39
+ CREATE TABLE IF NOT EXISTS symbols (
40
+ id TEXT PRIMARY KEY,
41
+ file TEXT, name TEXT, kind TEXT, signature_hash TEXT,
42
+ calls TEXT, called_by TEXT,
43
+ loc INTEGER, churn_90d INTEGER, bug_count INTEGER, fan_in INTEGER, fan_out INTEGER,
44
+ last_changed TEXT
45
+ );
46
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
47
+ CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
48
+
49
+ CREATE TABLE IF NOT EXISTS decisions (
50
+ id TEXT PRIMARY KEY,
51
+ title TEXT, status TEXT, context TEXT, decision TEXT,
52
+ consequences TEXT, alternatives_rejected TEXT,
53
+ related_components TEXT, related_files TEXT,
54
+ supersedes TEXT, caused_by_bug TEXT, "commit" TEXT,
55
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
56
+ date TEXT
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS bugs (
60
+ id TEXT PRIMARY KEY,
61
+ title TEXT, symptom TEXT, root_cause TEXT, severity TEXT, status TEXT,
62
+ affected_files TEXT, affected_symbols TEXT, lineage TEXT,
63
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS constraints (
67
+ id TEXT PRIMARY KEY,
68
+ type TEXT, statement TEXT, scope TEXT, severity TEXT, enforcement TEXT,
69
+ rationale TEXT, source_decision TEXT, violations TEXT,
70
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
71
+ );
72
+
73
+ -- Unified full-text search across every entity. Rebuilt on index; bm25-ranked.
74
+ CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
75
+ ref UNINDEXED, -- entity id
76
+ kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
77
+ title,
78
+ body,
79
+ tokenize = 'porter unicode61'
80
+ );
81
+
82
+ -- Local semantic-search vectors (opt-in; written by \`hunch embed\`). One row per
83
+ -- (ref, model); vec is a Float32 BLOB. DELIBERATELY NOT in RESET_SQL: reindex()
84
+ -- runs RESET on nearly every path (MCP startup, every query/context), so resetting
85
+ -- embeddings here would wipe them constantly and make the feature a no-op. Staleness
86
+ -- is tracked by doc_hash and reconciled by pruneStaleEmbeddings() instead. Recall is
87
+ -- exact brute-force cosine in JS (graphs are small); sqlite-vec only past ~100k rows.
88
+ CREATE TABLE IF NOT EXISTS embeddings (
89
+ ref TEXT, kind TEXT, model TEXT, dim INTEGER, doc_hash TEXT, vec BLOB,
90
+ PRIMARY KEY (ref, model)
91
+ );
92
+ `;
93
+ /** Drop derived data (used before a full reindex). NOTE: embeddings is omitted on
94
+ * purpose — see the embeddings table comment above. */
95
+ export const RESET_SQL = /* sql */ `
96
+ DELETE FROM components; DELETE FROM edges; DELETE FROM symbols;
97
+ DELETE FROM decisions; DELETE FROM bugs; DELETE FROM constraints;
98
+ DELETE FROM search;
99
+ `;
100
+ //# sourceMappingURL=schema.js.map