@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.
@@ -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 = `SRC-${today}`;
259
+ const prefix = `${kind}-${today}`;
243
260
 
244
- if (!existsSync(paths.rawSources)) return `${prefix}-001`;
261
+ if (!existsSync(dir)) return `${prefix}-001`;
245
262
 
246
- const dirs = readdirSync(paths.rawSources)
263
+ const dirs = readdirSync(dir)
247
264
  .filter((d) => d.startsWith(prefix))
248
265
  .sort();
249
266