pi-skill-shiori 0.1.1 → 0.2.1
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/README.md +10 -9
- package/package.json +3 -2
- package/src/discovery.ts +24 -8
- package/src/index.ts +154 -27
- package/src/indexer.ts +34 -6
- package/src/load-skills.ts +16 -0
- package/src/planning-kickoff.ts +50 -0
- package/src/prompt.ts +83 -38
- package/src/query.ts +83 -0
- package/src/retrieval-expanded.ts +30 -0
- package/src/retrieval.ts +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ It is built for vaults or projects with many Agent Skills where the default cata
|
|
|
15
15
|
|
|
16
16
|
## Status
|
|
17
17
|
|
|
18
|
-
`0.1
|
|
18
|
+
`0.2.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.
|
|
19
19
|
|
|
20
20
|
## Requirements
|
|
21
21
|
|
|
@@ -37,7 +37,7 @@ Pi core packages are peer dependencies and should be supplied by the Pi runtime:
|
|
|
37
37
|
After the package is published to npm:
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
pi install npm:pi-skill-shiori@0.1
|
|
40
|
+
pi install npm:pi-skill-shiori@0.2.1
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
Without a version pin:
|
|
@@ -51,7 +51,7 @@ pi install npm:pi-skill-shiori
|
|
|
51
51
|
Use `-l` to write the package to the current project’s `.pi/settings.json`:
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
pi install -l npm:pi-skill-shiori@0.1
|
|
54
|
+
pi install -l npm:pi-skill-shiori@0.2.1
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Without a version pin:
|
|
@@ -65,7 +65,7 @@ Equivalent pinned manual `.pi/settings.json` entry:
|
|
|
65
65
|
```json
|
|
66
66
|
{
|
|
67
67
|
"packages": [
|
|
68
|
-
"npm:pi-skill-shiori@0.1
|
|
68
|
+
"npm:pi-skill-shiori@0.2.1"
|
|
69
69
|
]
|
|
70
70
|
}
|
|
71
71
|
```
|
|
@@ -83,7 +83,7 @@ Equivalent unpinned manual `.pi/settings.json` entry:
|
|
|
83
83
|
### Global install from GitHub
|
|
84
84
|
|
|
85
85
|
```bash
|
|
86
|
-
pi install git:github.com/eiei114/pi-skill-shiori@v0.1
|
|
86
|
+
pi install git:github.com/eiei114/pi-skill-shiori@v0.2.1
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Without a tag pin:
|
|
@@ -95,7 +95,7 @@ pi install git:github.com/eiei114/pi-skill-shiori
|
|
|
95
95
|
### Project-local install from GitHub
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
|
-
pi install -l git:github.com/eiei114/pi-skill-shiori@v0.1
|
|
98
|
+
pi install -l git:github.com/eiei114/pi-skill-shiori@v0.2.1
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
Without a tag pin:
|
|
@@ -186,8 +186,9 @@ This writes a generated review file next to `.pi/skill-shiori.yml`. Review it be
|
|
|
186
186
|
| `/shiori:bootstrap` | Generate a review draft policy from discovered skill descriptions. |
|
|
187
187
|
| `/shiori:reload` | Rebuild Shiori’s skill inventory and retrieval index. |
|
|
188
188
|
| `/shiori:reload --runtime` | Rebuild Shiori and ask Pi to reload runtime resources. Code changes may still need full restart. |
|
|
189
|
-
| `/shiori:
|
|
190
|
-
| `/shiori:
|
|
189
|
+
| `/shiori:recommend <text>` | Pre-load up to 5 matching skills and queue the task for the agent in one shot. Planning phrases (e.g. `計画を立てたい`) return the dev-plan intake template without dumping SKILL bodies. |
|
|
190
|
+
| `/shiori:recommend` | Ask what you need help with, then pre-load matches and queue for the agent (Pi UI input). |
|
|
191
|
+
| `/shiori:recommend --pick <text>` | Recommend skills only (compact list + manual pick UI, no agent turn). |
|
|
191
192
|
| `/shiori:stats` | Show operational counters. |
|
|
192
193
|
|
|
193
194
|
## Tool
|
|
@@ -278,7 +279,7 @@ npm login
|
|
|
278
279
|
After publishing, verify the Pi install path:
|
|
279
280
|
|
|
280
281
|
```bash
|
|
281
|
-
pi install npm:pi-skill-shiori@0.1
|
|
282
|
+
pi install npm:pi-skill-shiori@0.2.1
|
|
282
283
|
pi install npm:pi-skill-shiori
|
|
283
284
|
pi list
|
|
284
285
|
/shiori:doctor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-skill-shiori",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"typecheck": "tsc --noEmit",
|
|
31
31
|
"build": "tsc --noEmit",
|
|
32
|
-
"test": "npm run typecheck",
|
|
32
|
+
"test": "npm run typecheck && tsx --test tests/*.test.mjs",
|
|
33
33
|
"prepublishOnly": "npm test",
|
|
34
34
|
"release:npm:dry": "npm pack --dry-run",
|
|
35
35
|
"release:npm": "npm publish --access public"
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"@earendil-works/pi-coding-agent": "^0.75.5",
|
|
54
54
|
"@earendil-works/pi-tui": "^0.75.5",
|
|
55
55
|
"@types/node": "^25.9.1",
|
|
56
|
+
"tsx": "^4.22.4",
|
|
56
57
|
"typebox": "^1.1.38",
|
|
57
58
|
"typescript": "^6.0.3"
|
|
58
59
|
}
|
package/src/discovery.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
4
5
|
import type { SkillRecord } from "./types.js";
|
|
5
6
|
|
|
6
7
|
const SKILL_FILE = "SKILL.md";
|
|
@@ -60,9 +61,29 @@ async function walk(dir: string, result: string[]): Promise<void> {
|
|
|
60
61
|
async function readSkillRecord(path: string, root: string): Promise<SkillRecord> {
|
|
61
62
|
const raw = await readFile(path, "utf8");
|
|
62
63
|
const frontmatter = raw.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
let name = basename(dirname(path));
|
|
65
|
+
let description: string | undefined;
|
|
66
|
+
|
|
67
|
+
if (frontmatter?.[1]) {
|
|
68
|
+
try {
|
|
69
|
+
const meta = parseYaml(frontmatter[1]) as Record<string, unknown> | null;
|
|
70
|
+
if (meta && typeof meta === "object") {
|
|
71
|
+
if (typeof meta.name === "string" && meta.name.trim()) {
|
|
72
|
+
name = meta.name.trim();
|
|
73
|
+
}
|
|
74
|
+
if (typeof meta.description === "string" && meta.description.trim()) {
|
|
75
|
+
description = meta.description.trim();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Fall back to body text when frontmatter is malformed.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!description) {
|
|
84
|
+
description = firstParagraph(raw) ?? "No description.";
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
return {
|
|
67
88
|
name,
|
|
68
89
|
description,
|
|
@@ -71,11 +92,6 @@ async function readSkillRecord(path: string, root: string): Promise<SkillRecord>
|
|
|
71
92
|
};
|
|
72
93
|
}
|
|
73
94
|
|
|
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
95
|
function firstParagraph(raw: string): string | undefined {
|
|
80
96
|
return raw
|
|
81
97
|
.replace(/^---\s*\n[\s\S]*?\n---/, "")
|
package/src/index.ts
CHANGED
|
@@ -4,20 +4,29 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import { discoverSkills, findDuplicates } from "./discovery.js";
|
|
6
6
|
import { buildSkillIndex, type SkillIndex } from "./indexer.js";
|
|
7
|
+
import { loadRecommendedSkills } from "./load-skills.js";
|
|
8
|
+
import { formatPlanningKickoffMessage, isPlanningIntent } from "./planning-kickoff.js";
|
|
7
9
|
import { createStats } from "./metrics.js";
|
|
8
10
|
import { getPolicyPath, loadPolicy } from "./policy.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
+
import {
|
|
12
|
+
formatCandidateDetail,
|
|
13
|
+
formatCandidateInjection,
|
|
14
|
+
formatLoadedSkillsSummary,
|
|
15
|
+
formatRecommendKickoffMessage,
|
|
16
|
+
suppressSkillCatalog,
|
|
17
|
+
} from "./prompt.js";
|
|
18
|
+
import { retrieveCandidatesExpanded } from "./retrieval-expanded.js";
|
|
11
19
|
import type { SkillRecord } from "./types.js";
|
|
12
20
|
|
|
13
|
-
interface ShioriLoadDetails {
|
|
21
|
+
interface ShioriLoadDetails {
|
|
14
22
|
skill: string;
|
|
15
23
|
path: string;
|
|
16
24
|
bytes: number;
|
|
17
25
|
loadedAt: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const SHIORI_CODE_MARKER = "prompt-boundary-v3";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SHIORI_CODE_MARKER = "prompt-boundary-v3";
|
|
29
|
+
const RECOMMEND_MAX_CANDIDATES = 5;
|
|
21
30
|
|
|
22
31
|
export default function piSkillShiori(pi: ExtensionAPI) {
|
|
23
32
|
let index: SkillIndex | undefined;
|
|
@@ -39,6 +48,16 @@ export default function piSkillShiori(pi: ExtensionAPI) {
|
|
|
39
48
|
return index ?? reload(cwd);
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
function withRecommendLimits(current: SkillIndex) {
|
|
52
|
+
return {
|
|
53
|
+
...current.policy,
|
|
54
|
+
candidateInjection: {
|
|
55
|
+
...current.policy.candidateInjection,
|
|
56
|
+
maxCandidates: Math.max(current.policy.candidateInjection.maxCandidates, RECOMMEND_MAX_CANDIDATES),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
pi.on("session_start", async (_event, ctx) => {
|
|
43
62
|
await reload(ctx.cwd);
|
|
44
63
|
});
|
|
@@ -60,7 +79,7 @@ export default function piSkillShiori(pi: ExtensionAPI) {
|
|
|
60
79
|
ctx.ui.notify("Pi Skill Shiori: Skill Catalog suppression pattern not found; leaving system prompt unchanged.", "warning");
|
|
61
80
|
}
|
|
62
81
|
|
|
63
|
-
const candidates =
|
|
82
|
+
const candidates = retrieveCandidatesExpanded(event.prompt, current.skills, current.policy, current);
|
|
64
83
|
if (candidates.length === 0) stats.zeroCandidateCount += 1;
|
|
65
84
|
else stats.candidateHitCount += 1;
|
|
66
85
|
|
|
@@ -72,6 +91,45 @@ export default function piSkillShiori(pi: ExtensionAPI) {
|
|
|
72
91
|
};
|
|
73
92
|
});
|
|
74
93
|
|
|
94
|
+
pi.registerTool({
|
|
95
|
+
name: "shiori_recommend",
|
|
96
|
+
label: "Shiori Recommend",
|
|
97
|
+
description: "Find Agent Skills that match a natural-language task description.",
|
|
98
|
+
promptSnippet: "Search the skill inventory for skills matching a task description",
|
|
99
|
+
promptGuidelines: [
|
|
100
|
+
"Use shiori_recommend when you need to discover which Agent Skills fit a task before loading them.",
|
|
101
|
+
"shiori_recommend may return multiple pre-loaded skills; follow all relevant ones.",
|
|
102
|
+
],
|
|
103
|
+
parameters: Type.Object({
|
|
104
|
+
task: Type.String({ description: "Natural-language description of what you want to do" }),
|
|
105
|
+
}),
|
|
106
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
107
|
+
const current = await ensureIndex(ctx.cwd);
|
|
108
|
+
const policy = withRecommendLimits(current);
|
|
109
|
+
const candidates = retrieveCandidatesExpanded(params.task, current.skills, policy, current);
|
|
110
|
+
const loaded = await loadRecommendedSkills(candidates);
|
|
111
|
+
const summary = formatLoadedSkillsSummary(loaded);
|
|
112
|
+
const blocks = loaded.map(({ candidate, content }) => ({
|
|
113
|
+
type: "text" as const,
|
|
114
|
+
text: `## ${candidate.skill.name}\n\n${content}`,
|
|
115
|
+
}));
|
|
116
|
+
return {
|
|
117
|
+
content: summary
|
|
118
|
+
? [{ type: "text", text: summary }, ...blocks]
|
|
119
|
+
: [{ type: "text", text: "No matching skills for that task." }],
|
|
120
|
+
details: { query: params.task, matches: candidates.map((c) => c.skill.name) },
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
renderResult(result, { expanded }, theme) {
|
|
124
|
+
const details = result.details as { query?: string; matches?: string[] } | undefined;
|
|
125
|
+
const names = details?.matches?.join(", ") ?? "none";
|
|
126
|
+
const collapsed = `${theme.fg("success", "✓ Shiori")} ${theme.bold(names)}`;
|
|
127
|
+
if (!expanded) return new Text(collapsed, 0, 0);
|
|
128
|
+
const query = details?.query ? `\n${theme.fg("dim", details.query)}` : "";
|
|
129
|
+
return new Text(`${collapsed}${query}`, 0, 0);
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
75
133
|
pi.registerTool({
|
|
76
134
|
name: "shiori_load_skill",
|
|
77
135
|
label: "Shiori Load Skill",
|
|
@@ -126,10 +184,10 @@ export default function piSkillShiori(pi: ExtensionAPI) {
|
|
|
126
184
|
`skills: ${current.skills.length}`,
|
|
127
185
|
`duplicates: ${duplicates.size}`,
|
|
128
186
|
`alwaysVisible: ${current.policy.alwaysVisible.join(", ") || "none"}`,
|
|
129
|
-
`retrievalBackend: ${current.retrievalBackend}`,
|
|
130
|
-
`suppression: ${stats.suppressionStatus}`,
|
|
131
|
-
`code: ${SHIORI_CODE_MARKER}`,
|
|
132
|
-
`builtAt: ${current.builtAt}`,
|
|
187
|
+
`retrievalBackend: ${current.retrievalBackend}`,
|
|
188
|
+
`suppression: ${stats.suppressionStatus}`,
|
|
189
|
+
`code: ${SHIORI_CODE_MARKER}`,
|
|
190
|
+
`builtAt: ${current.builtAt}`,
|
|
133
191
|
].join("\n"),
|
|
134
192
|
duplicates.size > 0 || stats.suppressionStatus === "failed-pattern-not-found" ? "warning" : "info",
|
|
135
193
|
);
|
|
@@ -148,16 +206,93 @@ export default function piSkillShiori(pi: ExtensionAPI) {
|
|
|
148
206
|
},
|
|
149
207
|
});
|
|
150
208
|
|
|
151
|
-
pi.registerCommand("shiori:
|
|
152
|
-
description: "
|
|
209
|
+
pi.registerCommand("shiori:recommend", {
|
|
210
|
+
description: "Recommend Agent Skills for a natural-language task and queue it for the agent",
|
|
153
211
|
handler: async (args, ctx) => {
|
|
154
212
|
const current = await ensureIndex(ctx.cwd);
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
213
|
+
const pickOnly = args.trimStart().startsWith("--pick");
|
|
214
|
+
let query = (pickOnly ? args.replace(/^--pick\s*/, "") : args).trim();
|
|
215
|
+
|
|
216
|
+
if (!query) {
|
|
217
|
+
if (!ctx.hasUI) {
|
|
218
|
+
ctx.ui.notify(
|
|
219
|
+
"Provide a task description: /shiori:recommend <what you want to do>",
|
|
220
|
+
"warning",
|
|
221
|
+
);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const input = await ctx.ui.input(
|
|
225
|
+
"What do you want help with?",
|
|
226
|
+
"Describe your task in natural language…",
|
|
227
|
+
);
|
|
228
|
+
if (!input?.trim()) {
|
|
229
|
+
ctx.ui.notify("Recommendation cancelled.", "info");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
query = input.trim();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const policy = withRecommendLimits(current);
|
|
236
|
+
const candidates = retrieveCandidatesExpanded(query, current.skills, policy, current);
|
|
237
|
+
const planning = isPlanningIntent(query);
|
|
238
|
+
const loaded = planning ? [] : await loadRecommendedSkills(candidates);
|
|
239
|
+
const summary = planning
|
|
240
|
+
? candidates.length > 0
|
|
241
|
+
? `Matched ${candidates.length} skill(s): ${candidates.map((c) => c.skill.name).join(", ")}`
|
|
242
|
+
: ""
|
|
243
|
+
: formatLoadedSkillsSummary(loaded);
|
|
244
|
+
|
|
245
|
+
if (summary) {
|
|
246
|
+
ctx.ui.notify(summary, "info");
|
|
247
|
+
} else {
|
|
248
|
+
ctx.ui.notify("No matching skills for that request.", "warning");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!pickOnly) {
|
|
252
|
+
const kickoff = isPlanningIntent(query)
|
|
253
|
+
? formatPlanningKickoffMessage(query, candidates)
|
|
254
|
+
: formatRecommendKickoffMessage(query, loaded);
|
|
255
|
+
pi.sendUserMessage(kickoff);
|
|
256
|
+
ctx.ui.notify(
|
|
257
|
+
isPlanningIntent(query)
|
|
258
|
+
? "Queued dev-plan intake for the agent."
|
|
259
|
+
: loaded.length > 0
|
|
260
|
+
? `Pre-loaded ${loaded.length} skill(s) and queued task for the agent.`
|
|
261
|
+
: "Queued task for the agent.",
|
|
262
|
+
"info",
|
|
263
|
+
);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (candidates.length === 0 || !ctx.hasUI) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const choice = await ctx.ui.select("Follow up on a recommendation:", [
|
|
272
|
+
...candidates.map((candidate) => candidate.skill.name),
|
|
273
|
+
"Done",
|
|
274
|
+
]);
|
|
275
|
+
if (!choice || choice === "Done") {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const selected = candidates.find((candidate) => candidate.skill.name === choice);
|
|
280
|
+
if (!selected) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
ctx.ui.notify(formatCandidateDetail(selected), "info");
|
|
285
|
+
|
|
286
|
+
const shouldLoad = await ctx.ui.confirm(
|
|
287
|
+
"Load this skill?",
|
|
288
|
+
`The agent can call shiori_load_skill({ skill: "${selected.skill.name}" }) to read the full SKILL.md.`,
|
|
160
289
|
);
|
|
290
|
+
if (shouldLoad) {
|
|
291
|
+
ctx.ui.notify(
|
|
292
|
+
`Ask the agent to run: shiori_load_skill({ skill: "${selected.skill.name}" })`,
|
|
293
|
+
"info",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
161
296
|
},
|
|
162
297
|
});
|
|
163
298
|
|
|
@@ -184,14 +319,6 @@ function findSkill(name: string, skills: SkillRecord[]): SkillRecord | undefined
|
|
|
184
319
|
return skills.find((skill) => skill.name === name);
|
|
185
320
|
}
|
|
186
321
|
|
|
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
322
|
function formatBytes(bytes: number): string {
|
|
196
323
|
if (bytes < 1024) return `${bytes}B`;
|
|
197
324
|
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
@@ -205,7 +332,7 @@ function renderGeneratedPolicy(skills: SkillRecord[]): string {
|
|
|
205
332
|
"defaults:",
|
|
206
333
|
" activation: explicit",
|
|
207
334
|
"candidateInjection:",
|
|
208
|
-
" maxCandidates:
|
|
335
|
+
" maxCandidates: 5",
|
|
209
336
|
" minScore: 0.62",
|
|
210
337
|
"alwaysVisible:",
|
|
211
338
|
" - pi-skill-shiori",
|
package/src/indexer.ts
CHANGED
|
@@ -24,7 +24,9 @@ export async function buildSkillIndex(cwd: string, skills: SkillRecord[], policy
|
|
|
24
24
|
await mkdir(cacheDir, { recursive: true });
|
|
25
25
|
|
|
26
26
|
const db = new sqlite.DatabaseSync(dbPath);
|
|
27
|
-
db.exec(
|
|
27
|
+
db.exec(
|
|
28
|
+
"CREATE VIRTUAL TABLE skill_fts USING fts5(name, description, triggers, path, tokenize='unicode61 remove_diacritics 2')",
|
|
29
|
+
);
|
|
28
30
|
const insert = db.prepare("INSERT INTO skill_fts(name, description, triggers, path) VALUES (?, ?, ?, ?)");
|
|
29
31
|
for (const skill of skills) {
|
|
30
32
|
const triggers = [
|
|
@@ -69,12 +71,38 @@ export async function buildSkillIndex(cwd: string, skills: SkillRecord[], policy
|
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
function buildFtsQuery(query: string): string {
|
|
73
|
-
const terms = query
|
|
74
|
-
|
|
75
|
-
return
|
|
74
|
+
export function buildFtsQuery(query: string): string {
|
|
75
|
+
const terms = extractSearchTerms(query);
|
|
76
|
+
if (terms.length === 0) return "";
|
|
77
|
+
return terms.map((term) => `${escapeFtsTerm(term)}*`).join(" OR ");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function extractSearchTerms(query: string): string[] {
|
|
81
|
+
const terms = new Set<string>();
|
|
82
|
+
const chunks = query.split(/[^\p{L}\p{N}_-]+/u).filter(Boolean);
|
|
83
|
+
|
|
84
|
+
for (const chunk of chunks) {
|
|
85
|
+
if (chunk.length < 2) continue;
|
|
86
|
+
|
|
87
|
+
const isJapanese =
|
|
88
|
+
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(chunk) &&
|
|
89
|
+
!/[A-Za-z]/u.test(chunk);
|
|
90
|
+
|
|
91
|
+
if (isJapanese) {
|
|
92
|
+
const segments = chunk.split(/(?:の|を|に|で|と|が|は|して|から|へ|も|など|まわり)/u);
|
|
93
|
+
for (const segment of segments) {
|
|
94
|
+
if (segment.length >= 2) terms.add(segment.toLocaleLowerCase());
|
|
95
|
+
}
|
|
96
|
+
if (terms.size === 0) terms.add(chunk.toLocaleLowerCase());
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
terms.add(chunk.toLocaleLowerCase());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [...terms].slice(0, 8);
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
function escapeFtsTerm(term: string): string {
|
|
79
|
-
return term.replace(/[
|
|
107
|
+
return term.replace(/["'*():^]/g, "");
|
|
80
108
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { SkillCandidate } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface LoadedSkill {
|
|
5
|
+
candidate: SkillCandidate;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function loadRecommendedSkills(candidates: SkillCandidate[]): Promise<LoadedSkill[]> {
|
|
10
|
+
const loaded: LoadedSkill[] = [];
|
|
11
|
+
for (const candidate of candidates) {
|
|
12
|
+
const content = await readFile(candidate.skill.path, "utf8");
|
|
13
|
+
loaded.push({ candidate, content });
|
|
14
|
+
}
|
|
15
|
+
return loaded;
|
|
16
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { SkillCandidate } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const PLANNING_INTENT = /計画|プラン|立てたい|planning|開発計画|タスク分解|実装計画/i;
|
|
4
|
+
|
|
5
|
+
export function isPlanningIntent(query: string): boolean {
|
|
6
|
+
return PLANNING_INTENT.test(query.trim());
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatPlanningKickoffMessage(query: string, candidates: SkillCandidate[]): string {
|
|
10
|
+
const skillLine =
|
|
11
|
+
candidates.length > 0
|
|
12
|
+
? `参考 skill: ${candidates.map((c) => c.skill.name).join(", ")}(必要なら shiori_load_skill で読む)`
|
|
13
|
+
: "参考 skill: なし(to-prd / to-issues / grill-me を検討)";
|
|
14
|
+
|
|
15
|
+
return [
|
|
16
|
+
query.trim(),
|
|
17
|
+
"",
|
|
18
|
+
"OK。今日やる開発計画の型で進める。",
|
|
19
|
+
"",
|
|
20
|
+
"まずこれ埋めて:",
|
|
21
|
+
"",
|
|
22
|
+
"```text",
|
|
23
|
+
" 対象:",
|
|
24
|
+
" やりたい変更:",
|
|
25
|
+
" 使える時間:",
|
|
26
|
+
" 完了条件:",
|
|
27
|
+
" 不安点:",
|
|
28
|
+
"```",
|
|
29
|
+
"",
|
|
30
|
+
"未定なら、これだけでOK:",
|
|
31
|
+
"",
|
|
32
|
+
"```text",
|
|
33
|
+
" 対象:",
|
|
34
|
+
" 変更:",
|
|
35
|
+
"```",
|
|
36
|
+
"",
|
|
37
|
+
"受け取ったら、こう分解する:",
|
|
38
|
+
"",
|
|
39
|
+
"1. 現状確認",
|
|
40
|
+
"2. 最小実装スコープ決定",
|
|
41
|
+
"3. タスク分解",
|
|
42
|
+
"4. 実行順",
|
|
43
|
+
"5. テスト/確認",
|
|
44
|
+
"6. 今日の完了ライン",
|
|
45
|
+
"7. 余った時間の追加作業",
|
|
46
|
+
"",
|
|
47
|
+
"---",
|
|
48
|
+
skillLine,
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import type { SkillCandidate, SuppressionStatus } from "./types.js";
|
|
2
2
|
|
|
3
|
-
interface CandidateFormatOptions {
|
|
4
|
-
verbose?: boolean;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
3
|
export interface CatalogSuppressionResult {
|
|
8
4
|
systemPrompt: string;
|
|
9
5
|
status: SuppressionStatus;
|
|
@@ -11,34 +7,83 @@ export interface CatalogSuppressionResult {
|
|
|
11
7
|
|
|
12
8
|
const COMPACT_DESCRIPTION_LIMIT = 120;
|
|
13
9
|
|
|
14
|
-
export function formatCandidateInjection(candidates: SkillCandidate[]
|
|
10
|
+
export function formatCandidateInjection(candidates: SkillCandidate[]): string {
|
|
15
11
|
if (candidates.length === 0) return "";
|
|
16
12
|
const lines = ["Relevant skills:"];
|
|
17
13
|
for (const candidate of candidates) {
|
|
18
14
|
const desc = compactDescription(candidate.skill.description, COMPACT_DESCRIPTION_LIMIT);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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}`);
|
|
31
65
|
}
|
|
32
66
|
return lines.join("\n");
|
|
33
67
|
}
|
|
34
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
|
+
|
|
35
80
|
export function compactDescription(description: string, maxChars = COMPACT_DESCRIPTION_LIMIT): string {
|
|
36
81
|
const singleLine = description.replace(/\s+/g, " ").trim();
|
|
37
82
|
if (singleLine.length <= maxChars) return singleLine;
|
|
38
83
|
return `${singleLine.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
|
39
84
|
}
|
|
40
85
|
|
|
41
|
-
export function suppressSkillCatalog(systemPrompt: string): CatalogSuppressionResult {
|
|
86
|
+
export function suppressSkillCatalog(systemPrompt: string): CatalogSuppressionResult {
|
|
42
87
|
const replacement = [
|
|
43
88
|
"### Available skills",
|
|
44
89
|
"Normal Skill Catalog hidden by Pi Skill Shiori.",
|
|
@@ -46,29 +91,29 @@ export function suppressSkillCatalog(systemPrompt: string): CatalogSuppressionRe
|
|
|
46
91
|
"Explicit /skill:name invocation remains allowed.",
|
|
47
92
|
].join("\n");
|
|
48
93
|
|
|
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
|
-
];
|
|
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
|
+
];
|
|
55
100
|
for (const pattern of patterns) {
|
|
56
101
|
if (!pattern.test(systemPrompt)) continue;
|
|
57
102
|
return {
|
|
58
103
|
systemPrompt: systemPrompt.replace(pattern, `${replacement}\n`),
|
|
59
104
|
status: "suppressed",
|
|
60
105
|
};
|
|
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
|
-
}
|
|
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
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { extractSearchTerms } from "./indexer.js";
|
|
2
|
+
|
|
3
|
+
/** Japanese/English aliases to improve natural-language retrieval. */
|
|
4
|
+
const TERM_ALIASES: Record<string, string[]> = {
|
|
5
|
+
認証: ["auth", "authentication", "login"],
|
|
6
|
+
ログイン: ["login", "auth"],
|
|
7
|
+
スキル: ["skill"],
|
|
8
|
+
ブラウザ: ["browser"],
|
|
9
|
+
スクレイピング: ["scraping", "scrape"],
|
|
10
|
+
スクリーンショット: ["screenshot"],
|
|
11
|
+
ノート: ["note", "vault", "markdown"],
|
|
12
|
+
検索: ["search"],
|
|
13
|
+
デバッグ: ["debug", "diagnose"],
|
|
14
|
+
バグ: ["bug", "debug"],
|
|
15
|
+
朝: ["morning"],
|
|
16
|
+
ルーティン: ["routine"],
|
|
17
|
+
コミット: ["commit", "git"],
|
|
18
|
+
プッシュ: ["push", "git"],
|
|
19
|
+
リファクタ: ["refactor"],
|
|
20
|
+
調査: ["research", "investigate"],
|
|
21
|
+
論文: ["paper", "research"],
|
|
22
|
+
投稿: ["post", "tweet"],
|
|
23
|
+
計画: ["plan", "prd", "planning"],
|
|
24
|
+
プラン: ["plan", "prd"],
|
|
25
|
+
設計: ["design", "plan"],
|
|
26
|
+
タスク: ["task", "issues"],
|
|
27
|
+
分解: ["breakdown", "issues"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Whole-prompt intents → English queries that match planning skills in descriptions. */
|
|
31
|
+
const INTENT_QUERY_PACKS: Array<{ test: RegExp; queries: string[] }> = [
|
|
32
|
+
{
|
|
33
|
+
test: /計画|プラン|立てたい|planning/i,
|
|
34
|
+
queries: [
|
|
35
|
+
"to-prd PRD plan",
|
|
36
|
+
"to-issues breakdown plan",
|
|
37
|
+
"grill me plan design",
|
|
38
|
+
"request refactor plan",
|
|
39
|
+
"to-prd-for-oss plan",
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
test: /開発計画|今日.*(やる|進める)|タスク分解|実装計画/i,
|
|
44
|
+
queries: ["to-prd plan", "to-issues breakdown", "refactor plan", "grill me plan"],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Build alternate queries for FTS + token scoring from one natural-language prompt. */
|
|
49
|
+
export function buildRetrievalQueries(query: string): string[] {
|
|
50
|
+
const trimmed = query.trim();
|
|
51
|
+
if (!trimmed) return [];
|
|
52
|
+
|
|
53
|
+
const queries = new Set<string>([trimmed]);
|
|
54
|
+
const terms = extractSearchTerms(trimmed);
|
|
55
|
+
const englishAliases: string[] = [];
|
|
56
|
+
|
|
57
|
+
for (const pack of INTENT_QUERY_PACKS) {
|
|
58
|
+
if (pack.test.test(trimmed)) {
|
|
59
|
+
for (const intentQuery of pack.queries) {
|
|
60
|
+
queries.add(intentQuery);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const term of terms) {
|
|
66
|
+
queries.add(term);
|
|
67
|
+
for (const alias of TERM_ALIASES[term] ?? []) {
|
|
68
|
+
englishAliases.push(alias);
|
|
69
|
+
queries.add(alias);
|
|
70
|
+
const swapped = terms.map((t) => (t === term ? alias : t)).join(" ");
|
|
71
|
+
if (swapped.trim()) queries.add(swapped.trim());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (englishAliases.length > 0) {
|
|
76
|
+
queries.add(englishAliases.join(" "));
|
|
77
|
+
if (terms.length > 0) {
|
|
78
|
+
queries.add([...terms, ...englishAliases].join(" "));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return [...queries];
|
|
83
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { buildRetrievalQueries } from "./query.js";
|
|
2
|
+
import { retrieveCandidates } from "./retrieval.js";
|
|
3
|
+
import type { ShioriPolicy, SkillCandidate, SkillRecord } from "./types.js";
|
|
4
|
+
|
|
5
|
+
interface RetrievalIndex {
|
|
6
|
+
search(query: string, limit: number): SkillRecord[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function retrieveCandidatesExpanded(
|
|
10
|
+
query: string,
|
|
11
|
+
skills: SkillRecord[],
|
|
12
|
+
policy: ShioriPolicy,
|
|
13
|
+
index?: RetrievalIndex,
|
|
14
|
+
): SkillCandidate[] {
|
|
15
|
+
const byName = new Map<string, SkillCandidate>();
|
|
16
|
+
const max = policy.candidateInjection.maxCandidates;
|
|
17
|
+
|
|
18
|
+
for (const variant of buildRetrievalQueries(query)) {
|
|
19
|
+
for (const candidate of retrieveCandidates(variant, skills, policy, index)) {
|
|
20
|
+
const previous = byName.get(candidate.skill.name);
|
|
21
|
+
if (!previous || candidate.score > previous.score) {
|
|
22
|
+
byName.set(candidate.skill.name, candidate);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return [...byName.values()]
|
|
28
|
+
.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
|
|
29
|
+
.slice(0, max);
|
|
30
|
+
}
|
package/src/retrieval.ts
CHANGED
|
@@ -22,7 +22,7 @@ export function retrieveCandidates(
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const ftsMatches = index?.search(query, policy.candidateInjection.maxCandidates * 4) ?? [];
|
|
25
|
-
const scoringPool = ftsMatches.length > 0 ? ftsMatches :
|
|
25
|
+
const scoringPool = ftsMatches.length > 0 ? ftsMatches : skills;
|
|
26
26
|
for (const skill of scoringPool) {
|
|
27
27
|
const candidate = evaluateSkill(skill, normalizedQuery, policy, false);
|
|
28
28
|
if (!candidate) continue;
|