@zosmaai/pi-llm-wiki 0.11.3 → 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 (60) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.de.md +8 -0
  3. package/README.es.md +8 -0
  4. package/README.fr.md +8 -0
  5. package/README.hi.md +8 -0
  6. package/README.ja.md +8 -0
  7. package/README.ko.md +8 -0
  8. package/README.md +88 -2
  9. package/README.pt.md +8 -0
  10. package/README.ru.md +8 -0
  11. package/README.zh.md +8 -0
  12. package/assets/wiki-dashboard.png +0 -0
  13. package/commands/wiki-digest.md +28 -0
  14. package/commands/wiki-discover.md +30 -0
  15. package/commands/wiki-ingest.md +37 -0
  16. package/commands/wiki-init.md +30 -0
  17. package/commands/wiki-lint.md +25 -0
  18. package/commands/wiki-query.md +37 -0
  19. package/commands/wiki-record.md +36 -0
  20. package/commands/wiki-req.md +56 -0
  21. package/commands/wiki-retro.md +35 -0
  22. package/commands/wiki-run.md +31 -0
  23. package/commands/wiki-skills.md +26 -0
  24. package/commands/wiki-status.md +16 -0
  25. package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
  26. package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
  27. package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
  28. package/dist/extensions/llm-wiki/lib/host.js +117 -0
  29. package/dist/extensions/llm-wiki/lib/ingest-worker.js +2 -1
  30. package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
  31. package/dist/extensions/llm-wiki/lib/knowledge-links.js +6 -3
  32. package/dist/extensions/llm-wiki/lib/metadata.js +1 -1
  33. package/dist/extensions/llm-wiki/lib/observation.js +31 -3
  34. package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
  35. package/dist/extensions/llm-wiki/lib/task-config.js +145 -43
  36. package/dist/extensions/llm-wiki/lib/utils.js +59 -16
  37. package/docs/api.md +24 -1
  38. package/docs/commands.md +6 -1
  39. package/docs/configuration.md +62 -11
  40. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
  41. package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
  42. package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
  43. package/extensions/llm-wiki/index.ts +48 -6
  44. package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
  45. package/extensions/llm-wiki/lib/dashboard.ts +210 -0
  46. package/extensions/llm-wiki/lib/guardrails.ts +26 -1
  47. package/extensions/llm-wiki/lib/host.ts +145 -0
  48. package/extensions/llm-wiki/lib/ingest-worker.ts +4 -0
  49. package/extensions/llm-wiki/lib/knowledge-document.ts +20 -2
  50. package/extensions/llm-wiki/lib/knowledge-links.ts +7 -3
  51. package/extensions/llm-wiki/lib/metadata.ts +1 -1
  52. package/extensions/llm-wiki/lib/observation.ts +37 -4
  53. package/extensions/llm-wiki/lib/settings-command.ts +483 -0
  54. package/extensions/llm-wiki/lib/task-config.ts +208 -46
  55. package/extensions/llm-wiki/lib/utils.ts +55 -14
  56. package/package.json +15 -4
  57. package/prompts/wiki-ingest.md +1 -0
  58. package/prompts/wiki-req.md +1 -0
  59. package/prompts/wiki-retro.md +1 -0
  60. 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,7 +21,13 @@ import {
20
21
  } from "./lib/recall.js";
21
22
  import { registerWikiRetro } from "./lib/retro.js";
22
23
  import { registerBackgroundRuntime } from "./lib/runtime.js";
