@zaganjade/pi-multi-skill 1.3.2 → 1.3.4

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 (3) hide show
  1. package/README.md +22 -2
  2. package/package.json +4 -1
  3. package/src/build.ts +50 -7
package/README.md CHANGED
@@ -207,17 +207,37 @@ Pi collapses user messages only when the **entire** message matches its native s
207
207
  <skill name="skill-a, skill-b" location="pi-multi-skill">
208
208
  <manually_attached_skills count="2" bundles="@debug">
209
209
  …priority rules…
210
- <skill name="skill-a" location="/path/to/SKILL.md">…</skill>
211
- <skill name="skill-b" location="/path/to/SKILL.md">…</skill>
210
+ <skill name="skill-a" location="/path/to/SKILL.md">…</skill-block>
211
+ <skill name="skill-b" location="/path/to/SKILL.md">…</skill-block>
212
212
  <user_query>Your instructions here</user_query>
213
213
  </manually_attached_skills>
214
214
  </skill>
215
+
216
+ Your instructions here
215
217
  ```
216
218
 
217
219
  - **1 skill, no extras** → single native block → `[skill] skill-name`
218
220
  - **2+ skills** (or bundles/instructions) → outer native block hides all inner content → `[skill] a, b, c` (Ctrl+O to expand)
221
+ - The user's free-text instructions are appended AFTER `</skill>` as Pi's
222
+ `userMessage` tail, so they render as a normal visible message below the
223
+ collapsed header: `[skill] a, b (ctrl+o to expand)` + `your instructions`.
219
224
  - Inner `<manually_attached_skills bundles="…">` is preserved for **pi-usage** bundle attribution
220
225
 
226
+ > **Why inner blocks close with `</skill-block>` and instructions live outside the envelope?**
227
+ > Two constraints shaped this. First, Pi's display parser matches the outer
228
+ > envelope with a non-greedy regex and cannot tell a nested `</skill>` apart
229
+ > from the envelope's own closing tag — with nested `</skill>`, any trailing
230
+ > section caused the parser to split at the last inner `</skill>` and **leak**
231
+ > `<user_query>…`, `</manually_attached_skills>`, and the outer `</skill>` into
232
+ > the rendered user message as raw tags. Inner blocks therefore open with
233
+ > `<skill name="…">` (kept so **pi-usage** still attributes each skill via
234
+ > `/<skill\s+name="([^"]+)"/g`) but close with the non-colliding `</skill-block>`,
235
+ > which neither parser's regex can match. Second, so the user's typed
236
+ > instructions stay visible (not hidden inside the collapsed block) while the
237
+ > skill *content* collapses, instructions are placed after `</skill>\n\n` as
238
+ > plain text — exactly Pi's native `userMessage` tail. Covered by
239
+ > `node --test test/parse-leak.test.mjs`.
240
+
221
241
  ### Session hints
222
242
 
223
243
  After each user turn, the extension may suggest a relevant bundle (non-blocking `info` notification):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaganjade/pi-multi-skill",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "Chain multiple skills via /skills — bundles (JSON/YAML), load modes, BMAD --auto, /skills-last, /skills-setup, conflict resolution, parallel dispatch, activation stats, bundle attribution for pi-usage.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -21,6 +21,9 @@
21
21
  "url": "https://github.com/ZaganJade/pi-extension/issues"
22
22
  },
23
23
  "main": "./src/index.ts",
