@zosmaai/pi-llm-wiki 0.11.4 → 0.11.6

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 (62) hide show
  1. package/README.de.md +8 -0
  2. package/README.es.md +8 -0
  3. package/README.fr.md +8 -0
  4. package/README.hi.md +8 -0
  5. package/README.ja.md +8 -0
  6. package/README.ko.md +8 -0
  7. package/README.md +8 -0
  8. package/README.pt.md +8 -0
  9. package/README.ru.md +8 -0
  10. package/README.zh.md +8 -0
  11. package/assets/wiki-dashboard.png +0 -0
  12. package/commands/wiki-ingest.md +1 -0
  13. package/commands/wiki-req.md +1 -0
  14. package/commands/wiki-retro.md +1 -0
  15. package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
  16. package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
  17. package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
  18. package/dist/extensions/llm-wiki/lib/host.js +21 -1
  19. package/dist/extensions/llm-wiki/lib/ingest-worker.js +44 -20
  20. package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
  21. package/dist/extensions/llm-wiki/lib/knowledge-links.js +133 -27
  22. package/dist/extensions/llm-wiki/lib/metadata.js +6 -6
  23. package/dist/extensions/llm-wiki/lib/observation.js +22 -3
  24. package/dist/extensions/llm-wiki/lib/retro.js +38 -4
  25. package/dist/extensions/llm-wiki/lib/runtime.js +2 -2
  26. package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
  27. package/dist/extensions/llm-wiki/lib/task-config.js +100 -1
  28. package/dist/extensions/llm-wiki/lib/tools.js +47 -8
  29. package/dist/mcp/index.js +2 -1
  30. package/dist/mcp/operations.js +21 -2
  31. package/docs/api.md +24 -1
  32. package/docs/commands.md +6 -1
  33. package/docs/configuration.md +11 -0
  34. package/docs/obsidian.md +6 -6
  35. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
  36. package/docs/superpowers/plans/2026-08-27-wikilink-resolver-normalization.md +735 -0
  37. package/docs/superpowers/plans/2026-08-29-wikilink-gate-ensure-page-retro.md +642 -0
  38. package/docs/superpowers/plans/2026-08-29-wikilink-write-validation.md +695 -0
  39. package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
  40. package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
  41. package/extensions/llm-wiki/index.ts +4 -0
  42. package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
  43. package/extensions/llm-wiki/lib/dashboard.ts +210 -0
  44. package/extensions/llm-wiki/lib/guardrails.ts +26 -1
  45. package/extensions/llm-wiki/lib/host.ts +21 -1
  46. package/extensions/llm-wiki/lib/ingest-worker.ts +64 -27
  47. package/extensions/llm-wiki/lib/knowledge-document.ts +21 -2
  48. package/extensions/llm-wiki/lib/knowledge-links.ts +208 -35
  49. package/extensions/llm-wiki/lib/metadata.ts +10 -6
  50. package/extensions/llm-wiki/lib/observation.ts +23 -3
  51. package/extensions/llm-wiki/lib/retro.ts +48 -4
  52. package/extensions/llm-wiki/lib/runtime.ts +2 -2
  53. package/extensions/llm-wiki/lib/settings-command.ts +483 -0
  54. package/extensions/llm-wiki/lib/task-config.ts +138 -0
  55. package/extensions/llm-wiki/lib/tools.ts +62 -8
  56. package/mcp/index.ts +12 -1
  57. package/mcp/operations.ts +32 -2
  58. package/package.json +4 -4
  59. package/prompts/wiki-ingest.md +1 -0
  60. package/prompts/wiki-req.md +1 -0
  61. package/prompts/wiki-retro.md +1 -0
  62. package/skills/llm-wiki/SKILL.md +11 -1