23
- import { loadTaskConfig, noticesEnabled, trajectoriesEnabled } from "./lib/task-config.js";
24
+ import { registerWikiSettingsCommand } from "./lib/settings-command.js";
25
+ import {
26
+ loadTaskConfig,
27
+ noticesEnabled,
28
+ personalVaultIsAmbient,
29
+ trajectoriesEnabled,
30
+ } from "./lib/task-config.js";
24
31
  import {
25
32
  registerWikiBootstrap,
26
33
  registerWikiCaptureSource,
@@ -40,7 +47,11 @@ import {
40
47
  registerWikiDistillSkills,
41
48
  registerWikiRecallSkill,
42
49
  } from "./lib/trajectory.js";
43
- import { migrateDoubledPersonalVault, resolveVaultPaths } from "./lib/utils.js";
50
+ import {
51
+ migrateDoubledPersonalVault,
52
+ resolveProjectVaultRoot,
53
+ resolveVaultPaths,
54
+ } from "./lib/utils.js";
44
55
  import { inspectWritableVault } from "./lib/vault-format.js";
45
56
  import { applySessionStartStatus } from "./lib/visible-status.js";
46
57
 
@@ -68,6 +79,26 @@ export default function (pi: ExtensionAPI) {
68
79
  // work. Created first so tools (e.g. wiki_ingest) can dispatch to it.
69
80
  const runtime = registerBackgroundRuntime(pi);
70
81
 
82
+ /**
83
+ * Does a wiki apply to the directory this session is working in?
84
+ *
85
+ * Gates every AMBIENT surface — the ones that speak without being asked:
86
+ * session bootstrap/notice, the periodic observe/retro reminder, and
87
+ * `before_agent_start` recall injection. Tools and commands are registered
88
+ * regardless, so `/wiki-init` remains the way in.
89
+ *
90
+ * `resolveVaultRoot` falls back to the personal vault when a project has
91
+ * none, which is why the ambient surfaces used to fire in EVERY directory
92
+ * once a personal vault existed — reminders and unrelated cross-project
93
+ * recall hits leaking into repositories that never initialized a wiki.
94
+ * Under omp that fallback is off by default (`llm-wiki.ambientPersonalVault`);
95
+ * under pi it stays on, preserving the historical behavior.
96
+ *
97
+ * Resolved per call, not once at load: `cwd` changes within a session.
98
+ */
99
+ const wikiAppliesTo = (cwd: string): boolean =>
100
+ resolveProjectVaultRoot(cwd) !== null || personalVaultIsAmbient(runtime.config);
101
+
71
102
  registerWikiBootstrap(pi);
72
103
  registerWikiCaptureSource(pi, runtime);
73
104
  registerWikiIngest(pi, runtime);
@@ -102,13 +133,17 @@ export default function (pi: ExtensionAPI) {
102
133
  // background task model. The taskModel config field + resolveModel already
103
134
  // exist; this exposes them to the user (default stays the session model).
104
135
  registerWikiModelCommand(pi, runtime);
136
+ registerWikiSettingsCommand(pi, runtime);
137
+ registerWikiDashboardCommand(pi, runtime);
105
138
  const reminderState = createReminderState();
106
139
  registerWikiObserve(pi, runtime, reminderState);
107
140
  // Visible observe/retro reminder by default (issue #77); silenced when the
108
141
  // user sets `llm-wiki.notices: false`. Resolver reads the live config so the
109
- // setting takes effect without a restart.
142
+ // setting takes effect without a restart. `display: false` still injects the
143
+ // reminder into model context, so the "no wiki here" case needs its own gate.
110
144
  registerObservationReminder(pi, reminderState, {
111
145
  display: () => noticesEnabled(runtime.config),
146
+ enabled: () => wikiAppliesTo(process.cwd()),
112
147
  });
113
148
 
114
149
  installGuardrails(pi, runtime);
@@ -137,6 +172,12 @@ export default function (pi: ExtensionAPI) {
137
172
  console.warn(`[llm-wiki] doubled-dotdir migration skipped: ${(err as Error).message}`);
138
173
  }
139
174
 
175
+ // Ambient gate. `ensureConfig` first so an explicit
176
+ // `llm-wiki.ambientPersonalVault` is honored on the very first session —
177
+ // `runtime.config` is otherwise empty until the first `turn_start`.
178
+ runtime.ensureConfig(process.cwd());
179
+ if (!wikiAppliesTo(process.cwd())) return;
180
+
140
181
  const paths = resolveVaultPaths(process.cwd());
141
182
  if (!existsSync(join(paths.dotWiki, "config.json"))) {
142
183
  // Silently create the wiki vault — no UI prompts. Topic/mode will be
@@ -168,9 +209,8 @@ export default function (pi: ExtensionAPI) {
168
209
 
169
210
  // Surface the "wiki active" badge and the active background task model
170
211
  // (issue #69), both gated by `llm-wiki.notices` (issue #77, regression
171
- // fixed in #83, helper extracted in #84). `ensureConfig` MUST run first so
172
- // the gate sees the loaded project settings.
173
- runtime.ensureConfig(process.cwd());
212
+ // fixed in #83, helper extracted in #84). The `ensureConfig` above the
213
+ // ambient gate already loaded the project settings this reads.
174
214
  applySessionStartStatus({
175
215
  ui: ctx.ui,
176
216
  runtime,
@@ -196,6 +236,8 @@ export default function (pi: ExtensionAPI) {
196
236
  // from the user's first prompt and update config via wiki_bootstrap.
197
237
  // 2. Search both personal + project vaults for relevant pages.
198
238
  pi.on("before_agent_start", async (event, ctx) => {
239
+ if (!wikiAppliesTo(process.cwd())) return;
240
+
199
241
  const paths = resolveVaultPaths(process.cwd());
200
242
  if (!existsSync(join(paths.dotWiki, "config.json"))) {
201
243
  return;
@@ -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
 
@@ -0,0 +1,145 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+
5
+ /**
6
+ * Host adapter for the two coding agents that can load this extension:
7
+ *
8
+ * - **pi** — `@mariozechner/pi-coding-agent`, config dir `.pi`
9
+ * - **omp** — oh-my-pi (`@oh-my-pi/pi-coding-agent`), config dir `.omp`
10
+ *
11
+ * omp rewrites `@mariozechner/pi-*` (and bare `typebox`) imports onto its own
12
+ * bundled packages at load time (its `legacy-pi-compat.ts`), so the *module
13
+ * graph* needs no changes. What does differ is the on-disk config layout:
14
+ *
15
+ * | | pi | omp |
16
+ * |---|---|---|
17
+ * | user dir | `~/.pi/agent` | `~/.omp/agent` |
18
+ * | project dir | `<cwd>/.pi` | `<cwd>/.omp` |
19
+ * | settings file | `settings.json` | `settings.json`, then `config.yml` |
20
+ *
21
+ * omp explicitly does **not** read `.pi` (its config source order is
22
+ * `.omp` → `.claude` → `.codex` → `.gemini`), so a wiki configured under pi
23
+ * would silently lose its settings after switching hosts. This module keeps
24
+ * both layouts readable and picks a sensible file to write to.
25
+ *
26
+ * Everything here is additive: on pi with only a `.pi/` directory the effective
27
+ * behaviour is identical to the pre-compat code path, which keeps upstream
28
+ * merges clean.
29
+ */
30
+
31
+ export type HostKind = "pi" | "omp";
32
+
33
+ /** Project config directory name per host. */
34
+ const CONFIG_DIR: Record<HostKind, string> = { pi: ".pi", omp: ".omp" };
35
+
36
+ /** Settings file names inside a config directory, lowest → highest precedence. */
37
+ const SETTINGS_FILES = ["settings.json", "config.yml", "config.yaml"] as const;
38
+
39
+ /**
40
+ * Detect which agent is hosting this extension.
41
+ *
42
+ * Ordered by reliability:
43
+ * 1. `LLM_WIKI_HOST` — explicit escape hatch (tests, exotic embeddings).
44
+ * 2. The agent directory path: pi resolves `~/.pi/agent`, omp `~/.omp/agent`.
45
+ * A `PI_CODING_AGENT_DIR` override that keeps the marker segment still
46
+ * classifies correctly; anything else falls through.
47
+ * 3. `OMP_PROFILE`, which omp sets on itself whenever a profile is active.
48
+ * 4. Default `pi` — the historical behaviour.
49
+ */
50
+ export function detectHost(): HostKind {
51
+ const forced = process.env.LLM_WIKI_HOST?.trim().toLowerCase();
52
+ if (forced === "omp" || forced === "pi") return forced;
53
+
54
+ let agentDir = "";
55
+ try {
56
+ agentDir = getAgentDir();
57
+ } catch {
58
+ agentDir = "";
59
+ }
60
+ if (agentDir) {
61
+ const segments = agentDir.split(/[\\/]/);
62
+ if (segments.includes(".omp")) return "omp";
63
+ if (segments.includes(".pi")) return "pi";
64
+ }
65
+
66
+ if (process.env.OMP_PROFILE) return "omp";
67
+ return "pi";
68
+ }
69
+
70
+ /**
71
+ * Every project settings file that may hold `llm-wiki` configuration, ordered
72
+ * from lowest to highest precedence so callers can merge left-to-right.
73
+ *
74
+ * The host's *native* directory is last (wins). The foreign directory is still
75
+ * read so a vault configured under pi keeps working after omp takes over the
76
+ * repository, and vice versa. Within a directory `config.yml` follows
77
+ * `settings.json`, matching omp's own project-settings precedence.
78
+ */
79
+ export function listProjectSettingsFiles(cwd: string, host: HostKind = detectHost()): string[] {
80
+ const foreign: HostKind = host === "omp" ? "pi" : "omp";
81
+ const files: string[] = [];
82
+ for (const kind of [foreign, host]) {
83
+ const dir = join(cwd, CONFIG_DIR[kind]);
84
+ for (const name of SETTINGS_FILES) files.push(join(dir, name));
85
+ }
86
+ return files;
87
+ }
88
+
89
+ /**
90
+ * User-level settings files, lowest → highest precedence.
91
+ *
92
+ * `getAgentDir()` already resolves per host (`~/.pi/agent` vs `~/.omp/agent`),
93
+ * so only the file names differ: omp migrates `settings.json` into `config.yml`
94
+ * on first start, and a migrated install has *only* the YAML file.
95
+ */
96
+ export function listGlobalSettingsFiles(): string[] {
97
+ let agentDir = "";
98
+ try {
99
+ agentDir = getAgentDir();
100
+ } catch {
101
+ return [];
102
+ }
103
+ if (!agentDir) return [];
104
+ return SETTINGS_FILES.map((name) => join(agentDir, name));
105
+ }
106
+
107
+ /**
108
+ * The project settings file this extension writes to.
109
+ *
110
+ * Always JSON (`settings.json`) — both hosts read it, and rewriting a user's
111
+ * hand-authored `config.yml` would destroy comments and formatting.
112
+ *
113
+ * Directory choice: an already-existing project config directory wins (so a
114
+ * repo that only has `.pi/` keeps a single settings file), otherwise the
115
+ * detected host's native directory is created.
116
+ */
117
+ export function resolveProjectSettingsPath(cwd: string, host: HostKind = detectHost()): string {
118
+ const native = join(cwd, CONFIG_DIR[host]);
119
+ if (existsSync(native)) return join(native, "settings.json");
120
+
121
+ const foreign = join(cwd, CONFIG_DIR[host === "omp" ? "pi" : "omp"]);
122
+ if (existsSync(foreign)) return join(foreign, "settings.json");
123
+
124
+ return join(native, "settings.json");
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,