pi-skill-shiori 0.3.2 → 0.5.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,32 @@ 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.5.0] - 2026-06-28
8
+
9
+ ### Added
10
+
11
+ - Vault-wide skill inventory: Shiori indexes skills from project `.pi/skills`, `.agents/skills`, the user-global `~/.pi/agent/skills` root, and optional policy `inventory.roots`.
12
+ - Automatic inventory refresh when skill files change under any indexed root (enabled by default; disable with `inventory.autoRefreshOnChange: false`).
13
+ - `/shiori:doctor` now lists indexed roots with per-root skill counts and auto-refresh status.
14
+ - `/shiori:stats` counters for manual and automatic inventory refreshes.
15
+ - Tests for multi-root discovery, refresh after local changes, and policy-aware retrieval.
16
+
17
+ ### Changed
18
+
19
+ - `/shiori:reload` rebuilds the vault-wide inventory and SQLite FTS index instead of relying on the session-start snapshot only.
20
+
21
+ ## [0.4.0] - 2026-06-28
22
+
23
+ ### Added
24
+
25
+ - Always Visible Skill policy: Zero-Catalog Mode now keeps only `alwaysVisible` allowlisted skills in the Skill Catalog instead of hiding every entry.
26
+ - Diagnostics for missing or duplicate `alwaysVisible` policy entries in `/shiori:doctor` and one-time runtime warnings for missing skills.
27
+ - Tests for visible, missing, duplicate, and markdown catalog filtering cases.
28
+
29
+ ### Changed
30
+
31
+ - `alwaysVisible` policy entries are normalized to a deduplicated allowlist at load time.
32
+
7
33
  ## [0.3.2] - 2026-06-24
8
34
 
9
35
  ### Added
package/README.md CHANGED
@@ -12,12 +12,13 @@ For maintenance priorities and the phased plan, see [`ROADMAP.md`](ROADMAP.md).
12
12
  - **Policy-based retrieval**: treats skills as explicit by default and only candidates skills marked as triggerable.
13
13
  - **Compact candidate injection**: injects short skill suggestions instead of full `SKILL.md` files.
14
14
  - **On-demand loading**: exposes `shiori_load_skill` so the model can load one selected skill body when needed.
15
+ - **Vault-wide skill inventory**: indexes skills from project and user-global roots, with optional extra roots in policy.
15
16
  - **SQLite FTS retrieval**: indexes skill names, descriptions, and policy triggers with `node:sqlite` + FTS5, with token-match fallback.
16
17
  - **Compact UI output**: keeps skill-load logs short while still passing full skill text to the model.
17
18
 
18
19
  ## Status
19
20
 
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.
21
+ `0.5.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
22
 
22
23
  ## Requirements
23
24
 
@@ -39,7 +40,7 @@ Pi core packages are peer dependencies and should be supplied by the Pi runtime:
39
40
  After the package is published to npm:
40
41
 
41
42
  ```bash
42
- pi install npm:pi-skill-shiori@0.3.1
43
+ pi install npm:pi-skill-shiori@0.5.0
43
44
  ```
44
45
 
45
46
  Without a version pin:
@@ -53,7 +54,7 @@ pi install npm:pi-skill-shiori
53
54
  Use `-l` to write the package to the current project’s `.pi/settings.json`:
54
55
 
55
56
  ```bash
56
- pi install -l npm:pi-skill-shiori@0.3.1
57
+ pi install -l npm:pi-skill-shiori@0.5.0
57
58
  ```
58
59
 
59
60
  Without a version pin:
@@ -67,7 +68,7 @@ Equivalent pinned manual `.pi/settings.json` entry:
67
68
  ```json
68
69
  {
69
70
  "packages": [
70
- "npm:pi-skill-shiori@0.3.1"
71
+ "npm:pi-skill-shiori@0.5.0"
71
72
  ]
72
73
  }
73
74
  ```
@@ -85,7 +86,7 @@ Equivalent unpinned manual `.pi/settings.json` entry:
85
86
  ### Global install from GitHub
86
87
 
87
88
  ```bash
