@zosmaai/pi-llm-wiki 0.9.1 → 0.9.3
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.
- package/CHANGELOG.md +1 -0
- package/README.de.md +2 -2
- package/README.es.md +2 -2
- package/README.fr.md +2 -2
- package/README.hi.md +2 -2
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +25 -2
- package/README.pt.md +2 -2
- package/README.ru.md +2 -2
- package/README.zh.md +2 -2
- package/docs/api.md +123 -10
- package/docs/architecture.md +38 -1
- package/docs/commands.md +21 -1
- package/extensions/llm-wiki/index.ts +51 -16
- package/extensions/llm-wiki/lib/inject.ts +27 -0
- package/extensions/llm-wiki/lib/metadata.ts +34 -1
- package/extensions/llm-wiki/lib/recall.ts +107 -8
- package/extensions/llm-wiki/lib/task-config.ts +85 -13
- package/extensions/llm-wiki/lib/tools.ts +120 -15
- package/extensions/llm-wiki/lib/trajectories-command.ts +67 -0
- package/extensions/llm-wiki/lib/trajectory.ts +613 -0
- package/extensions/llm-wiki/lib/utils.ts +20 -3
- package/extensions/llm-wiki/lib/visible-status.ts +51 -0
- package/package.json +1 -1
- package/prompts/wiki-record.md +36 -0
- package/prompts/wiki-run.md +4 -2
- package/prompts/wiki-skills.md +26 -0
- package/skills/llm-wiki/SKILL.md +70 -4
|
@@ -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
|
+
}
|
|
@@ -20,6 +20,7 @@ export interface VaultPaths {
|
|
|
20
20
|
root: string;
|
|
21
21
|
raw: string;
|
|
22
22
|
rawSources: string;
|
|
23
|
+
rawTrajectories: string;
|
|
23
24
|
wiki: string;
|
|
24
25
|
meta: string;
|
|
25
26
|
dotWiki: string;
|
|
@@ -157,6 +158,7 @@ export function getVaultPaths(root: string): VaultPaths {
|
|
|
157
158
|
root,
|
|
158
159
|
raw: join(root, ".llm-wiki", "raw"),
|
|
159
160
|
rawSources: join(root, ".llm-wiki", "raw", "sources"),
|
|
161
|
+
rawTrajectories: join(root, ".llm-wiki", "raw", "trajectories"),
|
|
160
162
|
wiki: join(root, ".llm-wiki", "wiki"),
|
|
161
163
|
meta: join(root, ".llm-wiki", "meta"),
|
|
162
164
|
dotWiki: join(root, ".llm-wiki"),
|
|
@@ -171,6 +173,7 @@ export function getLegacyVaultPaths(root: string): VaultPaths {
|
|
|
171
173
|
root,
|
|
172
174
|
raw: join(root, "raw"),
|
|
173
175
|
rawSources: join(root, "raw", "sources"),
|
|
176
|
+
rawTrajectories: join(root, "raw", "trajectories"),
|
|
174
177
|
wiki: join(root, "wiki"),
|
|
175
178
|
meta: join(root, "meta"),
|
|
176
179
|
dotWiki: join(root, ".wiki"),
|
|
@@ -192,6 +195,10 @@ export function resolveVaultPaths(cwd: string): VaultPaths {
|
|
|
192
195
|
|
|
193
196
|
/** Ensure all vault directories exist. */
|
|
194
197
|
export function ensureVaultStructure(paths: VaultPaths): void {
|
|
198
|
+
// NOTE: the agent-trajectory dirs (raw/trajectories, wiki/skills, wiki/cases)
|
|
199
|
+
// are intentionally NOT created here — they are created lazily on first
|
|
200
|
+
// capture/distill (issue #80), so a vault with the feature off carries no
|
|
201
|
+
// trace of it. All readers of these paths are existsSync-guarded.
|
|
195
202
|
const dirs = [
|
|
196
203
|
paths.rawSources,
|
|
197
204
|
join(paths.raw, "assets"),
|
|
@@ -238,12 +245,22 @@ export function readText(path: string): string {
|
|
|
238
245
|
|
|
239
246
|
/** Generate the next source ID. */
|
|
240
247
|
export function nextSourceId(paths: VaultPaths): string {
|
|
248
|
+
return nextSequentialId(paths.rawSources, "SRC");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Generate the next trajectory ID. */
|
|
252
|
+
export function nextTrajectoryId(paths: VaultPaths): string {
|
|
253
|
+
return nextSequentialId(paths.rawTrajectories, "TRJ");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Generate the next sequential, date-stamped packet ID for a raw subdir. */
|
|
257
|
+
function nextSequentialId(dir: string, kind: string): string {
|
|
241
258
|
const today = new Date().toISOString().split("T")[0];
|
|
242
|
-
const prefix =
|
|
259
|
+
const prefix = `${kind}-${today}`;
|
|
243
260
|
|
|
244
|
-
if (!existsSync(
|
|
261
|
+
if (!existsSync(dir)) return `${prefix}-001`;
|
|
245
262
|
|
|
246
|
-
const dirs = readdirSync(
|
|
263
|
+
const dirs = readdirSync(dir)
|
|
247
264
|
.filter((d) => d.startsWith(prefix))
|
|
248
265
|
.sort();
|
|
249
266
|
|