renor-engine 0.0.0-stage → 1.0.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.
Files changed (176) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +73 -0
  3. package/README.md +105 -2
  4. package/dist/BUILD.json +6 -0
  5. package/dist/api/apiAuth.js +43 -0
  6. package/dist/api/server.js +172 -0
  7. package/dist/buildIdentity.js +132 -0
  8. package/dist/cli-extractors.js +46 -0
  9. package/dist/cli.js +491 -0
  10. package/dist/config.js +65 -0
  11. package/dist/context/composer.js +320 -0
  12. package/dist/context/readHonesty.js +478 -0
  13. package/dist/context/rowStore.js +238 -0
  14. package/dist/conversation/formatter.js +81 -0
  15. package/dist/conversation/parser.js +189 -0
  16. package/dist/conversation/types.js +2 -0
  17. package/dist/conversation/writer.js +42 -0
  18. package/dist/db/client.js +147 -0
  19. package/dist/db/indexSalvage.js +81 -0
  20. package/dist/db/migrations.js +642 -0
  21. package/dist/db/readCache.js +109 -0
  22. package/dist/db/schema.js +5 -0
  23. package/dist/db/statements.js +89 -0
  24. package/dist/digests/digestCache.js +142 -0
  25. package/dist/digests/digests.js +597 -0
  26. package/dist/digests/worker.js +348 -0
  27. package/dist/embeddings/caching.js +337 -0
  28. package/dist/embeddings/coalescing.js +177 -0
  29. package/dist/embeddings/interactive.js +103 -0
  30. package/dist/embeddings/local.js +68 -0
  31. package/dist/embeddings/openai.js +105 -0
  32. package/dist/embeddings/rebuild.js +166 -0
  33. package/dist/embeddings/simdScan.js +258 -0
  34. package/dist/embeddings/space.js +21 -0
  35. package/dist/embeddings/types.js +14 -0
  36. package/dist/embeddings/vectorIndex.js +675 -0
  37. package/dist/embeddings/vertex.js +224 -0
  38. package/dist/eval/artifacts.js +42 -0
  39. package/dist/eval/cacheReport.js +139 -0
  40. package/dist/eval/comparison.js +182 -0
  41. package/dist/eval/context700-candidate.js +145 -0
  42. package/dist/eval/evalCache.js +525 -0
  43. package/dist/eval/hashEmbedding.js +35 -0
  44. package/dist/eval/longmemeval.js +56 -0
  45. package/dist/eval/metrics.js +181 -0
  46. package/dist/eval/renorbenchBaseline.js +486 -0
  47. package/dist/eval/renorbenchDocuments.js +134 -0
  48. package/dist/eval/renorbenchProduct.js +182 -0
  49. package/dist/eval/runLedger.js +279 -0
  50. package/dist/eval/runManifest.js +479 -0
  51. package/dist/eval/scoring.js +197 -0
  52. package/dist/evidence/attach.js +267 -0
  53. package/dist/evidence/background.js +147 -0
  54. package/dist/evidence/evidenceCache.js +182 -0
  55. package/dist/evidence/gate.js +37 -0
  56. package/dist/evidence/keyPool.js +85 -0
  57. package/dist/evidence/ollamaChat.js +84 -0
  58. package/dist/evidence/optin.js +165 -0
  59. package/dist/evidence/options.js +130 -0
  60. package/dist/evidence/secondPass.js +456 -0
  61. package/dist/extraction/classifyCapture.js +34 -0
  62. package/dist/extraction/decision-model.js +174 -0
  63. package/dist/extraction/eventExtractor.js +113 -0
  64. package/dist/extraction/extractionClient.js +122 -0
  65. package/dist/extraction/factExtractor.js +46 -0
  66. package/dist/extraction/factsWritePath.js +179 -0
  67. package/dist/extraction/preferenceExtractor.js +153 -0
  68. package/dist/extraction/secretGuard.js +176 -0
  69. package/dist/extraction/secretScanner.js +665 -0
  70. package/dist/extraction/typedFacts.js +224 -0
  71. package/dist/extraction/writePathAdjudication.js +110 -0
  72. package/dist/importing/apply.js +644 -0
  73. package/dist/importing/bundle-write.js +80 -0
  74. package/dist/importing/cli.js +407 -0
  75. package/dist/importing/existing.js +49 -0
  76. package/dist/importing/facts.js +123 -0
  77. package/dist/importing/forgetImported.js +79 -0
  78. package/dist/importing/hosted.js +467 -0
  79. package/dist/importing/index.js +29 -0
  80. package/dist/importing/journalLog.js +96 -0
  81. package/dist/importing/markdown.js +98 -0
  82. package/dist/importing/plan.js +206 -0
  83. package/dist/importing/secretScreen.js +113 -0
  84. package/dist/importing/serialize.js +188 -0
  85. package/dist/importing/source.js +463 -0
  86. package/dist/importing/types.js +6 -0
  87. package/dist/indexer/chunker.js +319 -0
  88. package/dist/indexer/dailyEntries.js +63 -0
  89. package/dist/indexer/hasher.js +10 -0
  90. package/dist/indexer/laneRuns.js +48 -0
  91. package/dist/indexer/o200k.js +481 -0
  92. package/dist/indexer/o200k.json +8 -0
  93. package/dist/indexer/outsideRoot.js +77 -0
  94. package/dist/indexer/parser.js +167 -0
  95. package/dist/indexer/pipeline.js +1168 -0
  96. package/dist/indexer/staleness.js +201 -0
  97. package/dist/indexer/tokenizer.js +77 -0
  98. package/dist/indexer/walker.js +184 -0
  99. package/dist/mcp/hive.js +88 -0
  100. package/dist/mcp/requestErrors.js +47 -0
  101. package/dist/mcp/server.js +396 -0
  102. package/dist/memory/agentLease.js +43 -0
  103. package/dist/memory/confinedWrite.js +315 -0
  104. package/dist/memory/controls.js +73 -0
  105. package/dist/memory/dailyNote.js +265 -0
  106. package/dist/memory/durableAppend.js +57 -0
  107. package/dist/memory/factConflicts.js +203 -0
  108. package/dist/memory/factTerms.js +321 -0
  109. package/dist/memory/hiveNameHeader.cjs +10 -0
  110. package/dist/memory/inventory.js +55 -0
  111. package/dist/memory/invisibleText.js +20 -0
  112. package/dist/memory/lifecycle.js +1756 -0
  113. package/dist/memory/limits.js +13 -0
  114. package/dist/memory/requests.js +183 -0
  115. package/dist/memory/sourceName.js +200 -0
  116. package/dist/memory/whatsNew.js +158 -0
  117. package/dist/memory/writer.js +175 -0
  118. package/dist/memory/writerConflicts.js +85 -0
  119. package/dist/memorymap/memoryMap.js +1568 -0
  120. package/dist/memorymap/replacedJournal.js +58 -0
  121. package/dist/memorymap/replacements.js +405 -0
  122. package/dist/mvp/activity.js +86 -0
  123. package/dist/mvp/agentMemory.js +1744 -0
  124. package/dist/mvp/auth.js +531 -0
  125. package/dist/mvp/cli.js +73 -0
  126. package/dist/mvp/client-store.js +462 -0
  127. package/dist/mvp/compileCache.js +249 -0
  128. package/dist/mvp/compileSeed.js +27 -0
  129. package/dist/mvp/config.js +288 -0
  130. package/dist/mvp/connectionStatus.js +80 -0
  131. package/dist/mvp/contextRelevance.js +413 -0
  132. package/dist/mvp/engine.js +1065 -0
  133. package/dist/mvp/exportAll.js +359 -0
  134. package/dist/mvp/factLanePack.js +77 -0
  135. package/dist/mvp/factMarkers.js +49 -0
  136. package/dist/mvp/factsLane.js +203 -0
  137. package/dist/mvp/hiddenSpans.js +585 -0
  138. package/dist/mvp/history.js +413 -0
  139. package/dist/mvp/hitDates.js +31 -0
  140. package/dist/mvp/hitFloor.js +36 -0
  141. package/dist/mvp/identity.js +18 -0
  142. package/dist/mvp/idle.js +113 -0
  143. package/dist/mvp/memoryDir.js +98 -0
  144. package/dist/mvp/posthogMcp.js +210 -0
  145. package/dist/mvp/readScope.js +104 -0
  146. package/dist/mvp/readUsefulness.js +222 -0
  147. package/dist/mvp/sanitize.js +48 -0
  148. package/dist/mvp/schemaRejections.js +91 -0
  149. package/dist/mvp/scope.js +127 -0
  150. package/dist/mvp/server.js +2089 -0
  151. package/dist/mvp/serverCard.js +29 -0
  152. package/dist/mvp/storedSize.js +139 -0
  153. package/dist/mvp/telemetry.js +441 -0
  154. package/dist/retrieval/adviceLines.js +195 -0
  155. package/dist/retrieval/adviceQuestion.js +44 -0
  156. package/dist/retrieval/conversationShape.js +44 -0
  157. package/dist/retrieval/dailyEntryTime.js +55 -0
  158. package/dist/retrieval/entityLookup.js +113 -0
  159. package/dist/retrieval/factsFirst.js +188 -0
  160. package/dist/retrieval/keyword.js +84 -0
  161. package/dist/retrieval/ledgerEventTime.js +51 -0
  162. package/dist/retrieval/ledgerLiveness.js +83 -0
  163. package/dist/retrieval/ledgerPresentation.js +161 -0
  164. package/dist/retrieval/noteIdentity.js +57 -0
  165. package/dist/retrieval/orchestrator.js +1086 -0
  166. package/dist/retrieval/queryHelpers.js +142 -0
  167. package/dist/retrieval/questionSpan.js +475 -0
  168. package/dist/retrieval/readerEvents.js +690 -0
  169. package/dist/retrieval/readerLayout.js +503 -0
  170. package/dist/retrieval/relativeTimeHints.js +113 -0
  171. package/dist/retrieval/semantic.js +67 -0
  172. package/dist/router/routerCache.js +106 -0
  173. package/dist/router/sessionRouter.js +726 -0
  174. package/dist/toolRefusal.js +120 -0
  175. package/dist/watcher/watcher.js +75 -0
  176. package/package.json +120 -4
