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
package/src/capture.ts ADDED
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Session knowledge capture — extracts insights from sessions into the wiki.
3
+ *
4
+ * Two modes:
5
+ * - Auto-capture on session_shutdown (standalone Agent with wiki tools)
6
+ * - Explicit capture via /wiki-capture command (standalone Agent)
7
+ *
8
+ * Both are fully automatic — no user confirmation prompts.
9
+ */
10
+
11
+ import type { Model } from "@mariozechner/pi-ai";
12
+ import { Agent } from "@mariozechner/pi-agent-core";
13
+ import type { AgentMessage, AgentEvent } from "@mariozechner/pi-agent-core";
14
+ import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
15
+ import type { QMDStore } from "qmd-engine";
16
+
17
+ import type { ProjectScope } from "./scope.js";
18
+ import type { PageRef, ParaCategory } from "./wiki.js";
19
+ import { readPage, PARA_CATEGORIES } from "./wiki.js";
20
+ import { appendSessionDigest } from "./raw.js";
21
+ import type { SessionDigest } from "./raw.js";
22
+ import { createStandaloneTools } from "./tools.js";
23
+ import { serializeSessionForWiki, generateSummary } from "./summarize.js";
24
+ import { CAPTURE_SYSTEM_PROMPT, EXPLICIT_CAPTURE_PROMPT } from "./templates/prompts.js";
25
+
26
+ // -- Types ------------------------------------------------------------------
27
+
28
+ export interface CaptureResult {
29
+ pagesCreated: PageRef[];
30
+ pagesUpdated: PageRef[];
31
+ skipped: boolean;
32
+ reason?: string; // why it was skipped ("trivial session", etc.)
33
+ digestEntry?: SessionDigest;
34
+ }
35
+
36
+ // -- Constants ---------------------------------------------------------------
37
+
38
+ const DEFAULT_TIMEOUT_MS = 60_000;
39
+ const NOTHING_PATTERN = /nothing to capture/i;
40
+
41
+ // -- Helpers -----------------------------------------------------------------
42
+
43
+ /**
44
+ * Parse a page path like "projects/auth-refactor" into category + slug.
45
+ * Returns null for invalid paths.
46
+ */
47
+ function parsePagePath(pagePath: string): { category: ParaCategory; slug: string } | null {
48
+ const parts = pagePath.split("/");
49
+ if (parts.length !== 2) return null;
50
+ const [cat, slug] = parts;
51
+ if (!(PARA_CATEGORIES as readonly string[]).includes(cat)) return null;
52
+ if (!slug) return null;
53
+ return { category: cat as ParaCategory, slug };
54
+ }
55
+
56
+ /** Estimate total text chars in user + assistant messages. */
57
+ function estimateSessionChars(messages: AgentMessage[]): number {
58
+ let total = 0;
59
+ for (const m of messages) {
60
+ if (m.role !== "user" && m.role !== "assistant") continue;
61
+ const content = "content" in m ? m.content : undefined;
62
+ if (typeof content === "string") { total += content.length; continue; }
63
+ if (Array.isArray(content)) {
64
+ for (const block of content) {
65
+ if (typeof block === "object" && block !== null && "type" in block && block.type === "text" && "text" in block) {
66
+ total += (block as { text: string }).text.length;
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return total;
72
+ }
73
+
74
+ /**
75
+ * Extract pages written from agent messages by inspecting tool results.
76
+ *
77
+ * After the standalone agent finishes, its messages contain tool calls and
78
+ * tool results. We inspect wiki_write tool results to find pagesWritten.
79
+ */
80
+ function extractWrittenPages(messages: AgentMessage[]): string[] {
81
+ const pages: string[] = [];
82
+
83
+ for (const msg of messages) {
84
+ if (typeof msg !== "object" || msg === null || !("role" in (msg as object))) continue;
85
+ const m = msg as unknown as Record<string, unknown>;
86
+
87
+ // Look for toolResult messages that contain pagesWritten info
88
+ if (m.role === "toolResult") {
89
+ // The content of a wiki_write tool result mentions "Wrote N page(s): path1, path2"
90
+ const content = m.content;
91
+ if (!Array.isArray(content)) continue;
92
+ for (const block of content) {
93
+ if (typeof block !== "object" || block === null) continue;
94
+ const b = block as unknown as Record<string, unknown>;
95
+ if (b.type === "text" && typeof b.text === "string") {
96
+ const match = b.text.match(/Wrote \d+ page\(s\): (.+)/);
97
+ if (match) {
98
+ const paths = match[1].split(", ").map((p) => p.trim());
99
+ pages.push(...paths);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ return [...new Set(pages)];
107
+ }
108
+
109
+ /**
110
+ * Check if the agent's final assistant message indicates nothing was captured.
111
+ */
112
+ function isNothingCaptured(messages: AgentMessage[]): boolean {
113
+ // Walk messages backwards to find the last assistant message
114
+ for (let i = messages.length - 1; i >= 0; i--) {
115
+ const msg = messages[i];
116
+ if (typeof msg !== "object" || msg === null || !("role" in (msg as object))) continue;
117
+ const m = msg as unknown as Record<string, unknown>;
118
+ if (m.role !== "assistant") continue;
119
+
120
+ const content = m.content;
121
+ if (!Array.isArray(content)) continue;
122
+ for (const block of content) {
123
+ if (typeof block !== "object" || block === null) continue;
124
+ const b = block as unknown as Record<string, unknown>;
125
+ if (b.type === "text" && typeof b.text === "string") {
126
+ if (NOTHING_PATTERN.test(b.text)) return true;
127
+ }
128
+ }
129
+ break; // only check the last assistant message
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * Build PageRef objects from page paths by reading actual pages from disk.
136
+ * Pages that don't exist on disk get a minimal PageRef.
137
+ */
138
+ async function buildPageRefs(
139
+ wikiDir: string,
140
+ pagePaths: string[],
141
+ ): Promise<{ created: PageRef[]; updated: PageRef[] }> {
142
+ const created: PageRef[] = [];
143
+ const updated: PageRef[] = [];
144
+
145
+ for (const pagePath of pagePaths) {
146
+ const parsed = parsePagePath(pagePath);
147
+ if (!parsed) continue;
148
+
149
+ const page = await readPage(wikiDir, parsed.category, parsed.slug);
150
+ const ref: PageRef = {
151
+ category: parsed.category,
152
+ slug: parsed.slug,
153
+ title: page?.frontmatter.title ?? parsed.slug,
154
+ path: `${parsed.category}/${parsed.slug}.md`,
155
+ };
156
+
157
+ // If created date == updated date, it's newly created; otherwise updated.
158
+ // In practice, all pages written in capture are "created" from capture's
159
+ // perspective — the wiki_write tool handles create vs update internally.
160
+ // We heuristically check: if the page was created within the last minute,
161
+ // treat it as newly created.
162
+ if (page) {
163
+ const createdTime = new Date(page.frontmatter.created).getTime();
164
+ const updatedTime = new Date(page.frontmatter.updated).getTime();
165
+ // If created and updated are within 2 seconds, it was just created
166
+ if (Math.abs(updatedTime - createdTime) < 2000) {
167
+ created.push(ref);
168
+ } else {
169
+ updated.push(ref);
170
+ }
171
+ } else {
172
+ // Page was written but we can't read it — treat as created
173
+ created.push(ref);
174
+ }
175
+ }
176
+
177
+ return { created, updated };
178
+ }
179
+
180
+ /**
181
+ * Run a standalone capture agent with the given prompt and tools.
182
+ * Returns the agent's final messages for analysis.
183
+ */
184
+ async function runCaptureAgent(
185
+ wikiDir: string,
186
+ store: QMDStore,
187
+ scope: ProjectScope,
188
+ model: Model<any>,
189
+ modelRegistry: ModelRegistry,
190
+ userPrompt: string,
191
+ timeoutMs?: number,
192
+ ): Promise<AgentMessage[]> {
193
+ const tools = createStandaloneTools(wikiDir, store, () => scope);
194
+
195
+ const agent = new Agent({
196
+ initialState: {
197
+ systemPrompt: CAPTURE_SYSTEM_PROMPT,
198
+ model,
199
+ tools,
200
+ messages: [],
201
+ },
202
+ getApiKey: (provider) => modelRegistry.getApiKeyForProvider(provider),
203
+ });
204
+
205
+ const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS;
206
+ const abortController = new AbortController();
207
+ const timer = setTimeout(() => abortController.abort(), timeout);
208
+
209
+ // Wire abort into agent
210
+ const unsubscribe = agent.subscribe(async (_event: AgentEvent, _signal: AbortSignal) => {
211
+ if (abortController.signal.aborted) {
212
+ agent.abort();
213
+ }
214
+ });
215
+
216
+ try {
217
+ // Prompt the agent — this runs the full tool loop
218
+ await agent.prompt(userPrompt);
219
+ } catch {
220
+ // Agent was aborted or encountered an error — non-fatal
221
+ } finally {
222
+ clearTimeout(timer);
223
+ unsubscribe();
224
+ }
225
+
226
+ return agent.state.messages;
227
+ }
228
+
229
+ // -- Public API --------------------------------------------------------------
230
+
231
+ /**
232
+ * Called automatically on session_shutdown.
233
+ * Spins up a standalone Agent with wiki tools, aborts after timeoutMs.
234
+ */
235
+ export async function autoCapture(
236
+ wikiDir: string,
237
+ store: QMDStore,
238
+ messages: AgentMessage[],
239
+ scope: ProjectScope,
240
+ sessionFile: string,
241
+ model: Model<any>,
242
+ modelRegistry: ModelRegistry,
243
+ timeoutMs?: number,
244
+ alreadyCaptured?: string[],
245
+ ): Promise<CaptureResult> {
246
+ // Short-circuit only truly empty sessions (greetings, single-word exchanges).
247
+ // Even short sessions can contain valuable operational knowledge (deploy keys,
248
+ // server configs, build commands, architecture decisions).
249
+ //
250
+ // We also check for tool usage — if the LLM read files, ran commands, or
251
+ // searched code, the session likely has project-specific knowledge.
252
+ const totalChars = estimateSessionChars(messages);
253
+ const hasToolCalls = messages.some(m =>
254
+ m.role === "assistant" && Array.isArray(m.content) &&
255
+ m.content.some((b: unknown) => typeof b === "object" && b !== null && "type" in (b as Record<string, unknown>) && (b as Record<string, unknown>).type === "toolCall")
256
+ );
257
+
258
+ // Skip only if: very short AND no tool usage
259
+ if (!hasToolCalls && (messages.length < 4 || totalChars < 200)) {
260
+ return {
261
+ pagesCreated: [],
262
+ pagesUpdated: [],
263
+ skipped: true,
264
+ reason: `trivial session (${messages.length} messages, ${totalChars} chars, no tools)`,
265
+ };
266
+ }
267
+
268
+ // Serialize recent messages only. For long sessions, the already-captured
269
+ // wiki pages serve as context for earlier work — no need to re-serialize
270
+ // old conversation. The wiki IS the summary.
271
+ const MAX_CAPTURE_CHARS = 60_000; // ~15k tokens for recent messages
272
+ let serialized = serializeSessionForWiki(messages);
273
+
274
+ if (!serialized.trim()) {
275
+ return {
276
+ pagesCreated: [],
277
+ pagesUpdated: [],
278
+ skipped: true,
279
+ reason: "empty session",
280
+ };
281
+ }
282
+
283
+ // If serialized is too long even after collecting only messages, the session
284
+ // is too massive for auto-capture to handle well. Skip it — rely on
285
+ // mid-session wiki_write and /wiki-capture for knowledge persistence.
286
+ if (serialized.length > MAX_CAPTURE_CHARS) {
287
+ return {
288
+ pagesCreated: [],
289
+ pagesUpdated: [],
290
+ skipped: true,
291
+ reason: `session too large for auto-capture (${Math.round(serialized.length / 1000)}K chars). Use /wiki-capture or wiki_write during the session instead.`,
292
+ };
293
+ }
294
+
295
+ // Build context from already-captured wiki pages — these ARE the
296
+ // distilled summary of earlier work, better than raw conversation.
297
+ let priorContext = "";
298
+ if (alreadyCaptured && alreadyCaptured.length > 0) {
299
+ const pageContents: string[] = [];
300
+ for (const slug of alreadyCaptured) {
301
+ // Try to find the page in any PARA category
302
+ for (const cat of ["projects", "areas", "resources", "archives"] as const) {
303
+ const page = await readPage(wikiDir, cat, slug);
304
+ if (page) {
305
+ pageContents.push(`### [[${slug}]] (${cat})\n${page.body.slice(0, 2000)}`);
306
+ break;
307
+ }
308
+ }
309
+ }
310
+ if (pageContents.length > 0) {
311
+ priorContext = [
312
+ "<already-captured-pages>",
313
+ "These wiki pages were already captured from earlier in this session.",
314
+ "Focus on NEW knowledge not yet in these pages. Update existing pages if there is significant new information.",
315
+ "",
316
+ ...pageContents,
317
+ "</already-captured-pages>",
318
+ ].join("\n");
319
+ }
320
+ }
321
+
322
+ // Build the capture prompt
323
+ const userPrompt = generateSummary(serialized, {
324
+ mode: "session",
325
+ scope,
326
+ });
327
+
328
+ const fullPrompt = [
329
+ userPrompt,
330
+ priorContext,
331
+ "",
332
+ `Session file: ${sessionFile}`,
333
+ `Project scope: ${scope.name}`,
334
+ ].join("\n");
335
+
336
+ const agentMessages = await runCaptureAgent(
337
+ wikiDir,
338
+ store,
339
+ scope,
340
+ model,
341
+ modelRegistry,
342
+ fullPrompt,
343
+ timeoutMs,
344
+ );
345
+
346
+ // Analyze what the agent did
347
+ if (isNothingCaptured(agentMessages)) {
348
+ return {
349
+ pagesCreated: [],
350
+ pagesUpdated: [],
351
+ skipped: true,
352
+ reason: "trivial session",
353
+ };
354
+ }
355
+
356
+ const writtenPaths = extractWrittenPages(agentMessages);
357
+
358
+ if (writtenPaths.length === 0) {
359
+ return {
360
+ pagesCreated: [],
361
+ pagesUpdated: [],
362
+ skipped: true,
363
+ reason: "no pages written",
364
+ };
365
+ }
366
+
367
+ const { created, updated } = await buildPageRefs(wikiDir, writtenPaths);
368
+ const allPageSlugs = writtenPaths.map((p) => p.split("/").pop() ?? p);
369
+
370
+ // Build a summary from the last assistant message text
371
+ const summary = extractAssistantSummary(agentMessages);
372
+
373
+ // Append session digest
374
+ const digestEntry: SessionDigest = {
375
+ date: new Date().toISOString().split("T")[0],
376
+ project: scope.name,
377
+ sessionFile,
378
+ scope: scope.name,
379
+ capturedPages: allPageSlugs,
380
+ summary: summary || `Captured ${writtenPaths.length} page(s) from session.`,
381
+ };
382
+
383
+ await appendSessionDigest(wikiDir, digestEntry);
384
+
385
+ return {
386
+ pagesCreated: created,
387
+ pagesUpdated: updated,
388
+ skipped: false,
389
+ digestEntry,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Called when user explicitly requests capture mid-session via /wiki-capture.
395
+ * Spins up a standalone Agent with wiki tools.
396
+ */
397
+ export async function explicitCapture(
398
+ wikiDir: string,
399
+ store: QMDStore,
400
+ topic: string | undefined,
401
+ messages: AgentMessage[],
402
+ scope: ProjectScope,
403
+ sessionFile: string,
404
+ model: Model<any>,
405
+ modelRegistry: ModelRegistry,
406
+ alreadyCaptured?: string[],
407
+ ): Promise<CaptureResult> {
408
+ const MAX_EXPLICIT_CHARS = 60_000;
409
+ let serialized = serializeSessionForWiki(messages);
410
+
411
+ if (!serialized.trim()) {
412
+ return {
413
+ pagesCreated: [],
414
+ pagesUpdated: [],
415
+ skipped: true,
416
+ reason: "empty session",
417
+ };
418
+ }
419
+
420
+ // For large sessions, keep only the recent tail
421
+ if (serialized.length > MAX_EXPLICIT_CHARS) {
422
+ serialized = "[... earlier conversation omitted ...]\n\n"
423
+ + serialized.slice(-MAX_EXPLICIT_CHARS);
424
+ }
425
+
426
+ // Build context from already-captured wiki pages
427
+ let priorContext = "";
428
+ if (alreadyCaptured && alreadyCaptured.length > 0) {
429
+ const pageContents: string[] = [];
430
+ for (const slug of alreadyCaptured) {
431
+ for (const cat of ["projects", "areas", "resources", "archives"] as const) {
432
+ const page = await readPage(wikiDir, cat, slug);
433
+ if (page) {
434
+ pageContents.push(`### [[${slug}]] (${cat})\n${page.body.slice(0, 2000)}`);
435
+ break;
436
+ }
437
+ }
438
+ }
439
+ if (pageContents.length > 0) {
440
+ priorContext = [
441
+ "<already-captured-pages>",
442
+ "These pages were already captured. Focus on NEW knowledge.",
443
+ "",
444
+ ...pageContents,
445
+ "</already-captured-pages>",
446
+ "",
447
+ ].join("\n");
448
+ }
449
+ }
450
+
451
+ // Build the explicit capture prompt
452
+ const topicInstruction = topic
453
+ ? `\n\nFocus on capturing knowledge about: ${topic}`
454
+ : "\n\nIdentify and capture any valuable knowledge from this session.";
455
+
456
+ const userPrompt = [
457
+ EXPLICIT_CAPTURE_PROMPT,
458
+ topicInstruction,
459
+ priorContext,
460
+ "",
461
+ `Session file: ${sessionFile}`,
462
+ `Project scope: ${scope.name}`,
463
+ "",
464
+ "<session-conversation>",
465
+ serialized,
466
+ "</session-conversation>",
467
+ ].join("\n");
468
+
469
+ const agentMessages = await runCaptureAgent(
470
+ wikiDir,
471
+ store,
472
+ scope,
473
+ model,
474
+ modelRegistry,
475
+ userPrompt,
476
+ // Explicit capture gets a generous timeout (60s)
477
+ 60_000,
478
+ );
479
+
480
+ // Analyze what the agent did
481
+ if (isNothingCaptured(agentMessages)) {
482
+ return {
483
+ pagesCreated: [],
484
+ pagesUpdated: [],
485
+ skipped: true,
486
+ reason: "trivial session",
487
+ };
488
+ }
489
+
490
+ const writtenPaths = extractWrittenPages(agentMessages);
491
+
492
+ if (writtenPaths.length === 0) {
493
+ return {
494
+ pagesCreated: [],
495
+ pagesUpdated: [],
496
+ skipped: true,
497
+ reason: "no pages written",
498
+ };
499
+ }
500
+
501
+ const { created, updated } = await buildPageRefs(wikiDir, writtenPaths);
502
+ const allPageSlugs = writtenPaths.map((p) => p.split("/").pop() ?? p);
503
+
504
+ const summary = extractAssistantSummary(agentMessages);
505
+
506
+ // Append session digest
507
+ const digestEntry: SessionDigest = {
508
+ date: new Date().toISOString().split("T")[0],
509
+ project: scope.name,
510
+ sessionFile,
511
+ scope: scope.name,
512
+ capturedPages: allPageSlugs,
513
+ summary: summary || `Captured ${writtenPaths.length} page(s) from session.`,
514
+ };
515
+
516
+ await appendSessionDigest(wikiDir, digestEntry);
517
+
518
+ return {
519
+ pagesCreated: created,
520
+ pagesUpdated: updated,
521
+ skipped: false,
522
+ digestEntry,
523
+ };
524
+ }
525
+
526
+ // -- Internal helpers --------------------------------------------------------
527
+
528
+ /**
529
+ * Extract a summary string from the agent's last assistant message.
530
+ * Used for the session digest summary field.
531
+ */
532
+ function extractAssistantSummary(messages: AgentMessage[]): string {
533
+ // Walk backwards to find the last assistant text
534
+ for (let i = messages.length - 1; i >= 0; i--) {
535
+ const msg = messages[i];
536
+ if (typeof msg !== "object" || msg === null || !("role" in (msg as object))) continue;
537
+ const m = msg as unknown as Record<string, unknown>;
538
+ if (m.role !== "assistant") continue;
539
+
540
+ const content = m.content;
541
+ if (!Array.isArray(content)) continue;
542
+
543
+ const texts: string[] = [];
544
+ for (const block of content) {
545
+ if (typeof block !== "object" || block === null) continue;
546
+ const b = block as unknown as Record<string, unknown>;
547
+ if (b.type === "text" && typeof b.text === "string") {
548
+ texts.push(b.text);
549
+ }
550
+ }
551
+
552
+ if (texts.length > 0) {
553
+ const full = texts.join(" ").trim();
554
+ // Truncate to a reasonable summary length
555
+ if (full.length > 300) {
556
+ return full.slice(0, 297) + "...";
557
+ }
558
+ return full;
559
+ }
560
+ break;
561
+ }
562
+ return "";
563
+ }