88
- pi install git:github.com/eiei114/pi-skill-shiori@v0.3.1
89
+ pi install git:github.com/eiei114/pi-skill-shiori@v0.5.0
89
90
  ```
90
91
 
91
92
  Without a tag pin:
@@ -97,7 +98,7 @@ pi install git:github.com/eiei114/pi-skill-shiori
97
98
  ### Project-local install from GitHub
98
99
 
99
100
  ```bash
100
- pi install -l git:github.com/eiei114/pi-skill-shiori@v0.3.1
101
+ pi install -l git:github.com/eiei114/pi-skill-shiori@v0.5.0
101
102
  ```
102
103
 
103
104
  Without a tag pin:
@@ -131,7 +132,38 @@ Or add a relative local path to `.pi/settings.json`:
131
132
  }
132
133
  ```
133
134
 
134
- > Note: policy/index changes can be refreshed with `/shiori:reload`, but extension code changes require restarting the Pi process because Node keeps imported extension modules cached.
135
+ > Note: policy/index changes can be refreshed with `/shiori:reload`, and Shiori auto-refreshes the vault-wide inventory when skill files change under indexed roots. Extension code changes still require restarting the Pi process because Node keeps imported extension modules cached.
136
+
137
+ ## Vault-wide skill inventory
138
+
139
+ Shiori builds a searchable inventory from these roots (in discovery order; first match wins on duplicate skill names):
140
+
141
+ | Root | Scope |
142
+ |---|---|
143
+ | `<cwd>/.pi/skills` | Project-local Pi skills |
144
+ | `<cwd>/.agents/skills` | Project-local Agent skills |
145
+ | `~/.pi/agent/skills` | User-global Pi agent skills |
146
+ | `inventory.roots` entries | Optional extra roots (relative to `<cwd>` or absolute) |
147
+
148
+ Refresh behavior:
149
+
150
+ - **Session start** builds the initial inventory and SQLite FTS index.
151
+ - **Auto-refresh** (default) rescans roots before retrieval when any `SKILL.md` path changes. This is a directory walk only; it rebuilds the FTS index when the fingerprint changes.
152
+ - **Manual refresh** via `/shiori:reload` always rebuilds the inventory and FTS index immediately.
153
+ - Disable auto-refresh when you prefer a fixed snapshot:
154
+
155
+ ```yaml
156
+ inventory:
157
+ autoRefreshOnChange: false
158
+ roots:
159
+ - custom-skills
160
+ ```
161
+
162
+ Cost/latency trade-offs:
163
+
164
+ - Auto-refresh adds a shallow filesystem walk on each agent turn while enabled. It avoids stale search results after local skill edits without restarting Pi.
165
+ - A full refresh rebuilds the SQLite FTS database and scales with total indexed skills and description length. Large vaults with many roots may prefer `autoRefreshOnChange: false` plus explicit `/shiori:reload` after bulk imports.
166
+ - Search stays description-first and policy-aware: only `triggerable` skills become candidates, with FTS ranking over names, descriptions, and policy triggers.
135
167
 
136
168
  ## Configure
137
169
 
@@ -168,7 +200,8 @@ Policy rules:
168
200
 
169
201
  - `defaults.activation: explicit` is the safe default. Unlisted skills are not auto-candidates.
170
202
  - `activation: triggerable` allows Shiori to recommend the skill for matching requests.
