@zaganjade/pi-multi-skill 1.3.0 → 1.3.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.
package/src/build.ts CHANGED
@@ -1,270 +1,352 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { homedir } from "node:os";
4
- import type { BuildOptions, EnrichedSkillInfo, LoadMode } from "./types.ts";
5
- import { stripFrontmatter } from "./metadata.ts";
6
- import { buildParallelDispatchBlock } from "./subagents.ts";
7
-
8
- const SUBAGENT_STOP = "<SUBAGENT-STOP>";
9
- const SKILL_CHECK_MARKERS = [
10
- "If you think there is even a 1% chance a skill might apply",
11
- "Invoke relevant or requested skills BEFORE any response",
12
- ];
13
-
14
- function getAgentDir(): string {
15
- return join(homedir(), ".pi", "agent");
16
- }
17
-
18
- function readSkillFile(skill: EnrichedSkillInfo, cwd: string): string | null {
19
- try {
20
- return readFileSync(skill.filePath, "utf-8");
21
- } catch {
22
- // fall through
23
- }
24
-
25
- const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
26
- for (const dir of dirs) {
27
- for (const candidate of [
28
- join(dir, skill.name, "SKILL.md"),
29
- join(dir, `${skill.name}.md`),
30
- ]) {
31
- try {
32
- return readFileSync(candidate, "utf-8");
33
- } catch {
34
- // continue
35
- }
36
- }
37
- }
38
- return null;
39
- }
40
-
41
- function extractCommandsSection(body: string): string {
42
- const match = body.match(
43
- /## Available Commands[\s\S]*?(?=\n## |\n# |$)/i,
44
- );
45
- return match ? match[0].trim() : "";
46
- }
47
-
48
- function formatMetaBlock(skill: EnrichedSkillInfo): string {
49
- const commands = skill.metadata.commands.length
50
- ? `\n\nCommands: ${skill.metadata.commands.join(", ")}`
51
- : "";
52
- const commandsSection = extractCommandsSection(skill.body);
53
- const sectionText = commandsSection ? `\n\n${commandsSection}` : "";
54
-
55
- return `<skill name="${skill.name}" location="${skill.filePath}" mode="meta">
56
- ${skill.metadata.description}${commands}${sectionText}
57
- </skill>`;
58
- }
59
-
60
- function formatLazyBlock(skill: EnrichedSkillInfo, body: string): string {
61
- const baseDir = dirname(skill.filePath);
62
- const intro = body
63
- .split("\n")
64
- .filter((l) => l.trim() && !l.startsWith("#"))
65
- .slice(0, 8)
66
- .join("\n");
67
-
68
- return `<skill name="${skill.name}" location="${skill.filePath}" mode="lazy">
69
- References are relative to ${baseDir}. Load reference files with \`read\` only when the active workflow step requires them.
70
-
71
- ${intro}
72
-
73
- ${extractCommandsSection(body)}
74
- </skill>`;
75
- }
76
-
77
- function formatFullBlock(skill: EnrichedSkillInfo, body: string): string {
78
- const baseDir = dirname(skill.filePath);
79
- return `<skill name="${skill.name}" location="${skill.filePath}" mode="full">
80
- References are relative to ${baseDir}.
81
-
82
- ${body}
83
- </skill>`;
84
- }
85
-
86
- function dedupeBody(name: string, body: string, seen: Set<string>): string {
87
- let result = body;
88
-
89
- if (body.includes(SUBAGENT_STOP)) {
90
- if (seen.has(SUBAGENT_STOP)) {
91
- result = result.replace(
92
- new RegExp(`${SUBAGENT_STOP}[\\s\\S]*?${SUBAGENT_STOP}`, "g"),
93
- "",
94
- );
95
- } else {
96
- seen.add(SUBAGENT_STOP);
97
- }
98
- }
99
-
100
- if (name !== "using-superpowers") {
101
- for (const marker of SKILL_CHECK_MARKERS) {
102
- if (result.includes(marker) && seen.has(marker)) {
103
- result = result.replace(
104
- new RegExp(`## Instruction Priority[\\s\\S]*?(?=\\n## |$)`, "i"),
105
- "",
106
- );
107
- }
108
- if (result.includes(marker)) seen.add(marker);
109
- }
110
- }
111
-
112
- return result.trim();
113
- }
114
-
115
- function renderSkillBlock(
116
- skill: EnrichedSkillInfo,
117
- cwd: string,
118
- mode: LoadMode,
119
- seenDedup: Set<string>,
120
- ): string | null {
121
- const effectiveMode = skill.metadata.tokenBudget ?? mode;
122
- const raw = readSkillFile(skill, cwd);
123
- if (!raw) return null;
124
-
125
- const body = dedupeBody(skill.name, stripFrontmatter(raw).trim(), seenDedup);
126
- switch (effectiveMode) {
127
- case "meta":
128
- return formatMetaBlock(skill);
129
- case "lazy":
130
- return formatLazyBlock(skill, body);
131
- default:
132
- return formatFullBlock(skill, body);
133
- }
134
- }
135
-
136
- export function buildCombinedMessage(
137
- skills: EnrichedSkillInfo[],
138
- cwd: string,
139
- options: BuildOptions,
140
- ): { message: string; notFound: string[]; skippedDuplicates: string[] } {
141
- const expandedBlocks: string[] = [];
142
- const notFound: string[] = [];
143
- const seenDedup = new Set<string>();
144
- const skippedDuplicates: string[] = [];
145
-
146
- for (const skill of skills) {
147
- const before = seenDedup.size;
148
- const block = renderSkillBlock(skill, cwd, options.mode, seenDedup);
149
- if (!block) {
150
- notFound.push(skill.name);
151
- continue;
152
- }
153
- if (seenDedup.size > before && before > 0) {
154
- skippedDuplicates.push(`${skill.name} (deduplicated sections)`);
155
- }
156
- expandedBlocks.push(block);
157
- }
158
-
159
- const skillNames = skills.map((s) => s.name).join(", ");
160
- const bundleAttr =
161
- options.bundles && options.bundles.length > 0
162
- ? ` bundles="${options.bundles.map((b) => `@${b}`).join(",")}"`
163
- : "";
164
- const parts: string[] = [
165
- `<manually_attached_skills count="${skills.length}"${bundleAttr}>`,
166
- "The user activated multiple skills. Follow ALL of them before responding.",
167
- "Priority: process skills → planning → implementation. User instructions override conflicts.",
168
- `Skills: ${skillNames}`,
169
- `Load mode: ${options.mode}`,
170
- ];
171
-
172
- if (options.parallel) {
173
- const tasks =
174
- options.parallelTasks && options.parallelTasks.length > 0
175
- ? options.parallelTasks
176
- : options.instructions
177
- ? [options.instructions]
178
- : [];
179
- parts.push(
180
- buildParallelDispatchBlock({
181
- tasks,
182
- subagentAvailable: options.subagentAvailable ?? false,
183
- }),
184
- );
185
- }
186
-
187
- parts.push("");
188
- parts.push(...expandedBlocks);
189
-
190
- if (options.bmadStatusBlock) {
191
- parts.push("");
192
- parts.push(options.bmadStatusBlock);
193
- }
194
-
195
- if (options.embeddedCommand) {
196
- parts.push("");
197
- parts.push(
198
- `<embedded_command>${options.embeddedCommand}</embedded_command>`,
199
- );
200
- parts.push(
201
- "Execute the embedded command workflow as part of fulfilling the user request.",
202
- );
203
- }
204
-
205
- if (options.instructions) {
206
- parts.push("");
207
- parts.push("<user_query>");
208
- parts.push(options.instructions);
209
- parts.push("</user_query>");
210
- }
211
-
212
- if (notFound.length > 0) {
213
- parts.push("");
214
- parts.push(`> ⚠️ Skills not found: ${notFound.join(", ")}`);
215
- }
216
-
217
- if (options.conflictWarnings?.length) {
218
- parts.push("");
219
- parts.push(`> ⚠️ Conflicts: ${options.conflictWarnings.join("; ")}`);
220
- }
221
-
222
- if (options.skippedDuplicates?.length) {
223
- parts.push("");
224
- parts.push(
225
- `> ℹ️ Deduplicated: ${options.skippedDuplicates.join(", ")}`,
226
- );
227
- }
228
-
229
- parts.push("</manually_attached_skills>");
230
-
231
- return {
232
- message: parts.join("\n"),
233
- notFound,
234
- skippedDuplicates,
235
- };
236
- }
237
-
238
- export function resolveAndReadLegacySkill(
239
- skillName: string,
240
- filePath: string,
241
- cwd: string,
242
- ): string | null {
243
- if (existsSync(filePath)) {
244
- try {
245
- const content = readFileSync(filePath, "utf-8");
246
- const body = stripFrontmatter(content).trim();
247
- const baseDir = dirname(filePath);
248
- return `<skill name="${skillName}" location="${filePath}">\nReferences are relative to ${baseDir}.\n\n${body}\n</skill>`;
249
- } catch {
250
- // fall through
251
- }
252
- }
253
-
254
- const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
255
- for (const dir of dirs) {
256
- for (const candidate of [
257
- join(dir, skillName, "SKILL.md"),
258
- join(dir, `${skillName}.md`),
259
- ]) {
260
- try {
261
- const content = readFileSync(candidate, "utf-8");
262
- const body = stripFrontmatter(content).trim();
263
- return `<skill name="${skillName}" location="${candidate}">\nReferences are relative to ${dirname(candidate)}.\n\n${body}\n</skill>`;
264
- } catch {
265
- // continue
266
- }
267
- }
268
- }
269
- return null;
270
- }
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import type { BuildOptions, EnrichedSkillInfo, LoadMode } from "./types.ts";
5
+ import { stripFrontmatter } from "./metadata.ts";
6
+ import { buildParallelDispatchBlock } from "./subagents.ts";
7
+
8
+ const SUBAGENT_STOP = "<SUBAGENT-STOP>";
9
+ const MULTI_SKILL_LOCATION = "pi-multi-skill";
10
+ const SKILL_CHECK_MARKERS = [
11
+ "If you think there is even a 1% chance a skill might apply",
12
+ "Invoke relevant or requested skills BEFORE any response",
13
+ ];
14
+
15
+ /** Pi TUI collapses user messages that match this envelope (see pi-coding-agent parseSkillBlock). */
16
+ const PI_SKILL_BLOCK_RE =
17
+ /^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/;
18
+
19
+ function getAgentDir(): string {
20
+ return join(homedir(), ".pi", "agent");
21
+ }
22
+
23
+ function escapeXml(text: string): string {
24
+ return text
25
+ .replace(/&/g, "&amp;")
26
+ .replace(/"/g, "&quot;")
27
+ .replace(/</g, "&lt;");
28
+ }
29
+
30
+ /** Wrap content in Pi's native skill block so the TUI shows `[skill] name`. */
31
+ export function formatPiSkillBlock(
32
+ name: string,
33
+ location: string,
34
+ content: string,
35
+ ): string {
36
+ return `<skill name="${escapeXml(name)}" location="${escapeXml(location)}">\n${content}\n</skill>`;
37
+ }
38
+
39
+ export function isPiSkillBlock(text: string): boolean {
40
+ return PI_SKILL_BLOCK_RE.test(text);
41
+ }
42
+
43
+ function formatCollapsedSkillName(skills: EnrichedSkillInfo[]): string {
44
+ const names = skills.map((s) => s.name);
45
+ if (names.length === 1) return names[0];
46
+ if (names.length <= 3) return names.join(", ");
47
+ return `${names.slice(0, 2).join(", ")} +${names.length - 2} more`;
48
+ }
49
+
50
+ function readSkillFile(skill: EnrichedSkillInfo, cwd: string): string | null {
51
+ try {
52
+ return readFileSync(skill.filePath, "utf-8");
53
+ } catch {
54
+ // fall through
55
+ }
56
+
57
+ const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
58
+ for (const dir of dirs) {
59
+ for (const candidate of [
60
+ join(dir, skill.name, "SKILL.md"),
61
+ join(dir, `${skill.name}.md`),
62
+ ]) {
63
+ try {
64
+ return readFileSync(candidate, "utf-8");
65
+ } catch {
66
+ // continue
67
+ }
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function extractCommandsSection(body: string): string {
74
+ const match = body.match(
75
+ /## Available Commands[\s\S]*?(?=\n## |\n# |$)/i,
76
+ );
77
+ return match ? match[0].trim() : "";
78
+ }
79
+
80
+ function formatMetaBody(skill: EnrichedSkillInfo): string {
81
+ const commands = skill.metadata.commands.length
82
+ ? `\n\nCommands: ${skill.metadata.commands.join(", ")}`
83
+ : "";
84
+ const commandsSection = extractCommandsSection(skill.body);
85
+ const sectionText = commandsSection ? `\n\n${commandsSection}` : "";
86
+
87
+ return `(load mode: meta)\n\n${skill.metadata.description}${commands}${sectionText}`;
88
+ }
89
+
90
+ function formatLazyBody(skill: EnrichedSkillInfo, body: string): string {
91
+ const baseDir = dirname(skill.filePath);
92
+ const intro = body
93
+ .split("\n")
94
+ .filter((l) => l.trim() && !l.startsWith("#"))
95
+ .slice(0, 8)
96
+ .join("\n");
97
+
98
+ return [
99
+ `(load mode: lazy)`,
100
+ `References are relative to ${baseDir}. Load reference files with \`read\` only when the active workflow step requires them.`,
101
+ "",
102
+ intro,
103
+ extractCommandsSection(body),
104
+ ]
105
+ .filter(Boolean)
106
+ .join("\n");
107
+ }
108
+
109
+ function formatFullBody(skill: EnrichedSkillInfo, body: string): string {
110
+ const baseDir = dirname(skill.filePath);
111
+ return `References are relative to ${baseDir}.\n\n${body}`;
112
+ }
113
+
114
+ function dedupeBody(name: string, body: string, seen: Set<string>): string {
115
+ let result = body;
116
+
117
+ if (body.includes(SUBAGENT_STOP)) {
118
+ if (seen.has(SUBAGENT_STOP)) {
119
+ result = result.replace(
120
+ new RegExp(`${SUBAGENT_STOP}[\\s\\S]*?${SUBAGENT_STOP}`, "g"),
121
+ "",
122
+ );
123
+ } else {
124
+ seen.add(SUBAGENT_STOP);
125
+ }
126
+ }
127
+
128
+ if (name !== "using-superpowers") {
129
+ for (const marker of SKILL_CHECK_MARKERS) {
130
+ if (result.includes(marker) && seen.has(marker)) {
131
+ result = result.replace(
132
+ new RegExp(`## Instruction Priority[\\s\\S]*?(?=\\n## |$)`, "i"),
133
+ "",
134
+ );
135
+ }
136
+ if (result.includes(marker)) seen.add(marker);
137
+ }
138
+ }
139
+
140
+ return result.trim();
141
+ }
142
+
143
+ function renderSkillBlock(
144
+ skill: EnrichedSkillInfo,
145
+ cwd: string,
146
+ mode: LoadMode,
147
+ seenDedup: Set<string>,
148
+ ): string | null {
149
+ const effectiveMode = skill.metadata.tokenBudget ?? mode;
150
+ const raw = readSkillFile(skill, cwd);
151
+ if (!raw) return null;
152
+
153
+ const body = dedupeBody(skill.name, stripFrontmatter(raw).trim(), seenDedup);
154
+ let content: string;
155
+ switch (effectiveMode) {
156
+ case "meta":
157
+ content = formatMetaBody(skill);
158
+ break;
159
+ case "lazy":
160
+ content = formatLazyBody(skill, body);
161
+ break;
162
+ default:
163
+ content = formatFullBody(skill, body);
164
+ }
165
+
166
+ return formatPiSkillBlock(skill.name, skill.filePath, content);
167
+ }
168
+
169
+ function buildAgentPayload(
170
+ skills: EnrichedSkillInfo[],
171
+ expandedBlocks: string[],
172
+ options: BuildOptions,
173
+ notFound: string[],
174
+ skippedDuplicates: string[],
175
+ ): string {
176
+ const skillNames = skills.map((s) => s.name).join(", ");
177
+ const bundleAttr =
178
+ options.bundles && options.bundles.length > 0
179
+ ? ` bundles="${options.bundles.map((b) => `@${b}`).join(",")}"`
180
+ : "";
181
+ const parts: string[] = [
182
+ `<manually_attached_skills count="${skills.length}"${bundleAttr}>`,
183
+ "The user activated multiple skills. Follow ALL of them before responding.",
184
+ "Priority: process skills → planning → implementation. User instructions override conflicts.",
185
+ `Skills: ${skillNames}`,
186
+ `Load mode: ${options.mode}`,
187
+ ];
188
+
189
+ if (options.parallel) {
190
+ const tasks =
191
+ options.parallelTasks && options.parallelTasks.length > 0
192
+ ? options.parallelTasks
193
+ : options.instructions
194
+ ? [options.instructions]
195
+ : [];
196
+ parts.push(
197
+ buildParallelDispatchBlock({
198
+ tasks,
199
+ subagentAvailable: options.subagentAvailable ?? false,
200
+ }),
201
+ );
202
+ }
203
+
204
+ parts.push("");
205
+ parts.push(...expandedBlocks);
206
+
207
+ if (options.bmadStatusBlock) {
208
+ parts.push("");
209
+ parts.push(options.bmadStatusBlock);
210
+ }
211
+
212
+ if (options.embeddedCommand) {
213
+ parts.push("");
214
+ parts.push(
215
+ `<embedded_command>${options.embeddedCommand}</embedded_command>`,
216
+ );
217
+ parts.push(
218
+ "Execute the embedded command workflow as part of fulfilling the user request.",
219
+ );
220
+ }
221
+
222
+ if (options.instructions) {
223
+ parts.push("");
224
+ parts.push("<user_query>");
225
+ parts.push(options.instructions);
226
+ parts.push("</user_query>");
227
+ }
228
+
229
+ if (notFound.length > 0) {
230
+ parts.push("");
231
+ parts.push(`> ⚠️ Skills not found: ${notFound.join(", ")}`);
232
+ }
233
+
234
+ if (options.conflictWarnings?.length) {
235
+ parts.push("");
236
+ parts.push(`> ⚠️ Conflicts: ${options.conflictWarnings.join("; ")}`);
237
+ }
238
+
239
+ if (skippedDuplicates.length > 0) {
240
+ parts.push("");
241
+ parts.push(`> ℹ️ Deduplicated: ${skippedDuplicates.join("; ")}`);
242
+ }
243
+
244
+ parts.push("</manually_attached_skills>");
245
+ return parts.join("\n");
246
+ }
247
+
248
+ function shouldWrapForDisplay(
249
+ skills: EnrichedSkillInfo[],
250
+ options: BuildOptions,
251
+ ): boolean {
252
+ if (skills.length > 1) return true;
253
+ if (options.parallel || options.bmadStatusBlock || options.embeddedCommand) {
254
+ return true;
255
+ }
256
+ if (options.instructions) return true;
257
+ if (options.bundles && options.bundles.length > 0) return true;
258
+ return false;
259
+ }
260
+
261
+ export function buildCombinedMessage(
262
+ skills: EnrichedSkillInfo[],
263
+ cwd: string,
264
+ options: BuildOptions,
265
+ ): { message: string; notFound: string[]; skippedDuplicates: string[] } {
266
+ const expandedBlocks: string[] = [];
267
+ const notFound: string[] = [];
268
+ const seenDedup = new Set<string>();
269
+ const skippedDuplicates: string[] = [];
270
+
271
+ for (const skill of skills) {
272
+ const before = seenDedup.size;
273
+ const block = renderSkillBlock(skill, cwd, options.mode, seenDedup);
274
+ if (!block) {
275
+ notFound.push(skill.name);
276
+ continue;
277
+ }
278
+ if (seenDedup.size > before && before > 0) {
279
+ skippedDuplicates.push(`${skill.name} (deduplicated sections)`);
280
+ }
281
+ expandedBlocks.push(block);
282
+ }
283
+
284
+ const payload = buildAgentPayload(
285
+ skills,
286
+ expandedBlocks,
287
+ options,
288
+ notFound,
289
+ skippedDuplicates,
290
+ );
291
+
292
+ let message: string;
293
+ if (expandedBlocks.length === 0) {
294
+ message = payload;
295
+ } else if (shouldWrapForDisplay(skills, options)) {
296
+ message = formatPiSkillBlock(
297
+ formatCollapsedSkillName(skills),
298
+ skills.length === 1 ? skills[0].filePath : MULTI_SKILL_LOCATION,
299
+ payload,
300
+ );
301
+ } else {
302
+ message = expandedBlocks[0];
303
+ }
304
+
305
+ return {
306
+ message,
307
+ notFound,
308
+ skippedDuplicates,
309
+ };
310
+ }
311
+
312
+ export function resolveAndReadLegacySkill(
313
+ skillName: string,
314
+ filePath: string,
315
+ cwd: string,
316
+ ): string | null {
317
+ if (existsSync(filePath)) {
318
+ try {
319
+ const content = readFileSync(filePath, "utf-8");
320
+ const body = stripFrontmatter(content).trim();
321
+ const baseDir = dirname(filePath);
322
+ return formatPiSkillBlock(
323
+ skillName,
324
+ filePath,
325
+ `References are relative to ${baseDir}.\n\n${body}`,
326
+ );
327
+ } catch {
328
+ // fall through
329
+ }
330
+ }
331
+
332
+ const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
333
+ for (const dir of dirs) {
334
+ for (const candidate of [
335
+ join(dir, skillName, "SKILL.md"),
336
+ join(dir, `${skillName}.md`),
337
+ ]) {
338
+ try {
339
+ const content = readFileSync(candidate, "utf-8");
340
+ const body = stripFrontmatter(content).trim();
341
+ return formatPiSkillBlock(
342
+ skillName,
343
+ candidate,
344
+ `References are relative to ${dirname(candidate)}.\n\n${body}`,
345
+ );
346
+ } catch {
347
+ // continue
348
+ }
349
+ }
350
+ }
351
+ return null;
352
+ }