@zosmaai/pi-llm-wiki 0.12.0 → 0.12.2
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.
- package/CHANGELOG.md +1 -0
- package/README.md +16 -8
- package/dist/extensions/llm-wiki/lib/bootstrap.js +2 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +24 -1
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +3 -1
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +11 -2
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +41 -6
- package/dist/extensions/llm-wiki/lib/model-command.js +45 -8
- package/dist/extensions/llm-wiki/lib/qmd-indexing.js +1024 -0
- package/dist/extensions/llm-wiki/lib/qmd-mirror.js +418 -0
- package/dist/extensions/llm-wiki/lib/qmd-store.js +112 -0
- package/dist/extensions/llm-wiki/lib/recall.js +77 -3
- package/dist/extensions/llm-wiki/lib/runtime.js +25 -1
- package/dist/extensions/llm-wiki/lib/subagent.js +47 -7
- package/dist/extensions/llm-wiki/lib/tools.js +165 -5
- package/dist/extensions/llm-wiki/lib/utils.js +16 -2
- package/dist/extensions/llm-wiki/lib/wiki-service.js +104 -5
- package/dist/mcp/index.js +66 -2
- package/dist/mcp/operations.js +26 -2
- package/docs/api.md +43 -1
- package/docs/architecture.md +28 -0
- package/docs/commands.md +1 -0
- package/docs/qmd-compatibility.md +47 -0
- package/docs/retrieval-benchmark.md +47 -0
- package/docs/superpowers/benchmarks/phase-1-current-baseline.json +53 -0
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-remediation.md +549 -0
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-validated-indexing.md +1493 -0
- package/docs/superpowers/plans/2026-08-11-qmd-retrieval-phase-3-retrieval-modes-and-recall-cutover.md +678 -0
- package/docs/superpowers/plans/2026-09-05-wikilink-alias-pipe-table-only.md +257 -0
- package/extensions/llm-wiki/index.ts +14 -1
- package/extensions/llm-wiki/lib/bootstrap.ts +2 -0
- package/extensions/llm-wiki/lib/indexing.ts +24 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +10 -2
- package/extensions/llm-wiki/lib/knowledge-document.ts +20 -3
- package/extensions/llm-wiki/lib/knowledge-links.ts +39 -7
- package/extensions/llm-wiki/lib/model-command.ts +57 -12
- package/extensions/llm-wiki/lib/qmd-indexing.ts +1304 -0
- package/extensions/llm-wiki/lib/qmd-mirror.ts +496 -0
- package/extensions/llm-wiki/lib/qmd-store.ts +222 -0
- package/extensions/llm-wiki/lib/recall.ts +77 -3
- package/extensions/llm-wiki/lib/runtime.ts +57 -5
- package/extensions/llm-wiki/lib/subagent.ts +73 -10
- package/extensions/llm-wiki/lib/tools.ts +188 -4
- package/extensions/llm-wiki/lib/utils.ts +21 -2
- package/extensions/llm-wiki/lib/wiki-service.ts +160 -4
- package/mcp/index.ts +78 -1
- package/mcp/operations.ts +41 -2
- package/package.json +9 -6
- package/skills/llm-wiki/SKILL.md +7 -1
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { cp as fsCp, rename as fsRename, rm as fsRm, mkdir, readdir, readFile, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { hostname as osHostname } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { hashQmdManifest, invalidateUnsafeQmdEntries, readQmdManifest, reconcileQmdMirror, } from "./qmd-mirror.js";
|
|
7
|
+
import { QMD_PACKAGE_VERSION, resolveQmdModels, } from "./qmd-store.js";
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
9
|
+
const STAGING_NAME = /^staging-[0-9a-f-]{36}$/;
|
|
10
|
+
export class QmdIndexError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "QmdIndexError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
class QmdIndexCancelledError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super("QMD indexing cancelled");
|
|
21
|
+
this.name = "QmdIndexCancelledError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function diag(severity, code, path, message) {
|
|
25
|
+
return { severity, code: code, path, message };
|
|
26
|
+
}
|
|
27
|
+
async function atomicWriteJson(path, data) {
|
|
28
|
+
const dir = join(path, "..");
|
|
29
|
+
await mkdir(dir, { recursive: true });
|
|
30
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
31
|
+
await writeFile(temporary, JSON.stringify(data, null, 2), "utf8");
|
|
32
|
+
await fsRename(temporary, path);
|
|
33
|
+
}
|
|
34
|
+
async function readJsonFile(path) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function pathExists(p) {
|
|
43
|
+
return new Promise((resolve) => (existsSync(p) ? resolve(true) : resolve(false)));
|
|
44
|
+
}
|
|
45
|
+
const realFs = {
|
|
46
|
+
exists: pathExists,
|
|
47
|
+
rename: fsRename,
|
|
48
|
+
rm: fsRm,
|
|
49
|
+
cp: fsCp,
|
|
50
|
+
};
|
|
51
|
+
function processExists(pid) {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return error.code === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Per-vault lock
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
const lockDir = (paths) => join(paths.qmd, "index.lock");
|
|
64
|
+
async function acquireIndexLock(paths) {
|
|
65
|
+
const dir = lockDir(paths);
|
|
66
|
+
try {
|
|
67
|
+
await mkdir(paths.qmd, { recursive: true });
|
|
68
|
+
await mkdir(dir, { recursive: false });
|
|
69
|
+
await writeFile(join(dir, "owner.json"), JSON.stringify({
|
|
70
|
+
pid: process.pid,
|
|
71
|
+
hostname: osHostname(),
|
|
72
|
+
acquiredAt: new Date().toISOString(),
|
|
73
|
+
}), "utf8");
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === "EEXIST") {
|
|
77
|
+
if (await canRecoverLock(dir)) {
|
|
78
|
+
await fsRm(dir, { recursive: true, force: true });
|
|
79
|
+
return acquireIndexLock(paths);
|
|
80
|
+
}
|
|
81
|
+
throw new QmdIndexError("qmd_index_busy", `QMD index is locked by another process (${dir})`);
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function canRecoverLock(dir) {
|
|
87
|
+
const owner = await readJsonFile(join(dir, "owner.json"));
|
|
88
|
+
if (!owner || typeof owner.pid !== "number" || typeof owner.hostname !== "string")
|
|
89
|
+
return false;
|
|
90
|
+
if (owner.hostname !== osHostname())
|
|
91
|
+
return false;
|
|
92
|
+
return !processExists(owner.pid);
|
|
93
|
+
}
|
|
94
|
+
async function releaseIndexLock(paths) {
|
|
95
|
+
await fsRm(lockDir(paths), { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// In-process per-vault queue (prevents same-extension races)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
const queues = new Map();
|
|
101
|
+
async function enqueue(root, work) {
|
|
102
|
+
const prev = queues.get(root) ?? Promise.resolve();
|
|
103
|
+
const next = prev.then(work, work);
|
|
104
|
+
queues.set(root, next.catch(() => undefined));
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
/** Test-only: drain/await queued work for a vault root. */
|
|
108
|
+
export function awaitQmdIndexQueue(root) {
|
|
109
|
+
return queues.get(root) ?? Promise.resolve();
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Stable vault id backfill
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
export async function ensureVaultId(paths) {
|
|
115
|
+
const configPath = join(paths.dotWiki, "config.json");
|
|
116
|
+
const configRead = await readJsonArtifact(configPath);
|
|
117
|
+
// Missing, unreadable, malformed, null, or array config is an error and must
|
|
118
|
+
// never become `{}` (which would let backfill overwrite the original bytes).
|
|
119
|
+
if (configRead.kind === "missing") {
|
|
120
|
+
throw new QmdIndexError("config_invalid", "config.json is missing; cannot confirm or create a vault identity");
|
|
121
|
+
}
|
|
122
|
+
if (configRead.kind === "invalid") {
|
|
123
|
+
throw new QmdIndexError("config_invalid", `config.json is unreadable or malformed: ${configRead.message}`);
|
|
124
|
+
}
|
|
125
|
+
const config = configRead.value;
|
|
126
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) {
|
|
127
|
+
throw new QmdIndexError("config_invalid", "config.json must be a JSON object with a vault_id");
|
|
128
|
+
}
|
|
129
|
+
const record = config;
|
|
130
|
+
if (typeof record.vault_id === "string") {
|
|
131
|
+
if (!UUID.test(record.vault_id)) {
|
|
132
|
+
throw new QmdIndexError("config_invalid_vault_id", "config.json contains an invalid vault_id");
|
|
133
|
+
}
|
|
134
|
+
return record.vault_id;
|
|
135
|
+
}
|
|
136
|
+
if (record.vault_id !== undefined) {
|
|
137
|
+
throw new QmdIndexError("config_invalid_vault_id", "config.json contains a non-string vault_id");
|
|
138
|
+
}
|
|
139
|
+
const vaultId = randomUUID();
|
|
140
|
+
await atomicWriteJson(configPath, { ...record, vault_id: vaultId });
|
|
141
|
+
return vaultId;
|
|
142
|
+
}
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Swap journal helpers
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
async function readSwapJournal(paths) {
|
|
147
|
+
const journal = await readJsonFile(paths.qmdSwap);
|
|
148
|
+
if (!journal)
|
|
149
|
+
return null;
|
|
150
|
+
if (journal.version !== 1 || !STAGING_NAME.test(journal.stagingName ?? ""))
|
|
151
|
+
return null;
|
|
152
|
+
const phases = ["prepared", "previous-moved", "current-promoted", "validated"];
|
|
153
|
+
if (!phases.includes(journal.phase))
|
|
154
|
+
return null;
|
|
155
|
+
return journal;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The document count a current store must report to be valid: the validated
|
|
159
|
+
* manifest entry count when the manifest is readable, otherwise the recorded
|
|
160
|
+
* count in the structurally valid current state file. Returns undefined when
|
|
161
|
+
* neither is available (openability alone is then the only check).
|
|
162
|
+
*/
|
|
163
|
+
async function expectedDocumentCount(paths, fs) {
|
|
164
|
+
if (await fs.exists(paths.qmdManifest)) {
|
|
165
|
+
try {
|
|
166
|
+
const stateFile = await readJsonFile(join(paths.qmdCurrent, "index-state.json"));
|
|
167
|
+
const vaultId = stateFile?.vaultId;
|
|
168
|
+
if (vaultId) {
|
|
169
|
+
const manifest = await readQmdManifest(paths, vaultId);
|
|
170
|
+
return Object.keys(manifest.entries).length;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Malformed manifest — fall through to the state file expectation.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const stateFile = await readJsonFile(join(paths.qmdCurrent, "index-state.json"));
|
|
178
|
+
return stateFile?.status?.totalDocuments;
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Recovery
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
/**
|
|
184
|
+
* Remove extension-owned staging directories under `paths.qmd` that are not
|
|
185
|
+
* referenced by the active journal. Only exact `staging-<uuid>` names are
|
|
186
|
+
* considered; symlinks and non-directories are never followed. Cleanup
|
|
187
|
+
* failures become safe diagnostics.
|
|
188
|
+
*/
|
|
189
|
+
async function removeUnreferencedStaging(paths, referencedName, fs) {
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = await readdir(paths.qmd, { withFileTypes: true });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return; // qmd dir may not exist yet
|
|
196
|
+
}
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (!STAGING_NAME.test(entry.name))
|
|
199
|
+
continue;
|
|
200
|
+
if (entry.name === referencedName)
|
|
201
|
+
continue;
|
|
202
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
203
|
+
continue;
|
|
204
|
+
try {
|
|
205
|
+
await fs.rm(join(paths.qmd, entry.name), { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Cleanup is best-effort; never mask the original indexing error.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Recover an interrupted prior swap. Assumes the index lock is already held.
|
|
214
|
+
* Handles both write-ahead journals (phase published before the destructive
|
|
215
|
+
* rename it covers) and legacy post-operation journals (phase published after
|
|
216
|
+
* the rename). Only cleans up generated state; malformed journals are left
|
|
217
|
+
* untouched for inspection.
|
|
218
|
+
*/
|
|
219
|
+
async function recoverQmdIndexLocked(paths, deps) {
|
|
220
|
+
const diagnostics = [];
|
|
221
|
+
const fs = deps?.fs ?? realFs;
|
|
222
|
+
const factory = deps?.factory ?? (await import("./qmd-store.js")).openQmdIndexStore;
|
|
223
|
+
const journal = await readSwapJournal(paths);
|
|
224
|
+
if (!journal) {
|
|
225
|
+
// Absent journal is fine. A malformed one is left untouched for inspection
|
|
226
|
+
// (including any staging dirs, which stay for the operator to inspect).
|
|
227
|
+
if (await pathExists(paths.qmdSwap)) {
|
|
228
|
+
diagnostics.push(diag("warning", "qmd_swap_interrupted", paths.qmdSwap, "QMD swap journal is malformed; leaving state untouched for inspection"));
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// No journal: sweep stale staging directories left by a failed or
|
|
232
|
+
// cancelled pre-journal operation.
|
|
233
|
+
await removeUnreferencedStaging(paths, undefined, fs);
|
|
234
|
+
}
|
|
235
|
+
return { ok: true, diagnostics };
|
|
236
|
+
}
|
|
237
|
+
// A valid journal references one staging dir; any other exact-pattern
|
|
238
|
+
// staging directories are stale leftovers and are swept while locked.
|
|
239
|
+
await removeUnreferencedStaging(paths, journal.stagingName, fs);
|
|
240
|
+
const staging = join(paths.qmd, journal.stagingName);
|
|
241
|
+
const current = paths.qmdCurrent;
|
|
242
|
+
const previous = join(paths.qmd, "previous");
|
|
243
|
+
const currentExists = await fs.exists(join(current, "index.sqlite"));
|
|
244
|
+
const previousExists = await fs.exists(previous);
|
|
245
|
+
const currentValid = () => validateCurrent(paths, factory, fs);
|
|
246
|
+
switch (journal.phase) {
|
|
247
|
+
case "prepared":
|
|
248
|
+
// Write-ahead: nothing was renamed yet. Legacy post-op journal: current
|
|
249
|
+
// may already have been moved to previous before the phase was written.
|
|
250
|
+
if (!currentExists && previousExists) {
|
|
251
|
+
await fs.rename(previous, current);
|
|
252
|
+
}
|
|
253
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
254
|
+
break;
|
|
255
|
+
case "previous-moved":
|
|
256
|
+
if (currentExists && previousExists) {
|
|
257
|
+
// Legacy: both renames happened before the phase was published.
|
|
258
|
+
if (await currentValid()) {
|
|
259
|
+
await fs.rm(previous, { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
await fs.rm(current, { recursive: true, force: true });
|
|
263
|
+
await fs.rename(previous, current);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else if (!currentExists && previousExists) {
|
|
267
|
+
// Crash after rename(current, previous): restore it.
|
|
268
|
+
await fs.rename(previous, current);
|
|
269
|
+
}
|
|
270
|
+
// Else: crash before rename(current, previous) — keep current.
|
|
271
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
272
|
+
break;
|
|
273
|
+
case "current-promoted":
|
|
274
|
+
if (currentExists && previousExists) {
|
|
275
|
+
// Crash after rename(staging, current) but before validated.
|
|
276
|
+
if (await currentValid()) {
|
|
277
|
+
await fs.rm(previous, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
await fs.rm(current, { recursive: true, force: true });
|
|
281
|
+
await fs.rename(previous, current);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else if (currentExists) {
|
|
285
|
+
// No prior current: keep the promoted store only if it validates;
|
|
286
|
+
// otherwise report missing rather than inventing a store.
|
|
287
|
+
if (!(await currentValid())) {
|
|
288
|
+
await fs.rm(current, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (previousExists) {
|
|
292
|
+
// Crash before rename(staging, current): restore the previous current.
|
|
293
|
+
await fs.rename(previous, current);
|
|
294
|
+
}
|
|
295
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
296
|
+
break;
|
|
297
|
+
case "validated":
|
|
298
|
+
if (currentExists && previousExists) {
|
|
299
|
+
if (await currentValid()) {
|
|
300
|
+
await fs.rm(previous, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
await fs.rm(current, { recursive: true, force: true });
|
|
304
|
+
await fs.rename(previous, current);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
await fs.rm(paths.qmdSwap, { recursive: true, force: true });
|
|
310
|
+
// A recovered validated current means the last indexing attempt actually
|
|
311
|
+
// succeeded: clear the stale error artifact. Without a usable current, keep
|
|
312
|
+
// it so status can still explain the failure.
|
|
313
|
+
if (await currentValid()) {
|
|
314
|
+
await fsRm(join(paths.qmd, "last-error.json"), { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
return { ok: true, diagnostics };
|
|
317
|
+
}
|
|
318
|
+
async function validateCurrent(paths, factory, fs) {
|
|
319
|
+
if (!(await fs.exists(join(paths.qmdCurrent, "index.sqlite"))))
|
|
320
|
+
return false;
|
|
321
|
+
try {
|
|
322
|
+
return await withStore(factory, { dbPath: join(paths.qmdCurrent, "index.sqlite"), documentsPath: paths.qmdDocuments }, async (store) => {
|
|
323
|
+
const status = await store.status();
|
|
324
|
+
const expected = await expectedDocumentCount(paths, fs);
|
|
325
|
+
return expected === undefined || status.totalDocuments === expected;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Public recovery entry point: acquires the lock and repairs any interrupted swap. */
|
|
333
|
+
export async function recoverQmdIndex(paths, deps) {
|
|
334
|
+
try {
|
|
335
|
+
await acquireIndexLock(paths);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error instanceof QmdIndexError && error.code === "qmd_index_busy") {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
diagnostics: [diag("error", "qmd_index_busy", lockDir(paths), error.message)],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
return await recoverQmdIndexLocked(paths, deps);
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await releaseIndexLock(paths);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Reindex
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
async function withStore(factory, input, work) {
|
|
357
|
+
const store = await factory(input);
|
|
358
|
+
try {
|
|
359
|
+
return await work(store);
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
await store.close();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function checkCancelled(signal) {
|
|
366
|
+
if (signal?.aborted)
|
|
367
|
+
throw new QmdIndexCancelledError();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Promote a validated staging store to current using a write-ahead journal.
|
|
371
|
+
* Every destructive rename is preceded by a durable journal phase, so a crash
|
|
372
|
+
* at any point leaves recovery enough intent to clean up or roll back.
|
|
373
|
+
*
|
|
374
|
+
* Ordering: prepared -> previous-moved (before rename current->previous)
|
|
375
|
+
* -> current-promoted (before rename staging->current) -> validate promoted
|
|
376
|
+
* current -> validated (only after count validation) -> cleanup.
|
|
377
|
+
*/
|
|
378
|
+
async function promoteStagingToCurrent(paths, stagingName, factory, fs) {
|
|
379
|
+
const staging = join(paths.qmd, stagingName);
|
|
380
|
+
const current = paths.qmdCurrent;
|
|
381
|
+
const previous = join(paths.qmd, "previous");
|
|
382
|
+
const journal = {
|
|
383
|
+
version: 1,
|
|
384
|
+
operationId: randomUUID(),
|
|
385
|
+
stagingName,
|
|
386
|
+
phase: "prepared",
|
|
387
|
+
startedAt: new Date().toISOString(),
|
|
388
|
+
};
|
|
389
|
+
await atomicWriteJson(paths.qmdSwap, journal);
|
|
390
|
+
const currentExists = await fs.exists(join(current, "index.sqlite"));
|
|
391
|
+
if (currentExists) {
|
|
392
|
+
await fs.rm(previous, { recursive: true, force: true });
|
|
393
|
+
journal.phase = "previous-moved";
|
|
394
|
+
await atomicWriteJson(paths.qmdSwap, journal);
|
|
395
|
+
await fs.rename(current, previous);
|
|
396
|
+
}
|
|
397
|
+
journal.phase = "current-promoted";
|
|
398
|
+
await atomicWriteJson(paths.qmdSwap, journal);
|
|
399
|
+
await fs.rename(staging, current);
|
|
400
|
+
// Reopen and validate the promoted current: openable AND matching the
|
|
401
|
+
// authoritative document count (manifest entry count, state file fallback).
|
|
402
|
+
await withStore(factory, { dbPath: join(current, "index.sqlite"), documentsPath: paths.qmdDocuments }, async (store) => {
|
|
403
|
+
const status = await store.status();
|
|
404
|
+
const expected = await expectedDocumentCount(paths, fs);
|
|
405
|
+
if (expected !== undefined && status.totalDocuments !== expected) {
|
|
406
|
+
throw new QmdIndexError("qmd_index_error", `Promoted store validation failed: expected ${expected} documents, got ${status.totalDocuments}`);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
journal.phase = "validated";
|
|
410
|
+
await atomicWriteJson(paths.qmdSwap, journal);
|
|
411
|
+
await fs.rm(previous, { recursive: true, force: true });
|
|
412
|
+
await fs.rm(paths.qmdSwap, { recursive: true, force: true });
|
|
413
|
+
await fsRm(join(paths.qmd, "last-error.json"), { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Reindex a vault's QMD store via copy-on-write staging and a journaled swap.
|
|
417
|
+
* Acquires the cross-process lock and first recovers any interrupted swap.
|
|
418
|
+
*/
|
|
419
|
+
export async function reindexQmdVault(paths, options, deps) {
|
|
420
|
+
const started = Date.now();
|
|
421
|
+
const scope = options.scope;
|
|
422
|
+
const components = [...new Set(options.components)];
|
|
423
|
+
const force = options.force ?? false;
|
|
424
|
+
const signal = options.signal;
|
|
425
|
+
const onProgress = options.onProgress;
|
|
426
|
+
const factory = deps?.factory ?? (await import("./qmd-store.js")).openQmdIndexStore;
|
|
427
|
+
const fs = deps?.fs ?? realFs;
|
|
428
|
+
const warnings = [];
|
|
429
|
+
const errors = [];
|
|
430
|
+
const documents = { indexed: 0, updated: 0, unchanged: 0, removed: 0 };
|
|
431
|
+
const vectors = { generated: 0, skipped: 0, errors: 0 };
|
|
432
|
+
return enqueue(paths.root, async () => {
|
|
433
|
+
try {
|
|
434
|
+
await acquireIndexLock(paths);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
if (error instanceof QmdIndexError && error.code === "qmd_index_busy") {
|
|
438
|
+
const status = await readQmdIndexStatus(paths);
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
scope,
|
|
442
|
+
components,
|
|
443
|
+
documents,
|
|
444
|
+
vectors,
|
|
445
|
+
elapsedMs: Date.now() - started,
|
|
446
|
+
status,
|
|
447
|
+
warnings,
|
|
448
|
+
errors: [{ code: "qmd_index_busy", message: error.message }],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
let vaultId;
|
|
454
|
+
let manifestHash = "";
|
|
455
|
+
// The active staging dir is cleaned on pre-journal failure/cancellation
|
|
456
|
+
// (before a journal is published, the swap owns nothing yet).
|
|
457
|
+
let stagingName;
|
|
458
|
+
try {
|
|
459
|
+
checkCancelled(signal);
|
|
460
|
+
// Always recover an interrupted prior swap first.
|
|
461
|
+
const recovery = await recoverQmdIndexLocked(paths, deps);
|
|
462
|
+
warnings.push(...recovery.diagnostics.map((d) => ({ code: d.code, message: d.message, path: d.path })));
|
|
463
|
+
checkCancelled(signal);
|
|
464
|
+
onProgress?.({ stage: "mirror", message: "Reconciling validated document mirror" });
|
|
465
|
+
vaultId = await ensureVaultId(paths);
|
|
466
|
+
const activeVaultId = vaultId;
|
|
467
|
+
const mirror = await reconcileQmdMirror(paths, vaultId, scope);
|
|
468
|
+
manifestHash = mirror.manifestHash;
|
|
469
|
+
documents.indexed = mirror.counts.indexed;
|
|
470
|
+
documents.updated = mirror.counts.updated;
|
|
471
|
+
documents.unchanged = mirror.counts.unchanged;
|
|
472
|
+
documents.removed = mirror.counts.removed;
|
|
473
|
+
warnings.push(...mirror.diagnostics.map((d) => ({ code: d.code, message: d.message, path: d.path })));
|
|
474
|
+
checkCancelled(signal);
|
|
475
|
+
onProgress?.({ stage: "copy", message: "Preparing staging store" });
|
|
476
|
+
const name = `staging-${randomUUID()}`;
|
|
477
|
+
stagingName = name;
|
|
478
|
+
const staging = join(paths.qmd, name);
|
|
479
|
+
await mkdir(staging, { recursive: true });
|
|
480
|
+
// Copy the current store unless this is a forced lexical rebuild.
|
|
481
|
+
const currentExists = await fs.exists(join(paths.qmdCurrent, "index.sqlite"));
|
|
482
|
+
const emptyStaging = force && components.includes("lexical");
|
|
483
|
+
if (currentExists && !emptyStaging) {
|
|
484
|
+
await fs.cp(paths.qmdCurrent, staging, { recursive: true, errorOnExist: true });
|
|
485
|
+
}
|
|
486
|
+
const wantsLexical = components.includes("lexical");
|
|
487
|
+
const wantsVectors = components.includes("vectors");
|
|
488
|
+
if (wantsLexical || wantsVectors)
|
|
489
|
+
onProgress?.({ stage: "lexical", message: "Updating lexical index" });
|
|
490
|
+
let needsEmbedding = 0;
|
|
491
|
+
let canonicalDocuments = 0;
|
|
492
|
+
let evidenceDocuments = 0;
|
|
493
|
+
let totalDocuments = 0;
|
|
494
|
+
let hasVectorIndex = false;
|
|
495
|
+
await withStore(factory, { dbPath: join(staging, "index.sqlite"), documentsPath: paths.qmdDocuments }, async (store) => {
|
|
496
|
+
if (wantsLexical || wantsVectors) {
|
|
497
|
+
await store.update((progress) => {
|
|
498
|
+
checkCancelled(signal);
|
|
499
|
+
onProgress?.({
|
|
500
|
+
stage: "lexical",
|
|
501
|
+
message: `Indexing ${progress.collection}: ${progress.file}`,
|
|
502
|
+
current: progress.current,
|
|
503
|
+
total: progress.total,
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
if (wantsVectors) {
|
|
508
|
+
onProgress?.({ stage: "vectors", message: "Embedding vectors" });
|
|
509
|
+
const embedResult = await store.embed({
|
|
510
|
+
force,
|
|
511
|
+
onProgress: (progress) => {
|
|
512
|
+
checkCancelled(signal);
|
|
513
|
+
onProgress?.({
|
|
514
|
+
stage: "vectors",
|
|
515
|
+
message: "Embedding chunks",
|
|
516
|
+
current: progress.chunksEmbedded,
|
|
517
|
+
total: progress.totalChunks,
|
|
518
|
+
});
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
vectors.generated = embedResult.docsProcessed;
|
|
522
|
+
vectors.errors = embedResult.errors;
|
|
523
|
+
}
|
|
524
|
+
const status = await store.status();
|
|
525
|
+
checkCancelled(signal);
|
|
526
|
+
totalDocuments = status.totalDocuments;
|
|
527
|
+
needsEmbedding = status.needsEmbedding;
|
|
528
|
+
canonicalDocuments = status.canonicalDocuments;
|
|
529
|
+
evidenceDocuments = status.evidenceDocuments;
|
|
530
|
+
hasVectorIndex = status.hasVectorIndex;
|
|
531
|
+
if (wantsVectors) {
|
|
532
|
+
vectors.skipped = Math.max(0, status.totalDocuments - vectors.generated);
|
|
533
|
+
}
|
|
534
|
+
const manifest = await readQmdManifest(paths, activeVaultId);
|
|
535
|
+
if (status.totalDocuments !== Object.keys(manifest.entries).length) {
|
|
536
|
+
throw new QmdIndexError("qmd_index_error", `Indexed document count (${status.totalDocuments}) does not match manifest (${Object.keys(manifest.entries).length})`);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
checkCancelled(signal);
|
|
540
|
+
const models = resolveQmdModels();
|
|
541
|
+
const stateFile = {
|
|
542
|
+
version: 1,
|
|
543
|
+
vaultId,
|
|
544
|
+
qmdVersion: QMD_PACKAGE_VERSION,
|
|
545
|
+
models,
|
|
546
|
+
manifestHash,
|
|
547
|
+
indexedAt: new Date().toISOString(),
|
|
548
|
+
status: {
|
|
549
|
+
totalDocuments,
|
|
550
|
+
canonicalDocuments,
|
|
551
|
+
evidenceDocuments,
|
|
552
|
+
needsEmbedding,
|
|
553
|
+
hasVectorIndex,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
await atomicWriteJson(join(staging, "index-state.json"), stateFile);
|
|
557
|
+
// Reopen staging and validate before any rename.
|
|
558
|
+
onProgress?.({ stage: "validate", message: "Validating staging store" });
|
|
559
|
+
await withStore(factory, { dbPath: join(staging, "index.sqlite"), documentsPath: paths.qmdDocuments }, async (store) => {
|
|
560
|
+
const status = await store.status();
|
|
561
|
+
checkCancelled(signal);
|
|
562
|
+
if (status.totalDocuments !== totalDocuments) {
|
|
563
|
+
throw new QmdIndexError("qmd_index_error", "Staging store validation failed");
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
// Journaled swap: every phase is durable intent published before the
|
|
567
|
+
// destructive rename it covers; recovery uses journal + filesystem state.
|
|
568
|
+
onProgress?.({ stage: "swap", message: "Promoting validated index" });
|
|
569
|
+
await promoteStagingToCurrent(paths, stagingName, factory, fs);
|
|
570
|
+
await fsRm(join(paths.qmd, "last-error.json"), { recursive: true, force: true });
|
|
571
|
+
const status = await readQmdIndexStatus(paths);
|
|
572
|
+
return {
|
|
573
|
+
ok: true,
|
|
574
|
+
vaultId,
|
|
575
|
+
scope,
|
|
576
|
+
components,
|
|
577
|
+
documents,
|
|
578
|
+
vectors,
|
|
579
|
+
elapsedMs: Date.now() - started,
|
|
580
|
+
status,
|
|
581
|
+
warnings,
|
|
582
|
+
errors,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
// Pre-journal failure/cancellation: remove this operation's staging
|
|
587
|
+
// copy. Once a journal references it, recovery owns the cleanup.
|
|
588
|
+
if (stagingName && !(await pathExists(paths.qmdSwap))) {
|
|
589
|
+
await fs.rm(join(paths.qmd, stagingName), { recursive: true, force: true });
|
|
590
|
+
}
|
|
591
|
+
if (!(error instanceof QmdIndexCancelledError)) {
|
|
592
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
593
|
+
errors.push({
|
|
594
|
+
code: error instanceof QmdIndexError ? error.code : "qmd_index_error",
|
|
595
|
+
message,
|
|
596
|
+
});
|
|
597
|
+
if (manifestHash) {
|
|
598
|
+
await atomicWriteJson(join(paths.qmd, "last-error.json"), {
|
|
599
|
+
code: error instanceof QmdIndexError ? error.code : "qmd_index_error",
|
|
600
|
+
message: error instanceof Error ? error.message : String(error),
|
|
601
|
+
manifestHash,
|
|
602
|
+
at: new Date().toISOString(),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const status = await readQmdIndexStatus(paths);
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
vaultId,
|
|
610
|
+
scope,
|
|
611
|
+
components,
|
|
612
|
+
documents,
|
|
613
|
+
vectors,
|
|
614
|
+
elapsedMs: Date.now() - started,
|
|
615
|
+
status,
|
|
616
|
+
warnings,
|
|
617
|
+
errors,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
await releaseIndexLock(paths);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Safety-only path used after a metadata projection failure. Backfills/validates
|
|
627
|
+
* the vault id, removes only unsafe mirror entries (missing or rejected pages),
|
|
628
|
+
* and runs a lexical removal update only when entries were removed. Never adds
|
|
629
|
+
* or updates valid mirror pages after a projection failure.
|
|
630
|
+
*/
|
|
631
|
+
export async function invalidateQmdAfterProjectionFailure(paths, deps) {
|
|
632
|
+
await enqueue(paths.root, async () => {
|
|
633
|
+
try {
|
|
634
|
+
await acquireIndexLock(paths);
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
// Busy or transient — the safety pass is best-effort; status will show
|
|
638
|
+
// the current state. Never remove a lock we do not own.
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
let stagingName;
|
|
642
|
+
try {
|
|
643
|
+
let vaultId;
|
|
644
|
+
try {
|
|
645
|
+
vaultId = await ensureVaultId(paths);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const result = await invalidateUnsafeQmdEntries(paths, vaultId);
|
|
651
|
+
if (result.counts.removed === 0)
|
|
652
|
+
return;
|
|
653
|
+
const factory = deps?.factory ?? (await import("./qmd-store.js")).openQmdIndexStore;
|
|
654
|
+
const fs = deps?.fs ?? realFs;
|
|
655
|
+
// Lexical removal update: copy current to staging, update, validate, swap.
|
|
656
|
+
const name = `staging-${randomUUID()}`;
|
|
657
|
+
stagingName = name;
|
|
658
|
+
const staging = join(paths.qmd, name);
|
|
659
|
+
await mkdir(staging, { recursive: true });
|
|
660
|
+
if (await fs.exists(join(paths.qmdCurrent, "index.sqlite"))) {
|
|
661
|
+
await fs.cp(paths.qmdCurrent, staging, { recursive: true, errorOnExist: true });
|
|
662
|
+
}
|
|
663
|
+
let totalDocuments = 0;
|
|
664
|
+
let canonicalDocuments = 0;
|
|
665
|
+
let evidenceDocuments = 0;
|
|
666
|
+
let needsEmbedding = 0;
|
|
667
|
+
let hasVectorIndex = false;
|
|
668
|
+
await withStore(factory, { dbPath: join(staging, "index.sqlite"), documentsPath: paths.qmdDocuments }, async (store) => {
|
|
669
|
+
await store.update();
|
|
670
|
+
const status = await store.status();
|
|
671
|
+
totalDocuments = status.totalDocuments;
|
|
672
|
+
canonicalDocuments = status.canonicalDocuments;
|
|
673
|
+
evidenceDocuments = status.evidenceDocuments;
|
|
674
|
+
needsEmbedding = status.needsEmbedding;
|
|
675
|
+
hasVectorIndex = status.hasVectorIndex;
|
|
676
|
+
const manifest = await readQmdManifest(paths, vaultId);
|
|
677
|
+
if (status.totalDocuments !== Object.keys(manifest.entries).length) {
|
|
678
|
+
throw new QmdIndexError("qmd_index_error", `Indexed document count (${status.totalDocuments}) does not match manifest (${Object.keys(manifest.entries).length})`);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
const models = resolveQmdModels();
|
|
682
|
+
await atomicWriteJson(join(staging, "index-state.json"), {
|
|
683
|
+
version: 1,
|
|
684
|
+
vaultId,
|
|
685
|
+
qmdVersion: QMD_PACKAGE_VERSION,
|
|
686
|
+
models,
|
|
687
|
+
manifestHash: result.manifestHash,
|
|
688
|
+
indexedAt: new Date().toISOString(),
|
|
689
|
+
status: {
|
|
690
|
+
totalDocuments,
|
|
691
|
+
canonicalDocuments,
|
|
692
|
+
evidenceDocuments,
|
|
693
|
+
needsEmbedding,
|
|
694
|
+
hasVectorIndex,
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
// Journaled swap with write-ahead phases, shared with normal reindexing.
|
|
698
|
+
await promoteStagingToCurrent(paths, stagingName, factory, fs);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Pre-journal failure: remove this operation's staging copy. Once a
|
|
702
|
+
// journal references it, recovery owns the cleanup. Generated QMD state
|
|
703
|
+
// is repairable; leave current intact. Status shows stale/error.
|
|
704
|
+
if (stagingName && !(await pathExists(paths.qmdSwap))) {
|
|
705
|
+
await fsRm(join(paths.qmd, stagingName), { recursive: true, force: true });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
await releaseIndexLock(paths);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
// Generated status
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
/**
|
|
717
|
+
* Read generated QMD index status without opening any QMD store or loading a
|
|
718
|
+
* model. Reads only manifest, current state, last-error, lock, and swap journal.
|
|
719
|
+
*
|
|
720
|
+
* Precedence: valid journal -> recovering; malformed artifacts -> error;
|
|
721
|
+
* no state/error -> missing; state without DB -> error; error artifact without
|
|
722
|
+
* a usable current -> error; usable current with any mismatch -> stale;
|
|
723
|
+
* otherwise -> ready. An absent config beside an existing state is an error
|
|
724
|
+
* because the indexed vault identity cannot be confirmed.
|
|
725
|
+
*/
|
|
726
|
+
export async function readQmdIndexStatus(paths) {
|
|
727
|
+
const models = resolveQmdModels();
|
|
728
|
+
const issues = [];
|
|
729
|
+
const repair = new Set();
|
|
730
|
+
const config = await readJsonArtifact(join(paths.dotWiki, "config.json"));
|
|
731
|
+
const vaultId = config.kind === "valid" &&
|
|
732
|
+
typeof config.value.vault_id === "string" &&
|
|
733
|
+
UUID.test(config.value.vault_id)
|
|
734
|
+
? config.value.vault_id
|
|
735
|
+
: undefined;
|
|
736
|
+
// Manifest is missing only when the file is absent; malformed JSON, unsafe
|
|
737
|
+
// entries, or a vault mismatch are all `error` (never trusted prior state).
|
|
738
|
+
let manifestHash;
|
|
739
|
+
if (config.kind === "valid" && vaultId) {
|
|
740
|
+
const manifestRead = await readJsonArtifact(paths.qmdManifest);
|
|
741
|
+
if (manifestRead.kind === "invalid") {
|
|
742
|
+
issues.push({
|
|
743
|
+
code: "qmd_manifest_invalid",
|
|
744
|
+
message: manifestRead.message,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
else if (manifestRead.kind === "valid") {
|
|
748
|
+
try {
|
|
749
|
+
const manifest = await readQmdManifest(paths, vaultId);
|
|
750
|
+
manifestHash = hashQmdManifest(manifest);
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
issues.push({
|
|
754
|
+
code: "qmd_manifest_invalid",
|
|
755
|
+
message: error.message,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
else if (config.kind === "invalid") {
|
|
761
|
+
issues.push({ code: "qmd_config_invalid", message: config.message });
|
|
762
|
+
}
|
|
763
|
+
const stateRead = await readJsonArtifact(join(paths.qmdCurrent, "index-state.json"));
|
|
764
|
+
const stateFile = stateRead.kind === "valid" && isQmdIndexStateFile(stateRead.value)
|
|
765
|
+
? stateRead.value
|
|
766
|
+
: undefined;
|
|
767
|
+
if (stateRead.kind === "invalid") {
|
|
768
|
+
issues.push({ code: "qmd_index_error", message: stateRead.message });
|
|
769
|
+
}
|
|
770
|
+
else if (stateRead.kind === "valid" && !stateFile) {
|
|
771
|
+
issues.push({ code: "qmd_index_error", message: "QMD index state file is malformed" });
|
|
772
|
+
}
|
|
773
|
+
const lastError = await readJsonArtifact(join(paths.qmd, "last-error.json"));
|
|
774
|
+
if (lastError.kind === "invalid") {
|
|
775
|
+
issues.push({ code: "qmd_index_error", message: lastError.message });
|
|
776
|
+
}
|
|
777
|
+
const journal = await readSwapJournal(paths);
|
|
778
|
+
if (journal) {
|
|
779
|
+
return {
|
|
780
|
+
state: "recovering",
|
|
781
|
+
vaultId: stateFile?.vaultId ?? vaultId,
|
|
782
|
+
qmdVersion: QMD_PACKAGE_VERSION,
|
|
783
|
+
models,
|
|
784
|
+
totalDocuments: stateFile?.status?.totalDocuments ?? 0,
|
|
785
|
+
canonicalDocuments: stateFile?.status?.canonicalDocuments ?? 0,
|
|
786
|
+
evidenceDocuments: stateFile?.status?.evidenceDocuments ?? 0,
|
|
787
|
+
needsEmbedding: stateFile?.status?.needsEmbedding ?? 0,
|
|
788
|
+
hasVectorIndex: stateFile?.status?.hasVectorIndex ?? false,
|
|
789
|
+
manifestHash,
|
|
790
|
+
indexedManifestHash: stateFile?.manifestHash,
|
|
791
|
+
lastIndexedAt: stateFile?.indexedAt,
|
|
792
|
+
swapPhase: journal.phase,
|
|
793
|
+
repairComponents: [],
|
|
794
|
+
issues: [
|
|
795
|
+
{
|
|
796
|
+
code: "qmd_swap_interrupted",
|
|
797
|
+
message: "A QMD index swap was interrupted and is being recovered",
|
|
798
|
+
},
|
|
799
|
+
],
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
if (await pathExists(paths.qmdSwap)) {
|
|
803
|
+
issues.push({
|
|
804
|
+
code: "qmd_swap_interrupted",
|
|
805
|
+
message: "QMD swap journal is malformed; leaving state untouched for inspection",
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
const dbExists = await pathExists(join(paths.qmdCurrent, "index.sqlite"));
|
|
809
|
+
const hasPriorVectors = stateFile?.status?.hasVectorIndex ?? false;
|
|
810
|
+
// Fail closed: any malformed artifact, an interrupted swap, a state without
|
|
811
|
+
// its DB, or an error artifact without a usable current is an error.
|
|
812
|
+
const malformedArtifact = issues.some((i) => i.code === "qmd_manifest_invalid" || i.code === "qmd_config_invalid") ||
|
|
813
|
+
stateRead.kind === "invalid" ||
|
|
814
|
+
(stateRead.kind === "valid" && !stateFile) ||
|
|
815
|
+
lastError.kind === "invalid";
|
|
816
|
+
const interruptedSwap = issues.some((i) => i.code === "qmd_swap_interrupted");
|
|
817
|
+
const stateWithoutDb = stateFile !== undefined && !dbExists;
|
|
818
|
+
const errorWithoutCurrent = lastError.kind === "valid" && stateFile === undefined;
|
|
819
|
+
if (malformedArtifact || interruptedSwap || stateWithoutDb || errorWithoutCurrent) {
|
|
820
|
+
if (stateWithoutDb) {
|
|
821
|
+
issues.push({
|
|
822
|
+
code: "qmd_index_error",
|
|
823
|
+
message: "QMD index state exists but current/index.sqlite is absent",
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
if (errorWithoutCurrent) {
|
|
827
|
+
issues.push({
|
|
828
|
+
code: "qmd_index_error",
|
|
829
|
+
message: "Last QMD index attempt failed and no usable current index exists",
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
repair.add(hasPriorVectors ? "vectors" : "lexical");
|
|
833
|
+
return errorStatus({
|
|
834
|
+
stateFile,
|
|
835
|
+
vaultId,
|
|
836
|
+
manifestHash,
|
|
837
|
+
models,
|
|
838
|
+
issues,
|
|
839
|
+
repair,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
if (!stateFile) {
|
|
843
|
+
// No state and no error artifact -> a fresh vault (legacy config without a
|
|
844
|
+
// vault_id remains backfillable, not invalid).
|
|
845
|
+
return {
|
|
846
|
+
state: "missing",
|
|
847
|
+
vaultId,
|
|
848
|
+
qmdVersion: QMD_PACKAGE_VERSION,
|
|
849
|
+
models,
|
|
850
|
+
totalDocuments: 0,
|
|
851
|
+
canonicalDocuments: 0,
|
|
852
|
+
evidenceDocuments: 0,
|
|
853
|
+
needsEmbedding: 0,
|
|
854
|
+
hasVectorIndex: false,
|
|
855
|
+
manifestHash,
|
|
856
|
+
repairComponents: [],
|
|
857
|
+
issues,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
// A usable state exists; absent config cannot confirm the vault identity.
|
|
861
|
+
if (config.kind !== "valid" || !vaultId) {
|
|
862
|
+
issues.push({
|
|
863
|
+
code: "qmd_config_invalid",
|
|
864
|
+
message: "QMD config is missing or malformed; vault identity cannot be confirmed",
|
|
865
|
+
});
|
|
866
|
+
repair.add(stateFile.status.hasVectorIndex ? "vectors" : "lexical");
|
|
867
|
+
return errorStatus({
|
|
868
|
+
stateFile,
|
|
869
|
+
vaultId,
|
|
870
|
+
manifestHash,
|
|
871
|
+
models,
|
|
872
|
+
issues,
|
|
873
|
+
repair,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
const embedChanged = stateFile.models.embed !== models.embed;
|
|
877
|
+
const manifestChanged = stateFile.manifestHash !== manifestHash;
|
|
878
|
+
const versionChanged = stateFile.qmdVersion !== QMD_PACKAGE_VERSION;
|
|
879
|
+
const vaultChanged = stateFile.vaultId !== vaultId;
|
|
880
|
+
const hasVectors = stateFile.status.hasVectorIndex;
|
|
881
|
+
if (lastError.kind === "valid") {
|
|
882
|
+
issues.push({
|
|
883
|
+
code: "qmd_index_error",
|
|
884
|
+
message: lastError.value.message ?? "Last QMD index attempt failed",
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
if (manifestChanged) {
|
|
888
|
+
issues.push({
|
|
889
|
+
code: "qmd_index_stale",
|
|
890
|
+
message: "QMD index is stale relative to the document manifest",
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
if (embedChanged) {
|
|
894
|
+
issues.push({
|
|
895
|
+
code: "qmd_index_stale",
|
|
896
|
+
message: "QMD embedding model changed; vectors are stale",
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
if (versionChanged) {
|
|
900
|
+
issues.push({
|
|
901
|
+
code: "qmd_index_stale",
|
|
902
|
+
message: "QMD package version changed; index needs rebuild",
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
if (vaultChanged) {
|
|
906
|
+
issues.push({ code: "qmd_index_stale", message: "QMD index vault identity changed" });
|
|
907
|
+
}
|
|
908
|
+
if (issues.length > 0) {
|
|
909
|
+
// Derive the minimal repair set: an embedding model change or any mismatch
|
|
910
|
+
// with an existing vector index requires a vectors pass, which refreshes
|
|
911
|
+
// the document index first (so it also repairs lexical staleness). Only
|
|
912
|
+
// when no vectors are involved does a lexical pass suffice.
|
|
913
|
+
const needsVectors = embedChanged ||
|
|
914
|
+
(hasVectors &&
|
|
915
|
+
(manifestChanged || versionChanged || vaultChanged || lastError.kind === "valid"));
|
|
916
|
+
const needsLexical = !needsVectors &&
|
|
917
|
+
(manifestChanged || versionChanged || vaultChanged || lastError.kind === "valid");
|
|
918
|
+
if (needsVectors)
|
|
919
|
+
repair.add("vectors");
|
|
920
|
+
if (needsLexical)
|
|
921
|
+
repair.add("lexical");
|
|
922
|
+
return {
|
|
923
|
+
state: "stale",
|
|
924
|
+
vaultId: stateFile.vaultId,
|
|
925
|
+
qmdVersion: QMD_PACKAGE_VERSION,
|
|
926
|
+
models,
|
|
927
|
+
totalDocuments: stateFile.status.totalDocuments,
|
|
928
|
+
canonicalDocuments: stateFile.status.canonicalDocuments,
|
|
929
|
+
evidenceDocuments: stateFile.status.evidenceDocuments,
|
|
930
|
+
needsEmbedding: stateFile.status.needsEmbedding,
|
|
931
|
+
hasVectorIndex: hasVectors,
|
|
932
|
+
manifestHash,
|
|
933
|
+
indexedManifestHash: stateFile.manifestHash,
|
|
934
|
+
lastIndexedAt: stateFile.indexedAt,
|
|
935
|
+
repairComponents: [...repair].sort(),
|
|
936
|
+
issues,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
state: "ready",
|
|
941
|
+
vaultId: stateFile.vaultId,
|
|
942
|
+
qmdVersion: QMD_PACKAGE_VERSION,
|
|
943
|
+
models,
|
|
944
|
+
totalDocuments: stateFile.status.totalDocuments,
|
|
945
|
+
canonicalDocuments: stateFile.status.canonicalDocuments,
|
|
946
|
+
evidenceDocuments: stateFile.status.evidenceDocuments,
|
|
947
|
+
needsEmbedding: stateFile.status.needsEmbedding,
|
|
948
|
+
hasVectorIndex: hasVectors,
|
|
949
|
+
manifestHash,
|
|
950
|
+
indexedManifestHash: stateFile.manifestHash,
|
|
951
|
+
lastIndexedAt: stateFile.indexedAt,
|
|
952
|
+
repairComponents: [],
|
|
953
|
+
issues,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function errorStatus(opts) {
|
|
957
|
+
return {
|
|
958
|
+
state: "error",
|
|
959
|
+
vaultId: opts.stateFile?.vaultId ?? opts.vaultId,
|
|
960
|
+
qmdVersion: QMD_PACKAGE_VERSION,
|
|
961
|
+
models: opts.models,
|
|
962
|
+
totalDocuments: opts.stateFile?.status?.totalDocuments ?? 0,
|
|
963
|
+
canonicalDocuments: opts.stateFile?.status?.canonicalDocuments ?? 0,
|
|
964
|
+
evidenceDocuments: opts.stateFile?.status?.evidenceDocuments ?? 0,
|
|
965
|
+
needsEmbedding: opts.stateFile?.status?.needsEmbedding ?? 0,
|
|
966
|
+
hasVectorIndex: opts.stateFile?.status?.hasVectorIndex ?? false,
|
|
967
|
+
manifestHash: opts.manifestHash,
|
|
968
|
+
indexedManifestHash: opts.stateFile?.manifestHash,
|
|
969
|
+
lastIndexedAt: opts.stateFile?.indexedAt,
|
|
970
|
+
repairComponents: [...opts.repair].sort(),
|
|
971
|
+
issues: opts.issues,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
function isQmdIndexStateFile(value) {
|
|
975
|
+
if (typeof value !== "object" || value === null)
|
|
976
|
+
return false;
|
|
977
|
+
const state = value;
|
|
978
|
+
if (state.version !== 1 || typeof state.vaultId !== "string")
|
|
979
|
+
return false;
|
|
980
|
+
if (typeof state.qmdVersion !== "string")
|
|
981
|
+
return false;
|
|
982
|
+
const models = state.models;
|
|
983
|
+
if (!models || typeof models !== "object")
|
|
984
|
+
return false;
|
|
985
|
+
for (const key of ["embed", "generate", "rerank"]) {
|
|
986
|
+
if (typeof models[key] !== "string")
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
if (typeof state.manifestHash !== "string")
|
|
990
|
+
return false;
|
|
991
|
+
if (typeof state.indexedAt !== "string")
|
|
992
|
+
return false;
|
|
993
|
+
const status = state.status;
|
|
994
|
+
if (!status || typeof status !== "object")
|
|
995
|
+
return false;
|
|
996
|
+
if (typeof status.totalDocuments !== "number")
|
|
997
|
+
return false;
|
|
998
|
+
if (typeof status.canonicalDocuments !== "number")
|
|
999
|
+
return false;
|
|
1000
|
+
if (typeof status.evidenceDocuments !== "number")
|
|
1001
|
+
return false;
|
|
1002
|
+
if (typeof status.needsEmbedding !== "number")
|
|
1003
|
+
return false;
|
|
1004
|
+
if (typeof status.hasVectorIndex !== "boolean")
|
|
1005
|
+
return false;
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
async function readJsonArtifact(path) {
|
|
1009
|
+
let raw;
|
|
1010
|
+
try {
|
|
1011
|
+
raw = await readFile(path, "utf8");
|
|
1012
|
+
}
|
|
1013
|
+
catch (error) {
|
|
1014
|
+
if (error.code === "ENOENT")
|
|
1015
|
+
return { kind: "missing" };
|
|
1016
|
+
return { kind: "invalid", message: error.message };
|
|
1017
|
+
}
|
|
1018
|
+
try {
|
|
1019
|
+
return { kind: "valid", value: JSON.parse(raw) };
|
|
1020
|
+
}
|
|
1021
|
+
catch (error) {
|
|
1022
|
+
return { kind: "invalid", message: error.message };
|
|
1023
|
+
}
|
|
1024
|
+
}
|