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,644 @@
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.ImportStorageLimitError = void 0;
7
+ exports.applyImportPlan = applyImportPlan;
8
+ exports.isEmptyLeftover = isEmptyLeftover;
9
+ const promises_1 = __importDefault(require("fs/promises"));
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const client_1 = require("../db/client");
13
+ const schema_1 = require("../db/schema");
14
+ const hasher_1 = require("../indexer/hasher");
15
+ const vectorIndex_1 = require("../embeddings/vectorIndex");
16
+ const confinedWrite_1 = require("../memory/confinedWrite");
17
+ const lifecycle_1 = require("../memory/lifecycle");
18
+ const secretGuard_1 = require("../extraction/secretGuard");
19
+ const secretScreen_1 = require("./secretScreen");
20
+ /**
21
+ * The write layer.
22
+ *
23
+ * Two layers are written deliberately:
24
+ *
25
+ * 1. **Canonical** — Markdown notes under an additive namespace plus ledger events.
26
+ * This is what the engine treats as durable: the index is derived and rebuildable,
27
+ * the ledger survives `rm index.db`, and the workspace's own reads (fact list and
28
+ * graph) are built only from lifecycle facts.
29
+ * 2. **Derived index rows** — the source's own parse of notes/chunks/entities, copied
30
+ * so the workspace shows real counts and lexical recall works **without any
31
+ * re-chunking, re-embedding or re-extraction**. This is a deliberate exception to
32
+ * "the index is derived": the derived rows are written only *after* the canonical
33
+ * bytes exist, and each copied note's `note_hash` is the hash of the exact bytes
34
+ * written, which is the engine's own condition for "unchanged — skip". Vectors are
35
+ * never transferred (an embedding is model-bound) and no embedding call is made here:
36
+ * an index whose vectors are in a model's space is marked unfinished in the same commit
37
+ * (`markVectorsUnfinished`), so the workspace's own model embeds exactly the copied
38
+ * chunks, once, before semantic search reads the index again.
39
+ */
40
+ /** A final measured-size refusal. applyImportPlan compensates canonical and derived writes before rethrowing it. */
41
+ class ImportStorageLimitError extends Error {
42
+ }
43
+ exports.ImportStorageLimitError = ImportStorageLimitError;
44
+ const PHASE_DESCRIPTION = {
45
+ notes: 'writing the imported notes failed',
46
+ 'ledger-read': 'reading the destination ledger failed',
47
+ ledger: 'appending the carried events to the ledger failed',
48
+ index: 'the destination index could not be opened or migrated',
49
+ replay: 'the ledger replay rejected the carried events',
50
+ copy: 'copying the source parse into the destination index failed',
51
+ };
52
+ async function applyImportPlan(plan, _bundle, options) {
53
+ // RENOR_SECRET_GUARD (default ON): a plan that still holds a note or fact that looks like a secret is refused
54
+ // whole, here, before the first directory, file, ledger event or index row. buildImportPlan leaves such items
55
+ // out, so this catches a plan built with the guard off, or by another caller.
56
+ if ((0, secretGuard_1.secretGuardEnabled)())
57
+ (0, secretScreen_1.refuseFlaggedPlan)(plan);
58
+ const memoryDir = path_1.default.resolve(options.memoryDir);
59
+ const dbPath = options.dbPath ?? path_1.default.join(memoryDir, '.memory-index', 'index.db');
60
+ const ledgerRelPath = options.ledgerRelPath ?? lifecycle_1.DEFAULT_LEDGER_REL;
61
+ const ledgerPath = path_1.default.join(memoryDir, ...ledgerRelPath.split('/'));
62
+ let notesWritten = 0;
63
+ let notesUnchanged = 0;
64
+ let notesConflicting = 0;
65
+ /** Destination paths that already hold different content: never written, never indexed. */
66
+ const skippedConflicts = new Set();
67
+ const conflicts = [];
68
+ // Compensation state. Every path in `created` was absent before this invocation, so
69
+ // removing it cannot lose memory the operator or another writer owns.
70
+ const created = { files: [], dirs: [] };
71
+ let phase = 'notes';
72
+ let priorLedger = null;
73
+ // Set immediately before this invocation's first append. A failed read or parse must
74
+ // never enter ledger compensation: no ledger bytes have been touched yet.
75
+ let ledgerTouched = false;
76
+ let db = null;
77
+ let committed = false;
78
+ let ledgerReplayed = 0;
79
+ let eventsAppended = 0;
80
+ let eventsAlreadyPresent = 0;
81
+ let indexRows = {
82
+ notesInserted: 0,
83
+ notesUpdated: 0,
84
+ chunksInserted: 0,
85
+ chunksAlreadyPresent: 0,
86
+ entitiesInserted: 0,
87
+ entitiesAlreadyPresent: 0,
88
+ };
89
+ try {
90
+ // A first vault import can target a workspace that has no memory directory
91
+ // yet. Create the root before creating tracked additive subdirectories.
92
+ ensureDirTracked(memoryDir, memoryDir, created);
93
+ for (const planned of plan.notes) {
94
+ const target = path_1.default.join(memoryDir, ...planned.relPath.split('/'));
95
+ const dir = path_1.default.dirname(target);
96
+ if (!isInside(memoryDir, target)) {
97
+ throw new Error(`refusing to write outside the memory directory: ${planned.relPath}`);
98
+ }
99
+ assertNoLinkBelow(memoryDir, dir);
100
+ ensureDirTracked(memoryDir, dir, created);
101
+ // Exclusive create: this run owns the file only if this run is what brought it into
102
+ // existence. A plain `writeFile` decides ownership from an `ENOENT` read that the
103
+ // write does not repeat, so anything appearing in that window is overwritten and then
104
+ // deleted by the compensation — and `writeFile` follows a symlink, so a dangling link
105
+ // at the planned path would have the import write outside the memory directory and
106
+ // then remove the link. `wx` makes the create atomic, so ownership is observed rather
107
+ // than assumed, and only a path this run created is ever recorded for removal. An empty
108
+ // leftover of an earlier import killed between that create and its write is replaced
109
+ // instead of reported as a conflict (openEmptyLeftover); the containment check below
110
+ // still runs on whichever handle it gets, before anything is written or recorded.
111
+ const handle = (await openExclusive(target)) ?? (await openEmptyLeftover(target));
112
+ if (handle) {
113
+ // A directory swapped for a link after assertNoLinkBelow would have the open land elsewhere: ask the kernel where
114
+ // the file is before anything is written or recorded (src/memory/confinedWrite.ts).
115
+ try {
116
+ await (0, confinedWrite_1.assertHandleInside)(handle, await promises_1.default.realpath(memoryDir), planned.relPath);
117
+ }
118
+ catch (err) {
119
+ await handle.close().catch(() => undefined);
120
+ throw err;
121
+ }
122
+ // From here the file exists because of THIS run, so it is this run's to remove if
123
+ // anything later fails.
124
+ created.files.push(target);
125
+ try {
126
+ options.onNoteCreated?.(planned);
127
+ await handle.writeFile(planned.content, 'utf8');
128
+ // Durable before the import is acknowledged, like every other acknowledged Markdown write (adv-crashsafe-1012 F3).
129
+ await handle.sync();
130
+ }
131
+ finally {
132
+ await handle.close();
133
+ }
134
+ notesWritten += 1;
135
+ continue;
136
+ }
137
+ // Something is already at the path, and the import is additive: it is never written
138
+ // to. A path this run did not create — including a symlink, which must not be written
139
+ // through even when it points back inside the memory directory — is a conflict the
140
+ // operator resolves, and it is skipped for the index too.
141
+ const existing = await promises_1.default.lstat(target);
142
+ if (!existing.isFile()) {
143
+ notesConflicting += 1;
144
+ skippedConflicts.add(target);
145
+ conflicts.push(planned.relPath);
146
+ continue;
147
+ }
148
+ const onDisk = await promises_1.default.readFile(target, 'utf8');
149
+ if (onDisk === planned.content) {
150
+ options.onNoteUnchanged?.(planned);
151
+ notesUnchanged += 1;
152
+ continue;
153
+ }
154
+ notesConflicting += 1;
155
+ skippedConflicts.add(target);
156
+ conflicts.push(planned.relPath);
157
+ }
158
+ phase = 'ledger-read';
159
+ // Snapshot the durable authority before any append. Only ENOENT means no ledger;
160
+ // permission and other read failures propagate so an existing ledger is never
161
+ // mistaken for absence and later removed by rollback. Parse IDs from the same bytes
162
+ // that compensation will restore, avoiding a second read with a different outcome.
163
+ // A ledger reached through a link, or a link itself, is refused before a byte is read from or written to it.
164
+ await (0, confinedWrite_1.assertConfinedTarget)(memoryDir, path_1.default.relative(memoryDir, ledgerPath));
165
+ priorLedger = readFileOrNull(ledgerPath);
166
+ const existingLedgerIds = new Set((0, lifecycle_1.parseLedgerEvents)(priorLedger ?? '').map(event => event.id.toUpperCase()));
167
+ // `appendLedgerEvent` creates the ledger's directory chain itself. Record the
168
+ // directories that are missing now, so a rollback cannot leave an empty tree behind.
169
+ for (const dir of missingDirsUnder(memoryDir, path_1.default.dirname(ledgerPath)))
170
+ created.dirs.push(dir);
171
+ for (const event of plan.events) {
172
+ const eventKey = event.id.toUpperCase();
173
+ if (existingLedgerIds.has(eventKey)) {
174
+ eventsAlreadyPresent += 1;
175
+ continue;
176
+ }
177
+ // From this point the durable bytes may change, so and only so can rollback restore
178
+ // the prior snapshot (or remove a ledger this invocation created from an absent path).
179
+ phase = 'ledger';
180
+ ledgerTouched = true;
181
+ await (0, lifecycle_1.appendLedgerEvent)(memoryDir, ledgerRelPath, toLedgerEvent(event));
182
+ existingLedgerIds.add(eventKey);
183
+ eventsAppended += 1;
184
+ }
185
+ phase = 'index';
186
+ db = (0, client_1.openDb)(dbPath);
187
+ (0, schema_1.runMigrations)(db);
188
+ // The append and the projection are ONE unit, in both directions.
189
+ //
190
+ // `replayLedger` applies events in a bare loop and each `applyEvent` commits its own
191
+ // transaction, so a mid-loop rejection would leave the already-applied facts
192
+ // committed. Restoring the ledger on top of that would remove the events while their
193
+ // facts remain — and the engine's global preflight (K13) fails closed when the index
194
+ // holds lifecycle facts whose event id is absent from the ledger. The result would be
195
+ // a workspace that refuses to serve, which is worse than the rejection itself.
196
+ // Wrapping the replay in one outer transaction makes the rollback real: the engine's
197
+ // own `db.transaction` calls become savepoints inside it.
198
+ phase = 'replay';
199
+ db.exec('BEGIN IMMEDIATE');
200
+ try {
201
+ // The lifecycle projection is applied through the engine's own replay, so the
202
+ // imported facts and the destination's existing facts obey one write path.
203
+ ledgerReplayed = await (0, lifecycle_1.replayLedger)(db, memoryDir, ledgerRelPath);
204
+ phase = 'copy';
205
+ if (!options.canonicalOnly) {
206
+ indexRows = copyParseRows(db, memoryDir, plan, skippedConflicts);
207
+ // The copied chunks have no vector. The mark lands in this commit, so it and the chunks land together or not at
208
+ // all; the next engine with the index's model embeds every chunk that has none, once (a running service starts
209
+ // that job when the import returns).
210
+ if (indexRows.chunksInserted > 0)
211
+ (0, vectorIndex_1.markVectorsUnfinished)(db);
212
+ }
213
+ // Canonical Markdown is now complete, but neither its index projection nor the files have committed as a unit.
214
+ // This is the last point at which a measured workspace ceiling can reject and compensate every added byte.
215
+ await options.validateBeforeCommit?.();
216
+ db.exec('COMMIT');
217
+ committed = true;
218
+ }
219
+ catch (err) {
220
+ // A failed ROLLBACK must not hide the failure that caused it.
221
+ try {
222
+ db.exec('ROLLBACK');
223
+ }
224
+ catch { /* the transaction is already unwound */ }
225
+ throw err;
226
+ }
227
+ return {
228
+ namespace: plan.namespace,
229
+ notesWritten,
230
+ notesUnchanged,
231
+ notesConflicting,
232
+ conflicts,
233
+ eventsAppended,
234
+ eventsAlreadyPresent,
235
+ ledgerReplayed,
236
+ indexRows,
237
+ destination: measureDestination(db),
238
+ };
239
+ }
240
+ catch (err) {
241
+ // Past COMMIT the projection is durable and there is nothing to roll back; saying that
242
+ // is the honest report, and it must not read like an ordinary failed import.
243
+ if (committed)
244
+ throw committedFailure(err);
245
+ // One compensation for the whole unit. The projection is already unwound (rolled back
246
+ // or never started), so what remains is the canonical layer this run added: its own
247
+ // note bytes, its own directories, and the ledger bytes it appended to. Files the run
248
+ // did not create — unchanged notes, conflicts, the operator's own files — are never
249
+ // touched, and pruning is limited to directories that are empty afterwards.
250
+ const restore = ledgerTouched
251
+ ? restoreLedger(ledgerPath, priorLedger)
252
+ : { restored: false };
253
+ // A ledger that could not be restored still holds its carried events, and those events
254
+ // are canonical: removing the notes they describe would leave a ledger pointing at
255
+ // memory that no longer exists. Keep the notes so the canonical layer stays
256
+ // self-consistent and recoverable, and report the residual instead of destroying it.
257
+ const notesRemoved = ledgerTouched && !restore.restored ? 0 : compensateCreatedPaths(created);
258
+ if (err instanceof ImportStorageLimitError && (!ledgerTouched || restore.restored))
259
+ throw err;
260
+ throw new Error(rollbackMessage({ phase, err, notesRemoved, ledgerTouched, restore, eventsAppended }));
261
+ }
262
+ finally {
263
+ try {
264
+ db?.close();
265
+ }
266
+ catch (closeErr) {
267
+ // A close failure after COMMIT is a post-commit failure: the writes are durable and
268
+ // no rollback happened. Before COMMIT the primary failure is already propagating and
269
+ // would be replaced by throwing here, so this one is dropped rather than allowed to
270
+ // hide the real cause.
271
+ if (committed)
272
+ throw committedFailure(closeErr);
273
+ }
274
+ }
275
+ }
276
+ /**
277
+ * `open(path, 'wx')`, or `null` when something already exists at the path.
278
+ * Exposed as a function so the exclusivity is one decision in one place, not a flag each
279
+ * caller has to remember.
280
+ */
281
+ async function openExclusive(target) {
282
+ try {
283
+ return await promises_1.default.open(target, 'wx', 0o600);
284
+ }
285
+ catch (err) {
286
+ if (err.code === 'EEXIST')
287
+ return null;
288
+ throw err;
289
+ }
290
+ }
291
+ /**
292
+ * Opens an empty leftover of an earlier import for writing, or returns `null` when the path holds anything else. An
293
+ * earlier import created each note before writing it, so a kill between the two left an empty file that no journal
294
+ * named; a retry replaces it, holding nothing to lose, instead of reporting a conflict. The open never creates and never
295
+ * follows a link (O_NOFOLLOW), a hard-linked file is refused (a write would reach its other name too), and the size is
296
+ * checked on the open descriptor, so the file written is the file that was checked. The caller still runs the
297
+ * containment check on the handle before anything is written or recorded. The check and the open are two calls, so a file
298
+ * another writer put at that exact instant would be written; an import is additive and runs on the owner's own workspace,
299
+ * so that window is accepted.
300
+ */
301
+ async function openEmptyLeftover(target) {
302
+ if (!isEmptyLeftover(await promises_1.default.lstat(target)))
303
+ return null;
304
+ let handle;
305
+ try {
306
+ handle = await promises_1.default.open(target, fs_1.default.constants.O_WRONLY | fs_1.default.constants.O_NOFOLLOW | fs_1.default.constants.O_NONBLOCK);
307
+ }
308
+ catch (err) {
309
+ if (['ELOOP', 'EACCES'].includes(err.code ?? ''))
310
+ return null;
311
+ throw err;
312
+ }
313
+ const opened = await handle.stat();
314
+ if (!isEmptyLeftover(opened) || opened.nlink !== 1) {
315
+ await handle.close();
316
+ return null;
317
+ }
318
+ return handle;
319
+ }
320
+ /** An empty regular file (not a symlink, not a directory): what an interrupted import leaves at a note's path. */
321
+ function isEmptyLeftover(stat) {
322
+ return stat.isFile() && stat.size === 0;
323
+ }
324
+ /** A failure after the import committed: durable writes that were deliberately not rolled back. */
325
+ function committedFailure(err) {
326
+ const detail = err instanceof Error ? err.message : String(err);
327
+ return new Error('import committed but the run failed afterwards, so no rollback was performed and the imported '
328
+ + `notes, ledger events and index rows remain as written (${detail})`);
329
+ }
330
+ /**
331
+ * The failure text. It names the step that actually failed and what was undone, so an
332
+ * operator is never told "the ledger replay rejected the carried events" for a chunk
333
+ * insert or a bad destination index, and never told the ledger was restored when it was not.
334
+ */
335
+ function rollbackMessage(args) {
336
+ const { phase, err, notesRemoved, ledgerTouched, restore, eventsAppended } = args;
337
+ const detail = err instanceof Error ? err.message : String(err);
338
+ const compensated = [];
339
+ if (notesRemoved > 0)
340
+ compensated.push(`the ${notesRemoved} note file(s) this run created were removed`);
341
+ if (ledgerTouched)
342
+ compensated.push(ledgerOutcome(restore, eventsAppended));
343
+ compensated.push('no facts were applied');
344
+ return `import rolled back: ${PHASE_DESCRIPTION[phase]}, so ${compensated.join(', ')} (${detail})`;
345
+ }
346
+ /**
347
+ * What happened to the ledger, stated exactly.
348
+ *
349
+ * The two failure branches are different states and must not be described with one
350
+ * sentence: with events actually appended, those events are canonical and will replay, so
351
+ * the notes are kept to keep the two layers consistent; with nothing appended, the ledger's
352
+ * state is simply unverified (an append that failed part-way may still have written bytes),
353
+ * and the notes are kept rather than removed against a ledger nobody has read back.
354
+ */
355
+ function ledgerOutcome(restore, eventsAppended) {
356
+ if (restore.restored)
357
+ return 'the ledger was restored to its previous contents';
358
+ const cause = restore.error ?? 'unknown error';
359
+ if (eventsAppended > 0) {
360
+ return 'the LEDGER COULD NOT BE RESTORED'
361
+ + ` (${cause}), so the ${eventsAppended} event(s) this run appended remain and WILL replay: the note`
362
+ + ' files this run created were left in place so the canonical notes and ledger stay consistent,'
363
+ + ' and the destination needs its own repair';
364
+ }
365
+ return 'the LEDGER COULD NOT BE RESTORED'
366
+ + ` (${cause}) and its state could not be verified, so the note files this run created were left in`
367
+ + ' place rather than removed against a ledger that was not read back; the destination needs its own repair';
368
+ }
369
+ /**
370
+ * Refuse a destination directory whose chain under the memory directory holds a symbolic link: the writes below it would
371
+ * land wherever the link points. The memory directory itself may be a link, it is where the operator put the vault. A
372
+ * directory that does not exist yet is fine.
373
+ */
374
+ function assertNoLinkBelow(root, dir) {
375
+ const relative = path_1.default.relative(path_1.default.resolve(root), path_1.default.resolve(dir));
376
+ if (relative === '' || relative.startsWith('..') || path_1.default.isAbsolute(relative))
377
+ return;
378
+ let current = path_1.default.resolve(root);
379
+ for (const segment of relative.split(path_1.default.sep)) {
380
+ current = path_1.default.join(current, segment);
381
+ let st;
382
+ try {
383
+ st = fs_1.default.lstatSync(current);
384
+ }
385
+ catch (err) {
386
+ if (err.code === 'ENOENT')
387
+ return;
388
+ throw err;
389
+ }
390
+ if (st.isSymbolicLink()) {
391
+ throw new Error(`refusing to write below ${JSON.stringify(path_1.default.relative(path_1.default.resolve(root), current))}: it is a symbolic link`);
392
+ }
393
+ }
394
+ }
395
+ /**
396
+ * `mkdir -p` that records every directory THIS run had to create.
397
+ *
398
+ * `fs.mkdir(dir, {recursive:true})` reports only the first directory it created, which is
399
+ * not enough to prune the chain again; and compensation must be able to tell a directory
400
+ * it made from one that was already there.
401
+ */
402
+ function ensureDirTracked(root, dir, created) {
403
+ const base = path_1.default.resolve(root);
404
+ const target = path_1.default.resolve(dir);
405
+ const relative = path_1.default.relative(base, target);
406
+ if (relative === '') {
407
+ // The memory directory itself: create it when absent, but never claim it for removal.
408
+ fs_1.default.mkdirSync(target, { recursive: true, mode: 0o700 });
409
+ return;
410
+ }
411
+ if (relative.startsWith('..') || path_1.default.isAbsolute(relative)) {
412
+ fs_1.default.mkdirSync(target, { recursive: true });
413
+ return;
414
+ }
415
+ for (const missing of missingDirsUnder(base, target)) {
416
+ try {
417
+ fs_1.default.mkdirSync(missing, { mode: 0o700 });
418
+ created.dirs.push(missing);
419
+ }
420
+ catch (err) {
421
+ // Created between the check and the mkdir: it is not this run's to remove.
422
+ if (err.code === 'EEXIST')
423
+ continue;
424
+ throw err;
425
+ }
426
+ }
427
+ }
428
+ /** Directory chain under `root` that does not exist yet, outermost first. */
429
+ function missingDirsUnder(root, dir) {
430
+ const base = path_1.default.resolve(root);
431
+ const target = path_1.default.resolve(dir);
432
+ const relative = path_1.default.relative(base, target);
433
+ if (relative === '' || relative.startsWith('..') || path_1.default.isAbsolute(relative))
434
+ return [];
435
+ const missing = [];
436
+ let current = base;
437
+ for (const segment of relative.split(path_1.default.sep)) {
438
+ current = path_1.default.join(current, segment);
439
+ if (!fs_1.default.existsSync(current))
440
+ missing.push(current);
441
+ }
442
+ return missing;
443
+ }
444
+ /**
445
+ * Remove what this run created: its own note files, then its own directories deepest-first
446
+ * and only while they are empty. Nothing recursive is ever removed — a directory that has
447
+ * picked up any other entry is left exactly as it is.
448
+ */
449
+ function compensateCreatedPaths(created) {
450
+ let removed = 0;
451
+ for (const file of created.files) {
452
+ try {
453
+ fs_1.default.unlinkSync(file);
454
+ removed += 1;
455
+ }
456
+ catch {
457
+ // Already gone: the compensation is idempotent.
458
+ }
459
+ }
460
+ const dirs = [...created.dirs].sort((a, b) => b.split(path_1.default.sep).length - a.split(path_1.default.sep).length || b.length - a.length);
461
+ for (const dir of dirs) {
462
+ try {
463
+ if (fs_1.default.readdirSync(dir).length === 0)
464
+ fs_1.default.rmdirSync(dir);
465
+ }
466
+ catch {
467
+ // Not empty, or already gone: leave it.
468
+ }
469
+ }
470
+ return removed;
471
+ }
472
+ function readFileOrNull(filePath) {
473
+ try {
474
+ return fs_1.default.readFileSync(filePath, 'utf8');
475
+ }
476
+ catch (err) {
477
+ if (err.code === 'ENOENT')
478
+ return null;
479
+ throw err;
480
+ }
481
+ }
482
+ /**
483
+ * Exact restore of the prior ledger bytes, or removal when it did not exist before.
484
+ * Returns the OUTCOME rather than swallowing it: a run that could not restore the ledger
485
+ * still holds the carried events, and must say so instead of claiming a clean rollback.
486
+ */
487
+ function restoreLedger(ledgerPath, prior) {
488
+ try {
489
+ if (prior === null) {
490
+ if (fs_1.default.existsSync(ledgerPath))
491
+ fs_1.default.unlinkSync(ledgerPath);
492
+ return { restored: !fs_1.default.existsSync(ledgerPath) };
493
+ }
494
+ fs_1.default.writeFileSync(ledgerPath, prior, 'utf8');
495
+ return { restored: fs_1.default.readFileSync(ledgerPath, 'utf8') === prior };
496
+ }
497
+ catch (err) {
498
+ return { restored: false, error: err instanceof Error ? err.message : String(err) };
499
+ }
500
+ }
501
+ function toLedgerEvent(event) {
502
+ return {
503
+ id: event.id,
504
+ at: event.at,
505
+ kind: event.kind,
506
+ subject: event.subject,
507
+ predicate: event.predicate,
508
+ object: event.object,
509
+ closedEvent: null,
510
+ source: event.source,
511
+ scope: event.scope,
512
+ };
513
+ }
514
+ /**
515
+ * Copy the source's parse of notes, chunks and entities into the destination index.
516
+ * Facts and relations are deliberately NOT copied: the dashboard's fact list and graph
517
+ * read lifecycle facts only, and relations carry no foreign key (they would fail
518
+ * silently). Note-derived entities whose `source_note` is the lifecycle sentinel are
519
+ * skipped so the destination's own lifecycle entity is never duplicated.
520
+ */
521
+ function copyParseRows(db, memoryDir, plan, skippedConflicts) {
522
+ const counters = {
523
+ notesInserted: 0,
524
+ notesUpdated: 0,
525
+ chunksInserted: 0,
526
+ chunksAlreadyPresent: 0,
527
+ entitiesInserted: 0,
528
+ entitiesAlreadyPresent: 0,
529
+ };
530
+ const findNote = db.prepare('SELECT note_hash FROM notes WHERE path = ?');
531
+ const insertNote = db.prepare(`INSERT INTO notes (path, title, kind, note_hash, modified_at, frontmatter_json)
532
+ VALUES (@path, @title, @kind, @noteHash, @modifiedAt, @frontmatterJson)`);
533
+ const updateNote = db.prepare(`UPDATE notes SET title = @title, kind = @kind, note_hash = @noteHash,
534
+ modified_at = @modifiedAt, frontmatter_json = @frontmatterJson WHERE path = @path`);
535
+ const countChunks = db.prepare('SELECT count(*) AS c FROM chunks WHERE note_path = ?');
536
+ const insertChunk = db.prepare(`INSERT INTO chunks (note_path, heading_path, text, start_line, end_line, token_count, chunk_hash)
537
+ VALUES (@notePath, @headingPath, @text, @startLine, @endLine, @tokenCount, @chunkHash)`);
538
+ const findEntity = db.prepare('SELECT id FROM entities WHERE canonical_name = ? AND type = ? AND source_note = ?');
539
+ const insertEntity = db.prepare(`INSERT INTO entities (type, canonical_name, aliases_json, source_note, confidence, updated_at)
540
+ VALUES (@type, @canonicalName, @aliasesJson, @sourceNote, @confidence, @updatedAt)`);
541
+ const run = db.transaction(() => {
542
+ for (const planned of plan.notes) {
543
+ const target = path_1.default.join(memoryDir, ...planned.relPath.split('/'));
544
+ // A path that was not written (conflict, or a file the operator owns) gets no
545
+ // index rows: otherwise the row would carry the source's hash and chunk text for
546
+ // bytes that are not on disk, which both breaks search honesty and makes the next
547
+ // index pass see a change and re-chunk and re-embed the note.
548
+ if (skippedConflicts.has(target))
549
+ continue;
550
+ // The note row must hash the bytes on disk: this is exactly the engine's
551
+ // "unchanged, skip" condition, so the note is never re-chunked or re-embedded.
552
+ const noteHash = (0, hasher_1.hashContent)(planned.content);
553
+ const modifiedAt = safeMtime(target);
554
+ const note = planned.note;
555
+ const row = {
556
+ path: target,
557
+ title: note.title || path_1.default.basename(planned.relPath),
558
+ kind: note.kind,
559
+ noteHash,
560
+ modifiedAt,
561
+ frontmatterJson: JSON.stringify(note.frontmatter ?? {}),
562
+ };
563
+ const existing = findNote.get(target);
564
+ if (!existing) {
565
+ insertNote.run(row);
566
+ counters.notesInserted += 1;
567
+ }
568
+ else if (existing.note_hash !== noteHash) {
569
+ // The file on disk is authoritative for the row, so re-sync rather than leave a
570
+ // stale hash that would make a later index pass re-process this note.
571
+ updateNote.run(row);
572
+ counters.notesUpdated += 1;
573
+ }
574
+ const chunkCount = countChunks.get(target).c;
575
+ if (chunkCount > 0) {
576
+ counters.chunksAlreadyPresent += note.chunks.length;
577
+ }
578
+ else {
579
+ for (let i = 0; i < note.chunks.length; i += 1) {
580
+ const chunk = note.chunks[i];
581
+ const lines = planned.chunkLines[i] ?? { startLine: 0, endLine: 0 };
582
+ insertChunk.run({
583
+ notePath: target,
584
+ headingPath: chunk.headingPath,
585
+ text: chunk.text,
586
+ startLine: lines.startLine,
587
+ endLine: lines.endLine,
588
+ tokenCount: chunk.tokenCount,
589
+ chunkHash: (0, hasher_1.hashContent)(chunk.text),
590
+ });
591
+ counters.chunksInserted += 1;
592
+ }
593
+ }
594
+ for (const entity of note.entities) {
595
+ if (!entity.canonicalName.trim())
596
+ continue;
597
+ const found = findEntity.get(entity.canonicalName, entity.type, target);
598
+ if (found) {
599
+ counters.entitiesAlreadyPresent += 1;
600
+ continue;
601
+ }
602
+ insertEntity.run({
603
+ type: entity.type || 'note',
604
+ canonicalName: entity.canonicalName,
605
+ aliasesJson: JSON.stringify(entity.aliases ?? []),
606
+ sourceNote: target,
607
+ confidence: typeof entity.confidence === 'number' ? entity.confidence : 1,
608
+ updatedAt: new Date().toISOString(),
609
+ });
610
+ counters.entitiesInserted += 1;
611
+ }
612
+ }
613
+ });
614
+ run();
615
+ return counters;
616
+ }
617
+ function safeMtime(filePath) {
618
+ try {
619
+ return fs_1.default.statSync(filePath).mtime.toISOString();
620
+ }
621
+ catch {
622
+ return new Date().toISOString();
623
+ }
624
+ }
625
+ function isInside(root, candidate) {
626
+ const rel = path_1.default.relative(root, candidate);
627
+ return rel !== '' && !rel.startsWith('..') && !path_1.default.isAbsolute(rel);
628
+ }
629
+ function measureDestination(db) {
630
+ const scalar = (sql) => db.prepare(sql).get().c;
631
+ try {
632
+ return {
633
+ notes: scalar('SELECT count(*) AS c FROM notes'),
634
+ chunks: scalar('SELECT count(*) AS c FROM chunks'),
635
+ entities: scalar('SELECT count(*) AS c FROM entities'),
636
+ facts: scalar('SELECT count(*) AS c FROM facts'),
637
+ currentFacts: scalar(`SELECT count(*) AS c FROM facts WHERE valid_to IS NULL AND source_path = '_mcp_'`),
638
+ ftsRows: scalar('SELECT count(*) AS c FROM chunks_fts'),
639
+ };
640
+ }
641
+ catch {
642
+ return null;
643
+ }
644
+ }