@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +16 -8
  3. package/dist/extensions/llm-wiki/lib/bootstrap.js +2 -0
  4. package/dist/extensions/llm-wiki/lib/indexing.js +24 -1
  5. package/dist/extensions/llm-wiki/lib/ingest-worker.js +3 -1
  6. package/dist/extensions/llm-wiki/lib/knowledge-document.js +11 -2
  7. package/dist/extensions/llm-wiki/lib/knowledge-links.js +41 -6
  8. package/dist/extensions/llm-wiki/lib/model-command.js +45 -8
  9. package/dist/extensions/llm-wiki/lib/qmd-indexing.js +1024 -0
  10. package/dist/extensions/llm-wiki/lib/qmd-mirror.js +418 -0
  11. package/dist/extensions/llm-wiki/lib/qmd-store.js +112 -0
  12. package/dist/extensions/llm-wiki/lib/recall.js +77 -3
  13. package/dist/extensions/llm-wiki/lib/runtime.js +25 -1
  14. package/dist/extensions/llm-wiki/lib/subagent.js +47 -7
  15. package/dist/extensions/llm-wiki/lib/tools.js +165 -5
  16. package/dist/extensions/llm-wiki/lib/utils.js +16 -2
  17. package/dist/extensions/llm-wiki/lib/wiki-service.js +104 -5
  18. package/dist/mcp/index.js +66 -2
  19. package/dist/mcp/operations.js +26 -2
  20. package/docs/api.md +43 -1
  21. package/docs/architecture.md +28 -0
  22. package/docs/commands.md +1 -0
  23. package/docs/qmd-compatibility.md +47 -0
  24. package/docs/retrieval-benchmark.md +47 -0
  25. package/docs/superpowers/benchmarks/phase-1-current-baseline.json +53 -0
  26. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-remediation.md +549 -0
  27. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-validated-indexing.md +1493 -0
  28. package/docs/superpowers/plans/2026-08-11-qmd-retrieval-phase-3-retrieval-modes-and-recall-cutover.md +678 -0
  29. package/docs/superpowers/plans/2026-09-05-wikilink-alias-pipe-table-only.md +257 -0
  30. package/extensions/llm-wiki/index.ts +14 -1
  31. package/extensions/llm-wiki/lib/bootstrap.ts +2 -0
  32. package/extensions/llm-wiki/lib/indexing.ts +24 -1
  33. package/extensions/llm-wiki/lib/ingest-worker.ts +10 -2
  34. package/extensions/llm-wiki/lib/knowledge-document.ts +20 -3
  35. package/extensions/llm-wiki/lib/knowledge-links.ts +39 -7
  36. package/extensions/llm-wiki/lib/model-command.ts +57 -12
  37. package/extensions/llm-wiki/lib/qmd-indexing.ts +1304 -0
  38. package/extensions/llm-wiki/lib/qmd-mirror.ts +496 -0
  39. package/extensions/llm-wiki/lib/qmd-store.ts +222 -0
  40. package/extensions/llm-wiki/lib/recall.ts +77 -3
  41. package/extensions/llm-wiki/lib/runtime.ts +57 -5
  42. package/extensions/llm-wiki/lib/subagent.ts +73 -10
  43. package/extensions/llm-wiki/lib/tools.ts +188 -4
  44. package/extensions/llm-wiki/lib/utils.ts +21 -2
  45. package/extensions/llm-wiki/lib/wiki-service.ts +160 -4
  46. package/mcp/index.ts +78 -1
  47. package/mcp/operations.ts +41 -2
  48. package/package.json +9 -6
  49. 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 13 custom tools and installs guardrails (+3 agent-trajectory tools
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) continue;
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;
@@ -589,7 +596,15 @@ export function serializeKnowledgeDocument(document: KnowledgeDocument): string
589
596
  return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
590
597
  }
591
598
 
592
- /** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */
599
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
600
+
601
+ /** Escape wikilink alias pipes only inside Markdown table rows.
602
+ *
603
+ * A bare `|` inside `[[target|alias]]` would be read as a table cell
604
+ * delimiter when the row renders, so table rows need the pipe escaped as
605
+ * `[[target\|alias]]`. Prose, lists, headings, and fenced code keep their
606
+ * pipes literal, so the written file stays valid for external readers
607
+ * (Obsidian, VS Code Wiki Links) that don't understand the escaped form. */
593
608
  function escapeWikilinkAliasPipes(body: string): string {
594
609
  let inFence = false;
595
610
  return body
@@ -600,7 +615,9 @@ function escapeWikilinkAliasPipes(body: string): string {
600
615
  return line;
601
616
  }
602
617
  if (inFence) return line;
603
- return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
618
+ return TABLE_ROW.test(line)
619
+ ? line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]")
620
+ : line;
604
621
  })
605
622
  .join("\n");
606
623
  }
@@ -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
- return { markdown, wikilinks };
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
- matchesKey,
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.list = new SelectList(
114
- items,
115
- Math.min(MAX_VISIBLE, Math.max(items.length, 1)),
116
- buildSelectTheme(theme),
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
- // matchesKey covers kitty CSI-u Esc; SelectList cancel is the fallback.
126
- if (matchesKey(data, "escape")) {
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.list.handleInput(data);
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 {