171
- - `alwaysVisible` lists skills that should remain visible/operational in Zero-Catalog workflows.
203
+ - `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.
204
+ - Missing `alwaysVisible` entries are reported by `/shiori:doctor` and warned once per session when Zero-Catalog Mode runs.
172
205
  - `candidateInjection.maxCandidates` limits how many suggestions enter the prompt.
173
206
  - `candidateInjection.minScore` drops weak matches.
174
207
 
@@ -184,7 +217,7 @@ This writes a generated review file next to `.pi/skill-shiori.yml`. Review it be
184
217
 
185
218
  | Command | Purpose |
186
219
  |---|---|
187
- | `/shiori:doctor` | Show policy path, inventory count, retrieval backend, suppression status, and code marker. |
220
+ | `/shiori:doctor` | Show policy path, indexed roots, inventory count, retrieval backend, suppression status, and code marker. |
188
221
  | `/shiori:bootstrap` | Generate a review draft policy from discovered skill descriptions. |
189
222
  | `/shiori:reload` | Ask what to reload, then rebuild Shiori’s skill inventory and optionally ask Pi to reload runtime resources. Code changes may still need full restart. |
190
223
  | `/shiori:recommend` | Ask what you need help with, then either pre-load matches and queue the task for the agent or review recommendations only. Planning phrases (e.g. `計画を立てたい`) return the dev-plan intake template without dumping SKILL bodies. |
@@ -280,7 +313,7 @@ npm login
280
313
  After publishing, verify the Pi install path:
281
314
 
282
315
  ```bash
283
- pi install npm:pi-skill-shiori@0.3.1
316
+ pi install npm:pi-skill-shiori@0.5.0
284
317
  pi install npm:pi-skill-shiori
285
318
  pi list
286
319
  /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.5.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/discovery.ts CHANGED
