takomi 2.1.44 → 2.1.45

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 (39) hide show
  1. package/.pi/README.md +4 -3
  2. package/.pi/extensions/oauth-router/commands.ts +66 -35
  3. package/.pi/extensions/oauth-router/index.ts +51 -7
  4. package/.pi/extensions/oauth-router/report-ui.ts +205 -0
  5. package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
  6. package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
  7. package/.pi/extensions/takomi-context-manager/index.ts +3 -2
  8. package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +36 -15
  9. package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
  10. package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
  11. package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
  12. package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
  13. package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
  14. package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
  15. package/.pi/extensions/takomi-context-manager/types.ts +6 -0
  16. package/.pi/extensions/takomi-runtime/commands.ts +45 -12
  17. package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
  18. package/.pi/extensions/takomi-runtime/index.ts +111 -42
  19. package/.pi/extensions/takomi-runtime/routing-policy.ts +14 -2
  20. package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
  21. package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
  22. package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
  23. package/.pi/extensions/takomi-subagents/agents.ts +4 -0
  24. package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
  25. package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
  26. package/.pi/extensions/takomi-subagents/index.ts +46 -1
  27. package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
  28. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +55 -46
  29. package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
  30. package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
  31. package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
  32. package/.pi/extensions/takomi-subagents/tool-runner.ts +158 -28
  33. package/.pi/takomi/model-routing.md +282 -3
  34. package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
  35. package/package.json +4 -3
  36. package/src/pi-takomi-core/types.ts +10 -0
  37. package/src/pi-takomi-core/workflows.ts +86 -45
  38. package/src/skills-catalog.js +2 -202
  39. package/src/skills-installer.js +4 -1
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { getSkillCategory } from "./skill-categories";
4
5
  import type { SkillRecord } from "./types";
5
6
 
6
7
  export function normalizeText(value: string): string {
@@ -35,7 +36,10 @@ export function collectSkillsFromOptions(options: unknown): SkillRecord[] {
35
36
  return [{
36
37
  name,
37
38
  description: getString(item, ["description", "summary"]),
38
- location: getString(item, ["location", "path", "file", "skillPath"]),
39
+ location: getString(item, ["filePath", "location", "path", "file", "skillPath"]),
40
+ category: getString(item, ["category", "group", "taxonomy"]),
41
+ sourceCategory: getString(item, ["sourceCategory", "installerCategory"]),
42
+ packageName: getString(item, ["package", "packageName", "sourceSlug", "source"]),
39
43
  source: "systemPromptOptions",
40
44
  }];
41
45
  });
@@ -57,7 +61,15 @@ export function collectSkillsFromXml(systemPrompt: string): SkillRecord[] {
57
61
  for (const match of root[1].matchAll(/<skill>([\s\S]*?)<\/skill>/gi)) {
58
62
  const name = extractTag(match[1], "name");
59
63
  if (!name) continue;
60
- skills.push({ name, description: extractTag(match[1], "description"), location: extractTag(match[1], "location"), source: "xml" });
64
+ skills.push({
65
+ name,
66
+ description: extractTag(match[1], "description"),
67
+ location: extractTag(match[1], "location"),
68
+ category: extractTag(match[1], "category") ?? extractTag(match[1], "group"),
69
+ sourceCategory: extractTag(match[1], "source_category") ?? extractTag(match[1], "installer_category"),
70
+ packageName: extractTag(match[1], "package") ?? extractTag(match[1], "source"),
71
+ source: "xml",
72
+ });
61
73
  }
62
74
  return skills;
63
75
  }
@@ -71,14 +83,95 @@ export function mergeSkills(records: SkillRecord[]): Map<string, SkillRecord> {
71
83
  name: existing.name,
72
84
  description: existing.description ?? skill.description,
73
85
  location: existing.location ?? skill.location,
86
+ category: existing.category ?? skill.category,
87
+ sourceCategory: existing.sourceCategory ?? skill.sourceCategory,
88
+ packageName: existing.packageName ?? skill.packageName,
74
89
  source: existing.source === "systemPromptOptions" ? existing.source : skill.source,
75
90
  } : skill);
