pi-para 0.5.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 (198) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +305 -0
  3. package/SETUP.md +171 -0
  4. package/dist/capture.d.ts +34 -0
  5. package/dist/capture.d.ts.map +1 -0
  6. package/dist/capture.js +461 -0
  7. package/dist/capture.js.map +1 -0
  8. package/dist/cli.d.ts +15 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +405 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/commands.d.ts +15 -0
  13. package/dist/commands.d.ts.map +1 -0
  14. package/dist/commands.js +786 -0
  15. package/dist/commands.js.map +1 -0
  16. package/dist/context.d.ts +61 -0
  17. package/dist/context.d.ts.map +1 -0
  18. package/dist/context.js +289 -0
  19. package/dist/context.js.map +1 -0
  20. package/dist/daemon.d.ts +49 -0
  21. package/dist/daemon.d.ts.map +1 -0
  22. package/dist/daemon.js +226 -0
  23. package/dist/daemon.js.map +1 -0
  24. package/dist/frontmatter.d.ts +59 -0
  25. package/dist/frontmatter.d.ts.map +1 -0
  26. package/dist/frontmatter.js +170 -0
  27. package/dist/frontmatter.js.map +1 -0
  28. package/dist/gepa/dataset.d.ts +36 -0
  29. package/dist/gepa/dataset.d.ts.map +1 -0
  30. package/dist/gepa/dataset.js +148 -0
  31. package/dist/gepa/dataset.js.map +1 -0
  32. package/dist/gepa/evaluator.d.ts +51 -0
  33. package/dist/gepa/evaluator.d.ts.map +1 -0
  34. package/dist/gepa/evaluator.js +317 -0
  35. package/dist/gepa/evaluator.js.map +1 -0
  36. package/dist/gepa/index.d.ts +66 -0
  37. package/dist/gepa/index.d.ts.map +1 -0
  38. package/dist/gepa/index.js +240 -0
  39. package/dist/gepa/index.js.map +1 -0
  40. package/dist/gepa/llm.d.ts +35 -0
  41. package/dist/gepa/llm.d.ts.map +1 -0
  42. package/dist/gepa/llm.js +268 -0
  43. package/dist/gepa/llm.js.map +1 -0
  44. package/dist/gepa/optimized/capture-prompt.txt +65 -0
  45. package/dist/gepa/optimizer.d.ts +29 -0
  46. package/dist/gepa/optimizer.d.ts.map +1 -0
  47. package/dist/gepa/optimizer.js +249 -0
  48. package/dist/gepa/optimizer.js.map +1 -0
  49. package/dist/gepa/targets.d.ts +39 -0
  50. package/dist/gepa/targets.d.ts.map +1 -0
  51. package/dist/gepa/targets.js +326 -0
  52. package/dist/gepa/targets.js.map +1 -0
  53. package/dist/gepa/types.d.ts +168 -0
  54. package/dist/gepa/types.d.ts.map +1 -0
  55. package/dist/gepa/types.js +124 -0
  56. package/dist/gepa/types.js.map +1 -0
  57. package/dist/index.d.ts +21 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +294 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/ingest.d.ts +88 -0
  62. package/dist/ingest.d.ts.map +1 -0
  63. package/dist/ingest.js +105 -0
  64. package/dist/ingest.js.map +1 -0
  65. package/dist/link-discovery.d.ts +40 -0
  66. package/dist/link-discovery.d.ts.map +1 -0
  67. package/dist/link-discovery.js +122 -0
  68. package/dist/link-discovery.js.map +1 -0
  69. package/dist/link-utils.d.ts +30 -0
  70. package/dist/link-utils.d.ts.map +1 -0
  71. package/dist/link-utils.js +145 -0
  72. package/dist/link-utils.js.map +1 -0
  73. package/dist/lint.d.ts +39 -0
  74. package/dist/lint.d.ts.map +1 -0
  75. package/dist/lint.js +827 -0
  76. package/dist/lint.js.map +1 -0
  77. package/dist/maintainer.d.ts +24 -0
  78. package/dist/maintainer.d.ts.map +1 -0
  79. package/dist/maintainer.js +358 -0
  80. package/dist/maintainer.js.map +1 -0
  81. package/dist/processor.d.ts +14 -0
  82. package/dist/processor.d.ts.map +1 -0
  83. package/dist/processor.js +361 -0
  84. package/dist/processor.js.map +1 -0
  85. package/dist/query.d.ts +69 -0
  86. package/dist/query.d.ts.map +1 -0
  87. package/dist/query.js +107 -0
  88. package/dist/query.js.map +1 -0
  89. package/dist/raw.d.ts +57 -0
  90. package/dist/raw.d.ts.map +1 -0
  91. package/dist/raw.js +219 -0
  92. package/dist/raw.js.map +1 -0
  93. package/dist/redact.d.ts +21 -0
  94. package/dist/redact.d.ts.map +1 -0
  95. package/dist/redact.js +143 -0
  96. package/dist/redact.js.map +1 -0
  97. package/dist/scope.d.ts +54 -0
  98. package/dist/scope.d.ts.map +1 -0
  99. package/dist/scope.js +161 -0
  100. package/dist/scope.js.map +1 -0
  101. package/dist/scripts/bulk-link-fix.d.ts +9 -0
  102. package/dist/scripts/bulk-link-fix.d.ts.map +1 -0
  103. package/dist/scripts/bulk-link-fix.js +106 -0
  104. package/dist/scripts/bulk-link-fix.js.map +1 -0
  105. package/dist/session-tools.d.ts +33 -0
  106. package/dist/session-tools.d.ts.map +1 -0
  107. package/dist/session-tools.js +211 -0
  108. package/dist/session-tools.js.map +1 -0
  109. package/dist/state.d.ts +54 -0
  110. package/dist/state.d.ts.map +1 -0
  111. package/dist/state.js +176 -0
  112. package/dist/state.js.map +1 -0
  113. package/dist/store.d.ts +56 -0
  114. package/dist/store.d.ts.map +1 -0
  115. package/dist/store.js +227 -0
  116. package/dist/store.js.map +1 -0
  117. package/dist/summarize.d.ts +56 -0
  118. package/dist/summarize.d.ts.map +1 -0
  119. package/dist/summarize.js +231 -0
  120. package/dist/summarize.js.map +1 -0
  121. package/dist/tag-registry.d.ts +56 -0
  122. package/dist/tag-registry.d.ts.map +1 -0
  123. package/dist/tag-registry.js +146 -0
  124. package/dist/tag-registry.js.map +1 -0
  125. package/dist/templates/prompts.d.ts +63 -0
  126. package/dist/templates/prompts.d.ts.map +1 -0
  127. package/dist/templates/prompts.js +260 -0
  128. package/dist/templates/prompts.js.map +1 -0
  129. package/dist/tools.d.ts +26 -0
  130. package/dist/tools.d.ts.map +1 -0
  131. package/dist/tools.js +1033 -0
  132. package/dist/tools.js.map +1 -0
  133. package/dist/watcher.d.ts +25 -0
  134. package/dist/watcher.d.ts.map +1 -0
  135. package/dist/watcher.js +84 -0
  136. package/dist/watcher.js.map +1 -0
  137. package/dist/webui/client/dist/index.html +1885 -0
  138. package/dist/webui/server.d.ts +31 -0
  139. package/dist/webui/server.d.ts.map +1 -0
  140. package/dist/webui/server.js +566 -0
  141. package/dist/webui/server.js.map +1 -0
  142. package/dist/wiki.d.ts +103 -0
  143. package/dist/wiki.d.ts.map +1 -0
  144. package/dist/wiki.js +428 -0
  145. package/dist/wiki.js.map +1 -0
  146. package/package.json +142 -0
  147. package/scripts/gepa/__pycache__/dataset.cpython-313.pyc +0 -0
  148. package/scripts/gepa/__pycache__/lm_providers.cpython-313.pyc +0 -0
  149. package/scripts/gepa/__pycache__/metric.cpython-313.pyc +0 -0
  150. package/scripts/gepa/__pycache__/program.cpython-313.pyc +0 -0
  151. package/scripts/gepa/__pycache__/wiki_reader.cpython-313.pyc +0 -0
  152. package/scripts/gepa/dataset.py +192 -0
  153. package/scripts/gepa/lm_providers.py +472 -0
  154. package/scripts/gepa/metric.py +279 -0
  155. package/scripts/gepa/optimize.py +367 -0
  156. package/scripts/gepa/program.py +99 -0
  157. package/scripts/gepa/pyproject.toml +19 -0
  158. package/scripts/gepa/run-all.sh +53 -0
  159. package/scripts/gepa/uv.lock +2241 -0
  160. package/scripts/gepa/wiki_reader.py +113 -0
  161. package/skills/para/SKILL.md +77 -0
  162. package/skills/setup/SKILL.md +20 -0
  163. package/src/capture.ts +563 -0
  164. package/src/cli.ts +416 -0
  165. package/src/commands.ts +878 -0
  166. package/src/context.ts +390 -0
  167. package/src/daemon.ts +288 -0
  168. package/src/frontmatter.ts +219 -0
  169. package/src/gepa/index.ts +287 -0
  170. package/src/gepa/optimized/capture-prompt.txt +65 -0
  171. package/src/index.ts +384 -0
  172. package/src/ingest.ts +177 -0
  173. package/src/link-discovery.ts +170 -0
  174. package/src/link-utils.ts +169 -0
  175. package/src/lint.ts +1018 -0
  176. package/src/maintainer.ts +404 -0
  177. package/src/processor.ts +415 -0
  178. package/src/python/__pycache__/minimax_lm.cpython-312.pyc +0 -0
  179. package/src/python/minimax_lm.py +198 -0
  180. package/src/python/rlm_capture.py +215 -0
  181. package/src/query.ts +148 -0
  182. package/src/raw.ts +276 -0
  183. package/src/redact.ts +159 -0
  184. package/src/scope.ts +214 -0
  185. package/src/scripts/bulk-link-fix.ts +126 -0
  186. package/src/session-tools.ts +260 -0
  187. package/src/state.ts +271 -0
  188. package/src/store.ts +288 -0
  189. package/src/summarize.ts +273 -0
  190. package/src/tag-registry.ts +157 -0
  191. package/src/templates/index.md +17 -0
  192. package/src/templates/prompts.ts +274 -0
  193. package/src/templates/schema.md +116 -0
  194. package/src/tools.ts +1293 -0
  195. package/src/watcher.ts +101 -0
  196. package/src/webui/client/dist/index.html +1885 -0
  197. package/src/webui/server.ts +765 -0
  198. package/src/wiki.ts +550 -0
