pi-skill-shiori 0.3.2 → 0.4.0

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 CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  This project follows semantic versioning.
6
6
 
7
+ ## [0.4.0] - 2026-06-28
8
+
9
+ ### Added
10
+
11
+ - Always Visible Skill policy: Zero-Catalog Mode now keeps only `alwaysVisible` allowlisted skills in the Skill Catalog instead of hiding every entry.
12
+ - Diagnostics for missing or duplicate `alwaysVisible` policy entries in `/shiori:doctor` and one-time runtime warnings for missing skills.
13
+ - Tests for visible, missing, duplicate, and markdown catalog filtering cases.
14
+
15
+ ### Changed
16
+
17
+ - `alwaysVisible` policy entries are normalized to a deduplicated allowlist at load time.
18
+
7
19
  ## [0.3.2] - 2026-06-24
8
20
 
9
21
  ### Added
package/README.md CHANGED
@@ -17,7 +17,7 @@ For maintenance priorities and the phased plan, see [`ROADMAP.md`](ROADMAP.md).
17
17
 
18
18
  ## Status
19
19
 
20
- `0.3.1` is an early working release. It is useful for local Pi workflows, but the prompt-boundary suppression logic is intentionally conservative: if Shiori cannot safely recognize a catalog boundary, it leaves the prompt untouched and warns instead of deleting too much.
20
+ `0.4.0` is an early working release. It is useful for local Pi workflows, but the prompt-boundary suppression logic is intentionally conservative: if Shiori cannot safely recognize a catalog boundary, it leaves the prompt untouched and warns instead of deleting too much.
21
21
 
22
22
  ## Requirements
23
23
 
@@ -39,7 +39,7 @@ Pi core packages are peer dependencies and should be supplied by the Pi runtime:
39
39
  After the package is published to npm:
40
40
 
41
41
  ```bash
42
- pi install npm:pi-skill-shiori@0.3.1
42
+ pi install npm:pi-skill-shiori@0.4.0
43
43
  ```
44
44
 
45
45
  Without a version pin:
@@ -53,7 +53,7 @@ pi install npm:pi-skill-shiori
53
53
  Use `-l` to write the package to the current project’s `.pi/settings.json`:
54
54
 
55
55
  ```bash
56
- pi install -l npm:pi-skill-shiori@0.3.1
56
+ pi install -l npm:pi-skill-shiori@0.4.0
57
57
  ```
58
58
 
59
59
  Without a version pin:
@@ -67,7 +67,7 @@ Equivalent pinned manual `.pi/settings.json` entry:
67
67
  ```json
68
68
  {
69
69
  "packages": [
70
- "npm:pi-skill-shiori@0.3.1"
70
+ "npm:pi-skill-shiori@0.4.0"
71
71
  ]
72
72
  }
73
73
  ```
@@ -85,7 +85,7 @@ Equivalent unpinned manual `.pi/settings.json` entry:
85
85
  ### Global install from GitHub
86
86
 
87
87
  ```bash
88
- pi install git:github.com/eiei114/pi-skill-shiori@v0.3.1
88
+ pi install git:github.com/eiei114/pi-skill-shiori@v0.4.0
89
89
  ```
90
90
 
91
91
  Without a tag pin:
@@ -97,7 +97,7 @@ pi install git:github.com/eiei114/pi-skill-shiori
97
97
  ### Project-local install from GitHub
98
98
 
99
99
  ```bash
100
- pi install -l git:github.com/eiei114/pi-skill-shiori@v0.3.1
100
+ pi install -l git:github.com/eiei114/pi-skill-shiori@v0.4.0
101
101
  ```
102
102
 
103
103
  Without a tag pin:
@@ -168,7 +168,8 @@ Policy rules:
168
168
 
169
169
  - `defaults.activation: explicit` is the safe default. Unlisted skills are not auto-candidates.
170
170
  - `activation: triggerable` allows Shiori to recommend the skill for matching requests.