24
+ "scripts": {
25
+ "test": "node --test test/"
26
+ },
24
27
  "files": [
25
28
  "src/",
26
29
  "README.md",
package/src/build.ts CHANGED
@@ -7,6 +7,26 @@ import { buildParallelDispatchBlock } from "./subagents.ts";
7
7
 
8
8
  const SUBAGENT_STOP = "<SUBAGENT-STOP>";
9
9
  const MULTI_SKILL_LOCATION = "pi-multi-skill";
10
+
11
+ /**
12
+ * Closing tag for INNER per-skill blocks inside a multi-skill payload.
13
+ *
14
+ * MUST NOT be `</skill>`. Pi's display parser (`parseSkillBlock`) uses a
15
+ * non-greedy regex that cannot distinguish a nested `</skill>` from the outer
16
+ * envelope's own closing tag. With nested `</skill>`, the parser split at the
17
+ * last inner `</skill>` and dumped every trailing section (`<user_query>`,
18
+ * `</manually_attached_skills>`, the outer `</skill>`) into the rendered
19
+ * `userMessage` — appearing as raw tags in the chat. Using a non-colliding
20
+ * closer keeps the outer envelope parseable so those sections stay inside the
21
+ * collapsed content.
22
+ *
23
+ * The opening tag stays `<skill name="…" location="…">` so pi-usage can still
24
+ * attribute each skill individually via `/<skill\s+name="([^"]+)"/g`; pi-usage
25
+ * only reads openings, so a non-colliding closer is safe. `</skill-block>` is
26
+ * chosen because it contains neither `</skill>` nor `<skill ` (with a space),
27
+ * so it cannot match either parser's regex.
28
+ */
29
+ const INNER_SKILL_CLOSE = "</skill-block>";
10
30
  const SKILL_CHECK_MARKERS = [
11
31
  "If you think there is even a 1% chance a skill might apply",
12
32
  "Invoke relevant or requested skills BEFORE any response",
@@ -36,6 +56,21 @@ export function formatPiSkillBlock(
36
56
  return `<skill name="${escapeXml(name)}" location="${escapeXml(location)}">\n${content}\n</skill>`;
37
57
  }
38
58
 
59
+ /**
60
+ * Wrap a single skill's content for inclusion INSIDE a multi-skill payload.
61
+ *
62
+ * Uses a non-colliding closer (see INNER_SKILL_CLOSE) so the outer native skill
63
+ * block parses cleanly with no tag leakage. The opening stays
64
+ * `<skill name="…">` to preserve pi-usage per-skill attribution.
65
+ */
66
+ function formatInnerSkillItem(
67
+ name: string,
68
+ location: string,
69
+ content: string,
70
+ ): string {
71
+ return `<skill name="${escapeXml(name)}" location="${escapeXml(location)}">\n${content}\n${INNER_SKILL_CLOSE}`;
72
+ }
73
+
39
74
  export function isPiSkillBlock(text: string): boolean {
40
75
  return PI_SKILL_BLOCK_RE.test(text);
41
76
  }
@@ -163,7 +198,7 @@ function renderSkillBlock(
163
198
  content = formatFullBody(skill, body);
164
199
  }
165
200
 
166
- return formatPiSkillBlock(skill.name, skill.filePath, content);
201
+ return formatInnerSkillItem(skill.name, skill.filePath, content);
167
202
  }
168
203
 
169
204
  function buildAgentPayload(
@@ -219,12 +254,11 @@ function buildAgentPayload(
219
254
  );
220
255
  }
221
256
 
222
- if (options.instructions) {
223
- parts.push("");
224
- parts.push("<user_query>");
225
- parts.push(options.instructions);
226
- parts.push("</user_query>");
227
- }
257
+ // NOTE: options.instructions is intentionally NOT added inside the payload.
258
+ // It is appended after the outer </skill> envelope in buildCombinedMessage
259
+ // so Pi renders it as the visible user message (the `userMessage` tail of
260
+ // parseSkillBlock) while the skill content stays collapsed. Wrapping it in
261
+ // <user_query> tags here would leak those tags into the rendered message.
228
262
 
229
263
  if (notFound.length > 0) {
230
264
  parts.push("");
@@ -302,6 +336,15 @@ export function buildCombinedMessage(
302
336
  message = expandedBlocks[0];
303
337
  }
304
338
 
339
+ // Surface the user's free-text instructions as Pi's `userMessage` tail
340
+ // (the optional text after `</skill>\n\n`). Pi renders the skill block
341
+ // collapsed as `[skill] a, b (ctrl+o to expand)` and this tail as a normal
342
+ // visible user message below it — which is the desired display. Plain text
343
+ // only: wrapping tags here would render literally.
344
+ if (options.instructions && expandedBlocks.length > 0) {
345
+ message = `${message}\n\n${options.instructions}`;
346
+ }
347
+
305
348
  return {
306
349
  message,
307
350
  notFound,