@zosmaai/pi-llm-wiki 0.11.4 → 0.11.5

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 (48) hide show
  1. package/README.de.md +8 -0
  2. package/README.es.md +8 -0
  3. package/README.fr.md +8 -0
  4. package/README.hi.md +8 -0
  5. package/README.ja.md +8 -0
  6. package/README.ko.md +8 -0
  7. package/README.md +8 -0
  8. package/README.pt.md +8 -0
  9. package/README.ru.md +8 -0
  10. package/README.zh.md +8 -0
  11. package/assets/wiki-dashboard.png +0 -0
  12. package/commands/wiki-ingest.md +1 -0
  13. package/commands/wiki-req.md +1 -0
  14. package/commands/wiki-retro.md +1 -0
  15. package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
  16. package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
  17. package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
  18. package/dist/extensions/llm-wiki/lib/host.js +21 -1
  19. package/dist/extensions/llm-wiki/lib/ingest-worker.js +2 -1
  20. package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
  21. package/dist/extensions/llm-wiki/lib/knowledge-links.js +6 -3
  22. package/dist/extensions/llm-wiki/lib/metadata.js +1 -1
  23. package/dist/extensions/llm-wiki/lib/observation.js +22 -3
  24. package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
  25. package/dist/extensions/llm-wiki/lib/task-config.js +78 -1
  26. package/docs/api.md +24 -1
  27. package/docs/commands.md +6 -1
  28. package/docs/configuration.md +10 -0
  29. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
  30. package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
  31. package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
  32. package/extensions/llm-wiki/index.ts +4 -0
  33. package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
  34. package/extensions/llm-wiki/lib/dashboard.ts +210 -0
  35. package/extensions/llm-wiki/lib/guardrails.ts +26 -1
  36. package/extensions/llm-wiki/lib/host.ts +21 -1
  37. package/extensions/llm-wiki/lib/ingest-worker.ts +4 -0
  38. package/extensions/llm-wiki/lib/knowledge-document.ts +20 -2
  39. package/extensions/llm-wiki/lib/knowledge-links.ts +7 -3
  40. package/extensions/llm-wiki/lib/metadata.ts +1 -1
  41. package/extensions/llm-wiki/lib/observation.ts +23 -3
  42. package/extensions/llm-wiki/lib/settings-command.ts +483 -0
  43. package/extensions/llm-wiki/lib/task-config.ts +102 -0
  44. package/package.json +4 -4
  45. package/prompts/wiki-ingest.md +1 -0
  46. package/prompts/wiki-req.md +1 -0
  47. package/prompts/wiki-retro.md +1 -0
  48. package/skills/llm-wiki/SKILL.md +11 -1
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, join } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  import { bootstrapVault } from "./lib/bootstrap.js";
5
+ import { registerWikiDashboardCommand } from "./lib/dashboard-command.js";
5
6
  import { installGuardrails } from "./lib/guardrails.js";
6
7
  import { buildAgentStartInjection, normalizeSystemPrompt } from "./lib/inject.js";
7
8
  import { registerWikiModelCommand } from "./lib/model-command.js";
@@ -20,6 +21,7 @@ import {
20
21
  } from "./lib/recall.js";
21
22
  import { registerWikiRetro } from "./lib/retro.js";
22
23
  import { registerBackgroundRuntime } from "./lib/runtime.js";