@@ -0,0 +1,878 @@
1
+ /**
2
+ * Slash command registrations for user interaction.
3
+ *
4
+ * Commands: /wiki, /wiki-ingest, /wiki-lint, /wiki-capture,
5
+ * /wiki-scope, /wiki-search, /wiki-summarize,
6
+ * /wiki-daemon, /wiki-settings
7
+ */
8
+
9
+ import { readFile, appendFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+
12
+ import type { QMDStore } from "qmd-engine";
13
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
14
+
15
+ import type { ProjectScope } from "./scope.js";
16
+ import type { LintReport } from "./lint.js";
17
+ import { lintWiki } from "./lint.js";
18
+ import { listPages, readPage, writePage, movePage, writeIndex, PARA_CATEGORIES } from "./wiki.js";
19
+ import type { ParaCategory, PageRef } from "./wiki.js";
20
+ import { searchWiki } from "./store.js";
21
+ import {
22
+ CURRENT_SCHEMA_VERSION,
23
+ migrateToLatest,
24
+ parseFrontmatter,
25
+ validateFrontmatter,
26
+ } from "./frontmatter.js";
27
+
28
+ // -- Helpers -----------------------------------------------------------------
29
+
30
+ /** Convert raw text to a kebab-case slug. */
31
+ function toKebabSlug(raw: string): string {
32
+ return raw
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9-]/g, "-")
35
+ .replace(/-+/g, "-")
36
+ .replace(/^-|-$/g, "");
37
+ }
38
+
39
+ /** Convert a slug to title case. */
40
+ function slugToTitle(slug: string): string {
41
+ return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
42
+ }
43
+
44
+ /** Rebuild index.md from all pages on disk. */
45
+ async function rebuildIndexFromDisk(wikiDir: string): Promise<void> {
46
+ const allPages = await listPages(wikiDir);
47
+ const sections: Record<ParaCategory, string[]> = {
48
+ projects: [],
49
+ areas: [],
50
+ resources: [],
51
+ archives: [],
52
+ };
53
+ for (const ref of allPages) {
54
+ const page = await readPage(wikiDir, ref.category, ref.slug);
55
+ const title = page?.frontmatter.title ?? ref.title;
56
+ const summary =
57
+ page?.body
58
+ .split("\n")
59
+ .find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("---"))
60
+ ?.trim() ?? "";
61
+ const desc = summary.length > 120 ? summary.slice(0, 117) + "..." : summary;
62
+ sections[ref.category].push(
63
+ `- [[${ref.slug}]] \u2014 ${title}${desc ? ": " + desc : ""}`,
64
+ );
65
+ }
66
+ const indexLines = [
67
+ "# Wiki Index",
68
+ "",
69
+ "## Projects",
70
+ "",
71
+ sections.projects.length > 0
72
+ ? sections.projects.join("\n")
73
+ : "_No active projects yet._",
74
+ "",
75
+ "## Areas",
76
+ "",
77
+ sections.areas.length > 0
78
+ ? sections.areas.join("\n")
79
+ : "_No areas defined yet._",
80
+ "",
81
+ "## Resources",
82
+ "",
83
+ sections.resources.length > 0
84
+ ? sections.resources.join("\n")
85
+ : "_No resources yet._",
86
+ "",
87
+ "## Archives",
88
+ "",
89
+ sections.archives.length > 0
90
+ ? sections.archives.join("\n")
91
+ : "_No archived items._",
92
+ ];
93
+ await writeIndex(wikiDir, indexLines.join("\n"));
94
+ }
95
+
96
+ /** Parse log.md and return the last N entries as text lines. */
97
+ async function readLastLogEntries(
98
+ wikiDir: string,
99
+ count: number,
100
+ ): Promise<string[]> {
101
+ let content: string;
102
+ try {
103
+ content = await readFile(join(wikiDir, "log.md"), "utf-8");
104
+ } catch {
105
+ return [];
106
+ }
107
+
108
+ // Find all entry start positions (each begins with "## [")
109
+ const starts: number[] = [];
110
+ const pattern = /^## \[/gm;
111
+ let match: RegExpExecArray | null;
112
+ while ((match = pattern.exec(content)) !== null) {
113
+ starts.push(match.index);
114
+ }
115
+
116
+ // Extract the last `count` entries
117
+ const selected = starts.slice(-count);
118
+ const entries: string[] = [];
119
+ for (let i = 0; i < selected.length; i++) {
120
+ const start = selected[i];
121
+ const nextIdx = starts.indexOf(start) + 1;
122
+ const end = nextIdx < starts.length ? starts[nextIdx] : content.length;
123
+ entries.push(content.slice(start, end).trim());
124
+ }
125
+
126
+ return entries;
127
+ }
128
+
129
+ /** Format lint report for display via notify. */
130
+ function formatLintNotify(report: LintReport): string {
131
+ const { issues, fixed, stats } = report;
132
+ const lines: string[] = [];
133
+
134
+ lines.push(`Pages: ${stats.totalPages} (${PARA_CATEGORIES.map((c) => `${c}: ${stats.byCategory[c]}`).join(", ")})`);
135
+ lines.push(`Links: ${stats.totalLinks} | Broken: ${stats.brokenLinks} | Orphans: ${stats.orphanPages}`);
136
+
137
+ if (fixed.length > 0) {
138
+ lines.push(`Auto-fixed: ${fixed.length} issue(s)`);
139
+ for (const f of fixed.slice(0, 5)) {
140
+ lines.push(` [fix] ${f.page ?? ""}: ${f.message}`);
141
+ }
142
+ if (fixed.length > 5) {
143
+ lines.push(` ... and ${fixed.length - 5} more`);
144
+ }
145
+ }
146
+
147
+ if (issues.length > 0) {
148
+ lines.push(`Remaining: ${issues.length} issue(s)`);
149
+ for (const issue of issues.slice(0, 10)) {
150
+ lines.push(` [${issue.severity}] ${issue.page ?? issue.category}: ${issue.message}`);
151
+ }
152
+ if (issues.length > 10) {
153
+ lines.push(` ... and ${issues.length - 10} more`);
154
+ }
155
+ } else {
156
+ lines.push("Wiki is healthy.");
157
+ }
158
+
159
+ return lines.join("\n");
160
+ }
161
+
162
+ // -- Public API --------------------------------------------------------------
163
+
164
+ /**
165
+ * Register all slash commands with the pi extension API.
166
+ */
167
+ export function registerCommands(
168
+ pi: ExtensionAPI,
169
+ wikiDir: string,
170
+ store: QMDStore,
171
+ getScope: () => ProjectScope,
172
+ setScope: (scope: ProjectScope) => void,
173
+ ): void {
174
+ // ---- /wiki — status overview ---------------------------------------------
175
+
176
+ pi.registerCommand("wiki", {
177
+ description: "Show wiki status overview (scope, page counts, recent log entries)",
178
+ handler: async (_args, ctx) => {
179
+ const scope = getScope();
180
+
181
+ // Page counts by category
182
+ const allPages = await listPages(wikiDir);
183
+ const byCat: Record<string, number> = {};
184
+ for (const cat of PARA_CATEGORIES) {
185
+ byCat[cat] = 0;
186
+ }
187
+ for (const page of allPages) {
188
+ byCat[page.category] = (byCat[page.category] ?? 0) + 1;
189
+ }
190
+
191
+ // Last 5 log entries
192
+ const logEntries = await readLastLogEntries(wikiDir, 5);
193
+
194
+ const lines: string[] = [];
195
+ lines.push(`Scope: ${scope.name} (${scope.source})`);
196
+ lines.push(` include: ${scope.include.join(", ")}`);
197
+ if (scope.exclude.length > 0) {
198
+ lines.push(` exclude: ${scope.exclude.join(", ")}`);
199
+ }
200
+ lines.push("");
201
+ lines.push(`Pages: ${allPages.length} total`);
202
+ for (const cat of PARA_CATEGORIES) {
203
+ lines.push(` ${cat}: ${byCat[cat]}`);
204
+ }
205
+ lines.push("");
206
+
207
+ if (logEntries.length > 0) {
208
+ lines.push("Recent activity:");
209
+ for (const entry of logEntries) {
210
+ // Compact each entry to one line
211
+ const firstLine = entry.split("\n")[0];
212
+ lines.push(` ${firstLine.replace(/^## /, "")}`);
213
+ }
214
+ } else {
215
+ lines.push("No activity logged yet.");
216
+ }
217
+
218
+ ctx.ui.notify(lines.join("\n"), "info");
219
+ },
220
+ });
221
+
222
+ // ---- /wiki-ingest — quick ingest shortcut --------------------------------
223
+
224
+ pi.registerCommand("wiki-ingest", {
225
+ description: "Ingest a URL or file into the wiki",
226
+ handler: async (args, ctx) => {
227
+ const source = args.trim();
228
+ if (!source) {
229
+ ctx.ui.notify("Usage: /wiki-ingest <url-or-file>", "error");
230
+ return;
231
+ }
232
+
233
+ await ctx.waitForIdle();
234
+ pi.sendUserMessage(`Ingest this into the wiki: ${source}`);
235
+ },
236
+ });
237
+
238
+ // ---- /wiki-lint — run lint checks ----------------------------------------
239
+
240
+ pi.registerCommand("wiki-lint", {
241
+ description: "Run wiki health checks (--report-only to skip auto-fix)",
242
+ getArgumentCompletions: (prefix) => {
243
+ if ("--report-only".startsWith(prefix)) {
244
+ return [{ value: "--report-only", label: "--report-only" }];
245
+ }
246
+ return null;
247
+ },
248
+ handler: async (args, ctx) => {
249
+ const reportOnly = args.trim() === "--report-only";
250
+
251
+ ctx.ui.notify("Running wiki lint...", "info");
252
+
253
+ const report = await lintWiki(wikiDir, {
254
+ autoFix: !reportOnly,
255
+ });
256
+
257
+ ctx.ui.notify(formatLintNotify(report), "info");
258
+ },
259
+ });
260
+
261
+ // ---- /wiki-capture — capture via session LLM ----------------------------
262
+
263
+ pi.registerCommand("wiki-capture", {
264
+ description: "Capture knowledge from this session into the wiki (runs in background via daemon)",
265
+ handler: async (args, ctx) => {
266
+ const sessionFile = ctx.sessionManager.getSessionFile();
267
+ if (!sessionFile) {
268
+ ctx.ui.notify("No session file found", "warning");
269
+ return;
270
+ }
271
+
272
+ // Register the session for daemon capture — runs in background, doesn't block user
273
+ try {
274
+ const registry = join(wikiDir, ".completed-sessions");
275
+ const entry = `${new Date().toISOString()}|${sessionFile}\n`;
276
+ await appendFile(registry, entry);
277
+
278
+ ctx.ui.notify(
279
+ "Session queued for wiki capture (daemon will process in background).\n" +
280
+ "Check progress: /wiki-daemon status\n" +
281
+ "Tip: Set \"daemonModel\" in ~/.pi/wiki/config.json for better capture quality.",
282
+ "info",
283
+ );
284
+ ctx.ui.setStatus("pi-para", "wiki: capture queued");
285
+ setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 5000);
286
+ } catch (err) {
287
+ ctx.ui.notify(
288
+ `Failed to queue capture: ${err instanceof Error ? err.message : String(err)}`,
289
+ "error",
290
+ );
291
+ }
292
+ },
293
+ });
294
+
295
+ // ---- /wiki-scope — show or override scope --------------------------------
296
+
297
+ pi.registerCommand("wiki-scope", {
298
+ description: "Show or override the current project scope",
299
+ handler: async (args, ctx) => {
300
+ const override = args.trim();
301
+
302
+ if (!override) {
303
+ // Show current scope
304
+ const scope = getScope();
305
+ const lines = [
306
+ `Scope: ${scope.name}`,
307
+ `Source: ${scope.source}`,
308
+ `Include: ${scope.include.join(", ")}`,
309
+ ];
310
+ if (scope.exclude.length > 0) {
311
+ lines.push(`Exclude: ${scope.exclude.join(", ")}`);
312
+ }
313
+ ctx.ui.notify(lines.join("\n"), "info");
314
+ return;
315
+ }
316
+
317
+ // Override scope for this session
318
+ const newScope: ProjectScope = {
319
+ name: override,
320
+ include: [override],
321
+ exclude: [],
322
+ source: "config", // treated as explicit override
323
+ };
324
+ setScope(newScope);
325
+ ctx.ui.notify(`Scope overridden to: ${override}`, "info");
326
+ },
327
+ });
328
+
329
+ // ---- /wiki-search — quick search -----------------------------------------
330
+
331
+ pi.registerCommand("wiki-search", {
332
+ description: "Search the wiki knowledge base",
333
+ handler: async (args, ctx) => {
334
+ const query = args.trim();
335
+ if (!query) {
336
+ ctx.ui.notify("Usage: /wiki-search <query>", "error");
337
+ return;
338
+ }
339
+
340
+ const scope = getScope();
341
+ const results = await searchWiki(store, query, {
342
+ scope,
343
+ limit: 10,
344
+ });
345
+
346
+ if (results.length === 0) {
347
+ ctx.ui.notify(`No results for "${query}"`, "info");
348
+ return;
349
+ }
350
+
351
+ const lines: string[] = [`Search: "${query}" (${results.length} result(s))\n`];
352
+ for (const r of results) {
353
+ const fm = r.frontmatter;
354
+ const scorePct = (r.score * 100).toFixed(0);
355
+ lines.push(`[${scorePct}%] ${fm.title} (${r.page.path})`);
356
+ lines.push(` ${fm.para} | ${fm.scope.join(", ")} | ${fm.tags.join(", ")}`);
357
+ // Show first 120 chars of snippet
358
+ const snippet = r.snippet.replace(/\n/g, " ").trim();
359
+ if (snippet) {
360
+ lines.push(` ${snippet.length > 120 ? snippet.slice(0, 117) + "..." : snippet}`);
361
+ }
362
+ lines.push("");
363
+ }
364
+
365
+ ctx.ui.notify(lines.join("\n"), "info");
366
+ },
367
+ });
368
+
369
+ // ---- /wiki-summarize — summarize pages/categories/wiki -------------------
370
+
371
+ pi.registerCommand("wiki-summarize", {
372
+ description: "Summarize a page, category, or the entire wiki",
373
+ getArgumentCompletions: (prefix) => {
374
+ // Offer category names and "all" as completions
375
+ const options = [...PARA_CATEGORIES, "all"];
376
+ const filtered = options.filter((o) => o.startsWith(prefix));
377
+ return filtered.length > 0
378
+ ? filtered.map((o) => ({ value: o, label: o }))
379
+ : null;
380
+ },
381
+ handler: async (args, ctx) => {
382
+ const target = args.trim() || "all";
383
+
384
+ await ctx.waitForIdle();
385
+ pi.sendUserMessage(`Summarize the wiki: target="${target}". Use wiki_summarize tool with target="${target}".`);
386
+ },
387
+ });
388
+
389
+ // ---- /wiki-daemon — daemon management ------------------------------------
390
+
391
+ pi.registerCommand("wiki-daemon", {
392
+ description: "Check daemon status and capture history",
393
+ getArgumentCompletions: (prefix) => {
394
+ const cmds = ["status", "history"];
395
+ const filtered = cmds.filter((c) => c.startsWith(prefix));
396
+ return filtered.length > 0 ? filtered.map((c) => ({ value: c, label: c })) : null;
397
+ },
398
+ handler: async (args, ctx) => {
399
+ const subcmd = args.trim().split(/\s+/)[0] || "status";
400
+
401
+ // Read state directly from the state DB — fast, no subprocess needed
402
+ const { StateDB } = await import("./state.js");
403
+ const state = new StateDB(wikiDir);
404
+
405
+ try {
406
+ switch (subcmd) {
407
+ case "status": {
408
+ const pid = state.getState("daemon_pid");
409
+ const startedAt = state.getState("daemon_started_at");
410
+ const history = state.getHistory(undefined, 5);
411
+ const failed = state.getFailed();
412
+
413
+ let running = false;
414
+ if (pid) {
415
+ try { process.kill(parseInt(pid), 0); running = true; } catch {}
416
+ }
417
+
418
+ const lines = [
419
+ `Daemon: ${running ? `running (PID ${pid})` : "not running"}`,
420
+ startedAt ? `Started: ${startedAt}` : "",
421
+ `Failed: ${failed.length}`,
422
+ "",
423
+ "Recent captures:",
424
+ ];
425
+ if (history.length === 0) {
426
+ lines.push(" (none)");
427
+ } else {
428
+ for (const h of history) {
429
+ const status = h.error
430
+ ? `ERROR: ${h.error.slice(0, 50)}`
431
+ : h.pagesCreated.length > 0
432
+ ? `${h.pagesCreated.length} page(s)`
433
+ : "skipped";
434
+ lines.push(` ${h.processedAt.slice(0, 19)} | ${h.scope} | ${status}`);
435
+ }
436
+ }
437
+ ctx.ui.notify(lines.filter(Boolean).join("\n"), "info");
438
+ break;
439
+ }
440
+ case "history": {
441
+ const scope = getScope();
442
+ const history = state.getHistory(scope.name, 15);
443
+
444
+ if (history.length === 0) {
445
+ ctx.ui.notify(`No capture history for scope: ${scope.name}`, "info");
446
+ break;
447
+ }
448
+
449
+ const lines = [`Capture history (${scope.name}):\n`];
450
+ for (const h of history) {
451
+ const status = h.error
452
+ ? `ERROR: ${h.error.slice(0, 60)}`
453
+ : h.pagesCreated.length > 0
454
+ ? `${h.pagesCreated.length} page(s): ${h.pagesCreated.join(", ")}`
455
+ : "skipped";
456
+ lines.push(`${h.processedAt.slice(0, 19)} | ${status}`);
457
+ }
458
+ ctx.ui.notify(lines.join("\n"), "info");
459
+ break;
460
+ }
461
+ default:
462
+ ctx.ui.notify(`Usage: /wiki-daemon [status|history]`, "error");
463
+ }
464
+ } finally {
465
+ state.close();
466
+ }
467
+ },
468
+ });
469
+
470
+ // ---- /wiki-migrate — batch schema migration ------------------------------
471
+
472
+ pi.registerCommand("wiki-migrate", {
473
+ description: "Migrate all wiki pages to the current schema version",
474
+ handler: async (_args, ctx) => {
475
+ ctx.ui.notify(`Migrating wiki pages to schema version ${CURRENT_SCHEMA_VERSION}...`, "info");
476
+
477
+ const allPages = await listPages(wikiDir);
478
+ let migratedCount = 0;
479
+
480
+ for (const ref of allPages) {
481
+ const page = await readPage(wikiDir, ref.category, ref.slug);
482
+ if (!page) continue;
483
+
484
+ const version = page.frontmatter.schemaVersion ?? 1;
485
+ if (version >= CURRENT_SCHEMA_VERSION) continue;
486
+
487
+ const rawObj = page.frontmatter as unknown as Record<string, unknown>;
488
+ const result = migrateToLatest(rawObj, page.body);
489
+
490
+ const migratedFm = result.fm as unknown as import("./wiki.js").PageFrontmatter;
491
+ migratedFm.updated = new Date().toISOString();
492
+ await writePage(wikiDir, {
493
+ category: ref.category,
494
+ slug: ref.slug,
495
+ frontmatter: migratedFm,
496
+ body: result.body,
497
+ });
498
+ migratedCount++;
499
+ }
500
+
501
+ ctx.ui.notify(
502
+ migratedCount > 0
503
+ ? `Migrated ${migratedCount} page(s) to schema version ${CURRENT_SCHEMA_VERSION}.`
504
+ : `All ${allPages.length} page(s) already at schema version ${CURRENT_SCHEMA_VERSION}.`,
505
+ "info",
506
+ );
507
+ },
508
+ });
509
+
510
+ // ---- /wiki-project — project lifecycle management ------------------------
511
+
512
+ pi.registerCommand("wiki-project", {
513
+ description:
514
+ "Create a project page (/wiki-project <name> <goal>) or archive it (/wiki-project done <name>)",
515
+ handler: async (args, ctx) => {
516
+ const parts = args.trim().split(/\s+/);
517
+
518
+ if (parts.length === 0 || !parts[0]) {
519
+ ctx.ui.notify(
520
+ "Usage: /wiki-project <name> <goal>\n /wiki-project done <name>",
521
+ "error",
522
+ );
523
+ return;
524
+ }
525
+
526
+ if (parts[0] === "done") {
527
+ // ---------- Archive a project ----------
528
+ const rawName = parts.slice(1).join("-");
529
+ if (!rawName) {
530
+ ctx.ui.notify("Usage: /wiki-project done <name>", "error");
531
+ return;
532
+ }
533
+
534
+ const slug = toKebabSlug(rawName);
535
+ if (!slug) {
536
+ ctx.ui.notify(
537
+ "Invalid project name — must contain at least one alphanumeric character.",
538
+ "error",
539
+ );
540
+ return;
541
+ }
542
+
543
+ const page = await readPage(wikiDir, "projects", slug);
544
+ if (!page) {
545
+ ctx.ui.notify(`Project not found: projects/${slug}`, "error");
546
+ return;
547
+ }
548
+
549
+ const ref: PageRef = {
550
+ category: "projects",
551
+ slug,
552
+ title: page.frontmatter.title,
553
+ path: `projects/${slug}.md`,
554
+ };
555
+ await movePage(wikiDir, ref, "archives");
556
+ await rebuildIndexFromDisk(wikiDir);
557
+
558
+ ctx.ui.notify(
559
+ `Archived project: ${page.frontmatter.title} → archives/${slug}`,
560
+ "info",
561
+ );
562
+ return;
563
+ }
564
+
565
+ // ---------- Create a new project ----------
566
+ const rawName = parts[0];
567
+ const goal = parts.slice(1).join(" ");
568
+
569
+ if (!goal) {
570
+ ctx.ui.notify(
571
+ "Usage: /wiki-project <name> <goal>\n /wiki-project done <name>",
572
+ "error",
573
+ );
574
+ return;
575
+ }
576
+
577
+ const slug = toKebabSlug(rawName);
578
+ if (!slug) {
579
+ ctx.ui.notify(
580
+ "Invalid project name — must contain at least one alphanumeric character.",
581
+ "error",
582
+ );
583
+ return;
584
+ }
585
+
586
+ const title = slugToTitle(slug);
587
+ const scope = getScope();
588
+ const now = new Date().toISOString();
589
+
590
+ const body = `# ${title}
591
+
592
+ ## Goal
593
+ ${goal}
594
+
595
+ ## Status
596
+ - [ ] Define scope and milestones
597
+ - [ ] Implementation
598
+ - [ ] Verification
599
+
600
+ ## End Condition
601
+ ${goal} — verified and complete.
602
+
603
+ ## Connections
604
+ (add related wiki pages here)`;
605
+
606
+ const frontmatter = validateFrontmatter({
607
+ title,
608
+ para: "projects",
609
+ scope: [scope.name],
610
+ tags: [],
611
+ sources: [],
612
+ created: now,
613
+ updated: now,
614
+ links: [],
615
+ });
616
+
617
+ await writePage(wikiDir, {
618
+ category: "projects",
619
+ slug,
620
+ frontmatter,
621
+ body,
622
+ });
623
+
624
+ await rebuildIndexFromDisk(wikiDir);
625
+
626
+ ctx.ui.notify(
627
+ `Created project: projects/${slug} — "${title}"`,
628
+ "info",
629
+ );
630
+ },
631
+ });
632
+
633
+ // ---- /wiki-settings — interactive configuration --------------------------
634
+
635
+ pi.registerCommand("wiki-settings", {
636
+ description: "View and edit wiki configuration",
637
+ handler: async (_args, ctx) => {
638
+ if (!ctx.hasUI) {
639
+ ctx.ui.notify("/wiki-settings requires interactive mode", "error");
640
+ return;
641
+ }
642
+
643
+ const { writeFile: writeFileAsync } = await import("node:fs/promises");
644
+ const { parse, stringify } = await import("yaml");
645
+ const { homedir } = await import("node:os");
646
+ const { execSync } = await import("node:child_process");
647
+
648
+ // Load current config
649
+ const configPath = join(wikiDir, "config.json");
650
+ let config: Record<string, unknown> = {};
651
+ try {
652
+ config = JSON.parse(await readFile(configPath, "utf-8"));
653
+ } catch { /* defaults */ }
654
+
655
+ // Load qmd config
656
+ const qmdPath = join(homedir(), ".config", "qmd", "index.yml");
657
+ let qmdConfig: Record<string, unknown> = {};
658
+ try {
659
+ qmdConfig = parse(await readFile(qmdPath, "utf-8")) ?? {};
660
+ } catch { /* no qmd config */ }
661
+ const providers = (qmdConfig.providers ?? {}) as Record<string, Record<string, string>>;
662
+
663
+ // Check statuses
664
+ let qmdVersion = "";
665
+ try { qmdVersion = execSync("qmd --version 2>/dev/null", { encoding: "utf-8" }).trim(); } catch {}
666
+ let daemonStatus = "not running";
667
+ try { daemonStatus = execSync("systemctl --user is-active pi-para-daemon 2>/dev/null", { encoding: "utf-8" }).trim(); } catch {}
668
+
669
+ // Main menu loop
670
+ while (true) {
671
+ const scope = getScope();
672
+ const pageCount = (await listPages(wikiDir).catch(() => [])).length;
673
+
674
+ const choice = await ctx.ui.select("Wiki Settings", [
675
+ `[Context] Max tokens: ${config.contextMaxTokens ?? 4000}`,
676
+ `[Search] Limit: ${config.searchLimit ?? 10}, Graph boost: ${config.searchGraphBoost ?? true}`,
677
+ `[Lint] Auto-fix: ${config.lintAutoFix ?? true}, Stale days: ${config.lintStaleDays ?? 90}`,
678
+ `[Daemon] Model: ${config.daemonModel ?? "auto"}`,
679
+ `[WebWiki] ${(config as any).webWiki?.enabled ? `Enabled at http://${(config as any).webWiki?.host ?? "0.0.0.0"}:${(config as any).webWiki?.port ?? 10973}` : "Disabled"}`,
680
+ `[Embed] ${providers.embed?.model ?? "not configured"} ${providers.embed?.url ? "at " + providers.embed.url : ""}`,
681
+ `[Chat] ${providers.chat?.model ?? "not configured"} ${providers.chat?.url ? "at " + providers.chat.url : ""}`,
682
+ `[Rerank] ${providers.rerank?.model ?? "not configured"} ${providers.rerank?.url ? "at " + providers.rerank.url : ""}`,
683
+ `---`,
684
+ `[Status] Scope: ${scope.name} | Pages: ${pageCount} | qmd: ${qmdVersion || "not installed"} | Daemon: ${daemonStatus}`,
685
+ ]);
686
+
687
+ if (!choice) break;
688
+
689
+ if (choice.startsWith("[Context]")) {
690
+ const val = await ctx.ui.input("Context max tokens:", String(config.contextMaxTokens ?? 4000));
691
+ if (val) {
692
+ config.contextMaxTokens = parseInt(val) || 4000;
693
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
694
+ ctx.ui.notify(`Set contextMaxTokens = ${config.contextMaxTokens}`, "info");
695
+ }
696
+ } else if (choice.startsWith("[Search]")) {
697
+ const sub = await ctx.ui.select("Search Settings", [
698
+ `Limit: ${config.searchLimit ?? 10}`,
699
+ `Graph boost: ${config.searchGraphBoost ?? true}`,
700
+ ]);
701
+ if (sub?.startsWith("Limit")) {
702
+ const val = await ctx.ui.input("Search result limit:", String(config.searchLimit ?? 10));
703
+ if (val) {
704
+ config.searchLimit = parseInt(val) || 10;
705
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
706
+ ctx.ui.notify(`Set searchLimit = ${config.searchLimit}`, "info");
707
+ }
708
+ } else if (sub?.startsWith("Graph boost")) {
709
+ config.searchGraphBoost = !(config.searchGraphBoost ?? true);
710
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
711
+ ctx.ui.notify(`Set searchGraphBoost = ${config.searchGraphBoost}`, "info");
712
+ }
713
+ } else if (choice.startsWith("[Lint]")) {
714
+ const sub = await ctx.ui.select("Lint Settings", [
715
+ `Auto-fix: ${config.lintAutoFix ?? true}`,
716
+ `Stale days: ${config.lintStaleDays ?? 90}`,
717
+ ]);
718
+ if (sub?.startsWith("Auto-fix")) {
719
+ config.lintAutoFix = !(config.lintAutoFix ?? true);
720
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
721
+ ctx.ui.notify(`Set lintAutoFix = ${config.lintAutoFix}`, "info");
722
+ } else if (sub?.startsWith("Stale")) {
723
+ const val = await ctx.ui.input("Stale days threshold:", String(config.lintStaleDays ?? 90));
724
+ if (val) {
725
+ config.lintStaleDays = parseInt(val) || 90;
726
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
727
+ ctx.ui.notify(`Set lintStaleDays = ${config.lintStaleDays}`, "info");
728
+ }
729
+ }
730
+ } else if (choice.startsWith("[Daemon]")) {
731
+ try {
732
+ const { getProviders, getModels } = await import("@mariozechner/pi-ai");
733
+ const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
734
+ const authStorage = AuthStorage.create();
735
+
736
+ // Find providers that have auth configured
737
+ const allProviders = getProviders();
738
+ const availableProviders: string[] = [];
739
+ for (const p of allProviders) {
740
+ if (authStorage.hasAuth(p)) availableProviders.push(p);
741
+ }
742
+
743
+ if (availableProviders.length === 0) {
744
+ ctx.ui.notify("No providers configured. Log in with pi first.", "warning");
745
+ } else {
746
+ // Let user pick provider
747
+ const providerChoice = await ctx.ui.select(
748
+ "Select provider",
749
+ ["auto (best available)", ...availableProviders],
750
+ );
751
+
752
+ if (providerChoice === "auto (best available)") {
753
+ config.daemonModel = null;
754
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
755
+ try { execSync("systemctl --user restart pi-para-daemon 2>/dev/null"); } catch {}
756
+ ctx.ui.notify("Set daemonModel = auto (daemon restarted)", "info");
757
+ } else if (providerChoice) {
758
+ // Show models for selected provider
759
+ const models = getModels(providerChoice as any)
760
+ .sort((a: any, b: any) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
761
+ const modelOptions = models.map((m: any) =>
762
+ `${m.id} (${Math.round((m.contextWindow ?? 0) / 1000)}k ctx${m.reasoning ? ", reasoning" : ""})`
763
+ );
764
+ const modelChoice = await ctx.ui.select(
765
+ `Select ${providerChoice} model`,
766
+ modelOptions,
767
+ );
768
+ if (modelChoice) {
769
+ const modelId = modelChoice.split(" (")[0];
770
+ config.daemonModel = `${providerChoice}/${modelId}`;
771
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
772
+ try { execSync("systemctl --user restart pi-para-daemon 2>/dev/null"); } catch {}
773
+ ctx.ui.notify(`Set daemonModel = ${config.daemonModel} (daemon restarted)`, "info");
774
+ }
775
+ }
776
+ }
777
+ } catch (err) {
778
+ // Fallback to manual input if AuthStorage not available
779
+ const val = await ctx.ui.input(
780
+ "Daemon model (provider/model-id, or empty for auto):",
781
+ config.daemonModel ? String(config.daemonModel) : "",
782
+ );
783
+ config.daemonModel = val?.trim() || null;
784
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
785
+ try { const { execSync: exec } = await import("node:child_process"); exec("systemctl --user restart pi-para-daemon 2>/dev/null"); } catch {}
786
+ ctx.ui.notify(`Set daemonModel = ${config.daemonModel ?? "auto"} (daemon restarted)`, "info");
787
+ }
788
+ } else if (choice.startsWith("[WebWiki]")) {
789
+ const webWiki = (config as any).webWiki ?? { enabled: false, host: "0.0.0.0", port: 10973 };
790
+ const sub = await ctx.ui.select("Web Wiki Settings", [
791
+ `Enabled: ${webWiki.enabled}`,
792
+ `Host: ${webWiki.host}`,
793
+ `Port: ${webWiki.port}`,
794
+ ]);
795
+ if (sub?.startsWith("Enabled")) {
796
+ webWiki.enabled = !webWiki.enabled;
797
+ (config as any).webWiki = webWiki;
798
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
799
+ if (webWiki.enabled) {
800
+ // Detect LAN IP
801
+ const { networkInterfaces } = await import("node:os");
802
+ const nets = networkInterfaces();
803
+ let lanIp = webWiki.host;
804
+ for (const ifaces of Object.values(nets)) {
805
+ for (const iface of ifaces ?? []) {
806
+ if (iface.family === "IPv4" && !iface.internal) {
807
+ lanIp = iface.address;
808
+ break;
809
+ }
810
+ }
811
+ }
812
+ ctx.ui.notify(`Web Wiki enabled at http://${lanIp}:${webWiki.port}\nRestart pi to apply.`, "info");
813
+ } else {
814
+ ctx.ui.notify("Web Wiki disabled. Restart pi to apply.", "info");
815
+ }
816
+ } else if (sub?.startsWith("Host")) {
817
+ const val = await ctx.ui.input("Host (0.0.0.0 for LAN, 127.0.0.1 for local only):", webWiki.host);
818
+ if (val) {
819
+ webWiki.host = val;
820
+ (config as any).webWiki = webWiki;
821
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
822
+ ctx.ui.notify(`Set webWiki.host = ${webWiki.host}`, "info");
823
+ }
824
+ } else if (sub?.startsWith("Port")) {
825
+ const val = await ctx.ui.input("Port:", String(webWiki.port));
826
+ if (val) {
827
+ webWiki.port = parseInt(val) || 10973;
828
+ (config as any).webWiki = webWiki;
829
+ await writeFileAsync(configPath, JSON.stringify(config, null, 2));
830
+ ctx.ui.notify(`Set webWiki.port = ${webWiki.port}`, "info");
831
+ }
832
+ }
833
+ } else if (choice.startsWith("[Embed]") || choice.startsWith("[Chat]") || choice.startsWith("[Rerank]")) {
834
+ const providerType = choice.startsWith("[Embed]") ? "embed" : choice.startsWith("[Chat]") ? "chat" : "rerank";
835
+ const current = providers[providerType] ?? {};
836
+
837
+ const sub = await ctx.ui.select(`${providerType} provider`, [
838
+ `URL: ${current.url ?? "not set"}`,
839
+ `Key: ${current.key ? current.key.slice(0, 8) + "..." : "not set"}`,
840
+ `Model: ${current.model ?? "not set"}`,
841
+ ...(providerType === "embed" ? [`Dims: ${current.dims ?? "auto"}`] : []),
842
+ ...(providerType === "chat" ? [`API format: ${current.api ?? "openai"}`] : []),
843
+ `Remove this provider`,
844
+ ]);
845
+
846
+ if (sub?.startsWith("URL")) {
847
+ const val = await ctx.ui.input(`${providerType} URL:`, current.url ?? "");
848
+ if (val) { current.url = val; providers[providerType] = current; }
849
+ } else if (sub?.startsWith("Key")) {
850
+ const val = await ctx.ui.input(`${providerType} API key:`, "");
851
+ if (val) { current.key = val; providers[providerType] = current; }
852
+ } else if (sub?.startsWith("Model")) {
853
+ const val = await ctx.ui.input(`${providerType} model:`, current.model ?? "");
854
+ if (val) { current.model = val; providers[providerType] = current; }
855
+ } else if (sub?.startsWith("Dims")) {
856
+ const val = await ctx.ui.input("Embedding dimensions:", current.dims ?? "");
857
+ if (val) { current.dims = val; providers[providerType] = current; }
858
+ } else if (sub?.startsWith("API format")) {
859
+ const fmt = await ctx.ui.select("API format", ["openai", "anthropic"]);
860
+ if (fmt) { current.api = fmt; providers[providerType] = current; }
861
+ } else if (sub?.startsWith("Remove")) {
862
+ delete providers[providerType];
863
+ }
864
+
865
+ // Save qmd config
866
+ qmdConfig.providers = providers;
867
+ const { mkdirSync } = await import("node:fs");
868
+ mkdirSync(join(homedir(), ".config", "qmd"), { recursive: true });
869
+ await writeFileAsync(qmdPath, stringify(qmdConfig));
870
+ ctx.ui.notify(`Updated ${qmdPath}`, "info");
871
+ } else if (choice.startsWith("---") || choice.startsWith("[Status]")) {
872
+ // Status row — just show, don't edit
873
+ continue;
874
+ }
875
+ }
876
+ },
877
+ });
878
+ }