@@ -0,0 +1,1520 @@
1
+ # QMD Retrieval Phase 1: Quality Baseline and Compatibility Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use /skill:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Establish a reproducible current-retrieval benchmark and prove the published QMD 2.5.3 dependency/runtime contract without changing active recall behavior.
6
+
7
+ **Architecture:** Keep production recall on the existing `searchWiki` path throughout this phase. Add a deterministic fixture-driven benchmark around that path, pin QMD as an unused runtime dependency, and exercise QMD's public SDK through isolated contract tests. Ordinary CI validates native installation and model-free lexical behavior; a separate cached workflow validates model-backed calls.
8
+
9
+ **Tech Stack:** TypeScript 5.9, Node.js 22+, Vitest 3, QMD 2.5.3, pnpm 9, GitHub Actions, Markdown fixtures.
10
+
11
+ **Roadmap:** [`docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md`](../roadmaps/2026-08-09-qmd-retrieval-roadmap.md)
12
+
13
+ **Phase:** Phase 1: Quality Baseline and QMD Compatibility
14
+
15
+ ---
16
+
17
+ ## Phase Boundary
18
+
19
+ This plan intentionally does not create persistent QMD vault indexes or route any Pi/MCP/automatic recall through QMD. At completion, current recall remains active and unchanged; QMD is pinned, installable, contract-tested, and ready for Phase 2.
20
+
21
+ ## File Map
22
+
23
+ **Create:**
24
+
25
+ - `test/fixtures/retrieval-benchmark/fixture.ts` — sanitized, versioned benchmark pages and 60 graded queries.
26
+ - `test/retrieval-benchmark-fixture.test.ts` — fixture completeness, split, category, identity, and privacy checks.
27
+ - `test/helpers/retrieval-metrics.ts` — pure information-retrieval metric functions.
28
+ - `test/retrieval-metrics.test.ts` — exact metric behavior tests.
29
+ - `test/retrieval-benchmark.test.ts` — materializes the fixture, runs current `searchWiki`, and compares the committed baseline.
30
+ - `scripts/update-retrieval-baseline.mjs` — cross-platform baseline regeneration entry point.
31
+ - `docs/superpowers/benchmarks/phase-1-current-baseline.json` — generated deterministic baseline artifact.
32
+ - `test/qmd-contract.test.ts` — QMD SDK type contract, native lexical smoke, and opt-in model smoke.
33
+ - `.github/workflows/qmd-model-smoke.yml` — scheduled/manual cached model-backed compatibility check.
34
+ - `docs/retrieval-benchmark.md` — benchmark schema, commands, privacy rules, and interpretation.
35
+ - `docs/qmd-compatibility.md` — pinned package/runtime/native/model support contract.
36
+
37
+ **Modify:**
38
+
39
+ - `package.json` — Node floor, exact QMD dependency, TypeScript peer-compatible version, benchmark scripts.
40
+ - `pnpm-lock.yaml` — generated dependency lock update.
41
+ - `test/package-structure.test.ts` — package/runtime assertions.
42
+ - `.github/workflows/ci.yml` — supported Node matrix and cross-platform QMD native-install job.
43
+
44
+ **Explicitly unchanged:**
45
+
46
+ - `extensions/llm-wiki/lib/recall.ts` — remains the active retrieval implementation and benchmark subject.
47
+ - `extensions/llm-wiki/index.ts` — automatic recall behavior stays unchanged.
48
+ - `mcp/operations.ts` — MCP recall behavior stays unchanged.
49
+
50
+ ---
51
+
52
+ ### Task 1: Pin the next-major runtime and QMD dependency contract
53
+
54
+ **Files:**
55
+ - Modify: `test/package-structure.test.ts`
56
+ - Modify: `package.json`
57
+ - Modify: `pnpm-lock.yaml`
58
+
59
+ - [ ] **Step 1: Add the failing package-contract assertions**
60
+
61
+ Add these assertions inside `it("should have a valid package.json with pi manifest", ...)`, immediately after parsing `pkg`:
62
+
63
+ ```ts
64
+ expect(pkg.engines.node).toBe(">=22.0.0");
65
+ expect(pkg.dependencies["@tobilu/qmd"]).toBe("2.5.3");
66
+ expect(pkg.devDependencies.typescript).toBe("^5.9.3");
67
+ expect(pkg.pnpm.onlyBuiltDependencies).toEqual([
68
+ "better-sqlite3",
69
+ "node-llama-cpp",
70
+ "sqlite-vec",
71
+ ]);
72
+ ```
73
+
74
+ - [ ] **Step 2: Run the targeted test and verify the old package contract fails**
75
+
76
+ Run:
77
+
78
+ ```bash
79
+ pnpm exec vitest run test/package-structure.test.ts --reporter=verbose
80
+ ```
81
+
82
+ Expected: FAIL because the current engine is `>=18`, QMD and the native-build whitelist are absent, and TypeScript is still `^5.7.0`.
83
+
84
+ - [ ] **Step 3: Install the exact published QMD package and compatible TypeScript**
85
+
86
+ Run:
87
+
88
+ ```bash
89
+ pnpm add --save-exact @tobilu/qmd@2.5.3
90
+ pnpm add --save-dev typescript@^5.9.3
91
+ ```
92
+
93
+ Expected: `package.json` contains exact QMD `2.5.3`, TypeScript `^5.9.3`, and `pnpm-lock.yaml` records QMD's native dependencies.
94
+
95
+ - [ ] **Step 4: Raise the Node.js engine floor and whitelist QMD native builds**
96
+
97
+ Change only the `engines` block in `package.json` to:
98
+
99
+ ```json
100
+ "engines": {
101
+ "node": ">=22.0.0"
102
+ }
103
+ ```
104
+
105
+ Add a top-level `pnpm` key so pnpm 10+ still runs QMD's native dependency build scripts (QMD's own `pnpm.onlyBuiltDependencies` does not propagate to consumers):
106
+
107
+ ```json
108
+ "pnpm": {
109
+ "onlyBuiltDependencies": ["better-sqlite3", "node-llama-cpp", "sqlite-vec"]
110
+ }
111
+ ```
112
+
113
+ Do not edit `package.json.version`; release versioning belongs to Phase 7 and must use the release script.
114
+
115
+ - [ ] **Step 5: Run package, type, and package-build checks**
116
+
117
+ Run:
118
+
119
+ ```bash
120
+ pnpm exec vitest run test/package-structure.test.ts --reporter=verbose
121
+ pnpm typecheck
122
+ pnpm build:mcp
123
+ ```
124
+
125
+ Expected: all commands exit 0; QMD remains unused by production code. If the TypeScript 5.9 bump surfaces new diagnostics in existing files, commit those fixes separately before the Task 1 commit.
126
+
127
+ - [ ] **Step 6: Commit the runtime contract**
128
+
129
+ ```bash
130
+ git add package.json pnpm-lock.yaml test/package-structure.test.ts
131
+ git commit -m "build: require Node 22 and pin QMD"
132
+ ```
133
+
134
+ ---
135
+
136
+ ### Task 2: Add the sanitized 60-query benchmark fixture
137
+
138
+ **Files:**
139
+ - Create: `test/fixtures/retrieval-benchmark/fixture.ts`
140
+ - Create: `test/retrieval-benchmark-fixture.test.ts`
141
+
142
+ - [ ] **Step 1: Write the failing fixture-contract test**
143
+
144
+ Create `test/retrieval-benchmark-fixture.test.ts`:
145
+
146
+ ```ts
147
+ import { describe, expect, it } from "vitest";
148
+ import {
149
+ BENCHMARK_VERSION,
150
+ benchmarkPages,
151
+ benchmarkQueries,
152
+ } from "./fixtures/retrieval-benchmark/fixture.js";
153
+
154
+ describe("retrieval benchmark fixture", () => {
155
+ it("contains the approved versioned 60-query train/held-out corpus", () => {
156
+ expect(BENCHMARK_VERSION).toBe(1);
157
+ expect(benchmarkPages).toHaveLength(22);
158
+ expect(benchmarkQueries).toHaveLength(60);
159
+ expect(benchmarkQueries.filter((query) => query.split === "train")).toHaveLength(45);
160
+ expect(benchmarkQueries.filter((query) => query.split === "heldout")).toHaveLength(15);
161
+ });
162
+
163
+ it("covers every Phase 1 query category", () => {
164
+ expect(new Set(benchmarkQueries.map((query) => query.category))).toEqual(
165
+ new Set([
166
+ "exact_lookup",
167
+ "entity_alias",
168
+ "paraphrase",
169
+ "vague_recollection",
170
+ "conceptual",
171
+ "graph_scope",
172
+ "evidence_request",
173
+ "temporal",
174
+ "contradiction",
175
+ "conclusion",
176
+ "synthesis",
177
+ "negative",
178
+ ]),
179
+ );
180
+ });
181
+
182
+ it("uses unique stable ids and judgments that reference fixture pages", () => {
183
+ const pageIds = new Set(benchmarkPages.map((page) => page.id));
184
+ const queryIds = benchmarkQueries.map((query) => query.id);
185
+ expect(new Set(queryIds).size).toBe(queryIds.length);
186
+
187
+ for (const query of benchmarkQueries) {
188
+ expect(query.judgments.length > 0 || query.autoExpectation === "none").toBe(true);
189
+ for (const judgment of query.judgments) expect(pageIds.has(judgment.pageId)).toBe(true);
190
+ for (const pageId of query.expectedConflicts ?? []) expect(pageIds.has(pageId)).toBe(true);
191
+ }
192
+ });
193
+
194
+ it("contains no raw home paths, email addresses, or token-shaped secrets", () => {
195
+ const serialized = JSON.stringify({ benchmarkPages, benchmarkQueries });
196
+ expect(serialized).not.toMatch(/\/home\//i);
197
+ expect(serialized).not.toMatch(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
198
+ expect(serialized).not.toMatch(/\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]{16,}\b/);
199
+ });
200
+ });
201
+ ```
202
+
203
+ - [ ] **Step 2: Run the fixture test and verify the module is missing**
204
+
205
+ Run:
206
+
207
+ ```bash
208
+ pnpm exec vitest run test/retrieval-benchmark-fixture.test.ts --reporter=verbose
209
+ ```
210
+
211
+ Expected: FAIL with a module-resolution error for `fixtures/retrieval-benchmark/fixture.js`.
212
+
213
+ - [ ] **Step 3: Create the complete benchmark fixture**
214
+
215
+ Create `test/fixtures/retrieval-benchmark/fixture.ts`:
216
+
217
+ ```ts
218
+ export const BENCHMARK_VERSION = 1;
219
+
220
+ export type BenchmarkRole = "canonical" | "evidence";
221
+ export type BenchmarkSplit = "train" | "heldout";
222
+ export type AutoExpectation = "hit" | "none";
223
+
224
+ export interface BenchmarkPage {
225
+ id: string;
226
+ type: string;
227
+ title: string;
228
+ body: string;
229
+ aliases?: string[];
230
+ tags?: string[];
231
+ status?: "draft" | "stable" | "deprecated";
232
+ }
233
+
234
+ export interface BenchmarkJudgment {
235
+ pageId: string;
236
+ grade: 1 | 2 | 3;
237
+ role: BenchmarkRole;
238
+ }
239
+
240
+ export interface BenchmarkQuery {
241
+ id: string;
242
+ text: string;
243
+ category:
244
+ | "exact_lookup"
245
+ | "entity_alias"
246
+ | "paraphrase"
247
+ | "vague_recollection"
248
+ | "conceptual"
249
+ | "graph_scope"
250
+ | "evidence_request"
251
+ | "temporal"
252
+ | "contradiction"
253
+ | "conclusion"
254
+ | "synthesis"
255
+ | "negative";
256
+ split: BenchmarkSplit;
257
+ judgments: BenchmarkJudgment[];
258
+ autoExpectation: AutoExpectation;
259
+ expectedConflicts?: string[];
260
+ }
261
+
262
+ export const benchmarkPages: BenchmarkPage[] = [
263
+ {
264
+ id: "entities/qmd",
265
+ type: "entity",
266
+ title: "QMD",
267
+ aliases: ["Query Markup Documents"],
268
+ tags: ["retrieval", "markdown"],
269
+ status: "stable",
270
+ body: "QMD is an on-device Markdown search engine combining BM25, vector retrieval, reciprocal-rank fusion, and local reranking.",
271
+ },
272
+ {
273
+ id: "concepts/reciprocal-rank-fusion",
274
+ type: "concept",
275
+ title: "Reciprocal Rank Fusion",
276
+ aliases: ["RRF"],
277
+ tags: ["retrieval", "ranking"],
278
+ body: "Reciprocal Rank Fusion combines independently ranked lists by rank instead of adding incomparable BM25 and cosine scores.",
279
+ },
280
+ {
281
+ id: "concepts/hybrid-retrieval",
282
+ type: "concept",
283
+ title: "Hybrid Retrieval",
284
+ tags: ["bm25", "embeddings"],
285
+ body: "Hybrid retrieval joins lexical matching for exact terms with dense semantic retrieval for paraphrases and vague recollection.",
286
+ },
287
+ {
288
+ id: "concepts/canonical-memory-cards",
289
+ type: "concept",
290
+ title: "Canonical Memory Cards",
291
+ tags: ["zettelkasten", "memory"],
292
+ body: "A reviewed canonical card should be returned before raw observations, with source evidence preserved behind the card.",
293
+ },
294
+ {
295
+ id: "concepts/zettelkasten",
296
+ type: "concept",
297
+ title: "Zettelkasten",
298
+ aliases: ["card box method", "卡片盒笔记法"],
299
+ body: "Zettelkasten organizes atomic permanent notes and meaningful links. It is a knowledge model, not a search ranking algorithm.",
300
+ },
301
+ {
302
+ id: "concepts/obsidian-graph-view",
303
+ type: "concept",
304
+ title: "Obsidian Graph View",
305
+ body: "Obsidian Graph View helps people inspect links, clusters, and orphan notes. It visualizes existing links but does not improve machine search relevance by itself.",
306
+ },
307
+ {
308
+ id: "concepts/wiki-reindex",
309
+ type: "concept",
310
+ title: "Wiki Reindexing",
311
+ aliases: ["wiki_reindex"],
312
+ body: "Reindexing rescans valid Markdown, refreshes lexical state, embeds stale chunks, removes deleted pages, and keeps the last usable index if rebuilding fails.",
313
+ },
314
+ {
315
+ id: "concepts/node-runtime-policy",
316
+ type: "concept",
317
+ title: "Node Runtime Policy",
318
+ body: "The QMD-backed major requires Node.js 22 or newer. Users requiring Node.js 18 remain on the previous package major.",
319
+ },
320
+ {
321
+ id: "concepts/conflict-preservation",
322
+ type: "concept",
323
+ title: "Memory Conflict Preservation",
324
+ body: "Conflicting claims remain stored with their evidence. Recall shows both and asks the user which scope or claim applies instead of silently choosing.",
325
+ },
326
+ {
327
+ id: "concepts/retrieval-feedback",
328
+ type: "concept",
329
+ title: "Retrieval Feedback",
330
+ body: "Explicit corrections are strong signals. Opened, cited, and shown-only events are weaker, bounded, decaying signals and never rewrite facts.",
331
+ },
332
+ {
333
+ id: "concepts/query-expansion",
334
+ type: "concept",
335
+ title: "Query Expansion",
336
+ body: "Query expansion may improve broad questions but can drift. Adaptive retrieval escalates only uncertain results while exact identifiers bypass expansion.",
337
+ },
338
+ {
339
+ id: "concepts/validated-index-mirror",
340
+ type: "concept",
341
+ title: "Validated Index Mirror",
342
+ body: "QMD indexes a generated mirror containing only pages accepted by the shared Markdown parser, so malformed pages cannot influence candidate generation.",
343
+ },
344
+ {
345
+ id: "syntheses/second-brain-retrieval",
346
+ type: "synthesis",
347
+ title: "Second-Brain Retrieval Architecture",
348
+ body: "The strongest second-brain design combines QMD candidate retrieval with canonical-card prioritization, source evidence, contradiction assembly, bounded feedback, and measured evaluation.",
349
+ },
350
+ {
351
+ id: "analyses/qmd-adoption-decision",
352
+ type: "analysis",
353
+ title: "QMD Adoption Decision",
354
+ body: "The project chose QMD as the first-class retrieval engine rather than rebuilding BM25, vector search, RRF, and local reranking.",
355
+ },
356
+ {
357
+ id: "sources/qmd-2-5-3-release",
358
+ type: "source",
359
+ title: "QMD 2.5.3 Release Evidence",
360
+ body: "The published QMD 2.5.3 package requires Node.js 22, exposes a TypeScript SDK, and supports model-free searchLex plus model-backed vector and reranking calls.",
361
+ },
362
+ {
363
+ id: "sources/vault-audit",
364
+ type: "source",
365
+ title: "Vault Retrieval Audit",
366
+ body: "The audited vault had hundreds of source observations, few canonical concepts, almost no retrieval metadata, and no active embedding sidecar.",
367
+ },
368
+ {
369
+ id: "sources/user-card-first-decision",
370
+ type: "source",
371
+ title: "Card-First User Decision",
372
+ body: "The user selected canonical atomic cards first, with observations retained as evidence rather than mixed equally in recall.",
373
+ },
374
+ {
375
+ id: "sources/reindex-failure-recovery",
376
+ type: "source",
377
+ title: "Reindex Recovery Requirement",
378
+ body: "A failed full rebuild must preserve the previous closed database artifact and report stale derived state instead of deleting the usable index.",
379
+ },
380
+ {
381
+ id: "sources/node-22-decision",
382
+ type: "source",
383
+ title: "Node 22 Major-Version Decision",
384
+ body: "The user approved a clean major-version break to Node.js 22 so QMD can be a first-class dependency.",
385
+ },
386
+ {
387
+ id: "sources/old-conflicting-claim",
388
+ type: "source",
389
+ title: "Older Conflicting Claim",
390
+ body: "An older note says raw observations should always outrank synthesized cards because they are closer to original evidence.",
391
+ },
392
+ {
393
+ id: "sources/new-conflicting-claim",
394
+ type: "source",
395
+ title: "Newer Conflicting Claim",
396
+ body: "A newer reviewed decision says canonical cards should rank first while raw observations remain attached evidence.",
397
+ },
398
+ {
399
+ id: "sources/feedback-evaluation",
400
+ type: "source",
401
+ title: "Feedback Evaluation Evidence",
402
+ body: "Click and open behavior is position-biased, so implicit engagement must remain a tiny supplementary signal rather than factual ground truth.",
403
+ },
404
+ ];
405
+
406
+ interface QueryGroup {
407
+ key: string;
408
+ category: BenchmarkQuery["category"];
409
+ split: BenchmarkSplit;
410
+ texts: [string, string, string, string, string];
411
+ judgments: BenchmarkJudgment[];
412
+ autoExpectation?: AutoExpectation;
413
+ expectedConflicts?: string[];
414
+ }
415
+
416
+ const canonical = (pageId: string, grade: 1 | 2 | 3 = 3): BenchmarkJudgment => ({
417
+ pageId,
418
+ grade,
419
+ role: "canonical",
420
+ });
421
+
422
+ const evidence = (pageId: string, grade: 1 | 2 | 3 = 2): BenchmarkJudgment => ({
423
+ pageId,
424
+ grade,
425
+ role: "evidence",
426
+ });
427
+
428
+ const groups: QueryGroup[] = [
429
+ {
430
+ key: "qmd-exact",
431
+ category: "exact_lookup",
432
+ split: "train",
433
+ texts: [
434
+ "QMD",
435
+ "Query Markup Documents",
436
+ "QMD Markdown search engine",
437
+ "find the QMD note",
438
+ "QMD BM25 vector reranker",
439
+ ],
440
+ judgments: [canonical("entities/qmd"), evidence("sources/qmd-2-5-3-release")],
441
+ },
442
+ {
443
+ key: "qmd-entity",
444
+ category: "entity_alias",
445
+ split: "train",
446
+ texts: [
447
+ "what tool is abbreviated QMD",
448
+ "the local document search tool",
449
+ "which entity provides local Markdown retrieval",
450
+ "QMD full name",
451
+ "on-device search for Markdown notes",
452
+ ],
453
+ judgments: [canonical("entities/qmd"), canonical("analyses/qmd-adoption-decision", 2)],
454
+ },
455
+ {
456
+ key: "hybrid-paraphrase",
457
+ category: "paraphrase",
458
+ split: "heldout",
459
+ texts: [
460
+ "combine exact words with meaning based search",
461
+ "find notes when I remember different wording",
462
+ "keyword plus semantic document lookup",
463
+ "search using both literal terms and concepts",
464
+ "mix sparse and dense retrieval",
465
+ ],
466
+ judgments: [canonical("concepts/hybrid-retrieval"), canonical("concepts/reciprocal-rank-fusion", 1)],
467
+ },
468
+ {
469
+ key: "cards-vague",
470
+ category: "vague_recollection",
471
+ split: "train",
472
+ texts: [
473
+ "the decision about useful memory before noisy notes",
474
+ "what should the AI remember first",
475
+ "reviewed conclusion with proof behind it",
476
+ "stop raw observations drowning good knowledge",
477
+ "card first memory organization",
478
+ ],
479
+ judgments: [canonical("concepts/canonical-memory-cards"), evidence("sources/user-card-first-decision")],
480
+ },
481
+ {
482
+ key: "zettelkasten-concept",
483
+ category: "conceptual",
484
+ split: "train",
485
+ texts: [
486
+ "what is Zettelkasten",
487
+ "is Zettelkasten a search algorithm",
488
+ "atomic permanent notes and meaningful links",
489
+ "什么是卡片盒笔记法",
490
+ "zettelkasten 是搜索算法吗",
491
+ ],
492
+ judgments: [canonical("concepts/zettelkasten")],
493
+ },
494
+ {
495
+ key: "obsidian-graph",
496
+ category: "graph_scope",
497
+ split: "heldout",
498
+ texts: [
499
+ "does Obsidian graph improve search ranking",
500
+ "what is graph view useful for",
501
+ "find orphan notes visually",
502
+ "can the Obsidian graph replace retrieval",
503
+ "human link visualization versus machine relevance",
504
+ ],
505
+ judgments: [canonical("concepts/obsidian-graph-view")],
506
+ },
507
+ {
508
+ key: "reindex-evidence",
509
+ category: "evidence_request",
510
+ split: "train",
511
+ texts: [
512
+ "how do I rebuild a stale wiki search index",
513
+ "what evidence says failed reindex keeps the old database",
514
+ "refresh lexical documents and stale vectors",
515
+ "remove deleted notes from search",
516
+ "wiki_reindex recovery requirement",
517
+ ],
518
+ judgments: [canonical("concepts/wiki-reindex"), evidence("sources/reindex-failure-recovery")],
519
+ },
520
+ {
521
+ key: "node-temporal",
522
+ category: "temporal",
523
+ split: "train",
524
+ texts: [
525
+ "which Node version does the next major require",
526
+ "can Node 18 use the QMD major",
527
+ "current runtime decision for QMD",
528
+ "why did the project move to Node 22",
529
+ "published QMD package Node requirement",
530
+ ],
531
+ judgments: [canonical("concepts/node-runtime-policy"), evidence("sources/node-22-decision")],
532
+ },
533
+ {
534
+ key: "memory-conflict",
535
+ category: "contradiction",
536
+ split: "heldout",
537
+ texts: [
538
+ "what happens when two memories disagree",
539
+ "raw observations or canonical cards should rank first",
540
+ "show both conflicting claims",
541
+ "which note superseded the older ranking claim",
542
+ "do not silently resolve contradictory memory",
543
+ ],
544
+ judgments: [
545
+ canonical("concepts/conflict-preservation"),
546
+ evidence("sources/old-conflicting-claim", 3),
547
+ evidence("sources/new-conflicting-claim", 3),
548
+ ],
549
+ expectedConflicts: ["sources/old-conflicting-claim", "sources/new-conflicting-claim"],
550
+ },
551
+ {
552
+ key: "feedback-conclusion",
553
+ category: "conclusion",
554
+ split: "train",
555
+ texts: [
556
+ "what did we conclude about retrieval feedback",
557
+ "should ignored search results change facts",
558
+ "how strong is an opened-result signal",
559
+ "explicit correction versus implicit engagement",
560
+ "position bias in second-brain feedback",
561
+ ],
562
+ judgments: [canonical("concepts/retrieval-feedback"), evidence("sources/feedback-evaluation")],
563
+ },
564
+ {
565
+ key: "architecture-synthesis",
566
+ category: "synthesis",
567
+ split: "train",
568
+ texts: [
569
+ "best architecture for an AI second brain",
570
+ "combine QMD with canonical evidence memory",
571
+ "full retrieval design summary",
572
+ "why QMD alone is not enough",
573
+ "candidate search plus wiki memory semantics",
574
+ ],
575
+ judgments: [canonical("syntheses/second-brain-retrieval"), canonical("analyses/qmd-adoption-decision", 2)],
576
+ },
577
+ {
578
+ key: "unrelated-negative",
579
+ category: "negative",
580
+ split: "train",
581
+ texts: [
582
+ "Flipkart casual mens wear",
583
+ "Razorpay webhook endpoint",
584
+ "hotel trial balance audit",
585
+ "exercise deletion in an admin panel",
586
+ "sales register round off convention",
587
+ ],
588
+ judgments: [],
589
+ autoExpectation: "none",
590
+ },
591
+ ];
592
+
593
+ export const benchmarkQueries: BenchmarkQuery[] = groups.flatMap((group) =>
594
+ group.texts.map((text, index) => ({
595
+ id: `${group.key}-${index + 1}`,
596
+ text,
597
+ category: group.category,
598
+ split: group.split,
599
+ judgments: group.judgments.map((judgment) => ({ ...judgment })),
600
+ autoExpectation: group.autoExpectation ?? "hit",
601
+ expectedConflicts: group.expectedConflicts ? [...group.expectedConflicts] : undefined,
602
+ })),
603
+ );
604
+ ```
605
+
606
+ - [ ] **Step 4: Run the fixture-contract test**
607
+
608
+ Run:
609
+
610
+ ```bash
611
+ pnpm exec vitest run test/retrieval-benchmark-fixture.test.ts --reporter=verbose
612
+ ```
613
+
614
+ Expected: 4 tests PASS; query count is 60, split is 45/15, and no privacy pattern appears.
615
+
616
+ - [ ] **Step 5: Commit the fixture**
617
+
618
+ ```bash
619
+ git add test/fixtures/retrieval-benchmark/fixture.ts test/retrieval-benchmark-fixture.test.ts
620
+ git commit -m "test: add retrieval benchmark fixture"
621
+ ```
622
+
623
+ ---
624
+
625
+ ### Task 3: Implement deterministic retrieval metrics
626
+
627
+ **Files:**
628
+ - Create: `test/helpers/retrieval-metrics.ts`
629
+ - Create: `test/retrieval-metrics.test.ts`
630
+
631
+ - [ ] **Step 1: Write failing unit tests for ranking metrics**
632
+
633
+ Create `test/retrieval-metrics.test.ts`:
634
+
635
+ ```ts
636
+ import { describe, expect, it } from "vitest";
637
+ import type { BenchmarkQuery } from "./fixtures/retrieval-benchmark/fixture.js";
638
+ import {
639
+ evaluateBenchmark,
640
+ ndcgAt,
641
+ reciprocalRank,
642
+ recallAt,
643
+ type BenchmarkRun,
644
+ } from "./helpers/retrieval-metrics.js";
645
+
646
+ const queries: BenchmarkQuery[] = [
647
+ {
648
+ id: "ranked",
649
+ text: "ranked query",
650
+ category: "contradiction",
651
+ split: "train",
652
+ judgments: [
653
+ { pageId: "cards/a", grade: 3, role: "canonical" },
654
+ { pageId: "sources/b", grade: 2, role: "evidence" },
655
+ ],
656
+ autoExpectation: "hit",
657
+ expectedConflicts: ["cards/a", "sources/b"],
658
+ },
659
+ {
660
+ id: "negative",
661
+ text: "unrelated query",
662
+ category: "negative",
663
+ split: "heldout",
664
+ judgments: [],
665
+ autoExpectation: "none",
666
+ },
667
+ ];
668
+
669
+ const runs: BenchmarkRun[] = [
670
+ { queryId: "ranked", rankedPageIds: ["sources/b", "cards/a", "other/x"], autoPageIds: ["sources/b"] },
671
+ { queryId: "negative", rankedPageIds: ["other/x"], autoPageIds: ["other/x"] },
672
+ ];
673
+
674
+ describe("retrieval metrics", () => {
675
+ it("computes recall and reciprocal rank", () => {
676
+ expect(recallAt(["x", "a", "b"], new Set(["a", "b"]), 2)).toBe(0.5);
677
+ expect(reciprocalRank(["x", "a", "b"], new Set(["a", "b"]))).toBe(0.5);
678
+ });
679
+
680
+ it("computes graded nDCG independent of raw retrieval scores", () => {
681
+ const grades = new Map([
682
+ ["cards/a", 3],
683
+ ["sources/b", 2],
684
+ ]);
685
+ expect(ndcgAt(["cards/a", "sources/b"], grades, 2)).toBe(1);
686
+ expect(ndcgAt(["sources/b", "cards/a"], grades, 2)).toBeGreaterThan(0.8);
687
+ expect(ndcgAt(["sources/b", "cards/a"], grades, 2)).toBeLessThan(1);
688
+ expect(ndcgAt(["sources/b", "cards/a"], grades, 2)).toBeCloseTo(0.834, 3);
689
+ });
690
+
691
+ it("separates relevance, evidence, contradiction, and auto false-positive measures", () => {
692
+ const report = evaluateBenchmark(queries, runs);
693
+ expect(report.all.candidateRecall20).toBe(1);
694
+ expect(report.all.mrr).toBe(1);
695
+ expect(report.all.canonicalAt3).toBe(1);
696
+ expect(report.all.evidenceRecall20).toBe(1);
697
+ expect(report.all.contradictionCoverage).toBe(1);
698
+ expect(report.all.autoFalsePositiveRate).toBe(1);
699
+ expect(report.train.queryCount).toBe(1);
700
+ expect(report.heldout.queryCount).toBe(1);
701
+ });
702
+ });
703
+ ```
704
+
705
+ - [ ] **Step 2: Run the metric test and verify the helper is missing**
706
+
707
+ Run:
708
+
709
+ ```bash
710
+ pnpm exec vitest run test/retrieval-metrics.test.ts --reporter=verbose
711
+ ```
712
+
713
+ Expected: FAIL with a module-resolution error for `helpers/retrieval-metrics.js`.
714
+
715
+ - [ ] **Step 3: Implement the pure metric helper**
716
+
717
+ Create `test/helpers/retrieval-metrics.ts`:
718
+
719
+ ```ts
720
+ import type {
721
+ BenchmarkJudgment,
722
+ BenchmarkQuery,
723
+ BenchmarkSplit,
724
+ } from "../fixtures/retrieval-benchmark/fixture.js";
725
+
726
+ export interface BenchmarkRun {
727
+ queryId: string;
728
+ rankedPageIds: string[];
729
+ autoPageIds: string[];
730
+ }
731
+
732
+ export interface RetrievalMetrics {
733
+ queryCount: number;
734
+ judgedQueryCount: number;
735
+ candidateRecall20: number;
736
+ mrr: number;
737
+ ndcg5: number;
738
+ ndcg10: number;
739
+ canonicalAt3: number;
740
+ evidenceRecall20: number;
741
+ contradictionCoverage: number;
742
+ autoFalsePositiveRate: number;
743
+ }
744
+
745
+ export interface RetrievalMetricReport {
746
+ all: RetrievalMetrics;
747
+ train: RetrievalMetrics;
748
+ heldout: RetrievalMetrics;
749
+ }
750
+
751
+ function round(value: number): number {
752
+ return Number(value.toFixed(6));
753
+ }
754
+
755
+ function mean(values: number[]): number {
756
+ return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
757
+ }
758
+
759
+ export function recallAt(rankedIds: string[], relevantIds: Set<string>, k: number): number {
760
+ if (relevantIds.size === 0) return 0;
761
+ const found = new Set(rankedIds.slice(0, k).filter((id) => relevantIds.has(id)));
762
+ return found.size / relevantIds.size;
763
+ }
764
+
765
+ export function reciprocalRank(rankedIds: string[], relevantIds: Set<string>): number {
766
+ const index = rankedIds.findIndex((id) => relevantIds.has(id));
767
+ return index < 0 ? 0 : 1 / (index + 1);
768
+ }
769
+
770
+ function dcg(grades: number[]): number {
771
+ return grades.reduce((sum, grade, index) => sum + (2 ** grade - 1) / Math.log2(index + 2), 0);
772
+ }
773
+
774
+ export function ndcgAt(rankedIds: string[], gradesById: Map<string, number>, k: number): number {
775
+ const actual = rankedIds.slice(0, k).map((id) => gradesById.get(id) ?? 0);
776
+ const ideal = [...gradesById.values()].sort((a, b) => b - a).slice(0, k);
777
+ const idealDcg = dcg(ideal);
778
+ return idealDcg === 0 ? 0 : dcg(actual) / idealDcg;
779
+ }
780
+
781
+ function idsForRole(judgments: BenchmarkJudgment[], role?: BenchmarkJudgment["role"]): Set<string> {
782
+ return new Set(
783
+ judgments
784
+ .filter((judgment) => judgment.grade > 0 && (role === undefined || judgment.role === role))
785
+ .map((judgment) => judgment.pageId),
786
+ );
787
+ }
788
+
789
+ function evaluateSlice(queries: BenchmarkQuery[], runById: Map<string, BenchmarkRun>): RetrievalMetrics {
790
+ const judged = queries.filter((query) => query.judgments.length > 0);
791
+ const canonical = queries.filter((query) => idsForRole(query.judgments, "canonical").size > 0);
792
+ const evidence = queries.filter((query) => idsForRole(query.judgments, "evidence").size > 0);
793
+ const conflicts = queries.filter((query) => (query.expectedConflicts?.length ?? 0) > 0);
794
+ const negatives = queries.filter((query) => query.autoExpectation === "none");
795
+
796
+ const rankedFor = (query: BenchmarkQuery) => runById.get(query.id)?.rankedPageIds ?? [];
797
+ const autoFor = (query: BenchmarkQuery) => runById.get(query.id)?.autoPageIds ?? [];
798
+
799
+ return {
800
+ queryCount: queries.length,
801
+ judgedQueryCount: judged.length,
802
+ candidateRecall20: round(
803
+ mean(judged.map((query) => recallAt(rankedFor(query), idsForRole(query.judgments), 20))),
804
+ ),
805
+ mrr: round(
806
+ mean(judged.map((query) => reciprocalRank(rankedFor(query), idsForRole(query.judgments)))),
807
+ ),
808
+ ndcg5: round(
809
+ mean(
810
+ judged.map((query) =>
811
+ ndcgAt(
812
+ rankedFor(query),
813
+ new Map(query.judgments.map((judgment) => [judgment.pageId, judgment.grade])),
814
+ 5,
815
+ ),
816
+ ),
817
+ ),
818
+ ),
819
+ ndcg10: round(
820
+ mean(
821
+ judged.map((query) =>
822
+ ndcgAt(
823
+ rankedFor(query),
824
+ new Map(query.judgments.map((judgment) => [judgment.pageId, judgment.grade])),
825
+ 10,
826
+ ),
827
+ ),
828
+ ),
829
+ ),
830
+ canonicalAt3: round(
831
+ mean(
832
+ canonical.map((query) => {
833
+ const relevant = idsForRole(query.judgments, "canonical");
834
+ return rankedFor(query).slice(0, 3).some((id) => relevant.has(id)) ? 1 : 0;
835
+ }),
836
+ ),
837
+ ),
838
+ evidenceRecall20: round(
839
+ mean(
840
+ evidence.map((query) => recallAt(rankedFor(query), idsForRole(query.judgments, "evidence"), 20)),
841
+ ),
842
+ ),
843
+ contradictionCoverage: round(
844
+ mean(
845
+ conflicts.map((query) => {
846
+ const found = new Set(rankedFor(query).slice(0, 20));
847
+ return query.expectedConflicts!.every((id) => found.has(id)) ? 1 : 0;
848
+ }),
849
+ ),
850
+ ),
851
+ autoFalsePositiveRate: round(
852
+ mean(negatives.map((query) => (autoFor(query).length > 0 ? 1 : 0))),
853
+ ),
854
+ };
855
+ }
856
+
857
+ export function evaluateBenchmark(
858
+ queries: BenchmarkQuery[],
859
+ runs: BenchmarkRun[],
860
+ ): RetrievalMetricReport {
861
+ const runById = new Map(runs.map((run) => [run.queryId, run]));
862
+ const bySplit = (split: BenchmarkSplit) => queries.filter((query) => query.split === split);
863
+ return {
864
+ all: evaluateSlice(queries, runById),
865
+ train: evaluateSlice(bySplit("train"), runById),
866
+ heldout: evaluateSlice(bySplit("heldout"), runById),
867
+ };
868
+ }
869
+ ```
870
+
871
+ - [ ] **Step 4: Run fixture and metric tests**
872
+
873
+ Run:
874
+
875
+ ```bash
876
+ pnpm exec vitest run test/retrieval-benchmark-fixture.test.ts test/retrieval-metrics.test.ts --reporter=verbose
877
+ ```
878
+
879
+ Expected: 7 tests PASS.
880
+
881
+ - [ ] **Step 5: Commit the metric engine**
882
+
883
+ ```bash
884
+ git add test/helpers/retrieval-metrics.ts test/retrieval-metrics.test.ts
885
+ git commit -m "test: add retrieval quality metrics"
886
+ ```
887
+
888
+ ---
889
+
890
+ ### Task 4: Record the current heuristic recall baseline
891
+
892
+ **Files:**
893
+ - Create: `test/retrieval-benchmark.test.ts`
894
+ - Create: `scripts/update-retrieval-baseline.mjs`
895
+ - Create: `docs/superpowers/benchmarks/phase-1-current-baseline.json` (generated)
896
+ - Modify: `package.json`
897
+
898
+ - [ ] **Step 1: Create the benchmark runner test**
899
+
900
+ Create `test/retrieval-benchmark.test.ts`:
901
+
902
+ ```ts
903
+ import {
904
+ existsSync,
905
+ mkdirSync,
906
+ mkdtempSync,
907
+ readFileSync,
908
+ rmSync,
909
+ writeFileSync,
910
+ } from "node:fs";
911
+ import { tmpdir } from "node:os";
912
+ import { dirname, join } from "node:path";
913
+ import { afterAll, describe, expect, it } from "vitest";
914
+ import { rebuildMetadata } from "../extensions/llm-wiki/lib/metadata.js";
915
+ import { searchWiki } from "../extensions/llm-wiki/lib/recall.js";
916
+ import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
917
+ import {
918
+ BENCHMARK_VERSION,
919
+ benchmarkPages,
920
+ benchmarkQueries,
921
+ type BenchmarkPage,
922
+ } from "./fixtures/retrieval-benchmark/fixture.js";
923
+ import { rootDir } from "./helpers.js";
924
+ import { evaluateBenchmark, type BenchmarkRun } from "./helpers/retrieval-metrics.js";
925
+
926
+ const baselinePath = join(
927
+ rootDir,
928
+ "docs",
929
+ "superpowers",
930
+ "benchmarks",
931
+ "phase-1-current-baseline.json",
932
+ );
933
+ const tempRoot = mkdtempSync(join(tmpdir(), "pi-llm-wiki-retrieval-"));
934
+
935
+ afterAll(() => rmSync(tempRoot, { recursive: true, force: true }));
936
+
937
+ function renderPage(page: BenchmarkPage): string {
938
+ const metadata = [
939
+ "---",
940
+ `type: ${page.type}`,
941
+ `title: ${JSON.stringify(page.title)}`,
942
+ `status: ${page.status ?? "stable"}`,
943
+ ...(page.aliases ? [`aliases: ${JSON.stringify(page.aliases)}`] : []),
944
+ ...(page.tags ? [`tags: ${JSON.stringify(page.tags)}`] : []),
945
+ "---",
946
+ "",
947
+ ];
948
+ return `${metadata.join("\n")}${page.body}\n`;
949
+ }
950
+
951
+ function createBenchmarkVault(): ReturnType<typeof getVaultPaths> {
952
+ const paths = getVaultPaths(tempRoot);
953
+ ensureVaultStructure(paths);
954
+ writeFileSync(
955
+ join(paths.dotWiki, "config.json"),
956
+ `${JSON.stringify({ name: "Retrieval Benchmark", knowledge_format: "legacy" }, null, 2)}\n`,
957
+ );
958
+ for (const page of benchmarkPages) {
959
+ const path = join(paths.wiki, `${page.id}.md`);
960
+ mkdirSync(dirname(path), { recursive: true });
961
+ writeFileSync(path, renderPage(page));
962
+ }
963
+ const rebuilt = rebuildMetadata(paths);
964
+ expect(rebuilt.ok, JSON.stringify(rebuilt.diagnostics, null, 2)).toBe(true);
965
+ return paths;
966
+ }
967
+
968
+ function currentPackageContract(): { node: string; qmd: string } {
969
+ const pkg = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")) as {
970
+ engines: { node: string };
971
+ dependencies: Record<string, string>;
972
+ };
973
+ return { node: pkg.engines.node, qmd: pkg.dependencies["@tobilu/qmd"] };
974
+ }
975
+
976
+ describe("current heuristic retrieval benchmark", () => {
977
+ it("matches the committed deterministic Phase 1 baseline", () => {
978
+ const paths = createBenchmarkVault();
979
+ const runs: BenchmarkRun[] = benchmarkQueries.map((query) => ({
980
+ queryId: query.id,
981
+ rankedPageIds: searchWiki(paths, query.text, 20, 0).map((result) => result.id),
982
+ autoPageIds: searchWiki(paths, query.text, 3, 5).map((result) => result.id),
983
+ }));
984
+ const report = {
985
+ schema: 1,
986
+ fixtureVersion: BENCHMARK_VERSION,
987
+ engine: "current-heuristic",
988
+ productionRecallChanged: false,
989
+ packageContract: currentPackageContract(),
990
+ queryCounts: {
991
+ all: benchmarkQueries.length,
992
+ train: benchmarkQueries.filter((query) => query.split === "train").length,
993
+ heldout: benchmarkQueries.filter((query) => query.split === "heldout").length,
994
+ },
995
+ metrics: evaluateBenchmark(benchmarkQueries, runs),
996
+ };
997
+
998
+ if (process.env.UPDATE_RETRIEVAL_BASELINE === "1") {
999
+ mkdirSync(dirname(baselinePath), { recursive: true });
1000
+ writeFileSync(baselinePath, `${JSON.stringify(report, null, 2)}\n`);
1001
+ }
1002
+
1003
+ expect(existsSync(baselinePath), `Run pnpm benchmark:retrieval:update to create ${baselinePath}`).toBe(
1004
+ true,
1005
+ );
1006
+ expect(JSON.parse(readFileSync(baselinePath, "utf8"))).toEqual(report);
1007
+ });
1008
+ });
1009
+ ```
1010
+
1011
+ - [ ] **Step 2: Run the benchmark test and verify the baseline is missing**
1012
+
1013
+ Run:
1014
+
1015
+ ```bash
1016
+ pnpm exec vitest run test/retrieval-benchmark.test.ts --reporter=verbose
1017
+ ```
1018
+
1019
+ Expected: FAIL with `Run pnpm benchmark:retrieval:update` because the committed baseline does not exist yet.
1020
+
1021
+ - [ ] **Step 3: Add the cross-platform baseline update script**
1022
+
1023
+ Create `scripts/update-retrieval-baseline.mjs`:
1024
+
1025
+ ```js
1026
+ #!/usr/bin/env node
1027
+
1028
+ import { spawnSync } from "node:child_process";
1029
+ import { join, resolve } from "node:path";
1030
+
1031
+ const root = resolve(import.meta.dirname, "..");
1032
+ const vitest = join(root, "node_modules", "vitest", "vitest.mjs");
1033
+ const result = spawnSync(
1034
+ process.execPath,
1035
+ [vitest, "run", "test/retrieval-benchmark.test.ts", "--reporter=verbose"],
1036
+ {
1037
+ cwd: root,
1038
+ env: { ...process.env, UPDATE_RETRIEVAL_BASELINE: "1" },
1039
+ stdio: "inherit",
1040
+ },
1041
+ );
1042
+
1043
+ process.exit(result.status ?? 1);
1044
+ ```
1045
+
1046
+ - [ ] **Step 4: Add benchmark scripts to `package.json`**
1047
+
1048
+ Add these entries to the existing `scripts` object:
1049
+
1050
+ ```json
1051
+ "benchmark:retrieval": "vitest run test/retrieval-benchmark.test.ts --reporter=verbose",
1052
+ "benchmark:retrieval:update": "node scripts/update-retrieval-baseline.mjs"
1053
+ ```
1054
+
1055
+ - [ ] **Step 5: Generate and inspect the deterministic baseline artifact**
1056
+
1057
+ Run:
1058
+
1059
+ ```bash
1060
+ pnpm benchmark:retrieval:update
1061
+ pnpm benchmark:retrieval
1062
+ ```
1063
+
1064
+ Expected: both commands PASS and create `docs/superpowers/benchmarks/phase-1-current-baseline.json` with schema 1, 60/45/15 query counts, QMD contract `2.5.3`, and separate all/train/heldout metrics. Do not hand-edit metric values.
1065
+
1066
+ - [ ] **Step 6: Prove the baseline is reproducible**
1067
+
1068
+ Run:
1069
+
1070
+ ```bash
1071
+ node -e "const f=require('node:fs').readFileSync('docs/superpowers/benchmarks/phase-1-current-baseline.json');const c=require('node:crypto').createHash('sha256').update(f).digest('hex');console.log(c)"
1072
+ pnpm benchmark:retrieval:update
1073
+ node -e "const f=require('node:fs').readFileSync('docs/superpowers/benchmarks/phase-1-current-baseline.json');const c=require('node:crypto').createHash('sha256').update(f).digest('hex');console.log(c)"
1074
+ ```
1075
+
1076
+ Expected: both hashes are identical.
1077
+
1078
+ - [ ] **Step 7: Run all benchmark unit tests**
1079
+
1080
+ Run:
1081
+
1082
+ ```bash
1083
+ pnpm exec vitest run test/retrieval-benchmark-fixture.test.ts test/retrieval-metrics.test.ts test/retrieval-benchmark.test.ts --reporter=verbose
1084
+ ```
1085
+
1086
+ Expected: 8 tests PASS.
1087
+
1088
+ - [ ] **Step 8: Commit the baseline harness and artifact**
1089
+
1090
+ ```bash
1091
+ git add package.json scripts/update-retrieval-baseline.mjs test/retrieval-benchmark.test.ts docs/superpowers/benchmarks/phase-1-current-baseline.json
1092
+ git commit -m "test: record current retrieval baseline"
1093
+ ```
1094
+
1095
+ ---
1096
+
1097
+ ### Task 5: Contract-test QMD's public SDK without activating it
1098
+
1099
+ **Files:**
1100
+ - Create: `test/qmd-contract.test.ts`
1101
+
1102
+ - [ ] **Step 1: Create exact compile-time mode contracts and model-free runtime smoke**
1103
+
1104
+ Create `test/qmd-contract.test.ts`:
1105
+
1106
+ ```ts
1107
+ import { homedir, tmpdir } from "node:os";
1108
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
1109
+ import { join } from "node:path";
1110
+ import {
1111
+ createStore,
1112
+ type ExpandedQuery,
1113
+ type QMDStore,
1114
+ type SearchOptions,
1115
+ } from "@tobilu/qmd";
1116
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
1117
+
1118
+ const hybridQueries: ExpandedQuery[] = [
1119
+ { type: "lex", query: "signed access tokens" },
1120
+ { type: "vec", query: "how users authenticate" },
1121
+ ];
1122
+
1123
+ const modeContracts = {
1124
+ hybrid: {
1125
+ queries: hybridQueries,
1126
+ rerank: false,
1127
+ candidateLimit: 40,
1128
+ limit: 10,
1129
+ explain: true,
1130
+ },
1131
+ adaptiveUncertain: {
1132
+ query: "how users authenticate",
1133
+ intent: "Authentication documentation",
1134
+ rerank: true,
1135
+ candidateLimit: 40,
1136
+ limit: 10,
1137
+ explain: true,
1138
+ },
1139
+ quality: {
1140
+ query: "how users authenticate",
1141
+ intent: "Authentication documentation",
1142
+ rerank: true,
1143
+ candidateLimit: 40,
1144
+ limit: 10,
1145
+ explain: true,
1146
+ },
1147
+ } satisfies Record<string, SearchOptions>;
1148
+
1149
+ const tempRoot = mkdtempSync(join(tmpdir(), "pi-llm-wiki-qmd-contract-"));
1150
+ const docsPath = join(tempRoot, "docs");
1151
+ const dbPath = join(tempRoot, "index.sqlite");
1152
+ let store: QMDStore;
1153
+
1154
+ function modelFiles(): string[] {
1155
+ const modelDir = join(homedir(), ".cache", "qmd", "models");
1156
+ if (!existsSync(modelDir)) return [];
1157
+ return readdirSync(modelDir).sort();
1158
+ }
1159
+
1160
+ beforeAll(async () => {
1161
+ mkdirSync(docsPath, { recursive: true });
1162
+ writeFileSync(join(docsPath, "auth.md"), "# Authentication\n\nUsers authenticate with signed access tokens.\n");
1163
+ writeFileSync(join(docsPath, "cache.md"), "# Cache\n\nCache entries expire after five minutes.\n");
1164
+ store = await createStore({
1165
+ dbPath,
1166
+ config: {
1167
+ global_context: "SDK compatibility fixture",
1168
+ collections: {
1169
+ docs: { path: docsPath, pattern: "**/*.md" },
1170
+ },
1171
+ },
1172
+ });
1173
+ });
1174
+
1175
+ afterAll(async () => {
1176
+ await store?.close();
1177
+ rmSync(tempRoot, { recursive: true, force: true });
1178
+ });
1179
+
1180
+ describe("QMD 2.5.3 SDK contract", () => {
1181
+ it("keeps the four-mode request shapes type-compatible", () => {
1182
+ expect(modeContracts.hybrid.queries).toEqual(hybridQueries);
1183
+ expect(modeContracts.adaptiveUncertain.rerank).toBe(true);
1184
+ expect(modeContracts.quality.candidateLimit).toBe(40);
1185
+ });
1186
+
1187
+ it("updates and performs lexical search without downloading a model", async () => {
1188
+ const beforeModels = modelFiles();
1189
+ const updated = await store.update();
1190
+ expect(updated.collections).toBe(1);
1191
+ expect(updated.indexed).toBe(2);
1192
+ expect(updated.needsEmbedding).toBe(2);
1193
+
1194
+ const results = await store.searchLex("signed access tokens", { collection: "docs", limit: 5 });
1195
+ expect(results.length).toBeGreaterThan(0);
1196
+ expect(results[0].source).toBe("fts");
1197
+ expect(results[0].score).toBeGreaterThan(0);
1198
+ expect(results[0].title).toContain("Authentication");
1199
+
1200
+ const status = await store.getStatus();
1201
+ expect(status.totalDocuments).toBe(2);
1202
+ expect(status.needsEmbedding).toBe(2);
1203
+ expect(modelFiles()).toEqual(beforeModels);
1204
+ });
1205
+
1206
+ it.runIf(process.env.QMD_MODEL_SMOKE === "1")(
1207
+ "embeds, performs vector/hybrid search, expands, and reranks",
1208
+ async () => {
1209
+ const embedded = await store.embed({ force: true, chunkStrategy: "regex" });
1210
+ expect(embedded.docsProcessed).toBe(2);
1211
+ expect(embedded.errors).toBe(0);
1212
+
1213
+ const vector = await store.searchVector("how users log in", { collection: "docs", limit: 5 });
1214
+ expect(vector.length).toBeGreaterThan(0);
1215
+
1216
+ const hybrid = await store.search({ ...modeContracts.hybrid, collections: ["docs"] });
1217
+ expect(hybrid.length).toBeGreaterThan(0);
1218
+
1219
+ const expanded = await store.expandQuery("how users authenticate", {
1220
+ intent: "Authentication documentation",
1221
+ });
1222
+ expect(expanded.length).toBeGreaterThan(0);
1223
+
1224
+ const quality = await store.search({ ...modeContracts.quality, collections: ["docs"] });
1225
+ expect(quality.length).toBeGreaterThan(0);
1226
+ expect(quality[0].score).toBeGreaterThan(0);
1227
+ },
1228
+ 1_200_000,
1229
+ );
1230
+ });
1231
+ ```
1232
+
1233
+ - [ ] **Step 2: Run the ordinary contract test**
1234
+
1235
+ Run:
1236
+
1237
+ ```bash
1238
+ QMD_FORCE_CPU=1 pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose
1239
+ ```
1240
+
1241
+ Windows PowerShell equivalent: `$env:QMD_FORCE_CPU='1'; pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose`
1242
+
1243
+ Expected: mode-shape and lexical tests PASS; model-backed test is skipped; no QMD model file is created.
1244
+
1245
+ - [ ] **Step 3: Run the full typecheck against QMD's exported declarations**
1246
+
1247
+ Run:
1248
+
1249
+ ```bash
1250
+ pnpm typecheck
1251
+ ```
1252
+
1253
+ Expected: PASS. A missing or changed QMD method/type fails here before Phase 2 planning.
1254
+
1255
+ - [ ] **Step 4: Commit the SDK contract**
1256
+
1257
+ ```bash
1258
+ git add test/qmd-contract.test.ts
1259
+ git commit -m "test: lock QMD SDK compatibility"
1260
+ ```
1261
+
1262
+ ---
1263
+
1264
+ ### Task 6: Add platform CI, model smoke, and operator documentation
1265
+
1266
+ **Files:**
1267
+ - Modify: `.github/workflows/ci.yml`
1268
+ - Create: `.github/workflows/qmd-model-smoke.yml`
1269
+ - Create: `docs/retrieval-benchmark.md`
1270
+ - Create: `docs/qmd-compatibility.md`
1271
+
1272
+ - [ ] **Step 1: Restrict the normal quality matrix to supported Node.js LTS versions**
1273
+
1274
+ In `.github/workflows/ci.yml`, replace:
1275
+
1276
+ ```yaml
1277
+ node-version: [20, 22, 23, 24, 25]
1278
+ ```
1279
+
1280
+ with:
1281
+
1282
+ ```yaml
1283
+ node-version: [22, 24]
1284
+ ```
1285
+
1286
+ Keep the standalone Node 18 migration-script job: it executes the shipped migration file directly and does not claim the new extension runtime supports Node 18.
1287
+
1288
+ - [ ] **Step 2: Add cross-platform native-install and lexical contract coverage to `ci.yml`**
1289
+
1290
+ Add this job after `quality` and before `migration-node18`:
1291
+
1292
+ ```yaml
1293
+ qmd-native-compatibility:
1294
+ name: QMD native compatibility (${{ matrix.os }})
1295
+ strategy:
1296
+ fail-fast: false
1297
+ matrix:
1298
+ os: [ubuntu-latest, macos-15, windows-latest]
1299
+ runs-on: ${{ matrix.os }}
1300
+
1301
+ steps:
1302
+ - uses: actions/checkout@v4
1303
+
1304
+ - uses: pnpm/action-setup@v4
1305
+
1306
+ - uses: actions/setup-node@v4
1307
+ with:
1308
+ node-version: 22
1309
+ cache: pnpm
1310
+
1311
+ - run: pnpm install --frozen-lockfile
1312
+ env:
1313
+ PUPPETEER_SKIP_DOWNLOAD: true
1314
+
1315
+ - name: Verify QMD SDK and model-free lexical search
1316
+ run: pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose
1317
+ env:
1318
+ QMD_FORCE_CPU: "1"
1319
+ ```
1320
+
1321
+ Expected: the same package and lexical SDK test runs against native SQLite dependencies on Linux x64, macOS arm64, and Windows x64.
1322
+
1323
+ - [ ] **Step 3: Create the opt-in and scheduled model-backed workflow**
1324
+
1325
+ Create `.github/workflows/qmd-model-smoke.yml`:
1326
+
1327
+ ```yaml
1328
+ name: QMD Model Smoke
1329
+
1330
+ on:
1331
+ workflow_dispatch:
1332
+ schedule:
1333
+ - cron: "0 4 * * 1"
1334
+
1335
+ concurrency:
1336
+ group: qmd-model-smoke
1337
+ cancel-in-progress: true
1338
+
1339
+ jobs:
1340
+ model-smoke:
1341
+ runs-on: ubuntu-latest
1342
+ timeout-minutes: 30
1343
+
1344
+ steps:
1345
+ - uses: actions/checkout@v4
1346
+
1347
+ - uses: pnpm/action-setup@v4
1348
+
1349
+ - uses: actions/setup-node@v4
1350
+ with:
1351
+ node-version: 22
1352
+ cache: pnpm
1353
+
1354
+ - run: pnpm install --frozen-lockfile
1355
+ env:
1356
+ PUPPETEER_SKIP_DOWNLOAD: true
1357
+
1358
+ - name: Cache QMD models
1359
+ uses: actions/cache@v4
1360
+ with:
1361
+ path: ~/.cache/qmd/models
1362
+ key: qmd-2.5.3-models-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
1363
+
1364
+ - name: Exercise embedding, vector, expansion, fusion, and reranking
1365
+ run: pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose
1366
+ env:
1367
+ QMD_FORCE_CPU: "1"
1368
+ QMD_MODEL_SMOKE: "1"
1369
+ ```
1370
+
1371
+ - [ ] **Step 4: Document the benchmark contract and commands**
1372
+
1373
+ Create `docs/retrieval-benchmark.md`:
1374
+
1375
+ ```markdown
1376
+ # Retrieval Benchmark
1377
+
1378
+ The Phase 1 benchmark records current heuristic recall quality before QMD powers production retrieval.
1379
+
1380
+ ## Corpus
1381
+
1382
+ `test/fixtures/retrieval-benchmark/fixture.ts` contains 22 sanitized Markdown pages and 60 graded queries:
1383
+
1384
+ - 45 train queries
1385
+ - 15 immutable held-out queries
1386
+ - exact lookup, aliases, paraphrase, vague recollection, concepts, graph scope, evidence, time, conflicts, conclusions, synthesis, and unrelated negatives
1387
+ - English, Chinese, and mixed-language examples
1388
+
1389
+ The fixture must not contain raw home paths, email addresses, credentials, customer identifiers, or copied private notes. Sanitize representative phrasing before committing it.
1390
+
1391
+ The CJK query `什么是卡片盒笔记法` (zettelkasten-concept #4) is an intentional guaranteed miss for the current heuristic engine: the page body is English-only, so lexical score is zero by construction. Do not reword the page to make it pass; it exists to prove that only CJK-aware lexical handling (QMD's normalized FTS) can recover it.
1392
+
1393
+ ## Judgments
1394
+
1395
+ Grades are:
1396
+
1397
+ - `3`: directly answers the query
1398
+ - `2`: useful supporting evidence or secondary answer
1399
+ - `1`: relevant context
1400
+
1401
+ Roles are `canonical` or `evidence`. Contradiction queries list every claim that must appear together.
1402
+
1403
+ ## Metrics
1404
+
1405
+ The report records candidate Recall@20, MRR, nDCG@5, nDCG@10, canonical@3, evidence Recall@20, contradiction coverage, and automatic-recall false-positive rate. Scores are computed from ranked page IDs, never raw engine scores.
1406
+
1407
+ ## Commands
1408
+
1409
+ Verify the committed baseline:
1410
+
1411
+ ```bash
1412
+ pnpm benchmark:retrieval
1413
+ ```
1414
+
1415
+ Regenerate after an intentional fixture or baseline-engine change:
1416
+
1417
+ ```bash
1418
+ pnpm benchmark:retrieval:update
1419
+ pnpm benchmark:retrieval
1420
+ ```
1421
+
1422
+ Never hand-edit `docs/superpowers/benchmarks/phase-1-current-baseline.json`. Every update must explain why the benchmark or baseline engine changed. Later phases may tune against `train`, but must not inspect or alter held-out judgments while tuning.
1423
+ ```
1424
+
1425
+ - [ ] **Step 5: Document the pinned QMD compatibility contract**
1426
+
1427
+ Create `docs/qmd-compatibility.md`:
1428
+
1429
+ ```markdown
1430
+ # QMD Compatibility
1431
+
1432
+ pi-llm-wiki's next major pins `@tobilu/qmd` **2.5.3**, the latest version published to npm when Phase 1 was planned.
1433
+
1434
+ ## Runtime
1435
+
1436
+ - Node.js: `>=22.0.0`
1437
+ - TypeScript development peer: `^5.9.3`
1438
+ - Package manager: pnpm 9
1439
+
1440
+ Users requiring Node.js 18 must remain on the previous pi-llm-wiki major.
1441
+
1442
+ ## Native compatibility
1443
+
1444
+ Clean-install CI covers:
1445
+
1446
+ - Linux x64
1447
+ - macOS arm64
1448
+ - Windows x64
1449
+
1450
+ QMD brings `better-sqlite3`, `sqlite-vec`, and `node-llama-cpp`. Failure to install required native packages is an installation failure, not a runtime lexical fallback.
1451
+
1452
+ ## Model-free contract
1453
+
1454
+ `createStore`, `update`, `searchLex`, `getStatus`, and `close` must work without downloading or loading an embedding, expansion, or reranking model. Ordinary CI tests this path with `QMD_FORCE_CPU=1`.
1455
+
1456
+ ## Model-backed contract
1457
+
1458
+ The scheduled/manual model smoke exercises:
1459
+
1460
+ - `embed`
1461
+ - `searchVector`
1462
+ - typed hybrid `search` with reranking disabled
1463
+ - `expandQuery`
1464
+ - expanded/reranked `search`
1465
+
1466
+ QMD stores default models under `~/.cache/qmd/models`. First use downloads roughly 2 GB across embedding, reranking, and expansion models. CI caches that directory. `QMD_FORCE_CPU=1` avoids GPU probing in compatibility jobs.
1467
+
1468
+ ## Upgrade rule
1469
+
1470
+ Do not widen the QMD version range. A QMD upgrade requires:
1471
+
1472
+ 1. exact-version lock update
1473
+ 2. SDK contract and clean-install CI passing
1474
+ 3. model smoke passing
1475
+ 4. retrieval benchmark comparison before production use
1476
+ 5. updated model and native-support documentation
1477
+ ```
1478
+
1479
+ - [ ] **Step 6: Run all Phase 1 checks**
1480
+
1481
+ Run:
1482
+
1483
+ ```bash
1484
+ pnpm benchmark:retrieval
1485
+ QMD_FORCE_CPU=1 pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose
1486
+ pnpm typecheck
1487
+ pnpm lint
1488
+ pnpm test
1489
+ pnpm build:mcp
1490
+ ```
1491
+
1492
+ Expected: all commands exit 0; QMD model-backed smoke remains skipped locally unless explicitly enabled.
1493
+
1494
+ - [ ] **Step 7: Verify active recall code did not change**
1495
+
1496
+ Run:
1497
+
1498
+ ```bash
1499
+ git diff 42d6fd1 -- extensions/llm-wiki/lib/recall.ts extensions/llm-wiki/index.ts mcp/operations.ts
1500
+ ```
1501
+
1502
+ Expected: no diff. Phase 1 must not alter production recall or indexing behavior.
1503
+
1504
+ - [ ] **Step 8: Commit CI and documentation**
1505
+
1506
+ ```bash
1507
+ git add .github/workflows/ci.yml .github/workflows/qmd-model-smoke.yml docs/retrieval-benchmark.md docs/qmd-compatibility.md
1508
+ git commit -m "ci: verify QMD compatibility"
1509
+ ```
1510
+
1511
+ - [ ] **Step 9: Confirm a clean phase boundary**
1512
+
1513
+ Run:
1514
+
1515
+ ```bash
1516
+ git status --short
1517
+ git log --oneline -6
1518
+ ```
1519
+
1520
+ Expected: clean working tree and six Phase 1 commits after the planning commits. Active recall remains current heuristic retrieval; Phase 2 may now plan validated per-vault QMD indexing.