76
91
  }
77
92
  return merged;
78
93
  }
79
94
 
95
+ export function compareSkillText(a: string, b: string): number {
96
+ return normalizeName(a).localeCompare(normalizeName(b), "en") || a.localeCompare(b, "en");
97
+ }
98
+
80
99
  export function sortedSkills(skills: Map<string, SkillRecord>): SkillRecord[] {
81
- return [...skills.values()].sort((a, b) => a.name.localeCompare(b.name));
100
+ return [...skills.values()].sort((a, b) => compareSkillText(a.name, b.name));
101
+ }
102
+
103
+ export type SkillCategoryGroup = { category: string; skills: SkillRecord[] };
104
+
105
+ /** Safe, renderer-only skill fields. Keep discovery/source details out of tool results. */
106
+ export type SkillRenderProjection = Pick<SkillRecord, "name" | "description">;
107
+ export type SkillIndexRenderGroup = { category: string; skills: SkillRenderProjection[] };
108
+
109
+ function categorySlug(value: string | undefined): string | undefined {
110
+ const normalized = value?.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
111
+ return normalized || undefined;
112
+ }
113
+
114
+ function pathTaxonomyCategory(location: string | undefined): string | undefined {
115
+ if (!location) return undefined;
116
+ const segments = location.replace(/\\/g, "/").split("/").filter(Boolean);
117
+ const skillsIndex = segments.map((segment) => segment.toLowerCase()).lastIndexOf("skills");
118
+ if (skillsIndex < 0) return undefined;
119
+ // A categorized filesystem layout is .../skills/<category>/<skill>/SKILL.md.
120
+ // A normal flat skill root (.../skills/<skill>/SKILL.md) intentionally falls through.
121
+ const afterSkills = segments.slice(skillsIndex + 1);
122
+ return afterSkills.length >= 3 ? categorySlug(afterSkills[0]) : undefined;
123
+ }
124
+
125
+ function packageSlug(location: string | undefined): string | undefined {
126
+ if (!location) return undefined;
127
+ const segments = location.replace(/\\/g, "/").split("/").filter(Boolean);
128
+ const nodeModulesIndex = segments.map((segment) => segment.toLowerCase()).lastIndexOf("node_modules");
129
+ if (nodeModulesIndex < 0) return undefined;
130
+ const first = segments[nodeModulesIndex + 1];
131
+ if (!first) return undefined;
132
+ if (first.startsWith("@")) {
133
+ const second = segments[nodeModulesIndex + 2];
134
+ return second ? categorySlug(`${first.slice(1)}-${second}`) : categorySlug(first.slice(1));
135
+ }
136
+ return categorySlug(first);
137
+ }
138
+
139
+ /**
140
+ * Category precedence is deterministic and does not infer from a skill name:
141
+ * explicit skill metadata → installer/source taxonomy → path/package metadata
142
+ * → uncategorized.
143
+ */
144
+ export function skillCategory(skill: SkillRecord): string {
145
+ return categorySlug(skill.category)
146
+ ?? categorySlug(skill.sourceCategory)
147
+ ?? pathTaxonomyCategory(skill.location)
148
+ ?? categorySlug(skill.packageName)
149
+ ?? packageSlug(skill.location)
150
+ ?? "uncategorized";
151
+ }
152
+
153
+ export function groupedSkills(skills: Iterable<SkillRecord>): SkillCategoryGroup[] {
154
+ const grouped = new Map<string, SkillRecord[]>();
155
+ for (const skill of skills) {
156
+ const category = skillCategory(skill);
157
+ const entries = grouped.get(category) ?? [];
158
+ entries.push(skill);
159
+ grouped.set(category, entries);
160
+ }
161
+ return [...grouped.entries()]
162
+ .sort(([a], [b]) => compareSkillText(a, b))
163
+ .map(([category, entries]) => ({ category, skills: entries.sort((a, b) => compareSkillText(a.name, b.name)) }));
164
+ }
165
+
166
+ /**
167
+ * Project the registry into the only fields the skill-index renderer needs.
168
+ * Locations and discovery metadata must remain internal to the registry.
169
+ */
170
+ export function skillIndexRenderGroups(skills: Iterable<SkillRecord>): SkillIndexRenderGroup[] {
171
+ return groupedSkills(skills).map(({ category, skills: entries }) => ({
172
+ category,
173
+ skills: entries.map(({ name, description }) => ({ name, description })),
174
+ }));
82
175
  }