24
+ import { registerWikiSettingsCommand } from "./lib/settings-command.js";
23
25
  import {
24
26
  loadTaskConfig,
25
27
  noticesEnabled,
@@ -131,6 +133,8 @@ export default function (pi: ExtensionAPI) {
131
133
  // background task model. The taskModel config field + resolveModel already
132
134
  // exist; this exposes them to the user (default stays the session model).
133
135
  registerWikiModelCommand(pi, runtime);
136
+ registerWikiSettingsCommand(pi, runtime);
137
+ registerWikiDashboardCommand(pi, runtime);
134
138
  const reminderState = createReminderState();
135
139
  registerWikiObserve(pi, runtime, reminderState);
136
140
  // Visible observe/retro reminder by default (issue #77); silenced when the
@@ -0,0 +1,106 @@
1
+ /**
2
+ * /wiki-dashboard command.
3
+ *
4
+ * Read-only mirror of /wiki-settings: one persistent screen, Esc closes.
5
+ * Unlike settings there is no per-key change — every line is computed once
6
+ * from on-disk vault state (see lib/dashboard.ts) and rendered as plain
7
+ * text through a Container + Text.
8
+ */
9
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
10
+ import { Container, Text, matchesKey } from "@mariozechner/pi-tui";
11
+ import { type DashboardStats, collectDashboardStats } from "./dashboard.js";
12
+ import type { Runtime } from "./runtime.js";
13
+
14
+ type App = ExtensionAPI;
15
+
16
+ const TYPE_ORDER = [
17
+ "concept",
18
+ "entity",
19
+ "source",
20
+ "skill",
21
+ "analysis",
22
+ "synthesis",
23
+ "requirement",
24
+ "short",
25
+ "shared",
26
+ ];
27
+ const KIND_ORDER = ["observe", "retro", "synth", "intake"];
28
+
29
+ function orderedPairs(counts: Record<string, number>): string {
30
+ const keys = Object.keys(counts);
31
+ const known = TYPE_ORDER.filter((k) => counts[k] !== undefined);
32
+ const rest = keys
33
+ .filter((k) => !TYPE_ORDER.includes(k))
34
+ .sort()
35
+ .slice(0, 3); // cap the tail: the dashboard is a glance, not a census
36
+ const all = [...known, ...rest];
37
+ if (all.length === 0) return "—";
38
+ return all.map((k) => `${k} ${counts[k]}`).join(" · ");
39
+ }
40
+
41
+ function orderedKinds(counts: Record<string, number>): string {
42
+ const keys = Object.keys(counts);
43
+ const known = KIND_ORDER.filter((k) => counts[k] !== undefined);
44
+ const rest = keys
45
+ .filter((k) => !KIND_ORDER.includes(k))
46
+ .sort()
47
+ .slice(0, 3);
48
+ return [...known, ...rest].map((k) => `${k} ${counts[k]}`).join(" · ");
49
+ }
50
+
51
+ function renderStatsLines(s: DashboardStats): string[] {
52
+ const emb = s.embEnabled ? `${s.embFiles}/${s.pageCount}` : "—";
53
+ return [
54
+ `Wiki Dashboard · ${s.root}`,
55
+ "",
56
+ `Pages ${s.pageCount} · ${s.sizeKB}KB · ${orderedPairs(s.byType)}`,
57
+ `Fresh last touch ${s.lastTouch || "never"} · stale(>30d) ${s.staleCount}`,
58
+ `Acts 7d ${s.last7dTotal > 0 ? orderedKinds(s.last7dByKind) : "none"} · total ${s.last7dTotal}/${s.totalEvents}`,
59
+ `Queue raw ${s.rawQueue} · emb ${emb}`,
60
+ `Links zero-backlink ${s.zeroBacklinks} · full scan: /wiki-lint`,
61
+ "",
62
+ "Esc to close",
63
+ ];
64
+ }
65
+
66
+ /** One-screen read-only TUI; handles Esc and `q`. */
67
+ export class DashboardScreen extends Container {
68
+ private doneFn: (result?: unknown) => void;
69
+
70
+ constructor(lines: string[], close: (result?: unknown) => void) {
71
+ super();
72
+ this.doneFn = close;
73
+ this.addChild(new Text(lines.join("\n"), 1, 1));
74
+ }
75
+
76
+ handleInput(data: string): void {
77
+ // ponytail: matchesKey handles every terminal key shape (raw bytes,
78
+ // kitty CSI-u like \u001b[27u for Esc, ctrl combos) — a raw === "\u001b"
79
+ // check only works in terminals without the kitty keyboard protocol,
80
+ // which is exactly where Ghostty users' Esc went nowhere.
81
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
82
+ this.doneFn();
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Register the /wiki-dashboard command.
89
+ */
90
+ export function registerWikiDashboardCommand(pi: ExtensionAPI, runtime: Runtime): void {
91
+ pi.registerCommand("wiki-dashboard", {
92
+ description:
93
+ "Show a read-only LLM Wiki dashboard (pages, freshness, activity, queue, links, emb)",
94
+ handler: async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
95
+ runtime.ensureConfig(ctx.cwd);
96
+ if (!ctx.hasUI) {
97
+ ctx.ui.notify("LLM Wiki: /wiki-dashboard requires an interactive UI.", "warning");
98
+ return;
99
+ }
100
+ const stats = await collectDashboardStats(ctx.cwd);
101
+ await ctx.ui.custom((_tui, _theme, _keybindings, close) => {
102
+ return new DashboardScreen(renderStatsLines(stats), close);
103
+ });
104
+ },
105
+ });
106
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Read-only vault statistics for the /wiki-dashboard screen.
3
+ *
4
+ * Everything is computed from pre-existing on-disk state:
5
+ * - page tree (paths.wiki + skills dir): counts, mtimes, sizes
6
+ * - meta/registry.json: page types (values; keys can carry legacy prefix noise)
7
+ * - meta/backlinks.json: zero-inbound pages
8
+ * - meta/events.jsonl: activity stream (observes/retros/syntheses...)
9
+ * - raw/sources: pending ingest queue
10
+ * - emb/: embedding coverage
11
+ *
12
+ * No writes, no LLM calls, no new files. Pure reader.
13
+ */
14
+ import { readFile, readdir, stat } from "node:fs/promises";
15
+ import type { VaultPaths } from "./utils.js";
16
+ import { readJson, resolveVaultPaths } from "./utils.js";
17
+
18
+ interface PageInfo {
19
+ rel: string;
20
+ mtime: number;
21
+ bytes: number;
22
+ }
23
+
24
+ export interface DashboardStats {
25
+ /** Vault root directory, as shown in the header. */
26
+ root: string;
27
+ /** Total .md pages across wiki + skills dirs (filesystem truth). */
28
+ pageCount: number;
29
+ /** Page count by registry type; unknown types fall back to the folder name. */
30
+ byType: Record<string, number>;
31
+ /** Total page size in KB (rounded up to at least 1). */
32
+ sizeKB: number;
33
+ /** Human age of the most recent page touch: "now" | "Xm" | "Xh" | "Xd" | "". */
34
+ lastTouch: string;
35
+ /** Pages untouched for 30+ days. */
36
+ staleCount: number;
37
+ /** Event kinds (observe/retro/synth/intake/...) seen in the last 7 days. */
38
+ last7dByKind: Record<string, number>;
39
+ /** Total events in the last 7 days. */
40
+ last7dTotal: number;
41
+ /** Total parseable events recorded. */
42
+ totalEvents: number;
43
+ /** Number of raw source packets awaiting ingest (subdirs of raw/sources). */
44
+ rawQueue: number;
45
+ /** Pages with zero backlinks (from backlinks.json). */
46
+ zeroBacklinks: number;
47
+ /** .bin files in emb/ (meaningful when embeddings are enabled). */
48
+ embFiles: number;
49
+ /** Emb dir is populated — embeddings appear enabled. */
50
+ embEnabled: boolean;
51
+ }
52
+
53
+ /** Directories excluded from the page walk (infrastructure, not pages). */
54
+ const SKIP_DIRS = new Set([
55
+ "templates",
56
+ "cases",
57
+ "outputs",
58
+ ".discoveries",
59
+ "raw",
60
+ "meta",
61
+ "emb",
62
+ ".git",
63
+ ]);
64
+
65
+ async function walkPages(dir: string, rel: string, out: PageInfo[]): Promise<void> {
66
+ try {
67
+ const entries = await readdir(dir, { withFileTypes: true });
68
+ for (const e of entries) {
69
+ const childRel = rel === "" ? e.name : `${rel}/${e.name}`;
70
+ if (e.isDirectory()) {
71
+ if (SKIP_DIRS.has(e.name)) continue;
72
+ await walkPages(`${dir}/${e.name}`, childRel, out);
73
+ continue;
74
+ }
75
+ if (!e.name.endsWith(".md")) continue;
76
+ try {
77
+ const st = await stat(`${dir}/${e.name}`);
78
+ out.push({ rel: childRel, mtime: st.mtimeMs, bytes: st.size });
79
+ } catch {
80
+ // unreadable file: skip
81
+ }
82
+ }
83
+ } catch {
84
+ // missing dir (empty vault): nothing to walk
85
+ }
86
+ }
87
+
88
+ function humanAge(ms: number): string {
89
+ const mins = Math.floor(ms / 60_000);
90
+ if (mins < 1) return "now";
91
+ if (mins < 60) return `${mins}m`;
92
+ const hrs = Math.floor(mins / 60);
93
+ if (hrs < 48) return `${hrs}h`;
94
+ return `${Math.floor(hrs / 24)}d`;
95
+ }
96
+
97
+ async function readEvents(metaDir: string) {
98
+ const byKind: Record<string, number> = {};
99
+ let recent = 0;
100
+ let total = 0;
101
+ try {
102
+ const raw = await readFile(`${metaDir}/events.jsonl`, "utf-8");
103
+ const cutoff = Date.now() - 7 * 86400_000;
104
+ for (const line of raw.split("\n")) {
105
+ if (!line.trim()) continue;
106
+ let ev: { timestamp?: unknown; kind?: unknown };
107
+ try {
108
+ ev = JSON.parse(line);
109
+ } catch {
110
+ continue; // corrupted line: skip, not fatal
111
+ }
112
+ const ts = typeof ev.timestamp === "string" ? Date.parse(ev.timestamp) : Number.NaN;
113
+ const kind = typeof ev.kind === "string" && ev.kind ? ev.kind : "event";
114
+ total += 1;
115
+ if (Number.isFinite(ts) && ts >= cutoff) {
116
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
117
+ recent += 1;
118
+ }
119
+ }
120
+ } catch {
121
+ // no events file: zeros
122
+ }
123
+ return { byKind, recent, total };
124
+ }
125
+
126
+ /**
127
+ * Collect all dashboard stats for the vault reachable from `cwd`.
128
+ * Reuses the extension's own path resolution (same root the tools see).
129
+ */
130
+ export async function collectDashboardStats(cwd: string): Promise<DashboardStats> {
131
+ const paths: VaultPaths = resolveVaultPaths(cwd);
132
+
133
+ const pages: PageInfo[] = [];
134
+ await walkPages(paths.wiki, "", pages);
135
+ // skill pages register as type:skill and live under <dotWiki>/skills
136
+ try {
137
+ const st = await stat(`${paths.dotWiki}/skills`);
138
+ if (st.isDirectory()) {
139
+ await walkPages(`${paths.dotWiki}/skills`, "skills", pages);
140
+ }
141
+ } catch {
142
+ // no skills dir: fine
143
+ }
144
+
145
+ // type map from registry values — keys may carry legacy noise, so normalize
146
+ const registry = readJson<{ pages?: Record<string, { type?: string }> }>(
147
+ `${paths.meta}/registry.json`,
148
+ { pages: {} },
149
+ );
150
+ const typeByKey: Record<string, string> = {};
151
+ for (const [key, val] of Object.entries(registry.pages ?? {})) {
152
+ const norm = key.startsWith('"') ? key.slice(1) : key;
153
+ if (val?.type) typeByKey[norm] = val.type;
154
+ }
155
+ const byType: Record<string, number> = {};
156
+ for (const p of pages) {
157
+ const slug = p.rel.endsWith(".md") ? p.rel.slice(0, -3) : p.rel;
158
+ const type = typeByKey[slug] ?? p.rel.split("/")[0] ?? "other";
159
+ byType[type] = (byType[type] ?? 0) + 1;
160
+ }
161
+
162
+ let sizeBytes = 0;
163
+ let latestMtime = 0;
164
+ const staleCutoff = Date.now() - 30 * 86400_000;
165
+ let stale = 0;
166
+ for (const p of pages) {
167
+ sizeBytes += p.bytes;
168
+ if (p.mtime > latestMtime) latestMtime = p.mtime;
169
+ if (p.mtime < staleCutoff) stale += 1;
170
+ }
171
+
172
+ const events = await readEvents(paths.meta);
173
+
174
+ let rawQueue = 0;
175
+ try {
176
+ const pkts = await readdir(paths.rawSources, { withFileTypes: true });
177
+ for (const e of pkts) {
178
+ if (e.isDirectory()) rawQueue += 1; // each subdirectory is one packet
179
+ }
180
+ } catch {
181
+ // no raw/sources: empty queue
182
+ }
183
+
184
+ const backlinks = readJson<Record<string, string[]>>(`${paths.meta}/backlinks.json`, {});
185
+ const zeroBacklinks = Object.values(backlinks).filter((v) => (v ?? []).length === 0).length;
186
+
187
+ let embFiles = 0;
188
+ try {
189
+ const embEntries = await readdir(`${paths.dotWiki}/emb`);
190
+ embFiles = embEntries.filter((f) => f.endsWith(".bin")).length;
191
+ } catch {
192
+ // no emb dir: none
193
+ }
194
+
195
+ return {
196
+ root: paths.root,
197
+ pageCount: pages.length,
198
+ byType,
199
+ sizeKB: Math.max(1, Math.round(sizeBytes / 1024)),
200
+ lastTouch: pages.length === 0 ? "" : humanAge(Date.now() - latestMtime),
201
+ staleCount: stale,
202
+ last7dByKind: events.byKind,
203
+ last7dTotal: events.recent,
204
+ totalEvents: events.total,
205
+ rawQueue,
206
+ zeroBacklinks,
207
+ embFiles,
208
+ embEnabled: embFiles > 0,
209
+ };
210
+ }
@@ -79,12 +79,34 @@ function parseMoveDestination(line: string): string | undefined {
79
79
  function scanPatchString(input: string): MutationScan {
80
80
  const paths: string[] = [];
81
81
  let sawHeader = false;
82
+ let sawApplyPatchHeader = false;
83
+ let applyPatchComplete = true;
82
84
  let sectionHasMove = false;
83
85
  let complete = true;
84
86
  const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
85
87
 
86
88
  for (const line of stripped.split("\n")) {
87
- const trimmed = line.replace(/\r$/, "").trim();
89
+ const raw = line.replace(/\r$/, "");
90
+ // apply_patch envelope headers (#162): matched on the raw (untrimmed)
91
+ // line so body rows (space/+/- prefixed) that quote the envelope shape
92
+ // are never mistaken for headers.
93
+ const fileOp = /^\*{3}\s+(?:update|add|delete)\s+file:\s*(\S.*)$/i.exec(raw);
94
+ if (fileOp) {
95
+ sawApplyPatchHeader = true;
96
+ const path = normalizeMutationPath(fileOp[1]);
97
+ if (path) paths.push(path);
98
+ else applyPatchComplete = false;
99
+ continue;
100
+ }
101
+ const moveTo = /^\*{3}\s+move\s+to:\s*(\S.*)$/i.exec(raw);
102
+ if (moveTo) {
103
+ sawApplyPatchHeader = true;
104
+ const destination = normalizeMutationPath(moveTo[1]);
105
+ if (destination) paths.push(destination);
106
+ else applyPatchComplete = false;
107
+ continue;
108
+ }
109
+ const trimmed = raw.trim();
88
110
  if (trimmed.startsWith("[")) {
89
111
  sawHeader = true;
90
112
  sectionHasMove = false;
@@ -102,6 +124,9 @@ function scanPatchString(input: string): MutationScan {
102
124
  }
103
125
  }
104
126
 
127
+ if (sawApplyPatchHeader) {
128
+ return { paths, complete: applyPatchComplete && paths.length > 0 };
129
+ }
105
130
  return { paths, complete: sawHeader && complete };
106
131
  }
107
132
 
@@ -63,7 +63,7 @@ export function detectHost(): HostKind {
63
63
  if (segments.includes(".pi")) return "pi";
64
64
  }
65
65
 
66
- if (process.env.OMP_PROFILE !== undefined) return "omp";
66
+ if (process.env.OMP_PROFILE) return "omp";
67
67
  return "pi";
68
68
  }
69
69
 
@@ -123,3 +123,23 @@ export function resolveProjectSettingsPath(cwd: string, host: HostKind = detectH
123
123
 
124
124
  return join(native, "settings.json");
125
125
  }
126
+
127
+ /**
128
+ * The global (user-level) settings file this extension writes to.
129
+ *
130
+ * Always writes to `settings.json` inside the agent dir — both hosts read it.
131
+ */
132
+ export function resolveGlobalSettingsPath(host: HostKind = detectHost()): string {
133
+ let agentDir = "";
134
+ try {
135
+ agentDir = getAgentDir();
136
+ } catch {
137
+ agentDir = "";
138
+ }
139
+ if (!agentDir) {
140
+ // Fallback: ~/.pi/agent/settings.json
141
+ const home = process.env.HOME || "~";
142
+ return join(home, ".pi", "agent", "settings.json");
143
+ }
144
+ return join(agentDir, "settings.json");
145
+ }
@@ -475,6 +475,8 @@ export interface RunIngestSynthesisArgs {
475
475
  signal?: AbortSignal;
476
476
  /** BCP 47 language tag for narrative content (issue #124). */
477
477
  synthesisLanguage?: string;
478
+ /** Max output tokens for synthesizer sub-agent (issue #160). Default 16384. */
479
+ synthesisMaxTokens?: number;
478
480
  }
479
481
 
480
482
  /**
@@ -496,6 +498,7 @@ export async function runIngestSynthesis(
496
498
  maxChars,
497
499
  signal,
498
500
  synthesisLanguage,
501
+ synthesisMaxTokens,
499
502
  } = args;
500
503
  const content = extracted.slice(0, maxChars ?? 24_000);
501
504
  if (!content.trim()) return undefined;
@@ -549,6 +552,7 @@ export async function runIngestSynthesis(
549
552
  systemPrompt,
550
553
  userPrompt,
551
554
  tools: [commitTool as AgentTool],
555
+ maxTokens: synthesisMaxTokens ?? 16384,
552
556
  signal,
553
557
  });
554
558
 
@@ -588,6 +588,22 @@ export function serializeKnowledgeDocument(document: KnowledgeDocument): string
588
588
  return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
589
589
  }
590
590
 
591
+ /** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */
592
+ function escapeWikilinkAliasPipes(body: string): string {
593
+ let inFence = false;
594
+ return body
595
+ .split("\n")
596
+ .map((line) => {
597
+ if (/^\s*(`{3,}|~{3,})/.test(line)) {
598
+ inFence = !inFence;
599
+ return line;
600
+ }
601
+ if (inFence) return line;
602
+ return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
603
+ })
604
+ .join("\n");
605
+ }
606
+
591
607
  export function createKnowledgeDocument<T extends KnowledgeCreationFields>(
592
608
  path: string,
593
609
  fields: T & NoInfer<KnowledgeCreationFields>,
@@ -614,7 +630,7 @@ export function createKnowledgeDocument<T extends KnowledgeCreationFields>(
614
630
  ? { kind: "canonical" as const, value: sources }
615
631
  : { kind: "absent" as const };
616
632
 
617
- const normalizedBody = body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
633
+ const normalizedBody = escapeWikilinkAliasPipes(body.replace(/\r\n?/g, "\n")).replace(/\n*$/, "");
618
634
 
619
635
  return {
620
636
  id: path.replace(/\.md$/, ""),
@@ -643,7 +659,9 @@ export function patchKnowledgeDocument(
643
659
  }
644
660
  }
645
661
 
646
- const newBody = patch.body ?? document.body;
662
+ const newBody = patch.body
663
+ ? escapeWikilinkAliasPipes(patch.body.replace(/\r\n?/g, "\n"))
664
+ : document.body;
647
665
 
648
666
  return {
649
667
  ...document,
@@ -33,13 +33,17 @@ function diag(
33
33
  return { severity, code, path, message };
34
34
  }
35
35
 
36
+ function normalizeWikilinkTarget(target: string): string {
37
+ return target.trim().replace(/\\$/, "");
38
+ }
39
+
36
40
  export function extractKnowledgeLinks(body: string): KnowledgeLinks {
37
41
  const markdown: ExtractedLink[] = [];
38
42
  const wikilinks: ExtractedLink[] = [];
39
43
 
40
- // Extract legacy wikilinks
44
+ // Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
41
45
  for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
42
- wikilinks.push({ target: match[1].trim(), offset: match.index ?? 0 });
46
+ wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
43
47
  }
44
48
 
45
49
  // Parse with CommonMark AST
@@ -111,7 +115,7 @@ export function extractKnowledgeLinks(body: string): KnowledgeLinks {
111
115
  export function extractLegacyWikilinks(body: string): ExtractedLink[] {
112
116
  const links: ExtractedLink[] = [];
113
117
  for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
114
- links.push({ target: match[1].trim(), offset: match.index ?? 0 });
118
+ links.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
115
119
  }
116
120
  return links;
117
121
  }
@@ -508,7 +508,7 @@ export function buildDirectoryIndexes(
508
508
  lines.push("## Directories");
509
509
  lines.push("");
510
510
  for (const subDir of [...dirs].sort(compareCodePoint)) {
511
- const encoded = `${encodeRelativePath(subDir)}/`;
511
+ const encoded = encodeRelativePath(`${subDir}/index.md`);
512
512
  lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
513
513
  }
514
514
  }
@@ -345,10 +345,12 @@ export function registerObservationReminder(
345
345
  return true;
346
346
  };
347
347
  let turnsSinceLastReminder = 0;
348
+ let agentEndsInUserTurn = 0;
348
349
 
349
350
  pi.on("session_start", async () => {
350
351
  turnsSinceLastReminder = 0;
351
352
  reminderState.observeDoneThisSession = false;
353
+ agentEndsInUserTurn = 0;
352
354
  });
353
355
 
354
356
  // After compaction, reset turn counter so reminders resume
@@ -358,10 +360,18 @@ export function registerObservationReminder(
358
360
  turnsSinceLastReminder = 0;
359
361
  });
360
362
 
363
+ // A retry re-runs the agent within the same user turn, and pi does not
364
+ // forward `willRetry` to extensions (zosmaai/pi-llm-wiki#151). Only the
365
+ // user-role message_start separates real user turns from retried agent
366
+ // runs, so the per-turn agent_end count resets there.
367
+ pi.on("message_start", async (event) => {
368
+ if (event.message.role === "user") agentEndsInUserTurn = 0;
369
+ });
370
+
361
371
  pi.on("agent_end", async (event, _ctx) => {
362
- // Skip reminder on retries — willRetry means pi will re-run the agent,
363
- // and queuing another reminder would duplicate them (issue: connection
364
- // errors cause multiple retries, each firing agent_end).
372
+ // Legacy guard: this pi build does not forward `willRetry` to extension
373
+ // events, but keep the check in case a future one does. The dedup that
374
+ // actually works is the per-turn agent_end count below.
365
375
  if ("willRetry" in event && (event as { willRetry?: boolean }).willRetry) return;
366
376
 
367
377
  // No wiki applies here: never nag, and never accumulate a pending reminder
@@ -372,6 +382,12 @@ export function registerObservationReminder(
372
382
  if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
373
383
  if (reminderState.observeDoneThisSession) return;
374
384
 
385
+ // One agent_end per user turn may queue a reminder. A rate-limit storm
386
+ // re-runs the agent several times within the same turn, each firing
387
+ // agent_end, so count them and let only the first through.
388
+ agentEndsInUserTurn++;
389
+ if (agentEndsInUserTurn > 1) return;
390
+
375
391
  pi.sendMessage(
376
392
  {
377
393
  customType: "wiki-observe-reminder",
@@ -382,5 +398,9 @@ export function registerObservationReminder(
382
398
  deliverAs: "nextTurn",
383
399
  },
384
400
  );
401
+
402
+ // Reset the interval counter after queueing: without it the count stays
403
+ // at/above the threshold and every later agent_end queues a reminder.
404
+ turnsSinceLastReminder = 0;
385
405
  });
386
406
  }