pi-skill-shiori 0.1.1 → 0.3.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/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.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.
18
+ `0.3.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
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.0
40
+ pi install npm:pi-skill-shiori@0.3.0
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.0
54
+ pi install -l npm:pi-skill-shiori@0.3.0
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.0"
68
+ "npm:pi-skill-shiori@0.3.0"
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.0
86
+ pi install git:github.com/eiei114/pi-skill-shiori@v0.3.0
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.0
98
+ pi install -l git:github.com/eiei114/pi-skill-shiori@v0.3.0
99
99
  ```
100
100
 
101
101
  Without a tag pin:
@@ -184,10 +184,8 @@ This writes a generated review file next to `.pi/skill-shiori.yml`. Review it be
184
184
  |---|---|
185
185
  | `/shiori:doctor` | Show policy path, inventory count, retrieval backend, suppression status, and code marker. |
186
186
  | `/shiori:bootstrap` | Generate a review draft policy from discovered skill descriptions. |
187
- | `/shiori:reload` | Rebuild Shiori’s skill inventory and retrieval index. |
188
- | `/shiori:reload --runtime` | Rebuild Shiori and ask Pi to reload runtime resources. Code changes may still need full restart. |
189
- | `/shiori:test-query <text>` | Preview candidates for a query. |
190
- | `/shiori:test-query --verbose <text>` | Include full descriptions, scores, paths, and load hints. |
187
+ | `/shiori:reload` | Ask what to reload, then rebuild Shiori’s skill inventory and optionally ask Pi to reload runtime resources. Code changes may still need full restart. |
188
+ | `/shiori:recommend` | Ask what you need help with, then either pre-load matches and queue the task for the agent or review recommendations only. Planning phrases (e.g. `計画を立てたい`) return the dev-plan intake template without dumping SKILL bodies. |
191
189
  | `/shiori:stats` | Show operational counters. |
192
190
 
193
191
  ## Tool
@@ -278,7 +276,7 @@ npm login
278
276
  After publishing, verify the Pi install path:
279
277
 