83
176
 
84
177
  export function findSkill(skills: Map<string, SkillRecord>, name: string): SkillRecord | undefined {
@@ -117,7 +210,14 @@ async function readSkillFile(filePath: string): Promise<SkillRecord | undefined>
117
210
  const name = frontmatter.name?.trim() || path.basename(path.dirname(filePath));
118
211
  const description = frontmatter.description?.trim();
119
212
  if (!name || !description) return undefined;
120
- return { name, description, location: filePath, source: "filesystem" };
213
+ return {
214
+ name,
215
+ description,
216
+ location: filePath,
217
+ category: frontmatter.category?.trim() || frontmatter.group?.trim(),
218
+ packageName: frontmatter.package?.trim() || frontmatter.source?.trim(),
219
+ source: "filesystem",
220
+ };
121
221
  } catch {
122
222
  return undefined;
123
223
  }
@@ -175,6 +275,77 @@ async function collectPackageSkillRoots(nodeModulesRoot: string): Promise<string
175
275
  return roots;
176
276
  }
177
277
 
278
+ export type InstallerTaxonomyEntry = { category: string; skillFilePath: string };
279
+
280
+ export type InstallerTaxonomyOptions = {
281
+ home?: string;
282
+ takomiHome?: string;
283
+ };
284
+
285
+ function canonicalPath(filePath: string): string {
286
+ const resolved = path.normalize(path.resolve(filePath));
287
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
288
+ }
289
+
290
+ /**
291
+ * Read Takomi's installer ownership registry without mutating or reconciling it.
292
+ * Legacy owned entries without category metadata use the tracked catalog's
293
+ * stable primary category, so upgrades do not require another installer run.
294
+ */
295
+ export async function readInstallerTaxonomy(options: InstallerTaxonomyOptions = {}): Promise<Map<string, InstallerTaxonomyEntry>> {
296
+ const home = options.home ?? os.homedir();
297
+ const manifestPath = path.join(options.takomiHome ?? process.env.TAKOMI_HOME_DIR ?? path.join(home, ".takomi"), "skills-manifest.json");
298
+ try {
299
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
300
+ targetRoot?: unknown;
301
+ owned?: Record<string, unknown>;
302
+ };
303
+ const targetRoot = typeof manifest.targetRoot === "string" && manifest.targetRoot.trim()
304
+ ? manifest.targetRoot
305
+ : path.join(home, ".agents", "skills");
306
+ const taxonomy = new Map<string, InstallerTaxonomyEntry>();
307
+ for (const [name, rawEntry] of Object.entries(manifest.owned ?? {})) {
308
+ if (!rawEntry || (typeof rawEntry !== "object" && typeof rawEntry !== "string")) continue;
309
+ const entry = typeof rawEntry === "object" ? rawEntry as Record<string, unknown> : {};
310
+ const manifestCategory = typeof entry.category === "string" && entry.category.trim()
311
+ ? entry.category.trim()
312
+ : undefined;
313
+ const category = manifestCategory ?? getSkillCategory(name);
314
+ if (!category) continue;
315
+ const targetPath = typeof entry.targetPath === "string" && entry.targetPath.trim()
316
+ ? entry.targetPath
317
+ : path.join(targetRoot, name);
318
+ taxonomy.set(normalizeName(name), {
319
+ category,
320
+ skillFilePath: canonicalPath(path.join(targetPath, "SKILL.md")),
321
+ });
322
+ }
323
+ return taxonomy;
324
+ } catch {
325
+ return new Map();
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Enrich records already supplied by Pi using exact name + canonical SKILL.md
331
+ * ownership. This remains independent from filesystem discovery and read-only.
332
+ */
333
+ export function applyInstallerTaxonomy(skills: SkillRecord[], taxonomy: Map<string, InstallerTaxonomyEntry>): SkillRecord[] {
334
+ return skills.map((skill) => {
335
+ const entry = taxonomy.get(normalizeName(skill.name));
336
+ if (!entry || !skill.location || canonicalPath(skill.location) !== entry.skillFilePath) return skill;
337
+ return { ...skill, sourceCategory: skill.sourceCategory ?? entry.category };
338
+ });
339
+ }
340
+
341
+ export async function enrichSkillsWithInstallerTaxonomy(
342
+ skills: SkillRecord[],
343
+ options: InstallerTaxonomyOptions = {},
344
+ ): Promise<SkillRecord[]> {
345
+ if (skills.length === 0) return skills;
346
+ return applyInstallerTaxonomy(skills, await readInstallerTaxonomy(options));
347
+ }
348
+
178
349
  function ancestorSkillRoots(cwd: string): string[] {
179
350
  const roots: string[] = [];
180
351
  let current = path.resolve(cwd || process.cwd());
@@ -190,8 +361,8 @@ function ancestorSkillRoots(cwd: string): string[] {
190
361
  return roots;
191
362
  }
192
363
 
193
- export async function discoverSkillsFromFilesystem(cwd = process.cwd()): Promise<SkillRecord[]> {
194
- const home = os.homedir();
364
+ export async function discoverSkillsFromFilesystem(cwd = process.cwd(), options: InstallerTaxonomyOptions = {}): Promise<SkillRecord[]> {
365
+ const home = options.home ?? os.homedir();
195
366
  const roots = [
196
367
  { root: path.join(home, ".pi", "agent", "skills"), directMarkdownFiles: true },
197
368
  { root: path.join(home, ".agents", "skills"), directMarkdownFiles: false },
@@ -202,6 +373,6 @@ export async function discoverSkillsFromFilesystem(cwd = process.cwd()): Promise
202
373
  for (const { root, directMarkdownFiles } of roots) {
203
374
  for (const file of await collectSkillFiles(root, directMarkdownFiles)) skillFiles.add(file);
204
375
  }
205
- const skills = await Promise.all([...skillFiles].map(readSkillFile));
206
- return skills.filter((skill): skill is SkillRecord => Boolean(skill));
376
+ const skills = (await Promise.all([...skillFiles].map(readSkillFile))).filter((skill): skill is SkillRecord => Boolean(skill));
377
+ return enrichSkillsWithInstallerTaxonomy(skills, options);
207
378
  }
@@ -1,103 +1,208 @@
1
- import { readFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { Type } from "typebox";
5
- import type { ContextManagerState } from "./state";
6
- import { discoverSkillsFromFilesystem, findSkill, mergeSkills, normalizeName, sortedSkills } from "./skill-registry";
7
- import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
8
-
9
- function renderSkillIndex(state: ContextManagerState): string {
10
- const skills = sortedSkills(state.skills);
11
- if (skills.length === 0) return "Available skills (names only): none discovered.";
12
- return ["Available skills (names only):", ...skills.map((skill) => `- ${skill.name}`)].join("\n");
13
- }
14
-
15
- function renderManifest(state: ContextManagerState, names: string[]): string {
16
- if (names.length === 0) return "No skills requested.";
17
- return names.map((name) => {
18
- const skill = findSkill(state.skills, name);
19
- if (!skill) {
20
- const close = sortedSkills(state.skills).filter((candidate) => normalizeName(candidate.name).includes(normalizeName(name).slice(0, 4))).slice(0, 5).map((candidate) => candidate.name);
21
- return [`Skill not found: ${name}`, close.length ? `Known close matches: ${close.join(", ")}` : ""].filter(Boolean).join("\n");
22
- }
23
- return [`Skill: ${skill.name}`, `Description: ${skill.description ?? "(no description discovered)"}`, `Location: ${skill.location ?? "(no location discovered)"}`].join("\n");
24
- }).join("\n\n");
25
- }
26
-
27
- async function loadSkillContent(location: string): Promise<string> {
28
- const fileName = path.basename(location).toLowerCase();
29
- if (fileName !== "skill.md" && !location.toLowerCase().endsWith(".md")) throw new Error(`Refusing to load non-markdown skill location: ${location}`);
30
- return readFile(location, "utf8");
31
- }
32
-
33
- async function ensureSkillsDiscovered(state: ContextManagerState, cwd: string): Promise<void> {
34
- if (state.skills.size > 0) return;
35
- state.skills = mergeSkills(await discoverSkillsFromFilesystem(cwd));
36
- state.report.skillCount = state.skills.size;
37
- }
38
-
39
- export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState): void {
40
- pi.registerTool({
41
- name: "skill_index",
42
- label: "Skill Index",
43
- description: "Return the available skill names only. Use this to inspect capability names without loading descriptions or full instructions.",
44
- promptSnippet: "List available skill names only for progressive skill loading",
45
- parameters: Type.Object({}),
46
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
47
- restoreReportFromSession(state, ctx);
48
- await ensureSkillsDiscovered(state, ctx.cwd);
49
- state.report.timestamp = new Date().toISOString();
50
- state.report.cwd = ctx.cwd;
51
- state.report.toolCalls.skillIndex += 1;
52
- persistReportSnapshot(pi, state, "skill_index");
53
- return { content: [{ type: "text", text: renderSkillIndex(state) }], details: { skillCount: state.skills.size } };
54
- },
55
- });
56
-
57
- pi.registerTool({
58
- name: "skill_manifest",
59
- label: "Skill Manifest",
60
- description: "Return descriptions and locations for selected skills without loading full SKILL.md instructions.",
61
- promptSnippet: "Show selected skill descriptions and locations without full instructions",
62
- parameters: Type.Object({ skills: Type.Array(Type.String({ description: "Skill name to inspect" })) }),
63
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
64
- restoreReportFromSession(state, ctx);
65
- await ensureSkillsDiscovered(state, ctx.cwd);
66
- state.report.timestamp = new Date().toISOString();
67
- state.report.cwd = ctx.cwd;
68
- state.report.toolCalls.skillManifest += 1;
69
- persistReportSnapshot(pi, state, "skill_manifest");
70
- return { content: [{ type: "text", text: renderManifest(state, params.skills) }], details: { requested: params.skills } };
71
- },
72
- });
73
-
74
- pi.registerTool({
75
- name: "skill_load",
76
- label: "Skill Load",
77
- description: "Load the full SKILL.md content for one selected skill that will actually be used.",
78
- promptSnippet: "Load full SKILL.md instructions for one selected skill",
79
- parameters: Type.Object({ skill: Type.String({ description: "Exact skill name to load" }) }),
80
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
81
- restoreReportFromSession(state, ctx);
82
- await ensureSkillsDiscovered(state, ctx.cwd);
83
- state.report.timestamp = new Date().toISOString();
84
- state.report.cwd = ctx.cwd;
85
- state.report.toolCalls.skillLoad += 1;
86
- const skill = findSkill(state.skills, params.skill);
87
- if (!skill?.location) {
88
- persistReportSnapshot(pi, state, "skill_load_not_found");
89
- return { content: [{ type: "text", text: renderManifest(state, [params.skill]) }], details: { found: false, requested: params.skill }, isError: true };
90
- }
91
- try {
92
- const content = await loadSkillContent(skill.location);
93
- state.report.loadedByTool = [...new Set([...state.report.loadedByTool, skill.name])].sort();
94
- persistReportSnapshot(pi, state, "skill_load");
95
- return { content: [{ type: "text", text: [`Skill: ${skill.name}`, `Location: ${skill.location}`, "", content].join("\n") }], details: { found: true, skill: skill.name, location: skill.location } };
96
- } catch (error) {
97
- const message = error instanceof Error ? error.message : String(error);
98
- persistReportSnapshot(pi, state, "skill_load_error");
99
- return { content: [{ type: "text", text: message }], details: { found: true, skill: skill.name, error: message }, isError: true };
100
- }
101
- },
102
- });
103
- }
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Markdown, Text } from "@earendil-works/pi-tui";
5
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+ import type { ContextManagerState } from "./state";
8
+ import { discoverSkillsFromFilesystem, findSkill, mergeSkills, normalizeName, skillIndexRenderGroups, sortedSkills, type SkillIndexRenderGroup } from "./skill-registry";
9
+ import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
10
+ import { renderCompactCard, renderExpandedMarkdown, renderToolCall, resultText, sanitizePresentation } from "./tool-renderers";
11
+
12
+ function renderSkillIndex(state: ContextManagerState): string {
13
+ const skills = sortedSkills(state.skills);
14
+ if (skills.length === 0) return "Available skills (names only): none discovered.";
15
+ return ["Available skills (names only):", ...skills.map((skill) => `- ${skill.name}`)].join("\n");
16
+ }
17
+
18
+ function renderManifest(state: ContextManagerState, names: string[]): string {
19
+ if (names.length === 0) return "No skills requested.";
20
+ return names.map((name) => {
21
+ const skill = findSkill(state.skills, name);
22
+ if (!skill) {
23
+ const close = sortedSkills(state.skills).filter((candidate) => normalizeName(candidate.name).includes(normalizeName(name).slice(0, 4))).slice(0, 5).map((candidate) => candidate.name);
24
+ return [`Skill not found: ${name}`, close.length ? `Known close matches: ${close.join(", ")}` : ""].filter(Boolean).join("\n");
25
+ }
26
+ return [`Skill: ${skill.name}`, `Description: ${skill.description ?? "(no description discovered)"}`, `Location: ${skill.location ?? "(no location discovered)"}`].join("\n");
27
+ }).join("\n\n");
28
+ }
29
+
30
+ const COMPACT_CATEGORY_LIMIT = 3;
31
+
32
+ function groupSummaryItems(groups: SkillIndexRenderGroup[], maximum = groups.length): string[] {
33
+ const visible = groups.slice(0, maximum).map((group) => `${group.category} ${group.skills.length}`);
34
+ const overflow = groups.length - visible.length;
35
+ return [...visible, ...(overflow > 0 ? [`+${overflow} more categories`] : [])];
36
+ }
37
+
38
+ function groupSummary(groups: SkillIndexRenderGroup[], maximum = groups.length): string {
39
+ return groupSummaryItems(groups, maximum).join(" · ");
40
+ }
41
+
42
+ function renderGroupedSkillIndex(groups: SkillIndexRenderGroup[]): string {
43
+ if (groups.length === 0) return "No skills discovered.";
44
+ return groups.map((group) => [
45
+ `## ${group.category}`,
46
+ "",
47
+ ...group.skills.map((skill) => `- \`${skill.name}\`${skill.description ? ` — ${skill.description}` : ""}`),
48
+ ].join("\n")).join("\n\n");
49
+ }
50
+
51
+ function skillMarkdown(text: string): string {
52
+ const separator = text.indexOf("\n\n");
53
+ return separator >= 0 ? text.slice(separator + 2) : text;
54
+ }
55
+
56
+ async function loadSkillContent(location: string): Promise<string> {
57
+ const fileName = path.basename(location).toLowerCase();
58
+ if (fileName !== "skill.md" && !location.toLowerCase().endsWith(".md")) throw new Error(`Refusing to load non-markdown skill location: ${location}`);
59
+ return readFile(location, "utf8");
60
+ }
61
+
62
+ async function ensureSkillsDiscovered(state: ContextManagerState, cwd: string): Promise<void> {
63
+ if (state.skills.size > 0) return;
64
+ state.skills = mergeSkills(await discoverSkillsFromFilesystem(cwd));
65
+ state.report.skillCount = state.skills.size;
66
+ }
67
+
68
+ export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState): void {
69
+ pi.registerTool({
70
+ name: "skill_index",
71
+ label: "Skill Index",
72
+ description: "Return the available skill names only. Use this to inspect capability names without loading descriptions or full instructions.",
73
+ promptSnippet: "List available skill names only for progressive skill loading",
74
+ parameters: Type.Object({}),
75
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
76
+ restoreReportFromSession(state, ctx);
77
+ await ensureSkillsDiscovered(state, ctx.cwd);
78
+ state.report.timestamp = new Date().toISOString();
79
+ state.report.cwd = ctx.cwd;
80
+ state.report.toolCalls.skillIndex += 1;
81
+ persistReportSnapshot(pi, state, "skill_index");
82
+ const groups = skillIndexRenderGroups(state.skills.values());
83
+ return { content: [{ type: "text", text: renderSkillIndex(state) }], details: { skillCount: state.skills.size, groups } };
84
+ },
85
+ renderCall(_args, theme) {
86
+ return renderToolCall("skill_index", undefined, theme);
87
+ },
88
+ renderResult(result, { expanded }, theme) {
89
+ const details = result.details as { skillCount?: number; groups?: SkillIndexRenderGroup[] } | undefined;
90
+ const groups = details?.groups ?? [];
91
+ const count = details?.skillCount ?? groups.reduce((total, group) => total + group.skills.length, 0);
92
+ if (!expanded) {
93
+ return renderCompactCard({
94
+ status: count ? "success" : "pending",
95
+ title: "Skill index",
96
+ summary: count ? `${count} skills across ${groups.length} categories` : "no skills discovered",
97
+ responsiveMetadata: groups.length ? groupSummaryItems(groups, COMPACT_CATEGORY_LIMIT) : undefined,
98
+ }, theme);
99
+ }
100
+ return renderExpandedMarkdown({
101
+ status: count ? "success" : "pending",
102
+ title: "Skill index",
103
+ summary: count ? `${count} skills across ${groups.length} categories` : "no skills discovered",
104
+ metadata: groups.length ? [groupSummary(groups)] : undefined,
105
+ markdown: renderGroupedSkillIndex(groups),
106
+ }, theme);
107
+ },
108
+ });
109
+
110
+ pi.registerTool({
111
+ name: "skill_manifest",
112
+ label: "Skill Manifest",
113
+ description: "Return descriptions and locations for selected skills without loading full SKILL.md instructions.",
114
+ promptSnippet: "Show selected skill descriptions and locations without full instructions",
115
+ parameters: Type.Object({ skills: Type.Array(Type.String({ description: "Skill name to inspect" })) }),
116
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
117
+ restoreReportFromSession(state, ctx);
118
+ await ensureSkillsDiscovered(state, ctx.cwd);
119
+ state.report.timestamp = new Date().toISOString();
120
+ state.report.cwd = ctx.cwd;
121
+ state.report.toolCalls.skillManifest += 1;
122
+ persistReportSnapshot(pi, state, "skill_manifest");
123
+ const found = params.skills.filter((name) => Boolean(findSkill(state.skills, name)));
124
+ const missing = params.skills.filter((name) => !findSkill(state.skills, name));
125
+ return { content: [{ type: "text", text: renderManifest(state, params.skills) }], details: { requested: params.skills, found, missing } };
126
+ },
127
+ renderCall(args, theme) {
128
+ return renderToolCall("skill_manifest", `${args.skills.length} requested`, theme);
129
+ },
130
+ renderResult(result, { expanded }, theme) {
131
+ const details = result.details as { requested?: string[]; found?: string[]; missing?: string[] } | undefined;
132
+ const requested = details?.requested ?? [];
133
+ const found = details?.found ?? [];
134
+ const missing = details?.missing ?? [];
135
+ const status = missing.length ? "warning" : requested.length ? "success" : "pending";
136
+ const summary = missing.length ? `${found.length} found · ${missing.length} unavailable` : requested.length ? `${found.length} skills available` : "no skills requested";
137
+ const metadata = missing.length ? `Missing: ${missing.join(", ")}` : `${requested.length} requested`;
138
+ if (!expanded) return renderCompactCard({ status, title: "Skill manifest", summary, metadata }, theme);
139
+ return renderExpandedMarkdown({ status, title: "Skill manifest", summary, metadata: [metadata], markdown: resultText(result) }, theme);
140
+ },
141
+ });
142
+
143
+ pi.registerTool({
144
+ name: "skill_load",
145
+ label: "Skill Load",
146
+ description: "Load the full SKILL.md content for one selected skill that will actually be used.",
147
+ promptSnippet: "Load full SKILL.md instructions for one selected skill",
148
+ parameters: Type.Object({ skill: Type.String({ description: "Exact skill name to load" }) }),
149
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
150
+ restoreReportFromSession(state, ctx);
151
+ await ensureSkillsDiscovered(state, ctx.cwd);
152
+ state.report.timestamp = new Date().toISOString();
153
+ state.report.cwd = ctx.cwd;
154
+ state.report.toolCalls.skillLoad += 1;
155
+ const skill = findSkill(state.skills, params.skill);
156
+ if (!skill?.location) {
157
+ persistReportSnapshot(pi, state, "skill_load_not_found");
158
+ return { content: [{ type: "text", text: renderManifest(state, [params.skill]) }], details: { found: false, requested: params.skill }, isError: true };
159
+ }
160
+ try {
161
+ const content = await loadSkillContent(skill.location);
162
+ state.report.loadedByTool = [...new Set([...state.report.loadedByTool, skill.name])].sort();
163
+ persistReportSnapshot(pi, state, "skill_load");
164
+ return {
165
+ content: [{ type: "text", text: [`Skill: ${skill.name}`, `Location: ${skill.location}`, "", content].join("\n") }],
166
+ details: {
167
+ found: true,
168
+ skill: skill.name,
169
+ description: skill.description,
170
+ location: skill.location,
171
+ lineCount: content.split(/\r?\n/).length,
172
+ },
173
+ };
174
+ } catch (error) {
175
+ const message = error instanceof Error ? error.message : String(error);
176
+ persistReportSnapshot(pi, state, "skill_load_error");
177
+ return { content: [{ type: "text", text: message }], details: { found: true, skill: skill.name, error: message }, isError: true };
178
+ }
179
+ },
180
+ renderCall(args, theme) {
181
+ return renderToolCall("skill_load", args.skill, theme);
182
+ },
183
+ renderResult(result, { expanded }, theme) {
184
+ const details = result.details as { found?: boolean; skill?: string; description?: string; location?: string; lineCount?: number; error?: string } | undefined;
185
+ const text = resultText(result);
186
+ if (details?.error || details?.found === false) {
187
+ const summary = details?.error ?? "skill not found";
188
+ if (!expanded) return renderCompactCard({ status: "error", title: "Skill load", summary }, theme);
189
+ return renderExpandedMarkdown({ status: "error", title: "Skill load", summary, markdown: text }, theme);
190
+ }
191
+
192
+ const name = sanitizePresentation(details?.skill ?? "skill");
193
+ const description = details?.description ? sanitizePresentation(details.description) : undefined;
194
+ const summary = description ?? "skill instructions loaded";
195
+ const metadata = `${details?.lineCount ?? text.split(/\r?\n/).length} lines`;
196
+ if (!expanded) return renderCompactCard({ status: "success", title: name, summary, metadata }, theme);
197
+
198
+ const location = details?.location ? sanitizePresentation(details.location) : undefined;
199
+ const container = new Container();
200
+ container.addChild(new Text(`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(name))} ${theme.fg("muted", "skill instructions")}`, 0, 0));
201
+ if (description) container.addChild(new Text(theme.fg("muted", description), 0, 0));
202
+ if (location) container.addChild(new Text(theme.fg("dim", location), 0, 0));
203
+ container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "collapse")), 0, 0));
204
+ container.addChild(new Markdown(sanitizePresentation(skillMarkdown(text)), 0, 1, getMarkdownTheme()));
205
+ return container;
206
+ },
207
+ });
208
+ }