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 +12 -0
- package/README.md +9 -8
- package/package.json +1 -1
- package/src/always-visible.ts +54 -0
- package/src/index.ts +36 -16
- package/src/policy.ts +2 -1
- package/src/prompt.ts +289 -119
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
lines
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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, "&")
|
|
270
|
+
.replace(/</g, "<")
|
|
271
|
+
.replace(/>/g, ">")
|
|
272
|
+
.replace(/"/g, """)
|
|
273
|
+
.replace(/'/g, "'");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function unescapeXml(value: string): string {
|
|
277
|
+
return value
|
|
278
|
+
.replace(/'/g, "'")
|
|
279
|
+
.replace(/"/g, '"')
|
|
280
|
+
.replace(/>/g, ">")
|
|
281
|
+
.replace(/</g, "<")
|
|
282
|
+
.replace(/&/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
|
+
}
|