pi-skill-shiori 0.3.1 → 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,26 @@ 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
+
19
+ ## [0.3.2] - 2026-06-24
20
+
21
+ ### Added
22
+
23
+ - `ROADMAP.md` with maintenance-first phased goals (Month 1–3), template compliance checklist, and maintenance schedule.
24
+ - README links to `ROADMAP.md`.
25
+ - npm package `files` now includes `ROADMAP.md`.
26
+
7
27
  ## [0.3.1] - 2026-06-19
8
28
 
9
29
  ### Added
package/README.md CHANGED
@@ -4,6 +4,8 @@ Pi Skill Shiori is a [Pi](https://pi.dev/) package that keeps large Agent Skill
4
4
 
5
5
  It is built for vaults or projects with many Agent Skills where the default catalog becomes noisy and expensive.
6
6
 
7
+ For maintenance priorities and the phased plan, see [`ROADMAP.md`](ROADMAP.md).
8
+
7
9
  ## What it does
8
10
 
9
11
  - **Zero-Catalog Mode**: hides the normal skill catalog when Pi exposes one in the system prompt.
@@ -15,7 +17,7 @@ It is built for vaults or projects with many Agent Skills where the default cata
15
17
 
16
18
  ## Status
17
19
 
18
- `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.
19
21
 
20
22
  ## Requirements
21
23
 
@@ -37,7 +39,7 @@ Pi core packages are peer dependencies and should be supplied by the Pi runtime:
37
39
  After the package is published to npm:
38
40
 
39
41
  ```bash
40
- pi install npm:pi-skill-shiori@0.3.1
42
+ pi install npm:pi-skill-shiori@0.4.0
41
43
  ```
42
44
 
43
45
  Without a version pin:
@@ -51,7 +53,7 @@ pi install npm:pi-skill-shiori
51
53
  Use `-l` to write the package to the current project’s `.pi/settings.json`:
52
54
 
53
55
  ```bash
54
- pi install -l npm:pi-skill-shiori@0.3.1
56
+ pi install -l npm:pi-skill-shiori@0.4.0
55
57
  ```
56
58
 
57
59
  Without a version pin:
@@ -65,7 +67,7 @@ Equivalent pinned manual `.pi/settings.json` entry:
65
67
  ```json
66
68
  {
67
69
  "packages": [
68
- "npm:pi-skill-shiori@0.3.1"
70
+ "npm:pi-skill-shiori@0.4.0"
69
71
  ]
70
72
  }
71
73
  ```
@@ -83,7 +85,7 @@ Equivalent unpinned manual `.pi/settings.json` entry:
83
85
  ### Global install from GitHub
84
86
 
85
87
  ```bash
86
- 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
87
89
  ```
88
90
 
89
91
  Without a tag pin:
@@ -95,7 +97,7 @@ pi install git:github.com/eiei114/pi-skill-shiori
95
97
  ### Project-local install from GitHub
96
98
 
97
99
  ```bash
98
- 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
99
101
  ```
100
102
 
101
103
  Without a tag pin:
@@ -166,7 +168,8 @@ Policy rules:
166
168
 
167
169
  - `defaults.activation: explicit` is the safe default. Unlisted skills are not auto-candidates.
168
170
  - `activation: triggerable` allows Shiori to recommend the skill for matching requests.
169
- - `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.
170
173
  - `candidateInjection.maxCandidates` limits how many suggestions enter the prompt.
171
174
  - `candidateInjection.minScore` drops weak matches.
172
175
 
@@ -278,7 +281,7 @@ npm login
278
281
  After publishing, verify the Pi install path:
279
282
 
280
283
  ```bash
281
- pi install npm:pi-skill-shiori@0.3.1
284
+ pi install npm:pi-skill-shiori@0.4.0
282
285
  pi install npm:pi-skill-shiori
283
286
  pi list
284
287
  /shiori:doctor
package/ROADMAP.md ADDED
@@ -0,0 +1,132 @@
1
+ # Roadmap
2
+
3
+ Pi Skill Shiori keeps large Agent Skill catalogs out of the model prompt and lets
4
+ the agent load only the skills that match the current task. This roadmap states
5
+ **what we prioritize next** and how the package is maintained.
6
+
7
+ ## Direction
8
+
9
+ `0.3.x` is a working release. The near-term priority is **stabilization and
10
+ public quality over new feature surface**. Before widening functionality, we
11
+ harden what already ships:
12
+
13
+ - **Stabilize existing behavior** — make catalog suppression and retrieval reliable.
14
+ - **Speed and token efficiency** — startup cost, index rebuild, and candidate injection footprint.
15
+ - **Explicit design boundaries** — name the contracts (policy file, hook activation, suppression).
16
+ - **Template compliance** — close gaps against the shared Pi extension OSS policy.
17
+ - **Publish quality** — README, CHANGELOG, SECURITY, CI, `npm pack`, and release handoff.
18
+
19
+ Constraints:
20
+
21
+ - Existing commands, tools, and the main UX stay unless a compatibility fix is required.
22
+ - `shiori:*` commands stay argument-less; detail is collected after launch via Pi UI.
23
+ - npm publish stays Trusted Publishing / OIDC. Secrets and production actions remain human-owned.
24
+
25
+ ## Skill features vs. template requirements
26
+
27
+ Two kinds of work are tracked separately so feature work is not confused with
28
+ packaging/hygiene work:
29
+
30
+ - **Skill features** — Zero-Catalog suppression, policy-based retrieval, candidate
31
+ injection, on-demand loading, hybrid retrieval. These change what the package does.
32
+ - **Template requirements** — README badges, CI gates, `npm pack` cleanliness, action
33
+ pinning, version-bump checks. These keep the package publishable and aligned with
34
+ `pi-extension-template`.
35
+
36
+ Template gaps are addressed first because they protect every later release.
37
+
38
+ ## Maintenance schedule
39
+
40
+ - **Patch release** — as needed for fixes, doc/template compliance, and small behavior changes.
41
+ - **Minor release** — retrieval quality or candidate-flow improvements.
42
+ - **Release cadence** — ship per ready slice; no fixed calendar release. Every mergeable
43
+ change bumps `package.json` and `CHANGELOG.md` so npm can publish.
44
+ - **Dependency review** — monthly; bump `devDependencies`/Pi peers without behavior change.
45
+ - **Release flow** — `npm version <patch|minor>` → push → `auto-release.yml` tags and
46
+ releases → `publish.yml` publishes via Trusted Publishing (provenance verified).
47
+
48
+ `/shiori:stats` reports operational counters (token saved, candidate hits, loads,
49
+ zero-candidate turns, explicit invocations) that feed back into this schedule.
50
+
51
+ ## Phased goals (Month 1–3)
52
+
53
+ Each item is a small, independently verifiable slice (roughly 30–90 minutes) with a
54
+ semver classification. Patches default to `patch`; `none` means no package publish.
55
+
56
+ ### Month 1 — Public quality and template compliance
57
+
58
+ | # | Slice | Bump | Status |
59
+ |---|---|---|---|
60
+ | 1 | Add README status badges (CI, Publish, npm version, npm downloads, License, Pi package, Trusted Publishing) | patch | planned |
61
+ | 2 | Add `version:check` CI gate (block publishable-path changes without a semver increase) | none | planned |
62
+ | 3 | Pin GitHub Actions to commit SHAs in `ci.yml`, `auto-release.yml`, `publish.yml` | none | planned |
63
+
64
+ ### Month 2 — Stabilization and design boundaries
65
+
66
+ | # | Slice | Bump | Status |
67
+ |---|---|---|---|
68
+ | 4 | Harden catalog boundary detection and add boundary tests (reduce `failed-pattern-not-found`) | patch | planned |
69
+ | 5 | Integration test for Dual Hook Activation (input-hook explicit pass-through + `before_agent_start` candidate injection) | none | planned |
70
+
71
+ ### Month 3 — Speed and token efficiency
72
+
73
+ | # | Slice | Bump | Status |
74
+ |---|---|---|---|
75
+ | 6 | Add startup/operation timing instrumentation to `/shiori:stats` (Full Index Rebuild + retrieval baseline) | patch | planned |
76
+ | 7 | Startup index strategy (incremental / lazy / cached) — needs a design decision before implementation | none | blocked (design) |
77
+
78
+ ## Integration testing for skill hooks
79
+
80
+ Dual Hook Activation is the core discovery path and must be covered end to end:
81
+
82
+ - **Input hook** — explicit invocation pass-through: `/skill:name` and explicit names load
83
+ regardless of activation policy, including under Zero-Catalog Mode.
84
+ - **`before_agent_start` hook** — automatic candidate injection for triggerable skills only,
85
+ bounded by `maxCandidates` and `minScore`, with explicit skills excluded.
86
+
87
+ The Month 2 integration test asserts both paths together (explicit load still works while
88
+ triggerable candidates are injected) so a regression in one hook surfaces immediately.
89
+
90
+ ## Template compliance checklist
91
+
92
+ Current status against the shared Pi extension OSS policy. Gaps become Month 1 work.
93
+
94
+ - [x] `README.md`, `LICENSE`, `SECURITY.md`, `CHANGELOG.md` at repo root
95
+ - [x] CI runs `npm run typecheck`, `npm test`, and `npm pack --dry-run`
96
+ - [x] npm Trusted Publishing (`publish.yml` uses `id-token: write`, no `NPM_TOKEN`)
97
+ - [x] `auto-release.yml` → `publish.yml` release handoff
98
+ - [x] `package.json` `files` is explicit (no `node_modules`, tests, or local state)
99
+ - [x] No template `example-skill` / `example` / `example-theme` resources shipped
100
+ - [ ] **README status badges** (CI, Publish, npm version, npm downloads, License, Pi package, Trusted Publishing) — *Month 1 #1*
101
+ - [ ] **`version:check` CI gate** so publishable-path changes cannot merge without a bump — *Month 1 #2*
102
+ - [ ] **Pin GitHub Actions to commit SHAs** (currently floating `@v4`) — *Month 1 #3*
103
+
104
+ ## Current backlog integration
105
+
106
+ Feature-track items already shipped (kept here so the roadmap reflects reality, not a
107
+ fresh to-do list):
108
+
109
+ - Recommendation-first `/shiori:recommend` flow with query expansion and planning intent — shipped (`0.2.0`)
110
+ - CI + auto-release + publish handoff — shipped (`0.1.1`+)
111
+ - `shiori:*` commands use Pi UI flows (argument-less) — shipped (`0.3.0`)
112
+ - `SECURITY.md` and `CHANGELOG.md` — shipped (`0.3.1`)
113
+
114
+ Remaining feature-track item, deferred behind the maintenance-first direction:
115
+
116
+ - README/docs alignment with the minimal public-docs policy — open; the Month 1 badge
117
+ and CI slices advance this track without blocking on the broader docs-policy decision.
118
+
119
+ ## Later (feature track)
120
+
121
+ - Retrieval quality improvements (embedding candidate added beside SQLite FTS).
122
+ - Cross-vault skill index.
123
+ - ResourceLoader-side catalog replacement (stable-stage Two-stage Catalog Suppression).
124
+
125
+ These are intentionally **after** the Month 1–3 maintenance work.
126
+
127
+ ## Non-goals
128
+
129
+ - Rewriting existing commands, tools, or the candidate/recommend UX.
130
+ - Auto-approving npm publish, permission changes, or production actions.
131
+ - Removing the conservative catalog-suppression safety boundary (`not-needed` /
132
+ `failed-pattern-not-found` stay safe-by-default).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-shiori",
3
- "version": "0.3.1",
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",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "files": [
25
25
  "README.md",
26
+ "ROADMAP.md",
26
27
  "LICENSE",
27
28
  "SECURITY.md",
28
29
  "CHANGELOG.md",
@@ -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
+ }