pi-skill-shiori 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eiei114
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,280 @@
1
+ # Pi Skill Shiori
2
+
3
+ Pi Skill Shiori is a [Pi](https://pi.dev/) package that keeps large Agent Skill catalogs out of the model prompt and lets the agent load only the skills that match the current task.
4
+
5
+ It is built for vaults or projects with many Agent Skills where the default catalog becomes noisy and expensive.
6
+
7
+ ## What it does
8
+
9
+ - **Zero-Catalog Mode**: hides the normal skill catalog when Pi exposes one in the system prompt.
10
+ - **Policy-based retrieval**: treats skills as explicit by default and only candidates skills marked as triggerable.
11
+ - **Compact candidate injection**: injects short skill suggestions instead of full `SKILL.md` files.
12
+ - **On-demand loading**: exposes `shiori_load_skill` so the model can load one selected skill body when needed.
13
+ - **SQLite FTS retrieval**: indexes skill names, descriptions, and policy triggers with `node:sqlite` + FTS5, with token-match fallback.
14
+ - **Compact UI output**: keeps skill-load logs short while still passing full skill text to the model.
15
+
16
+ ## Status
17
+
18
+ `0.1.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
+
20
+ ## Requirements
21
+
22
+ - Pi Coding Agent with package support
23
+ - Node.js `>=22.5`
24
+ - `npm`
25
+
26
+ Pi core packages are peer dependencies and should be supplied by the Pi runtime:
27
+
28
+ - `@earendil-works/pi-ai`
29
+ - `@earendil-works/pi-coding-agent`
30
+ - `@earendil-works/pi-tui`
31
+ - `typebox`
32
+
33
+ ## Install
34
+
35
+ ### Global install from npm
36
+
37
+ After the package is published to npm:
38
+
39
+ ```bash
40
+ pi install npm:pi-skill-shiori@0.1.0
41
+ ```
42
+
43
+ Without a version pin:
44
+
45
+ ```bash
46
+ pi install npm:pi-skill-shiori
47
+ ```
48
+
49
+ ### Project-local install from npm
50
+
51
+ Use `-l` to write the package to the current project’s `.pi/settings.json`:
52
+
53
+ ```bash
54
+ pi install -l npm:pi-skill-shiori@0.1.0
55
+ ```
56
+
57
+ Equivalent manual `.pi/settings.json` entry:
58
+
59
+ ```json
60
+ {
61
+ "packages": [
62
+ "npm:pi-skill-shiori@0.1.0"
63
+ ]
64
+ }
65
+ ```
66
+
67
+ ### Global install from GitHub
68
+
69
+ ```bash
70
+ pi install git:github.com/eiei114/pi-skill-shiori@v0.1.0
71
+ ```
72
+
73
+ Without a tag pin:
74
+
75
+ ```bash
76
+ pi install git:github.com/eiei114/pi-skill-shiori
77
+ ```
78
+
79
+ ### Project-local install from GitHub
80
+
81
+ ```bash
82
+ pi install -l git:github.com/eiei114/pi-skill-shiori@v0.1.0
83
+ ```
84
+
85
+ ### Try without installing
86
+
87
+ ```bash
88
+ pi -e npm:pi-skill-shiori
89
+ pi -e git:github.com/eiei114/pi-skill-shiori
90
+ ```
91
+
92
+ ### Local development install
93
+
94
+ From a project that should use your checkout:
95
+
96
+ ```bash
97
+ pi install -l /absolute/path/to/pi-skill-shiori
98
+ ```
99
+
100
+ Or add a relative local path to `.pi/settings.json`:
101
+
102
+ ```json
103
+ {
104
+ "packages": [
105
+ "../../OSS/pi-skill-shiori"
106
+ ]
107
+ }
108
+ ```
109
+
110
+ > 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.
111
+
112
+ ## Configure
113
+
114
+ Create `.pi/skill-shiori.yml` in the project where Pi runs.
115
+
116
+ Minimal config:
117
+
118
+ ```yaml
119
+ zeroCatalog:
120
+ enabled: true
121
+
122
+ defaults:
123
+ activation: explicit
124
+
125
+ candidateInjection:
126
+ maxCandidates: 3
127
+ minScore: 0.62
128
+
129
+ alwaysVisible:
130
+ - pi-skill-shiori
131
+
132
+ skills:
133
+ reddit-research:
134
+ activation: triggerable
135
+ triggers:
136
+ include:
137
+ - Reddit
138
+ - Redditで調べて
139
+ - reputation on Reddit
140
+ exclude: []
141
+ ```
142
+
143
+ Policy rules:
144
+
145
+ - `defaults.activation: explicit` is the safe default. Unlisted skills are not auto-candidates.
146
+ - `activation: triggerable` allows Shiori to recommend the skill for matching requests.
147
+ - `alwaysVisible` lists skills that should remain visible/operational in Zero-Catalog workflows.
148
+ - `candidateInjection.maxCandidates` limits how many suggestions enter the prompt.
149
+ - `candidateInjection.minScore` drops weak matches.
150
+
151
+ Generate a starter policy from discovered skills:
152
+
153
+ ```text
154
+ /shiori:bootstrap
155
+ ```
156
+
157
+ This writes a generated review file next to `.pi/skill-shiori.yml`. Review it before using it as your real policy.
158
+
159
+ ## Commands
160
+
161
+ | Command | Purpose |
162
+ |---|---|
163
+ | `/shiori:doctor` | Show policy path, inventory count, retrieval backend, suppression status, and code marker. |
164
+ | `/shiori:bootstrap` | Generate a review draft policy from discovered skill descriptions. |
165
+ | `/shiori:reload` | Rebuild Shiori’s skill inventory and retrieval index. |
166
+ | `/shiori:reload --runtime` | Rebuild Shiori and ask Pi to reload runtime resources. Code changes may still need full restart. |
167
+ | `/shiori:test-query <text>` | Preview candidates for a query. |
168
+ | `/shiori:test-query --verbose <text>` | Include full descriptions, scores, paths, and load hints. |
169
+ | `/shiori:stats` | Show operational counters. |
170
+
171
+ ## Tool
172
+
173
+ Shiori registers one tool:
174
+
175
+ ```ts
176
+ shiori_load_skill({ skill: "reddit-research" })
177
+ ```
178
+
179
+ The model receives the full selected `SKILL.md` content. The Pi UI shows a compact result like:
180
+
181
+ ```text
182
+ ✓ Loaded reddit-research (6.3KB)
183
+ ```
184
+
185
+ ## Suppression statuses
186
+
187
+ `/shiori:doctor` reports the last suppression result:
188
+
189
+ | Status | Meaning |
190
+ |---|---|
191
+ | `disabled` | `zeroCatalog.enabled` is false. |
192
+ | `not-needed` | No normal Skill Catalog marker was present in that turn’s prompt, so nothing needed deletion. |
193
+ | `suppressed` | Shiori recognized and removed the normal Skill Catalog for that turn. |
194
+ | `failed-pattern-not-found` | A catalog-like marker existed, but Shiori could not safely identify its boundary. Prompt left unchanged. |
195
+
196
+ `not-needed` is not an error. It often means Pi or another configuration already avoided injecting the normal catalog.
197
+
198
+ ## Retrieval backend
199
+
200
+ Shiori prefers `sqlite-fts` using Node’s built-in `node:sqlite` and FTS5. If that is unavailable, it falls back to `token-match`.
201
+
202
+ Check backend:
203
+
204
+ ```text
205
+ /shiori:doctor
206
+ ```
207
+
208
+ Example:
209
+
210
+ ```text
211
+ retrievalBackend: sqlite-fts
212
+ code: prompt-boundary-v3
213
+ ```
214
+
215
+ ## Development
216
+
217
+ ```bash
218
+ git clone https://github.com/eiei114/pi-skill-shiori.git
219
+ cd pi-skill-shiori
220
+ npm install
221
+ npm run typecheck
222
+ ```
223
+
224
+ Run in a Pi project without installing globally:
225
+
226
+ ```bash
227
+ pi -e /absolute/path/to/pi-skill-shiori
228
+ ```
229
+
230
+ Recommended release checks:
231
+
232
+ ```bash
233
+ npm run typecheck
234
+ npm test
235
+ ```
236
+
237
+
238
+ ## Publish to npm
239
+
240
+ Release checklist for npm:
241
+
242
+ ```bash
243
+ npm run typecheck
244
+ npm test
245
+ npm run release:npm:dry
246
+ npm whoami
247
+ npm run release:npm
248
+ ```
249
+
250
+ For a first-time publish, login first:
251
+
252
+ ```bash
253
+ npm login
254
+ ```
255
+
256
+ After publishing, verify the Pi install path:
257
+
258
+ ```bash
259
+ pi install npm:pi-skill-shiori@0.1.0
260
+ pi list
261
+ /shiori:doctor
262
+ ```
263
+
264
+ For future versions:
265
+
266
+ ```bash
267
+ npm version patch
268
+ git push --follow-tags
269
+ npm run release:npm
270
+ ```
271
+
272
+ ## Security notes
273
+
274
+ Pi packages execute local code with the same permissions as Pi. Review third-party packages before installing them.
275
+
276
+ Shiori itself does not sandbox skills. It only changes how skill candidates are discovered and loaded. A loaded skill can still instruct the model to run tools, edit files, or execute commands according to your Pi/tool permissions.
277
+
278
+ ## License
279
+
280
+ MIT
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "pi-skill-shiori",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that reduces Agent Skill catalog tokens with policy-based skill retrieval and on-demand skill loading.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/eiei114/pi-skill-shiori#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/eiei114/pi-skill-shiori.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/eiei114/pi-skill-shiori/issues"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi-extension",
18
+ "agent-skills",
19
+ "skill-retrieval"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.5"
23
+ },
24
+ "files": [
25
+ "README.md",
26
+ "LICENSE",
27
+ "src"
28
+ ],
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit",
31
+ "build": "tsc --noEmit",
32
+ "test": "npm run typecheck",
33
+ "prepublishOnly": "npm test",
34
+ "release:npm:dry": "npm pack --dry-run",
35
+ "release:npm": "npm publish --access public"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/index.ts"
40
+ ]
41
+ },
42
+ "dependencies": {
43
+ "yaml": "^2.8.1"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
49
+ "typebox": "*"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-ai": "^0.75.5",
53
+ "@earendil-works/pi-coding-agent": "^0.75.5",
54
+ "@earendil-works/pi-tui": "^0.75.5",
55
+ "@types/node": "^25.9.1",
56
+ "typebox": "^1.1.38",
57
+ "typescript": "^6.0.3"
58
+ }
59
+ }
@@ -0,0 +1,96 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import type { SkillRecord } from "./types.js";
5
+
6
+ const SKILL_FILE = "SKILL.md";
7
+
8
+ export async function discoverSkills(cwd: string): Promise<SkillRecord[]> {
9
+ const roots = [
10
+ join(cwd, ".pi", "skills"),
11
+ join(cwd, ".agents", "skills"),
12
+ join(homedir(), ".pi", "agent", "skills"),
13
+ ];
14
+
15
+ const records: SkillRecord[] = [];
16
+ for (const root of roots) {
17
+ const files = await findSkillFiles(root);
18
+ for (const file of files) {
19
+ records.push(await readSkillRecord(file, root));
20
+ }
21
+ }
22
+ return dedupeByDiscoveryOrder(records);
23
+ }
24
+
25
+ export function findDuplicates(records: SkillRecord[]): Map<string, SkillRecord[]> {
26
+ const groups = new Map<string, SkillRecord[]>();
27
+ for (const record of records) {
28
+ const current = groups.get(record.name) ?? [];
29
+ current.push(record);
30
+ groups.set(record.name, current);
31
+ }
32
+ return new Map([...groups].filter(([, items]) => items.length > 1));
33
+ }
34
+
35
+ async function findSkillFiles(root: string): Promise<string[]> {
36
+ try {
37
+ const info = await stat(root);
38
+ if (!info.isDirectory()) return [];
39
+ } catch {
40
+ return [];
41
+ }
42
+
43
+ const result: string[] = [];
44
+ await walk(root, result);
45
+ return result;
46
+ }
47
+
48
+ async function walk(dir: string, result: string[]): Promise<void> {
49
+ const entries = await readdir(dir, { withFileTypes: true });
50
+ for (const entry of entries) {
51
+ const fullPath = join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ await walk(fullPath, result);
54
+ } else if (entry.isFile() && entry.name === SKILL_FILE) {
55
+ result.push(fullPath);
56
+ }
57
+ }
58
+ }
59
+
60
+ async function readSkillRecord(path: string, root: string): Promise<SkillRecord> {
61
+ const raw = await readFile(path, "utf8");
62
+ const frontmatter = raw.match(/^---\s*\n([\s\S]*?)\n---/);
63
+ const block = frontmatter?.[1] ?? "";
64
+ const name = matchYamlScalar(block, "name") ?? basename(dirname(path));
65
+ const description = matchYamlScalar(block, "description") ?? firstParagraph(raw) ?? "No description.";
66
+ return {
67
+ name,
68
+ description,
69
+ path: resolve(path),
70
+ source: root,
71
+ };
72
+ }
73
+
74
+ function matchYamlScalar(block: string, key: string): string | undefined {
75
+ const match = block.match(new RegExp(`^${key}:\s*(.+)$`, "m"));
76
+ return match?.[1]?.trim().replace(/^['\"]|['\"]$/g, "");
77
+ }
78
+
79
+ function firstParagraph(raw: string): string | undefined {
80
+ return raw
81
+ .replace(/^---\s*\n[\s\S]*?\n---/, "")
82
+ .split(/\n\s*\n/)
83
+ .map((part) => part.trim())
84
+ .find(Boolean);
85
+ }
86
+
87
+ function dedupeByDiscoveryOrder(records: SkillRecord[]): SkillRecord[] {
88
+ const seen = new Set<string>();
89
+ const result: SkillRecord[] = [];
90
+ for (const record of records) {
91
+ if (seen.has(record.name)) continue;
92
+ seen.add(record.name);
93
+ result.push(record);
94
+ }
95
+ return result;
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,233 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { discoverSkills, findDuplicates } from "./discovery.js";
6
+ import { buildSkillIndex, type SkillIndex } from "./indexer.js";
7
+ import { createStats } from "./metrics.js";
8
+ import { getPolicyPath, loadPolicy } from "./policy.js";
9
+ import { formatCandidateInjection, suppressSkillCatalog } from "./prompt.js";
10
+ import { retrieveCandidates } from "./retrieval.js";
11
+ import type { SkillRecord } from "./types.js";
12
+
13
+ interface ShioriLoadDetails {
14
+ skill: string;
15
+ path: string;
16
+ bytes: number;
17
+ loadedAt: string;
18
+ }
19
+
20
+ const SHIORI_CODE_MARKER = "prompt-boundary-v3";
21
+
22
+ export default function piSkillShiori(pi: ExtensionAPI) {
23
+ let index: SkillIndex | undefined;
24
+ const stats = createStats();
25
+
26
+ async function reload(cwd: string): Promise<SkillIndex> {
27
+ const [policy, skills] = await Promise.all([loadPolicy(cwd), discoverSkills(cwd)]);
28
+ index?.close?.();
29
+ index = await buildSkillIndex(cwd, skills, policy);
30
+ stats.inventoryCount = skills.length;
31
+ stats.duplicateCount = findDuplicates(skills).size;
32
+ stats.lastReloadAt = index.builtAt;
33
+ stats.retrievalBackend = index.retrievalBackend;
34
+ stats.suppressionStatus = policy.zeroCatalog.enabled ? "not-needed" : "disabled";
35
+ return index;
36
+ }
37
+
38
+ async function ensureIndex(cwd: string): Promise<SkillIndex> {
39
+ return index ?? reload(cwd);
40
+ }
41
+
42
+ pi.on("session_start", async (_event, ctx) => {
43
+ await reload(ctx.cwd);
44
+ });
45
+
46
+ pi.on("input", async (event) => {
47
+ if (/^\/(skill:|shiori:)/.test(event.text.trim())) {
48
+ stats.explicitInvocationCount += 1;
49
+ }
50
+ return { action: "continue" };
51
+ });
52
+
53
+ pi.on("before_agent_start", async (event, ctx) => {
54
+ const current = await ensureIndex(ctx.cwd);
55
+ if (!current.policy.zeroCatalog.enabled) return;
56
+
57
+ const suppression = suppressSkillCatalog(event.systemPrompt);
58
+ stats.suppressionStatus = suppression.status;
59
+ if (suppression.status === "failed-pattern-not-found") {
60
+ ctx.ui.notify("Pi Skill Shiori: Skill Catalog suppression pattern not found; leaving system prompt unchanged.", "warning");
61
+ }
62
+
63
+ const candidates = retrieveCandidates(event.prompt, current.skills, current.policy, current);
64
+ if (candidates.length === 0) stats.zeroCandidateCount += 1;
65
+ else stats.candidateHitCount += 1;
66
+
67
+ const injection = formatCandidateInjection(candidates);
68
+ const candidateSection = injection ? `\n\n## Pi Skill Shiori Candidates\n\n${injection}\n` : "";
69
+
70
+ return {
71
+ systemPrompt: `${suppression.systemPrompt}${candidateSection}`,
72
+ };
73
+ });
74
+
75
+ pi.registerTool({
76
+ name: "shiori_load_skill",
77
+ label: "Shiori Load Skill",
78
+ description: "Load one Agent Skill by name from the Pi Skill Shiori inventory.",
79
+ promptSnippet: "Load an Agent Skill by name after Pi Skill Shiori recommends it.",
80
+ promptGuidelines: [
81
+ "Use shiori_load_skill only when a Pi Skill Shiori candidate is relevant to the user's current request.",
82
+ ],
83
+ parameters: Type.Object({
84
+ skill: Type.String({ description: "Agent Skill name to load" }),
85
+ }),
86
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87
+ const current = await ensureIndex(ctx.cwd);
88
+ const record = findSkill(params.skill, current.skills);
89
+ if (!record) throw new Error(`Skill not found: ${params.skill}`);
90
+ const content = await readFile(record.path, "utf8");
91
+ stats.loadedSkillCount += 1;
92
+ const details: ShioriLoadDetails = {
93
+ skill: record.name,
94
+ path: record.path,
95
+ bytes: Buffer.byteLength(content, "utf8"),
96
+ loadedAt: new Date().toISOString(),
97
+ };
98
+ return {
99
+ content: [{ type: "text", text: `Loaded ${record.name} from ${record.path}\n\n${content}` }],
100
+ details,
101
+ };
102
+ },
103
+ renderResult(result, { expanded }, theme) {
104
+ const details = result.details as Partial<ShioriLoadDetails> | undefined;
105
+ if (!details?.skill) {
106
+ return new Text(theme.fg("success", "Loaded skill"), 0, 0);
107
+ }
108
+ const size = typeof details.bytes === "number" ? formatBytes(details.bytes) : "unknown size";
109
+ const collapsed = `${theme.fg("success", "✓ Loaded")} ${theme.bold(details.skill)} ${theme.fg("muted", `(${size})`)}`;
110
+ if (!expanded) return new Text(collapsed, 0, 0);
111
+ const path = details.path ? `\n${theme.fg("dim", details.path)}` : "";
112
+ return new Text(`${collapsed}${path}`, 0, 0);
113
+ },
114
+ });
115
+
116
+ pi.registerCommand("shiori:doctor", {
117
+ description: "Show Pi Skill Shiori configuration and inventory status",
118
+ handler: async (_args, ctx) => {
119
+ const current = await ensureIndex(ctx.cwd);
120
+ const duplicates = findDuplicates(current.skills);
121
+ ctx.ui.notify(
122
+ [
123
+ "Pi Skill Shiori doctor",
124
+ `policy: ${getPolicyPath(ctx.cwd)}`,
125
+ `zeroCatalog: ${current.policy.zeroCatalog.enabled ? "enabled" : "disabled"}`,
126
+ `skills: ${current.skills.length}`,
127
+ `duplicates: ${duplicates.size}`,
128
+ `alwaysVisible: ${current.policy.alwaysVisible.join(", ") || "none"}`,
129
+ `retrievalBackend: ${current.retrievalBackend}`,
130
+ `suppression: ${stats.suppressionStatus}`,
131
+ `code: ${SHIORI_CODE_MARKER}`,
132
+ `builtAt: ${current.builtAt}`,
133
+ ].join("\n"),
134
+ duplicates.size > 0 || stats.suppressionStatus === "failed-pattern-not-found" ? "warning" : "info",
135
+ );
136
+ },
137
+ });
138
+
139
+ pi.registerCommand("shiori:reload", {
140
+ description: "Rebuild Pi Skill Shiori inventory and optional Pi runtime resources",
141
+ handler: async (args, ctx) => {
142
+ await reload(ctx.cwd);
143
+ if (args.includes("--runtime")) {
144
+ await ctx.reload();
145
+ return;
146
+ }
147
+ ctx.ui.notify(`Pi Skill Shiori reloaded: ${stats.inventoryCount} skills (${stats.retrievalBackend})`, "info");
148
+ },
149
+ });
150
+
151
+ pi.registerCommand("shiori:test-query", {
152
+ description: "Test Pi Skill Shiori candidate retrieval for arbitrary text",
153
+ handler: async (args, ctx) => {
154
+ const current = await ensureIndex(ctx.cwd);
155
+ const { query, verbose } = parseTestQueryArgs(args);
156
+ const candidates = retrieveCandidates(query, current.skills, current.policy, current);
157
+ ctx.ui.notify(
158
+ formatCandidateInjection(candidates, { verbose }) || "No candidates",
159
+ candidates.length ? "info" : "warning",
160
+ );
161
+ },
162
+ });
163
+
164
+ pi.registerCommand("shiori:stats", {
165
+ description: "Show Pi Skill Shiori operational counters",
166
+ handler: async (_args, ctx) => {
167
+ ctx.ui.notify(JSON.stringify(stats, null, 2), "info");
168
+ },
169
+ });
170
+
171
+ pi.registerCommand("shiori:bootstrap", {
172
+ description: "Generate a review policy draft from discovered skill descriptions",
173
+ handler: async (_args, ctx) => {
174
+ const current = await ensureIndex(ctx.cwd);
175
+ const draft = renderGeneratedPolicy(current.skills);
176
+ const outputPath = getPolicyPath(ctx.cwd).replace(/\.yml$/, ".generated.yml");
177
+ await writeFile(outputPath, draft, "utf8");
178
+ ctx.ui.notify(`Generated ${outputPath}`, "info");
179
+ },
180
+ });
181
+ }
182
+
183
+ function findSkill(name: string, skills: SkillRecord[]): SkillRecord | undefined {
184
+ return skills.find((skill) => skill.name === name);
185
+ }
186
+
187
+ function parseTestQueryArgs(args: string): { query: string; verbose: boolean } {
188
+ const verbose = /(^|\s)--verbose(\s|$)/.test(args);
189
+ return {
190
+ verbose,
191
+ query: args.replace(/(^|\s)--verbose(\s|$)/g, " ").trim(),
192
+ };
193
+ }
194
+
195
+ function formatBytes(bytes: number): string {
196
+ if (bytes < 1024) return `${bytes}B`;
197
+ return `${(bytes / 1024).toFixed(1)}KB`;
198
+ }
199
+
200
+
201
+ function renderGeneratedPolicy(skills: SkillRecord[]): string {
202
+ const lines = [
203
+ "zeroCatalog:",
204
+ " enabled: false",
205
+ "defaults:",
206
+ " activation: explicit",
207
+ "candidateInjection:",
208
+ " maxCandidates: 3",
209
+ " minScore: 0.62",
210
+ "alwaysVisible:",
211
+ " - pi-skill-shiori",
212
+ "skills:",
213
+ ];
214
+
215
+ for (const skill of skills) {
216
+ const triggerable = /proactively activate|triggers?:|use when/i.test(skill.description);
217
+ lines.push(` ${skill.name}:`);
218
+ lines.push(` activation: ${triggerable ? "triggerable" : "explicit"}`);
219
+ if (triggerable) {
220
+ lines.push(" triggers:");
221
+ lines.push(" include:");
222
+ lines.push(` - ${JSON.stringify(skill.name)}`);
223
+ lines.push(" exclude: []");
224
+ }
225
+ lines.push(` path: ${JSON.stringify(relativeForYaml(skill.path))}`);
226
+ }
227
+
228
+ return `${lines.join("\n")}\n`;
229
+ }
230
+
231
+ function relativeForYaml(path: string): string {
232
+ return path.replace(/\\/g, "/");
233
+ }
package/src/indexer.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+ import type { RetrievalBackend, ShioriPolicy, SkillRecord } from "./types.js";
5
+
6
+ export interface SkillIndex {
7
+ skills: SkillRecord[];
8
+ policy: ShioriPolicy;
9
+ builtAt: string;
10
+ retrievalBackend: RetrievalBackend;
11
+ search(query: string, limit: number): SkillRecord[];
12
+ close(): void;
13
+ }
14
+
15
+ export async function buildSkillIndex(cwd: string, skills: SkillRecord[], policy: ShioriPolicy): Promise<SkillIndex> {
16
+ const builtAt = new Date().toISOString();
17
+ const byName = new Map(skills.map((skill) => [skill.name, skill]));
18
+
19
+ try {
20
+ const require = createRequire(import.meta.url);
21
+ const sqlite = require("node:sqlite") as typeof import("node:sqlite");
22
+ const cacheDir = join(cwd, ".pi", "cache", "skill-shiori");
23
+ const dbPath = join(cacheDir, `index-${process.pid}-${Date.now()}.sqlite`);
24
+ await mkdir(cacheDir, { recursive: true });
25
+
26
+ const db = new sqlite.DatabaseSync(dbPath);
27
+ db.exec("CREATE VIRTUAL TABLE skill_fts USING fts5(name, description, triggers, path)");
28
+ const insert = db.prepare("INSERT INTO skill_fts(name, description, triggers, path) VALUES (?, ?, ?, ?)");
29
+ for (const skill of skills) {
30
+ const triggers = [
31
+ ...(policy.skills[skill.name]?.triggers?.include ?? []),
32
+ ...(policy.skills[skill.name]?.triggers?.exclude ?? []),
33
+ ].join(" ");
34
+ insert.run(skill.name, skill.description, triggers, skill.path);
35
+ }
36
+
37
+ return {
38
+ skills,
39
+ policy,
40
+ builtAt,
41
+ retrievalBackend: "sqlite-fts",
42
+ search(query: string, limit: number): SkillRecord[] {
43
+ const ftsQuery = buildFtsQuery(query);
44
+ if (!ftsQuery) return [];
45
+ try {
46
+ const rows = db
47
+ .prepare("SELECT name FROM skill_fts WHERE skill_fts MATCH ? LIMIT ?")
48
+ .all(ftsQuery, limit) as Array<{ name: string }>;
49
+ return rows.map((row) => byName.get(row.name)).filter((skill): skill is SkillRecord => Boolean(skill));
50
+ } catch {
51
+ return [];
52
+ }
53
+ },
54
+ close(): void {
55
+ db.close();
56
+ },
57
+ };
58
+ } catch {
59
+ return {
60
+ skills,
61
+ policy,
62
+ builtAt,
63
+ retrievalBackend: "token-match",
64
+ search(): SkillRecord[] {
65
+ return [];
66
+ },
67
+ close(): void {},
68
+ };
69
+ }
70
+ }
71
+
72
+ function buildFtsQuery(query: string): string {
73
+ const terms = query.match(/[A-Za-z0-9_-]{2,}/g) ?? [];
74
+ const uniqueTerms = [...new Set(terms.map((term) => term.toLowerCase()))].slice(0, 8);
75
+ return uniqueTerms.map((term) => `${escapeFtsTerm(term)}*`).join(" OR ");
76
+ }
77
+
78
+ function escapeFtsTerm(term: string): string {
79
+ return term.replace(/[^A-Za-z0-9_-]/g, "");
80
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { ShioriStats } from "./types.js";
2
+
3
+ export function createStats(): ShioriStats {
4
+ return {
5
+ inventoryCount: 0,
6
+ duplicateCount: 0,
7
+ candidateHitCount: 0,
8
+ zeroCandidateCount: 0,
9
+ loadedSkillCount: 0,
10
+ explicitInvocationCount: 0,
11
+ retrievalBackend: "token-match",
12
+ suppressionStatus: "disabled",
13
+ };
14
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ import type { ShioriPolicy } from "./types.js";
5
+
6
+ export const DEFAULT_POLICY: ShioriPolicy = {
7
+ zeroCatalog: { enabled: false },
8
+ defaults: { activation: "explicit" },
9
+ candidateInjection: { maxCandidates: 3, minScore: 0.62 },
10
+ alwaysVisible: ["pi-skill-shiori"],
11
+ skills: {},
12
+ };
13
+
14
+ export function getPolicyPath(cwd: string): string {
15
+ return join(cwd, ".pi", "skill-shiori.yml");
16
+ }
17
+
18
+ export async function loadPolicy(cwd: string): Promise<ShioriPolicy> {
19
+ try {
20
+ const raw = await readFile(getPolicyPath(cwd), "utf8");
21
+ const parsed = parse(raw) as Partial<ShioriPolicy> | undefined;
22
+ return normalizePolicy(parsed);
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
25
+ return DEFAULT_POLICY;
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ function normalizePolicy(policy: Partial<ShioriPolicy> | undefined): ShioriPolicy {
32
+ return {
33
+ zeroCatalog: {
34
+ enabled: policy?.zeroCatalog?.enabled ?? DEFAULT_POLICY.zeroCatalog.enabled,
35
+ },
36
+ defaults: {
37
+ activation: policy?.defaults?.activation ?? DEFAULT_POLICY.defaults.activation,
38
+ },
39
+ candidateInjection: {
40
+ maxCandidates: policy?.candidateInjection?.maxCandidates ?? DEFAULT_POLICY.candidateInjection.maxCandidates,
41
+ minScore: policy?.candidateInjection?.minScore ?? DEFAULT_POLICY.candidateInjection.minScore,
42
+ },
43
+ alwaysVisible: policy?.alwaysVisible ?? DEFAULT_POLICY.alwaysVisible,
44
+ skills: policy?.skills ?? DEFAULT_POLICY.skills,
45
+ };
46
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { SkillCandidate, SuppressionStatus } from "./types.js";
2
+
3
+ interface CandidateFormatOptions {
4
+ verbose?: boolean;
5
+ }
6
+
7
+ export interface CatalogSuppressionResult {
8
+ systemPrompt: string;
9
+ status: SuppressionStatus;
10
+ }
11
+
12
+ const COMPACT_DESCRIPTION_LIMIT = 120;
13
+
14
+ export function formatCandidateInjection(candidates: SkillCandidate[], options: CandidateFormatOptions = {}): string {
15
+ if (candidates.length === 0) return "";
16
+ const lines = ["Relevant skills:"];
17
+ for (const candidate of candidates) {
18
+ const desc = compactDescription(candidate.skill.description, COMPACT_DESCRIPTION_LIMIT);
19
+ if (options.verbose) {
20
+ lines.push(`- ${candidate.skill.name}`);
21
+ lines.push(` desc: ${candidate.skill.description}`);
22
+ lines.push(` score: ${candidate.score.toFixed(2)}`);
23
+ lines.push(` why: ${candidate.why}`);
24
+ lines.push(` path: ${candidate.skill.path}`);
25
+ lines.push(` load: shiori_load_skill({ skill: "${candidate.skill.name}" })`);
26
+ } else {
27
+ lines.push(
28
+ `- ${candidate.skill.name}: ${desc} Reason: ${candidate.why}. Load: shiori_load_skill({ skill: "${candidate.skill.name}" })`,
29
+ );
30
+ }
31
+ }
32
+ return lines.join("\n");
33
+ }
34
+
35
+ export function compactDescription(description: string, maxChars = COMPACT_DESCRIPTION_LIMIT): string {
36
+ const singleLine = description.replace(/\s+/g, " ").trim();
37
+ if (singleLine.length <= maxChars) return singleLine;
38
+ return `${singleLine.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
39
+ }
40
+
41
+ export function suppressSkillCatalog(systemPrompt: string): CatalogSuppressionResult {
42
+ const replacement = [
43
+ "### Available skills",
44
+ "Normal Skill Catalog hidden by Pi Skill Shiori.",
45
+ "Use injected candidates or shiori_load_skill when relevant.",
46
+ "Explicit /skill:name invocation remains allowed.",
47
+ ].join("\n");
48
+
49
+ const patterns = [
50
+ /\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>/,
51
+ /(^|\r?\n)<available_skills>[\s\S]*?<\/available_skills>/i,
52
+ /(^|\r?\n)#{2,4}\s*Available skills\b[\s\S]*?(?=\r?\n#{2,4}\s*How to use skills\b)/i,
53
+ /(^|\r?\n)#{2,4}\s*Available skills\b[\s\S]*?(?=\r?\n#{2,4}\s*Fallback\b)/i,
54
+ ];
55
+ for (const pattern of patterns) {
56
+ if (!pattern.test(systemPrompt)) continue;
57
+ return {
58
+ systemPrompt: systemPrompt.replace(pattern, `${replacement}\n`),
59
+ status: "suppressed",
60
+ };
61
+ }
62
+
63
+ if (!hasSkillCatalogSignal(systemPrompt)) {
64
+ return { systemPrompt, status: "not-needed" };
65
+ }
66
+
67
+ return { systemPrompt, status: "failed-pattern-not-found" };
68
+ }
69
+
70
+ function hasSkillCatalogSignal(systemPrompt: string): boolean {
71
+ return /<\/?available_skills>|The following skills provide specialized instructions|#{2,4}\s*Available skills\b/i.test(
72
+ systemPrompt,
73
+ );
74
+ }
@@ -0,0 +1,88 @@
1
+ import type { ShioriPolicy, SkillCandidate, SkillRecord } from "./types.js";
2
+
3
+ interface RetrievalIndex {
4
+ search(query: string, limit: number): SkillRecord[];
5
+ }
6
+
7
+ export function retrieveCandidates(
8
+ query: string,
9
+ skills: SkillRecord[],
10
+ policy: ShioriPolicy,
11
+ index?: RetrievalIndex,
12
+ ): SkillCandidate[] {
13
+ const normalizedQuery = normalize(query);
14
+ const candidatesByName = new Map<string, SkillCandidate>();
15
+ const triggerableSkills = skills.filter(
16
+ (skill) => (policy.skills[skill.name]?.activation ?? policy.defaults.activation) === "triggerable",
17
+ );
18
+
19
+ for (const skill of triggerableSkills) {
20
+ const triggerCandidate = evaluateSkill(skill, normalizedQuery, policy, true);
21
+ if (triggerCandidate) candidatesByName.set(skill.name, triggerCandidate);
22
+ }
23
+
24
+ const ftsMatches = index?.search(query, policy.candidateInjection.maxCandidates * 4) ?? [];
25
+ const scoringPool = ftsMatches.length > 0 ? ftsMatches : triggerableSkills;
26
+ for (const skill of scoringPool) {
27
+ const candidate = evaluateSkill(skill, normalizedQuery, policy, false);
28
+ if (!candidate) continue;
29
+ const previous = candidatesByName.get(skill.name);
30
+ if (!previous || candidate.score > previous.score) {
31
+ candidatesByName.set(skill.name, candidate);
32
+ }
33
+ }
34
+
35
+ return [...candidatesByName.values()]
36
+ .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
37
+ .slice(0, policy.candidateInjection.maxCandidates);
38
+ }
39
+
40
+ function evaluateSkill(
41
+ skill: SkillRecord,
42
+ normalizedQuery: string,
43
+ policy: ShioriPolicy,
44
+ exactTriggerOnly: boolean,
45
+ ): SkillCandidate | undefined {
46
+ const skillPolicy = policy.skills[skill.name];
47
+ const excludes = skillPolicy?.triggers?.exclude ?? [];
48
+ const matchedExclude = excludes.find((trigger) => includesNormalized(normalizedQuery, trigger));
49
+ if (matchedExclude) return undefined;
50
+
51
+ const includes = skillPolicy?.triggers?.include ?? [];
52
+ const matchedInclude = includes.find((trigger) => includesNormalized(normalizedQuery, trigger));
53
+ if (matchedInclude) {
54
+ return {
55
+ skill,
56
+ score: 0.95,
57
+ why: `matched trigger "${matchedInclude}"`,
58
+ };
59
+ }
60
+ if (exactTriggerOnly) return undefined;
61
+
62
+ const text = normalize(`${skill.name} ${skill.description} ${includes.join(" ")}`);
63
+ const tokenScore = scoreTokens(normalizedQuery, text);
64
+ if (tokenScore < policy.candidateInjection.minScore) return undefined;
65
+ return {
66
+ skill,
67
+ score: tokenScore,
68
+ why: "matched skill description",
69
+ };
70
+ }
71
+
72
+ function normalize(input: string): string {
73
+ return input.toLowerCase().replace(/\s+/g, " ").trim();
74
+ }
75
+
76
+ function includesNormalized(query: string, trigger: string): boolean {
77
+ return query.includes(normalize(trigger));
78
+ }
79
+
80
+ function scoreTokens(query: string, text: string): number {
81
+ const tokens = new Set(query.split(/[^\p{L}\p{N}_-]+/u).filter((token) => token.length >= 2));
82
+ if (tokens.size === 0) return 0;
83
+ let hits = 0;
84
+ for (const token of tokens) {
85
+ if (text.includes(token)) hits += 1;
86
+ }
87
+ return hits / tokens.size;
88
+ }
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ export type SkillActivation = "explicit" | "triggerable";
2
+ export type RetrievalBackend = "sqlite-fts" | "token-match";
3
+ export type SuppressionStatus = "disabled" | "not-needed" | "suppressed" | "failed-pattern-not-found";
4
+
5
+ export interface ShioriPolicy {
6
+ zeroCatalog: {
7
+ enabled: boolean;
8
+ };
9
+ defaults: {
10
+ activation: SkillActivation;
11
+ };
12
+ candidateInjection: {
13
+ maxCandidates: number;
14
+ minScore: number;
15
+ };
16
+ alwaysVisible: string[];
17
+ skills: Record<string, SkillPolicy>;
18
+ }
19
+
20
+ export interface SkillPolicy {
21
+ activation?: SkillActivation;
22
+ path?: string;
23
+ triggers?: {
24
+ include?: string[];
25
+ exclude?: string[];
26
+ };
27
+ }
28
+
29
+ export interface SkillRecord {
30
+ name: string;
31
+ description: string;
32
+ path: string;
33
+ source: string;
34
+ }
35
+
36
+ export interface SkillCandidate {
37
+ skill: SkillRecord;
38
+ score: number;
39
+ why: string;
40
+ }
41
+
42
+ export interface ShioriStats {
43
+ inventoryCount: number;
44
+ duplicateCount: number;
45
+ candidateHitCount: number;
46
+ zeroCandidateCount: number;
47
+ loadedSkillCount: number;
48
+ explicitInvocationCount: number;
49
+ retrievalBackend: RetrievalBackend;
50
+ suppressionStatus: SuppressionStatus;
51
+ lastReloadAt?: string;
52
+ }