@zosmaai/pi-llm-wiki 0.12.1 → 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-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 +8 -1
- 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,496 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { type KnowledgeDiagnostic, serializeKnowledgeDocument } from "./knowledge-document.js";
|
|
6
|
+
import type { VaultPaths } from "./utils.js";
|
|
7
|
+
import { discoverKnowledgeDocuments } from "./vault-format.js";
|
|
8
|
+
|
|
9
|
+
export const QMD_MANIFEST_VERSION = 1;
|
|
10
|
+
export type QmdRole = "canonical" | "evidence";
|
|
11
|
+
|
|
12
|
+
export interface QmdManifestEntry {
|
|
13
|
+
sourcePath: string;
|
|
14
|
+
vaultId: string;
|
|
15
|
+
pageId: string;
|
|
16
|
+
contentHash: string;
|
|
17
|
+
role: QmdRole;
|
|
18
|
+
type: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface QmdManifest {
|
|
22
|
+
version: 1;
|
|
23
|
+
vaultId: string;
|
|
24
|
+
entries: Record<string, QmdManifestEntry>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QmdMirrorCounts {
|
|
28
|
+
indexed: number;
|
|
29
|
+
updated: number;
|
|
30
|
+
unchanged: number;
|
|
31
|
+
removed: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface QmdMirrorResult {
|
|
35
|
+
manifest: QmdManifest;
|
|
36
|
+
manifestHash: string;
|
|
37
|
+
counts: QmdMirrorCounts;
|
|
38
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const CANONICAL_TYPES = new Set([
|
|
42
|
+
"concept",
|
|
43
|
+
"entity",
|
|
44
|
+
"analysis",
|
|
45
|
+
"synthesis",
|
|
46
|
+
"requirement",
|
|
47
|
+
"skill",
|
|
48
|
+
"case",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** Classify a page type into a QMD collection role. Unknown types are evidence. */
|
|
52
|
+
export function roleForDocumentType(type: string): QmdRole {
|
|
53
|
+
return CANONICAL_TYPES.has(type.trim().toLowerCase()) ? "canonical" : "evidence";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build the deterministic generated mirror path for a role + page id. */
|
|
57
|
+
export function manifestKey(role: QmdRole, pageId: string): string {
|
|
58
|
+
return `${["documents", role, ...pageId.split("/")].join("/")}.md`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function hashQmdContent(content: string): string {
|
|
62
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function hashQmdManifest(manifest: QmdManifest): string {
|
|
66
|
+
const entries = Object.fromEntries(
|
|
67
|
+
Object.entries(manifest.entries).sort(([left], [right]) =>
|
|
68
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
return hashQmdContent(
|
|
72
|
+
JSON.stringify({ version: manifest.version, vaultId: manifest.vaultId, entries }),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function atomicWrite(path: string, content: string): Promise<void> {
|
|
77
|
+
await mkdir(dirname(path), { recursive: true });
|
|
78
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
79
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
|
|
80
|
+
await rename(temporary, path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a manifest key to a physical path, rejecting anything that could
|
|
85
|
+
* escape `paths.qmd` or traverse via `..`. Never trust paths from JSON.
|
|
86
|
+
*/
|
|
87
|
+
function manifestKeyToPath(paths: VaultPaths, key: string): string {
|
|
88
|
+
if (isAbsolute(key)) {
|
|
89
|
+
throw new Error("qmd_manifest_invalid: manifest key is absolute");
|
|
90
|
+
}
|
|
91
|
+
const parts = key.split("/");
|
|
92
|
+
if (parts.some((part) => part === ".." || part === "" || part.includes("\\"))) {
|
|
93
|
+
throw new Error("qmd_manifest_invalid: manifest key contains unsafe path segments");
|
|
94
|
+
}
|
|
95
|
+
if (parts[0] !== "documents") {
|
|
96
|
+
throw new Error("qmd_manifest_invalid: manifest key outside documents");
|
|
97
|
+
}
|
|
98
|
+
const resolved = resolve(paths.qmd, key);
|
|
99
|
+
if (!resolved.startsWith(resolve(paths.qmd) + sep)) {
|
|
100
|
+
throw new Error("qmd_manifest_invalid: manifest key escapes qmd directory");
|
|
101
|
+
}
|
|
102
|
+
return resolved;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Load and validate a previously published manifest for this vault. */
|
|
106
|
+
export async function readQmdManifest(
|
|
107
|
+
paths: VaultPaths,
|
|
108
|
+
expectedVaultId: string,
|
|
109
|
+
): Promise<QmdManifest> {
|
|
110
|
+
let raw: string;
|
|
111
|
+
try {
|
|
112
|
+
raw = await readFile(paths.qmdManifest, "utf8");
|
|
113
|
+
} catch {
|
|
114
|
+
return { version: QMD_MANIFEST_VERSION, vaultId: expectedVaultId, entries: {} };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let data: unknown;
|
|
118
|
+
try {
|
|
119
|
+
data = JSON.parse(raw) as unknown;
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error("qmd_manifest_invalid: manifest is not valid JSON");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
typeof data !== "object" ||
|
|
126
|
+
data === null ||
|
|
127
|
+
(data as Record<string, unknown>).version !== QMD_MANIFEST_VERSION
|
|
128
|
+
) {
|
|
129
|
+
throw new Error("qmd_manifest_invalid: unsupported manifest version");
|
|
130
|
+
}
|
|
131
|
+
const vaultId = (data as Record<string, unknown>).vaultId;
|
|
132
|
+
if (typeof vaultId !== "string" || vaultId !== expectedVaultId) {
|
|
133
|
+
throw new Error("qmd_manifest_invalid: manifest vaultId mismatch");
|
|
134
|
+
}
|
|
135
|
+
const rawEntries = (data as Record<string, unknown>).entries;
|
|
136
|
+
if (typeof rawEntries !== "object" || rawEntries === null) {
|
|
137
|
+
throw new Error("qmd_manifest_invalid: manifest entries missing");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const entries: Record<string, QmdManifestEntry> = {};
|
|
141
|
+
const wikiRoot = resolve(paths.wiki) + sep;
|
|
142
|
+
for (const [key, value] of Object.entries(rawEntries as Record<string, unknown>)) {
|
|
143
|
+
// Reject absolute keys and traversal keys before trusting any entry.
|
|
144
|
+
if (isAbsolute(key) || key.includes("\\") || key.split("/").includes("..")) {
|
|
145
|
+
throw new Error("qmd_manifest_invalid: unsafe manifest key");
|
|
146
|
+
}
|
|
147
|
+
const entry = value as Partial<QmdManifestEntry>;
|
|
148
|
+
if (
|
|
149
|
+
typeof entry !== "object" ||
|
|
150
|
+
entry === null ||
|
|
151
|
+
typeof entry.sourcePath !== "string" ||
|
|
152
|
+
typeof entry.pageId !== "string" ||
|
|
153
|
+
typeof entry.contentHash !== "string" ||
|
|
154
|
+
typeof entry.type !== "string" ||
|
|
155
|
+
(entry.role !== "canonical" && entry.role !== "evidence")
|
|
156
|
+
) {
|
|
157
|
+
throw new Error("qmd_manifest_invalid: malformed manifest entry");
|
|
158
|
+
}
|
|
159
|
+
// The key is deterministic: documents/<role>/<pageId>.md. The entry must
|
|
160
|
+
// match both the role and page id encoded in its key.
|
|
161
|
+
if (!key.startsWith("documents/")) {
|
|
162
|
+
throw new Error("qmd_manifest_invalid: manifest key outside documents");
|
|
163
|
+
}
|
|
164
|
+
const rest = key.slice("documents/".length);
|
|
165
|
+
const slash = rest.indexOf("/");
|
|
166
|
+
if (slash <= 0 || !rest.endsWith(".md")) {
|
|
167
|
+
throw new Error("qmd_manifest_invalid: manifest key lacks role/page id");
|
|
168
|
+
}
|
|
169
|
+
const keyRole = rest.slice(0, slash);
|
|
170
|
+
const keyPageId = rest.slice(slash + 1, -3);
|
|
171
|
+
if (keyRole !== entry.role || keyPageId !== entry.pageId || keyPageId === "") {
|
|
172
|
+
throw new Error("qmd_manifest_invalid: manifest key/entry mismatch");
|
|
173
|
+
}
|
|
174
|
+
if (entry.vaultId !== vaultId) {
|
|
175
|
+
throw new Error("qmd_manifest_invalid: entry vaultId mismatch");
|
|
176
|
+
}
|
|
177
|
+
if (entry.type.trim() === "") {
|
|
178
|
+
throw new Error("qmd_manifest_invalid: entry type is empty");
|
|
179
|
+
}
|
|
180
|
+
if (!/^[0-9a-f]{64}$/.test(entry.contentHash)) {
|
|
181
|
+
throw new Error("qmd_manifest_invalid: entry contentHash is not a sha256 hex");
|
|
182
|
+
}
|
|
183
|
+
// Source paths are authoritative page files under paths.wiki, therefore
|
|
184
|
+
// under paths.root and outside paths.qmd.
|
|
185
|
+
const source = resolve(entry.sourcePath);
|
|
186
|
+
if (!isAbsolute(entry.sourcePath) || !source.startsWith(wikiRoot)) {
|
|
187
|
+
throw new Error("qmd_manifest_invalid: entry sourcePath outside wiki");
|
|
188
|
+
}
|
|
189
|
+
entries[key] = entry as QmdManifestEntry;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { version: QMD_MANIFEST_VERSION, vaultId, entries };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function mirrorDiagnostic(
|
|
196
|
+
code: KnowledgeDiagnostic["code"],
|
|
197
|
+
path: string,
|
|
198
|
+
message: string,
|
|
199
|
+
): KnowledgeDiagnostic {
|
|
200
|
+
return { severity: "warning", code, path, message };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Reconcile the generated QMD mirror against parser-valid authoritative pages.
|
|
205
|
+
* Publishes a validated manifest and only writes/removes generated files.
|
|
206
|
+
*/
|
|
207
|
+
export async function reconcileQmdMirror(
|
|
208
|
+
paths: VaultPaths,
|
|
209
|
+
vaultId: string,
|
|
210
|
+
scope: "changed" | "all",
|
|
211
|
+
): Promise<QmdMirrorResult> {
|
|
212
|
+
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
213
|
+
const discovery = discoverKnowledgeDocuments(paths);
|
|
214
|
+
diagnostics.push(...discovery.diagnostics);
|
|
215
|
+
|
|
216
|
+
let prior: QmdManifest;
|
|
217
|
+
try {
|
|
218
|
+
prior = await readQmdManifest(paths, vaultId);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
prior = { version: QMD_MANIFEST_VERSION, vaultId, entries: {} };
|
|
221
|
+
diagnostics.push(
|
|
222
|
+
mirrorDiagnostic("qmd_manifest_invalid", paths.qmdManifest, (error as Error).message),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Build the desired manifest from parser-valid documents. Serialize each
|
|
227
|
+
// document once; the mirror file and its content hash use that exact string.
|
|
228
|
+
const desired: QmdManifest = { version: QMD_MANIFEST_VERSION, vaultId, entries: {} };
|
|
229
|
+
const serializedByKey = new Map<string, string>();
|
|
230
|
+
for (const doc of discovery.documents) {
|
|
231
|
+
const role = roleForDocumentType(doc.frontmatter.type);
|
|
232
|
+
const key = manifestKey(role, doc.id);
|
|
233
|
+
const serialized = serializeKnowledgeDocument(doc);
|
|
234
|
+
serializedByKey.set(key, serialized);
|
|
235
|
+
desired.entries[key] = {
|
|
236
|
+
sourcePath: doc.absolutePath,
|
|
237
|
+
vaultId,
|
|
238
|
+
pageId: doc.id,
|
|
239
|
+
contentHash: hashQmdContent(serialized),
|
|
240
|
+
role,
|
|
241
|
+
type: String(doc.frontmatter.type),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const counts = { indexed: 0, updated: 0, unchanged: 0, removed: 0 };
|
|
246
|
+
|
|
247
|
+
// Determine which prior entries are unsafe (deleted, malformed, or role-moved)
|
|
248
|
+
// and must be removed from the manifest before files change.
|
|
249
|
+
const removedEntries: Record<string, QmdManifestEntry> = {};
|
|
250
|
+
for (const [key, entry] of Object.entries(prior.entries)) {
|
|
251
|
+
const desiredEntry = desired.entries[key];
|
|
252
|
+
if (!desiredEntry || desiredEntry.pageId !== entry.pageId) {
|
|
253
|
+
removedEntries[key] = entry;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (Object.keys(removedEntries).length > 0) {
|
|
258
|
+
const intermediate: QmdManifest = {
|
|
259
|
+
version: QMD_MANIFEST_VERSION,
|
|
260
|
+
vaultId,
|
|
261
|
+
entries: Object.fromEntries(
|
|
262
|
+
Object.entries(prior.entries).filter(([key]) => !removedEntries[key]),
|
|
263
|
+
),
|
|
264
|
+
};
|
|
265
|
+
await atomicWrite(paths.qmdManifest, JSON.stringify(intermediate, null, 2));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Write new/changed mirror files.
|
|
269
|
+
for (const [key, entry] of Object.entries(desired.entries)) {
|
|
270
|
+
const priorEntry = prior.entries[key];
|
|
271
|
+
const mirrorPath = manifestKeyToPath(paths, key);
|
|
272
|
+
const content = serializedByKey.get(key) ?? "";
|
|
273
|
+
const changed = !priorEntry || priorEntry.contentHash !== entry.contentHash;
|
|
274
|
+
if (scope === "all" || changed) {
|
|
275
|
+
// Full scope still rewrites every file; count unchanged pages by their
|
|
276
|
+
// content identity so the totals reconcile with the final manifest.
|
|
277
|
+
if (changed) {
|
|
278
|
+
await atomicWrite(mirrorPath, content);
|
|
279
|
+
if (!priorEntry) counts.indexed++;
|
|
280
|
+
else counts.updated++;
|
|
281
|
+
} else {
|
|
282
|
+
counts.unchanged++;
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
counts.unchanged++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Remove orphaned generated files that are no longer in the desired manifest.
|
|
290
|
+
await removeOrphans(paths, desired);
|
|
291
|
+
|
|
292
|
+
// Final desired manifest.
|
|
293
|
+
await atomicWrite(paths.qmdManifest, JSON.stringify(desired, null, 2));
|
|
294
|
+
counts.removed = Object.keys(removedEntries).length;
|
|
295
|
+
|
|
296
|
+
// Remove generated mirror files for removed entries.
|
|
297
|
+
for (const key of Object.keys(removedEntries)) {
|
|
298
|
+
try {
|
|
299
|
+
await rm(manifestKeyToPath(paths, key), { force: true });
|
|
300
|
+
} catch {
|
|
301
|
+
// Already absent.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const finalManifest = desired;
|
|
306
|
+
return {
|
|
307
|
+
manifest: finalManifest,
|
|
308
|
+
manifestHash: hashQmdManifest(finalManifest),
|
|
309
|
+
counts,
|
|
310
|
+
diagnostics,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Remove generated mirror files under documents/ that are not referenced by the
|
|
316
|
+
* desired manifest, plus empty directories. Does not follow symlinks.
|
|
317
|
+
*/
|
|
318
|
+
async function removeOrphans(paths: VaultPaths, desired: QmdManifest): Promise<void> {
|
|
319
|
+
const documentsRoot = join(paths.qmd, "documents");
|
|
320
|
+
const referenced = new Set(Object.keys(desired.entries));
|
|
321
|
+
|
|
322
|
+
async function walk(dir: string): Promise<void> {
|
|
323
|
+
let entries: Dirent[];
|
|
324
|
+
try {
|
|
325
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
326
|
+
} catch {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
let isEmpty = true;
|
|
330
|
+
for (const entry of entries) {
|
|
331
|
+
const full = join(dir, entry.name);
|
|
332
|
+
if (entry.isDirectory()) {
|
|
333
|
+
await walk(full);
|
|
334
|
+
let stillEmpty: boolean;
|
|
335
|
+
try {
|
|
336
|
+
stillEmpty = (await readdir(full)).length === 0;
|
|
337
|
+
} catch {
|
|
338
|
+
stillEmpty = true;
|
|
339
|
+
}
|
|
340
|
+
if (stillEmpty) {
|
|
341
|
+
try {
|
|
342
|
+
await rm(full, { recursive: false, force: true });
|
|
343
|
+
} catch {
|
|
344
|
+
// Ignore
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
isEmpty = false;
|
|
348
|
+
}
|
|
349
|
+
} else if (entry.name.endsWith(".md")) {
|
|
350
|
+
// Manifest keys are rooted at paths.qmd (e.g. "documents/canonical/...").
|
|
351
|
+
const rel = relative(paths.qmd, full).split(sep).join("/");
|
|
352
|
+
if (!referenced.has(rel)) {
|
|
353
|
+
try {
|
|
354
|
+
await rm(full, { force: true });
|
|
355
|
+
} catch {
|
|
356
|
+
// Ignore
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
isEmpty = false;
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
isEmpty = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (isEmpty && dir !== documentsRoot) {
|
|
366
|
+
try {
|
|
367
|
+
await rm(dir, { recursive: false, force: true });
|
|
368
|
+
} catch {
|
|
369
|
+
// Ignore
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
await stat(documentsRoot);
|
|
376
|
+
await walk(documentsRoot);
|
|
377
|
+
} catch {
|
|
378
|
+
// No documents dir yet.
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Safety-only path used when metadata projection fails: may only REMOVE mirror
|
|
384
|
+
* entries for missing or rejected pages. Never adds or updates valid documents.
|
|
385
|
+
*/
|
|
386
|
+
export async function invalidateUnsafeQmdEntries(
|
|
387
|
+
paths: VaultPaths,
|
|
388
|
+
vaultId: string,
|
|
389
|
+
): Promise<QmdMirrorResult> {
|
|
390
|
+
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
391
|
+
const discovery = discoverKnowledgeDocuments(paths);
|
|
392
|
+
diagnostics.push(...discovery.diagnostics);
|
|
393
|
+
|
|
394
|
+
let prior: QmdManifest;
|
|
395
|
+
let priorUnsafe = false;
|
|
396
|
+
try {
|
|
397
|
+
prior = await readQmdManifest(paths, vaultId);
|
|
398
|
+
} catch {
|
|
399
|
+
// Fail closed in the removal direction: a corrupt prior manifest cannot be
|
|
400
|
+
// trusted, so treat every previously generated mirror entry as unsafe and
|
|
401
|
+
// remove only files we can enumerate safely from disk.
|
|
402
|
+
prior = { version: QMD_MANIFEST_VERSION, vaultId, entries: {} };
|
|
403
|
+
priorUnsafe = true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (priorUnsafe) {
|
|
407
|
+
// Remove every generated mirror file under documents/ that can be
|
|
408
|
+
// enumerated safely, so stale deleted-page candidates cannot survive.
|
|
409
|
+
const documentsRoot = join(paths.qmd, "documents");
|
|
410
|
+
const removedFiles: string[] = [];
|
|
411
|
+
async function collect(dir: string): Promise<void> {
|
|
412
|
+
let entries: Dirent[];
|
|
413
|
+
try {
|
|
414
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
415
|
+
} catch {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
for (const entry of entries) {
|
|
419
|
+
const full = join(dir, entry.name);
|
|
420
|
+
if (entry.isDirectory()) {
|
|
421
|
+
await collect(full);
|
|
422
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
423
|
+
removedFiles.push(full);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
await collect(documentsRoot);
|
|
428
|
+
for (const file of removedFiles) {
|
|
429
|
+
try {
|
|
430
|
+
await rm(file, { force: true });
|
|
431
|
+
} catch {
|
|
432
|
+
// Already absent.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
await atomicWrite(
|
|
436
|
+
paths.qmdManifest,
|
|
437
|
+
JSON.stringify({ version: QMD_MANIFEST_VERSION, vaultId, entries: {} }, null, 2),
|
|
438
|
+
);
|
|
439
|
+
return {
|
|
440
|
+
manifest: { version: QMD_MANIFEST_VERSION, vaultId, entries: {} },
|
|
441
|
+
manifestHash: hashQmdManifest({ version: QMD_MANIFEST_VERSION, vaultId, entries: {} }),
|
|
442
|
+
counts: { indexed: 0, updated: 0, unchanged: 0, removed: removedFiles.length },
|
|
443
|
+
diagnostics: [
|
|
444
|
+
...diagnostics,
|
|
445
|
+
{
|
|
446
|
+
severity: "warning",
|
|
447
|
+
code: "qmd_manifest_invalid",
|
|
448
|
+
path: paths.qmdManifest,
|
|
449
|
+
message:
|
|
450
|
+
"Corrupt QMD manifest during invalidation; removed generated mirror entries fail-closed",
|
|
451
|
+
} as KnowledgeDiagnostic,
|
|
452
|
+
],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// The set of still-valid mirror entries (present and parser-valid now).
|
|
457
|
+
const validHashes = new Map<string, string>();
|
|
458
|
+
for (const doc of discovery.documents) {
|
|
459
|
+
const role = roleForDocumentType(doc.frontmatter.type);
|
|
460
|
+
const key = manifestKey(role, doc.id);
|
|
461
|
+
const serialized = serializeKnowledgeDocument(doc);
|
|
462
|
+
validHashes.set(key, hashQmdContent(serialized));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const removed: Record<string, QmdManifestEntry> = {};
|
|
466
|
+
const kept: Record<string, QmdManifestEntry> = {};
|
|
467
|
+
for (const [key, entry] of Object.entries(prior.entries)) {
|
|
468
|
+
if (!validHashes.has(key)) {
|
|
469
|
+
removed[key] = entry;
|
|
470
|
+
} else {
|
|
471
|
+
kept[key] = entry;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (Object.keys(removed).length > 0) {
|
|
476
|
+
await atomicWrite(
|
|
477
|
+
paths.qmdManifest,
|
|
478
|
+
JSON.stringify({ version: QMD_MANIFEST_VERSION, vaultId, entries: kept }, null, 2),
|
|
479
|
+
);
|
|
480
|
+
for (const key of Object.keys(removed)) {
|
|
481
|
+
try {
|
|
482
|
+
await rm(manifestKeyToPath(paths, key), { force: true });
|
|
483
|
+
} catch {
|
|
484
|
+
// Already absent.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const finalManifest: QmdManifest = { version: QMD_MANIFEST_VERSION, vaultId, entries: kept };
|
|
490
|
+
return {
|
|
491
|
+
manifest: finalManifest,
|
|
492
|
+
manifestHash: hashQmdManifest(finalManifest),
|
|
493
|
+
counts: { indexed: 0, updated: 0, unchanged: 0, removed: Object.keys(removed).length },
|
|
494
|
+
diagnostics,
|
|
495
|
+
};
|
|
496
|
+
}
|