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,404 @@
1
+ /**
2
+ * Wiki maintenance agent - uses LLM to intelligently maintain the wiki.
3
+ *
4
+ * Replaces brittle code-based maintenance (link discovery, dedup detection,
5
+ * tag canonicalization, category review) with an LLM agent that understands
6
+ * content and can make smarter decisions.
7
+ *
8
+ * Deterministic operations (secret redaction, schema validation, index rebuild)
9
+ * stay in code. This agent handles everything that needs intelligence.
10
+ */
11
+
12
+ import { Agent } from "@mariozechner/pi-agent-core";
13
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
14
+ import type { Model } from "@mariozechner/pi-ai";
15
+ import type { QMDStore } from "qmd-engine";
16
+ import { Type } from "typebox";
17
+ import { StringEnum } from "@mariozechner/pi-ai";
18
+
19
+ import {
20
+ readPage,
21
+ writePage,
22
+ listPages,
23
+ writeIndex,
24
+ gitCommit,
25
+ PARA_CATEGORIES,
26
+ } from "./wiki.js";
27
+ import type { WikiPage, ParaCategory, PageRef } from "./wiki.js";
28
+ import { validateFrontmatter } from "./frontmatter.js";
29
+ import { searchWiki, reindex } from "./store.js";
30
+ import { extractWikilinks, syncFrontmatterLinks } from "./link-utils.js";
31
+ import { normalizeTags, normalizeScopes } from "./tag-registry.js";
32
+ import { redactSecrets } from "./redact.js";
33
+ import { lintWiki } from "./lint.js";
34
+
35
+ // -- Types -------------------------------------------------------------------
36
+
37
+ export interface MaintenanceResult {
38
+ pagesUpdated: number;
39
+ pagesMerged: number;
40
+ linksAdded: number;
41
+ issuesFixed: number;
42
+ summary: string;
43
+ }
44
+
45
+ // -- Maintenance prompt ------------------------------------------------------
46
+
47
+ const MAINTENANCE_SYSTEM_PROMPT = `You are a wiki maintenance agent. Your job is to keep a PARA knowledge base healthy, well-linked, and free of duplicates.
48
+
49
+ You have tools to inspect and modify the wiki:
50
+ - wiki_list: List all pages with their scope, tags, and link counts
51
+ - wiki_read: Read a page's full content
52
+ - wiki_query: Search for pages by keyword
53
+ - wiki_write: Update a page (merge content, add links, fix category)
54
+ - wiki_merge: Merge two pages into one (keeps the target, deletes the source)
55
+ - wiki_lint: Run automated checks and get a report of issues
56
+
57
+ Your maintenance tasks (in priority order):
58
+
59
+ 1. DUPLICATE DETECTION & MERGING
60
+ - Find pages covering the same topic with different slugs
61
+ - Merge them: combine content into the older/more complete page, delete the duplicate
62
+ - Examples: "pi-para daemon architecture" and "pi-para daemon" are duplicates
63
+
64
+ 2. WIKILINK DISCOVERY
65
+ - Find pages that discuss related topics but aren't linked
66
+ - Add [[wikilinks]] in body text and ## Connections sections
67
+ - Every page should link to at least 2 related pages
68
+
69
+ 3. CATEGORY REVIEW
70
+ - resources/ should have reference docs, architecture, how-tos, patterns
71
+ - areas/ should have ongoing responsibilities only
72
+ - projects/ should ONLY have actual goals with end dates (almost never used)
73
+ - Move miscategorized pages
74
+
75
+ 4. TAG CLEANUP
76
+ - Remove redundant tags (tags that duplicate scope values)
77
+ - Canonicalize similar tags (e.g. "capture" and "session-capture" → keep "session-capture")
78
+ - Ensure tags are kebab-case
79
+
80
+ 5. CONTENT QUALITY
81
+ - Pages should have: Topic, Key Facts, Connections sections at minimum
82
+ - Flag pages with very thin content for expansion
83
+ - Flag pages with no ## Connections section
84
+
85
+ 6. STALENESS REVIEW
86
+ - Identify pages not updated in >30 days that make specific claims about code, file paths, configs, ports, or API endpoints
87
+ - These are high-risk for being outdated - add a note in Open Questions: "⚠️ This page has not been updated since [date]. Claims about [specific thing] should be verified."
88
+ - If you can determine from other pages or context that a claim is now wrong, fix it directly
89
+ - Pages about architecture decisions or historical events are less likely to go stale than pages about configs or deployment
90
+
91
+ 7. PROJECT LIFECYCLE REVIEW
92
+ - Review projects/ pages — only archive a project if ALL of these are true:
93
+ a) The End Condition section explicitly says the goal is met or done
94
+ b) There are NO unchecked items ([ ]) remaining anywhere in the page
95
+ c) The page has no Open Questions or pending tasks
96
+ - Do NOT archive just because status checkboxes are ticked — there may be remaining work not captured in checkboxes
97
+ - When archiving: use wiki_write to create the page in archives/ with '(Completed)' in the title, then wiki_merge to remove the projects/ copy
98
+
99
+ 8. AREAS HEALTH METRICS
100
+ - Review areas/ pages - update current health metrics based on recent lint results and maintenance findings
101
+ - Areas represent ongoing responsibilities; keep them current with the latest status
102
+
103
+ Rules:
104
+ - Work systematically: list all pages first, then process in batches
105
+ - For merges: preserve ALL information from both pages, combine into the better-structured one
106
+ - For links: only add links between genuinely related pages, not random connections
107
+ - Be conservative: when unsure, leave it alone
108
+ - Never include secrets/API keys in page content
109
+ - Scope must be a kebab-case project name, not a topic description
110
+
111
+ When done, summarize what you changed.`;
112
+
113
+ // -- Tools -------------------------------------------------------------------
114
+
115
+ function createMaintenanceTools(wikiDir: string, store: QMDStore): AgentTool[] {
116
+ const WikiListParams = Type.Object({});
117
+ const WikiReadParams = Type.Object({ path: Type.String({ description: "category/slug" }) });
118
+ const WikiQueryParams = Type.Object({ query: Type.String() });
119
+ const WikiWriteParams = Type.Object({
120
+ category: StringEnum(["projects", "areas", "resources", "archives"] as const),
121
+ slug: Type.String(),
122
+ title: Type.String(),
123
+ scope: Type.Array(Type.String()),
124
+ tags: Type.Array(Type.String()),
125
+ body: Type.String(),
126
+ });
127
+ const WikiMergeParams = Type.Object({
128
+ keepCategory: StringEnum(["projects", "areas", "resources", "archives"] as const),
129
+ keepSlug: Type.String({ description: "Slug of the page to keep (target)" }),
130
+ deleteCategory: StringEnum(["projects", "areas", "resources", "archives"] as const),
131
+ deleteSlug: Type.String({ description: "Slug of the page to delete (source)" }),
132
+ mergedBody: Type.String({ description: "Combined body text for the kept page" }),
133
+ });
134
+ const WikiLintParams = Type.Object({});
135
+
136
+ const wikiList: AgentTool<typeof WikiListParams> = {
137
+ name: "wiki_list",
138
+ label: "Wiki List",
139
+ description: "List all wiki pages with scope, tags, and outgoing link count.",
140
+ parameters: WikiListParams,
141
+ execute: async () => {
142
+ const refs = await listPages(wikiDir);
143
+ const lines: string[] = [];
144
+ for (const ref of refs) {
145
+ const page = await readPage(wikiDir, ref.category, ref.slug);
146
+ if (!page) continue;
147
+ const linkCount = extractWikilinks(page.body).length;
148
+ lines.push(
149
+ `${ref.category}/${ref.slug} | "${page.frontmatter.title}" | scope:[${page.frontmatter.scope.join(",")}] | tags:[${page.frontmatter.tags.join(",")}] | links:${linkCount}`
150
+ );
151
+ }
152
+ return {
153
+ content: [{ type: "text", text: `${lines.length} pages:\n${lines.join("\n")}` }],
154
+ details: { count: lines.length },
155
+ };
156
+ },
157
+ };
158
+
159
+ const wikiRead: AgentTool<typeof WikiReadParams> = {
160
+ name: "wiki_read",
161
+ label: "Wiki Read",
162
+ description: "Read a wiki page by path (category/slug).",
163
+ parameters: WikiReadParams,
164
+ execute: async (_id, params: { path: string }) => {
165
+ const parts = params.path.split("/");
166
+ if (parts.length !== 2) {
167
+ return { content: [{ type: "text", text: `Invalid path: ${params.path}` }], details: {} };
168
+ }
169
+ const page = await readPage(wikiDir, parts[0] as ParaCategory, parts[1]);
170
+ if (!page) {
171
+ return { content: [{ type: "text", text: `Not found: ${params.path}` }], details: {} };
172
+ }
173
+ return {
174
+ content: [{ type: "text", text: `# ${page.frontmatter.title}\nCategory: ${page.category}\nScope: ${page.frontmatter.scope.join(", ")}\nTags: ${page.frontmatter.tags.join(", ")}\nLinks: ${page.frontmatter.links.join(", ") || "none"}\n\n${page.body}` }],
175
+ details: { title: page.frontmatter.title },
176
+ };
177
+ },
178
+ };
179
+
180
+ const wikiQuery: AgentTool<typeof WikiQueryParams> = {
181
+ name: "wiki_query",
182
+ label: "Wiki Query",
183
+ description: "Search wiki pages by keyword. Returns top matches with scores.",
184
+ parameters: WikiQueryParams,
185
+ execute: async (_id, params: { query: string }) => {
186
+ const results = await searchWiki(store, params.query, { limit: 8 });
187
+ if (results.length === 0) {
188
+ return { content: [{ type: "text", text: "No results." }], details: { count: 0 } };
189
+ }
190
+ const text = results
191
+ .map(r => `${r.page.path} | "${r.frontmatter.title}" | score:${r.score.toFixed(3)}`)
192
+ .join("\n");
193
+ return {
194
+ content: [{ type: "text", text: `${results.length} results:\n${text}` }],
195
+ details: { count: results.length },
196
+ };
197
+ },
198
+ };
199
+
200
+ const wikiWrite: AgentTool<typeof WikiWriteParams> = {
201
+ name: "wiki_write",
202
+ label: "Wiki Write",
203
+ description: "Create or update a wiki page. Always redacts secrets and normalizes tags/scope.",
204
+ parameters: WikiWriteParams,
205
+ execute: async (_id, params: { category: ParaCategory; slug: string; title: string; scope: string[]; tags: string[]; body: string }) => {
206
+ const now = new Date().toISOString();
207
+ const slug = params.slug.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
208
+ const existing = await readPage(wikiDir, params.category, slug);
209
+ const body = redactSecrets(params.body).text;
210
+ const scope = normalizeScopes(params.scope);
211
+ const tags = normalizeTags(params.tags, scope);
212
+ const links = syncFrontmatterLinks(body);
213
+
214
+ if (existing) {
215
+ const updated: WikiPage = {
216
+ ...existing,
217
+ body,
218
+ frontmatter: { ...existing.frontmatter, title: params.title, scope, tags, updated: now, links },
219
+ };
220
+ await writePage(wikiDir, updated);
221
+ } else {
222
+ const fm = validateFrontmatter({ title: params.title, para: params.category, scope, tags, sources: [], created: now, updated: now, links });
223
+ await writePage(wikiDir, { category: params.category, slug, frontmatter: fm, body });
224
+ }
225
+ await reindex(store);
226
+ return {
227
+ content: [{ type: "text", text: `Wrote ${params.category}/${slug}` }],
228
+ details: { path: `${params.category}/${slug}` },
229
+ };
230
+ },
231
+ };
232
+
233
+ const wikiMerge: AgentTool<typeof WikiMergeParams> = {
234
+ name: "wiki_merge",
235
+ label: "Wiki Merge",
236
+ description: "Merge two pages: update the target page with combined content, delete the source page.",
237
+ parameters: WikiMergeParams,
238
+ execute: async (_id, params: { keepCategory: ParaCategory; keepSlug: string; deleteCategory: ParaCategory; deleteSlug: string; mergedBody: string }) => {
239
+ const keepPage = await readPage(wikiDir, params.keepCategory, params.keepSlug);
240
+ const deletePage = await readPage(wikiDir, params.deleteCategory, params.deleteSlug);
241
+ if (!keepPage) return { content: [{ type: "text", text: `Target not found: ${params.keepCategory}/${params.keepSlug}` }], details: {} };
242
+ if (!deletePage) return { content: [{ type: "text", text: `Source not found: ${params.deleteCategory}/${params.deleteSlug}` }], details: {} };
243
+
244
+ const now = new Date().toISOString();
245
+ const body = redactSecrets(params.mergedBody).text;
246
+ const mergedScope = normalizeScopes([...new Set([...keepPage.frontmatter.scope, ...deletePage.frontmatter.scope])]);
247
+ const mergedTags = normalizeTags([...new Set([...keepPage.frontmatter.tags, ...deletePage.frontmatter.tags])], mergedScope);
248
+
249
+ // Update target
250
+ const updated: WikiPage = {
251
+ ...keepPage,
252
+ body,
253
+ frontmatter: {
254
+ ...keepPage.frontmatter,
255
+ scope: mergedScope,
256
+ tags: mergedTags,
257
+ updated: now,
258
+ links: syncFrontmatterLinks(body),
259
+ },
260
+ };
261
+ await writePage(wikiDir, updated);
262
+
263
+ // Delete source
264
+ const { unlink } = await import("node:fs/promises");
265
+ const { join } = await import("node:path");
266
+ await unlink(join(wikiDir, params.deleteCategory, params.deleteSlug + ".md"));
267
+
268
+ await reindex(store);
269
+ return {
270
+ content: [{ type: "text", text: `Merged ${params.deleteCategory}/${params.deleteSlug} into ${params.keepCategory}/${params.keepSlug}, deleted source.` }],
271
+ details: { kept: `${params.keepCategory}/${params.keepSlug}`, deleted: `${params.deleteCategory}/${params.deleteSlug}` },
272
+ };
273
+ },
274
+ };
275
+
276
+ const wikiLint: AgentTool<typeof WikiLintParams> = {
277
+ name: "wiki_lint",
278
+ label: "Wiki Lint",
279
+ description: "Run lint checks and return a report. Does NOT auto-fix - you decide what to fix.",
280
+ parameters: WikiLintParams,
281
+ execute: async () => {
282
+ const report = await lintWiki(wikiDir, { autoFix: false });
283
+ const lines = report.issues.map(i => `[${i.severity}] ${i.category}: ${i.page ?? ""} - ${i.message}`);
284
+ return {
285
+ content: [{ type: "text", text: `${report.issues.length} issues:\n${lines.join("\n")}\n\nStats: ${JSON.stringify(report.stats.byCategory)}` }],
286
+ details: { issueCount: report.issues.length },
287
+ };
288
+ },
289
+ };
290
+
291
+ return [wikiList, wikiRead, wikiQuery, wikiWrite, wikiMerge, wikiLint];
292
+ }
293
+
294
+ // -- Rebuild index helper (shared with processor) ----------------------------
295
+
296
+ async function rebuildIndex(wikiDir: string): Promise<void> {
297
+ const allPages = await listPages(wikiDir);
298
+ const sections: Record<ParaCategory, string[]> = {
299
+ projects: [], areas: [], resources: [], archives: [],
300
+ };
301
+ for (const ref of allPages) {
302
+ const page = await readPage(wikiDir, ref.category, ref.slug);
303
+ const title = page?.frontmatter.title ?? ref.title;
304
+ const summary = page?.body.split("\n").find(l => l.trim() && !l.startsWith("#") && !l.startsWith("---"))?.trim() ?? "";
305
+ const desc = summary.length > 120 ? summary.slice(0, 117) + "..." : summary;
306
+ sections[ref.category].push(`- [[${ref.slug}]] - ${title}${desc ? ": " + desc : ""}`);
307
+ }
308
+ const indexLines = [
309
+ "# Wiki Index", "",
310
+ "## Projects", "", sections.projects.length > 0 ? sections.projects.join("\n") : "_No active projects._", "",
311
+ "## Areas", "", sections.areas.length > 0 ? sections.areas.join("\n") : "_No areas._", "",
312
+ "## Resources", "", sections.resources.length > 0 ? sections.resources.join("\n") : "_No resources._", "",
313
+ "## Archives", "", sections.archives.length > 0 ? sections.archives.join("\n") : "_No archived items._",
314
+ ];
315
+ await writeIndex(wikiDir, indexLines.join("\n"));
316
+ }
317
+
318
+ // -- Main entry point --------------------------------------------------------
319
+
320
+ /**
321
+ * Run the maintenance agent. Called periodically by the daemon.
322
+ */
323
+ export async function runMaintenance(
324
+ wikiDir: string,
325
+ store: QMDStore,
326
+ model: Model<any>,
327
+ getApiKey: (provider: string) => Promise<string | undefined>,
328
+ ): Promise<MaintenanceResult> {
329
+ const tools = createMaintenanceTools(wikiDir, store);
330
+
331
+ const agent = new Agent({
332
+ initialState: {
333
+ systemPrompt: MAINTENANCE_SYSTEM_PROMPT,
334
+ model,
335
+ tools,
336
+ messages: [],
337
+ },
338
+ getApiKey,
339
+ });
340
+
341
+ const prompt = `Run a maintenance pass on the wiki. Start by listing all pages, then:
342
+ 1. Look for duplicate pages (same topic, different slugs) and merge them
343
+ 2. Find pages with 0 outgoing links and add relevant [[wikilinks]]
344
+ 3. Check for any category misuse or tag issues
345
+ 4. Review pages not updated in >30 days for staleness - add Open Questions warnings for pages making specific claims about code/configs/ports that may have changed
346
+ Be thorough but conservative. Summarize what you changed at the end.`;
347
+
348
+ try {
349
+ await agent.prompt(prompt);
350
+ } catch (err) {
351
+ return {
352
+ pagesUpdated: 0,
353
+ pagesMerged: 0,
354
+ linksAdded: 0,
355
+ issuesFixed: 0,
356
+ summary: `Agent error: ${err instanceof Error ? err.message : String(err)}`,
357
+ };
358
+ }
359
+
360
+ // Rebuild index after all changes
361
+ await rebuildIndex(wikiDir);
362
+
363
+ // Extract summary from agent's last message
364
+ const messages = agent.state.messages;
365
+ const lastAssistant = [...messages].reverse().find(m => m.role === "assistant");
366
+ let summary = "Maintenance completed.";
367
+ if (lastAssistant && "content" in lastAssistant) {
368
+ const content = lastAssistant.content;
369
+ if (typeof content === "string") summary = content;
370
+ else if (Array.isArray(content)) {
371
+ const textBlock = content.find((b: any) => b.type === "text" && typeof b.text === "string") as { text: string } | undefined;
372
+ if (textBlock) summary = textBlock.text;
373
+ }
374
+ }
375
+
376
+ // Count changes from tool results
377
+ let pagesUpdated = 0;
378
+ let pagesMerged = 0;
379
+ for (const msg of messages) {
380
+ if (msg.role !== "toolResult") continue;
381
+ const c = msg.content;
382
+ if (!Array.isArray(c)) continue;
383
+ for (const block of c) {
384
+ const b = block as unknown as Record<string, unknown>;
385
+ if (b.type === "text" && typeof b.text === "string") {
386
+ if (b.text.startsWith("Wrote ")) pagesUpdated++;
387
+ if (b.text.startsWith("Merged ")) pagesMerged++;
388
+ }
389
+ }
390
+ }
391
+
392
+ // Git commit all maintenance changes
393
+ if (pagesUpdated > 0 || pagesMerged > 0) {
394
+ await gitCommit(wikiDir, `maintenance: ${pagesUpdated} updated, ${pagesMerged} merged`);
395
+ }
396
+
397
+ return {
398
+ pagesUpdated,
399
+ pagesMerged,
400
+ linksAdded: 0, // Agent handles this via wiki_write
401
+ issuesFixed: pagesUpdated + pagesMerged,
402
+ summary,
403
+ };
404
+ }