171
- - `alwaysVisible` lists skills that should remain visible/operational in Zero-Catalog workflows.
171
+ - `alwaysVisible` lists skills that should remain visible in the Skill Catalog during Zero-Catalog Mode. Use this for a tiny safety-critical allowlist instead of broad trigger rules.
172
+ - Missing `alwaysVisible` entries are reported by `/shiori:doctor` and warned once per session when Zero-Catalog Mode runs.
172
173
  - `candidateInjection.maxCandidates` limits how many suggestions enter the prompt.
173
174
  - `candidateInjection.minScore` drops weak matches.
174
175
 
@@ -280,7 +281,7 @@ npm login
280
281
  After publishing, verify the Pi install path:
281
282
 
282
283
  ```bash
283
- pi install npm:pi-skill-shiori@0.3.1
284
+ pi install npm:pi-skill-shiori@0.4.0
284
285
  pi install npm:pi-skill-shiori
285
286
  pi list
286
287
  /shiori:doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-shiori",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension that reduces Agent Skill catalog tokens with policy-based skill retrieval and on-demand skill loading.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,54 @@
1
+ import type { SkillRecord } from "./types.js";
2
+
3
+ export interface AlwaysVisibleDiagnostics {
4
+ missing: string[];
5
+ duplicates: string[];
6
+ resolved: string[];
7
+ }
8
+
9
+ export function normalizeAlwaysVisible(alwaysVisible: string[] | undefined): {
10
+ allowlist: string[];
11
+ duplicates: string[];
12
+ } {
13
+ const duplicates: string[] = [];
14
+ const seen = new Set<string>();
15
+ const allowlist: string[] = [];
16
+
17
+ for (const entry of alwaysVisible ?? []) {
18
+ const name = entry.trim();
19
+ if (!name) continue;
20
+ if (seen.has(name)) {
21
+ if (!duplicates.includes(name)) duplicates.push(name);
22
+ continue;
23
+ }
24
+ seen.add(name);
25
+ allowlist.push(name);
26
+ }
27
+
28
+ return { allowlist, duplicates };
29
+ }
30
+
31
+ export function evaluateAlwaysVisible(
32
+ alwaysVisible: string[] | undefined,
33
+ skills: SkillRecord[],
34
+ ): AlwaysVisibleDiagnostics {
35
+ const { allowlist, duplicates } = normalizeAlwaysVisible(alwaysVisible);
36
+ const inventory = new Set(skills.map((skill) => skill.name));
37
+ const missing = allowlist.filter((name) => !inventory.has(name));
38
+ const resolved = allowlist.filter((name) => inventory.has(name));
39
+ return { missing, duplicates, resolved };
40
+ }
41
+
42
+ export function formatAlwaysVisibleDiagnostics(diagnostics: AlwaysVisibleDiagnostics): string[] {
43
+ const lines: string[] = [];
44
+ if (diagnostics.resolved.length > 0) {
45
+ lines.push(`alwaysVisible resolved: ${diagnostics.resolved.join(", ")}`);
46
+ }
47
+ if (diagnostics.missing.length > 0) {
48
+ lines.push(`alwaysVisible missing: ${diagnostics.missing.join(", ")}`);
49
+ }
50
+ if (diagnostics.duplicates.length > 0) {
51
+ lines.push(`alwaysVisible duplicates: ${diagnostics.duplicates.join(", ")}`);
52
+ }
53
+ return lines;
54
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { readFile, writeFile } from "node:fs/promises";
5
+ import { evaluateAlwaysVisible, formatAlwaysVisibleDiagnostics } from "./always-visible.js";
5
6
  import { discoverSkills, findDuplicates } from "./discovery.js";
6
7
  import { buildSkillIndex, type SkillIndex } from "./indexer.js";
7
8
  import { loadRecommendedSkills } from "./load-skills.js";
@@ -35,6 +36,7 @@ const CANCEL_CHOICE = "Cancel";
35
36
 
36
37
  export default function piSkillShiori(pi: ExtensionAPI) {
37
38
  let index: SkillIndex | undefined;
39
+ let warnedAlwaysVisibleMissing = new Set<string>();
38
40
  const stats = createStats();
39
41
 
40
42
  async function reload(cwd: string): Promise<SkillIndex> {
@@ -83,7 +85,20 @@ export default function piSkillShiori(pi: ExtensionAPI) {
83
85
  const current = await ensureIndex(ctx.cwd);
84
86
  if (!current.policy.zeroCatalog.enabled) return;
85
87
 
86
- const suppression = suppressSkillCatalog(event.systemPrompt);
88
+ const alwaysVisible = evaluateAlwaysVisible(current.policy.alwaysVisible, current.skills);
89
+ for (const name of alwaysVisible.missing) {
90
+ if (warnedAlwaysVisibleMissing.has(name)) continue;
91
+ warnedAlwaysVisibleMissing.add(name);
92
+ ctx.ui.notify(
93
+ `Pi Skill Shiori: always-visible skill not found in inventory: ${name}`,
94
+ "warning",
95
+ );
96
+ }
97
+
98
+ const suppression = suppressSkillCatalog(event.systemPrompt, {
99
+ alwaysVisible: current.policy.alwaysVisible,
100
+ skills: current.skills,
101
+ });
87
102
  stats.suppressionStatus = suppression.status;
88
103
  if (suppression.status === "failed-pattern-not-found") {
89
104
  ctx.ui.notify("Pi Skill Shiori: Skill Catalog suppression pattern not found; leaving system prompt unchanged.", "warning");
@@ -186,21 +201,26 @@ export default function piSkillShiori(pi: ExtensionAPI) {
186
201
  handler: async (_args, ctx) => {
187
202
  const current = await ensureIndex(ctx.cwd);
188
203
  const duplicates = findDuplicates(current.skills);
189
- ctx.ui.notify(
190
- [
191
- "Pi Skill Shiori doctor",
192
- `policy: ${getPolicyPath(ctx.cwd)}`,
193
- `zeroCatalog: ${current.policy.zeroCatalog.enabled ? "enabled" : "disabled"}`,
194
- `skills: ${current.skills.length}`,
195
- `duplicates: ${duplicates.size}`,
196
- `alwaysVisible: ${current.policy.alwaysVisible.join(", ") || "none"}`,
197
- `retrievalBackend: ${current.retrievalBackend}`,
198
- `suppression: ${stats.suppressionStatus}`,
199
- `code: ${SHIORI_CODE_MARKER}`,
200
- `builtAt: ${current.builtAt}`,
201
- ].join("\n"),
202
- duplicates.size > 0 || stats.suppressionStatus === "failed-pattern-not-found" ? "warning" : "info",
203
- );
204
+ const alwaysVisible = evaluateAlwaysVisible(current.policy.alwaysVisible, current.skills);
205
+ const doctorLines = [
206
+ "Pi Skill Shiori doctor",
207
+ `policy: ${getPolicyPath(ctx.cwd)}`,
208
+ `zeroCatalog: ${current.policy.zeroCatalog.enabled ? "enabled" : "disabled"}`,
209
+ `skills: ${current.skills.length}`,
210
+ `duplicates: ${duplicates.size}`,
211
+ `alwaysVisible: ${current.policy.alwaysVisible.join(", ") || "none"}`,
212
+ ...formatAlwaysVisibleDiagnostics(alwaysVisible),
213
+ `retrievalBackend: ${current.retrievalBackend}`,
214
+ `suppression: ${stats.suppressionStatus}`,
215
+ `code: ${SHIORI_CODE_MARKER}`,
216
+ `builtAt: ${current.builtAt}`,
217
+ ];
218
+ const hasWarning =
219
+ duplicates.size > 0 ||
220
+ stats.suppressionStatus === "failed-pattern-not-found" ||
221
+ alwaysVisible.missing.length > 0 ||
222
+ alwaysVisible.duplicates.length > 0;
223
+ ctx.ui.notify(doctorLines.join("\n"), hasWarning ? "warning" : "info");
204
224
  },
205
225
  });
206
226
 
package/src/policy.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { parse } from "yaml";
4
+ import { normalizeAlwaysVisible } from "./always-visible.js";
4
5
  import type { ShioriPolicy } from "./types.js";
5
6
 
6
7
  export const DEFAULT_POLICY: ShioriPolicy = {
@@ -40,7 +41,7 @@ function normalizePolicy(policy: Partial<ShioriPolicy> | undefined): ShioriPolic
40
41
  maxCandidates: policy?.candidateInjection?.maxCandidates ?? DEFAULT_POLICY.candidateInjection.maxCandidates,
41
42
  minScore: policy?.candidateInjection?.minScore ?? DEFAULT_POLICY.candidateInjection.minScore,
42
43
  },
43
- alwaysVisible: policy?.alwaysVisible ?? DEFAULT_POLICY.alwaysVisible,
44
+ alwaysVisible: normalizeAlwaysVisible(policy?.alwaysVisible ?? DEFAULT_POLICY.alwaysVisible).allowlist,
44
45
  skills: policy?.skills ?? DEFAULT_POLICY.skills,
45
46
  };
46
47
  }
package/src/prompt.ts CHANGED
@@ -1,119 +1,289 @@
1
- import type { SkillCandidate, SuppressionStatus } from "./types.js";
2
-
3
- export interface CatalogSuppressionResult {
4
- systemPrompt: string;
5
- status: SuppressionStatus;
6
- }
7
-
8
- const COMPACT_DESCRIPTION_LIMIT = 120;
9
-
10
- export function formatCandidateInjection(candidates: SkillCandidate[]): string {
11
- if (candidates.length === 0) return "";
12
- const lines = ["Relevant skills:"];
13
- for (const candidate of candidates) {
14
- const desc = compactDescription(candidate.skill.description, COMPACT_DESCRIPTION_LIMIT);
15
- lines.push(
16
- `- ${candidate.skill.name}: ${desc} Reason: ${candidate.why}. Load: shiori_load_skill({ skill: "${candidate.skill.name}" })`,
17
- );
18
- }
19
- return lines.join("\n");
20
- }
21
-
22
- export function formatRecommendKickoffMessage(
23
- query: string,
24
- loaded: Array<{ candidate: SkillCandidate; content: string }>,
25
- ): string {
26
- const lines = [query.trim(), ""];
27
-
28
- if (loaded.length === 0) {
29
- lines.push(
30
- "---",
31
- "Pi Skill Shiori: no matching skills for this task.",
32
- "Continue without a dedicated skill, or refine the task description and run /shiori:recommend again.",
33
- );
34
- return lines.join("\n");
35
- }
36
-
37
- const names = loaded.map(({ candidate }) => candidate.skill.name).join(", ");
38
- lines.push(
39
- "---",
40
- `Pi Skill Shiori: pre-loaded ${loaded.length} skill(s): ${names}`,
41
- "Follow the skill instructions below and continue with the task above.",
42
- "",
43
- );
44
-
45
- for (const { candidate, content } of loaded) {
46
- lines.push(
47
- `### Skill: ${candidate.skill.name}`,
48
- `Score: ${candidate.score.toFixed(2)} | Why: ${candidate.why}`,
49
- `Path: ${candidate.skill.path}`,
50
- "",
51
- content.trim(),
52
- "",
53
- );
54
- }
55
-
56
- return lines.join("\n");
57
- }
58
-
59
- export function formatLoadedSkillsSummary(loaded: Array<{ candidate: SkillCandidate; content: string }>): string {
60
- if (loaded.length === 0) return "";
61
- const lines = [`Pre-loaded ${loaded.length} skill(s):`];
62
- for (const { candidate } of loaded) {
63
- const desc = compactDescription(candidate.skill.description, COMPACT_DESCRIPTION_LIMIT);
64
- lines.push(`- ${candidate.skill.name} (${candidate.score.toFixed(2)}): ${desc}`);
65
- }
66
- return lines.join("\n");
67
- }
68
-
69
- export function formatCandidateDetail(candidate: SkillCandidate): string {
70
- return [
71
- candidate.skill.name,
72
- `Description: ${candidate.skill.description}`,
73
- `Score: ${candidate.score.toFixed(2)}`,
74
- `Why: ${candidate.why}`,
75
- `Path: ${candidate.skill.path}`,
76
- `Load: shiori_load_skill({ skill: "${candidate.skill.name}" })`,
77
- ].join("\n");
78
- }
79
-
80
- export function compactDescription(description: string, maxChars = COMPACT_DESCRIPTION_LIMIT): string {
81
- const singleLine = description.replace(/\s+/g, " ").trim();
82
- if (singleLine.length <= maxChars) return singleLine;
83
- return `${singleLine.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
84
- }
85
-
86
- export function suppressSkillCatalog(systemPrompt: string): CatalogSuppressionResult {
87
- const replacement = [
88
- "### Available skills",
89
- "Normal Skill Catalog hidden by Pi Skill Shiori.",
90
- "Use injected candidates or shiori_load_skill when relevant.",
91
- "Explicit /skill:name invocation remains allowed.",
92
- ].join("\n");
93
-
94
- const patterns = [
95
- /\r?\n\r?\nThe following skills provide specialized instructions for specific tasks\.\r?\n[\s\S]*?\r?\n<available_skills>[\s\S]*?\r?\n<\/available_skills>/,
96
- /(^|\r?\n)<available_skills>[\s\S]*?<\/available_skills>/i,
97
- /(^|\r?\n)#{2,4}\s*Available skills\b[\s\S]*?(?=\r?\n#{2,4}\s*How to use skills\b)/i,
98
- /(^|\r?\n)#{2,4}\s*Available skills\b[\s\S]*?(?=\r?\n#{2,4}\s*Fallback\b)/i,
99
- ];
100
- for (const pattern of patterns) {
101
- if (!pattern.test(systemPrompt)) continue;
102
- return {
103
- systemPrompt: systemPrompt.replace(pattern, `${replacement}\n`),
104
- status: "suppressed",
105
- };
106
- }
107
-
108
- if (!hasSkillCatalogSignal(systemPrompt)) {
109
- return { systemPrompt, status: "not-needed" };
110
- }
111
-
112
- return { systemPrompt, status: "failed-pattern-not-found" };
113
- }
114
-
115
- function hasSkillCatalogSignal(systemPrompt: string): boolean {
116
- return /<\/?available_skills>|The following skills provide specialized instructions|#{2,4}\s*Available skills\b/i.test(
117
- systemPrompt,
118
- );
119
- }
1
+ import { normalizeAlwaysVisible } from "./always-visible.js";
2
+ import type { SkillCandidate, SkillRecord, SuppressionStatus } from "./types.js";
3
+
4
+ export interface CatalogSuppressionResult {
5
+ systemPrompt: string;
6
+ status: SuppressionStatus;
7
+ }
8
+
9
+ export interface SuppressCatalogOptions {
10
+ alwaysVisible: string[];
11
+ skills: SkillRecord[];
12
+ }
13
+
14
+ const COMPACT_DESCRIPTION_LIMIT = 120;
15
+
16
+ const CATALOG_BOUNDARY_PATTERNS: Array<{ pattern: RegExp; format: "xml" | "markdown" }> = [
17
+ {
18
+ pattern:
19
+ /\r?\n\r?\nThe following skills provide specialized instructions for specific tasks\.\r?\n[\s\S]*?\r?\n<available_skills>[\s\S]*?\r?\n<\/available_skills>/,
20
+ format: "xml",
21
+ },
22
+ {
23
+ pattern: /(^|\r?\n)<available_skills>[\s\S]*?<\/available_skills>/i,
24
+ format: "xml",
25
+ },
26
+ {
27
+ pattern: /(^|\r?\n)#{2,4}\s*Available skills\b[\s\S]*?(?=\r?\n#{2,4}\s*How to use skills\b)/i,
28
+ format: "markdown",
29
+ },
30
+ {
31
+ pattern: /(^|\r?\n)#{2,4}\s*Available skills\b[\s\S]*?(?=\r?\n#{2,4}\s*Fallback\b)/i,
32
+ format: "markdown",
33
+ },
34
+ ];
35
+
36
+ export function formatCandidateInjection(candidates: SkillCandidate[]): string {
37
+ if (candidates.length === 0) return "";
38
+ const lines = ["Relevant skills:"];
39
+ for (const candidate of candidates) {
40
+ const desc = compactDescription(candidate.skill.description, COMPACT_DESCRIPTION_LIMIT);
41
+ lines.push(
42
+ `- ${candidate.skill.name}: ${desc} Reason: ${candidate.why}. Load: shiori_load_skill({ skill: "${candidate.skill.name}" })`,
43
+ );
44
+ }
45
+ return lines.join("\n");
46
+ }
47
+
48
+ export function formatRecommendKickoffMessage(
49
+ query: string,
50
+ loaded: Array<{ candidate: SkillCandidate; content: string }>,
51
+ ): string {
52
+ const lines = [query.trim(), ""];
53
+
54
+ if (loaded.length === 0) {
55
+ lines.push(
56
+ "---",
57
+ "Pi Skill Shiori: no matching skills for this task.",
58
+ "Continue without a dedicated skill, or refine the task description and run /shiori:recommend again.",
59
+ );
60
+ return lines.join("\n");
61
+ }
62
+
63
+ const names = loaded.map(({ candidate }) => candidate.skill.name).join(", ");
64
+ lines.push(
65
+ "---",
66
+ `Pi Skill Shiori: pre-loaded ${loaded.length} skill(s): ${names}`,
67
+ "Follow the skill instructions below and continue with the task above.",
68
+ "",
69
+ );
70
+
71
+ for (const { candidate, content } of loaded) {
72
+ lines.push(
73
+ `### Skill: ${candidate.skill.name}`,
74
+ `Score: ${candidate.score.toFixed(2)} | Why: ${candidate.why}`,
75
+ `Path: ${candidate.skill.path}`,
76
+ "",
77
+ content.trim(),
78
+ "",
79
+ );
80
+ }
81
+
82
+ return lines.join("\n");
83
+ }
84
+
85
+ export function formatLoadedSkillsSummary(loaded: Array<{ candidate: SkillCandidate; content: string }>): string {
86
+ if (loaded.length === 0) return "";
87
+ const lines = [`Pre-loaded ${loaded.length} skill(s):`];
88
+ for (const { candidate } of loaded) {
89
+ const desc = compactDescription(candidate.skill.description, COMPACT_DESCRIPTION_LIMIT);
90
+ lines.push(`- ${candidate.skill.name} (${candidate.score.toFixed(2)}): ${desc}`);
91
+ }
92
+ return lines.join("\n");
93
+ }
94
+
95
+ export function formatCandidateDetail(candidate: SkillCandidate): string {
96
+ return [
97
+ candidate.skill.name,
98
+ `Description: ${candidate.skill.description}`,
99
+ `Score: ${candidate.score.toFixed(2)}`,
100
+ `Why: ${candidate.why}`,
101
+ `Path: ${candidate.skill.path}`,
102
+ `Load: shiori_load_skill({ skill: "${candidate.skill.name}" })`,
103
+ ].join("\n");
104
+ }
105
+
106
+ export function compactDescription(description: string, maxChars = COMPACT_DESCRIPTION_LIMIT): string {
107
+ const singleLine = description.replace(/\s+/g, " ").trim();
108
+ if (singleLine.length <= maxChars) return singleLine;
109
+ return `${singleLine.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
110
+ }
111
+
112
+ export function suppressSkillCatalog(
113
+ systemPrompt: string,
114
+ options?: SuppressCatalogOptions,
115
+ ): CatalogSuppressionResult {
116
+ for (const { pattern, format } of CATALOG_BOUNDARY_PATTERNS) {
117
+ const match = systemPrompt.match(pattern);
118
+ if (!match) continue;
119
+ const replacement = buildFilteredCatalogReplacement(match[0], format, options);
120
+ return {
121
+ systemPrompt: systemPrompt.replace(pattern, replacement),
122
+ status: "suppressed",
123
+ };
124
+ }
125
+
126
+ if (!hasSkillCatalogSignal(systemPrompt)) {
127
+ return { systemPrompt, status: "not-needed" };
128
+ }
129
+
130
+ return { systemPrompt, status: "failed-pattern-not-found" };
131
+ }
132
+
133
+ function buildFilteredCatalogReplacement(
134
+ catalogSection: string,
135
+ format: "xml" | "markdown",
136
+ options?: SuppressCatalogOptions,
137
+ ): string {
138
+ if (!options) {
139
+ return [
140
+ "### Available skills",
141
+ "Normal Skill Catalog hidden by Pi Skill Shiori.",
142
+ "Use injected candidates or shiori_load_skill when relevant.",
143
+ "Explicit /skill:name invocation remains allowed.",
144
+ ].join("\n");
145
+ }
146
+
147
+ const { allowlist } = normalizeAlwaysVisible(options.alwaysVisible);
148
+ const allowSet = new Set(allowlist);
149
+ const skillsByName = new Map(options.skills.map((skill) => [skill.name, skill]));
150
+
151
+ if (format === "xml") {
152
+ return buildFilteredXmlCatalog(catalogSection, allowSet, allowlist, skillsByName);
153
+ }
154
+
155
+ return buildFilteredMarkdownCatalog(catalogSection, allowSet, allowlist, skillsByName);
156
+ }
157
+
158
+ function buildFilteredXmlCatalog(
159
+ catalogSection: string,
160
+ allowSet: Set<string>,
161
+ allowlist: string[],
162
+ skillsByName: Map<string, SkillRecord>,
163
+ ): string {
164
+ const prefix = extractXmlCatalogPrefix(catalogSection);
165
+ const keptBlocks: string[] = [];
166
+ const keptNames = new Set<string>();
167
+
168
+ for (const block of extractXmlSkillBlocks(catalogSection)) {
169
+ const name = parseXmlSkillName(block);
170
+ if (!name || !allowSet.has(name)) continue;
171
+ keptBlocks.push(block);
172
+ keptNames.add(name);
173
+ }
174
+
175
+ for (const name of allowlist) {
176
+ if (keptNames.has(name)) continue;
177
+ const record = skillsByName.get(name);
178
+ if (!record) continue;
179
+ keptBlocks.push(formatXmlSkillBlock(record));
180
+ keptNames.add(name);
181
+ }
182
+
183
+ if (keptBlocks.length === 0) {
184
+ return buildHiddenCatalogStub();
185
+ }
186
+
187
+ const lines = [...prefix, "<available_skills>"];
188
+ for (const block of keptBlocks) {
189
+ lines.push(block);
190
+ }
191
+ lines.push("</available_skills>");
192
+ return lines.join("\n");
193
+ }
194
+
195
+ function buildFilteredMarkdownCatalog(
196
+ catalogSection: string,
197
+ allowSet: Set<string>,
198
+ allowlist: string[],
199
+ skillsByName: Map<string, SkillRecord>,
200
+ ): string {
201
+ const headerMatch = catalogSection.match(/(^|\r?\n)(#{2,4}\s*Available skills\b[^\n]*)/i);
202
+ const header = headerMatch?.[2] ?? "### Available skills";
203
+ const keptLines: string[] = [];
204
+ const keptNames = new Set<string>();
205
+
206
+ for (const line of catalogSection.split(/\r?\n/)) {
207
+ const match = line.match(/^\s*[-*]\s+\*\*([^*]+)\*\*:\s*(.*)$/);
208
+ if (!match) continue;
209
+ const name = match[1].trim();
210
+ if (!allowSet.has(name)) continue;
211
+ keptLines.push(`- **${name}**: ${match[2].trim()}`);
212
+ keptNames.add(name);
213
+ }
214
+
215
+ for (const name of allowlist) {
216
+ if (keptNames.has(name)) continue;
217
+ const record = skillsByName.get(name);
218
+ if (!record) continue;
219
+ keptLines.push(`- **${record.name}**: ${compactDescription(record.description, 240)}`);
220
+ keptNames.add(name);
221
+ }
222
+
223
+ if (keptLines.length === 0) {
224
+ return buildHiddenCatalogStub();
225
+ }
226
+
227
+ return [`${header}`, "Pi Skill Shiori: only always-visible skills remain in the Skill Catalog.", ...keptLines].join(
228
+ "\n",
229
+ );
230
+ }
231
+
232
+ function buildHiddenCatalogStub(): string {
233
+ return [
234
+ "### Available skills",
235
+ "Normal Skill Catalog hidden by Pi Skill Shiori.",
236
+ "Use injected candidates or shiori_load_skill when relevant.",
237
+ "Explicit /skill:name invocation remains allowed.",
238
+ ].join("\n");
239
+ }
240
+
241
+ function extractXmlCatalogPrefix(catalogSection: string): string[] {
242
+ const marker = catalogSection.search(/<available_skills>/i);
243
+ if (marker <= 0) return [];
244
+ return catalogSection.slice(0, marker).replace(/\r?\n$/, "").split(/\r?\n/).filter(Boolean);
245
+ }
246
+
247
+ function extractXmlSkillBlocks(catalogSection: string): string[] {
248
+ return [...catalogSection.matchAll(/<skill>[\s\S]*?<\/skill>/gi)].map((match) => match[0]);
249
+ }
250
+
251
+ function parseXmlSkillName(block: string): string | undefined {
252
+ const match = block.match(/<name>([\s\S]*?)<\/name>/i);
253
+ if (!match) return undefined;
254
+ return unescapeXml(match[1].trim());
255
+ }
256
+
257
+ function formatXmlSkillBlock(skill: SkillRecord): string {
258
+ return [
259
+ " <skill>",
260
+ ` <name>${escapeXml(skill.name)}</name>`,
261
+ ` <description>${escapeXml(skill.description)}</description>`,
262
+ ` <location>${escapeXml(skill.path)}</location>`,
263
+ " </skill>",
264
+ ].join("\n");
265
+ }
266
+
267
+ function escapeXml(value: string): string {
268
+ return value
269
+ .replace(/&/g, "&amp;")
270
+ .replace(/</g, "&lt;")
271
+ .replace(/>/g, "&gt;")
272
+ .replace(/"/g, "&quot;")
273
+ .replace(/'/g, "&apos;");
274
+ }
275
+
276
+ function unescapeXml(value: string): string {
277
+ return value
278
+ .replace(/&apos;/g, "'")
279
+ .replace(/&quot;/g, '"')
280
+ .replace(/&gt;/g, ">")
281
+ .replace(/&lt;/g, "<")
282
+ .replace(/&amp;/g, "&");
283
+ }
284
+
285
+ function hasSkillCatalogSignal(systemPrompt: string): boolean {
286
+ return /<\/?available_skills>|The following skills provide specialized instructions|#{2,4}\s*Available skills\b/i.test(
287
+ systemPrompt,
288
+ );
289
+ }