@@ -1,18 +1,42 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { basename, dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
- import type { SkillRecord } from "./types.js";
5
+ import type { ShioriPolicy, SkillRecord } from "./types.js";
6
6
 
7
7
  const SKILL_FILE = "SKILL.md";
8
8
 
9
- export async function discoverSkills(cwd: string): Promise<SkillRecord[]> {
9
+ /** Project-local skill roots scanned on every vault-wide inventory build. */
10
+ export const VAULT_LOCAL_SKILL_ROOT_SEGMENTS = [".pi/skills", ".agents/skills"] as const;
11
+
12
+ /** User-global Pi agent skills root (lowest discovery precedence). */
13
+ export const GLOBAL_AGENT_SKILL_ROOT_SEGMENTS = ".pi/agent/skills";
14
+
15
+ export function resolveVaultSkillRoots(cwd: string, policy?: Pick<ShioriPolicy, "inventory">): string[] {
10
16
  const roots = [
11
17
  join(cwd, ".pi", "skills"),
12
18
  join(cwd, ".agents", "skills"),
13
19
  join(homedir(), ".pi", "agent", "skills"),
14
20
  ];
15
21
 
22
+ for (const entry of policy?.inventory?.roots ?? []) {
23
+ const trimmed = entry.trim();
24
+ if (!trimmed) continue;
25
+ const resolved = isAbsolute(trimmed) ? resolve(trimmed) : resolve(cwd, trimmed);
26
+ if (!roots.includes(resolved)) {
27
+ roots.push(resolved);
28
+ }
29
+ }
30
+
31
+ return roots;
32
+ }
33
+
34
+ export async function discoverSkills(cwd: string, policy?: ShioriPolicy): Promise<SkillRecord[]> {
35
+ const roots = resolveVaultSkillRoots(cwd, policy);
36
+ return discoverSkillsFromRoots(roots);
37
+ }
38
+
39
+ export async function discoverSkillsFromRoots(roots: string[]): Promise<SkillRecord[]> {
16
40
  const records: SkillRecord[] = [];
17
41
  for (const root of roots) {
18
42
  const files = await findSkillFiles(root);
@@ -23,6 +47,15 @@ export async function discoverSkills(cwd: string): Promise<SkillRecord[]> {
23
47
  return dedupeByDiscoveryOrder(records);
24
48
  }
25
49
 
50
+ export async function computeInventoryFingerprint(roots: string[]): Promise<string> {
51
+ const parts: string[] = [];
52
+ for (const root of roots) {
53
+ const files = await findSkillFiles(root);
54
+ parts.push(`${root}:${files.sort().join(",")}`);
55
+ }
56
+ return parts.join("|");
57
+ }
58
+
26
59
  export function findDuplicates(records: SkillRecord[]): Map<string, SkillRecord[]> {
27
60
  const groups = new Map<string, SkillRecord[]>();
28
61
  for (const record of records) {
package/src/index.ts CHANGED
@@ -2,8 +2,9 @@ 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 { discoverSkills, findDuplicates } from "./discovery.js";
6
- import { buildSkillIndex, type SkillIndex } from "./indexer.js";
5
+ import { evaluateAlwaysVisible, formatAlwaysVisibleDiagnostics } from "./always-visible.js";
6
+ import { findDuplicates, formatInventoryRoots, isAutoRefreshEnabled, isInventoryStale, refreshSkillInventory } from "./inventory.js";
7
+ import type { SkillIndex } from "./indexer.js";
7
8
  import { loadRecommendedSkills } from "./load-skills.js";
8
9
  import { formatPlanningKickoffMessage, isPlanningIntent } from "./planning-kickoff.js";
9
10
  import { createStats } from "./metrics.js";
@@ -35,22 +36,42 @@ const CANCEL_CHOICE = "Cancel";
35
36
 
36
37
  export default function piSkillShiori(pi: ExtensionAPI) {
37
38
  let index: SkillIndex | undefined;
39
+ let inventoryFingerprint: string | undefined;
40
+ let inventoryRoots: string[] = [];
41
+ let warnedAlwaysVisibleMissing = new Set<string>();
38
42
  const stats = createStats();
39
43
 
40
44
  async function reload(cwd: string): Promise<SkillIndex> {
41
- const [policy, skills] = await Promise.all([loadPolicy(cwd), discoverSkills(cwd)]);
42
- index?.close?.();
43
- index = await buildSkillIndex(cwd, skills, policy);
44
- stats.inventoryCount = skills.length;
45
- stats.duplicateCount = findDuplicates(skills).size;
45
+ const policy = await loadPolicy(cwd);
46
+ const inventory = await refreshSkillInventory(cwd, policy, index);
47
+ index = inventory.index;
48
+ inventoryFingerprint = inventory.fingerprint;
49
+ inventoryRoots = inventory.roots;
50
+ stats.inventoryCount = index.skills.length;
51
+ stats.duplicateCount = findDuplicates(index.skills).size;
46
52
  stats.lastReloadAt = index.builtAt;
47
53
  stats.retrievalBackend = index.retrievalBackend;
48
54
  stats.suppressionStatus = policy.zeroCatalog.enabled ? "not-needed" : "disabled";
55
+ stats.inventoryRefreshCount += 1;
49
56
  return index;
50
57
  }
51
58
 
52
59
  async function ensureIndex(cwd: string): Promise<SkillIndex> {
53
- return index ?? reload(cwd);
60
+ if (!index || !inventoryFingerprint) {
61
+ return reload(cwd);
62
+ }
63
+
64
+ const policy = await loadPolicy(cwd);
65
+ if (!isAutoRefreshEnabled(policy)) {
66
+ return index;
67
+ }
68
+
69
+ if (await isInventoryStale(cwd, policy, inventoryFingerprint)) {
70
+ stats.inventoryAutoRefreshCount += 1;
71
+ return reload(cwd);
72
+ }
73
+
74
+ return index;
54
75
  }
55
76
 
56
77
  function withRecommendLimits(current: SkillIndex) {
@@ -70,6 +91,8 @@ export default function piSkillShiori(pi: ExtensionAPI) {
70
91
  pi.on("session_shutdown", () => {
71
92
  index?.close?.();
72
93
  index = undefined;
94
+ inventoryFingerprint = undefined;
95
+ inventoryRoots = [];
73
96
  });
74
97
 
75
98
  pi.on("input", async (event) => {
@@ -83,7 +106,20 @@ export default function piSkillShiori(pi: ExtensionAPI) {
83
106
  const current = await ensureIndex(ctx.cwd);
84
107
  if (!current.policy.zeroCatalog.enabled) return;
85
108
 
86
- const suppression = suppressSkillCatalog(event.systemPrompt);
109
+ const alwaysVisible = evaluateAlwaysVisible(current.policy.alwaysVisible, current.skills);
110
+ for (const name of alwaysVisible.missing) {
111
+ if (warnedAlwaysVisibleMissing.has(name)) continue;
112
+ warnedAlwaysVisibleMissing.add(name);
113
+ ctx.ui.notify(
114
+ `Pi Skill Shiori: always-visible skill not found in inventory: ${name}`,
115
+ "warning",
116
+ );
117
+ }
118
+
119
+ const suppression = suppressSkillCatalog(event.systemPrompt, {
120
+ alwaysVisible: current.policy.alwaysVisible,
121
+ skills: current.skills,
122
+ });
87
123
  stats.suppressionStatus = suppression.status;
88
124
  if (suppression.status === "failed-pattern-not-found") {
89
125
  ctx.ui.notify("Pi Skill Shiori: Skill Catalog suppression pattern not found; leaving system prompt unchanged.", "warning");
@@ -186,21 +222,29 @@ export default function piSkillShiori(pi: ExtensionAPI) {
186
222
  handler: async (_args, ctx) => {
187
223
  const current = await ensureIndex(ctx.cwd);
188
224
  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
- );
225
+ const alwaysVisible = evaluateAlwaysVisible(current.policy.alwaysVisible, current.skills);
226
+ const doctorLines = [
227
+ "Pi Skill Shiori doctor",
228
+ `policy: ${getPolicyPath(ctx.cwd)}`,
229
+ `zeroCatalog: ${current.policy.zeroCatalog.enabled ? "enabled" : "disabled"}`,
230
+ `skills: ${current.skills.length}`,
231
+ `duplicates: ${duplicates.size}`,
232
+ `alwaysVisible: ${current.policy.alwaysVisible.join(", ") || "none"}`,
233
+ ...formatAlwaysVisibleDiagnostics(alwaysVisible),
234
+ "inventoryRoots:",
235
+ ...formatInventoryRoots(inventoryRoots, current.skills).map((line) => ` - ${line}`),
236
+ `autoRefreshOnChange: ${isAutoRefreshEnabled(current.policy) ? "enabled" : "disabled"}`,
237
+ `retrievalBackend: ${current.retrievalBackend}`,
238
+ `suppression: ${stats.suppressionStatus}`,
239
+ `code: ${SHIORI_CODE_MARKER}`,
240
+ `builtAt: ${current.builtAt}`,
241
+ ];
242
+ const hasWarning =
243
+ duplicates.size > 0 ||
244
+ stats.suppressionStatus === "failed-pattern-not-found" ||
245
+ alwaysVisible.missing.length > 0 ||
246
+ alwaysVisible.duplicates.length > 0;
247
+ ctx.ui.notify(doctorLines.join("\n"), hasWarning ? "warning" : "info");
204
248
  },
205
249
  });
206
250
 
@@ -0,0 +1,58 @@
1
+ import {
2
+ computeInventoryFingerprint,
3
+ discoverSkillsFromRoots,
4
+ findDuplicates,
5
+ resolveVaultSkillRoots,
6
+ } from "./discovery.js";
7
+ import { buildSkillIndex, type SkillIndex } from "./indexer.js";
8
+ import type { ShioriPolicy } from "./types.js";
9
+
10
+ export interface SkillInventory {
11
+ index: SkillIndex;
12
+ roots: string[];
13
+ fingerprint: string;
14
+ }
15
+
16
+ export function isAutoRefreshEnabled(policy: ShioriPolicy): boolean {
17
+ return policy.inventory?.autoRefreshOnChange !== false;
18
+ }
19
+
20
+ export async function refreshSkillInventory(
21
+ cwd: string,
22
+ policy: ShioriPolicy,
23
+ previous?: SkillIndex,
24
+ ): Promise<SkillInventory> {
25
+ previous?.close?.();
26
+ const roots = resolveVaultSkillRoots(cwd, policy);
27
+ const [skills, fingerprint] = await Promise.all([
28
+ discoverSkillsFromRoots(roots),
29
+ computeInventoryFingerprint(roots),
30
+ ]);
31
+ const index = await buildSkillIndex(cwd, skills, policy);
32
+ return { index, roots, fingerprint };
33
+ }
34
+
35
+ export async function isInventoryStale(
36
+ cwd: string,
37
+ policy: ShioriPolicy,
38
+ fingerprint: string,
39
+ ): Promise<boolean> {
40
+ const roots = resolveVaultSkillRoots(cwd, policy);
41
+ const current = await computeInventoryFingerprint(roots);
42
+ return current !== fingerprint;
43
+ }
44
+
45
+ export function countSkillsByRoot(skills: SkillIndex["skills"], roots: string[]): Map<string, number> {
46
+ const counts = new Map(roots.map((root) => [root, 0]));
47
+ for (const skill of skills) {
48
+ counts.set(skill.source, (counts.get(skill.source) ?? 0) + 1);
49
+ }
50
+ return counts;
51
+ }
52
+
53
+ export function formatInventoryRoots(roots: string[], skills: SkillIndex["skills"]): string[] {
54
+ const counts = countSkillsByRoot(skills, roots);
55
+ return roots.map((root) => `${root} (${counts.get(root) ?? 0} skills)`);
56
+ }
57
+
58
+ export { findDuplicates };
package/src/metrics.ts CHANGED
@@ -10,5 +10,7 @@ export function createStats(): ShioriStats {
10
10
  explicitInvocationCount: 0,
11
11
  retrievalBackend: "token-match",
12
12
  suppressionStatus: "disabled",
13
+ inventoryRefreshCount: 0,
14
+ inventoryAutoRefreshCount: 0,
13
15
  };
14
16
  }
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 = {
@@ -29,6 +30,10 @@ export async function loadPolicy(cwd: string): Promise<ShioriPolicy> {
29
30
  }
30
31
 
31
32
  function normalizePolicy(policy: Partial<ShioriPolicy> | undefined): ShioriPolicy {
33
+ const extraRoots = (policy?.inventory?.roots ?? [])
34
+ .map((root) => (typeof root === "string" ? root.trim() : ""))
35
+ .filter(Boolean);
36
+
32
37
  return {
33
38
  zeroCatalog: {
34
39
  enabled: policy?.zeroCatalog?.enabled ?? DEFAULT_POLICY.zeroCatalog.enabled,
@@ -40,7 +45,14 @@ function normalizePolicy(policy: Partial<ShioriPolicy> | undefined): ShioriPolic
40
45
  maxCandidates: policy?.candidateInjection?.maxCandidates ?? DEFAULT_POLICY.candidateInjection.maxCandidates,
41
46
  minScore: policy?.candidateInjection?.minScore ?? DEFAULT_POLICY.candidateInjection.minScore,
42
47
  },
43
- alwaysVisible: policy?.alwaysVisible ?? DEFAULT_POLICY.alwaysVisible,
48
+ alwaysVisible: normalizeAlwaysVisible(policy?.alwaysVisible ?? DEFAULT_POLICY.alwaysVisible).allowlist,
49
+ inventory:
50
+ extraRoots.length > 0 || policy?.inventory?.autoRefreshOnChange === false
51
+ ? {
52
+ roots: extraRoots.length > 0 ? extraRoots : undefined,
53
+ autoRefreshOnChange: policy?.inventory?.autoRefreshOnChange,
54
+ }
55
+ : undefined,
44
56
  skills: policy?.skills ?? DEFAULT_POLICY.skills,
45
57
  };
46
58
  }
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
+ }
package/src/types.ts CHANGED
@@ -14,6 +14,12 @@ export interface ShioriPolicy {
14
14
  minScore: number;
15
15
  };
16
16
  alwaysVisible: string[];
17
+ inventory?: {
18
+ /** Extra skill roots relative to cwd or absolute paths. */
19
+ roots?: string[];
20
+ /** Rebuild the index when skill files change under any root (default: true). */
21
+ autoRefreshOnChange?: boolean;
22
+ };
17
23
  skills: Record<string, SkillPolicy>;
18
24
  }
19
25
 
@@ -49,4 +55,6 @@ export interface ShioriStats {
49
55
  retrievalBackend: RetrievalBackend;
50
56
  suppressionStatus: SuppressionStatus;
51
57
  lastReloadAt?: string;
58
+ inventoryRefreshCount: number;
59
+ inventoryAutoRefreshCount: number;
52
60
  }