@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,678 @@
|
|
|
1
|
+
# QMD Retrieval Phase 3 Implementation Plan — Retrieval Modes and Recall Cutover
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use `/skill:executing-plans` to implement this plan task-by-task. Track progress with the checkboxes below. Do not start Phase 4 until every required gate passes.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make QMD the single active relevance engine for explicit `wiki_recall`, automatic `before_agent_start` recall, and MCP, with lexical, hybrid, adaptive, and quality modes plus deterministic fallback. End the dual-engine era: the old heuristic scorer and page-level embedding sidecar leave active recall paths after parity gates pass.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Keep authoritative Markdown, the validated QMD store lifecycle, and the cache-safe injection boundary from Phase 2 unchanged. Add one shared QMD-backed retrieval service that owns mode selection, fallback chains, score normalization, and layered vault merging. Route the Pi tool, the automatic hook, and MCP through that single service. Recall rendering (links-first, skill/case inlining) stays in the existing `recall.ts` formatting surface, fed by QMD candidates instead of the heuristic scorer. The old scorer survives only in benchmark tooling for transitional comparison, never as a second production ranking path.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript 5.9, Node.js 22 `node:fs/promises`, `@tobilu/qmd` 2.5.3 public SDK, TypeBox, Vitest, Biome, pnpm.
|
|
10
|
+
|
|
11
|
+
**Normative inputs:**
|
|
12
|
+
|
|
13
|
+
- `docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md` (Retrieval Modes, Recall Data Flow, Reranking and Adjustments, Error Handling, Tools and Interfaces, Configuration, Evaluation)
|
|
14
|
+
- `docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md` (Phase 3)
|
|
15
|
+
- `docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-remediation.md` (Phase 2 gates that Phase 3 builds on)
|
|
16
|
+
|
|
17
|
+
**Phase:** Phase 3. Phase 4 (card-first memory assembly) remains blocked until every gate here passes.
|
|
18
|
+
|
|
19
|
+
**How to read the code shapes:** each task carries a `**Code shape**` block. These are concrete, codebase-convention-following sketches — the executor adapts them to the exact surrounding types (seam names, `VaultPaths` fields, existing helper exports) and writes the real tests first. Constants (floors, ranks, clamps, thresholds) are normative: change them only through benchmarked code changes.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Baseline
|
|
24
|
+
|
|
25
|
+
Verified immediately before this plan (branch `qmd-phase-1` at `5a0552b`, PR #144):
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
Full suite: 725 passed (55 files), 1 model smoke skipped
|
|
29
|
+
Typecheck: pass
|
|
30
|
+
Biome lint: pass
|
|
31
|
+
MCP build: pass
|
|
32
|
+
Retrieval baseline: Phase 1 committed baseline unchanged
|
|
33
|
+
CodeQL: 0 open alerts on qmd-phase-1 (Analyze pass on 5a0552b)
|
|
34
|
+
Mechanical scope: QMD import isolated to qmd-store.ts; recall.ts/inject.ts untouched since Phase 1
|
|
35
|
+
Contract tests: qmd-contract.test.ts pins the four-mode request shapes against @tobilu/qmd 2.5.3
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
SDK facts the code shapes rely on (pinned `@tobilu/qmd` 2.5.3 `index.d.ts`/`store.d.ts`):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// BM25, model-free: QMDStore.searchLex(query, { limit?, collection? }) → SearchResult[]
|
|
42
|
+
// SearchResult = DocumentResult & { score: number; source: "fts" | "vec"; chunkPos?: number }
|
|
43
|
+
// DocumentResult = { filepath, displayPath, title, context, hash, docid, collectionName, modifiedAt, bodyLength, body? }
|
|
44
|
+
|
|
45
|
+
// Fused: QMDStore.search(options) → HybridQueryResult[]
|
|
46
|
+
// HybridQueryResult = { file, displayPath, title, body, bestChunk, bestChunkPos, score, context, docid, explain? }
|
|
47
|
+
// SearchOptions = { query? (auto-expands), queries?: ExpandedQuery[] (skips expansion),
|
|
48
|
+
// intent?, rerank?, collection?, collections?, limit?, candidateLimit?, minScore?, explain?, ... }
|
|
49
|
+
// ExpandedQuery = { type: "lex" | "vec" | "hyde"; query: string; line?: number }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Mirror layout the mapping depends on (Phase 2): each hit's `file`/`filepath` points inside `<vault>/.llm-wiki/meta/qmd/current/documents/<role>/<pageId>.md`, where `<role>` is `canonical` or `evidence` and `<pageId>` may contain `/` (e.g. `concepts/rag`).
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Scope Boundaries
|
|
57
|
+
|
|
58
|
+
This plan must not:
|
|
59
|
+
|
|
60
|
+
- change authoritative Markdown, metadata projection, or the write path;
|
|
61
|
+
- change the QMD store swap lifecycle, status ladder, or lock semantics from Phase 2;
|
|
62
|
+
- import `@tobilu/qmd` outside `extensions/llm-wiki/lib/qmd-store.ts`;
|
|
63
|
+
- send the conversational transcript or raw query text into durable feedback events (feedback is Phase 6; query text stays in-process only);
|
|
64
|
+
- implement typed-link expansion, canonical/evidence bundle assembly, or contradiction rendering (Phase 4);
|
|
65
|
+
- implement conflict resolution (Phase 5) or feedback learning (Phase 6);
|
|
66
|
+
- download or load models during ordinary tests, status, lint, or lexical retrieval;
|
|
67
|
+
- break the `inject.ts` cache-safety contract (system prompt stays byte-stable; volatile recall content stays in the tail message);
|
|
68
|
+
- keep two production ranking paths — the heuristic scorer must leave active recall once parity gates pass, and any transitional comparison lives only in benchmark tooling;
|
|
69
|
+
- change `wiki_reindex` input parameters or the QMD manifest/state formats.
|
|
70
|
+
|
|
71
|
+
A failed or degraded QMD retrieval must fail toward no recall or a cheaper mode, never toward unrelated injected memory. If no candidate clears the confidence floor, recall returns no result — an empty result is a valid successful outcome.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## File Responsibility Map
|
|
76
|
+
|
|
77
|
+
### Production files
|
|
78
|
+
|
|
79
|
+
- `extensions/llm-wiki/lib/qmd-store.ts` — search methods on the adapter (`searchLex`, `searchTyped`, `searchExpanded`) with the exact SDK mappings from the spec; `QmdSearchHit` result shape. Only this file touches QMD search types.
|
|
80
|
+
- `extensions/llm-wiki/lib/retrieval.ts` — NEW shared QMD-backed retrieval service: query normalization, mode resolution, fallback chains, within-store confidence, cross-vault rank-based fusion, exact-match and role adjustments, QMD-hit → `RecallResult` mapping, structured diagnostics. Pure of rendering; model-free when the mode/store allow.
|
|
81
|
+
- `extensions/llm-wiki/lib/recall.ts` — route `searchWikiHybrid`/`searchWikiLayered` active callers to the retrieval service; keep `formatRecallContext`, links-first gating, and skill/case inlining as the rendering surface; retire the heuristic scoring internals from active paths after the parity gate.
|
|
82
|
+
- `extensions/llm-wiki/lib/task-config.ts` — `retrievalMode` setting with fail-closed validation; default `adaptive`.
|
|
83
|
+
- `extensions/llm-wiki/lib/wiki-service.ts` — status additions (resolved retrieval mode, resolved QMD models, index vector health), shared operation used by Pi and MCP.
|
|
84
|
+
- `extensions/llm-wiki/index.ts` — `before_agent_start` automatic recall calls the shared service with the precision-first policy.
|
|
85
|
+
- `extensions/llm-wiki/lib/tools.ts` — `wiki_reindex_embeddings` delegates to `wiki_reindex` (deprecation), `wiki_status` renders retrieval state.
|
|
86
|
+
- `extensions/llm-wiki/lib/embeddings.ts` — retire the page-level embedding sidecar from active recall; keep only what benchmark tooling needs, or mark the module deprecated.
|
|
87
|
+
- `mcp/operations.ts` and `mcp/index.ts` — `recallOperation` becomes async over the shared service; MCP `wiki_recall` parity.
|
|
88
|
+
|
|
89
|
+
### Tests
|
|
90
|
+
|
|
91
|
+
- `test/qmd-contract.test.ts` — extend to pin the three adapter search methods against the real SDK.
|
|
92
|
+
- `test/retrieval.test.ts` — NEW: normalization, mode selection, fallback chains, score normalization, layered merge, diagnostics; model-free via the fake store factory or a temp real lexical store.
|
|
93
|
+
- `test/recall.test.ts` — update active-path expectations to QMD candidates; move heuristic-scorer assertions to a benchmark-comparison section or delete with the retired scorer.
|
|
94
|
+
- `test/agent-start-injection.test.ts` — automatic precision-first policy: floor, project-only scope, no-injection cases.
|
|
95
|
+
- `test/mcp-parity.test.ts` — Pi tool and MCP return structurally equivalent recall results.
|
|
96
|
+
- `test/retrieval-benchmark.test.ts` — Phase 3 ablation gates and the old-scorer retirement gate.
|
|
97
|
+
- `test/task-config.test.ts` — `retrievalMode` validation and defaults.
|
|
98
|
+
|
|
99
|
+
### Documentation
|
|
100
|
+
|
|
101
|
+
- `docs/configuration.md` — `retrievalMode`, `recallFeedback` placeholder note, deprecation note for `wiki_reindex_embeddings`.
|
|
102
|
+
- `docs/api.md` — recall diagnostics, modes, fallback behavior.
|
|
103
|
+
- `docs/architecture.md` — one retrieval engine, fallback chains, score normalization, layering.
|
|
104
|
+
- `docs/troubleshooting.md` or migration docs — first-use model download messaging and lexical-before-vectors behavior.
|
|
105
|
+
|
|
106
|
+
No change to the manifest/state formats, `wiki_reindex` parameters, or lock semantics.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Task 1: Retrieval Adapter Modes and Contract Verification
|
|
111
|
+
|
|
112
|
+
**Files:**
|
|
113
|
+
|
|
114
|
+
- Modify: `extensions/llm-wiki/lib/qmd-store.ts`
|
|
115
|
+
- Modify: `test/qmd-contract.test.ts`
|
|
116
|
+
|
|
117
|
+
- [ ] **Step 1: Define the adapter search surface**
|
|
118
|
+
|
|
119
|
+
Extend the existing `QmdIndexStore` interface (the object returned by `openQmdIndexStore`) with three methods whose request shapes match the spec's mode mappings exactly. All three return a normalized, SDK-free hit shape:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// extensions/llm-wiki/lib/qmd-store.ts (inside QmdIndexStore interface)
|
|
123
|
+
export interface QmdSearchHit {
|
|
124
|
+
/** Mirror collection the hit came from ("canonical" | "evidence"). */
|
|
125
|
+
collection: "canonical" | "evidence";
|
|
126
|
+
/** Mirror-relative file, e.g. "documents/canonical/concepts/rag.md". */
|
|
127
|
+
file: string;
|
|
128
|
+
title: string;
|
|
129
|
+
/** 0..1, normalized to this result list's max. Within-store confidence only. */
|
|
130
|
+
score: number;
|
|
131
|
+
source: "fts" | "vec";
|
|
132
|
+
/** Best chunk body when the SDK returned one (hybrid results). */
|
|
133
|
+
body?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface QmdIndexStore {
|
|
137
|
+
// ...existing update/embed/status/close...
|
|
138
|
+
/** lexical: store.searchLex(query, { limit }) — BM25 only, no model load. */
|
|
139
|
+
searchLex(query: string, limit?: number): Promise<QmdSearchHit[]>;
|
|
140
|
+
/** hybrid + adaptive-initial: typed lex/vec queries, NO LLM expansion, NO rerank. */
|
|
141
|
+
searchTyped(query: string, limit?: number): Promise<QmdSearchHit[]>;
|
|
142
|
+
/** adaptive-uncertain + quality: plain query, LLM expansion + rerank, with intent. */
|
|
143
|
+
searchExpanded(query: string, intent: string | undefined, limit?: number): Promise<QmdSearchHit[]>;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- [ ] **Step 2: Add failing contract tests**
|
|
148
|
+
|
|
149
|
+
Extend `test/qmd-contract.test.ts` (the file already builds a real temp store and asserts the model cache is untouched by lexical ops):
|
|
150
|
+
|
|
151
|
+
1. `searchLex` returns BM25 hits without downloading or loading models — assert `modelFiles()` unchanged, mirroring the existing lexical test.
|
|
152
|
+
2. `searchTyped` with `rerank: false` and typed `queries` is accepted by the pinned SDK; under `QMD_MODEL_SMOKE=1` it returns fused hits, otherwise assert request-shape only (no model load).
|
|
153
|
+
3. `searchExpanded` with `rerank: true` and `intent` is accepted under the model smoke flag; without it, assert the shape only.
|
|
154
|
+
4. Result mapping: a hit on a file under `documents/canonical/` yields `collection: "canonical"`, and `file` parses back to the page ID (see `roleFromFile` below). The same for `documents/evidence/`.
|
|
155
|
+
|
|
156
|
+
- [ ] **Step 3: Implement and verify**
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// extensions/llm-wiki/lib/qmd-store.ts — inside openQmdIndexStore's returned object
|
|
160
|
+
function roleFromFile(file: string): QmdSearchHit["collection"] {
|
|
161
|
+
// ".../documents/canonical/concepts/rag.md" → "canonical"; anything else → "evidence"
|
|
162
|
+
return /(?:^|\/)documents\/canonical\//.test(file) ? "canonical" : "evidence";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Map raw SDK results to score-less hits plus their raw score, then normalize
|
|
166
|
+
// to 0..1 per list (score = raw / list-max). Raw scores are within-store only.
|
|
167
|
+
type RawHit = Omit<QmdSearchHit, "score"> & { raw: number };
|
|
168
|
+
function withNormalizedScores(hits: RawHit[]): QmdSearchHit[] {
|
|
169
|
+
const max = Math.max(...hits.map((h) => h.raw), 1e-9);
|
|
170
|
+
return hits.map(({ raw, ...rest }) => ({ ...rest, score: raw / max }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const mapLexHit = (r: SearchResult): RawHit => ({
|
|
174
|
+
collection: roleFromFile(r.filepath),
|
|
175
|
+
file: r.filepath,
|
|
176
|
+
title: r.title,
|
|
177
|
+
source: r.source,
|
|
178
|
+
raw: r.score,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Collect the raw SDK arrays first, map, then normalize:
|
|
182
|
+
searchLex: async (query, limit = 40) =>
|
|
183
|
+
withNormalizedScores(
|
|
184
|
+
(await store.searchLex(query, { limit })).map(mapLexHit),
|
|
185
|
+
),
|
|
186
|
+
|
|
187
|
+
searchTyped: async (query, limit = 10) =>
|
|
188
|
+
withNormalizedScores(
|
|
189
|
+
(
|
|
190
|
+
await store.search({
|
|
191
|
+
queries: [
|
|
192
|
+
{ type: "lex", query },
|
|
193
|
+
{ type: "vec", query },
|
|
194
|
+
],
|
|
195
|
+
rerank: false,
|
|
196
|
+
candidateLimit: 40,
|
|
197
|
+
limit,
|
|
198
|
+
explain: true,
|
|
199
|
+
})
|
|
200
|
+
).map((r: HybridQueryResult): RawHit => ({
|
|
201
|
+
collection: roleFromFile(r.file),
|
|
202
|
+
file: r.file,
|
|
203
|
+
title: r.title,
|
|
204
|
+
source: "fts" as const,
|
|
205
|
+
body: r.bestChunk,
|
|
206
|
+
raw: r.score,
|
|
207
|
+
})),
|
|
208
|
+
),
|
|
209
|
+
|
|
210
|
+
searchExpanded: async (query, intent, limit = 10) =>
|
|
211
|
+
withNormalizedScores(
|
|
212
|
+
(
|
|
213
|
+
await store.search({
|
|
214
|
+
query,
|
|
215
|
+
intent: intent ?? undefined,
|
|
216
|
+
rerank: true,
|
|
217
|
+
candidateLimit: 40,
|
|
218
|
+
limit,
|
|
219
|
+
explain: true,
|
|
220
|
+
})
|
|
221
|
+
).map((r: HybridQueryResult): RawHit => ({
|
|
222
|
+
collection: roleFromFile(r.file),
|
|
223
|
+
file: r.file,
|
|
224
|
+
title: r.title,
|
|
225
|
+
source: "fts" as const,
|
|
226
|
+
body: r.bestChunk,
|
|
227
|
+
raw: r.score,
|
|
228
|
+
})),
|
|
229
|
+
),
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Keep `SearchResult`/`HybridQueryResult` imports type-only, and add nothing else to the module's QMD exposure.
|
|
233
|
+
|
|
234
|
+
Verify: `pnpm typecheck`, `pnpm lint`, `QMD_FORCE_CPU=1` vitest over `qmd-contract.test.ts` (model-free tests green; model smoke skipped), full suite green.
|
|
235
|
+
|
|
236
|
+
**Verification:** each adapter method maps to exactly its spec SDK call; lexical search stays model-free; no new QMD import site.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Task 2: Shared QMD Retrieval Service
|
|
241
|
+
|
|
242
|
+
**Files:**
|
|
243
|
+
|
|
244
|
+
- Add: `extensions/llm-wiki/lib/retrieval.ts`
|
|
245
|
+
- Modify: `test/recall.test.ts` (or new `test/retrieval.test.ts`)
|
|
246
|
+
|
|
247
|
+
- [ ] **Step 1: Add failing mode/fallback tests (model-free)**
|
|
248
|
+
|
|
249
|
+
Using the existing fake store factory (or a temp real lexical store), pin:
|
|
250
|
+
|
|
251
|
+
1. Query normalization: NFKC-normalize, trim, collapse whitespace; punctuation, quoted phrases, path separators, and identifier characters preserved; empty query → no results; input over 2,000 chars → `recall_query_too_long` diagnostic, never truncated.
|
|
252
|
+
2. Mode selection: `lexical` always calls `searchLex`; `hybrid` always calls `searchTyped`; `quality` always calls `searchExpanded`, then falls back through `searchTyped` to `searchLex`; `adaptive` starts with `searchTyped` and escalates only when a trigger fires — otherwise stays hybrid.
|
|
253
|
+
3. Fallback chains per the spec error table: vectors missing/stale → BM25 with a stale-vector diagnostic; embedding model load failure → lexical; reranker failure/timeout → keep the fused hybrid list; store cannot open → automatic policy injects nothing, explicit policy returns a structured diagnostic.
|
|
254
|
+
4. Exact-match bypass: exact title, alias, command, filename, or page-ID match with normalized score `>= 0.80` skips reranking.
|
|
255
|
+
5. Adaptive trigger math as pure functions with pinned examples: `jaccardOverlap(["a","b","c"], ["a","d","e"]) === 1/5`; a two-hit list with normalized margin `0.07` escalates, `0.12` does not; `interrogativePrefix("who designed the cache")` is true, `interrogativePrefix("cached response design")` is false.
|
|
256
|
+
6. The contradiction-edge adaptive trigger is a no-op hook this phase (typed relations arrive in Phase 4); assert the hook exists and never fires on a store with no relation projection.
|
|
257
|
+
7. `intent` is `topic` + `mode` from config capped at 256 chars, omitted when both are absent; the transcript never reaches the adapter.
|
|
258
|
+
|
|
259
|
+
- [ ] **Step 2: Implement the service**
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// extensions/llm-wiki/lib/retrieval.ts — pure pieces first (exported for tests)
|
|
263
|
+
|
|
264
|
+
export type RetrievalMode = "lexical" | "hybrid" | "adaptive" | "quality";
|
|
265
|
+
export type RecallPolicy = {
|
|
266
|
+
/** automatic: false (project only); explicit: true (personal + project) */
|
|
267
|
+
includePersonal: boolean;
|
|
268
|
+
/** normalized floor: automatic 0.50, explicit 0.25 */
|
|
269
|
+
minScore: number;
|
|
270
|
+
/** max candidates per vault: 10 */
|
|
271
|
+
maxPerVault: number;
|
|
272
|
+
/** injected result cap: automatic 3, explicit caller max_results */
|
|
273
|
+
maxResults: number;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// ── query normalization (spec: NFKC, trim, collapse ws, keep punctuation) ──
|
|
277
|
+
export const QUERY_MAX_CHARS = 2_000;
|
|
278
|
+
export function normalizeQuery(raw: string): string | null {
|
|
279
|
+
const q = raw.normalize("NFKC").trim().replace(/\s+/g, " ");
|
|
280
|
+
if (q.length === 0 || q.length > QUERY_MAX_CHARS) return null; // empty → no result; too long → diagnostic
|
|
281
|
+
return q;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── adaptive triggers (spec constants) ──
|
|
285
|
+
const INTERROGATIVE_PREFIXES = [
|
|
286
|
+
"who", "what", "when", "where", "why", "how", "which", "compare", "explain",
|
|
287
|
+
];
|
|
288
|
+
export function interrogativePrefix(query: string): boolean {
|
|
289
|
+
const first = query.split(/\s+/)[0]?.toLowerCase();
|
|
290
|
+
return first !== undefined && INTERROGATIVE_PREFIXES.includes(first);
|
|
291
|
+
}
|
|
292
|
+
export function jaccardOverlap(a: string[], b: string[]): number {
|
|
293
|
+
const aset = new Set(a);
|
|
294
|
+
const bset = new Set(b);
|
|
295
|
+
const intersection = [...aset].filter((x) => bset.has(x)).length;
|
|
296
|
+
const union = new Set([...aset, ...bset]).size;
|
|
297
|
+
return union === 0 ? 1 : intersection / union;
|
|
298
|
+
}
|
|
299
|
+
/** Normalized margin + lexical/vector overlap; contradiction-edge hook no-ops until Phase 4. */
|
|
300
|
+
export function shouldEscalate(
|
|
301
|
+
query: string,
|
|
302
|
+
hits: QmdSearchHit[],
|
|
303
|
+
hasContradictionEdge: () => boolean = () => false,
|
|
304
|
+
): boolean {
|
|
305
|
+
const lex = hits.filter((h) => h.source === "fts").slice(0, 5).map((h) => h.file);
|
|
306
|
+
const vec = hits.filter((h) => h.source === "vec").slice(0, 5).map((h) => h.file);
|
|
307
|
+
const margin = hits.length >= 2 ? hits[0].score - hits[1].score : 1;
|
|
308
|
+
return (
|
|
309
|
+
margin < 0.08 ||
|
|
310
|
+
jaccardOverlap(lex, vec) < 0.40 ||
|
|
311
|
+
interrogativePrefix(query) ||
|
|
312
|
+
hasContradictionEdge()
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── mode execution + fallback chains (spec table) ──
|
|
317
|
+
const MODE_FALLBACKS: Record<RetrievalMode, RetrievalMode[]> = {
|
|
318
|
+
lexical: [], // no lower mode; native/store failure → diagnostic
|
|
319
|
+
hybrid: ["lexical"],
|
|
320
|
+
adaptive: ["lexical"], // initial hybrid → searchLex on failure
|
|
321
|
+
quality: ["hybrid", "lexical"], // expanded/reranked → typed hybrid → lexical
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
async function executeMode(
|
|
325
|
+
store: QmdIndexStore,
|
|
326
|
+
mode: RetrievalMode,
|
|
327
|
+
query: string,
|
|
328
|
+
intent: string | undefined,
|
|
329
|
+
): Promise<QmdSearchHit[]> {
|
|
330
|
+
switch (mode) {
|
|
331
|
+
case "lexical":
|
|
332
|
+
return store.searchLex(query);
|
|
333
|
+
case "hybrid":
|
|
334
|
+
return store.searchTyped(query);
|
|
335
|
+
case "quality":
|
|
336
|
+
return store.searchExpanded(query, intent);
|
|
337
|
+
case "adaptive": {
|
|
338
|
+
const initial = await store.searchTyped(query);
|
|
339
|
+
if (exactMatchBypass(query, initial)) return initial;
|
|
340
|
+
if (shouldEscalate(query, initial)) {
|
|
341
|
+
try {
|
|
342
|
+
return await store.searchExpanded(query, intent);
|
|
343
|
+
} catch {
|
|
344
|
+
return initial; // retain initial hybrid list on reranker failure
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return initial;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function retrieveStore(
|
|
353
|
+
store: QmdIndexStore,
|
|
354
|
+
mode: RetrievalMode,
|
|
355
|
+
query: string,
|
|
356
|
+
intent: string | undefined,
|
|
357
|
+
): Promise<{ hits: QmdSearchHit[]; trace: string[] }> {
|
|
358
|
+
const trace: string[] = [];
|
|
359
|
+
for (const m of [mode, ...MODE_FALLBACKS[mode]]) {
|
|
360
|
+
try {
|
|
361
|
+
const hits = await executeMode(store, m, query, intent);
|
|
362
|
+
if (m !== mode) trace.push(`fallback:${mode}->${m}`);
|
|
363
|
+
return { hits, trace };
|
|
364
|
+
} catch (error) {
|
|
365
|
+
trace.push(`failed:${m}:${(error as Error).message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { hits: [], trace }; // caller policy decides: auto → inject nothing, explicit → diagnostic
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Exact title/alias/command/filename/page-ID match with normalized score ≥ 0.80 bypasses reranking. */
|
|
372
|
+
export function exactMatchBypass(query: string, hits: QmdSearchHit[]): boolean {
|
|
373
|
+
const q = normalizeQuery(query)?.toLowerCase();
|
|
374
|
+
if (!q) return false;
|
|
375
|
+
return hits.some((h) => {
|
|
376
|
+
const id = pageIdFromFile(h.file).toLowerCase();
|
|
377
|
+
const title = h.title.toLowerCase();
|
|
378
|
+
return (id === q || title === q) && h.score >= 0.8;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── cross-vault fusion (spec: reciprocal rank, project precedence) ──
|
|
383
|
+
const RANK_OFFSET = 60;
|
|
384
|
+
function reciprocalRank(rank: number): number {
|
|
385
|
+
return 1 / (RANK_OFFSET + rank); // 1/(60+rank), rank 0-based
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function pageIdFromFile(file: string): string {
|
|
389
|
+
// ".../documents/canonical/concepts/rag.md" → "concepts/rag" (pageId may contain "/")
|
|
390
|
+
const m = /(?:^|\/)documents\/(?:canonical|evidence)\/(.+)\.md$/.exec(file);
|
|
391
|
+
return m?.[1] ?? file;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function fuseLayers(layers: Array<{ vaultLabel?: string; hits: QmdSearchHit[] }>): RecallResult[] {
|
|
395
|
+
const best = new Map<string, { hit: QmdSearchHit; rr: number; rank: number; vaultLabel?: string }>();
|
|
396
|
+
for (const layer of layers) {
|
|
397
|
+
layer.hits.forEach((hit, rank) => {
|
|
398
|
+
const id = pageIdFromFile(hit.file);
|
|
399
|
+
const rr = reciprocalRank(rank);
|
|
400
|
+
const prev = best.get(id);
|
|
401
|
+
// Greater rr wins; on a tie the PROJECT layer wins (project has no vaultLabel).
|
|
402
|
+
if (!prev || rr > prev.rr || (rr === prev.rr && layer.vaultLabel === undefined)) {
|
|
403
|
+
best.set(id, { hit, rr, rank, vaultLabel: layer.vaultLabel });
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return [...best.values()]
|
|
408
|
+
.sort((a, b) => b.rr - a.rr || a.rank - b.rank || a.hit.file.localeCompare(b.hit.file))
|
|
409
|
+
.map(({ hit, rr, vaultLabel }) => toRecallResult(hit, rr, vaultLabel));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── bounded final multiplier (spec clamp 0.90–1.10; feedback ±0.02 is a zeroed Phase 6 hook) ──
|
|
413
|
+
export function boundedMultiplier(
|
|
414
|
+
isExactMatch: boolean,
|
|
415
|
+
role: "canonical" | "evidence",
|
|
416
|
+
deprecated: boolean,
|
|
417
|
+
): number {
|
|
418
|
+
let m = 1;
|
|
419
|
+
if (isExactMatch) m += 0.05;
|
|
420
|
+
if (role === "canonical") m += 0.03;
|
|
421
|
+
if (role === "evidence") m -= 0.02;
|
|
422
|
+
if (deprecated) m -= 0.08;
|
|
423
|
+
return Math.min(1.1, Math.max(0.9, m));
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The orchestrating entry point, `recallWiki(paths, rawQuery, { policy, config, signal })`:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
// 1. normalizeQuery(rawQuery) — null → return { results: [], diagnostics: [{ code: "recall_query_too_long" | "recall_query_empty", message }] }
|
|
431
|
+
// 2. resolve applicable vaults:
|
|
432
|
+
// automatic: active project vault if one exists (resolveVaultPaths(cwd) != personal), else personal
|
|
433
|
+
// explicit: project (if any) AND personal (~/.llm-wiki), unless primary IS personal
|
|
434
|
+
// 3. per vault: readQmdIndexStatus(paths) — not ready → automatic: skip vault silently; explicit: collect diagnostic
|
|
435
|
+
// 4. per vault: openQmdIndexStore({ dbPath: join(paths.qmdCurrent, "index.sqlite"), documentsPath: paths.qmdDocuments }),
|
|
436
|
+
// retrieveStore(store, mode, query, intent), close() in finally
|
|
437
|
+
// intent = [config.topic, config.mode].filter(Boolean).join(" ").slice(0, 256) || undefined
|
|
438
|
+
// 5. per vault: apply policy.minScore floor on normalized rr * boundedMultiplier(...) — strict >, floor applies to the FINAL score
|
|
439
|
+
// 6. fuseLayers(vaultLayers) → cap to policy.maxResults
|
|
440
|
+
// 7. map each fused hit to RecallResult via toRecallResult: id = pageIdFromFile, path = manifest entry
|
|
441
|
+
// sourcePath (readQmdManifest(paths, vaultId)) else join(paths.wiki, `${id}.md`),
|
|
442
|
+
// title/type/preview from the authoritative page (reuse parsePage/preview helpers from recall.ts),
|
|
443
|
+
// score = final bounded score, vaultLabel for personal hits
|
|
444
|
+
// 8. return { results, diagnostics, trace, mode }
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
`toRecallResult` reuses the existing `RecallResult` shape from `recall.ts` (`id, title, type, preview, path, vaultLabel?, score`) so rendering stays untouched. Do NOT open the store when status is not ready (matches Phase 2's status contract).
|
|
448
|
+
|
|
449
|
+
- [ ] **Step 3: Verify**
|
|
450
|
+
|
|
451
|
+
`pnpm typecheck`, `pnpm lint`, focused vitest over the new tests plus `recall.test.ts` and `qmd-indexing.test.ts` (status reads unchanged). Full model-free QMD suite must stay green.
|
|
452
|
+
|
|
453
|
+
**Verification:** each mode invokes only its specified SDK path (asserted via the adapter seam or request recording); every failure path lands on its designed fallback; no model load outside `QMD_MODEL_SMOKE=1` paths.
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Task 3: Retrieval Mode Configuration and Status
|
|
458
|
+
|
|
459
|
+
**Files:**
|
|
460
|
+
|
|
461
|
+
- Modify: `extensions/llm-wiki/lib/task-config.ts`
|
|
462
|
+
- Modify: `extensions/llm-wiki/lib/wiki-service.ts`
|
|
463
|
+
- Modify: `extensions/llm-wiki/lib/tools.ts`
|
|
464
|
+
- Modify: `test/task-config.test.ts`
|
|
465
|
+
|
|
466
|
+
- [ ] **Step 1: Add failing config/status tests**
|
|
467
|
+
|
|
468
|
+
1. `retrievalMode: "lexical" | "hybrid" | "adaptive" | "quality"` parses; invalid explicit values (`"fuzzy"`, `"vector"`, numbers) are dropped and the default applies — the setting must never disable recall or crash the hook.
|
|
469
|
+
2. Default is `adaptive` when the setting is absent.
|
|
470
|
+
3. `wiki_status` reports: resolved retrieval mode, resolved QMD embed/generate/rerank model IDs (from `resolveQmdModels`), and vector index health (`hasVectorIndex`), without opening the store.
|
|
471
|
+
|
|
472
|
+
- [ ] **Step 2: Implement**
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
// extensions/llm-wiki/lib/task-config.ts
|
|
476
|
+
export const RETRIEVAL_MODES = ["lexical", "hybrid", "adaptive", "quality"] as const;
|
|
477
|
+
export type RetrievalMode = (typeof RETRIEVAL_MODES)[number];
|
|
478
|
+
|
|
479
|
+
// in the interface:
|
|
480
|
+
retrievalMode?: RetrievalMode;
|
|
481
|
+
|
|
482
|
+
// in readNamespacedConfig(), matching the existing silent-drop pattern
|
|
483
|
+
// (invalid values are not applied — same convention as synthesisLanguage):
|
|
484
|
+
const mode = section.retrievalMode;
|
|
485
|
+
if (typeof mode === "string" && (RETRIEVAL_MODES as readonly string[]).includes(mode)) {
|
|
486
|
+
out.retrievalMode = mode as RetrievalMode;
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
// extensions/llm-wiki/lib/wiki-service.ts — extend the status object readQmdIndexStatus
|
|
492
|
+
// already produces (qmdIndexing readQmdIndexStatus paths) with, in getWikiStatus:
|
|
493
|
+
retrievalMode: config?.retrievalMode ?? "adaptive",
|
|
494
|
+
qmdModels: resolveQmdModels(), // embed/generate/rerank strings; no download
|
|
495
|
+
// hasVectorIndex already on qmd status from Phase 2 — surface it in the tool output
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`tools.ts`: render the new fields in `wiki_status` output text (mode, models, vector health) — read-only, no store open. The retrieval service defensively treats any unknown internal mode as `adaptive`.
|
|
499
|
+
|
|
500
|
+
- [ ] **Step 3: Verify**
|
|
501
|
+
|
|
502
|
+
Focused vitest over `task-config.test.ts`, `qmd-indexing.test.ts`, and `lint-okf.test.ts`; typecheck; lint.
|
|
503
|
+
|
|
504
|
+
**Verification:** invalid mode values never reach the retrieval service as an unknown mode; status stays model-free and store-open-free.
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## Task 4: Recall Cutover — Pi, MCP, Automatic
|
|
509
|
+
|
|
510
|
+
**Files:**
|
|
511
|
+
|
|
512
|
+
- Modify: `extensions/llm-wiki/lib/recall.ts`
|
|
513
|
+
- Modify: `extensions/llm-wiki/index.ts`
|
|
514
|
+
- Modify: `mcp/operations.ts`, `mcp/index.ts`
|
|
515
|
+
- Modify: `test/recall.test.ts`, `test/agent-start-injection.test.ts`, `test/mcp-parity.test.ts`
|
|
516
|
+
|
|
517
|
+
- [ ] **Step 1: Add failing cutover tests**
|
|
518
|
+
|
|
519
|
+
1. `wiki_recall` (Pi tool) returns QMD-backed candidates with the explicit policy: both vaults when both exist, reciprocal-rank fusion, project tie precedence, `0.25` normalized floor, `max_results` default 5 / max 10.
|
|
520
|
+
2. `wiki_recall` returns a structured diagnostic (not a crash) when the QMD store cannot open, and returns "no reliable memory" (not weak candidates) when nothing clears the floor.
|
|
521
|
+
3. Automatic `before_agent_start` recall uses the precision-first policy: active project only, at most 10 candidates per vault, normalized floor `0.50`, at most 3 injected results, nothing injected below the floor, and the existing links-first/skill-inline rendering unchanged when results exist.
|
|
522
|
+
4. MCP `wiki_recall` returns structurally equivalent results to the Pi tool for the same vault and query (same ids, order, scores, vault labels, diagnostics).
|
|
523
|
+
5. The cache-safe split is preserved: recall results still travel in the volatile tail message, never in the system prompt (existing `agent-start-injection` assertions keep passing with the new recall backend).
|
|
524
|
+
|
|
525
|
+
- [ ] **Step 2: Route the Pi tool and automatic hook**
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
// extensions/llm-wiki/lib/recall.ts — registerWikiRecall execute body (explicit policy):
|
|
529
|
+
const policy = {
|
|
530
|
+
includePersonal: true,
|
|
531
|
+
minScore: 0.25, // QMD normalized floor, spec
|
|
532
|
+
maxPerVault: 10,
|
|
533
|
+
maxResults: Math.min(params.max_results ?? 5, 10),
|
|
534
|
+
};
|
|
535
|
+
const { results, diagnostics } = await recallWiki(paths, params.query, {
|
|
536
|
+
policy,
|
|
537
|
+
config: runtime?.config,
|
|
538
|
+
signal,
|
|
539
|
+
});
|
|
540
|
+
// keep: no-vault guard, empty-result message ("No wiki pages found…" + diagnostics),
|
|
541
|
+
// links-first gate (shouldUseLinksFirst), formatRecallContext rendering, details payload
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
// extensions/llm-wiki/index.ts — before_agent_start (automatic, precision-first):
|
|
546
|
+
const policy = { includePersonal: false, minScore: 0.5, maxPerVault: 10, maxResults: 3 };
|
|
547
|
+
const { results } = await recallWiki(paths, prompt, { policy, config: runtime.config });
|
|
548
|
+
// keep: formatRecallContext links-only gate, status-line notice, buildAgentStartInjection tail delivery
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
- [ ] **Step 3: Route MCP**
|
|
552
|
+
|
|
553
|
+
```ts
|
|
554
|
+
// mcp/operations.ts — recallOperation becomes async over the shared service:
|
|
555
|
+
export async function recallOperation(
|
|
556
|
+
paths: VaultPaths,
|
|
557
|
+
query: string,
|
|
558
|
+
maxResults = 5,
|
|
559
|
+
): Promise<{ results: RecallResult[]; diagnostics: Array<{ code: string; message: string }> }> {
|
|
560
|
+
const { results, diagnostics } = await recallWiki(paths, query, {
|
|
561
|
+
policy: { includePersonal: true, minScore: 0.25, maxPerVault: 10, maxResults: Math.min(maxResults, 10) },
|
|
562
|
+
});
|
|
563
|
+
const vaultState = inspectVaultFormat(paths); // existing format diagnostics appended
|
|
564
|
+
return { results, diagnostics: [...diagnostics, ...vaultState.diagnostics.map((d) => ({ code: d.code, message: d.message }))] };
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
`mcp/index.ts` `wiki_recall` already awaits `recallOperation` — update the description only (results shape unchanged). Keep `searchOperation` (`wiki_search`) as the fast exact registry lookup, untouched.
|
|
569
|
+
|
|
570
|
+
- [ ] **Step 4: Verify parity**
|
|
571
|
+
|
|
572
|
+
Run `test/mcp-parity.test.ts`, `test/agent-start-injection.test.ts`, `test/recall.test.ts`, and the okf-integration suite. Pi and MCP must emit equivalent structured candidates; the automatic hook must inject nothing below the floor and nothing in a session with no vault.
|
|
573
|
+
|
|
574
|
+
**Verification:** all four surfaces (Pi tool, MCP, automatic hook, rendering) share one retrieval path; no caller still reaches the heuristic scorer for production recall.
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## Task 5: Deprecation and Scorer Retirement
|
|
579
|
+
|
|
580
|
+
**Files:**
|
|
581
|
+
|
|
582
|
+
- Modify: `extensions/llm-wiki/lib/tools.ts` (`wiki_reindex_embeddings`)
|
|
583
|
+
- Modify: `extensions/llm-wiki/lib/embeddings.ts`
|
|
584
|
+
- Modify: `extensions/llm-wiki/lib/recall.ts` (retire heuristic internals from active paths)
|
|
585
|
+
- Modify: `test/retrieval-benchmark.test.ts`, `test/embeddings.test.ts`
|
|
586
|
+
|
|
587
|
+
- [ ] **Step 1: Deprecate `wiki_reindex_embeddings`**
|
|
588
|
+
|
|
589
|
+
The tool delegates to the shared reindex operation; behavior for the same inputs is identical modulo the notice:
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
// extensions/llm-wiki/lib/tools.ts — registerWikiReindexEmbeddings.execute body:
|
|
593
|
+
// (keep the vault check, then:)
|
|
594
|
+
const result = await reindexWiki(paths, {
|
|
595
|
+
scope: "all",
|
|
596
|
+
components: ["vectors"],
|
|
597
|
+
force: params.force === true,
|
|
598
|
+
vault: "active",
|
|
599
|
+
});
|
|
600
|
+
const deprecation =
|
|
601
|
+
"⚠️ wiki_reindex_embeddings is deprecated — use wiki_reindex (lexical/vector, changed/all, per-vault). " +
|
|
602
|
+
"It delegates to wiki_reindex for one major release cycle.";
|
|
603
|
+
return { content: [{ type: "text", text: `${deprecation}\n\n${summaryText(result)}` }], details: result };
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Update the description/promptSnippet/promptGuidelines to say deprecated. `test/qmd-reindex-tool.test.ts` and MCP parity cover the delegation (identical behavior modulo the notice).
|
|
607
|
+
|
|
608
|
+
- [ ] **Step 2: Retire the old scorer from active paths**
|
|
609
|
+
|
|
610
|
+
Remove heuristic scoring, the page-level embedding sidecar read (`readEmbeddingStore`), and `searchWiki`/`searchWikiLayered`/`searchWikiHybrid` from production call sites after the parity gate passes. Keep only what benchmark tooling needs for transitional comparison, explicitly labeled and never imported by `recall.ts`, `index.ts`, `wiki-service.ts`, or MCP. `wiki_search` (exact registry lookup) remains.
|
|
611
|
+
|
|
612
|
+
- [ ] **Step 3: Run the ablation gates**
|
|
613
|
+
|
|
614
|
+
Extend `test/retrieval-benchmark.test.ts` to record and gate, against the Phase 1 committed baseline:
|
|
615
|
+
|
|
616
|
+
1. QMD lexical and QMD hybrid/adaptive/quality results (quality and adaptive under `QMD_MODEL_SMOKE=1`) versus the heuristic baseline; exact-lookup results stay within release-gate tolerance.
|
|
617
|
+
2. No regression on exact identifier/title/alias lookups (the strongest historical recall failure mode).
|
|
618
|
+
3. Automatic-recall false-positive rate: nothing injected below the floor.
|
|
619
|
+
|
|
620
|
+
If a gate fails, return to the owning task rather than weakening the criterion.
|
|
621
|
+
|
|
622
|
+
- [ ] **Step 4: Verify + docs**
|
|
623
|
+
|
|
624
|
+
Update `docs/configuration.md` (retrievalMode, deprecation), `docs/api.md` (recall diagnostics and modes), `docs/architecture.md` (single engine, fallback chains, normalization), and migration/troubleshooting notes (first-use model download messaging; lexical recall available before vectors finish). Full suite, typecheck, lint, `build:mcp`, `benchmark:retrieval`.
|
|
625
|
+
|
|
626
|
+
**Verification:** no production import chain reaches the retired scorer; `wiki_reindex_embeddings` and `wiki_reindex` behave identically for the same inputs; the benchmark report records all ablations with model/index versions.
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## Task 6: Certification
|
|
631
|
+
|
|
632
|
+
**Files:**
|
|
633
|
+
|
|
634
|
+
- All of the above.
|
|
635
|
+
|
|
636
|
+
- [ ] **Step 1: Model-free gates**
|
|
637
|
+
|
|
638
|
+
```bash
|
|
639
|
+
QMD_FORCE_CPU=1 pnpm exec vitest run test/qmd-mirror.test.ts test/qmd-contract.test.ts \
|
|
640
|
+
test/qmd-indexing.test.ts test/qmd-indexing-recovery.test.ts test/qmd-reindex-tool.test.ts \
|
|
641
|
+
test/retrieval.test.ts test/recall.test.ts test/lint-okf.test.ts
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
All pass, model smoke skipped, no unexpected stderr warnings.
|
|
645
|
+
|
|
646
|
+
- [ ] **Step 2: Full gates**
|
|
647
|
+
|
|
648
|
+
```bash
|
|
649
|
+
pnpm test
|
|
650
|
+
pnpm typecheck
|
|
651
|
+
pnpm lint
|
|
652
|
+
pnpm build:mcp
|
|
653
|
+
pnpm benchmark:retrieval
|
|
654
|
+
git diff --check 2ff868c..HEAD
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
- [ ] **Step 3: Mechanical scope verification**
|
|
658
|
+
|
|
659
|
+
`git diff 2ff868c..HEAD` shows: no `inject.ts` cache-safety contract change; no `@tobilu/qmd` import outside `lib/qmd-store.ts`; no manifest/state format change; no change to `wiki_reindex` parameters; heuristic scorer reachable only from benchmark tooling.
|
|
660
|
+
|
|
661
|
+
- [ ] **Step 4: Optional real-model smoke**
|
|
662
|
+
|
|
663
|
+
`QMD_MODEL_SMOKE=1 QMD_FORCE_CPU=1 pnpm exec vitest run test/qmd-contract.test.ts test/retrieval.test.ts` with cached pinned models (approximately 2 GB first-use download; if not run, record as an explicit release-risk note rather than claiming model-backed verification).
|
|
664
|
+
|
|
665
|
+
- [ ] **Step 5: Push and certify**
|
|
666
|
+
|
|
667
|
+
Push `qmd-phase-1`; confirm PR #144 checks (CodeQL, quality, package smoke, MCP parity) pass; record the benchmark report and any release-risk notes. After certification, create a separate Phase 4 (Card-First Memory Assembly) plan — do not fold Phase 4 into this one.
|
|
668
|
+
|
|
669
|
+
**Verification:** every gate green; single active retrieval engine; retrieval benchmark reports QMD ablations; docs match actual tool behavior.
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
673
|
+
## Release-Risk Notes (tracked here, not deferred)
|
|
674
|
+
|
|
675
|
+
- First-use model download is approximately 2 GB (default embedding, expansion, and reranker models) and happens only on vector/hybrid/adaptive-escalation/quality paths; lexical retrieval works with zero downloads. Document and surface progress/cancellation per the spec's error-handling section.
|
|
676
|
+
- Adaptive escalation and quality-mode verification depend on a real model run; without `QMD_MODEL_SMOKE=1` the plan certifies shapes and fallbacks, not end-to-end reranked scores.
|
|
677
|
+
- Cold-latency: a per-call store open/close may add measurable latency to first recall after idle. If the benchmark shows a cold-start regression beyond tolerance, add an open-store cache inside the retrieval service as a follow-up task — but only with benchmark evidence.
|
|
678
|
+
- The `recallFeedback` setting is not implemented this phase (Phase 6); the config surface may document it as reserved to avoid a later breaking config addition.
|