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 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.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.0
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.0
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.0"
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.0
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.0
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:test-query <text>` | Preview candidates for a query. |
190
- | `/shiori:test-query --verbose <text>` | Include full descriptions, scores, paths, and load hints. |
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.0
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.1.1",
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
- 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,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 { 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;
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 = retrieveCandidates(event.prompt, current.skills, current.policy, current);
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:test-query", {
152
- description: "Test Pi Skill Shiori candidate retrieval for arbitrary text",
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 { 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",
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: 3",
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("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;