@zosmaai/pi-llm-wiki 0.9.0 → 0.9.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.
@@ -0,0 +1,67 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { loadTaskConfig, persistTrajectoriesEnabled, trajectoriesEnabled } from "./task-config.js";
3
+
4
+ /**
5
+ * Activation surface for agent-trajectory working-memory (issue #80).
6
+ *
7
+ * The feature is opt-in / default-off. The trajectory tools
8
+ * (`wiki_capture_trajectory`, `wiki_distill_skills`, `wiki_recall_skill`) are
9
+ * registered conditionally at extension-load time (see index.ts) based on the
10
+ * `llm-wiki.trajectories` setting. Because pi has no runtime add/remove-tool
11
+ * API — the extension factory runs once at load — flipping the flag can only
12
+ * change the registered tool set by RELOADING extensions. This command does
13
+ * exactly that: persist the setting, then `ctx.reload()` so the gate
14
+ * re-evaluates and the tools appear (or disappear) immediately.
15
+ *
16
+ * /wiki-trajectories → show current state
17
+ * /wiki-trajectories on → enable + reload
18
+ * /wiki-trajectories off → disable + reload
19
+ */
20
+
21
+ const ON_WORDS = new Set(["on", "true", "enable", "enabled", "yes", "1"]);
22
+ const OFF_WORDS = new Set(["off", "false", "disable", "disabled", "no", "0"]);
23
+
24
+ export function registerWikiTrajectoriesCommand(pi: ExtensionAPI): void {
25
+ pi.registerCommand("wiki-trajectories", {
26
+ description:
27
+ "Enable or disable agent-trajectory working-memory (capture/distill/recall). Off by default.",
28
+ handler: async (args, ctx) => {
29
+ const arg = args.trim().toLowerCase();
30
+ const current = trajectoriesEnabled(loadTaskConfig(ctx.cwd));
31
+
32
+ if (!arg) {
33
+ ctx.ui.notify(
34
+ `LLM Wiki trajectories are ${current ? "ON" : "OFF"}. Use \`/wiki-trajectories on\` or \`off\`.`,
35
+ "info",
36
+ );
37
+ return;
38
+ }
39
+
40
+ let next: boolean;
41
+ if (ON_WORDS.has(arg)) next = true;
42
+ else if (OFF_WORDS.has(arg)) next = false;
43
+ else {
44
+ ctx.ui.notify(
45
+ `LLM Wiki: could not parse "${args.trim()}". Use \`on\` or \`off\`.`,
46
+ "error",
47
+ );
48
+ return;
49
+ }
50
+
51
+ if (next === current) {
52
+ ctx.ui.notify(`LLM Wiki trajectories already ${current ? "ON" : "OFF"}.`, "info");
53
+ return;
54
+ }
55
+
56
+ persistTrajectoriesEnabled(ctx.cwd, next);
57
+ ctx.ui.notify(
58
+ `LLM Wiki: trajectory tools ${next ? "enabled" : "disabled"} — reloading extensions…`,
59
+ "info",
60
+ );
61
+ // pi has no runtime register/unregister-tool API: reload re-runs the
62
+ // extension factory so the conditional registration re-evaluates and the
63
+ // tool set (and status line) reflect the new state.
64
+ await ctx.reload();
65
+ },
66
+ });
67
+ }
@@ -0,0 +1,613 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { appendEvent, rebuildMetadataLight } from "./metadata.js";
6
+ import { searchWikiLayered } from "./recall.js";
7
+ import {
8
+ type VaultPaths,
9
+ fmtDate,
10
+ nextTrajectoryId,
11
+ readJson,
12
+ resolveVaultPaths,
13
+ writeJson,
14
+ } from "./utils.js";
15
+
16
+ /**
17
+ * Agent trajectory memory — the working-memory half of the wiki.
18
+ *
19
+ * Where wiki_capture_source captures what the agent *read* (URLs, files, text),
20
+ * wiki_capture_trajectory captures what the agent *did*: the sequence of
21
+ * tool calls that solved a real task. Capture is deliberately lightweight — a
22
+ * single call writes an immutable packet plus a SELF-CONTAINED summary
23
+ * (extracted.md); it does NOT emit a to-be-fleshed skeleton (issue #80). The
24
+ * optional distillation step then turns trajectories into reusable pages:
25
+ *
26
+ * raw/trajectories/TRJ-* (immutable packet + self-contained summary)
27
+ * → wiki/skills/* (reusable pattern distilled from many trajectories)
28
+ * → wiki/cases/* (a specific past task, written during distillation)
29
+ * → meta/* (auto-generated registry/backlinks)
30
+ */
31
+
32
+ // ─── Types ─────────────────────────────────────────────
33
+
34
+ /** A single normalized step in a captured trajectory. */
35
+ export interface TrajectoryStep {
36
+ role: "user" | "assistant" | "tool";
37
+ /** Free text (user prompt, assistant prose, or tool output preview). */
38
+ text?: string;
39
+ /** Tool calls issued by an assistant step. */
40
+ tool_calls?: Array<{ id: string; name: string; arguments: unknown }>;
41
+ /** Identifies which tool_call a tool step responds to. */
42
+ tool_call_id?: string;
43
+ /** Tool name for a tool step. */
44
+ tool_name?: string;
45
+ /** Whether a tool step reported an error. */
46
+ is_error?: boolean;
47
+ }
48
+
49
+ export interface TrajectoryPacket {
50
+ id: string;
51
+ captured: string;
52
+ packet_version: string;
53
+ /** The prompt that started the task. */
54
+ prompt: string;
55
+ /** Model that ran the task, if known. */
56
+ model?: string;
57
+ steps: TrajectoryStep[];
58
+ }
59
+
60
+ export interface CaptureTrajectoryInput {
61
+ /** Short descriptive title for the task. */
62
+ title?: string;
63
+ /** The task/prompt that started the work. Inferred from the session if omitted. */
64
+ task?: string;
65
+ /** Explicit trajectory steps. When omitted, extracted from the live session. */
66
+ steps?: TrajectoryStep[];
67
+ /** Model identifier, inferred from the session when available. */
68
+ model?: string;
69
+ /** Outcome label for the case skeleton. */
70
+ outcome?: "success" | "failure" | "partial";
71
+ }
72
+
73
+ export interface CaptureTrajectoryResult {
74
+ trajectoryId: string;
75
+ packetPath: string;
76
+ stepCount: number;
77
+ }
78
+
79
+ // Max characters of a single tool result preserved in the packet preview.
80
+ const TOOL_PREVIEW_LIMIT = 600;
81
+ const ASSISTANT_PREVIEW_LIMIT = 1200;
82
+
83
+ // ─── Session extraction ────────────────────────────────
84
+
85
+ type SessionLike = {
86
+ getBranch?: () => unknown[];
87
+ getEntries?: () => unknown[];
88
+ };
89
+
90
+ function textFromContent(content: unknown, limit: number): string {
91
+ if (typeof content === "string") return content.slice(0, limit);
92
+ if (!Array.isArray(content)) return "";
93
+ const parts: string[] = [];
94
+ for (const block of content) {
95
+ if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
96
+ parts.push(String((block as { text?: string }).text ?? ""));
97
+ }
98
+ }
99
+ return parts.join("\n").trim().slice(0, limit);
100
+ }
101
+
102
+ /**
103
+ * Extract a normalized trajectory from the live session.
104
+ *
105
+ * Reads message entries from the current branch and flattens pi's
106
+ * AgentMessage content blocks (text / toolCall / toolResult) into compact
107
+ * steps. Defensive about shapes so it degrades gracefully across pi versions.
108
+ */
109
+ export function extractTrajectoryFromSession(sessionManager: unknown): {
110
+ steps: TrajectoryStep[];
111
+ model?: string;
112
+ prompt: string;
113
+ } {
114
+ const sm = sessionManager as SessionLike;
115
+ const entries: unknown[] =
116
+ (typeof sm?.getBranch === "function" ? sm.getBranch() : undefined) ??
117
+ (typeof sm?.getEntries === "function" ? sm.getEntries() : undefined) ??
118
+ [];
119
+
120
+ const steps: TrajectoryStep[] = [];
121
+ let model: string | undefined;
122
+ let prompt = "";
123
+
124
+ for (const entry of entries) {
125
+ const e = entry as { type?: string; message?: unknown };
126
+ if (e?.type !== "message" || !e.message) continue;
127
+ const msg = e.message as {
128
+ role?: string;
129
+ content?: unknown;
130
+ model?: string;
131
+ toolName?: string;
132
+ toolCallId?: string;
133
+ isError?: boolean;
134
+ };
135
+
136
+ if (msg.role === "user") {
137
+ const text = textFromContent(msg.content, ASSISTANT_PREVIEW_LIMIT);
138
+ if (!prompt && text) prompt = text;
139
+ steps.push({ role: "user", text });
140
+ } else if (msg.role === "assistant") {
141
+ if (!model && msg.model) model = msg.model;
142
+ const toolCalls: TrajectoryStep["tool_calls"] = [];
143
+ if (Array.isArray(msg.content)) {
144
+ for (const block of msg.content) {
145
+ const b = block as {
146
+ type?: string;
147
+ id?: string;
148
+ name?: string;
149
+ arguments?: unknown;
150
+ };
151
+ if (b?.type === "toolCall") {
152
+ toolCalls.push({
153
+ id: String(b.id ?? ""),
154
+ name: String(b.name ?? "unknown"),
155
+ arguments: b.arguments ?? {},
156
+ });
157
+ }
158
+ }
159
+ }
160
+ const text = textFromContent(msg.content, ASSISTANT_PREVIEW_LIMIT);
161
+ const step: TrajectoryStep = { role: "assistant" };
162
+ if (text) step.text = text;
163
+ if (toolCalls.length > 0) step.tool_calls = toolCalls;
164
+ if (step.text || step.tool_calls) steps.push(step);
165
+ } else if (msg.role === "toolResult") {
166
+ steps.push({
167
+ role: "tool",
168
+ tool_call_id: msg.toolCallId ? String(msg.toolCallId) : undefined,
169
+ tool_name: msg.toolName ? String(msg.toolName) : undefined,
170
+ text: textFromContent(msg.content, TOOL_PREVIEW_LIMIT),
171
+ is_error: Boolean(msg.isError),
172
+ });
173
+ }
174
+ }
175
+
176
+ return { steps, model, prompt };
177
+ }
178
+
179
+ // ─── Capture ───────────────────────────────────────────
180
+
181
+ /** Build a human/LLM-readable README summary of a trajectory packet. */
182
+ function buildTrajectoryReadme(packet: TrajectoryPacket, title: string): string {
183
+ const toolCalls = packet.steps.flatMap((s) => s.tool_calls ?? []);
184
+ const toolCounts = new Map<string, number>();
185
+ for (const tc of toolCalls) toolCounts.set(tc.name, (toolCounts.get(tc.name) ?? 0) + 1);
186
+ const toolSummary =
187
+ [...toolCounts.entries()]
188
+ .sort((a, b) => b[1] - a[1])
189
+ .map(([name, n]) => `${name}×${n}`)
190
+ .join(", ") || "none";
191
+
192
+ const lines: string[] = [
193
+ `# Trajectory ${packet.id}: ${title}`,
194
+ "",
195
+ `- **Captured:** ${packet.captured}`,
196
+ `- **Model:** ${packet.model || "unknown"}`,
197
+ `- **Steps:** ${packet.steps.length}`,
198
+ `- **Tools used:** ${toolSummary}`,
199
+ "",
200
+ "## Task",
201
+ "",
202
+ packet.prompt || "_No prompt recorded._",
203
+ "",
204
+ "## Tool-call sequence",
205
+ "",
206
+ ];
207
+
208
+ let i = 1;
209
+ for (const step of packet.steps) {
210
+ if (step.role === "assistant" && step.tool_calls?.length) {
211
+ for (const tc of step.tool_calls) {
212
+ lines.push(`${i}. \`${tc.name}\``);
213
+ i++;
214
+ }
215
+ }
216
+ }
217
+ if (i === 1) lines.push("_No tool calls recorded._");
218
+ lines.push("");
219
+
220
+ return lines.join("\n");
221
+ }
222
+
223
+ /**
224
+ * Capture an agent task trajectory into an immutable packet plus a
225
+ * self-contained summary (extracted.md). No `[LLM:]` case skeleton is emitted
226
+ * (issue #80) — case pages, if wanted, are written during distillation via
227
+ * wiki_ensure_page(type='case').
228
+ */
229
+ export function captureTrajectory(
230
+ paths: VaultPaths,
231
+ input: CaptureTrajectoryInput,
232
+ ): CaptureTrajectoryResult {
233
+ const trajectoryId = nextTrajectoryId(paths);
234
+ const packetPath = join(paths.rawTrajectories, trajectoryId);
235
+ mkdirSync(packetPath, { recursive: true });
236
+
237
+ const steps = input.steps ?? [];
238
+ const prompt = input.task?.trim() || steps.find((s) => s.role === "user")?.text?.trim() || "";
239
+ const title =
240
+ input.title?.trim() ||
241
+ (prompt ? prompt.replace(/\s+/g, " ").slice(0, 60) : `Task ${trajectoryId}`);
242
+
243
+ const packet: TrajectoryPacket = {
244
+ id: trajectoryId,
245
+ captured: fmtDate(),
246
+ packet_version: "1.0",
247
+ prompt,
248
+ ...(input.model ? { model: input.model } : {}),
249
+ steps,
250
+ };
251
+
252
+ // Immutable full trajectory.
253
+ writeJson(join(packetPath, "packet.json"), packet);
254
+
255
+ // Lightweight manifest so buildRegistry catalogs it uniformly with sources.
256
+ const toolCallCount = steps.reduce((n, s) => n + (s.tool_calls?.length ?? 0), 0);
257
+ writeJson(join(packetPath, "manifest.json"), {
258
+ id: trajectoryId,
259
+ captured: packet.captured,
260
+ packet_version: "1.0",
261
+ title,
262
+ format: "trajectory",
263
+ model: packet.model || "unknown",
264
+ outcome: input.outcome ?? "success",
265
+ step_count: steps.length,
266
+ tool_call_count: toolCallCount,
267
+ });
268
+
269
+ // Self-contained, human/LLM-readable summary — no skeleton to flesh later.
270
+ writeFileSync(join(packetPath, "extracted.md"), buildTrajectoryReadme(packet, title), "utf-8");
271
+
272
+ appendEvent(paths, {
273
+ kind: "capture_trajectory",
274
+ trajectory_id: trajectoryId,
275
+ step_count: steps.length,
276
+ tool_call_count: toolCallCount,
277
+ });
278
+
279
+ rebuildMetadataLight(paths);
280
+
281
+ return {
282
+ trajectoryId,
283
+ packetPath,
284
+ stepCount: steps.length,
285
+ };
286
+ }
287
+
288
+ // ─── Tool: wiki_capture_trajectory ─────────────────────
289
+
290
+ function vaultMissing() {
291
+ return {
292
+ content: [
293
+ {
294
+ type: "text" as const,
295
+ text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
296
+ },
297
+ ],
298
+ details: { error: "no_vault" } as Record<string, unknown>,
299
+ isError: true,
300
+ };
301
+ }
302
+
303
+ const StepSchema = Type.Object({
304
+ role: Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool")]),
305
+ text: Type.Optional(Type.String()),
306
+ tool_calls: Type.Optional(
307
+ Type.Array(
308
+ Type.Object({
309
+ id: Type.String(),
310
+ name: Type.String(),
311
+ arguments: Type.Unknown(),
312
+ }),
313
+ ),
314
+ ),
315
+ tool_call_id: Type.Optional(Type.String()),
316
+ tool_name: Type.Optional(Type.String()),
317
+ is_error: Type.Optional(Type.Boolean()),
318
+ });
319
+
320
+ export function registerWikiCaptureTrajectory(pi: ExtensionAPI): void {
321
+ pi.registerTool({
322
+ name: "wiki_capture_trajectory",
323
+ label: "Wiki Capture Trajectory",
324
+ description:
325
+ "Capture the just-completed task's tool-call trajectory into an immutable " +
326
+ "packet plus a skeleton case page. By default the trajectory is extracted " +
327
+ "automatically from the live session; pass `steps` to override. This is the " +
328
+ "working-memory counterpart to wiki_capture_source.",
329
+ promptSnippet: "Record the completed task trajectory into the wiki",
330
+ promptGuidelines: [
331
+ "Use wiki_capture_trajectory after a non-trivial task worth learning from. The extension auto-extracts the tool-call trajectory from the session — you usually only need to pass a title.",
332
+ "Then run wiki_distill_skills to generalize captured trajectories into reusable skill pages.",
333
+ ],
334
+ parameters: Type.Object({
335
+ title: Type.Optional(
336
+ Type.String({ description: "Short descriptive title for the task (≤60 chars)." }),
337
+ ),
338
+ task: Type.Optional(
339
+ Type.String({
340
+ description:
341
+ "The task/prompt that started the work. Inferred from the session if omitted.",
342
+ }),
343
+ ),
344
+ outcome: Type.Optional(
345
+ Type.Union([Type.Literal("success"), Type.Literal("failure"), Type.Literal("partial")], {
346
+ description: "Outcome of the task (default: success).",
347
+ }),
348
+ ),
349
+ steps: Type.Optional(
350
+ Type.Array(StepSchema, {
351
+ description:
352
+ "Explicit trajectory steps (OpenAI-ish tool-call history). Omit to auto-extract from the live session.",
353
+ }),
354
+ ),
355
+ model: Type.Optional(Type.String({ description: "Model that ran the task." })),
356
+ }),
357
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
358
+ const paths = resolveVaultPaths(ctx.cwd ?? process.cwd());
359
+ if (!existsSync(join(paths.dotWiki, "config.json"))) return vaultMissing();
360
+
361
+ let steps = params.steps as TrajectoryStep[] | undefined;
362
+ let model = params.model;
363
+ let task = params.task;
364
+
365
+ if (!steps || steps.length === 0) {
366
+ const extracted = extractTrajectoryFromSession(ctx.sessionManager);
367
+ steps = extracted.steps;
368
+ model = model || extracted.model;
369
+ task = task || extracted.prompt;
370
+ }
371
+
372
+ if (!steps || steps.length === 0) {
373
+ return {
374
+ content: [
375
+ {
376
+ type: "text",
377
+ text: "No trajectory could be extracted from the session. Pass `steps` explicitly to capture a trajectory.",
378
+ },
379
+ ],
380
+ details: { error: "empty_trajectory" } as Record<string, unknown>,
381
+ isError: true,
382
+ };
383
+ }
384
+
385
+ const result = captureTrajectory(paths, {
386
+ title: params.title,
387
+ task,
388
+ steps,
389
+ model,
390
+ outcome: params.outcome,
391
+ });
392
+
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text",
397
+ text: [
398
+ `🧭 **Trajectory captured**: ${result.trajectoryId}`,
399
+ "",
400
+ `- Packet: \`${result.packetPath}/packet.json\``,
401
+ `- Summary: \`${result.packetPath}/extracted.md\``,
402
+ `- Steps: ${result.stepCount}`,
403
+ "",
404
+ "**Next (optional):** run `wiki_distill_skills` to generalize captured trajectories into reusable skill pages.",
405
+ ].join("\n"),
406
+ },
407
+ ],
408
+ details: {
409
+ trajectoryId: result.trajectoryId,
410
+ packetPath: result.packetPath,
411
+ stepCount: result.stepCount,
412
+ } as Record<string, unknown>,
413
+ };
414
+ },
415
+ });
416
+ }
417
+
418
+ // ─── Tool: wiki_distill_skills ─────────────────────────
419
+
420
+ /** Determine which trajectory IDs have already been cited by a skill page. */
421
+ function distilledTrajectoryIds(paths: VaultPaths): Set<string> {
422
+ const backlinks = readJson<Record<string, string[]>>(join(paths.meta, "backlinks.json"), {});
423
+ const distilled = new Set<string>();
424
+ for (const [pageId, inbound] of Object.entries(backlinks)) {
425
+ if (!pageId.startsWith("trajectories/")) continue;
426
+ if (inbound.some((src) => src.startsWith("skills/"))) {
427
+ distilled.add(pageId.split("/").pop() as string);
428
+ }
429
+ }
430
+ return distilled;
431
+ }
432
+
433
+ export function registerWikiDistillSkills(pi: ExtensionAPI): void {
434
+ pi.registerTool({
435
+ name: "wiki_distill_skills",
436
+ label: "Wiki Distill Skills",
437
+ description:
438
+ "Return a batch of captured trajectories (agent working-memory) that have not " +
439
+ "yet been distilled into skill pages. Read each packet and synthesize reusable " +
440
+ "skill pages (and/or refine case pages) that cite the trajectory IDs.",
441
+ promptSnippet: "Distill captured trajectories into reusable skill pages",
442
+ promptGuidelines: [
443
+ "Use wiki_distill_skills to generalize one or more captured trajectories into reusable skill pages via wiki_ensure_page(type='skill').",
444
+ "Every skill page must cite the trajectory IDs it was distilled from with [[trajectories/TRJ-...]] wikilinks.",
445
+ ],
446
+ parameters: Type.Object({
447
+ trajectory_id: Type.Optional(
448
+ Type.String({
449
+ description: "Specific trajectory ID to distill. Omit for all undistilled.",
450
+ }),
451
+ ),
452
+ batch_size: Type.Optional(
453
+ Type.Number({ description: "Max trajectories to return (default: 3, max: 5)", default: 3 }),
454
+ ),
455
+ }),
456
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
457
+ const paths = resolveVaultPaths(ctx.cwd ?? process.cwd());
458
+ if (!existsSync(join(paths.dotWiki, "config.json"))) return vaultMissing();
459
+
460
+ const batchSize = Math.min(params.batch_size ?? 3, 5);
461
+
462
+ if (!existsSync(paths.rawTrajectories)) {
463
+ return {
464
+ content: [
465
+ {
466
+ type: "text",
467
+ text: "No raw/trajectories/ directory. Capture trajectories first with wiki_capture_trajectory.",
468
+ },
469
+ ],
470
+ details: { error: "no_trajectories" } as Record<string, unknown>,
471
+ };
472
+ }
473
+
474
+ const packets = readdirSync(paths.rawTrajectories)
475
+ .filter((d) => d.startsWith("TRJ-"))
476
+ .sort();
477
+ const distilled = distilledTrajectoryIds(paths);
478
+
479
+ let toProcess = packets.filter((p) => !distilled.has(p));
480
+ if (params.trajectory_id) {
481
+ if (!packets.includes(params.trajectory_id)) {
482
+ return {
483
+ content: [{ type: "text", text: `Trajectory ${params.trajectory_id} not found.` }],
484
+ details: { trajectory_id: params.trajectory_id, status: "not_found" } as Record<
485
+ string,
486
+ unknown
487
+ >,
488
+ };
489
+ }
490
+ toProcess = [params.trajectory_id];
491
+ }
492
+
493
+ const batch = toProcess.slice(0, batchSize);
494
+ if (batch.length === 0) {
495
+ return {
496
+ content: [
497
+ {
498
+ type: "text",
499
+ text: "✅ All trajectories distilled. Capture more with wiki_capture_trajectory.",
500
+ },
501
+ ],
502
+ details: { distilled: distilled.size, total: packets.length } as Record<string, unknown>,
503
+ };
504
+ }
505
+
506
+ const trajectories = batch.map((id) => {
507
+ const readmePath = join(paths.rawTrajectories, id, "extracted.md");
508
+ const manifestPath = join(paths.rawTrajectories, id, "manifest.json");
509
+ const readme = existsSync(readmePath) ? readFileSync(readmePath, "utf-8") : "";
510
+ const manifest = readJson<Record<string, unknown>>(manifestPath, {});
511
+ return { id, readme, manifest };
512
+ });
513
+
514
+ return {
515
+ content: [
516
+ {
517
+ type: "text",
518
+ text: [
519
+ `🧪 **${batch.length} trajectory(ies) ready** (${toProcess.length - batch.length} remaining)`,
520
+ "",
521
+ ...trajectories.map((t) =>
522
+ [
523
+ `- **${t.id}**: ${t.manifest.title || t.id}`,
524
+ ` - Tool calls: ${t.manifest.tool_call_count ?? "?"}, steps: ${t.manifest.step_count ?? "?"}`,
525
+ ` - Packet: \`raw/trajectories/${t.id}/packet.json\``,
526
+ ` - Summary: \`raw/trajectories/${t.id}/extracted.md\``,
527
+ ].join("\n"),
528
+ ),
529
+ "",
530
+ "**Next steps for each trajectory:**",
531
+ "1. Read packet.json (full tool-call sequence) and extracted.md (summary)",
532
+ "2. Create/refine reusable skill pages via wiki_ensure_page(type='skill')",
533
+ "3. Optionally write a case page (a specific past task) via wiki_ensure_page(type='case')",
534
+ "4. Cite the trajectory with [[trajectories/TRJ-...]] in each skill's 'Distilled From'",
535
+ "",
536
+ "The extension auto-updates metadata when you're done.",
537
+ ].join("\n"),
538
+ },
539
+ ],
540
+ details: {
541
+ batch: trajectories.map((t) => t.id),
542
+ remaining: toProcess.length - batch.length,
543
+ } as Record<string, unknown>,
544
+ };
545
+ },
546
+ });
547
+ }
548
+
549
+ // ─── Tool: wiki_recall_skill ───────────────────────────
550
+
551
+ export function registerWikiRecallSkill(pi: ExtensionAPI): void {
552
+ pi.registerTool({
553
+ name: "wiki_recall_skill",
554
+ label: "Wiki Recall Skill",
555
+ description:
556
+ "Search distilled skills and past cases (agent working-memory) for patterns " +
557
+ "relevant to the current task — answers 'have I done something like this before?'. " +
558
+ "Filters layered recall to skill and case pages.",
559
+ promptSnippet: "Recall distilled skills and past cases relevant to the task",
560
+ promptGuidelines: [
561
+ "Use wiki_recall_skill at the START of a task to find reusable skills and similar past cases before doing the work.",
562
+ ],
563
+ parameters: Type.Object({
564
+ query: Type.String({ description: "Search query — use the task description or key terms" }),
565
+ kind: Type.Optional(
566
+ Type.Union([Type.Literal("skill"), Type.Literal("case"), Type.Literal("any")], {
567
+ description: "Filter to skills, cases, or both (default: any).",
568
+ }),
569
+ ),
570
+ max_results: Type.Optional(
571
+ Type.Number({ description: "Max results (default: 5, max: 10)", default: 5 }),
572
+ ),
573
+ }),
574
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
575
+ const paths = resolveVaultPaths(ctx.cwd ?? process.cwd());
576
+ if (!existsSync(join(paths.dotWiki, "config.json"))) return vaultMissing();
577
+
578
+ const maxResults = Math.min(params.max_results ?? 5, 10);
579
+ const kind = params.kind ?? "any";
580
+ // Over-fetch then filter by type, since searchWikiLayered is type-agnostic.
581
+ const raw = searchWikiLayered(paths, params.query, maxResults * 4, 0);
582
+ const wanted = kind === "any" ? ["skill", "case"] : [kind];
583
+ const results = raw.filter((r) => wanted.includes(r.type)).slice(0, maxResults);
584
+
585
+ if (results.length === 0) {
586
+ return {
587
+ content: [
588
+ {
589
+ type: "text",
590
+ text: `No ${kind === "any" ? "skills or cases" : `${kind}s`} found matching "${params.query}". Capture work with wiki_capture_trajectory and distill it with wiki_distill_skills.`,
591
+ },
592
+ ],
593
+ details: { query: params.query, kind, matches: [] } as Record<string, unknown>,
594
+ };
595
+ }
596
+
597
+ return {
598
+ content: [
599
+ {
600
+ type: "text",
601
+ text: `Found ${results.length} ${kind === "any" ? "skill/case" : kind} page(s) matching "${params.query}":\n\n${results
602
+ .map((r) => {
603
+ const vault = r.vaultLabel ? ` ${r.vaultLabel}` : "";
604
+ return `## [[${r.id}]] — ${r.title}${vault}\nType: ${r.type}\nPath: ${r.path}\n\n${r.preview}`;
605
+ })
606
+ .join("\n\n---\n\n")}`,
607
+ },
608
+ ],
609
+ details: { query: params.query, kind, matches: results } as Record<string, unknown>,
610
+ };
611
+ },
612
+ });
613
+ }