280
278
  ```bash
281
- pi install npm:pi-skill-shiori@0.1.0
279
+ pi install npm:pi-skill-shiori@0.3.0
282
280
  pi install npm:pi-skill-shiori
283
281
  pi list
284
282
  /shiori:doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-shiori",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension that reduces Agent Skill catalog tokens with policy-based skill retrieval and on-demand skill loading.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- const block = frontmatter?.[1] ?? "";
64
- const name = matchYamlScalar(block, "name") ?? basename(dirname(path));
65
- const description = matchYamlScalar(block, "description") ?? firstParagraph(raw) ?? "No description.";
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,34 @@ 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 { formatCandidateInjection, suppressSkillCatalog } from "./prompt.js";
10
- import { retrieveCandidates } from "./retrieval.js";
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;
30
+ const RELOAD_INVENTORY_ONLY = "Shiori inventory only";
31
+ const RELOAD_WITH_RUNTIME = "Shiori inventory + Pi runtime resources";
32
+ const RECOMMEND_QUEUE_TASK = "Pre-load matches and queue task for agent";
33
+ const RECOMMEND_REVIEW_ONLY = "Review recommendations only";
34
+ const CANCEL_CHOICE = "Cancel";
21
35
 
22
36
  export default function piSkillShiori(pi: ExtensionAPI) {
23
37
  let index: SkillIndex | undefined;
@@ -39,10 +53,25 @@ export default function piSkillShiori(pi: ExtensionAPI) {
39
53
  return index ?? reload(cwd);
40
54
  }
41
55
 
56
+ function withRecommendLimits(current: SkillIndex) {
57
+ return {
58
+ ...current.policy,
59
+ candidateInjection: {
60
+ ...current.policy.candidateInjection,
61
+ maxCandidates: Math.max(current.policy.candidateInjection.maxCandidates, RECOMMEND_MAX_CANDIDATES),
62
+ },
63
+ };
64
+ }
65
+
42
66
  pi.on("session_start", async (_event, ctx) => {
43
67
  await reload(ctx.cwd);
44
68
  });
45
69
 
70
+ pi.on("session_shutdown", () => {
71
+ index?.close?.();
72
+ index = undefined;
73
+ });
74
+
46
75
  pi.on("input", async (event) => {
47
76
  if (/^\/(skill:|shiori:)/.test(event.text.trim())) {
48
77
  stats.explicitInvocationCount += 1;
@@ -60,7 +89,7 @@ export default function piSkillShiori(pi: ExtensionAPI) {
60
89
  ctx.ui.notify("Pi Skill Shiori: Skill Catalog suppression pattern not found; leaving system prompt unchanged.", "warning");
61
90
  }
62
91
 
63
- const candidates = retrieveCandidates(event.prompt, current.skills, current.policy, current);
92
+ const candidates = retrieveCandidatesExpanded(event.prompt, current.skills, current.policy, current);
64
93
  if (candidates.length === 0) stats.zeroCandidateCount += 1;
65
94
  else stats.candidateHitCount += 1;
66
95
 
@@ -72,6 +101,45 @@ export default function piSkillShiori(pi: ExtensionAPI) {
72
101
  };
73
102
  });
74
103
 
104
+ pi.registerTool({
105
+ name: "shiori_recommend",
106
+ label: "Shiori Recommend",
107
+ description: "Find Agent Skills that match a natural-language task description.",
108
+ promptSnippet: "Search the skill inventory for skills matching a task description",
109
+ promptGuidelines: [
110
+ "Use shiori_recommend when you need to discover which Agent Skills fit a task before loading them.",
111
+ "shiori_recommend may return multiple pre-loaded skills; follow all relevant ones.",
112
+ ],
113
+ parameters: Type.Object({
114
+ task: Type.String({ description: "Natural-language description of what you want to do" }),
115
+ }),
116
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
117
+ const current = await ensureIndex(ctx.cwd);
118
+ const policy = withRecommendLimits(current);
119
+ const candidates = retrieveCandidatesExpanded(params.task, current.skills, policy, current);
120
+ const loaded = await loadRecommendedSkills(candidates);
121
+ const summary = formatLoadedSkillsSummary(loaded);
122
+ const blocks = loaded.map(({ candidate, content }) => ({
123
+ type: "text" as const,
124
+ text: `## ${candidate.skill.name}\n\n${content}`,
125
+ }));
126
+ return {
127
+ content: summary
128
+ ? [{ type: "text", text: summary }, ...blocks]
129
+ : [{ type: "text", text: "No matching skills for that task." }],
130
+ details: { query: params.task, matches: candidates.map((c) => c.skill.name) },
131
+ };
132
+ },
133
+ renderResult(result, { expanded }, theme) {
134
+ const details = result.details as { query?: string; matches?: string[] } | undefined;
135
+ const names = details?.matches?.join(", ") ?? "none";
136
+ const collapsed = `${theme.fg("success", "✓ Shiori")} ${theme.bold(names)}`;
137
+ if (!expanded) return new Text(collapsed, 0, 0);
138
+ const query = details?.query ? `\n${theme.fg("dim", details.query)}` : "";
139
+ return new Text(`${collapsed}${query}`, 0, 0);
140
+ },
141
+ });
142
+
75
143
  pi.registerTool({
76
144
  name: "shiori_load_skill",
77
145
  label: "Shiori Load Skill",
@@ -126,10 +194,10 @@ export default function piSkillShiori(pi: ExtensionAPI) {
126
194
  `skills: ${current.skills.length}`,
127
195
  `duplicates: ${duplicates.size}`,
128
196
  `alwaysVisible: ${current.policy.alwaysVisible.join(", ") || "none"}`,
129
- `retrievalBackend: ${current.retrievalBackend}`,
130
- `suppression: ${stats.suppressionStatus}`,
131
- `code: ${SHIORI_CODE_MARKER}`,
132
- `builtAt: ${current.builtAt}`,
197
+ `retrievalBackend: ${current.retrievalBackend}`,
198
+ `suppression: ${stats.suppressionStatus}`,
199
+ `code: ${SHIORI_CODE_MARKER}`,
200
+ `builtAt: ${current.builtAt}`,
133
201
  ].join("\n"),
134
202
  duplicates.size > 0 || stats.suppressionStatus === "failed-pattern-not-found" ? "warning" : "info",
135
203
  );
@@ -138,9 +206,23 @@ export default function piSkillShiori(pi: ExtensionAPI) {
138
206
 
139
207
  pi.registerCommand("shiori:reload", {
140
208
  description: "Rebuild Pi Skill Shiori inventory and optional Pi runtime resources",
141
- handler: async (args, ctx) => {
209
+ handler: async (_args, ctx) => {
210
+ let target = RELOAD_INVENTORY_ONLY;
211
+ if (ctx.hasUI) {
212
+ const choice = await ctx.ui.select("Reload what?", [
213
+ RELOAD_INVENTORY_ONLY,
214
+ RELOAD_WITH_RUNTIME,
215
+ CANCEL_CHOICE,
216
+ ]);
217
+ if (!choice || choice === CANCEL_CHOICE) {
218
+ ctx.ui.notify("Reload cancelled.", "info");
219
+ return;
220
+ }
221
+ target = choice;
222
+ }
223
+
142
224
  await reload(ctx.cwd);
143
- if (args.includes("--runtime")) {
225
+ if (target === RELOAD_WITH_RUNTIME) {
144
226
  await ctx.reload();
145
227
  return;
146
228
  }
@@ -148,16 +230,97 @@ export default function piSkillShiori(pi: ExtensionAPI) {
148
230
  },
149
231
  });
150
232
 
151
- pi.registerCommand("shiori:test-query", {
152
- description: "Test Pi Skill Shiori candidate retrieval for arbitrary text",
153
- handler: async (args, ctx) => {
233
+ pi.registerCommand("shiori:recommend", {
234
+ description: "Recommend Agent Skills for a natural-language task and queue it for the agent",
235
+ handler: async (_args, ctx) => {
154
236
  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",
237
+ if (!ctx.hasUI) {
238
+ ctx.ui.notify("Run /shiori:recommend in the Pi UI to enter a task description.", "warning");
239
+ return;
240
+ }
241
+
242
+ const input = await ctx.ui.input(
243
+ "What do you want help with?",
244
+ "Describe your task in natural language…",
160
245
  );
246
+ const query = input?.trim();
247
+ if (!query) {
248
+ ctx.ui.notify("Recommendation cancelled.", "info");
249
+ return;
250
+ }
251
+
252
+ const action = await ctx.ui.select("How should Shiori use these recommendations?", [
253
+ RECOMMEND_QUEUE_TASK,
254
+ RECOMMEND_REVIEW_ONLY,
255
+ CANCEL_CHOICE,
256
+ ]);
257
+ if (!action || action === CANCEL_CHOICE) {
258
+ ctx.ui.notify("Recommendation cancelled.", "info");
259
+ return;
260
+ }
261
+
262
+ const policy = withRecommendLimits(current);
263
+ const candidates = retrieveCandidatesExpanded(query, current.skills, policy, current);
264
+ const planning = isPlanningIntent(query);
265
+ const reviewOnly = action === RECOMMEND_REVIEW_ONLY;
266
+ const loaded = planning || reviewOnly ? [] : await loadRecommendedSkills(candidates);
267
+ const summary = planning || reviewOnly
268
+ ? candidates.length > 0
269
+ ? `Matched ${candidates.length} skill(s): ${candidates.map((c) => c.skill.name).join(", ")}`
270
+ : ""
271
+ : formatLoadedSkillsSummary(loaded);
272
+
273
+ if (summary) {
274
+ ctx.ui.notify(summary, "info");
275
+ } else {
276
+ ctx.ui.notify("No matching skills for that request.", "warning");
277
+ }
278
+
279
+ if (!reviewOnly) {
280
+ const kickoff = isPlanningIntent(query)
281
+ ? formatPlanningKickoffMessage(query, candidates)
282
+ : formatRecommendKickoffMessage(query, loaded);
283
+ pi.sendUserMessage(kickoff);
284
+ ctx.ui.notify(
285
+ isPlanningIntent(query)
286
+ ? "Queued dev-plan intake for the agent."
287
+ : loaded.length > 0
288
+ ? `Pre-loaded ${loaded.length} skill(s) and queued task for the agent.`
289
+ : "Queued task for the agent.",
290
+ "info",
291
+ );
292
+ return;
293
+ }
294
+
295
+ if (candidates.length === 0) {
296
+ return;
297
+ }
298
+
299
+ const choice = await ctx.ui.select("Follow up on a recommendation:", [
300
+ ...candidates.map((candidate) => candidate.skill.name),
301
+ "Done",
302
+ ]);
303
+ if (!choice || choice === "Done") {
304
+ return;
305
+ }
306
+
307
+ const selected = candidates.find((candidate) => candidate.skill.name === choice);
308
+ if (!selected) {
309
+ return;
310
+ }
311
+
312
+ ctx.ui.notify(formatCandidateDetail(selected), "info");
313
+
314
+ const shouldLoad = await ctx.ui.confirm(
315
+ "Load this skill?",
316
+ `The agent can call shiori_load_skill({ skill: "${selected.skill.name}" }) to read the full SKILL.md.`,
317
+ );
318
+ if (shouldLoad) {
319
+ ctx.ui.notify(
320
+ `Ask the agent to run: shiori_load_skill({ skill: "${selected.skill.name}" })`,
321
+ "info",
322
+ );
323
+ }
161
324
  },
162
325
  });
163
326
 
@@ -184,14 +347,6 @@ function findSkill(name: string, skills: SkillRecord[]): SkillRecord | undefined
184
347
  return skills.find((skill) => skill.name === name);
185
348
  }
186
349
 
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
350
  function formatBytes(bytes: number): string {
196
351
  if (bytes < 1024) return `${bytes}B`;
197
352
  return `${(bytes / 1024).toFixed(1)}KB`;
@@ -205,7 +360,7 @@ function renderGeneratedPolicy(skills: SkillRecord[]): string {
205
360
  "defaults:",
206
361
  " activation: explicit",
207
362
  "candidateInjection:",
208
- " maxCandidates: 3",
363
+ " maxCandidates: 5",
209
364
  " minScore: 0.62",
210
365
  "alwaysVisible:",
211
366
  " - 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("CREATE VIRTUAL TABLE skill_fts USING fts5(name, description, triggers, path)");
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.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 ");
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(/[^A-Za-z0-9_-]/g, "");
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[], options: CandidateFormatOptions = {}): string {
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
- 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
- }
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 : triggerableSkills;
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;