@@ -0,0 +1,1168 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.NoteTooLargeError = exports.MAX_NOTE_BYTES = void 0;
7
+ exports.resolveWikilinks = resolveWikilinks;
8
+ exports.indexFile = indexFile;
9
+ exports.indexVault = indexVault;
10
+ exports.removeDailyHeaderFacts = removeDailyHeaderFacts;
11
+ const promises_1 = __importDefault(require("fs/promises"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const fs_1 = require("fs");
14
+ const crypto_1 = require("crypto");
15
+ const buffer_1 = require("buffer");
16
+ const statements_1 = require("../db/statements");
17
+ const hasher_1 = require("./hasher");
18
+ const dailyEntries_1 = require("./dailyEntries");
19
+ const parser_1 = require("./parser");
20
+ const chunker_1 = require("./chunker");
21
+ const walker_1 = require("./walker");
22
+ const space_1 = require("../embeddings/space");
23
+ const writer_1 = require("../memory/writer");
24
+ const fs_2 = require("fs");
25
+ const dailyNote_1 = require("../memory/dailyNote");
26
+ // HARMONIZED write path (merge of feat/c2-facts-writepath × origin/main):
27
+ // the fleet's extractors (typedFacts/eventExtractor/preferenceExtractor) are
28
+ // the CANDIDATE GENERATION layer (RENOR_TYPED_FACTS / RENOR_CHRONICLE /
29
+ // RENOR_PREFS, all default off); this branch's DecisionModel is the DECISION
30
+ // layer (capture-gate / dedupe / sensitivity-guardrail adjudication before
31
+ // any row is written). Single write path: decisions before writes.
32
+ const factsWritePath_1 = require("../extraction/factsWritePath");
33
+ const writePathAdjudication_1 = require("../extraction/writePathAdjudication");
34
+ const typedFacts_1 = require("../extraction/typedFacts");
35
+ const eventExtractor_1 = require("../extraction/eventExtractor");
36
+ const preferenceExtractor_1 = require("../extraction/preferenceExtractor");
37
+ const rowStore_1 = require("../context/rowStore");
38
+ const laneRuns_1 = require("./laneRuns");
39
+ /** Texts per request the embedding service takes at once (what Vertex's predict accepts). */
40
+ const EMBED_REQUEST_TEXTS = 32;
41
+ /**
42
+ * The largest note the indexer reads. A note is held whole, parsed, serialised and chunked in memory; a flat front matter
43
+ * costs about 28x its size in RSS (8 MB -> 487 MB, 64 MB -> 1.9 GB), and nothing else bounds a file on disk. A note over
44
+ * it is reported, never read (IndexResult.skippedTooLarge); split it, or move it out of the memory folder.
45
+ */
46
+ exports.MAX_NOTE_BYTES = 8 * 1024 * 1024;
47
+ /** A note over MAX_NOTE_BYTES. indexFile throws it before reading a byte; indexVault reports it instead of counting an error. */
48
+ class NoteTooLargeError extends Error {
49
+ filePath;
50
+ bytes;
51
+ constructor(filePath, bytes) {
52
+ super(`note is ${bytes} bytes; a note over ${exports.MAX_NOTE_BYTES / (1024 * 1024)} MiB (${exports.MAX_NOTE_BYTES} bytes) is not indexed. Split it or move it out of the memory folder.`);
53
+ this.filePath = filePath;
54
+ this.bytes = bytes;
55
+ this.name = 'NoteTooLargeError';
56
+ }
57
+ }
58
+ exports.NoteTooLargeError = NoteTooLargeError;
59
+ const FACT_PREDICATES = ['owner', 'status', 'updated', 'tags'];
60
+ /** A front-matter value as fact text: a scalar as written, a mapping (or binary) as JSON. Never throws, never "[object Object]". */
61
+ const factText = (v) => (v !== null && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : String(v));
62
+ /** The note is indexed anyway; say which front-matter value was not text, so a note that seems to ignore it can be explained. */
63
+ function warnFrontmatter(filePath, what) {
64
+ console.warn(`[index] ${filePath}: ${what}; the note is indexed without it`);
65
+ }
66
+ /** The note's `kind`: a string, else none. Anything else (a mapping, a list, a date, a number) is ignored with a warning. */
67
+ function noteKind(filePath, frontmatter) {
68
+ const kind = frontmatter.kind;
69
+ if (typeof kind === 'string')
70
+ return kind;
71
+ if (kind !== undefined && kind !== null)
72
+ warnFrontmatter(filePath, 'front matter "kind" is not text');
73
+ return null;
74
+ }
75
+ /**
76
+ * The extraction version each structured-row lane writes today. This is the
77
+ * second half of the lane cache key, (source_hash, extraction_version), so it
78
+ * is also the test for "is this preserved row still this lane's current row?".
79
+ * One mapping, used by the in-transaction reattach and by the post-failure
80
+ * restore, so a version bump cannot be applied in one place and missed in the
81
+ * other. `row_kind` is CHECK-constrained to these three kinds.
82
+ */
83
+ const LANE_EXTRACTION_VERSION = {
84
+ fact: typedFacts_1.FACTS_EXTRACTION_VERSION,
85
+ event: eventExtractor_1.EVENTS_EXTRACTION_VERSION,
86
+ preference: preferenceExtractor_1.PREFS_EXTRACTION_VERSION,
87
+ };
88
+ function currentVersion(kind) {
89
+ return LANE_EXTRACTION_VERSION[kind];
90
+ }
91
+ /**
92
+ * Whether the vectors this index holds are in the embedding space the provider writes, so a stored vector may
93
+ * stand in for a fresh one. A new index takes the provider's space; vectors recorded in a different one are
94
+ * never mixed with new ones (that fails the file, before anything changes, until the index is rebuilt); an
95
+ * index that cannot say, or a provider with no identity, reuses nothing.
96
+ */
97
+ function storedVectorsMatch(options) {
98
+ const index = options.vectorIndex;
99
+ const wanted = (0, space_1.embeddingSpace)(options.embeddingProvider);
100
+ if (!wanted || !index.space || !index.setSpace || !index.hasVectors)
101
+ return false;
102
+ const stored = index.space();
103
+ if (stored === wanted)
104
+ return true;
105
+ if (stored !== null) {
106
+ throw new Error(`the index holds vectors from another embedding model (${stored}), not the configured one (${wanted}); `
107
+ + 'rebuild the index rather than mix the two');
108
+ }
109
+ if (index.hasVectors())
110
+ return false; // built before spaces were recorded: it cannot be vouched for
111
+ index.setSpace(wanted);
112
+ return true;
113
+ }
114
+ /** How many chunks, from the top of the note, are stored exactly as the note chunks now: same text, heading and place. */
115
+ function unchangedPrefix(stored, chunks) {
116
+ let n = 0;
117
+ while (n < stored.length && n < chunks.length) {
118
+ const s = stored[n];
119
+ const c = chunks[n];
120
+ if (s.text !== c.text || s.heading_path !== c.headingPath || s.start_line !== c.startLine
121
+ || s.end_line !== c.endLine || s.token_count !== c.tokenCount)
122
+ break;
123
+ n++;
124
+ }
125
+ return n;
126
+ }
127
+ /** The note's row: written before its chunks, as every pass does (the chunks reference it). */
128
+ function upsertNoteRow(db, filePath, row) {
129
+ db.prepare(`
130
+ INSERT INTO notes (path, title, kind, note_hash, modified_at, frontmatter_json)
131
+ VALUES (@path, @title, @kind, @noteHash, @modifiedAt, @frontmatterJson)
132
+ ON CONFLICT(path) DO UPDATE SET
133
+ title = excluded.title,
134
+ kind = excluded.kind,
135
+ note_hash = excluded.note_hash,
136
+ modified_at = excluded.modified_at,
137
+ frontmatter_json = excluded.frontmatter_json
138
+ `).run({ path: filePath, ...row });
139
+ }
140
+ /** SQLite's LIKE and COLLATE NOCASE fold the ASCII letters and nothing else; so does this. */
141
+ const asciiLower = (s) => s.replace(/[A-Z]/g, c => String.fromCharCode(c.charCodeAt(0) + 32));
142
+ /**
143
+ * The entity each wikilink points at, keyed by the link with its ASCII letters lowercased. A link names the entity whose
144
+ * note's path ends in `/<link>.md` or whose name is the link (ASCII case-insensitive), and where several do, the one with
145
+ * the lowest id. That is what a query per link with `path LIKE '%/<link>.md' OR name = ?` answered, but a leading wildcard
146
+ * cannot use an index, so that cost a scan of every note per distinct link: a 61 KB note of 7,000 links took 22 s in a
147
+ * 2,000-note vault. A link nothing names has no entry. The claims are kept (see claimsOf), so a note does not scan them.
148
+ */
149
+ function resolveWikilinks(db, links) {
150
+ const wanted = new Set(links.map(asciiLower));
151
+ const found = new Map();
152
+ if (wanted.size === 0)
153
+ return found;
154
+ const byKey = claimsOf(db);
155
+ for (const key of wanted) {
156
+ const ids = byKey.get(key);
157
+ if (ids !== undefined)
158
+ found.set(key, ids[0]);
159
+ }
160
+ return found;
161
+ }
162
+ /** The keys an entity answers to: its name, and each path below its note's folder (see resolveWikilinks). */
163
+ function claimKeysOf(name, path) {
164
+ const keys = [asciiLower(name)];
165
+ const lowerPath = asciiLower(path);
166
+ if (lowerPath.endsWith('.md')) {
167
+ const stem = lowerPath.slice(0, -3);
168
+ for (let at = stem.indexOf('/'); at !== -1; at = stem.indexOf('/', at + 1))
169
+ keys.push(stem.slice(at + 1));
170
+ }
171
+ return keys;
172
+ }
173
+ const claimsByDb = new WeakMap();
174
+ function entityFingerprint(db) {
175
+ return (0, statements_1.prepareOnce)(db, "SELECT COALESCE(MAX(id), 0) || '|' || COUNT(*) FROM entities").pluck().get();
176
+ }
177
+ function addClaim(byKey, key, id) {
178
+ const ids = byKey.get(key);
179
+ // Ids arrive in ascending order (a new entity is the newest), so the last one is the only one this id can repeat.
180
+ if (ids === undefined)
181
+ byKey.set(key, [id]);
182
+ else if (ids[ids.length - 1] !== id)
183
+ ids.push(id);
184
+ }
185
+ function dropClaim(byKey, key, id) {
186
+ const ids = byKey.get(key);
187
+ if (ids === undefined)
188
+ return;
189
+ const at = ids.indexOf(id);
190
+ if (at !== -1)
191
+ ids.splice(at, 1);
192
+ if (ids.length === 0)
193
+ byKey.delete(key);
194
+ }
195
+ function claimsOf(db) {
196
+ const fingerprint = entityFingerprint(db);
197
+ const held = claimsByDb.get(db);
198
+ if (held !== undefined && held.fingerprint === fingerprint)
199
+ return held.byKey;
200
+ const byKey = new Map();
201
+ const rows = db.prepare('SELECT e.id AS id, e.canonical_name AS name, n.path AS path FROM entities e JOIN notes n ON e.source_note = n.path ORDER BY e.id').iterate();
202
+ for (const row of rows) {
203
+ for (const key of claimKeysOf(row.name, row.path))
204
+ addClaim(byKey, key, row.id);
205
+ }
206
+ claimsByDb.set(db, { fingerprint, byKey });
207
+ return byKey;
208
+ }
209
+ /** The entity rows of one note, as the claims read them (joined to their note, as resolveWikilinks joins them). */
210
+ function entityRowsOf(db, filePath) {
211
+ return (0, statements_1.prepareOnce)(db, 'SELECT e.id AS id, e.canonical_name AS name, n.path AS path FROM entities e JOIN notes n ON e.source_note = n.path WHERE e.source_note = ?')
212
+ .all(filePath);
213
+ }
214
+ /**
215
+ * Follows one note's entity change (rebuildNoteRows: its old entity deleted, its new one inserted): what the old rows claimed
216
+ * is dropped and what the new rows claim is added, if the claims were current before the change. If they were not, they are
217
+ * dropped and read again on the next resolve.
218
+ */
219
+ function followEntityChange(db, fingerprintBefore, removed, added) {
220
+ const held = claimsByDb.get(db);
221
+ if (held === undefined)
222
+ return;
223
+ if (held.fingerprint !== fingerprintBefore) {
224
+ claimsByDb.delete(db);
225
+ return;
226
+ }
227
+ for (const row of removed)
228
+ for (const key of claimKeysOf(row.name, row.path))
229
+ dropClaim(held.byKey, key, row.id);
230
+ for (const row of added)
231
+ for (const key of claimKeysOf(row.name, row.path))
232
+ addClaim(held.byKey, key, row.id);
233
+ held.fingerprint = entityFingerprint(db);
234
+ }
235
+ /** A note's entity, frontmatter facts and wikilink relations, written again from its parse. Returns the entity's id. */
236
+ function rebuildNoteRows(db, filePath, note, kind, engineWrote, now) {
237
+ // Frontmatter/LLM note facts use this index instant (and the default global scope). Keep the workspace's
238
+ // observation even when a later pass replaces the rows after a backward clock step, or a note is retired.
239
+ // This shares the note-row transaction, so a rolled-back pass cannot advance the read clock.
240
+ db.prepare(`INSERT INTO note_index_clock (scope, observed_at) VALUES ('global', ?)
241
+ ON CONFLICT(scope) DO UPDATE SET observed_at = MAX(note_index_clock.observed_at, excluded.observed_at)`)
242
+ .run(now);
243
+ // 10. Extract entities — delete old, insert new (the wikilink claims follow the change, see followEntityChange)
244
+ const fingerprintBefore = entityFingerprint(db);
245
+ const removedEntities = entityRowsOf(db, filePath);
246
+ db.prepare('DELETE FROM entities WHERE source_note = ?').run(filePath);
247
+ const rawAliases = note.frontmatter.aliases;
248
+ const aliases = Array.isArray(rawAliases)
249
+ ? rawAliases.filter((a) => typeof a === 'string')
250
+ : typeof rawAliases === 'string'
251
+ ? [rawAliases]
252
+ : [];
253
+ if (rawAliases !== undefined && rawAliases !== null
254
+ && (Array.isArray(rawAliases) ? aliases.length < rawAliases.length : typeof rawAliases !== 'string')) {
255
+ warnFrontmatter(filePath, 'front matter "aliases" has a value that is not text');
256
+ }
257
+ const entityRow = db.prepare(`
258
+ INSERT INTO entities (type, canonical_name, aliases_json, source_note, confidence, updated_at)
259
+ VALUES (@type, @canonicalName, @aliasesJson, @sourceNote, @confidence, @updatedAt)
260
+ RETURNING id
261
+ `).get({
262
+ type: kind ?? 'note',
263
+ canonicalName: note.title,
264
+ aliasesJson: JSON.stringify(aliases),
265
+ sourceNote: filePath,
266
+ confidence: 1.0,
267
+ updatedAt: now,
268
+ });
269
+ followEntityChange(db, fingerprintBefore, removedEntities, entityRowsOf(db, filePath));
270
+ // 11. Extract facts — delete old, insert new
271
+ db.prepare('DELETE FROM facts WHERE source_path = ?').run(filePath);
272
+ const insertFact = db.prepare(`
273
+ INSERT INTO facts (subject_entity_id, predicate, object_text, source_path, confidence, valid_from, updated_at)
274
+ VALUES (@subjectEntityId, @predicate, @objectText, @sourcePath, @confidence, @validFrom, @updatedAt)
275
+ `);
276
+ // The engine's own header in a daily note is bookkeeping, not something anyone said: the day the file is about, which
277
+ // its name already says. Only a note the engine wrote has it (`engineWrote`, told from its front matter alone, not
278
+ // its place, its name or its body), and only that value is dropped, never one a person put there (dailyNote.ts).
279
+ for (const predicate of FACT_PREDICATES) {
280
+ const value = note.frontmatter[predicate];
281
+ if (value === undefined || value === null)
282
+ continue;
283
+ if (predicate === dailyNote_1.DAILY_NOTE_HEADER_KEY && engineWrote && (0, dailyNote_1.isEngineHeaderDate)(filePath, value))
284
+ continue;
285
+ const objectText = Array.isArray(value) ? value.map(v => (v == null ? '' : factText(v))).join(', ') : factText(value);
286
+ insertFact.run({
287
+ subjectEntityId: entityRow.id,
288
+ predicate,
289
+ objectText,
290
+ sourcePath: filePath,
291
+ confidence: 1.0,
292
+ validFrom: now,
293
+ updatedAt: now,
294
+ });
295
+ }
296
+ // 12. Save wikilink relations — delete old, insert new
297
+ db.prepare('DELETE FROM relations WHERE source_note = ?').run(filePath);
298
+ const insertRelation = db.prepare(`
299
+ INSERT INTO relations (source_entity_id, relation, target_entity_id, source_note, confidence)
300
+ VALUES (?, 'links_to', ?, ?, 1.0)
301
+ `);
302
+ const targets = resolveWikilinks(db, note.wikilinks);
303
+ for (const wikilink of note.wikilinks) {
304
+ const target = targets.get(asciiLower(wikilink));
305
+ if (target !== undefined)
306
+ insertRelation.run(entityRow.id, target, filePath);
307
+ }
308
+ return entityRow.id;
309
+ }
310
+ /**
311
+ * After a pass's rows are committed: the written chunks take the vectors of replaced chunks with the same text, the
312
+ * replaced chunks' vectors go, the rest are embedded, and only then is the note's hash committed (two-phase).
313
+ */
314
+ async function storeVectors(db, filePath, options, pass) {
315
+ // A chunk whose text did not change keeps the vector it already has: copy it to the replacement
316
+ // chunk before the old vectors go, and embed only the chunks with new text. An edited or
317
+ // appended file then costs what changed, not what it holds (a ledger is one chunk per event).
318
+ const { insertedChunks, kept, oldChunkIds, vectorSource, vectorsMatch, embedsHere, noteHash } = pass;
319
+ const reused = new Set();
320
+ if (vectorsMatch && options.vectorIndex?.copy) {
321
+ for (const [chunkId, fromId] of vectorSource) {
322
+ if (options.vectorIndex.copy(fromId, chunkId))
323
+ reused.add(chunkId);
324
+ }
325
+ }
326
+ if (options.vectorIndex) {
327
+ for (const id of oldChunkIds) {
328
+ options.vectorIndex.delete(id);
329
+ }
330
+ }
331
+ // 9. Embed chunks if provider given (outside transaction — async)
332
+ if (embedsHere && options.embeddingProvider && options.vectorIndex) {
333
+ const { embeddingProvider, vectorIndex } = options;
334
+ const toEmbed = insertedChunks.slice(kept).filter(c => !reused.has(c.id));
335
+ const texts = toEmbed.map(c => c.text);
336
+ if (texts.length > 0) {
337
+ const embeddings = await embeddingProvider.embed(texts, options.embedBudgetMs === undefined ? undefined : { budgetMs: options.embedBudgetMs * Math.ceil(texts.length / EMBED_REQUEST_TEXTS) });
338
+ // Fix #3: Warn on embedding count mismatch instead of silently losing vectors
339
+ if (embeddings.length !== texts.length) {
340
+ console.error(`[pipeline] embedding count mismatch: expected ${texts.length}, got ${embeddings.length} for ${filePath}`);
341
+ }
342
+ for (let i = 0; i < toEmbed.length; i++) {
343
+ if (embeddings[i]) {
344
+ if (vectorIndex.insertNew)
345
+ vectorIndex.insertNew(toEmbed[i].id, embeddings[i]);
346
+ else
347
+ vectorIndex.upsert(toEmbed[i].id, embeddings[i]);
348
+ }
349
+ }
350
+ }
351
+ // Fix #2 (cont): Commit the real note_hash only after embedding succeeds
352
+ db.prepare('UPDATE notes SET note_hash = ? WHERE path = ?').run(noteHash, filePath);
353
+ }
354
+ }
355
+ /** Per database: the last pass's AppendBase of each file, for at most this many files, the most recently indexed. */
356
+ const MAX_APPEND_BASES = 64;
357
+ const appendBases = new WeakMap();
358
+ /** The file's base, removed: a pass that fails leaves none, and one that succeeds records its own. */
359
+ function takeAppendBase(db, filePath) {
360
+ const bases = appendBases.get(db);
361
+ const base = bases?.get(filePath);
362
+ bases?.delete(filePath);
363
+ return base;
364
+ }
365
+ function recordAppendBase(db, filePath, base) {
366
+ let bases = appendBases.get(db);
367
+ if (!bases)
368
+ appendBases.set(db, bases = new Map());
369
+ bases.delete(filePath);
370
+ bases.set(filePath, base);
371
+ if (bases.size > MAX_APPEND_BASES)
372
+ bases.delete(bases.keys().next().value);
373
+ }
374
+ const chunkingOf = (options) => `${options.chunkOptions?.maxTokens ?? 400}/${options.chunkOptions?.overlapTokens ?? 0}`;
375
+ const hashKey = (chunkHash) => parseInt(chunkHash.slice(0, 12), 16);
376
+ /** Whether any extraction lane would read the note: each reads all of it, so such a pass is a full one. */
377
+ function laneReadsNote(options) {
378
+ return !!options.factExtractor
379
+ || (0, factsWritePath_1.factsWritePathEnabled)()
380
+ || !!(options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)())
381
+ || !!(options.eventExtractor && (0, eventExtractor_1.chronicleEnabled)())
382
+ || !!(options.preferenceExtractor && (0, preferenceExtractor_1.prefsEnabled)());
383
+ }
384
+ /** The base a full pass leaves, or null when an append to the note could not be indexed by its appended part. */
385
+ function appendBaseOf(filePath, raw, content, detailed, kind, cut, rows, noteHash, options) {
386
+ if ((0, dailyNote_1.isDailyNotePath)(filePath) || raw.length === 0 || raw[raw.length - 1] !== 0x0a || !(0, buffer_1.isUtf8)(raw))
387
+ return null;
388
+ if (!detailed.titleFromHeading || !cut.last || cut.lastFirst === undefined)
389
+ return null;
390
+ if (cut.lastFirst >= rows.ids.length)
391
+ return null;
392
+ if (!(0, parser_1.wikilinksSettled)(detailed.rawBody))
393
+ return null;
394
+ // Where the last section's first line starts in the file: the body is the text after the frontmatter, trimmed.
395
+ const lines = detailed.note.body.split('\n');
396
+ let at = content.length - detailed.rawBody.trimStart().length;
397
+ for (let i = 0; i < cut.last.line; i++)
398
+ at += lines[i].length + 1;
399
+ if (!content.startsWith(lines[cut.last.line], at))
400
+ return null;
401
+ return {
402
+ bytes: raw.length,
403
+ noteHash,
404
+ chunking: chunkingOf(options),
405
+ title: detailed.note.title,
406
+ kind,
407
+ frontmatter: detailed.note.frontmatter,
408
+ frontmatterJson: JSON.stringify(detailed.note.frontmatter),
409
+ wikilinks: detailed.note.wikilinks,
410
+ last: { ...cut.last, byte: Buffer.byteLength(content.slice(0, at)) },
411
+ tailFirstId: rows.ids[cut.lastFirst],
412
+ headCount: cut.lastFirst,
413
+ headHashes: new Set(rows.hashes.slice(0, cut.lastFirst).map(hashKey)),
414
+ };
415
+ }
416
+ /**
417
+ * Index `raw`, the file's bytes, by what was appended to the bytes `base` describes (see AppendBase). Returns false,
418
+ * having changed nothing, when it cannot; the caller then indexes the file in full.
419
+ */
420
+ async function indexAppend(db, filePath, raw, options, base) {
421
+ if (raw.length <= base.bytes || chunkingOf(options) !== base.chunking || laneReadsNote(options))
422
+ return false;
423
+ // The bytes that were indexed, unchanged, then valid text: hashed on the way to the note's new hash.
424
+ const hash = (0, crypto_1.createHash)('sha256').update(raw.subarray(0, base.bytes));
425
+ if (hash.copy().digest('hex') !== base.noteHash)
426
+ return false;
427
+ const appended = raw.subarray(base.bytes);
428
+ if (!(0, buffer_1.isUtf8)(appended))
429
+ return false;
430
+ const noteHash = hash.update(appended).digest('hex');
431
+ // The note from its last section on: that section as it was indexed, then what was appended. The body is trimmed,
432
+ // and this text starts with a line that is not blank (a heading, or the body's first line), so trimming its end
433
+ // trims the body's.
434
+ const kept = raw.toString('utf8', base.last.byte, base.bytes);
435
+ const tail = kept + appended.toString('utf8');
436
+ const lines = tail.trimEnd().split('\n');
437
+ const cut = (0, chunker_1.chunkBodyFrom)(filePath, lines, base.last, options.chunkOptions);
438
+ if (!cut.last || cut.lastFirst === undefined || cut.lastFirst >= cut.chunks.length)
439
+ return false;
440
+ const wikilinks = [...new Set([...base.wikilinks, ...(0, parser_1.wikilinkTargets)(tail, kept.length)])];
441
+ const stat = await promises_1.default.stat(filePath);
442
+ const modifiedAt = stat.mtime.toISOString();
443
+ const now = new Date().toISOString();
444
+ const embeddingConfigured = !!(options.embeddingProvider && options.vectorIndex);
445
+ const vectorsMatch = embeddingConfigured && storedVectorsMatch(options);
446
+ const embedsHere = embeddingConfigured && !(vectorsMatch && options.vectorIndex.rebuilding?.() === true);
447
+ const write = db.transaction(() => {
448
+ const existing = db.prepare('SELECT note_hash FROM notes WHERE path = ?').pluck().get(filePath);
449
+ if (existing !== base.noteHash)
450
+ return null;
451
+ if (db.prepare('SELECT 1 FROM structured_rows WHERE source_path = ? LIMIT 1').get(filePath))
452
+ return null;
453
+ const oldTail = db.prepare(`SELECT id, heading_path, text, start_line, end_line, token_count, chunk_hash FROM chunks
454
+ WHERE note_path = ? AND id >= ? ORDER BY id`).all(filePath, base.tailFirstId);
455
+ const headIds = db.prepare('SELECT id FROM chunks WHERE note_path = ? AND id < ? ORDER BY id')
456
+ .pluck().all(filePath, base.tailFirstId);
457
+ if (headIds.length !== base.headCount)
458
+ return null;
459
+ // As a full pass does: the chunks from the top that are stored as they are now are kept (all of those before the
460
+ // last section, whose lines did not change), and only if each kept chunk has its vector when the pass embeds.
461
+ const keep = unchangedPrefix(oldTail, cut.chunks);
462
+ const keptIds = [...headIds, ...oldTail.slice(0, keep).map(c => c.id)];
463
+ if (keptIds.length > 0 && embedsHere
464
+ && (!vectorsMatch || options.vectorIndex.countStored?.(keptIds) !== keptIds.length))
465
+ return null;
466
+ const added = cut.chunks.slice(keep).map(chunk => ({ chunk, chunkHash: (0, hasher_1.hashContent)(chunk.text) }));
467
+ if (added.some(a => base.headHashes.has(hashKey(a.chunkHash))))
468
+ return null;
469
+ upsertNoteRow(db, filePath, {
470
+ title: base.title, kind: base.kind, noteHash: embedsHere ? '' : noteHash, modifiedAt, frontmatterJson: base.frontmatterJson,
471
+ });
472
+ if (keep < oldTail.length)
473
+ db.prepare('DELETE FROM chunks WHERE note_path = ? AND id >= ?').run(filePath, oldTail[keep].id);
474
+ const oldIdByHash = new Map();
475
+ for (const old of oldTail)
476
+ if (!oldIdByHash.has(old.chunk_hash))
477
+ oldIdByHash.set(old.chunk_hash, old.id);
478
+ const insertChunk = db.prepare(`
479
+ INSERT INTO chunks (note_path, heading_path, text, start_line, end_line, token_count, chunk_hash)
480
+ VALUES (@notePath, @headingPath, @text, @startLine, @endLine, @tokenCount, @chunkHash)
481
+ RETURNING id
482
+ `);
483
+ const insertedChunks = oldTail.slice(0, keep).map((old, i) => ({ id: old.id, text: cut.chunks[i].text }));
484
+ const vectorSource = new Map();
485
+ for (const { chunk, chunkHash } of added) {
486
+ const row = insertChunk.get({
487
+ notePath: chunk.notePath,
488
+ headingPath: chunk.headingPath,
489
+ text: chunk.text,
490
+ startLine: chunk.startLine,
491
+ endLine: chunk.endLine,
492
+ tokenCount: chunk.tokenCount,
493
+ chunkHash,
494
+ });
495
+ insertedChunks.push({ id: row.id, text: chunk.text });
496
+ const sameText = oldIdByHash.get(chunkHash);
497
+ if (sameText !== undefined)
498
+ vectorSource.set(row.id, sameText);
499
+ }
500
+ rebuildNoteRows(db, filePath, { title: base.title, frontmatter: base.frontmatter, wikilinks }, base.kind, false, now);
501
+ return {
502
+ insertedChunks,
503
+ kept: keep,
504
+ oldChunkIds: oldTail.slice(keep).map(old => old.id),
505
+ vectorSource,
506
+ chunkHashes: [...oldTail.slice(0, keep).map(old => old.chunk_hash), ...added.map(a => a.chunkHash)],
507
+ };
508
+ });
509
+ const written = write();
510
+ if (!written)
511
+ return false;
512
+ await storeVectors(db, filePath, options, { ...written, vectorsMatch, embedsHere, noteHash });
513
+ // The next append starts from this note's new last section.
514
+ if (raw[raw.length - 1] === 0x0a && (0, parser_1.wikilinksSettled)(tail, kept.length)) {
515
+ let at = 0;
516
+ for (let i = base.last.line; i < cut.last.line; i++)
517
+ at += lines[i - base.last.line].length + 1;
518
+ for (const chunkHash of written.chunkHashes.slice(0, cut.lastFirst))
519
+ base.headHashes.add(hashKey(chunkHash));
520
+ recordAppendBase(db, filePath, {
521
+ ...base,
522
+ bytes: raw.length,
523
+ noteHash,
524
+ wikilinks,
525
+ last: { ...cut.last, byte: base.last.byte + Buffer.byteLength(tail.slice(0, at)) },
526
+ tailFirstId: written.insertedChunks[cut.lastFirst].id,
527
+ headCount: base.headCount + cut.lastFirst,
528
+ });
529
+ }
530
+ return true;
531
+ }
532
+ async function readRegularFile(filePath) {
533
+ try {
534
+ return await promises_1.default.readFile(filePath, { flag: fs_1.constants.O_RDONLY | fs_1.constants.O_NOFOLLOW | fs_1.constants.O_NONBLOCK });
535
+ }
536
+ catch (err) {
537
+ if (err.code === 'ELOOP')
538
+ throw new Error(`refusing to index ${JSON.stringify(filePath)}: it is a symbolic link`);
539
+ throw err;
540
+ }
541
+ }
542
+ /**
543
+ * Index one file. Runs under the same per-file lock every writer of that file takes, so an index
544
+ * pass can never read a file, lose the race to a rewrite (memory retirement), and then commit the
545
+ * stale text over the newer index.
546
+ */
547
+ function indexFile(db, filePath, options = {}) {
548
+ return (0, writer_1.withDailyFileLock)(filePath, () => indexFileUnlocked(db, filePath, options));
549
+ }
550
+ async function indexFileUnlocked(db, filePath, options = {}) {
551
+ // 0. A note over the cap is not read (a read allocates the file, and parsing it a multiple of that)
552
+ const { size } = await promises_1.default.stat(filePath);
553
+ if (size > exports.MAX_NOTE_BYTES)
554
+ throw new NoteTooLargeError(filePath, size);
555
+ // 1. Read file content. Never through a symbolic link: the walker does not list one, and a caller that names one (the
556
+ // watcher is told of a link to a file as the file) must not pull a file from outside the vault into recall.
557
+ const raw = await readRegularFile(filePath);
558
+ // A note this process indexed that has only been appended to since is indexed by what was appended (see
559
+ // indexAppend); anything else is read and indexed in full, below.
560
+ const base = takeAppendBase(db, filePath);
561
+ if (base && await indexAppend(db, filePath, raw, options, base))
562
+ return;
563
+ const content = raw.toString('utf8');
564
+ // 2. Hash content (a daily note's hash also names the layout its chunks are cut in, see dailyEntries.ts)
565
+ const noteHash = (0, dailyEntries_1.noteContentHash)(filePath, content);
566
+ // 3. Check if unchanged
567
+ const existing = db.prepare('SELECT note_hash FROM notes WHERE path = ?').get(filePath);
568
+ const noteUnchanged = !!(existing && existing.note_hash === noteHash);
569
+ if (noteUnchanged && base?.noteHash === noteHash)
570
+ recordAppendBase(db, filePath, base); // it still describes the note
571
+ if (noteUnchanged) {
572
+ // The facts of an unchanged note are not rebuilt below, so a row an earlier build made of the engine's own header
573
+ // would stay. Remove it here: one statement for a daily note, no chunk or extractor involved.
574
+ removeDailyHeaderFacts(db, filePath, content);
575
+ // C2: unchanged content skips extraction ONLY when current-version rows
576
+ // already exist (ADR §4.2 — the cache key is (source_hash,
577
+ // extraction_version)). An extraction_version change invalidates the cache
578
+ // and falls through to re-extract, still with zero model calls until the
579
+ // (re-)extraction itself.
580
+ if ((options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)()) ||
581
+ (options.eventExtractor && (0, eventExtractor_1.chronicleEnabled)()) ||
582
+ (options.preferenceExtractor && (0, preferenceExtractor_1.prefsEnabled)())) {
583
+ // The cache key is per lane: (source_hash, extraction_version). Skip
584
+ // only when EVERY enabled lane is already done for these bytes — an
585
+ // enabled lane without rows (first run, version bump) falls through.
586
+ // "Done" is either half of the key: a current row for this hash, or a
587
+ // completed zero-yield run (#189, lane_runs — a lane that produced
588
+ // nothing has no row to record it).
589
+ const factsEnabled = !!(options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)());
590
+ const eventsEnabled = !!(options.eventExtractor && (0, eventExtractor_1.chronicleEnabled)());
591
+ const prefsEnabledLane = !!(options.preferenceExtractor && (0, preferenceExtractor_1.prefsEnabled)());
592
+ const haveCurrentVersionRows = !factsEnabled || !!db.prepare(`SELECT id FROM structured_rows
593
+ WHERE source_path = ? AND row_kind = 'fact' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, typedFacts_1.FACTS_EXTRACTION_VERSION, noteHash) || !!(0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'fact', noteHash, typedFacts_1.FACTS_EXTRACTION_VERSION);
594
+ const haveCurrentEventRows = !eventsEnabled || !!db.prepare(`SELECT id FROM structured_rows
595
+ WHERE source_path = ? AND row_kind = 'event' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, eventExtractor_1.EVENTS_EXTRACTION_VERSION, noteHash) || !!(0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'event', noteHash, eventExtractor_1.EVENTS_EXTRACTION_VERSION);
596
+ const haveCurrentPrefRows = !prefsEnabledLane || !!db.prepare(`SELECT id FROM structured_rows
597
+ WHERE source_path = ? AND row_kind = 'preference' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, preferenceExtractor_1.PREFS_EXTRACTION_VERSION, noteHash) || !!(0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'preference', noteHash, preferenceExtractor_1.PREFS_EXTRACTION_VERSION);
598
+ if (haveCurrentVersionRows && haveCurrentEventRows && haveCurrentPrefRows) {
599
+ return; // unchanged content: zero model calls
600
+ }
601
+ // else: a lane lacks current rows → fall through, stale rows deleted below.
602
+ }
603
+ else {
604
+ return; // unchanged — skip
605
+ }
606
+ }
607
+ // 4. Parse note
608
+ const detailed = (0, parser_1.parseNoteDetailed)(filePath, content);
609
+ const parsed = detailed.note;
610
+ // 5. Get mtime
611
+ const stat = await promises_1.default.stat(filePath);
612
+ const modifiedAt = stat.mtime.toISOString();
613
+ const now = new Date().toISOString();
614
+ const kind = noteKind(filePath, parsed.frontmatter);
615
+ // HARMONIZATION: lane ownership — when the fleet's typed-facts lane is armed
616
+ // (extractor + RENOR_TYPED_FACTS), it is THE fact-lane candidate generator;
617
+ // this branch's local-rules capture lane (RENOR_FACTS_WRITE_PATH) stands
618
+ // down for that note so the same note is never fact-extracted twice (two
619
+ // generators would double-write the same identities under two extraction
620
+ // versions). With only RENOR_FACTS_WRITE_PATH on, behavior is unchanged
621
+ // from this branch (local rules + DecisionModel seam).
622
+ const typedFactsLaneArmed = !!(options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)());
623
+ const embeddingConfigured = !!(options.embeddingProvider && options.vectorIndex);
624
+ // Stored vectors may be kept or copied only if they are in the space this provider writes.
625
+ const vectorsMatch = embeddingConfigured && storedVectorsMatch(options);
626
+ // While the index is unfinished in this provider's space, the background job that completes it embeds every chunk
627
+ // that has no vector, this file's included. The file's chunks are then written without vectors and left to it, so
628
+ // no chunk is embedded twice and a save embeds nothing it did not change (a ledger appended to keeps its earlier
629
+ // events as they are). Decided in the same synchronous step as the write below, so the job cannot have finished
630
+ // in between: it ends only when no chunk lacks a vector.
631
+ const embedsHere = embeddingConfigured && !(vectorsMatch && options.vectorIndex.rebuilding?.() === true);
632
+ // Fix #2: Use two-phase note_hash commit. If this file embeds its chunks, store ''
633
+ // so that a failed embed() causes the file to be re-processed on the next run.
634
+ // Store the real hash only after embeddings succeed (or immediately if nothing is embedded here).
635
+ const transactionHash = embedsHere ? '' : noteHash;
636
+ // 6-12. Wrap all DB mutations in a transaction.
637
+ // Fix #1: Query old chunk IDs inside the transaction so they are only deleted
638
+ // from the vector index AFTER the transaction commits successfully.
639
+ const upsertAll = db.transaction(() => {
640
+ // The stored chunks, top to bottom (a note's chunk ids ascend with position). The ones that
641
+ // are replaced have their vectors deleted after commit.
642
+ const oldChunks = db.prepare('SELECT id, heading_path, text, start_line, end_line, token_count, chunk_hash FROM chunks WHERE note_path = ? ORDER BY id').all(filePath);
643
+ // The old chunk holding each text: a chunk whose text is unchanged keeps its vector.
644
+ const oldIdByHash = new Map();
645
+ for (const old of oldChunks)
646
+ if (!oldIdByHash.has(old.chunk_hash))
647
+ oldIdByHash.set(old.chunk_hash, old.id);
648
+ const cut = (0, chunker_1.chunkNoteCut)(parsed, options.chunkOptions);
649
+ const chunks = cut.chunks;
650
+ // 6. Upsert note row
651
+ upsertNoteRow(db, filePath, {
652
+ title: parsed.title, kind, noteHash: transactionHash, modifiedAt, frontmatterJson: JSON.stringify(parsed.frontmatter),
653
+ });
654
+ // 7. Delete old chunks. structured_rows.source_chunk_id cascades, and
655
+ // the per-lane cache check happens after this rewrite. Copy current rows
656
+ // out first and reattach them to the replacement chunk, or a current lane
657
+ // looks stale and is extracted again.
658
+ const preservedStructured = db.prepare(`SELECT r.row_kind, r.source_hash, r.scope, r.extraction_version, r.valid_from, r.valid_to,
659
+ r.payload_json, r.dedupe_key, r.confirm_count, r.created_at, c.text AS source_text
660
+ FROM structured_rows r
661
+ LEFT JOIN chunks c ON c.id = r.source_chunk_id
662
+ WHERE r.source_path = ?`).all(filePath);
663
+ const preservedByKind = new Map();
664
+ for (const saved of preservedStructured) {
665
+ const kind = String(saved.row_kind);
666
+ const bucket = preservedByKind.get(kind) ?? [];
667
+ bucket.push(saved);
668
+ preservedByKind.set(kind, bucket);
669
+ }
670
+ // The chunks from the top of an edited note that are already stored exactly as they are now
671
+ // keep their rows, search entries and vectors; only the rest is replaced (a ledger appended to
672
+ // replaces its last event and adds the new one). The whole note is replaced instead when the
673
+ // bytes are unchanged (a lane re-run), when the last pass over it did not finish (an empty
674
+ // hash), when structured rows hang off its chunks, or when a stored chunk lacks its vector,
675
+ // so a full pass heals it, as it always did.
676
+ let keep = 0;
677
+ if (existing && !noteUnchanged && existing.note_hash !== '' && preservedStructured.length === 0) {
678
+ keep = unchangedPrefix(oldChunks, chunks);
679
+ if (keep > 0 && embedsHere
680
+ && (!vectorsMatch || options.vectorIndex.countStored?.(oldChunks.slice(0, keep).map(c => c.id)) !== keep)) {
681
+ keep = 0;
682
+ }
683
+ }
684
+ if (keep === 0)
685
+ db.prepare('DELETE FROM chunks WHERE note_path = ?').run(filePath);
686
+ else
687
+ db.prepare('DELETE FROM chunks WHERE note_path = ? AND id > ?').run(filePath, oldChunks[keep - 1].id);
688
+ // 8. Insert the chunks that are not kept, collecting inserted IDs
689
+ const insertChunk = db.prepare(`
690
+ INSERT INTO chunks (note_path, heading_path, text, start_line, end_line, token_count, chunk_hash)
691
+ VALUES (@notePath, @headingPath, @text, @startLine, @endLine, @tokenCount, @chunkHash)
692
+ RETURNING id
693
+ `);
694
+ const insertedChunks = oldChunks.slice(0, keep).map((old, i) => ({ id: old.id, text: chunks[i].text }));
695
+ const chunkHashes = oldChunks.slice(0, keep).map(old => old.chunk_hash); // of every chunk, in order
696
+ const vectorSource = new Map(); // new chunk id -> old chunk id with the same text
697
+ for (const chunk of chunks.slice(keep)) {
698
+ const chunkHash = (0, hasher_1.hashContent)(chunk.text);
699
+ chunkHashes.push(chunkHash);
700
+ const row = insertChunk.get({
701
+ notePath: chunk.notePath,
702
+ headingPath: chunk.headingPath,
703
+ text: chunk.text,
704
+ startLine: chunk.startLine,
705
+ endLine: chunk.endLine,
706
+ tokenCount: chunk.tokenCount,
707
+ chunkHash,
708
+ });
709
+ insertedChunks.push({ id: row.id, text: chunk.text });
710
+ const sameText = oldIdByHash.get(chunkHash);
711
+ if (sameText !== undefined)
712
+ vectorSource.set(row.id, sameText);
713
+ }
714
+ // Reattach rows copied out before the chunk delete. A lane whose version
715
+ // and hash are still current keeps its row; a stale lane stays deleted so
716
+ // only that lane re-extracts. The row keeps the replacement chunk that
717
+ // still contains its source text. Chunk 0 is only the fallback when that
718
+ // text is gone; it is not the provenance of every preserved row.
719
+ if (preservedStructured.length > 0 && insertedChunks.length > 0) {
720
+ const reattach = db.prepare(`INSERT INTO structured_rows
721
+ (row_kind, source_path, source_hash, source_chunk_id, scope, extraction_version,
722
+ valid_from, valid_to, payload_json, dedupe_key, confirm_count, created_at)
723
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
724
+ for (const saved of preservedStructured) {
725
+ const kind = saved.row_kind;
726
+ const version = saved.extraction_version;
727
+ const hash = saved.source_hash;
728
+ const current = saved.valid_to == null
729
+ && version === currentVersion(kind)
730
+ && hash === noteHash;
731
+ if (!current)
732
+ continue;
733
+ const sourceText = typeof saved.source_text === 'string' ? saved.source_text : '';
734
+ const attributed = sourceText
735
+ ? (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, { statement: sourceText })
736
+ : null;
737
+ const chunkId = attributed?.chunkId ?? insertedChunks[0].id;
738
+ reattach.run(kind, filePath, hash, chunkId, saved.scope, version, saved.valid_from, saved.valid_to, saved.payload_json, saved.dedupe_key, saved.confirm_count, saved.created_at);
739
+ }
740
+ }
741
+ // 10-12. Entities, frontmatter facts and wikilink relations: delete old, insert new
742
+ const entityId = rebuildNoteRows(db, filePath, parsed, kind, (0, dailyNote_1.isEngineDailyNote)(filePath, content), now);
743
+ return {
744
+ insertedChunks,
745
+ kept: keep,
746
+ oldChunkIds: oldChunks.slice(keep).map(old => old.id),
747
+ vectorSource,
748
+ entityId,
749
+ preservedByKind,
750
+ cut,
751
+ chunkHashes,
752
+ };
753
+ });
754
+ // Fix #1 (cont): Delete old vectors only after transaction commits successfully
755
+ const { insertedChunks, kept, oldChunkIds, vectorSource, entityId, preservedByKind, cut, chunkHashes } = upsertAll();
756
+ // These post-failure helpers and the in-transaction reattach above must
757
+ // agree on one thing: a preserved row only counts as that lane's current row
758
+ // when its extraction_version matches the version that lane writes today
759
+ // (currentVersion), which is the same key the lane cache-skip below uses.
760
+ function preservedLaneMatches(kind) {
761
+ const savedRows = preservedByKind.get(kind) ?? [];
762
+ return savedRows.some(saved => (saved.valid_to == null
763
+ && saved.source_hash === noteHash
764
+ && saved.extraction_version === currentVersion(kind)));
765
+ }
766
+ function restorePreservedLane(kind) {
767
+ const savedRows = (preservedByKind.get(kind) ?? []).filter(saved => (saved.valid_to == null
768
+ && saved.source_hash === noteHash
769
+ && saved.extraction_version === currentVersion(kind)));
770
+ if (savedRows.length === 0 || insertedChunks.length === 0)
771
+ return;
772
+ const restore = db.prepare(`INSERT INTO structured_rows
773
+ (row_kind, source_path, source_hash, source_chunk_id, scope, extraction_version,
774
+ valid_from, valid_to, payload_json, dedupe_key, confirm_count, created_at)
775
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
776
+ for (const saved of savedRows) {
777
+ const sourceText = typeof saved.source_text === 'string' ? saved.source_text : '';
778
+ const attributed = sourceText
779
+ ? (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, { statement: sourceText })
780
+ : null;
781
+ restore.run(kind, filePath, saved.source_hash, attributed?.chunkId ?? insertedChunks[0].id, saved.scope, saved.extraction_version, saved.valid_from, saved.valid_to, saved.payload_json, saved.dedupe_key, saved.confirm_count, saved.created_at);
782
+ }
783
+ }
784
+ await storeVectors(db, filePath, options, { insertedChunks, kept, oldChunkIds, vectorSource, vectorsMatch, embedsHere, noteHash });
785
+ // 11b. LLM fact extraction — runs outside transaction, after embeddings (async/network).
786
+ // Fix #5: On extraction failure, reset note_hash to '' so the next unchanged-file
787
+ // indexing pass re-processes this note and retries extraction. Otherwise the real
788
+ // hash committed above (embedding path) or in the transaction marks the note as
789
+ // fully current while its LLM facts are missing/stale — and the transaction has
790
+ // already deleted the prior facts for this source, making the loss sticky.
791
+ if (options.factExtractor) {
792
+ try {
793
+ const llmFacts = await options.factExtractor(parsed.title, parsed.body);
794
+ if (llmFacts.length > 0) {
795
+ const insertLlmFact = db.prepare(`
796
+ INSERT INTO facts (subject_entity_id, predicate, object_text, source_path, confidence, valid_from, updated_at)
797
+ VALUES (?, ?, ?, ?, ?, ?, ?)
798
+ `);
799
+ for (const fact of llmFacts) {
800
+ insertLlmFact.run(entityId, fact.predicate, `${fact.subject}: ${fact.object}`, filePath, fact.confidence, now, now);
801
+ }
802
+ }
803
+ }
804
+ catch (err) {
805
+ console.error(`[pipeline] LLM extraction failed for ${filePath}:`, err.message);
806
+ // #188: reset the hash so the next pass retries (Fix #5), AND rethrow.
807
+ // Swallowing it here left `indexVault` counting the note as INDEXED, so
808
+ // `index` reported 0 errors and exited 0 with this note's facts missing —
809
+ // the blanket catch #188 is about. The structured lanes below already
810
+ // rethrow; this lane now fails the same way, so a real failure reaches the
811
+ // process exit status. Every other note still indexes.
812
+ db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
813
+ throw err;
814
+ }
815
+ }
816
+ // C2 facts write-path (flag-gated, default OFF): save-time typed fact
817
+ // capture into the structured-row store through the real index pipeline.
818
+ // Flag off = zero decision calls, zero new writes, byte-identical shipped
819
+ // behavior. The bitemporal model is the rowStore's identity-keyed
820
+ // supersession (the markdown ledger stays `_mcp_`-lifecycle-only per
821
+ // inventory-facts). Fix #5 pattern: a capture failure resets note_hash so
822
+ // the next pass retries — failed capture never marks a note current.
823
+ // HARMONIZATION: stands down when the fleet's typed-facts lane is armed
824
+ // (single fact-lane candidate generator — see lane ownership above).
825
+ if (!typedFactsLaneArmed && (0, factsWritePath_1.factsWritePathEnabled)()) {
826
+ try {
827
+ await (0, factsWritePath_1.captureNoteFacts)(db, {
828
+ notePath: filePath,
829
+ noteTitle: parsed.title,
830
+ frontmatter: parsed.frontmatter,
831
+ sourceHash: noteHash,
832
+ chunks: insertedChunks,
833
+ }, options.decisionModel);
834
+ }
835
+ catch (err) {
836
+ console.error(`[pipeline] facts write-path capture failed for ${filePath}:`, err.message);
837
+ // Same #188 contract as the legacy lane above: reset the hash so the next
838
+ // pass retries, then rethrow so the failure is counted and reaches the
839
+ // exit status instead of being hidden by a blanket catch.
840
+ db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
841
+ throw err;
842
+ }
843
+ }
844
+ // 11c. C2 typed numeric facts → structured_rows (RENOR_TYPED_FACTS, default
845
+ // off; ADR-0003 §4 provenance contract + §4.2 cache invalidation). The
846
+ // (source_hash, extraction_version) skip happens BEFORE any model call:
847
+ // unchanged content is either already covered by current-version rows or by
848
+ // a completed zero-yield run (lane_runs, #189) and costs zero extractor calls.
849
+ // Edit path: stale rows for this source are deleted and re-extracted. On
850
+ // extractor-parse-error the note hash resets so the next pass retries;
851
+ // parse failures are never cached and never silently [].
852
+ // HARMONIZATION: candidates flow through the DecisionModel seam
853
+ // (adjudicateAndWriteRows) BEFORE any row is written — capture-gate /
854
+ // sensitivity-guardrail (a `sensitive` decision blocks the candidate) and
855
+ // dedupe adjudication with the confirm-count + v11 unique-index semantics.
856
+ if (options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)()) {
857
+ // One predicate, both halves of the lane cache: a current row for these
858
+ // bytes, OR a completed zero-yield run for them (#189). A lane that
859
+ // produced nothing must be recognised BEFORE any extraction work starts,
860
+ // or it is re-extracted on every unchanged pass forever.
861
+ const structured = db.prepare(`SELECT id FROM structured_rows
862
+ WHERE source_path = ? AND row_kind = 'fact' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, typedFacts_1.FACTS_EXTRACTION_VERSION, noteHash)
863
+ ?? (0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'fact', noteHash, typedFacts_1.FACTS_EXTRACTION_VERSION);
864
+ if (!structured) {
865
+ // This lane is stale. Re-extract only fact rows; a current fact lane
866
+ // falls through so events and preferences still run. The previous rows
867
+ // stay until the new extraction returns, so an extractor that yields
868
+ // nothing does not empty the lane. An extractor that THROWS does: a
869
+ // stale-version lane is left empty on purpose (see the catch below).
870
+ try {
871
+ const extracted = await options.numericFactExtractor(parsed.title, parsed.body);
872
+ (0, rowStore_1.deleteStructuredRowsForSourceKind)(db, filePath, 'fact');
873
+ const validFrom = (0, typedFacts_1.contentDateFromFrontmatter)(parsed.frontmatter);
874
+ const firstChunkId = db.prepare('SELECT id FROM chunks WHERE note_path = ? ORDER BY id LIMIT 1').get(filePath)?.id;
875
+ const candidates = extracted.map((f) => {
876
+ const attributed = (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, f);
877
+ return {
878
+ payload: { value: f.value, unit: f.unit, verb: f.verb, object: f.object, event: f.event },
879
+ sourceChunkId: attributed?.chunkId ?? firstChunkId ?? 1,
880
+ sourceText: attributed?.text ?? `${f.value} ${f.object}`.trim(),
881
+ };
882
+ });
883
+ const outcome = await (0, writePathAdjudication_1.adjudicateAndWriteRows)(db, {
884
+ rowKind: 'fact',
885
+ extractionVersion: typedFacts_1.FACTS_EXTRACTION_VERSION,
886
+ notePath: filePath,
887
+ sourceHash: noteHash,
888
+ validFrom,
889
+ candidates,
890
+ }, options.decisionModel);
891
+ // #189: the zero-yield checkpoint. This lane used to be called again
892
+ // on every unchanged pass because a zero yield leaves no row to mark it
893
+ // current; the completed run is recorded here instead — after the
894
+ // extractor and the writes both succeeded, so the throw path below
895
+ // stays uncached (it resets the note hash and must retry). What is
896
+ // recorded is what THIS run's writes returned — a fact of the run, not a
897
+ // count re-read from structured_rows, which a concurrent writer could
898
+ // tear (review B1) — so a zero is exactly "left nothing current".
899
+ (0, laneRuns_1.recordLaneRun)(db, filePath, 'fact', noteHash, typedFacts_1.FACTS_EXTRACTION_VERSION, outcome.rowsWritten);
900
+ }
901
+ catch (err) {
902
+ console.error(`[pipeline] typed-fact extraction failed for ${filePath}:`, err.message);
903
+ // Never cached, never silent []: reset the note hash so the next pass
904
+ // retries, then rethrow the explicit error (extractor-parse-error or
905
+ // otherwise) — a note whose extraction failed is never reported current.
906
+ // A stale-version row was not reattached and is deliberately NOT put
907
+ // back: a bumped extraction_version invalidates the lane, so its old
908
+ // payload must never be served as current. The lane stays empty until
909
+ // the retry succeeds, which the reset hash guarantees it will attempt.
910
+ db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
911
+ // An edited note must not keep the previous assertion as current
912
+ // against the new text. Restore only when the preserved row is still
913
+ // this lane's current row for these bytes, including a retry after an
914
+ // earlier failure cleared the note hash.
915
+ if (noteUnchanged || preservedLaneMatches('fact'))
916
+ restorePreservedLane('fact');
917
+ throw err;
918
+ }
919
+ }
920
+ }
921
+ // 11d. C3 chronicle events → structured_rows (RENOR_CHRONICLE, default off;
922
+ // ADR-0003 §4 event-lane payload + §4.1 identity-keyed supersession via the
923
+ // shared rowStore insert). The (source_hash, extraction_version) skip happens
924
+ // BEFORE any model call: unchanged content is either already covered by
925
+ // current-version event rows or by a completed zero-yield run (lane_runs,
926
+ // #189) and costs zero extractor calls. Edit path: stale rows for this source are
927
+ // deleted and re-extracted. `when` stays the user's own wording inside the
928
+ // payload; the row's valid_from comes only from the note's frontmatter date
929
+ // (NULL = unknown, never resolved/invented from the relative `when`). On
930
+ // extractor-parse-error the note hash resets so the next pass retries;
931
+ // parse failures are never cached and never silently [].
932
+ // HARMONIZATION: candidates flow through the DecisionModel seam
933
+ // (adjudicateAndWriteRows) BEFORE any row is written — capture-gate /
934
+ // sensitivity-guardrail (a `sensitive` decision blocks the candidate) and
935
+ // dedupe adjudication; the write keeps the fleet's identity-keyed
936
+ // supersession semantics (newer dated restatement supersedes; NULL dates
937
+ // never supersede).
938
+ if (options.eventExtractor && (0, eventExtractor_1.chronicleEnabled)()) {
939
+ const currentEvents = db.prepare(`SELECT id FROM structured_rows
940
+ WHERE source_path = ? AND row_kind = 'event' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, eventExtractor_1.EVENTS_EXTRACTION_VERSION, noteHash)
941
+ ?? (0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'event', noteHash, eventExtractor_1.EVENTS_EXTRACTION_VERSION);
942
+ if (!currentEvents) {
943
+ // This lane is stale. Re-extract only event rows. The previous rows
944
+ // stay until the new extraction returns.
945
+ try {
946
+ const extracted = await options.eventExtractor(parsed.title, parsed.body);
947
+ (0, rowStore_1.deleteStructuredRowsForSourceKind)(db, filePath, 'event');
948
+ const validFrom = (0, typedFacts_1.contentDateFromFrontmatter)(parsed.frontmatter);
949
+ const firstChunkId = db.prepare('SELECT id FROM chunks WHERE note_path = ? ORDER BY id LIMIT 1').get(filePath)?.id;
950
+ const candidates = extracted.map((e) => {
951
+ const attributed = (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, e);
952
+ return {
953
+ payload: { when: e.when, event: e.event, entities: e.entities },
954
+ sourceChunkId: attributed?.chunkId ?? firstChunkId ?? 1,
955
+ sourceText: attributed?.text ?? `${e.when} ${e.event}`.trim(),
956
+ };
957
+ });
958
+ const outcome = await (0, writePathAdjudication_1.adjudicateAndWriteRows)(db, {
959
+ rowKind: 'event',
960
+ extractionVersion: eventExtractor_1.EVENTS_EXTRACTION_VERSION,
961
+ notePath: filePath,
962
+ sourceHash: noteHash,
963
+ validFrom,
964
+ candidates,
965
+ }, options.decisionModel);
966
+ // Zero-yield events: same checkpoint as the fact lane (#189).
967
+ (0, laneRuns_1.recordLaneRun)(db, filePath, 'event', noteHash, eventExtractor_1.EVENTS_EXTRACTION_VERSION, outcome.rowsWritten);
968
+ }
969
+ catch (err) {
970
+ console.error(`[pipeline] event extraction failed for ${filePath}:`, err.message);
971
+ // Never cached, never silent []: reset the note hash so the next pass
972
+ // retries, then rethrow the explicit error (extractor-parse-error or
973
+ // otherwise) — a note whose extraction failed is never reported current.
974
+ db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
975
+ if (noteUnchanged || preservedLaneMatches('event'))
976
+ restorePreservedLane('event');
977
+ throw err;
978
+ }
979
+ }
980
+ }
981
+ // 11e. C4 standing preferences → structured_rows (RENOR_PREFS, default off;
982
+ // ADR-0003 §4 preference-lane payload + §4.1 identity-keyed supersession via
983
+ // the shared rowStore insert — contradiction on edit supersedes, never
984
+ // co-current, only via (row_kind, scope, norm(statement)) identity). The
985
+ // (source_hash, extraction_version) skip happens BEFORE any model call:
986
+ // unchanged content is either already covered by current-version pref rows
987
+ // or by a completed zero-yield run (lane_runs, #189) and costs zero
988
+ // extractor calls. `kind` is an unvalidated render-only enum, never in the
989
+ // dedupe key. One-off past-tense statements are dropped by the extractor's
990
+ // structural guard. On extractor-parse-error the note hash resets so the
991
+ // next pass retries; parse failures are never cached and never silently [].
992
+ // HARMONIZATION: candidates flow through the DecisionModel seam
993
+ // (adjudicateAndWriteRows) BEFORE any row is written — capture-gate /
994
+ // sensitivity-guardrail (a `sensitive` decision blocks the candidate) and
995
+ // dedupe adjudication (norm(statement) identity); the write keeps the
996
+ // fleet's identity-keyed supersession semantics (contradiction on edit →
997
+ // same-identity newer restatement supersedes, never co-current).
998
+ if (options.preferenceExtractor && (0, preferenceExtractor_1.prefsEnabled)()) {
999
+ const currentPrefs = db.prepare(`SELECT id FROM structured_rows
1000
+ WHERE source_path = ? AND row_kind = 'preference' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, preferenceExtractor_1.PREFS_EXTRACTION_VERSION, noteHash)
1001
+ ?? (0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'preference', noteHash, preferenceExtractor_1.PREFS_EXTRACTION_VERSION);
1002
+ if (!currentPrefs) {
1003
+ // This lane is stale. Re-extract only preference rows. The previous
1004
+ // rows stay until the new extraction returns.
1005
+ try {
1006
+ const extracted = await options.preferenceExtractor(parsed.title, parsed.body);
1007
+ (0, rowStore_1.deleteStructuredRowsForSourceKind)(db, filePath, 'preference');
1008
+ const validFrom = (0, typedFacts_1.contentDateFromFrontmatter)(parsed.frontmatter);
1009
+ const firstChunkId = db.prepare('SELECT id FROM chunks WHERE note_path = ? ORDER BY id LIMIT 1').get(filePath)?.id;
1010
+ const candidates = extracted.map((p) => {
1011
+ const attributed = (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, p);
1012
+ return {
1013
+ payload: { statement: p.statement, kind: p.kind },
1014
+ sourceChunkId: attributed?.chunkId ?? firstChunkId ?? 1,
1015
+ sourceText: attributed?.text ?? p.statement,
1016
+ };
1017
+ });
1018
+ const outcome = await (0, writePathAdjudication_1.adjudicateAndWriteRows)(db, {
1019
+ rowKind: 'preference',
1020
+ extractionVersion: preferenceExtractor_1.PREFS_EXTRACTION_VERSION,
1021
+ notePath: filePath,
1022
+ sourceHash: noteHash,
1023
+ validFrom,
1024
+ candidates,
1025
+ }, options.decisionModel);
1026
+ // Zero-yield preferences: same checkpoint as the fact lane (#189).
1027
+ (0, laneRuns_1.recordLaneRun)(db, filePath, 'preference', noteHash, preferenceExtractor_1.PREFS_EXTRACTION_VERSION, outcome.rowsWritten);
1028
+ }
1029
+ catch (err) {
1030
+ console.error(`[pipeline] preference extraction failed for ${filePath}:`, err.message);
1031
+ // Never cached, never silent []: reset the note hash so the next pass
1032
+ // retries, then rethrow the explicit error (extractor-parse-error or
1033
+ // otherwise) — a note whose extraction failed is never reported current.
1034
+ db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
1035
+ if (noteUnchanged || preservedLaneMatches('preference'))
1036
+ restorePreservedLane('preference');
1037
+ throw err;
1038
+ }
1039
+ }
1040
+ }
1041
+ const rows = { ids: insertedChunks.map(c => c.id), hashes: chunkHashes };
1042
+ const next = appendBaseOf(filePath, raw, content, detailed, kind, cut, rows, noteHash, options);
1043
+ if (next)
1044
+ recordAppendBase(db, filePath, next);
1045
+ }
1046
+ async function indexVault(db, vaultPath, options = {}, concurrency = 5) {
1047
+ // A name another OS or locale wrote can hold bytes that are not UTF-8. Read the directory as
1048
+ // bytes and name those files here instead of letting the walk return a path that exists
1049
+ // nowhere: for a FILE that path failed the first read and counted as an indexing error, and
1050
+ // for a FOLDER `scandir` threw inside the walk, so one such name aborted the whole pass and
1051
+ // not one note reached the index.
1052
+ const skippedNonUtf8Names = [];
1053
+ const files = await (0, walker_1.walkVault)(vaultPath, {
1054
+ onNonUtf8Markdown: relPath => skippedNonUtf8Names.push(relPath),
1055
+ });
1056
+ const walked = files.length;
1057
+ const skippedTooLarge = [];
1058
+ let indexed = 0;
1059
+ let errors = 0;
1060
+ let fileIdx = 0;
1061
+ let active = 0;
1062
+ // Skipped names are reported even when they are the only Markdown in the vault: "nothing to
1063
+ // index" and "nothing indexable" must not read the same.
1064
+ if (walked === 0)
1065
+ return { total: 0, indexed: 0, errors: 0, skippedNonUtf8Names: skippedNonUtf8Names.sort() };
1066
+ console.log(`[index] starting: ${walked} files, concurrency ${concurrency}`);
1067
+ return new Promise((resolve) => {
1068
+ function onComplete() {
1069
+ const done = indexed + errors + skippedTooLarge.length;
1070
+ if (done % 50 === 0 || done === walked) {
1071
+ console.log(`[index] ${done}/${walked} files (${errors} errors)`);
1072
+ }
1073
+ if (done === walked) {
1074
+ resolve({
1075
+ total: walked - skippedTooLarge.length,
1076
+ indexed,
1077
+ errors,
1078
+ skippedNonUtf8Names: skippedNonUtf8Names.sort(),
1079
+ ...(skippedTooLarge.length > 0 ? { skippedTooLarge: skippedTooLarge.sort() } : {}),
1080
+ });
1081
+ }
1082
+ else {
1083
+ next();
1084
+ }
1085
+ }
1086
+ function next() {
1087
+ while (active < concurrency && fileIdx < walked) {
1088
+ const filePath = files[fileIdx++];
1089
+ active++;
1090
+ indexFile(db, filePath, options).then(() => {
1091
+ indexed++;
1092
+ active--;
1093
+ onComplete();
1094
+ }, (err) => {
1095
+ active--;
1096
+ if (err instanceof NoteTooLargeError) {
1097
+ skippedTooLarge.push(path_1.default.relative(vaultPath, filePath));
1098
+ console.warn(`[index] ${filePath}: ${err.message}`);
1099
+ }
1100
+ else {
1101
+ errors++;
1102
+ console.error(`[index] error indexing ${filePath}:`, err);
1103
+ }
1104
+ onComplete();
1105
+ });
1106
+ }
1107
+ }
1108
+ next();
1109
+ });
1110
+ }
1111
+ /**
1112
+ * Remove the facts an earlier build made of the engine's own daily-note headers (dailyNote.ts), and say how many. An
1113
+ * index written then holds one for every daily note, and a note whose bytes did not change is never rebuilt, so the row
1114
+ * would stay and be served (retrieve_memory answers it as a current fact). The server calls this when it starts, the
1115
+ * indexer when it passes over an unchanged daily note (it hands over the text it has just read); `filePath` narrows it
1116
+ * to one note.
1117
+ *
1118
+ * Nothing a person wrote is touched. A row goes only when it looks like what that indexer made of the header (a fact
1119
+ * named `updated` that no ledger event made, whose text is the note's own day as a Date) AND the note is one the engine
1120
+ * wrote: the front matter at the start of the file, read here, carries the engine's marker or is the complete front
1121
+ * matter the engine wrote before it had one (isEngineDailyNote). A person's note in the same folder with a natural
1122
+ * `updated:`, a note whose file is gone, or one that cannot be read, is not told to be the engine's, and its row stays.
1123
+ */
1124
+ function removeDailyHeaderFacts(db, filePath, content) {
1125
+ if (filePath !== undefined && !(0, dailyNote_1.isDailyNotePath)(filePath))
1126
+ return 0;
1127
+ const rows = (filePath === undefined
1128
+ ? db.prepare('SELECT id, source_path, object_text FROM facts WHERE predicate = ? AND event_id IS NULL').all(dailyNote_1.DAILY_NOTE_HEADER_KEY)
1129
+ : db.prepare('SELECT id, source_path, object_text FROM facts WHERE predicate = ? AND event_id IS NULL AND source_path = ?').all(dailyNote_1.DAILY_NOTE_HEADER_KEY, filePath));
1130
+ const byNote = new Map();
1131
+ for (const row of rows) {
1132
+ if (typeof row.source_path !== 'string' || !(0, dailyNote_1.isEarlierHeaderText)(row.source_path, row.object_text))
1133
+ continue;
1134
+ byNote.set(row.source_path, [...(byNote.get(row.source_path) ?? []), row.id]);
1135
+ }
1136
+ const ids = [];
1137
+ for (const [note, noteIds] of byNote) {
1138
+ const head = filePath !== undefined && content !== undefined ? content : readHead(note);
1139
+ if (head !== null && (0, dailyNote_1.isEngineDailyNote)(note, head))
1140
+ ids.push(...noteIds);
1141
+ }
1142
+ if (ids.length === 0)
1143
+ return 0;
1144
+ const drop = db.prepare('DELETE FROM facts WHERE id = ?');
1145
+ db.transaction(() => {
1146
+ for (const id of ids)
1147
+ drop.run(id);
1148
+ })();
1149
+ return ids.length;
1150
+ }
1151
+ const HEAD_BYTES = 4096;
1152
+ /** The start of a file; null when it cannot be read (a note that is gone proves nothing). */
1153
+ function readHead(file) {
1154
+ let fd;
1155
+ try {
1156
+ fd = (0, fs_2.openSync)(file, 'r');
1157
+ const buffer = Buffer.alloc(HEAD_BYTES);
1158
+ const read = (0, fs_2.readSync)(fd, buffer, 0, HEAD_BYTES, 0);
1159
+ return buffer.subarray(0, read).toString('utf8');
1160
+ }
1161
+ catch {
1162
+ return null;
1163
+ }
1164
+ finally {
1165
+ if (fd !== undefined)
1166
+ (0, fs_2.closeSync)(fd);
1167
+ }
1168
+ }