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 +21 -0
- package/README.md +280 -0
- package/package.json +59 -0
- package/src/discovery.ts +96 -0
- package/src/index.ts +233 -0
- package/src/indexer.ts +80 -0
- package/src/metrics.ts +14 -0
- package/src/policy.ts +46 -0
- package/src/prompt.ts +74 -0
- package/src/retrieval.ts +88 -0
- package/src/types.ts +52 -0
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
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -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
|
+
}
|
package/src/retrieval.ts
ADDED
|
@@ -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
|
+
}
|