@zosmaai/pi-llm-wiki 0.12.1 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/README.md +16 -8
- package/dist/extensions/llm-wiki/lib/bootstrap.js +2 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +24 -1
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +3 -1
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +41 -6
- package/dist/extensions/llm-wiki/lib/model-command.js +45 -8
- package/dist/extensions/llm-wiki/lib/qmd-indexing.js +1024 -0
- package/dist/extensions/llm-wiki/lib/qmd-mirror.js +418 -0
- package/dist/extensions/llm-wiki/lib/qmd-store.js +112 -0
- package/dist/extensions/llm-wiki/lib/recall.js +77 -3
- package/dist/extensions/llm-wiki/lib/runtime.js +25 -1
- package/dist/extensions/llm-wiki/lib/subagent.js +47 -7
- package/dist/extensions/llm-wiki/lib/tools.js +165 -5
- package/dist/extensions/llm-wiki/lib/utils.js +16 -2
- package/dist/extensions/llm-wiki/lib/wiki-service.js +104 -5
- package/dist/mcp/index.js +66 -2
- package/dist/mcp/operations.js +26 -2
- package/docs/api.md +43 -1
- package/docs/architecture.md +28 -0
- package/docs/commands.md +1 -0
- package/docs/qmd-compatibility.md +47 -0
- package/docs/retrieval-benchmark.md +47 -0
- package/docs/superpowers/benchmarks/phase-1-current-baseline.json +53 -0
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-remediation.md +549 -0
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-validated-indexing.md +1493 -0
- package/docs/superpowers/plans/2026-08-11-qmd-retrieval-phase-3-retrieval-modes-and-recall-cutover.md +678 -0
- package/docs/superpowers/plans/2026-09-05-wikilink-alias-pipe-table-only.md +257 -0
- package/extensions/llm-wiki/index.ts +14 -1
- package/extensions/llm-wiki/lib/bootstrap.ts +2 -0
- package/extensions/llm-wiki/lib/indexing.ts +24 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +10 -2
- package/extensions/llm-wiki/lib/knowledge-document.ts +8 -1
- package/extensions/llm-wiki/lib/knowledge-links.ts +39 -7
- package/extensions/llm-wiki/lib/model-command.ts +57 -12
- package/extensions/llm-wiki/lib/qmd-indexing.ts +1304 -0
- package/extensions/llm-wiki/lib/qmd-mirror.ts +496 -0
- package/extensions/llm-wiki/lib/qmd-store.ts +222 -0
- package/extensions/llm-wiki/lib/recall.ts +77 -3
- package/extensions/llm-wiki/lib/runtime.ts +57 -5
- package/extensions/llm-wiki/lib/subagent.ts +73 -10
- package/extensions/llm-wiki/lib/tools.ts +188 -4
- package/extensions/llm-wiki/lib/utils.ts +21 -2
- package/extensions/llm-wiki/lib/wiki-service.ts +160 -4
- package/mcp/index.ts +78 -1
- package/mcp/operations.ts +41 -2
- package/package.json +9 -6
- package/skills/llm-wiki/SKILL.md +7 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Wikilink Alias Pipe Escaping — Table-Only 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:** Stop escaping `[[target|alias]]` pipes outside of Markdown table rows, so the `.md` files pi writes stay valid for external readers (Obsidian, VS Code Wiki Links) that don't understand the escaped `\|` form.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The escape logic lives in one pure function `escapeWikilinkAliasPipes(body)` in `extensions/llm-wiki/lib/knowledge-document.ts`, called from both write paths (`createKnowledgeDocument` for new/edited pages and `patchKnowledgeDocument` for ingest source updates). The fix narrows that function so its pipe-escaping regex only runs on lines that are Markdown table rows. Everything else (prose, lists, headings, fenced code) is passed through verbatim. No new files, no new public API — this is a one-function behavioral change.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (ES2022, ESM), Vitest (tests), Biome (lint), `tsc` (typecheck). Pure functions, no I/O inside the function under test.
|
|
10
|
+
|
|
11
|
+
**Roadmap:** None. Single focused bug fix.
|
|
12
|
+
|
|
13
|
+
**Phase:** Single-plan implementation.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Context (read before starting)
|
|
18
|
+
|
|
19
|
+
- **The bug:** `escapeWikilinkAliasPipes` applies its escaping regex to *every* non-fenced line, including prose. So `[[entities/peanut-cat|Peanut]]` in a sentence is written to disk as `[[entities/peanut-cat\|Peanut]]`. pi-llm-wiki's own reader tolerates this (`normalizeWikilinkTarget` strips a trailing `\`), but external readers treat `\|` as a literal backslash-pipe and report "Unresolved or ambiguous wiki-link".
|
|
20
|
+
- **The intent (introduced in PR #158, commit `7f1eea0`):** the escaping exists so an aliased wikilink inside a Markdown table cell doesn't have its inner `|` read as a cell delimiter. That intent is valid — it just must be *scoped* to table rows.
|
|
21
|
+
- **Current code (`knowledge-document.ts` ~line 593):**
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
function escapeWikilinkAliasPipes(body: string): string {
|
|
25
|
+
let inFence = false;
|
|
26
|
+
return body
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((line) => {
|
|
29
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) {
|
|
30
|
+
inFence = !inFence;
|
|
31
|
+
return line;
|
|
32
|
+
}
|
|
33
|
+
if (inFence) return line;
|
|
34
|
+
return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
|
|
35
|
+
})
|
|
36
|
+
.join("\n");
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The final `return line.replace(...)` runs for every non-fenced line — that's the bug.
|
|
41
|
+
|
|
42
|
+
- **Call sites (both go through `escapeWikilinkAliasPipes`):**
|
|
43
|
+
- `createKnowledgeDocument` → `knowledge-document.ts:634` (`normalizedBody`). Covers `/wiki create_page`, `wiki_ensure_page`, and auto-created stub concepts.
|
|
44
|
+
- `patchKnowledgeDocument` → `knowledge-document.ts:664`. Covers ingest source updates in `ingest-worker.ts:390`.
|
|
45
|
+
- **Table-row detection:** a Markdown table row starts (after optional whitespace) with `|` and ends (after optional whitespace) with `|`. Regex: `/^\s*\|.*\|\s*$/`. The separator row (`| --- |`) and header row match this but contain no `[[...|...]]`, so the replace is a no-op on them — safe.
|
|
46
|
+
- **Existing test that encodes the buggy behavior and MUST be updated:** `test/ingest-worker.test.ts:309` expects prose `[[concepts/transformer\|T]]`. After the fix it must expect the *unescaped* form `[[concepts/transformer|T]]` (the prose lives in a source page, not a table).
|
|
47
|
+
- **Existing tests that must stay green (no change needed):**
|
|
48
|
+
- `test/knowledge-document.test.ts:63` — table row escapes, fenced code does not. Stays.
|
|
49
|
+
- `test/knowledge-links.test.ts:51-52, 61` — reader-side, verifies both escaped and unescaped forms resolve. Unaffected by a writer change. Stays.
|
|
50
|
+
|
|
51
|
+
## Files changed
|
|
52
|
+
|
|
53
|
+
- Modify: `extensions/llm-wiki/lib/knowledge-document.ts` (the fix)
|
|
54
|
+
- Modify: `test/knowledge-document.test.ts` (add prose regression test)
|
|
55
|
+
- Modify: `test/ingest-worker.test.ts` (update buggy escaping assertion)
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Task 1: Write a failing test that pins the prose bug
|
|
60
|
+
|
|
61
|
+
**Files:**
|
|
62
|
+
- Modify: `test/knowledge-document.test.ts`
|
|
63
|
+
|
|
64
|
+
- [ ] **Step 1: Add a regression test asserting prose wikilinks are written verbatim**
|
|
65
|
+
|
|
66
|
+
Find the existing table-escape test in `test/knowledge-document.test.ts` (around line 63) — it currently ends right before the `it.each([...])` block that starts at line 71:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
it("escopes alias-pipe escaping to Markdown table rows only", () => {
|
|
70
|
+
const table = createKnowledgeDocument(
|
|
71
|
+
"concepts/table.md",
|
|
72
|
+
{ type: "concept" },
|
|
73
|
+
"| Name |\n| --- |\n| [[entities/gildan|Gildan]] |",
|
|
74
|
+
);
|
|
75
|
+
// Table row: alias pipe MUST be escaped so the cell isn't split.
|
|
76
|
+
expect(table.body).toContain("[[entities/gildan\\|Gildan]]");
|
|
77
|
+
|
|
78
|
+
const prose = createKnowledgeDocument(
|
|
79
|
+
"concepts/prose.md",
|
|
80
|
+
{ type: "concept" },
|
|
81
|
+
"See [[entities/peanut-cat|Peanut]] and [[entities/alice|Alice]] here.",
|
|
82
|
+
);
|
|
83
|
+
// Prose: alias pipes MUST stay literal so external readers follow the link.
|
|
84
|
+
expect(prose.body).toContain("[[entities/peanut-cat|Peanut]]");
|
|
85
|
+
expect(prose.body).toContain("[[entities/alice|Alice]]");
|
|
86
|
+
expect(prose.body).not.toContain("\\|");
|
|
87
|
+
|
|
88
|
+
// Fenced code stays verbatim (existing behavior, keep asserting).
|
|
89
|
+
const fenced = createKnowledgeDocument(
|
|
90
|
+
"concepts/fenced.md",
|
|
91
|
+
{ type: "concept" },
|
|
92
|
+
"```md\n[[entities/raw|Raw]]\n```",
|
|
93
|
+
);
|
|
94
|
+
expect(fenced.body).toContain("[[entities/raw|Raw]]");
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- [ ] **Step 2: Run the test to verify it fails**
|
|
99
|
+
|
|
100
|
+
Run: `npx vitest run test/knowledge-document.test.ts -t "escopes alias-pipe escaping to Markdown table rows only"`
|
|
101
|
+
|
|
102
|
+
Expected: FAIL. The `prose.body` assertions fail because the current implementation escapes the pipes and writes `[[entities/peanut-cat\|Peanut]]`. (The table-row assertion already passes.)
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 3: Commit the failing test**
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
git add test/knowledge-document.test.ts
|
|
108
|
+
git commit -m "test: add regression for prose wikilink alias-pipe escaping"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Expected: the commit lands with the test still red.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Task 2: Scope the escaping to Markdown table rows only
|
|
116
|
+
|
|
117
|
+
**Files:**
|
|
118
|
+
- Modify: `extensions/llm-wiki/lib/knowledge-document.ts`
|
|
119
|
+
|
|
120
|
+
- [ ] **Step 1: Replace the escape function so its regex only runs on table rows**
|
|
121
|
+
|
|
122
|
+
In `extensions/llm-wiki/lib/knowledge-document.ts`, replace the current `escapeWikilinkAliasPipes` function (from `/** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */` through its closing `}`) with this version:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
/** Escape wikilink alias pipes only inside Markdown table rows.
|
|
126
|
+
*
|
|
127
|
+
* A bare `|` inside `[[target|alias]]` would be read as a table cell
|
|
128
|
+
* delimiter when the row is rendered, so table rows need the pipe escaped as
|
|
129
|
+
* `[[target\\|alias]]`. Prose, lists, headings, and fenced code keep their
|
|
130
|
+
* pipes literal, so the written file stays valid for external readers
|
|
131
|
+
* (Obsidian, VS Code Wiki Links) that don't understand the escaped form. */
|
|
132
|
+
const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
|
133
|
+
|
|
134
|
+
function escapeWikilinkAliasPipes(body: string): string {
|
|
135
|
+
let inFence = false;
|
|
136
|
+
return body
|
|
137
|
+
.split("\n")
|
|
138
|
+
.map((line) => {
|
|
139
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) {
|
|
140
|
+
inFence = !inFence;
|
|
141
|
+
return line;
|
|
142
|
+
}
|
|
143
|
+
if (inFence) return line;
|
|
144
|
+
// Only table rows (and no-op separator/header rows) get the escape pass.
|
|
145
|
+
return TABLE_ROW.test(line)
|
|
146
|
+
? line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]")
|
|
147
|
+
: line;
|
|
148
|
+
})
|
|
149
|
+
.join("\n");
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The key change: the final `return` now guards the `.replace(...)` behind `TABLE_ROW.test(line)`, leaving non-table lines untouched.
|
|
154
|
+
|
|
155
|
+
- [ ] **Step 2: Run the test to verify it passes**
|
|
156
|
+
|
|
157
|
+
Run: `npx vitest run test/knowledge-document.test.ts -t "escopes alias-pipe escaping to Markdown table rows only"`
|
|
158
|
+
|
|
159
|
+
Expected: PASS. The prose assertions now hold because non-table lines are passed through verbatim; the table-row assertion still holds because the escaping still runs on table rows.
|
|
160
|
+
|
|
161
|
+
- [ ] **Step 3: Commit the fix**
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
git add extensions/llm-wiki/lib/knowledge-document.ts
|
|
165
|
+
git commit -m "fix: escape wikilink alias pipes only inside Markdown table rows"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Task 3: Correct the ingest-worker prose assertion
|
|
171
|
+
|
|
172
|
+
**Files:**
|
|
173
|
+
- Modify: `test/ingest-worker.test.ts`
|
|
174
|
+
|
|
175
|
+
- [ ] **Step 1: Update the assertion at line ~309 to expect unescaped prose pipes**
|
|
176
|
+
|
|
177
|
+
Find this block in `test/ingest-worker.test.ts` (inside the `commitSynthesis` "normalize" test, ~line 292-310):
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
expect(res.ok).toBe(true);
|
|
181
|
+
const written = readFileSync(join(paths.wiki, "sources", "SRC-001.md"), "utf8");
|
|
182
|
+
expect(written).toContain("[[concepts/transformer\\|T]]");
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The source page body is plain prose (`The [[transformer|T]] changed everything.`), so after the fix the pipe is no longer escaped. Change it to:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
expect(res.ok).toBe(true);
|
|
189
|
+
const written = readFileSync(join(paths.wiki, "sources", "SRC-001.md"), "utf8");
|
|
190
|
+
expect(written).toContain("[[concepts/transformer|T]]");
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- [ ] **Step 2: Run the ingest-worker test**
|
|
194
|
+
|
|
195
|
+
Run: `npx vitest run test/ingest-worker.test.ts`
|
|
196
|
+
|
|
197
|
+
Expected: PASS. (Before this change it would FAIL because the source page now stores the unescaped form.)
|
|
198
|
+
|
|
199
|
+
- [ ] **Step 3: Commit**
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
git add test/ingest-worker.test.ts
|
|
203
|
+
git commit -m "test: expect unescaped wikilink pipes in prose source pages"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Task 4: Full verification and commit sweep
|
|
209
|
+
|
|
210
|
+
**Files:**
|
|
211
|
+
- (none new)
|
|
212
|
+
|
|
213
|
+
- [ ] **Step 1: Run the full test suite**
|
|
214
|
+
|
|
215
|
+
Run: `pnpm test`
|
|
216
|
+
|
|
217
|
+
Expected: all tests pass. If anything else fails, it is encoding the old escaping behavior — update that assertion to the correct (unescaped-for-prose) expectation.
|
|
218
|
+
|
|
219
|
+
- [ ] **Step 2: Typecheck**
|
|
220
|
+
|
|
221
|
+
Run: `pnpm typecheck`
|
|
222
|
+
|
|
223
|
+
Expected: clean (no diagnostics).
|
|
224
|
+
|
|
225
|
+
- [ ] **Step 3: Lint**
|
|
226
|
+
|
|
227
|
+
Run: `pnpm lint`
|
|
228
|
+
|
|
229
|
+
Expected: clean (no lint errors).
|
|
230
|
+
|
|
231
|
+
- [ ] **Step 4: Commit any remaining changes**
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
git add -A
|
|
235
|
+
git commit -m "test: full suite, typecheck, lint green for alias-pipe fix"
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Expected: working tree clean.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Self-Review Checklist
|
|
243
|
+
|
|
244
|
+
1. **Spec coverage:** The issue (escaped `\|` in prose breaks external readers) is covered by Task 2's fix and Task 1's regression test. The ingest-worker regression is covered by Task 3.
|
|
245
|
+
2. **Placeholder scan:** No TBD/TODO/“implement later” markers. Every step has concrete code and exact commands.
|
|
246
|
+
3. **Type consistency:** `escapeWikilinkAliasPipes` keeps its `(body: string) => string` signature; `TABLE_ROW` is `RegExp`. Call sites unchanged.
|
|
247
|
+
4. **Phase boundary health:** After Task 2 the project is still green — the reader still handles both escaped and unescaped forms (knowledge-links tests), so no migration or half-done state. The only behavior change is on the write side, which is what the issue targets.
|
|
248
|
+
|
|
249
|
+
## Verification commands (verbatim)
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
npx vitest run test/knowledge-document.test.ts
|
|
253
|
+
npx vitest run test/ingest-worker.test.ts
|
|
254
|
+
pnpm test
|
|
255
|
+
pnpm typecheck
|
|
256
|
+
pnpm lint
|
|
257
|
+
```
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
registerObservationReminder,
|
|
13
13
|
registerWikiObserve,
|
|
14
14
|
} from "./lib/observation.js";
|
|
15
|
+
import { recoverQmdIndex } from "./lib/qmd-indexing.js";
|
|
15
16
|
import {
|
|
16
17
|
formatRecallContext,
|
|
17
18
|
registerWikiRecall,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
registerWikiLint,
|
|
37
38
|
registerWikiLogEvent,
|
|
38
39
|
registerWikiRebuildMeta,
|
|
40
|
+
registerWikiReindex,
|
|
39
41
|
registerWikiReindexEmbeddings,
|
|
40
42
|
registerWikiSearch,
|
|
41
43
|
registerWikiStatus,
|
|
@@ -58,7 +60,7 @@ import { applySessionStartStatus } from "./lib/visible-status.js";
|
|
|
58
60
|
/**
|
|
59
61
|
* @zosmaai/pi-llm-wiki — LLM Wiki extension for Pi
|
|
60
62
|
*
|
|
61
|
-
* Registers
|
|
63
|
+
* Registers 14 custom tools and installs guardrails (+3 agent-trajectory tools
|
|
62
64
|
* when `llm-wiki.trajectories` is enabled — opt-in, off by default, issue #80):
|
|
63
65
|
* - wiki_recall (layered: personal + project vaults)
|
|
64
66
|
* - wiki_retro (lightweight: single markdown file)
|
|
@@ -107,6 +109,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
107
109
|
registerWikiLint(pi, runtime);
|
|
108
110
|
registerWikiStatus(pi);
|
|
109
111
|
registerWikiRebuildMeta(pi, runtime);
|
|
112
|
+
registerWikiReindex(pi);
|
|
110
113
|
registerWikiReindexEmbeddings(pi, runtime);
|
|
111
114
|
registerWikiLogEvent(pi);
|
|
112
115
|
registerWikiWatch(pi);
|
|
@@ -218,6 +221,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
221
|
sessionModelId: (ctx.model as { id?: string })?.id,
|
|
219
222
|
});
|
|
220
223
|
|
|
224
|
+
// Fire-and-forget QMD index recovery. Generated-state repair only — it must
|
|
225
|
+
// never block heuristic recall or the first-turn injection path.
|
|
226
|
+
runtime.launchTask(ctx, `qmd-recovery:${paths.root}`, async () => {
|
|
227
|
+
try {
|
|
228
|
+
await recoverQmdIndex(paths);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.warn(`[llm-wiki] QMD index recovery failed: ${(err as Error).message}`);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
221
234
|
// One-time, user-visible session notice announcing the full wiki loop
|
|
222
235
|
// (issue #77). Without this, recall/observe/retro are invisible — they
|
|
223
236
|
// live only in the system prompt. Queued for the first prompt so it never
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { existsSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type { KnowledgeDiagnostic } from "./knowledge-document.js";
|
|
@@ -76,6 +77,7 @@ export function bootstrapVault(paths: VaultPaths, input: BootstrapInput): Bootst
|
|
|
76
77
|
topic: input.topic,
|
|
77
78
|
created: existing.created ?? fmtDate(),
|
|
78
79
|
version: existing.version ?? "1.0",
|
|
80
|
+
vault_id: existing.vault_id ?? randomUUID(),
|
|
79
81
|
...(created ? { knowledge_format: "okf-0.2" } : {}),
|
|
80
82
|
};
|
|
81
83
|
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { reindexEmbeddings, resolveEmbedder } from "./embeddings.js";
|
|
27
27
|
import { rebuildMetadataLight } from "./metadata.js";
|
|
28
|
+
import { invalidateQmdAfterProjectionFailure, reindexQmdVault } from "./qmd-indexing.js";
|
|
28
29
|
import type { LaunchCtx, Runtime } from "./runtime.js";
|
|
29
30
|
import type { VaultPaths } from "./utils.js";
|
|
30
31
|
|
|
@@ -65,7 +66,29 @@ export function scheduleReindex(
|
|
|
65
66
|
while (dirty.has(root)) {
|
|
66
67
|
dirty.delete(root);
|
|
67
68
|
const projection = rebuildMetadataLight(paths);
|
|
68
|
-
if (!projection.ok)
|
|
69
|
+
if (!projection.ok) {
|
|
70
|
+
// Generated QMD search state is repairable and must not fail the
|
|
71
|
+
// authoritative write. On a projection failure, only invalidate unsafe
|
|
72
|
+
// QMD entries (never index valid additions).
|
|
73
|
+
try {
|
|
74
|
+
await invalidateQmdAfterProjectionFailure(paths);
|
|
75
|
+
} catch {
|
|
76
|
+
// Best-effort safety pass; a busy/transient lock must not abort the
|
|
77
|
+
// metadata drain loop.
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// Post-projection lexical QMD pass. Model-free and repairable.
|
|
84
|
+
await reindexQmdVault(paths, {
|
|
85
|
+
scope: "changed",
|
|
86
|
+
components: ["lexical"],
|
|
87
|
+
force: false,
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
// Generated search indexing is repairable and must not fail the write.
|
|
91
|
+
}
|
|
69
92
|
|
|
70
93
|
// Refresh embeddings only after metadata is consistent. Stale-aware and
|
|
71
94
|
// a no-op unless an embedder is configured.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { AgentTool, StreamFn } from "@earendil-works/pi-agent-core";
|
|
4
4
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
5
|
import type { Static } from "typebox";
|
|
6
6
|
import { Type } from "typebox";
|
|
@@ -494,7 +494,11 @@ Rules:
|
|
|
494
494
|
export interface RunIngestSynthesisArgs {
|
|
495
495
|
model: Model<Api>;
|
|
496
496
|
apiKey: string;
|
|
497
|
-
headers?: Record<string, string>;
|
|
497
|
+
headers?: Record<string, string | null>;
|
|
498
|
+
/** Stream function for extension-registered providers (issue #222). */
|
|
499
|
+
streamFn?: StreamFn;
|
|
500
|
+
/** Provider-scoped env from auth resolution (issue #222; pi >= 0.85). */
|
|
501
|
+
env?: Record<string, string>;
|
|
498
502
|
paths: VaultPaths;
|
|
499
503
|
sourceId: string;
|
|
500
504
|
manifest: Record<string, unknown>;
|
|
@@ -522,6 +526,8 @@ export async function runIngestSynthesis(
|
|
|
522
526
|
model,
|
|
523
527
|
apiKey,
|
|
524
528
|
headers,
|
|
529
|
+
streamFn,
|
|
530
|
+
env,
|
|
525
531
|
paths,
|
|
526
532
|
sourceId,
|
|
527
533
|
manifest,
|
|
@@ -582,6 +588,8 @@ export async function runIngestSynthesis(
|
|
|
582
588
|
model,
|
|
583
589
|
apiKey,
|
|
584
590
|
headers,
|
|
591
|
+
streamFn,
|
|
592
|
+
env,
|
|
585
593
|
systemPrompt,
|
|
586
594
|
userPrompt,
|
|
587
595
|
tools: [commitTool as AgentTool],
|
|
@@ -26,7 +26,14 @@ export type DiagnosticCode =
|
|
|
26
26
|
| "event_source_unreadable"
|
|
27
27
|
| "event_invalid_json"
|
|
28
28
|
| "event_invalid_timestamp"
|
|
29
|
-
| "event_missing_kind"
|
|
29
|
+
| "event_missing_kind"
|
|
30
|
+
| "config_invalid_vault_id"
|
|
31
|
+
| "qmd_index_missing"
|
|
32
|
+
| "qmd_index_stale"
|
|
33
|
+
| "qmd_index_error"
|
|
34
|
+
| "qmd_index_busy"
|
|
35
|
+
| "qmd_manifest_invalid"
|
|
36
|
+
| "qmd_swap_interrupted";
|
|
30
37
|
|
|
31
38
|
export interface KnowledgeDiagnostic {
|
|
32
39
|
severity: DiagnosticSeverity;
|
|
@@ -38,23 +38,55 @@ function normalizeWikilinkTarget(target: string): string {
|
|
|
38
38
|
return target.trim().replace(/\\$/, "");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Blank out code spans, fenced/indented code blocks, and raw HTML (the same
|
|
42
|
+
// node types the link walk skips), preserving length so offsets stay valid.
|
|
43
|
+
function maskCodeRegions(tree: Root | null, body: string): string {
|
|
44
|
+
if (!tree) return body;
|
|
45
|
+
const ranges: Array<[number, number]> = [];
|
|
46
|
+
function visit(node: Nodes): void {
|
|
47
|
+
const position = (node as { position?: { start: { offset: number }; end: { offset: number } } })
|
|
48
|
+
.position;
|
|
49
|
+
if (node.type === "inlineCode" || node.type === "code" || node.type === "html") {
|
|
50
|
+
if (position) ranges.push([position.start.offset, position.end.offset]);
|
|
51
|
+
}
|
|
52
|
+
if ("children" in node && Array.isArray((node as { children?: Nodes[] }).children)) {
|
|
53
|
+
for (const child of (node as { children: Nodes[] }).children) {
|
|
54
|
+
visit(child);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
visit(tree);
|
|
59
|
+
if (ranges.length === 0) return body;
|
|
60
|
+
const chars = body.split("");
|
|
61
|
+
for (const [start, end] of ranges) {
|
|
62
|
+
for (let i = start; i < end && i < chars.length; i++) {
|
|
63
|
+
if (i >= 0) chars[i] = " ";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return chars.join("");
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
export function extractKnowledgeLinks(body: string): KnowledgeLinks {
|
|
42
70
|
const markdown: ExtractedLink[] = [];
|
|
43
71
|
const wikilinks: ExtractedLink[] = [];
|
|
44
72
|
|
|
45
|
-
// Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
|
|
46
|
-
for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
|
|
47
|
-
wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
|
|
48
|
-
}
|
|
49
|
-
|
|
50
73
|
// Parse with CommonMark AST
|
|
51
|
-
let tree: Root;
|
|
74
|
+
let tree: Root | null = null;
|
|
52
75
|
try {
|
|
53
76
|
tree = fromMarkdown(body);
|
|
54
77
|
} catch {
|
|
55
|
-
|
|
78
|
+
tree = null;
|
|
56
79
|
}
|
|
57
80
|
|
|
81
|
+
// Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
|
|
82
|
+
// Scan the code-masked body so [[...]] inside code is not a real link.
|
|
83
|
+
const scanBody = maskCodeRegions(tree, body);
|
|
84
|
+
for (const match of scanBody.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
|
|
85
|
+
wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!tree) return { markdown, wikilinks };
|
|
89
|
+
|
|
58
90
|
// Build definition map (case-insensitive)
|
|
59
91
|
const defs = new Map<string, Definition>();
|
|
60
92
|
function collectDefs(node: Nodes) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
3
|
Container,
|
|
4
|
-
|
|
4
|
+
getKeybindings,
|
|
5
|
+
Input,
|
|
5
6
|
type SelectItem,
|
|
6
7
|
SelectList,
|
|
7
8
|
type SelectListTheme,
|
|
@@ -93,8 +94,18 @@ function buildPickerItems(
|
|
|
93
94
|
return { items, selectedIndex };
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** Case-insensitive substring on label/value. Not SelectList.setFilter (prefix-on-value). */
|
|
98
|
+
function itemMatchesQuery(item: SelectItem, query: string): boolean {
|
|
99
|
+
if (!query) return true;
|
|
100
|
+
const needle = query.toLowerCase();
|
|
101
|
+
return item.label.toLowerCase().includes(needle) || item.value.toLowerCase().includes(needle);
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
/** Editor-dock picker; `ui.custom` without overlay replaces the input slot like `/model`. */
|
|
97
105
|
class ModelPickerScreen extends Container {
|
|
106
|
+
private readonly allItems: SelectItem[];
|
|
107
|
+
private readonly listTheme: SelectListTheme;
|
|
108
|
+
private readonly search: Input;
|
|
98
109
|
private list: SelectList;
|
|
99
110
|
private doneFn: (result?: string) => void;
|
|
100
111
|
private closed = false;
|
|
@@ -107,27 +118,61 @@ class ModelPickerScreen extends Container {
|
|
|
107
118
|
done: (result?: string) => void,
|
|
108
119
|
) {
|
|
109
120
|
super();
|
|
121
|
+
this.allItems = items;
|
|
122
|
+
this.listTheme = buildSelectTheme(theme);
|
|
110
123
|
this.doneFn = done;
|
|
111
124
|
this.addChild(new Text(title, 0, 0));
|
|
112
125
|
this.addChild(new Spacer(1));
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
118
|
-
this.list.setSelectedIndex(selectedIndex);
|
|
119
|
-
this.list.onSelect = (item) => this.finish(item.value);
|
|
120
|
-
this.list.onCancel = () => this.finish(undefined);
|
|
126
|
+
this.search = new Input();
|
|
127
|
+
this.search.focused = true;
|
|
128
|
+
this.addChild(this.search);
|
|
129
|
+
this.list = this.makeList(items, selectedIndex);
|
|
121
130
|
this.addChild(this.list);
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
handleInput(data: string): void {
|
|
125
|
-
|
|
126
|
-
if (
|
|
134
|
+
const kb = getKeybindings();
|
|
135
|
+
if (
|
|
136
|
+
kb.matches(data, "tui.select.up") ||
|
|
137
|
+
kb.matches(data, "tui.select.down") ||
|
|
138
|
+
kb.matches(data, "tui.select.confirm")
|
|
139
|
+
) {
|
|
140
|
+
this.list.handleInput(data);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// tui.select.cancel defaults to escape + ctrl+c and the kitty CSI-u Esc
|
|
144
|
+
// form, so one keybinding check covers every cancel keypath (no hardcoded Esc,
|
|
145
|
+
// so a remapped binding is honored).
|
|
146
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
127
147
|
this.finish(undefined);
|
|
128
148
|
return;
|
|
129
149
|
}
|
|
130
|
-
this.
|
|
150
|
+
this.search.handleInput(data);
|
|
151
|
+
this.applyFilter();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private makeList(items: SelectItem[], selectedIndex: number): SelectList {
|
|
155
|
+
const list = new SelectList(
|
|
156
|
+
items,
|
|
157
|
+
Math.min(MAX_VISIBLE, Math.max(items.length, 1)),
|
|
158
|
+
this.listTheme,
|
|
159
|
+
);
|
|
160
|
+
list.setSelectedIndex(selectedIndex);
|
|
161
|
+
list.onSelect = (item) => this.finish(item.value);
|
|
162
|
+
list.onCancel = () => this.finish(undefined);
|
|
163
|
+
return list;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private applyFilter(): void {
|
|
167
|
+
const filtered = this.allItems.filter((item) => itemMatchesQuery(item, this.search.getValue()));
|
|
168
|
+
const previous = this.list.getSelectedItem()?.value;
|
|
169
|
+
const selectedIndex = Math.max(
|
|
170
|
+
0,
|
|
171
|
+
filtered.findIndex((item) => item.value === previous),
|
|
172
|
+
);
|
|
173
|
+
this.removeChild(this.list);
|
|
174
|
+
this.list = this.makeList(filtered, selectedIndex);
|
|
175
|
+
this.addChild(this.list);
|
|
131
176
|
}
|
|
132
177
|
|
|
133
178
|
private finish(result?: string): void {
|