@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
package/CHANGELOG.md CHANGED
@@ -13,6 +13,7 @@
13
13
  - **17 more tests** (`test/ambient-gate.test.ts`): project-vault resolution (own vault, ancestor walk, `WIKI_HOME`, personal-vault exclusion), the host-dependent default and its explicit override, the composed gate across four project states, and the reminder gate — including that a closed gate does not bank turns, so opening it later is not an instant nag, and that `display: false` is not a substitute because it still injects into model context.
14
14
 
15
15
  ### Fixed
16
+ - **`/wiki-model` picker ignored typing on long lists** (Issue #218): printable keys were forwarded to `SelectList`, which only handles arrows/enter/esc, so finding `anthropic/claude-haiku` still meant paging. A visible `Input` above the editor-dock list now filters with a case-insensitive substring on `label` and `value` (`SelectList.setFilter` is prefix-on-value and would miss `haiku`). Empty query restores the full list; no matches uses SelectList's no-match row; Esc still cancels immediately. Persist and scriptable args are unchanged.
16
17
  - **MCP server failed to start on `@modelcontextprotocol/server` 2.0.0** (Issue #128): `mcp/index.ts` imported `StdioServerTransport` from the package root, but SDK 2.0.0 (published 2026-07-27) moved that export to the `./stdio` subpath, so `node dist/mcp/index.js` died with `SyntaxError: The requested module '@modelcontextprotocol/server' does not provide an export named 'StdioServerTransport'` before the transport ever connected. The declared range `^2.0.0-alpha.2` is a caret range over a prerelease, so it permits `2.0.0`: every fresh consumer install resolved the stable SDK against the pre-stable import, while `pnpm-lock.yaml` pinned the `2.0.0-alpha.2` floor — so `test/mcp-package.test.ts`, which does spawn the published command and complete the stdio handshake, only ever exercised the alpha where the root export still existed. Fixed by importing `StdioServerTransport` from `@modelcontextprotocol/server/stdio` (the `./stdio` subpath does not exist in `2.0.0-alpha.2`, so the range, the lockfile and the import move together), raising the range to `^2.0.0`, and refreshing the lockfile so the existing smoke test now runs against the SDK consumers actually get.
17
18
  - **Packaged MCP server was never exercised with consumer-resolved dependencies**: a new `packaged-mcp-consumer` CI job packs the tarball, installs it into a directory with **no lockfile**, prints the resolved SDK version, and runs `scripts/mcp-smoke.mjs` — the stdio handshake (`initialize` → `tools/list`) — against that install. This closes the whole class of failure behind Issue #128: a dependency range that resolves differently for consumers than for this repo's lockfile can no longer break the published server undetected.
18
19
  - **README documented a standalone MCP command that ships no JavaScript** (Issue #129): all ten READMEs pointed MCP clients at `node node_modules/@zosmaai/pi-llm-wiki/mcp/index.js`, but the published `mcp/` directory contains only TypeScript (`index.ts`, `exec.ts`, `operations.ts`), so the documented command failed with `MODULE_NOT_FOUND` — independently of any dependency-resolution problem. The runnable entry is `dist/mcp/index.js`, which the package's own `pi.mcpservers` manifest already declared correctly. Every README now documents that path and adds an `mcpServers` config block for `.mcp.json` / `claude_desktop_config.json` using **absolute** paths, because MCP clients spawn the command without a shell: a `~` in `args` or `env` is passed through literally and the server fails to start behind a generic connection error. A new `test/package-structure.test.ts` case derives the expected path from `pi.mcpservers` — the same command `test/mcp-package.test.ts` spawns and handshakes with — and asserts every documented `…/mcp/index.js` occurrence in every README resolves to it, so docs cannot drift from the runnable entry point again.
package/README.md CHANGED
@@ -127,6 +127,7 @@ The result is a wiki that **compounds** as you capture sources, ask questions, a
127
127
  | `wiki_status` | Show counts, source states, and recent activity |
128
128
  | `wiki_observe` | Record timestamped, searchable observations from the current session (decisions, findings, constraints) — later distilled into pages |
129
129
  | `wiki_rebuild_meta` | Force a full metadata rebuild (registry, backlinks, index, log) |
130
+ | `wiki_reindex` | Rebuild/repair the generated QMD search index at `meta/qmd` (lexical is model-free; vectors may download ~2 GB) |
130
131
  | `wiki_reindex_embeddings` | Refresh semantic embeddings for new or stale pages (no-op if no embedding provider is configured) |
131
132
  | `wiki_log_event` | Append a structured event to the wiki activity log |
132
133
  | `wiki_watch` | Print a `crontab` line for automatic wiki updates (daily / weekly / hourly) — does not install it |
@@ -582,6 +583,13 @@ Thanks to everyone who has contributed! This list is regenerated automatically b
582
583
  <sub><b>PrestaLab</b></sub>
583
584
  </a>
584
585
  </td>
586
+ <td align="center">
587
+ <a href="https://github.com/wooksong">
588
+ <img src="https://avatars.githubusercontent.com/u/2772376?v=4" width="64;" alt="wooksong"/>
589
+ <br />
590
+ <sub><b>wooksong</b></sub>
591
+ </a>
592
+ </td>
585
593
  <td align="center">
586
594
  <a href="https://github.com/xcsf">
587
595
  <img src="https://avatars.githubusercontent.com/u/43439835?v=4" width="64;" alt="xcsf"/>
@@ -589,6 +597,8 @@ Thanks to everyone who has contributed! This list is regenerated automatically b
589
597
  <sub><b>xcsf</b></sub>
590
598
  </a>
591
599
  </td>
600
+ </tr>
601
+ <tr>
592
602
  <td align="center">
593
603
  <a href="https://github.com/danielnaab">
594
604
  <img src="https://avatars.githubusercontent.com/u/136512?v=4" width="64;" alt="danielnaab"/>
@@ -596,20 +606,18 @@ Thanks to everyone who has contributed! This list is regenerated automatically b
596
606
  <sub><b>Daniel Naab</b></sub>
597
607
  </a>
598
608
  </td>
599
- </tr>
600
- <tr>
601
609
  <td align="center">
602
- <a href="https://github.com/deestax">
603
- <img src="https://avatars.githubusercontent.com/u/152369481?v=4" width="64;" alt="deestax"/>
610
+ <a href="https://github.com/mdmayfield">
611
+ <img src="https://avatars.githubusercontent.com/u/26154258?v=4" width="64;" alt="mdmayfield"/>
604
612
  <br />
605
- <sub><b>Superdao</b></sub>
613
+ <sub><b>Matt Mayfield</b></sub>
606
614
  </a>
607
615
  </td>
608
616
  <td align="center">
609
- <a href="https://github.com/wooksong">
610
- <img src="https://avatars.githubusercontent.com/u/2772376?v=4" width="64;" alt="wooksong"/>
617
+ <a href="https://github.com/deestax">
618
+ <img src="https://avatars.githubusercontent.com/u/152369481?v=4" width="64;" alt="deestax"/>
611
619
  <br />
612
- <sub><b>wooksong</b></sub>
620
+ <sub><b>Superdao</b></sub>
613
621
  </a>
614
622
  </td>
615
623
  <td align="center">
@@ -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 { appendEvent, rebuildMetadata } from "./metadata.js";
@@ -64,6 +65,7 @@ export function bootstrapVault(paths, input) {
64
65
  topic: input.topic,
65
66
  created: existing.created ?? fmtDate(),
66
67
  version: existing.version ?? "1.0",
68
+ vault_id: existing.vault_id ?? randomUUID(),
67
69
  ...(created ? { knowledge_format: "okf-0.2" } : {}),
68
70
  };
69
71
  ensureVaultStructure(paths);
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { reindexEmbeddings, resolveEmbedder } from "./embeddings.js";
26
26
  import { rebuildMetadataLight } from "./metadata.js";
27
+ import { invalidateQmdAfterProjectionFailure, reindexQmdVault } from "./qmd-indexing.js";
27
28
  /** Promise of the current background pass, keyed by vault root. */
28
29
  const inflight = new Map();
29
30
  /** Vault roots with writes awaiting a (re)build. */
@@ -54,8 +55,30 @@ export function scheduleReindex(runtime, ctx, paths) {
54
55
  while (dirty.has(root)) {
55
56
  dirty.delete(root);
56
57
  const projection = rebuildMetadataLight(paths);
57
- if (!projection.ok)
58
+ if (!projection.ok) {
59
+ // Generated QMD search state is repairable and must not fail the
60
+ // authoritative write. On a projection failure, only invalidate unsafe
61
+ // QMD entries (never index valid additions).
62
+ try {
63
+ await invalidateQmdAfterProjectionFailure(paths);
64
+ }
65
+ catch {
66
+ // Best-effort safety pass; a busy/transient lock must not abort the
67
+ // metadata drain loop.
68
+ }
58
69
  continue;
70
+ }
71
+ try {
72
+ // Post-projection lexical QMD pass. Model-free and repairable.
73
+ await reindexQmdVault(paths, {
74
+ scope: "changed",
75
+ components: ["lexical"],
76
+ force: false,
77
+ });
78
+ }
79
+ catch {
80
+ // Generated search indexing is repairable and must not fail the write.
81
+ }
59
82
  // Refresh embeddings only after metadata is consistent. Stale-aware and
60
83
  // a no-op unless an embedder is configured.
61
84
  runtime.ensureConfig(root);
@@ -388,7 +388,7 @@ Rules:
388
388
  * synthesis.
389
389
  */
390
390
  export async function runIngestSynthesis(args) {
391
- const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, synthesisMaxTokens, wikilinkValidation, } = args;
391
+ const { model, apiKey, headers, streamFn, env, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, synthesisMaxTokens, wikilinkValidation, } = args;
392
392
  const content = extracted.slice(0, maxChars ?? 24_000);
393
393
  if (!content.trim())
394
394
  return undefined;
@@ -422,6 +422,8 @@ export async function runIngestSynthesis(args) {
422
422
  model,
423
423
  apiKey,
424
424
  headers,
425
+ streamFn,
426
+ env,
425
427
  systemPrompt,
426
428
  userPrompt,
427
429
  tools: [commitTool],
@@ -383,7 +383,14 @@ export function serializeKnowledgeDocument(document) {
383
383
  const body = document.body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
384
384
  return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
385
385
  }
386
- /** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */
386
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
387
+ /** Escape wikilink alias pipes only inside Markdown table rows.
388
+ *
389
+ * A bare `|` inside `[[target|alias]]` would be read as a table cell
390
+ * delimiter when the row renders, so table rows need the pipe escaped as
391
+ * `[[target\|alias]]`. Prose, lists, headings, and fenced code keep their
392
+ * pipes literal, so the written file stays valid for external readers
393
+ * (Obsidian, VS Code Wiki Links) that don't understand the escaped form. */
387
394
  function escapeWikilinkAliasPipes(body) {
388
395
  let inFence = false;
389
396
  return body
@@ -395,7 +402,9 @@ function escapeWikilinkAliasPipes(body) {
395
402
  }
396
403
  if (inFence)
397
404
  return line;
398
- return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
405
+ return TABLE_ROW.test(line)
406
+ ? line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]")
407
+ : line;
399
408
  })
400
409
  .join("\n");
401
410
  }
@@ -7,21 +7,56 @@ function diag(severity, code, path, message) {
7
7
  function normalizeWikilinkTarget(target) {
8
8
  return target.trim().replace(/\\$/, "");
9
9
  }
10
+ // Blank out code spans, fenced/indented code blocks, and raw HTML (the same
11
+ // node types the link walk skips), preserving length so offsets stay valid.
12
+ function maskCodeRegions(tree, body) {
13
+ if (!tree)
14
+ return body;
15
+ const ranges = [];
16
+ function visit(node) {
17
+ const position = node
18
+ .position;
19
+ if (node.type === "inlineCode" || node.type === "code" || node.type === "html") {
20
+ if (position)
21
+ ranges.push([position.start.offset, position.end.offset]);
22
+ }
23
+ if ("children" in node && Array.isArray(node.children)) {
24
+ for (const child of node.children) {
25
+ visit(child);
26
+ }
27
+ }
28
+ }
29
+ visit(tree);
30
+ if (ranges.length === 0)
31
+ return body;
32
+ const chars = body.split("");
33
+ for (const [start, end] of ranges) {
34
+ for (let i = start; i < end && i < chars.length; i++) {
35
+ if (i >= 0)
36
+ chars[i] = " ";
37
+ }
38
+ }
39
+ return chars.join("");
40
+ }
10
41
  export function extractKnowledgeLinks(body) {
11
42
  const markdown = [];
12
43
  const wikilinks = [];
13
- // Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
14
- for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
15
- wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
16
- }
17
44
  // Parse with CommonMark AST
18
- let tree;
45
+ let tree = null;
19
46
  try {
20
47
  tree = fromMarkdown(body);
21
48
  }
22
49
  catch {
23
- return { markdown, wikilinks };
50
+ tree = null;
24
51
  }
52
+ // Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
53
+ // Scan the code-masked body so [[...]] inside code is not a real link.
54
+ const scanBody = maskCodeRegions(tree, body);
55
+ for (const match of scanBody.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
56
+ wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
57
+ }
58
+ if (!tree)
59
+ return { markdown, wikilinks };
25
60
  // Build definition map (case-insensitive)
26
61
  const defs = new Map();
27
62
  function collectDefs(node) {
@@ -1,4 +1,4 @@
1
- import { Container, matchesKey, SelectList, Spacer, Text, } from "@earendil-works/pi-tui";
1
+ import { Container, getKeybindings, Input, SelectList, Spacer, Text, } from "@earendil-works/pi-tui";
2
2
  import { parseModelRef, persistTaskModel } from "./task-config.js";
3
3
  /** Words that clear the override and revert to the session model. */
4
4
  const CLEAR_WORDS = new Set(["session", "default", "reset", "clear", "none", "unset"]);
@@ -53,29 +53,66 @@ function buildPickerItems(pool, currentRef) {
53
53
  }
54
54
  return { items, selectedIndex };
55
55
  }
56
+ /** Case-insensitive substring on label/value. Not SelectList.setFilter (prefix-on-value). */
57
+ function itemMatchesQuery(item, query) {
58
+ if (!query)
59
+ return true;
60
+ const needle = query.toLowerCase();
61
+ return item.label.toLowerCase().includes(needle) || item.value.toLowerCase().includes(needle);
62
+ }
56
63
  /** Editor-dock picker; `ui.custom` without overlay replaces the input slot like `/model`. */
57
64
  class ModelPickerScreen extends Container {
65
+ allItems;
66
+ listTheme;
67
+ search;
58
68
  list;
59
69
  doneFn;
60
70
  closed = false;
61
71
  constructor(title, items, selectedIndex, theme, done) {
62
72
  super();
73
+ this.allItems = items;
74
+ this.listTheme = buildSelectTheme(theme);
63
75
  this.doneFn = done;
64
76
  this.addChild(new Text(title, 0, 0));
65
77
  this.addChild(new Spacer(1));
66
- this.list = new SelectList(items, Math.min(MAX_VISIBLE, Math.max(items.length, 1)), buildSelectTheme(theme));
67
- this.list.setSelectedIndex(selectedIndex);
68
- this.list.onSelect = (item) => this.finish(item.value);
69
- this.list.onCancel = () => this.finish(undefined);
78
+ this.search = new Input();
79
+ this.search.focused = true;
80
+ this.addChild(this.search);
81
+ this.list = this.makeList(items, selectedIndex);
70
82
  this.addChild(this.list);
71
83
  }
72
84
  handleInput(data) {
73
- // matchesKey covers kitty CSI-u Esc; SelectList cancel is the fallback.
74
- if (matchesKey(data, "escape")) {
85
+ const kb = getKeybindings();
86
+ if (kb.matches(data, "tui.select.up") ||
87
+ kb.matches(data, "tui.select.down") ||
88
+ kb.matches(data, "tui.select.confirm")) {
89
+ this.list.handleInput(data);
90
+ return;
91
+ }
92
+ // tui.select.cancel defaults to escape + ctrl+c and the kitty CSI-u Esc
93
+ // form, so one keybinding check covers every cancel keypath (no hardcoded Esc,
94
+ // so a remapped binding is honored).
95
+ if (kb.matches(data, "tui.select.cancel")) {
75
96
  this.finish(undefined);
76
97
  return;
77
98
  }
78
- this.list.handleInput(data);
99
+ this.search.handleInput(data);
100
+ this.applyFilter();
101
+ }
102
+ makeList(items, selectedIndex) {
103
+ const list = new SelectList(items, Math.min(MAX_VISIBLE, Math.max(items.length, 1)), this.listTheme);
104
+ list.setSelectedIndex(selectedIndex);
105
+ list.onSelect = (item) => this.finish(item.value);
106
+ list.onCancel = () => this.finish(undefined);
107
+ return list;
108
+ }
109
+ applyFilter() {
110
+ const filtered = this.allItems.filter((item) => itemMatchesQuery(item, this.search.getValue()));
111
+ const previous = this.list.getSelectedItem()?.value;
112
+ const selectedIndex = Math.max(0, filtered.findIndex((item) => item.value === previous));
113
+ this.removeChild(this.list);
114
+ this.list = this.makeList(filtered, selectedIndex);
115
+ this.addChild(this.list);
79
116
  }
80
117
  finish(result) {
81
118
  if (this.closed)