Keeps the skill listing out of the context window and lets TypeSafe's Jev, a System One decision model, pick at most one skill per prompt from the skills'…

TypeSafe Jev, a System One decision model, picks at most one skill for each prompt you send and attaches that skill's SKILL.md to the prompt. Every other skill stays loadable: the model still sees each one by name and can load it with the Skill tool, so a skill that says "now use /other-skill" keeps working.
This is a Claude Code mod (a plugin whose behaviour lives in a hooks module). It started as a fork of jev-skill-suggestion in davila7/claude-code-templates at 55457cfcc7 (tag upstream-55457cf in this repository). See What changed from upstream.
Upstream's setup set every skill to user-invocable-only and turned on disableBundledSkills. That takes skills off the listing, and it also makes the Skill tool refuse every hidden skill. So the model could use only the one skill the mod injected (upstream #1013):
/code-review before you commit" fails;/simplify, /loop and the other bundled skills disappear, typed or not.Claude Code has a gentler state: "name-only" in skillOverrides keeps the skill in the listing as - name, without its description, and the Skill tool still loads it. That keeps most of the context saving, because the descriptions are most of the listing. This fork's setup writes name-only, keeps the listing in place by default, and never touches the bundled skills.
Requires Claude Code 2.1.287 or newer (mods load by default from there). The find_skill tool was verified on 2.1.299.
From the marketplace (any machine):
/plugin install jev-skill-suggestion --marketplace audichuang/jev-skill-suggestion
or from a shell:
claude plugin marketplace add audichuang/jev-skill-suggestion
claude plugin install jev-skill-suggestion@jev-skill-suggestion
From a clone, for every project (what you want while you edit it):
git clone https://github.com/audichuang/jev-skill-suggestion
ln -s "$PWD/jev-skill-suggestion" ~/.claude/skills/jev-skill-suggestion
A plugin folder under ~/.claude/skills/ loads in every project as jev-skill-suggestion@skills-dir. Under a project's own .claude/skills/ it loads only there, and only once you trust the project. Keep one copy: two plugins with the same name shadow each other, and /plugin reports the one it dropped.
Switching from npx claude-code-templates --mod: delete the project's .claude/skills/jev-skill-suggestion first. If you ran upstream's setup, change its user-invocable-only entries in ~/.claude/settings.json to name-only by hand (this fork's setup leaves existing user-invocable-only entries alone), and remove "disableBundledSkills": true if you want the bundled skills back.
The key. Set TYPESAFE_API_KEY (or AI_GATEWAY_API_KEY for the Vercel AI Gateway) in the environment Claude Code starts from, and the mod uses it. A key in the plugin options wins over the environment. Options live in user settings only (~/.claude/settings.json, or $CLAUDE_CONFIG_DIR/settings.json), under the plugin's id:
{ "pluginConfigs": { "jev-skill-suggestion@skills-dir": { "options": { "rerankTimeoutMs": 2500 } } } }
The id follows how the plugin was loaded: jev-skill-suggestion@skills-dir from a skills folder, jev-skill-suggestion@jev-skill-suggestion from the marketplace, jev-skill-suggestion with --plugin-dir. Under the wrong id every option stays at its default.
Two things to know about a key stored as an option. Claude Code reads a plaintext key from settings.json, but a value saved through /config goes to the keychain, wins over the file, and removes the plaintext copy. The environment variable avoids both: it never lands in a settings file you might commit.
With no key at all, the mod falls back to Claude Code's built-in classifier: one request, no gate, no rerank, and no time limit.
Trim the listing (optional). Run /jev-skill-suggestion:setup. It shows the user skills it will set to name-only and asks before it edits anything. It writes the old values to jev-skill-suggestion.skill-overrides.backup.json next to your settings. /jev-skill-suggestion:setup restore puts them back. Skills that are already name-only, user-invocable-only or off stay as they are. Plugin skills cannot be trimmed this way. Project skills (.claude/skills in the project) are left with their descriptions: they are the few that project needs most, and you choose how many there are. An earlier version did trim them; to give them back their descriptions, delete their entries from skillOverrides.
Restart Claude Code after either step.
Send a prompt in any project. The transcript should show:
[jev-skill-suggestion] ready on typesafe (https://api.typesafe.ai/v1/systemone); leaving the skill listing in place
[jev-skill-suggestion] jev: needs a skill 0.76 · top of 58: pptx-author (0.70), … · 160ms
ready on the built-in classifier, no key set: the key was not found. Check the environment Claude Code was started from, or the pluginConfigs id.typesafe responded 401 to the ranking: the key is wrong. The prompt goes through with no suggestion.claude --debug and look for hooks module jev-skill-suggestion@… loaded.| Hook | What it does |
|---|---|
prompt.submit | Runs the two requests below on the prompt you sent. It attaches the winner, if any, as a <skill_relevance> block the model reads and you never see. With inject: "content" (the default) the block holds the skill's SKILL.md; with inject: "suggest" it holds only the name, and the model loads the skill itself. |
prompt.attachment on skill_listing | Leaves the listing alone by default. With hideListing: true it withholds the listing, or trims it to the names in alwaysListed. |
session.start, tool.describe, tool.call | The find_skill tool (see below): registered when the session starts, kept out of ToolSearch so the model sees its description from the first request, and answered with the same two requests. |
skill.prompt | Logs whether a skill the model loaded was the suggested one, and writes the prompt of /jev-skill-suggestion:setup. |
When it decides. Once per prompt you send, from that prompt's text alone. A long turn can run into sub-tasks the prompt never named, and with name-only the model sees those skills by name only. For that, the mod gives the model a find_skill tool. The model calls it with the sub-task in one sentence, and Jev answers with at most one skill: its name and the description the listing leaves out. The model then loads it with the Skill tool, or ignores it. Nothing is pushed mid-turn. A skill that hands off to another skill works as it does without the mod.
find_skill offers only skills the main conversation's listing names, because the Skill tool loads only those. It leaves out the gate questions a prompt must pass: a model that calls find_skill has already decided it wants a skill, and one sub-task sentence without its conversation scores low on them. The rerank's fits check is then the only check, so a pick the rerank never read is not answered, and the tool is offered only with a TypeSafe or Gateway key and rerank on. When a request fails or times out, it says the lookup is unavailable rather than "no skill fits". It answers a name and never a SKILL.md: a skill loaded with the Skill tool is attached again after a compaction, and one attached by the mod is not. findSkill: false turns the tool off. The tool's description and schema cost about 160 tokens in every session.
What it attaches:
<skill_relevance>
Relevant to the current request: commit. Ignore this if it does not fit what the user actually asked for.
Its instructions follow: follow them now, including any setup steps. Do not load it again with the Skill tool: it is already loaded here. Its files are in /home/me/.claude/skills/commit.
<skill name="commit" dir="/home/me/.claude/skills/commit">
…the SKILL.md body, frontmatter off, ${CLAUDE_SKILL_DIR} and ${CLAUDE_PROJECT_DIR} filled in…
</skill>
</skill_relevance>
A skill already injected is only named again on later prompts, until /clear, a resume or a compaction. When nothing is picked, nothing is attached, so the model is free to load a skill from the listing.
Candidates. Every skill $.command.list() reports, except built-ins, names in neverSuggested, and skills set to off in your user settings. A skill whose frontmatter says disable-model-invocation: true is never picked.
Known limits.
allowed-tools are not applied.Two requests, in the cookbook's shape. Each may come back empty-handed.
Request 1 — skim every skill. One choice (which) over every candidate, with its one-line description as the criterion; its probability distribution is the ranking. Beside it, four nouls about the request, not about any skill:
| noul | asks |
|---|---|
acts_on_user_system | Is the assistant being asked to act on the user's files, accounts, devices or services, rather than only to explain or advise? |
would_follow_documented_procedure | Would a careful expert consult a specific documented procedure or set of commands, rather than answer from general understanding? |
prose_suffices | Could a knowledgeable generalist fully satisfy this in prose, with no tools and no access to the user's files? (counts the other way round) |
names_its_task | Does this message on its own say what is to be done, specifically enough to start without reading earlier messages? |
Their mean is the gate: under gateThreshold (0.30) nothing is suggested, whatever the ranking said. Questions about subject matter would not do this job — explain what a monad is and a task that needs a skill are both software. names_its_task is this fork's: Jev reads the prompt alone, and a follow-up such as "好幫我完成優化吧" carries its task in earlier turns, so without it such replies were ranked as if they were tasks (see evals/).
Request 2 — read the top three properly. The same choice over the shortlist (shortlist, 3), now with each skill's full frontmatter description and the first excerptChars (700) of its SKILL.md as the criterion, and one noul per candidate — does this skill do the specific thing the request asks for? — answered on its own, so all of them can come back low. A shortlist whose best fits is under fitsThreshold (0.50) is dropped entirely; otherwise the choice's winner is suggested. The two decide different things: the choice settles which, the nouls settle whether.
This is where lookalikes separate — on one line the skill that edits .pptx files reads nearly the same as the one that authors them; on 700 characters they do not.
The skill bodies come from disk, by where Claude Code keeps them: .claude/skills/<name>/SKILL.md and .claude/commands/<name>.md in the project and under ~, ~/.claude/skills/synced/<account>/<name>/SKILL.md for a skill synced from claude.ai, and for a plugin's skill its install path from ~/.claude/plugins/installed_plugins.json. A body that cannot be found leaves that candidate with its one-line description; the request still goes out. Bodies are read once per session, and the same read is what gets injected.
noul as a boolean with a probability; both shapes are read.rerank: false skips the second request and suggests the top of the ranking, once the gate passes. A second request that was attempted and failed suggests nothing: the ranking's winner has not had its false-positive check.disable-model-invocation: true is never suggested, whichever path picked it: the engine leaves it out of the listing and the Skill tool refuses it. Before the first listing has been seen the candidates come from $.command.list(), which also names such skills, so their SKILL.md is the check (read for the shortlist and for the winner).Every failure — a non-2xx response, a timeout past timeoutMs (ranking) or rerankTimeoutMs (rerank), a thrown error, a malformed body — lets the prompt through with no suggestion. The mod never blocks a prompt.
Prompts that are not a task get no suggestion: notifications, peer messages, observer reports, a prompt that is itself a /name (its skill is already named), and a reply with no task of its own — no letters at all (2, 👍), or a stock go-ahead such as ok, continue, 好, 繼續. The decision model reads the prompt alone, so it could only guess at those; the model keeps whatever the task already loaded.
With logDecisions on (the default), the mod reports every step of its own work, because nothing else in Claude Code shows it — a listing that was never sent leaves no trace:
[jev-skill-suggestion] ready on typesafe (https://api.typesafe.ai/v1/systemone); leaving the skill listing in place
[jev-skill-suggestion] jev: needs a skill 0.76 · top of 58: powerpoint (0.70), pptx-author (0.30), chroma (0.00) · 160ms
[jev-skill-suggestion] jev: rerank → pptx-author (0.81) · fits powerpoint 0.73, pptx-author 0.38, chroma 0.02 · 90ms · 3/3 bodies read
[jev-skill-suggestion] suggesting /pptx-author: rerank of 3, fits 0.38
[jev-skill-suggestion] injected /pptx-author from /home/me/.claude/skills/pptx-author/SKILL.md (4210 characters)
[jev-skill-suggestion] jev: needs a skill 0.12 · top of 58: debug (0.41), code-review (0.22), commit (0.05) · 150ms
[jev-skill-suggestion] no suggestion: needs a skill 0.12 < 0.3
jev: lines are what the decision model replied to each request, before any policy is applied — the gate, the top of the ranking, then the rerank's winner and every fits, with how long each call took and how many SKILL.md bodies were found.injected /name from <file> is the skill going in with the prompt; already injected this session; named again on a repeat.withheld the skill listing appears only with hideListing: true: the listing hook firing, with what it cost the context and what it kept. While some listed skills still bring their whole description, the mod adds N skills are still listed with a full description; run /jev-skill-suggestion:setup … once per session.find_skill "<sub-task>": suggesting /name (or no suggestion, or lookup unavailable) is the model calling find_skill mid-turn, after the same jev: lines.skill /name loaded is the model calling the Skill tool on its own (inject: "suggest", or a typed /name): as suggested, or which skill was suggested instead when it reached for another.It also keeps a one-line status on screen, replaced as it goes:
jev · skill: pptx-author
jev · no skill
The transcript lines scroll away, and a headless run writes them only to its debug log. With recordDecisions on (the default), every decision also goes to one file per session, ~/.claude/jev-skill-suggestion/decisions/<session id>.jsonl (under CLAUDE_CONFIG_DIR when set), one JSON object per line:
{"at":"2026-10-16T08:12:03.120Z","source":"prompt","text":"畫一張 web 服務的架構圖","pick":"drawio-skill","reason":"rerank of 3, fits 0.91","answered":true,"ms":1180,"model":"jev-1.13.0","gate":0.71,"gateValues":{…},"top":[{"name":"drawio-skill","probability":0.62},…],"rerank":{"winner":"drawio-skill","confidence":0.88,"fits":{…}}}
{"at":"2026-10-16T08:12:09.402Z","loaded":"drawio-skill","suggested":"drawio-skill"}
source is prompt or find_skill; a loaded line is the model calling the Skill tool, with the pick it was made under. The file holds the request text as typed: it never leaves the machine, and is there so a wrong suggestion can be read back and turned into a test case. Set recordDecisions: false to keep nothing.
To list every recorded decision, newest session files included:
cat ~/.claude/jev-skill-suggestion/decisions/*.jsonl | jq -c 'select(.source) | {source, pick, reason, text: .text[0:60]}'
No lines at all has three causes, and only the last is the module failing to load. Check them in this order:
claude -p (or the SDK). A headless run has no transcript and no status row: every line still goes to the debug log, ~/.claude/debug/<session-id>.txt (a .txt, not a .log; latest is a symlink to the newest), and an SDK host receives each one as ui_log..claude/skills/ only once the project is trusted: it is repository content, so an untrusted folder's .claude/ is not read at all, and claude -p never asks. Open claude interactively in the folder and accept the trust prompt, or name the plugin explicitly with --plugin-dir (see Install). claude --debug settles it: a loaded module prints hooks module jev-skill-suggestion@skills-dir loaded (worker, …); events: prompt.attachment,prompt.submit,skill.prompt (@inline when loaded with --plugin-dir); Found N plugins without it means the plugin is not in the session.installed plugins' hooks modules not loaded: rollout flag (tengu_plugin_hooks_modules) is off. Update Claude Code (this mod needs 2.1.278+, see below).A ready on the built-in classifier, no key set line when you did set a key means the key sits under the wrong pluginConfigs entry: the key must match the plugin's id, which depends on how it was loaded (see Options).
With a key set, the prompt text and every candidate skill's name and one-line description leave the machine on the first request, and the first excerptChars of each shortlisted skill's SKILL.md on the second, to whichever backend the key belongs to. Nothing else. With no key set, nothing leaves the machine.
typesafeApiKey: string TypeSafe API key; empty reads TYPESAFE_API_KEY
gatewayApiKey: string Vercel AI Gateway key; empty reads AI_GATEWAY_API_KEY
provider: string "auto" | "typesafe" | "gateway" | "builtin"
typesafeBaseUrl: string empty uses https://api.typesafe.ai
typesafeModel: string empty uses jev-1.14.0
gatewayBaseUrl: string empty uses https://ai-gateway.vercel.sh/v4/ai
gatewayModel: string empty uses typesafe-ai/jev
inject: string "content" attaches the chosen SKILL.md (default); "suggest" names it for the Skill tool
hideListing: boolean withhold the engine's skill listing (default false)
rerank: boolean second request over the shortlist (default true)
shortlist: number how many of the ranking the second request re-reads (default 3)
gateThreshold: number gate mean under which nothing is suggested (default 0.3)
fitsThreshold: number best `fits` under which the shortlist is dropped (default 0.5)
excerptChars: number SKILL.md characters each candidate brings (default 700)
alwaysListed: string comma-separated names kept when hideListing is on
neverSuggested: string comma-separated names never offered to the decision model
timeoutMs: number latency budget for the ranking request (default 800)
rerankTimeoutMs: number latency budget for the rerank request (default 2500)
findSkill: boolean give the model the find_skill tool for mid-turn sub-tasks (default true; needs a key and rerank on)
logDecisions: boolean log each decision (default true)
recordDecisions: boolean keep every decision in a file per session (default true)
sendRecentContext: boolean send the previous user message and the end of the last reply with the request (default true)
| Change | Upstream reference |
|---|---|
Setup writes name-only, treats name-only as already done, and never sets disableBundledSkills. Restore leaves disableBundledSkills alone. | #1013 |
hideListing defaults to false. The "run setup" hint fires only while listed skills still carry a description. | #1013 |
| Content mode attaches nothing when nothing is picked. Upstream attached "No skill in the roster appears relevant", which talked the model out of loading one itself. | — |
A key can come from TYPESAFE_API_KEY / AI_GATEWAY_API_KEY. | #954 (closed upstream) |
Gateway requests send ai-gateway-protocol-version. | #1123 |
| Setup trims user skills only: a project's own skills keep their descriptions, so the person decides how much of the listing they take. Upstream trims project skills too. | #1123 |
The rerank request gets its own budget, rerankTimeoutMs, default 2500 ms. | #962 |
Settings, skills, plugin records and the backup follow CLAUDE_CONFIG_DIR. | #1042 |
Replies with no task of their own (ok, 2, 繼續) are not ranked. | #1081 |
Skills set to off are never injected. | — |
A find_skill tool lets the model ask Jev mid-turn, so a sub-task that turns up after the prompt is not left to a guess from bare names. | — |
Every decision, with the request text and the ranking, is kept in a file per session (recordDecisions), so a wrong pick can be read back. | — |
The turn before the request (the previous user message and the end of the assistant's last reply) goes in recent_context, which upstream always sends empty (sendRecentContext). | — |
A fourth gate question (names_its_task), a structured fits question, fitsThreshold 0.5 and the model pinned to jev-1.14.0, all measured with evals/run.ts (see evals/README.md). | Cookbook uses three gate nouls, fits 0.30 and jev-latest |
To see upstream's later changes: gh api "repos/davila7/claude-code-templates/commits?path=cli-tool/components/mods/productivity/jev-skill-suggestion", then diff the new upstream copy against the upstream-* tag.
claude plugin test .
claude plugin validate .
MIT. Original work © Daniel (San) Ávila and the claude-code-templates contributors; changes © audichuang. See LICENSE.
hooks/jev-skill-suggestion.ts 833 lines1/**
2 * jev-skill-suggestion — Claude Mod
3 *
4 * Takes the skill listing out of the context window and has TypeSafe's Jev,
5 * a System One decision model, suggest at most one skill per prompt, going
6 * by the skills' descriptions. The skills stay installed and loadable; what
7 * goes away is the listing the engine sends the model every session, one
8 * line per skill, whether the prompt has anything to do with any of them.
9 *
10 * The decision follows TypeSafe's "Skill suggestion" cookbook: two requests
11 * per prompt, one to rank every skill and ask whether the prompt needs a
12 * skill at all, one to re-read the top few with their full text and let each
13 * be rejected on its own. Either may come back empty-handed.
14 *
15 * The hooks:
16 * prompt.attachment — the engine's `skill_listing` attachment is answered
17 * with `{ text: null }` (left out) or trimmed to the
18 * names in `alwaysListed`. Its names are remembered:
19 * they are the engine's word on which skills the model
20 * may invoke.
21 * prompt.submit — the two requests run, and the winner (if any) is
22 * attached to the prompt as a `<skill_relevance>`
23 * block: with the skill's own SKILL.md inside it
24 * (`inject: "content"`, the default), so the skill
25 * loads even when `skillOverrides` hides it from the
26 * model, or with its name for the Skill tool
27 * (`inject: "suggest"`).
28 * session.start, tool.describe, tool.call
29 * — the `find_skill` tool: registered when the session
30 * starts, kept out of ToolSearch so the model sees its
31 * description, and answered by the same two requests
32 * on the sub-task the model names. The prompt.submit
33 * pick covers the prompt; this covers what turns up
34 * mid-turn, for a model that sees most skills by name
35 * only. It answers a name for the Skill tool, never a
36 * SKILL.md.
37 * skill.prompt — observation: whether the model took the suggestion,
38 * or loaded a skill on its own. Also writes the prompt
39 * of the plugin's own `/jev-skill-suggestion:setup`,
40 * which hides every skill from the engine's listing
41 * (user-invocable-only) once the person has seen the
42 * list and said yes; the model makes the edit with its
43 * own tools, so it shows and asks like any other.
44 *
45 * Jev is reached one of two ways, whichever key is configured: TypeSafe's
46 * own API (`typesafeApiKey`), which reports a calibrated confidence, or the
47 * Vercel AI Gateway (`gatewayApiKey`), which does not. With neither, the
48 * engine's own `$.model.classify` stands in with a single request and no
49 * gate, so the mod is useful without any account.
50 *
51 * The candidates come from `$.command.list()`, not from the listing: the
52 * listing is only rendered at the turn's first request, after `prompt.submit`
53 * has run, so the first prompt of a session would otherwise have nothing to
54 * choose from. The listing, once seen, narrows the candidates to what the
55 * engine itself would have shown.
56 *
57 * The second request reads the opening of each shortlisted skill's SKILL.md,
58 * found on disk by how Claude Code lays skills out (project and user
59 * `.claude/skills` and `.claude/commands`, a plugin's install path from
60 * `~/.claude/plugins/installed_plugins.json`). A body that cannot be found
61 * leaves that skill with its one-line description; nothing fails over it.
62 *
63 * Only the main conversation is handled. A subagent's own listing is left as
64 * the engine renders it: its prompt is a tool call's argument, not a
65 * `prompt.submit`, so nothing here could suggest for it. `find_skill`, where a
66 * subagent is given it, is the exception (unverified: calls carry `agentId`).
67 *
68 * Every failure path is fail-open: a request that errors or runs past the
69 * latency budget lets the prompt through with no suggestion, and the listing
70 * hook always answers the same way, so the model's prompt cache holds.
71 *
72 * The API key comes from the plugin's options (userConfig "typesafeApiKey"
73 * or "gatewayApiKey"). Never hardcode it in this file.
74 *
75 * Needs Claude Code >= 2.1.278: the
76 * `prompt.attachment` event is that release's. Typed against Anthropic's
77 * declarations: https://github.com/anthropics/claude-code/tree/main/mods
78 *
79 * Privacy: with a key set, the prompt text, every candidate skill's name and
80 * description, and the opening of each shortlisted skill's SKILL.md are sent
81 * to whichever backend the key belongs to.
82 */
83import type { EngineInterface, Register } from 'claude-code'
84import {
85 DEFAULT_BASE_URL,
86 DEFAULT_MODEL,
87 FIND_SKILL_TOOL,
88 NONE,
89 SETUP_COMMAND,
90 builtinWide,
91 catalog,
92 classifyText,
93 commandLike,
94 configDirOf,
95 contextFree,
96 decide,
97 describeRerank,
98 describeSetup,
99 describeStatus,
100 describeStillListed,
101 describeWide,
102 detailOf,
103 displayIds,
104 canonical,
105 endpoint,
106 findSkillResult,
107 injectionBlock,
108 installPathsOf,
109 parseListing,
110 parseNames,
111 passesGate,
112 pluginFileCandidates,
113 readRerank,
114 readSkillSettings,
115 modelInvocable,
116 readWide,
117 recentContextOf,
118 modelOf,
119 recordLine,
120 rerankQuestions,
121 requestBody,
122 requestHeaders,
123 selectProvider,
124 setupAborted,
125 setupInstructions,
126 setupPlan,
127 shortlistOf,
128 validBackup,
129 skillFileCandidates,
130 suggestionBlock,
131 syncedFileCandidates,
132 trimListing,
133 underConfigDir,
134 wideQuestions,
135} from './policy.ts'
136import type { Candidate, PolicyConfig, Provider, RecentContext, Rerank, Skill, Trace, Wide } from './policy.ts'
137
138/** Prompt origins that are not a task of the person's: nothing to suggest for. */
139const NOT_A_TASK = new Set([
140 'task-notification',
141 'peer',
142 'peer-send-message',
143 'projects-relay',
144 'observer',
145 'observer-activity',
146])
147
148/** A SKILL.md as found on disk. */
149type SkillFile = { path: string; markdown: string }
150
151/** The backend the keys select, with where and as which model it is asked. */
152type Backend = { active: Provider | null; apiKey: string; modelId: string; url: string }
153
154/** What `choose` answers. */
155interface Choice {
156 pick: Skill | null
157 /** Why, for the log line. */
158 reason: string
159 file: SkillFile | null
160 /** False when a request failed or timed out: "no pick" is then not an answer. */
161 answered: boolean
162 /** What the requests answered, for the record file; null when none was asked. */
163 trace: Trace | null
164}
165
166/** What `choose` reads from the options, and the session state it shares with the hooks. */
167interface ChooseContext {
168 readonly backendOf: (typesafeEnv: string | undefined, gatewayEnv: string | undefined) => Backend
169 readonly forced: string
170 readonly neverSuggested: ReadonlySet<string>
171 readonly logDecisions: boolean
172 readonly recordDecisions: boolean
173 readonly sendRecentContext: boolean
174 readonly timeoutMs: number
175 readonly rerankTimeoutMs: number
176 readonly rerankEnabled: boolean
177 readonly excerptChars: number
178 readonly policy: PolicyConfig
179 // Session state, filled in as hooks run.
180 files: Map<string, SkillFile | null>
181 unusableReported: boolean
182 displayToId: Map<string, string> | null
183 /** Each session's record file as written so far, by session id. */
184 records: Map<string, string>
185}
186
187/**
188 * The turn before `current` (null mid-turn), sent beside the request; null
189 * when off or unreadable, and for a subagent, whose conversation is not the
190 * session's transcript.
191 */
192async function contextFor(
193 $: EngineInterface,
194 ctx: ChooseContext,
195 current: string | null,
196 agentId?: string,
197): Promise<RecentContext | null> {
198 if (!ctx.sendRecentContext || agentId) return null
199 try {
200 return recentContextOf(await $.session.messages(), current)
201 } catch (error) {
202 $.ui.log(`[jev-skill-suggestion] could not read the transcript: ${String(error)}`)
203 return null
204 }
205}
206
207/** A record file stops growing here, under `$.fs`'s 4 MiB per read and write. */
208const RECORD_LIMIT = 3_500_000
209
210/**
211 * Appends one line to the session's record file,
212 * `<config dir>/jev-skill-suggestion/decisions/<session id>.jsonl`. `$.fs`
213 * writes whole files, so the content so far is kept in memory, read back from
214 * disk the first time (a resumed session). Fails open, like everything else.
215 */
216async function record($: EngineInterface, ctx: ChooseContext, entry: Parameters<typeof recordLine>[1]) {
217 if (!ctx.recordDecisions) return
218 try {
219 const configDir = await configDirFor($)
220 if (!configDir) return
221 const id = await $.session.id()
222 const path = `${configDir}/jev-skill-suggestion/decisions/${id}.jsonl`
223 let content = ctx.records.get(id)
224 if (content === undefined) content = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
225 const next = content + recordLine(await $.clock.now(), entry)
226 if (next.length > RECORD_LIMIT) return
227 ctx.records.set(id, next)
228 await $.fs.write(path, next)
229 } catch (error) {
230 $.ui.log(`[jev-skill-suggestion] could not record the decision: ${String(error)}`)
231 }
232}
233
234/** The user's config dir, or '' when HOME is unset and it cannot be named. */
235async function configDirFor($: EngineInterface): Promise<string> {
236 const home = (await $.env.get('HOME')) ?? ''
237 return home ? configDirOf(home, await $.env.get('CLAUDE_CONFIG_DIR')) : ''
238}
239
240/**
241 * Every command `$.command.list()` reports, under the id the engine lists,
242 * runs and overrides it by. Throws as `$.command.list()` does.
243 */
244async function listCommands($: EngineInterface, ctx: ChooseContext, configDir: string) {
245 const commands = await $.command.list()
246 if (!ctx.displayToId && commands.some((command) => !commandLike(command.name))) {
247 const found: { dir: string; markdown: string }[] = []
248 for (const dir of [`${await $.session.cwd()}/.claude/skills`, configDir && `${configDir}/skills`]) {
249 if (!dir || !(await $.fs.exists(dir))) continue
250 for (const entry of await $.fs.list(dir)) {
251 const file = `${dir}/${entry.name}/SKILL.md`
252 if (entry.kind === 'dir' && (await $.fs.exists(file))) found.push({ dir: entry.name, markdown: await $.fs.read(file) })
253 }
254 }
255 ctx.displayToId = displayIds(found)
256 }
257 return canonical(commands, ctx.displayToId ?? new Map())
258}
259
260/**
261 * Jev's pick for `text`, with the pick's SKILL.md when found, or null when
262 * the skills could not be listed. `restrictTo`, when
263 * non-empty, keeps the candidates to those names: the listing's, for a pick
264 * the model will load with the Skill tool (empty until the listing is seen).
265 * `askGate` off leaves the gate questions out, so the gate passes: a model
266 * that calls find_skill has already decided it wants a skill.
267 */
268async function choose(
269 $: EngineInterface,
270 ctx: ChooseContext,
271 text: string,
272 { restrictTo, askGate, context }: { restrictTo: ReadonlySet<string>; askGate: boolean; context: RecentContext | null },
273): Promise<Choice | null> {
274 const configDir = await configDirFor($)
275 const { active, apiKey, modelId, url } = ctx.backendOf(await $.env.get('TYPESAFE_API_KEY'), await $.env.get('AI_GATEWAY_API_KEY'))
276
277 /** One request to the active backend, or null on timeout, error or a non-2xx. */
278 const ask = async (
279 prompt: string,
280 questions: Record<string, unknown>,
281 what: string,
282 budgetMs: number,
283 ): Promise<string | null> => {
284 if (!active) return null
285 try {
286 const response = await Promise.race([
287 $.http.fetch(url, {
288 method: 'POST',
289 headers: requestHeaders(active, apiKey, modelId),
290 body: requestBody(active, prompt, questions, modelId, context),
291 }),
292 $.clock.sleep(budgetMs),
293 ])
294 if (response && response.ok) return response.text
295 if (response) $.ui.log(`[jev-skill-suggestion] ${active} responded ${response.status} to the ${what}`)
296 else $.ui.log(`[jev-skill-suggestion] ${what} passed ${budgetMs}ms; no suggestion`)
297 } catch (error) {
298 $.ui.log(`[jev-skill-suggestion] ${what} failed: ${String(error)}`)
299 }
300 return null
301 }
302
303 /** A skill's file, found on disk by Claude Code's layout, or null. */
304 const fileOf = async (
305 skill: Skill,
306 plugin: string | undefined,
307 ): Promise<SkillFile | null> => {
308 const cached = ctx.files.get(skill.name)
309 if (cached !== undefined) return cached
310 let found: SkillFile | null = null
311 try {
312 const relative = skillFileCandidates(skill.name, plugin)
313 // The engine reads the project's `.claude/` (the working directory
314 // only, not its ancestors) and the user's config dir.
315 const candidates = [...relative, ...(configDir ? relative.map((file) => underConfigDir(configDir, file)) : [])]
316 if (plugin && configDir) {
317 const installed = `${configDir}/plugins/installed_plugins.json`
318 if (await $.fs.exists(installed)) {
319 for (const path of installPathsOf(await $.fs.read(installed), plugin)) {
320 candidates.push(...pluginFileCandidates(path, skill.name, plugin))
321 }
322 }
323 }
324 // A claude.ai-synced skill sits under an account directory only
325 // `$.fs.list` can name.
326 if (configDir) {
327 const synced = `${configDir}/skills/synced`
328 if (await $.fs.exists(synced)) {
329 const accounts = (await $.fs.list(synced)).filter((entry) => entry.kind === 'dir').map((entry) => entry.name)
330 candidates.push(...syncedFileCandidates(configDir, accounts, skill.name))
331 }
332 }
333 for (const file of candidates) {
334 if (await $.fs.exists(file)) {
335 found = { path: file, markdown: await $.fs.read(file) }
336 break
337 }
338 }
339 } catch (error) {
340 $.ui.log(`[jev-skill-suggestion] could not read /${skill.name}: ${String(error)}`)
341 }
342 ctx.files.set(skill.name, found)
343 return found
344 }
345
346 if (!ctx.unusableReported && !active) {
347 ctx.unusableReported = true
348 $.ui.log(`[jev-skill-suggestion] provider "${ctx.forced}" has no key set; using the built-in classifier`)
349 }
350
351 let commands: Awaited<ReturnType<typeof $.command.list>>
352 try {
353 commands = await listCommands($, ctx, configDir)
354 } catch (error) {
355 $.ui.log(`[jev-skill-suggestion] could not list the skills: ${String(error)}`)
356 return null
357 }
358 // Loading the skill itself, the mod is not bound to what the engine would
359 // list, but a skill the person turned `off` stays off: the overrides are
360 // read from the user settings on every prompt, so an edit counts at once.
361 const off = new Set<string>()
362 try {
363 const settingsPath = `${configDir}/settings.json`
364 if (configDir && (await $.fs.exists(settingsPath))) {
365 const { skillOverrides } = readSkillSettings(await $.fs.read(settingsPath))
366 for (const [name, state] of Object.entries(skillOverrides)) if (state === 'off') off.add(name)
367 }
368 } catch (error) {
369 $.ui.log(`[jev-skill-suggestion] could not read the skill overrides: ${String(error)}`)
370 }
371 const skills = catalog(commands, restrictTo, new Set([...ctx.neverSuggested, ...off]))
372 if (skills.length === 0) {
373 return { pick: null, reason: 'no candidate skills', file: null, answered: true, trace: null }
374 }
375 const pluginOf = new Map(commands.map((command) => [command.name, command.plugin]))
376
377 // Request 1: rank everything, and ask whether the prompt wants a skill at all.
378 const startedAt = await $.clock.now()
379 let wide: Wide | null = null
380 let model: string | null = null
381 if (active) {
382 const answer = await ask(text, wideQuestions(active, skills, { askGate }), 'ranking', ctx.timeoutMs)
383 if (answer) {
384 wide = readWide(answer)
385 model = modelOf(answer)
386 }
387 } else {
388 // No backend: the engine's own small-model classifier answers the same
389 // question, with the descriptions folded into the text it reads. One
390 // label, no gate, no rerank.
391 try {
392 const label = await $.model.classify(classifyText(text, skills), [
393 NONE,
394 ...skills.map((skill) => skill.name),
395 ])
396 wide = builtinWide(label)
397 } catch (error) {
398 $.ui.log(`[jev-skill-suggestion] built-in classifier failed: ${String(error)}`)
399 }
400 }
401 // What the decision model actually answered, whatever the policy then
402 // does with it. This is the line that proves the ranking ran.
403 if (ctx.logDecisions) {
404 const ms = (await $.clock.now()) - startedAt
405 $.ui.log(`[jev-skill-suggestion] jev: ${describeWide(wide, skills.length, ms)}`)
406 }
407
408 // Request 2: re-read the shortlist with each skill's full text, and let
409 // every candidate be rejected on its own.
410 let rerank: Rerank | null = null
411 let rerankAttempted = false
412 // Before the listing has been seen, `$.command.list()` may name a skill
413 // the model is not allowed to invoke; its own frontmatter tells.
414 const barred: string[] = []
415 if (active && ctx.rerankEnabled && wide && passesGate(wide, ctx.policy)) {
416 const candidates: Candidate[] = []
417 for (const skill of shortlistOf(wide, skills, ctx.policy.shortlist)) {
418 const body = (await fileOf(skill, pluginOf.get(skill.name)))?.markdown ?? null
419 if (!modelInvocable(body)) {
420 barred.push(skill.name)
421 continue
422 }
423 candidates.push({ ...skill, detail: detailOf(skill, body, ctx.excerptChars) })
424 }
425 if (candidates.length > 0) {
426 const rerankStartedAt = await $.clock.now()
427 rerankAttempted = true
428 const answer = await ask(text, rerankQuestions(active, candidates), 'rerank', ctx.rerankTimeoutMs)
429 if (answer) rerank = readRerank(answer)
430 if (ctx.logDecisions) {
431 const ms = (await $.clock.now()) - rerankStartedAt
432 const read = candidates.filter((candidate) => ctx.files.get(candidate.name)).length
433 $.ui.log(
434 `[jev-skill-suggestion] jev: ${describeRerank(rerank, ms)} · ${read}/${candidates.length} bodies read`,
435 )
436 }
437 }
438 }
439
440 const offered = barred.length > 0 ? skills.filter((skill) => !barred.includes(skill.name)) : skills
441 let decision = decide(wide, rerank, offered, ctx.policy, rerankAttempted)
442 let pick = decision.name ? (skills.find((skill) => skill.name === decision.name) ?? null) : null
443 // With the gate left out, the rerank's `fits` is the only check against a
444 // wrong pick: a pick it never read (every shortlisted skill barred, so the
445 // ranking's next one stands) is no answer.
446 if (pick && !askGate && !rerank) {
447 decision = { name: null, reason: 'no rerank read the pick' }
448 pick = null
449 }
450 // The winner's own frontmatter has the last word, whichever path picked it.
451 if (pick && !barred.includes(pick.name) && !modelInvocable((await fileOf(pick, pluginOf.get(pick.name)))?.markdown ?? null)) {
452 barred.push(pick.name)
453 decision = { name: null, reason: `/${pick.name} has disable-model-invocation` }
454 pick = null
455 }
456 if (ctx.logDecisions && barred.length > 0) {
457 $.ui.log(
458 `[jev-skill-suggestion] not model-invocable, left out: ${barred.map((name) => `/${name}`).join(', ')}`,
459 )
460 }
461 const file = pick ? await fileOf(pick, pluginOf.get(pick.name)) : null
462 return {
463 pick,
464 reason: decision.reason,
465 file,
466 answered: wide !== null && (!rerankAttempted || rerank !== null),
467 trace: { wide, rerank, ms: (await $.clock.now()) - startedAt, model },
468 }
469}
470
471export const register: Register = (on, options) => {
472 const text = (key: string, fallback: string) =>
473 typeof options[key] === 'string' && options[key] ? (options[key] as string) : fallback
474 const number = (key: string, fallback: number) =>
475 typeof options[key] === 'number' ? (options[key] as number) : fallback
476 const flag = (key: string, fallback: boolean) =>
477 typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback
478
479 const forced = text('provider', 'auto')
480
481 // TypeSafe's own API is preferred when both keys are set: it is the only
482 // one that reports a calibrated confidence. `provider` forces one,
483 // including "builtin" to use neither. A key option wins; with none, the
484 // key's environment variable (TYPESAFE_API_KEY, AI_GATEWAY_API_KEY) is
485 // read at the first hook, since only `$` can read the environment; the
486 // first answer stands for the process.
487 let backend: Backend | null = null
488 const backendOf = (typesafeEnv: string | undefined, gatewayEnv: string | undefined): Backend => {
489 if (backend) return backend
490 const typesafeKey = text('typesafeApiKey', '') || (typesafeEnv ?? '').trim()
491 const gatewayKey = text('gatewayApiKey', '') || (gatewayEnv ?? '').trim()
492 const active = selectProvider(forced, typesafeKey, gatewayKey)
493 // Each backend keeps its own URL and model, so an override written for one
494 // can never be sent to the other when `auto` picks differently than expected.
495 backend = {
496 active,
497 apiKey: active === 'typesafe' ? typesafeKey : active === 'gateway' ? gatewayKey : '',
498 modelId: !active
499 ? ''
500 : active === 'typesafe'
501 ? text('typesafeModel', DEFAULT_MODEL.typesafe)
502 : text('gatewayModel', DEFAULT_MODEL.gateway),
503 url: !active
504 ? ''
505 : active === 'typesafe'
506 ? endpoint('typesafe', text('typesafeBaseUrl', DEFAULT_BASE_URL.typesafe))
507 : endpoint('gateway', text('gatewayBaseUrl', DEFAULT_BASE_URL.gateway)),
508 }
509 return backend
510 }
511
512 // The listing stays by default: `name-only` overrides (what the setup
513 // writes) already take the descriptions out, and a listed name is what lets
514 // the model load a skill another skill names.
515 const hideListing = flag('hideListing', false)
516 // "content": the mod reads the chosen skill's SKILL.md and attaches it, so
517 // the skill loads even when the engine lists it as user-invocable-only or
518 // off. "suggest": the cookbook's block alone, and the model loads the skill
519 // with the Skill tool, which honours the engine's skillOverrides.
520 const injectContent = text('inject', 'content') !== 'suggest'
521 const alwaysListed = parseNames(text('alwaysListed', ''))
522 const neverSuggested = parseNames(text('neverSuggested', ''))
523 const rerankEnabled = flag('rerank', true)
524 const excerptChars = number('excerptChars', 700)
525 const timeoutMs = number('timeoutMs', 800)
526 // The second request carries each shortlisted SKILL.md's opening and one
527 // question per candidate, so it gets its own, longer budget.
528 const rerankTimeoutMs = number('rerankTimeoutMs', 2500)
529 const logDecisions = flag('logDecisions', true)
530 const recordDecisions = flag('recordDecisions', true)
531 const sendRecentContext = flag('sendRecentContext', true)
532 const findSkill = flag('findSkill', true)
533 const policy: PolicyConfig = {
534 shortlist: Math.max(1, Math.round(number('shortlist', 3))),
535 gateThreshold: number('gateThreshold', 0.3),
536 fitsThreshold: number('fitsThreshold', 0.5),
537 }
538
539 // The names every skill_listing attachment carried so far. Once non-empty,
540 // only these are offered to the decision model: the listing is the engine's
541 // word on which skills the model is allowed to invoke, and `$.command.list()`
542 // also names commands the model may not.
543 const listed = new Set<string>()
544 // The skill suggested for the current prompt, so a skill.prompt that loads
545 // it can be told apart from one the model reached for on its own.
546 let suggested: string | null = null
547 // Each skill's SKILL.md as first found, with where, or null when nowhere:
548 // read once per session, since the second request wants it on every prompt
549 // it is on, and the injection wants it whole.
550 const files = new Map<string, SkillFile | null>()
551 // The skills whose instructions were already attached this session: a
552 // second time, the block only names the skill again.
553 const injected = new Set<string>()
554 // Said once, the first time a hook runs. A mod that loaded and one that
555 // never loaded are otherwise told apart only by the absence of later lines,
556 // and absence is not evidence: with the listing gone, silence is the norm.
557 let announced = false
558 // The setup hint, once per session.
559 let hintedSetup = false
560
561 const state: ChooseContext = {
562 backendOf,
563 forced,
564 files,
565 neverSuggested,
566 logDecisions,
567 recordDecisions,
568 sendRecentContext,
569 timeoutMs,
570 rerankTimeoutMs,
571 rerankEnabled,
572 excerptChars,
573 policy,
574 // A backend named in the options but missing its key degrades to the
575 // built-in classifier, which is silent; say so once, when a hook first runs.
576 unusableReported: forced === 'auto' || forced === 'builtin',
577 // A skill whose frontmatter `name:` has spaces ("PocketBase API Rules") is
578 // reported by `$.command.list()` under that name, but the engine lists,
579 // runs and overrides it by its directory name (`pb-api-rules`). The map
580 // from one to the other is read from disk once per session, in whichever
581 // hook first needs it.
582 displayToId: null,
583 records: new Map(),
584 }
585
586 on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
587 if (!announced) {
588 announced = true
589 const { active, url } = backendOf(await $.env.get('TYPESAFE_API_KEY'), await $.env.get('AI_GATEWAY_API_KEY'))
590 if (logDecisions) {
591 $.ui.log(`[jev-skill-suggestion] ${describeSetup(active, url, hideListing, forced === 'builtin')}`)
592 }
593 }
594
595 const skills = parseListing(e.text)
596 // A subagent's listing may name skills the main conversation's does not.
597 if (!e.agentId) for (const skill of skills) listed.add(skill.name)
598
599 // A listed skill that still brings its whole description is context the
600 // setup command would trim to the name: say so once.
601 // ponytail: a `prefix:name` is taken for a plugin's skill, which
602 // skillOverrides cannot trim; read `$.command.list()` sources if a
603 // colon-named user skill ever shows up.
604 const described = skills.filter((skill) => skill.description && !skill.name.includes(':')).length
605 if (!hintedSetup && described > 0 && !e.agentId) {
606 hintedSetup = true
607 if (logDecisions) $.ui.log(`[jev-skill-suggestion] ${describeStillListed(described)}`)
608 }
609
610 // A subagent's listing is not ours: nothing here suggests for a subagent,
611 // so hiding its listing would leave it with no skills at all.
612 if (!hideListing || e.agentId) return next(e)
613
614 const kept = trimListing(e.text, alwaysListed)
615 if (logDecisions) {
616 const keptNames = kept
617 ? parseListing(kept)
618 .map((skill) => skill.name)
619 .join(', ')
620 : 'none'
621 $.ui.log(
622 `[jev-skill-suggestion] withheld the skill listing (${skills.length} skills, ${e.text.length} characters); kept listed: ${keptNames}`,
623 )
624 }
625 // Answered without `next`: the engine's text never reaches the model.
626 return { text: kept }
627 })
628
629 on('prompt.submit', async ($, e, next) => {
630 if (!announced) {
631 announced = true
632 const { active, url } = backendOf(await $.env.get('TYPESAFE_API_KEY'), await $.env.get('AI_GATEWAY_API_KEY'))
633 if (logDecisions) {
634 $.ui.log(`[jev-skill-suggestion] ${describeSetup(active, url, hideListing, forced === 'builtin')}`)
635 }
636 }
637 suggested = null
638
639 // Notifications and peer messages are not tasks; a typed `/name` already
640 // names its skill; "ok" or "繼續" carries no task the prompt alone shows.
641 // None of them gets a suggestion.
642 if (!e.text.trim() || /^\/\S/.test(e.text.trim())) return next(e)
643 if (e.origin && NOT_A_TASK.has(e.origin.kind)) return next(e)
644 if (contextFree(e.text)) {
645 if (logDecisions) $.ui.log('[jev-skill-suggestion] no suggestion: a reply with no task of its own')
646 return next(e)
647 }
648
649 // Content mode loads the skill itself, so it is not bound to what the
650 // engine lists; "suggest" leaves loading to the Skill tool, which is.
651 const context = await contextFor($, state, e.text)
652 const choice = await choose($, state, e.text, { restrictTo: injectContent ? new Set() : listed, askGate: true, context })
653 if (!choice) return next(e)
654 const { pick, file } = choice
655 await record($, state, { source: 'prompt', text: e.text, context, pick: pick?.name ?? null, reason: choice.reason, answered: choice.answered, trace: choice.trace })
656 // A row in the transcript scrolls away; this line stays on screen.
657 if (logDecisions) $.ui.status(describeStatus(pick?.name ?? null))
658 if (logDecisions) {
659 $.ui.log(
660 pick
661 ? `[jev-skill-suggestion] suggesting /${pick.name}: ${choice.reason}`
662 : `[jev-skill-suggestion] no suggestion: ${choice.reason}`,
663 )
664 }
665
666 suggested = pick?.name ?? null
667 let block: string | null
668 if (injectContent && pick) {
669 const projectDir = await $.session.cwd()
670 block = injectionBlock(pick, file?.markdown ?? null, file?.path ?? null, projectDir, injected.has(pick.name))
671 if (logDecisions) {
672 $.ui.log(
673 file
674 ? injected.has(pick.name)
675 ? `[jev-skill-suggestion] /${pick.name} already injected this session; named again`
676 : `[jev-skill-suggestion] injected /${pick.name} from ${file.path} (${file.markdown.length} characters)`
677 : `[jev-skill-suggestion] no file found for /${pick.name}; suggested by name only`,
678 )
679 }
680 if (file) injected.add(pick.name)
681 } else if (injectContent) {
682 // Content mode with nothing picked says nothing: "no skill is relevant"
683 // would talk the model out of loading one from the listing on its own.
684 block = null
685 } else {
686 block = suggestionBlock(pick, hideListing)
687 }
688 if (!block) return next(e)
689 // Attached on the way down: one block after the prompt as typed, read by
690 // the model and never shown to the person.
691 return next({ ...e, context: [...(e.context ?? []), block] })
692 })
693
694 // An injected skill lives in the conversation, not the process: `/clear`
695 // or a resume starts another under the same worker, and a compaction may
696 // summarize the block away. Either way the next pick goes in whole again.
697 on('session.end', async ($, e, next) => {
698 injected.clear()
699 suggested = null
700 return next(e)
701 })
702 on('session.compact', async ($, e, next) => {
703 if (!e.agentId) injected.clear()
704 return next(e)
705 })
706
707 // find_skill: the prompt's suggestion is made once, from the prompt alone;
708 // a sub-task that turns up mid-turn is the model's to look up. Registered
709 // at session.start so the tool list, and the prompt cache, stay put; the
710 // full name is the engine's (it depends on how the plugin was loaded).
711 let findSkillName = ''
712 on('session.start', async ($, e, next) => {
713 // Without a backend that reranks, nothing would check the ranking's pick,
714 // and the gate is left out: the tool is not offered.
715 const vetted = backendOf(await $.env.get('TYPESAFE_API_KEY'), await $.env.get('AI_GATEWAY_API_KEY')).active && rerankEnabled
716 if (findSkill && !vetted && logDecisions) {
717 $.ui.log('[jev-skill-suggestion] find_skill not offered: it needs a TypeSafe or Gateway key and rerank on')
718 }
719 if (findSkill && vetted) {
720 try {
721 findSkillName = (await $.tool.register(FIND_SKILL_TOOL)).tool
722 } catch (error) {
723 $.ui.log(`[jev-skill-suggestion] could not register find_skill: ${String(error)}`)
724 }
725 }
726 return next(e)
727 })
728 // Left to the engine, a plugin's tool waits behind ToolSearch, named but
729 // without its description: the model would have to guess at it, which is
730 // the very thing the tool is for.
731 on('tool.describe', async ($, e, next) => {
732 const out = await next(e)
733 return findSkillName && e.tool === findSkillName ? { ...out, isDeferred: false } : out
734 })
735 on('tool.call', async ($, e, next) => {
736 if (!findSkillName || e.tool !== findSkillName) return next(e)
737 const task = 'task' in e && typeof e.task === 'string' ? e.task.trim() : ''
738 // The model loads the answer with the Skill tool, so only what the
739 // engine lists is offered; before the listing is seen, nothing is.
740 let answer: Skill | null | 'unavailable' = 'unavailable'
741 try {
742 const context = await contextFor($, state, null, e.agentId)
743 const choice = task && listed.size > 0 ? await choose($, state, task, { restrictTo: listed, askGate: false, context }) : null
744 if (choice?.answered) answer = choice.pick
745 if (choice) {
746 const { reason, answered, trace } = choice
747 await record($, state, { source: 'find_skill', text: task, context, pick: choice.pick?.name ?? null, reason, answered, trace, agentId: e.agentId })
748 }
749 } catch (error) {
750 $.ui.log(`[jev-skill-suggestion] find_skill failed: ${String(error)}`)
751 }
752 if (logDecisions) {
753 const outcome = answer === 'unavailable' ? 'lookup unavailable' : answer ? `suggesting /${answer.name}` : 'no suggestion'
754 $.ui.log(`[jev-skill-suggestion] find_skill${e.agentId ? ` (${e.agentId})` : ''} "${task}": ${outcome}`)
755 }
756 // The latest pick, prompt's or find_skill's, is what a skill.prompt that
757 // follows is measured against: without it, a skill loaded on
758 // find_skill's word would be logged as one nothing suggested.
759 if (answer && answer !== 'unavailable') suggested = answer.name
760 return { result: findSkillResult(answer) }
761 })
762
763 on('skill.prompt', { skill: 'jev-skill-suggestion:setup' }, async ($, e, next) => {
764 // The plugin's own setup command: its markdown is a placeholder, and the
765 // prompt the model reads is written here, from the roster as the engine
766 // has it and the user settings as they are. The model does the editing
767 // with its own tools, so the change shows as a diff and asks permission.
768 const mode = /\brestore\b/i.test(e.text) ? 'restore' : 'apply'
769 // No plan from a partial roster or unreadable settings: the edit would
770 // hide too little, and the backup would save the wrong values.
771 let commands: Awaited<ReturnType<typeof $.command.list>> = []
772 try {
773 commands = await listCommands($, state, await configDirFor($))
774 } catch (error) {
775 $.ui.log(`[jev-skill-suggestion] setup: could not list the skills: ${String(error)}`)
776 return next({ ...e, text: setupAborted(`the skills could not be listed (${String(error)})`) })
777 }
778 const configDir = configDirOf((await $.env.get('HOME')) ?? '~', await $.env.get('CLAUDE_CONFIG_DIR'))
779 const settingsPath = `${configDir}/settings.json`
780 const backupPath = `${configDir}/jev-skill-suggestion.skill-overrides.backup.json`
781 let json: string | null = null
782 try {
783 if (await $.fs.exists(settingsPath)) json = await $.fs.read(settingsPath)
784 } catch (error) {
785 $.ui.log(`[jev-skill-suggestion] setup: could not read ${settingsPath}: ${String(error)}`)
786 return next({ ...e, text: setupAborted(`${settingsPath} exists but could not be read (${String(error)})`) })
787 }
788 const settings = readSkillSettings(json)
789 const plan = setupPlan(commands, settings, new Set([SETUP_COMMAND]))
790 // An earlier run's backup is reused only if it is one: a file that is
791 // not this mod's, or is corrupt, is nothing restore could apply, so no
792 // setup is built on top of it.
793 let backupExists = false
794 try {
795 backupExists = await $.fs.exists(backupPath)
796 if (backupExists && !validBackup(await $.fs.read(backupPath))) {
797 $.ui.log(`[jev-skill-suggestion] setup: ${backupPath} is not a valid backup`)
798 return next({
799 ...e,
800 text: setupAborted(
801 `${backupPath} exists but is not a backup this mod wrote (expected {"skillOverrides": {...}, "disableBundledSkills": true|false|null}); ask the user to inspect it and move it away, or fix it, before running the setup again`,
802 ),
803 })
804 }
805 } catch (error) {
806 $.ui.log(`[jev-skill-suggestion] setup: could not read ${backupPath}: ${String(error)}`)
807 return next({ ...e, text: setupAborted(`${backupPath} could not be read (${String(error)})`) })
808 }
809 if (logDecisions) {
810 $.ui.log(
811 `[jev-skill-suggestion] setup (${mode}): ${plan.hide.length} to hide, ${plan.alreadyHidden.length} already hidden, ${plan.locked.length} locked by a plugin`,
812 )
813 }
814 return next({ ...e, text: setupInstructions(mode, plan, settings, settingsPath, backupPath, backupExists) })
815 })
816
817 on('skill.prompt', async ($, e, next) => {
818 // Observation only: whether the model took the suggestion, or reached for
819 // a skill it was never told about, is the one measure of this mod's worth.
820 if (logDecisions && e.skill !== SETUP_COMMAND) {
821 const how =
822 suggested === e.skill
823 ? 'as suggested'
824 : suggested
825 ? `suggested was /${suggested}`
826 : 'nothing was suggested'
827 $.ui.log(`[jev-skill-suggestion] skill /${e.skill} loaded (${how})`)
828 }
829 if (e.skill !== SETUP_COMMAND) await record($, state, { loaded: e.skill, suggested })
830 return next(e)
831 })
832}
833hooks/policy.ts 1117 lines1/**
2 * jev-skill-suggestion — pure decision logic.
3 *
4 * No `$` and no I/O here: this module reads the engine's skill listing, builds
5 * the two requests the decision API takes, reads their answers, and turns them
6 * into at most one skill name and the text that suggests it. The hooks module
7 * does every call on `$` at its own call site.
8 *
9 * The shape follows TypeSafe's "Skill suggestion" cookbook
10 * (https://docs.typesafe.ai/cookbooks/skill_suggestion):
11 *
12 * request 1 rank every skill (`which`, a Choice with each skill's one-line
13 * description as its criterion) and ask three Nouls about the
14 * request itself — whether it wants an action taken rather than
15 * an explanation given. Their mean is the gate.
16 * request 2 re-read the top few (`which` again, now with each skill's full
17 * description and the opening of its SKILL.md) and ask one Noul
18 * per candidate: does this skill do the specific thing asked?
19 * Every `fits` may come back low, and then nothing is suggested.
20 *
21 * Two backends speak to the same model with different wire shapes:
22 *
23 * typesafe POST https://api.typesafe.ai/v1/systemone
24 * `{ model, state, questions }`; a yes/no question is a `noul`
25 * and every answer carries its own `confidence`.
26 * gateway POST https://ai-gateway.vercel.sh/v4/ai/evaluation-model
27 * `{ state, questions }` with the model in a header; a yes/no
28 * question is a `boolean` answered as `probability`.
29 *
30 * The Gateway shape is not documented publicly; it was read from
31 * @ai-sdk/gateway and @ai-sdk/provider.
32 */
33
34export type Provider = 'typesafe' | 'gateway'
35
36/** One skill as the model could be told about it: its name and one line. */
37export interface Skill {
38 name: string
39 description: string
40}
41
42/** A shortlisted skill with the longer text the second request reads. */
43export interface Candidate extends Skill {
44 /** The full description and the opening of the skill's body, joined. */
45 detail: string
46}
47
48/** What the first request answered. */
49export interface Wide {
50 /** Every skill with the probability it was given, surest first. */
51 ranked: { name: string; probability: number | null }[]
52 /** The mean of the oriented gate nouls, or null when none was answered. */
53 gate: number | null
54 /** Each gate noul as answered, for the log. */
55 gateValues: Record<string, number>
56}
57
58/** What the second request answered. */
59export interface Rerank {
60 /** The candidate the Choice named. */
61 winner: string
62 /** Confidence in that choice, or null when the backend reported none. */
63 confidence: number | null
64 /** P(true) per candidate that it does the specific thing asked. */
65 fits: Record<string, number>
66}
67
68/** The label the built-in classifier answers when no skill applies. */
69export const NONE = 'none'
70
71export const DEFAULT_BASE_URL: Record<Provider, string> = {
72 typesafe: 'https://api.typesafe.ai',
73 gateway: 'https://ai-gateway.vercel.sh/v4/ai',
74}
75
76export const DEFAULT_MODEL: Record<Provider, string> = {
77 // Pinned, not `jev-latest`: an alias moves, and the thresholds are tuned
78 // against one version (evals/README.md).
79 typesafe: 'jev-1.14.0',
80 gateway: 'typesafe-ai/jev',
81}
82
83/**
84 * The Gateway's own protocol version, sent as `ai-gateway-protocol-version`:
85 * without it every request is refused with 400. Tracks the
86 * `AI_GATEWAY_PROTOCOL_VERSION` of `@ai-sdk/gateway` (4.0.87).
87 */
88const AI_GATEWAY_PROTOCOL_VERSION = '0.0.1'
89
90/**
91 * Which backend a configuration asks for, or null for the built-in
92 * classifier. `auto` prefers TypeSafe, since it is the only one that reports
93 * a calibrated confidence; a forced backend whose key is missing resolves to
94 * null rather than falling through to the other one's key.
95 */
96export function selectProvider(forced: string, typesafeKey: string, gatewayKey: string): Provider | null {
97 if (forced === 'builtin') return null
98 if (forced === 'typesafe') return typesafeKey ? 'typesafe' : null
99 if (forced === 'gateway') return gatewayKey ? 'gateway' : null
100 if (typesafeKey) return 'typesafe'
101 if (gatewayKey) return 'gateway'
102 return null
103}
104
105/** The full endpoint a backend posts to. */
106export function endpoint(provider: Provider, baseUrl: string): string {
107 const root = baseUrl.replace(/\/+$/, '')
108 return provider === 'typesafe' ? `${root}/v1/systemone` : `${root}/evaluation-model`
109}
110
111/** A comma-separated option as a set of trimmed, non-empty names. */
112export function parseNames(option: string): Set<string> {
113 return new Set(
114 option
115 .split(',')
116 .map((name) => name.trim())
117 .filter(Boolean),
118 )
119}
120
121/**
122 * Reads the engine's `skill_listing` attachment: a header line, then one
123 * `- name: description` per skill. A description may run over several lines,
124 * and an entry the engine trimmed to fit its budget has no description at
125 * all. Anything before the first entry is the header and is skipped.
126 */
127export function parseListing(text: string): Skill[] {
128 const skills: Skill[] = []
129 let current: Skill | null = null
130 for (const raw of text.split('\n')) {
131 const line = raw.trimEnd()
132 // A name has no spaces, so it runs up to the first `: `; a name that
133 // itself contains `:` (`engineering:code-review`) is kept whole.
134 const entry = /^- (\S+?)(?::\s(.*))?$/.exec(line)
135 if (entry) {
136 current = { name: entry[1] as string, description: (entry[2] ?? '').trim() }
137 skills.push(current)
138 } else if (current && line.trim()) {
139 current.description = `${current.description} ${line.trim()}`.trim()
140 }
141 }
142 return skills
143}
144
145/**
146 * The skills the decision model is offered: every command the person can run
147 * that the model may also call, less the built-ins (`/help`, `/clear`) and the
148 * names configured out. When the engine's listing has been seen, only skills
149 * it listed are offered: the listing is the engine's word on which skills the
150 * model is allowed to invoke.
151 */
152export function catalog(
153 commands: readonly { name: string; description: string; source: string }[],
154 listed: ReadonlySet<string>,
155 excluded: ReadonlySet<string>,
156): Skill[] {
157 const seen = new Set<string>()
158 const skills: Skill[] = []
159 for (const command of commands) {
160 if (command.source === 'builtin') continue
161 if (listed.size > 0 && !listed.has(command.name)) continue
162 if (excluded.has(command.name) || seen.has(command.name)) continue
163 seen.add(command.name)
164 skills.push({ name: command.name, description: command.description.trim() })
165 }
166 return skills
167}
168
169/**
170 * The listing with only `keep` left in it, or null when nothing is kept: what
171 * the model reads in place of the engine's full listing.
172 */
173export function trimListing(text: string, keep: ReadonlySet<string>): string | null {
174 if (keep.size === 0) return null
175 const kept = parseListing(text).filter((skill) => keep.has(skill.name))
176 if (kept.length === 0) return null
177 const header = text.split('\n').find((line) => line.trim() && !line.startsWith('- ')) ?? ''
178 return [header.trim(), '', ...kept.map(line)].join('\n')
179}
180
181function line(skill: Skill): string {
182 return skill.description ? `- ${skill.name}: ${skill.description}` : `- ${skill.name}`
183}
184
185/**
186 * Whether a skill or plugin name may be spliced into a path: the engine's
187 * names are directory basenames, so anything with a path separator or a
188 * `..` segment is refused rather than resolved.
189 */
190function safeName(name: string): boolean {
191 return name.length > 0 && !name.includes('/') && !name.includes('\\') && !name.split(':').includes('..')
192}
193
194/**
195 * The relative files a skill's body may live in, project or user level, by
196 * how Claude Code lays skills and commands out. A plugin's are under its
197 * install path instead, with the plugin prefix taken off the name.
198 */
199export function skillFileCandidates(name: string, plugin?: string): string[] {
200 if (!safeName(name) || (plugin !== undefined && !safeName(plugin))) return []
201 const short = plugin && name.startsWith(`${plugin}:`) ? name.slice(plugin.length + 1) : name
202 const asPath = short.replace(/:/g, '/')
203 const files = [
204 `.claude/skills/${short}/SKILL.md`,
205 `.claude/commands/${asPath}.md`,
206 `.claude/skills/${asPath}/SKILL.md`,
207 ]
208 if (plugin) {
209 // A plugin auto-loaded from a skills dir keeps its own `skills/` inside.
210 files.push(`.claude/skills/${plugin}/skills/${short}/SKILL.md`)
211 files.push(`.claude/skills/${plugin}/commands/${asPath}.md`)
212 }
213 return files
214}
215
216/** Whether a name is one the engine would run as `/name`: no whitespace. */
217export function commandLike(name: string): boolean {
218 return name.length > 0 && !/\s/.test(name)
219}
220
221/** The `name:` a SKILL.md's frontmatter declares, or null. */
222export function frontmatterName(markdown: string): string | null {
223 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
224 if (!frontmatter) return null
225 const field = /^name:\s*(.*)$/m.exec(frontmatter[1] as string)
226 if (!field) return null
227 const value = (field[1] as string).trim().replace(/^["']|["']$/g, '')
228 return value || null
229}
230
231/**
232 * Display name → directory name, from the skills found on disk: only the
233 * ones whose frontmatter `name:` differs from their directory, since those
234 * are the ones `$.command.list()` reports under a name the engine will not
235 * run, list or override. First found wins.
236 */
237export function displayIds(found: readonly { dir: string; markdown: string }[]): Map<string, string> {
238 const ids = new Map<string, string>()
239 for (const { dir, markdown } of found) {
240 const declared = frontmatterName(markdown)
241 if (declared && declared !== dir && !ids.has(declared)) ids.set(declared, dir)
242 }
243 return ids
244}
245
246/** Commands with every display name replaced by the engine's id. */
247export function canonical<T extends { name: string }>(commands: readonly T[], ids: ReadonlyMap<string, string>): T[] {
248 if (ids.size === 0) return [...commands]
249 return commands.map((command) => (ids.has(command.name) ? { ...command, name: ids.get(command.name) as string } : command))
250}
251
252/**
253 * A claude.ai-synced skill's file: Claude Code keeps them under
254 * `~/.claude/skills/synced/<account>/<skill>/SKILL.md`, listed to the model
255 * with a prefix (`anthropic-skills:pptx`) that is not on disk.
256 */
257export function syncedFileCandidates(configDir: string, accounts: readonly string[], name: string): string[] {
258 if (!safeName(name)) return []
259 const short = name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name
260 return accounts.filter(safeName).map((account) => `${configDir}/skills/synced/${account}/${short}/SKILL.md`)
261}
262
263/**
264 * Where Claude Code keeps the user's settings, skills and plugin records:
265 * `CLAUDE_CONFIG_DIR` when set, else `~/.claude`.
266 */
267export function configDirOf(home: string, override: string | undefined): string {
268 const dir = override?.trim()
269 return (dir ? dir : `${home}/.claude`).replace(/\/+$/, '')
270}
271
272/** A project-relative `.claude/...` file as it sits under the user's config dir. */
273export function underConfigDir(configDir: string, file: string): string {
274 return `${configDir}/${file.replace(/^\.claude\//, '')}`
275}
276
277/**
278 * The same files under a plugin's install path, as
279 * `~/.claude/plugins/installed_plugins.json` records it.
280 */
281export function pluginFileCandidates(installPath: string, name: string, plugin: string): string[] {
282 if (!safeName(name) || !safeName(plugin)) return []
283 const short = name.startsWith(`${plugin}:`) ? name.slice(plugin.length + 1) : name
284 const root = installPath.replace(/\/+$/, '')
285 return [`${root}/skills/${short}/SKILL.md`, `${root}/commands/${short.replace(/:/g, '/')}.md`]
286}
287
288/**
289 * The install paths `installed_plugins.json` records for a plugin name, any
290 * marketplace: the keys are `name@marketplace`.
291 */
292export function installPathsOf(installedJson: string, plugin: string): string[] {
293 let parsed: unknown
294 try {
295 parsed = JSON.parse(installedJson)
296 } catch {
297 return []
298 }
299 const plugins = (parsed as { plugins?: Record<string, { installPath?: string }[]> }).plugins
300 if (!plugins) return []
301 const paths: string[] = []
302 for (const [key, entries] of Object.entries(plugins)) {
303 if (key !== plugin && !key.startsWith(`${plugin}@`)) continue
304 for (const entry of entries ?? []) {
305 if (typeof entry?.installPath === 'string') paths.push(entry.installPath)
306 }
307 }
308 return paths
309}
310
311/**
312 * What the second request reads for one skill: its full frontmatter
313 * description, then the opening of its body with the frontmatter taken off.
314 * With no file found, the one-line description alone.
315 */
316export function detailOf(skill: Skill, markdown: string | null, excerptChars: number): string {
317 if (!markdown) return skill.description || `A skill named ${skill.name}.`
318 let body = markdown
319 let description = skill.description
320 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(markdown)
321 if (frontmatter) {
322 body = markdown.slice(frontmatter[0].length)
323 const field = /^description:\s*(.*)$/m.exec(frontmatter[1] as string)
324 if (field) {
325 const value = (field[1] as string).trim().replace(/^["']|["']$/g, '')
326 if (value.length > description.length) description = value
327 }
328 }
329 const excerpt = body.trim().slice(0, Math.max(0, excerptChars))
330 if (!excerpt) return description || `A skill named ${skill.name}.`
331 return description ? `${description} — ${excerpt}` : excerpt
332}
333
334/**
335 * Whether a skill's own frontmatter lets the model invoke it. A skill with
336 * `disable-model-invocation: true` is left out of the engine's listing and
337 * refused by the Skill tool, so it must never be suggested; before the
338 * listing has been seen, this is the only way to tell. No file, or no such
339 * field, reads as invocable.
340 */
341/** A skill's body with its frontmatter taken off. */
342export function bodyOf(markdown: string): string {
343 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(markdown)
344 return (frontmatter ? markdown.slice(frontmatter[0].length) : markdown).trim()
345}
346
347/** The directory a skill file lives in, for `${CLAUDE_SKILL_DIR}`. */
348export function dirOf(path: string): string {
349 const cut = path.lastIndexOf('/')
350 return cut > 0 ? path.slice(0, cut) : '.'
351}
352
353/**
354 * The block attached to the prompt when the mod loads the skill itself: the
355 * cookbook's line, then the skill's instructions as the engine would have
356 * rendered them, with the paths the engine substitutes. The Skill tool is
357 * not needed, so the model is told not to load it a second time. A skill already injected earlier in
358 * the session is only named again: the engine does the same on a byte-identical
359 * re-invocation.
360 */
361export function injectionBlock(
362 suggested: Skill,
363 markdown: string | null,
364 path: string | null,
365 projectDir: string,
366 alreadyLoaded: boolean,
367): string {
368 const lines = [
369 '<skill_relevance>',
370 `Relevant to the current request: ${suggested.name}. Ignore this if it does not fit what the user actually asked for.`,
371 ]
372 if (alreadyLoaded) {
373 lines.push(`Skill /${suggested.name} is already loaded above; instructions unchanged.`)
374 } else if (markdown && path) {
375 const dir = dirOf(path)
376 // Callbacks, so a path holding `$&` or `$1` goes in verbatim.
377 const body = bodyOf(markdown)
378 .replace(/\$\{CLAUDE_SKILL_DIR\}/g, () => dir)
379 .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, () => projectDir)
380 lines.push(
381 `Its instructions follow: follow them now, including any setup steps. Do not load it again with the Skill tool: it is already loaded here. Its files are in ${dir}.`,
382 `<skill name="${suggested.name}" dir="${dir}">`,
383 body,
384 '</skill>',
385 )
386 } else {
387 // No file on disk (a synced or bundled skill the mod cannot read): the
388 // name and the way to load it are all there is.
389 lines.push(
390 `${line(suggested)}`,
391 `Load it with the Skill tool (skill: "${suggested.name}") before you start; if the tool refuses it, tell the user to type /${suggested.name}.`,
392 )
393 }
394 lines.push('</skill_relevance>')
395 return lines.join('\n')
396}
397
398export function modelInvocable(markdown: string | null): boolean {
399 if (!markdown) return true
400 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
401 if (!frontmatter) return true
402 return !/^disable-model-invocation:\s*true\s*$/m.test(frontmatter[1] as string)
403}
404
405/**
406 * The three questions about the request, each asking a different way whether
407 * it wants an action taken rather than an explanation given. Questions about
408 * subject matter would not separate "explain what a monad is" from a task
409 * that needs a skill, since both are software.
410 */
411export const GATE_QUESTIONS: Record<string, string> = {
412 acts_on_user_system:
413 "Is the assistant being asked to act on the user's files, accounts, devices, or online services, rather than only to explain or advise?",
414 would_follow_documented_procedure:
415 'Would a careful expert answering this consult a specific documented procedure or set of commands, rather than answering from general understanding?',
416 prose_suffices:
417 "Could a knowledgeable generalist fully satisfy this request in prose, with no tools, no documentation, and no access to the user's files or accounts?",
418 names_its_task:
419 'Does this message on its own say what is to be done, specifically enough to start without reading any earlier messages?',
420}
421
422/** Gate questions where a yes points away from needing a skill. */
423const INVERTED = new Set(['prose_suffices'])
424
425/** The same yes/no question under two names. */
426function yesNo(provider: Provider, instructions: string | Record<string, unknown>): Record<string, unknown> {
427 return { type: provider === 'typesafe' ? 'noul' : 'boolean', instructions }
428}
429
430/** The first request's `questions`: the ranking, and the gate unless `askGate` is off. */
431export function wideQuestions(
432 provider: Provider,
433 skills: readonly Skill[],
434 { askGate = true }: { askGate?: boolean } = {},
435): Record<string, unknown> {
436 const criteria: Record<string, string> = {}
437 for (const skill of skills) criteria[skill.name] = skill.description || `A skill named ${skill.name}.`
438 const questions: Record<string, unknown> = {
439 which: {
440 type: 'choice',
441 instructions:
442 "Which of these skills, if any, is the right one to load to help with the user's latest request?",
443 criteria,
444 },
445 }
446 if (!askGate) return questions
447 for (const [key, text] of Object.entries(GATE_QUESTIONS)) {
448 questions[`gate::${key}`] = yesNo(provider, text)
449 }
450 return questions
451}
452
453/**
454 * Does this candidate do the thing asked? Structured instructions are
455 * TypeSafe's documented form; the Gateway's shape is read from its SDK, so it
456 * keeps the plain string.
457 */
458function fitsInstructions(provider: Provider, candidate: Candidate): string | Record<string, unknown> {
459 const description = candidate.description || candidate.detail
460 if (provider === 'gateway') {
461 return `Does the skill '${candidate.name}' do the specific thing the user's request asks for? It is described as: ${description}`
462 }
463 return { question: 'Does `skill` do the specific thing the request asks for?', skill: { name: candidate.name, description } }
464}
465
466/** The second request's `questions`: the shortlist re-read, one `fits` each. */
467export function rerankQuestions(
468 provider: Provider,
469 candidates: readonly Candidate[],
470): Record<string, unknown> {
471 const criteria: Record<string, string> = {}
472 for (const candidate of candidates) criteria[candidate.name] = candidate.detail
473 const questions: Record<string, unknown> = {
474 which: {
475 type: 'choice',
476 instructions:
477 "Exactly one of these skills is the right one to load for the user's latest request. Which one? Read what each actually does, not just its name.",
478 criteria,
479 },
480 }
481 for (const candidate of candidates) {
482 questions[`fits::${candidate.name}`] = yesNo(provider, fitsInstructions(provider, candidate))
483 }
484 return questions
485}
486
487/** The turn before the request, as the decision model reads it beside the request. */
488export interface RecentContext {
489 previous_user_message: string
490 assistant_reply: string
491}
492
493/**
494 * The turn before `current` in a transcript: the last user message before it
495 * and the assistant's last reply, each cut short, or null when there is none.
496 * A follow-up such as "查無資料ㄟ 為什麼??" carries its task in that turn, and
497 * the decision model otherwise reads the request alone. Mid-turn (find_skill),
498 * `current` is null: the context is the prompt being worked on and the reply
499 * so far.
500 */
501export function recentContextOf(
502 messages: readonly { role: 'user' | 'assistant'; text: string }[],
503 current: string | null,
504): RecentContext | null {
505 const said = messages.filter((message) => message.text.trim())
506 // The prompt being decided may already be in the transcript.
507 const last = said.at(-1)
508 if (current !== null && last?.role === 'user' && last.text.trim() === current.trim()) said.pop()
509 const at = said.findLastIndex((message) => message.role === 'user')
510 const user = at < 0 ? '' : (said[at] as { text: string }).text.trim()
511 const reply = said.slice(at + 1).findLast((message) => message.role === 'assistant')?.text.trim() ?? ''
512 if (!user && !reply) return null
513 return { previous_user_message: user.slice(0, 400), assistant_reply: reply.slice(-600) }
514}
515
516/** A request body. The Gateway carries the model in a header instead. */
517export function requestBody(
518 provider: Provider,
519 prompt: string,
520 questions: Record<string, unknown>,
521 model: string,
522 context: RecentContext | null = null,
523): string {
524 const state = { request: prompt, recent_context: context ?? '' }
525 const body = provider === 'typesafe' ? { model, state, questions } : { state, questions }
526 return JSON.stringify(body)
527}
528
529/** The request headers. */
530export function requestHeaders(provider: Provider, apiKey: string, model: string): Record<string, string> {
531 const common = { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }
532 if (provider === 'typesafe') return common
533 return {
534 ...common,
535 'ai-gateway-auth-method': 'api-key',
536 'ai-model-id': model,
537 'ai-gateway-protocol-version': AI_GATEWAY_PROTOCOL_VERSION,
538 'ai-evaluation-model-specification-version': '4',
539 }
540}
541
542type Answers = Record<string, Record<string, unknown>>
543
544function answersOf(responseText: string): Answers | null {
545 let parsed: unknown
546 try {
547 parsed = JSON.parse(responseText)
548 } catch {
549 return null
550 }
551 const answers = (parsed as { answers?: Answers }).answers
552 return answers && typeof answers === 'object' ? answers : null
553}
554
555/** P(true) of a yes/no answer: `noul` on TypeSafe, `probability` on the Gateway. */
556function yesNoOf(answer: Record<string, unknown> | undefined): number | null {
557 if (!answer) return null
558 if (typeof answer.noul === 'number') return answer.noul
559 if (typeof answer.probability === 'number') return answer.probability
560 return null
561}
562
563/** The model version a response says answered it (`jev-latest` resolves to one), or null. */
564export function modelOf(responseText: string): string | null {
565 try {
566 const model = (JSON.parse(responseText) as { model?: unknown }).model
567 return typeof model === 'string' ? model : null
568 } catch {
569 return null
570 }
571}
572
573/**
574 * Reads the first request's answer. The ranking is the Choice's probability
575 * distribution, surest first; a backend that sends none ranks the named
576 * choice alone, at the reported confidence or none. The gate is the mean of
577 * the oriented nouls that were answered, or null when none was.
578 */
579export function readWide(responseText: string): Wide | null {
580 const answers = answersOf(responseText)
581 const which = answers?.which
582 if (!answers || !which || typeof which.choice !== 'string') return null
583
584 const probabilities = which.probabilities as Record<string, number> | undefined
585 const ranked: Wide['ranked'] = []
586 if (probabilities) {
587 for (const [name, probability] of Object.entries(probabilities)) {
588 if (typeof probability === 'number') ranked.push({ name, probability })
589 }
590 ranked.sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0))
591 }
592 if (ranked.length === 0) {
593 ranked.push({
594 name: which.choice,
595 probability: typeof which.confidence === 'number' ? which.confidence : null,
596 })
597 }
598
599 const gateValues: Record<string, number> = {}
600 const oriented: number[] = []
601 for (const key of Object.keys(GATE_QUESTIONS)) {
602 const value = yesNoOf(answers[`gate::${key}`])
603 if (value === null) continue
604 gateValues[key] = value
605 oriented.push(INVERTED.has(key) ? 1 - value : value)
606 }
607 const gate = oriented.length > 0 ? oriented.reduce((a, b) => a + b, 0) / oriented.length : null
608 return { ranked, gate, gateValues }
609}
610
611/** Reads the second request's answer. */
612export function readRerank(responseText: string): Rerank | null {
613 const answers = answersOf(responseText)
614 const which = answers?.which
615 if (!answers || !which || typeof which.choice !== 'string') return null
616 const fits: Record<string, number> = {}
617 for (const [key, answer] of Object.entries(answers)) {
618 if (!key.startsWith('fits::')) continue
619 const value = yesNoOf(answer)
620 if (value !== null) fits[key.slice('fits::'.length)] = value
621 }
622 return {
623 winner: which.choice,
624 confidence: typeof which.confidence === 'number' ? which.confidence : null,
625 fits,
626 }
627}
628
629/** The first request's answer as the built-in classifier can give it: one label, no gate. */
630export function builtinWide(label: string | undefined): Wide | null {
631 if (!label) return null
632 return {
633 ranked: label === NONE ? [] : [{ name: label, probability: null }],
634 gate: null,
635 gateValues: {},
636 }
637}
638
639/**
640 * The text the built-in classifier reads: the prompt and the catalog it must
641 * choose from, since `$.model.classify` takes bare labels and the descriptions
642 * are the whole point.
643 */
644export function classifyText(prompt: string, skills: readonly Skill[]): string {
645 return [
646 'Which skill, going by its description, should be loaded before working on the prompt below? Answer "none" unless the prompt is clearly the kind of task a description names.',
647 '',
648 'Skills:',
649 ...skills.map(line),
650 `- ${NONE}: no listed skill is about this prompt`,
651 '',
652 'Prompt:',
653 prompt,
654 ].join('\n')
655}
656
657/**
658 * Replies that carry no task of their own: a greeting, an acknowledgement, a
659 * go-ahead, a bare number picking an option. The decision model reads the
660 * prompt alone, with no conversation, so ranking these only guesses; they get
661 * no suggestion, and the model keeps whatever skill the task already loaded.
662 */
663const CONTEXT_FREE = new Set([
664 'hi', 'hello', 'hey', 'thanks', 'thank you', 'thx', 'ok', 'okay', 'k', 'yes', 'y', 'no', 'n', 'sure',
665 'go', 'go ahead', 'continue', 'proceed', 'next', 'done', 'lgtm', 'sounds good', 'do it',
666 '好', '好的', '好啊', '好喔', '好哦', '可以', '是', '是的', '對', '對的', '嗯', '恩', '謝謝', '感謝',
667 '繼續', '继续', '下一步', '做吧', '好做吧', '開始', '开始', '沒問題', '没问题', '不用', '不要', 'ok啦', '可',
668])
669
670/** Whether a prompt is such a reply: no letters at all, or a stock phrase. */
671export function contextFree(text: string): boolean {
672 const trimmed = text.trim().toLowerCase()
673 if (!/\p{L}/u.test(trimmed)) return true
674 return CONTEXT_FREE.has(trimmed.replace(/[\s!.,?~。!,?~、]+$/u, '').replace(/\s+/g, ' '))
675}
676
677export interface PolicyConfig {
678 /** How many of the ranking the second request re-reads. */
679 shortlist: number
680 /** The gate mean under which nothing is suggested. */
681 gateThreshold: number
682 /** The best `fits` under which the whole shortlist is dropped. */
683 fitsThreshold: number
684}
685
686/** The shortlist the second request reads: the top of the ranking, by name. */
687export function shortlistOf(wide: Wide, skills: readonly Skill[], count: number): Skill[] {
688 const byName = new Map(skills.map((skill) => [skill.name, skill]))
689 const picked: Skill[] = []
690 for (const entry of wide.ranked) {
691 const skill = byName.get(entry.name)
692 if (skill) picked.push(skill)
693 if (picked.length >= count) break
694 }
695 return picked
696}
697
698/** Whether the first request's answer is worth a second look at all. */
699export function passesGate(wide: Wide, config: PolicyConfig): boolean {
700 return wide.gate === null || wide.gate >= config.gateThreshold
701}
702
703export interface Suggestion {
704 /** The one skill to suggest, or null for "nothing here applies". */
705 name: string | null
706 /** Why, for the log line. */
707 reason: string
708}
709
710/**
711 * At most one skill name for a request. With the second request switched
712 * off, the top of the ranking stands, which is what the first request alone
713 * can say; a second request that was attempted and failed suggests nothing.
714 */
715export function decide(
716 wide: Wide | null,
717 rerank: Rerank | null,
718 skills: readonly Skill[],
719 config: PolicyConfig,
720 /**
721 * Whether a second request was made: with one attempted and no answer,
722 * nothing is suggested, since the first request's winner has not had its
723 * false-positive check. Off means the second request was never meant to
724 * run, and the top of the ranking is the whole answer.
725 */
726 rerankAttempted = false,
727): Suggestion {
728 if (!wide) return { name: null, reason: 'no answer' }
729 if (!passesGate(wide, config)) {
730 return {
731 name: null,
732 reason: `needs a skill ${(wide.gate as number).toFixed(2)} < ${config.gateThreshold}`,
733 }
734 }
735 const shortlist = shortlistOf(wide, skills, config.shortlist)
736 if (shortlist.length === 0) return { name: null, reason: 'nothing ranked' }
737
738 if (!rerank && rerankAttempted) return { name: null, reason: 'rerank gave no answer; no suggestion' }
739
740 if (rerank) {
741 const values = Object.values(rerank.fits)
742 const best = values.length > 0 ? Math.max(...values) : null
743 if (best !== null && best < config.fitsThreshold) {
744 return { name: null, reason: `nothing fits, best ${best.toFixed(2)} < ${config.fitsThreshold}` }
745 }
746 if (shortlist.some((skill) => skill.name === rerank.winner)) {
747 const fit = rerank.fits[rerank.winner]
748 return {
749 name: rerank.winner,
750 reason: `rerank of ${shortlist.length}${fit === undefined ? '' : `, fits ${fit.toFixed(2)}`}`,
751 }
752 }
753 return { name: null, reason: `rerank named ${rerank.winner}, not on the shortlist` }
754 }
755
756 const top = shortlist[0] as Skill
757 const probability = wide.ranked.find((entry) => entry.name === top.name)?.probability ?? null
758 return {
759 name: top.name,
760 reason: `top of ${wide.ranked.length}${probability === null ? '' : ` (${probability.toFixed(2)})`}, no rerank`,
761 }
762}
763
764/**
765 * The block attached to the prompt, in the cookbook's words. It can be
766 * ignored, because pushing harder wins compliance on wrong suggestions too.
767 * When the listing is still in place, a turn with nothing to suggest says so:
768 * sending nothing would leave "err on the side of loading" unopposed. With
769 * the listing withheld there is nothing to oppose, so nothing is sent.
770 */
771export function suggestionBlock(suggested: Skill | null, hidden: boolean): string | null {
772 if (!suggested) {
773 return hidden
774 ? null
775 : '<skill_relevance>\nNo skill in the roster appears relevant to this request.\n</skill_relevance>'
776 }
777 const lines = [
778 '<skill_relevance>',
779 `Relevant to the current request: ${suggested.name}. Ignore this if it does not fit what the user actually asked for.`,
780 ]
781 if (hidden) {
782 // The model has no listing to look the name up in, so the line it would
783 // have found there and the way to load it come along.
784 lines.push(
785 `${line(suggested)}`,
786 'Load it with the Skill tool (skill: "' +
787 suggested.name +
788 '") before you start. The full skill listing is withheld from your context; the user can invoke any skill by typing /name.',
789 )
790 }
791 lines.push('</skill_relevance>')
792 return lines.join('\n')
793}
794
795/**
796 * The tool the model calls mid-turn, when a sub-task starts that the prompt's
797 * own suggestion did not cover. Worded as facts, not orders: text that reads
798 * like a command can trip the model's prompt-injection defences.
799 */
800export const FIND_SKILL_TOOL = {
801 name: 'find_skill',
802 description:
803 'Finds which installed skill fits a sub-task. The skill listing names most skills without a description; ' +
804 'this tool reads their descriptions and SKILL.md and answers with at most one skill, to load with the Skill tool, or none. ' +
805 'Call it when a new sub-task starts partway through the work (the next step of a plan, a new kind of file, tool or service), ' +
806 'and before you hand the user something whose shape a team may have rules for: a query or command for them to run, ' +
807 'a document, a diagram, a config. Skip it when a loaded skill already covers the step. Pass the sub-task in one sentence.',
808 inputSchema: {
809 type: 'object',
810 properties: { task: { type: 'string', description: 'The sub-task, in one sentence.' } },
811 required: ['task'],
812 },
813} as const
814
815/** What the requests answered for one decision, kept for the record file. */
816export interface Trace {
817 wide: Wide | null
818 rerank: Rerank | null
819 ms: number
820 /** The model version that answered, so a shift in scores can be told from an alias moving. */
821 model: string | null
822}
823
824/** One decision as the record file keeps it. */
825export interface Decision {
826 /** Where the text came from: the person's prompt, or the model's find_skill. */
827 source: 'prompt' | 'find_skill'
828 text: string
829 /** The turn before, as sent beside the text. */
830 context: RecentContext | null
831 pick: string | null
832 reason: string
833 /** False when a request failed or timed out. */
834 answered: boolean
835 trace: Trace | null
836 /** The subagent that called find_skill, when one did. */
837 agentId?: string
838}
839
840/**
841 * One line of the session's record file (JSON Lines): a decision, with the
842 * ranking's top five, so a wrong pick can be read back and replayed later; or
843 * a skill the model loaded, with the pick it was made under.
844 */
845export function recordLine(at: number, entry: Decision | { loaded: string; suggested: string | null }): string {
846 const time = new Date(at).toISOString()
847 if ('loaded' in entry) return `${JSON.stringify({ at: time, ...entry })}\n`
848 const { trace, ...rest } = entry
849 return `${JSON.stringify({
850 at: time,
851 ...rest,
852 ms: trace?.ms ?? 0,
853 model: trace?.model ?? null,
854 gate: trace?.wide?.gate ?? null,
855 gateValues: trace?.wide?.gateValues ?? {},
856 top: trace?.wide?.ranked.slice(0, 5) ?? [],
857 rerank: trace?.rerank ? { winner: trace.rerank.winner, confidence: trace.rerank.confidence, fits: trace.rerank.fits } : null,
858 })}\n`
859}
860
861/**
862 * What `find_skill` answers: the pick with the description the listing left
863 * out, a plain "none", or that the lookup failed, which is not a "none". Never
864 * a SKILL.md: the model loads the skill with the Skill tool, which re-attaches
865 * it after a compaction.
866 */
867export function findSkillResult(pick: Skill | null | 'unavailable'): string {
868 if (pick === 'unavailable') return 'The skill lookup is unavailable right now (it failed or timed out); the skill listing still names every skill.'
869 if (!pick) return 'No installed skill fits this sub-task.'
870 return [
871 line(pick),
872 `Load it with the Skill tool (skill: "${pick.name}") if it fits the sub-task; ignore it if it does not.`,
873 ].join('\n')
874}
875
876/** A number for the log, or `n/d` when the backend reported none. */
877function reported(value: number | null): string {
878 return value === null ? 'n/d' : value.toFixed(2)
879}
880
881/**
882 * The one-time line that says the mod is alive, which backend answers it,
883 * and whether the listing is being withheld.
884 */
885export function describeSetup(
886 provider: Provider | null,
887 url: string,
888 hideListing: boolean,
889 builtinByChoice = false,
890): string {
891 const backend = provider
892 ? `${provider} (${url})`
893 : builtinByChoice
894 ? 'the built-in classifier, by choice'
895 : 'the built-in classifier, no key set'
896 const listing = hideListing ? 'withholding the skill listing' : 'leaving the skill listing in place'
897 return `ready on ${backend}; ${listing}`
898}
899
900/** What the first request answered: the gate and the top of the ranking. */
901export function describeWide(wide: Wide | null, candidates: number, ms: number | null): string {
902 const took = ms === null ? '' : ` · ${Math.round(ms)}ms`
903 if (!wide) return `no answer from ${candidates} candidates${took}`
904 const top = wide.ranked
905 .slice(0, 3)
906 .map((entry) => `${entry.name} (${reported(entry.probability)})`)
907 .join(', ')
908 return `needs a skill ${reported(wide.gate)} · top of ${candidates}: ${top || 'none'}${took}`
909}
910
911/** What the second request answered: the winner and every `fits`. */
912export function describeRerank(rerank: Rerank | null, ms: number | null): string {
913 const took = ms === null ? '' : ` · ${Math.round(ms)}ms`
914 if (!rerank) return `rerank: no answer${took}`
915 const fits = Object.entries(rerank.fits)
916 .map(([name, value]) => `${name} ${value.toFixed(2)}`)
917 .join(', ')
918 return `rerank → ${rerank.winner} (${reported(rerank.confidence)}) · fits ${fits || 'n/d'}${took}`
919}
920
921/**
922 * The persistent status line: the last thing the mod did, short enough to
923 * sit on screen beside the engine's own notices.
924 */
925export function describeStatus(suggested: string | null): string {
926 return suggested ? `jev · skill: ${suggested}` : 'jev · no skill'
927}
928
929/** The name of the plugin's own setup command, as the engine runs it. */
930export const SETUP_COMMAND = 'jev-skill-suggestion:setup'
931
932/** A user settings file's parts the setup touches. */
933export interface SkillSettings {
934 skillOverrides: Record<string, string>
935 disableBundledSkills: boolean | undefined
936}
937
938/** Reads the two fields from `~/.claude/settings.json`; malformed reads as empty. */
939export function readSkillSettings(json: string | null): SkillSettings {
940 let parsed: unknown = null
941 try {
942 parsed = json ? JSON.parse(json) : null
943 } catch {
944 parsed = null
945 }
946 const settings = (parsed ?? {}) as { skillOverrides?: unknown; disableBundledSkills?: unknown }
947 const overrides: Record<string, string> = {}
948 if (settings.skillOverrides && typeof settings.skillOverrides === 'object') {
949 for (const [name, value] of Object.entries(settings.skillOverrides as Record<string, unknown>)) {
950 if (typeof value === 'string') overrides[name] = value
951 }
952 }
953 return {
954 skillOverrides: overrides,
955 disableBundledSkills: typeof settings.disableBundledSkills === 'boolean' ? settings.disableBundledSkills : undefined,
956 }
957}
958
959/**
960 * Whether a file is this mod's backup: `skillOverrides` an object of
961 * strings and `disableBundledSkills` a boolean or null. Anything else is
962 * not something `restore` could apply, so a setup must not build on it.
963 */
964export function validBackup(json: string): boolean {
965 let parsed: unknown
966 try {
967 parsed = JSON.parse(json)
968 } catch {
969 return false
970 }
971 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false
972 const { skillOverrides, disableBundledSkills } = parsed as Record<string, unknown>
973 if (!skillOverrides || typeof skillOverrides !== 'object' || Array.isArray(skillOverrides)) return false
974 if (!Object.values(skillOverrides as Record<string, unknown>).every((value) => typeof value === 'string')) return false
975 return disableBundledSkills === null || typeof disableBundledSkills === 'boolean'
976}
977
978/** The state the setup writes: the name stays listed, the description goes, the Skill tool still loads it. */
979export const SETUP_STATE = 'name-only'
980
981/** Override states that already keep a skill's description out of the listing. */
982const TRIMMED = new Set(['name-only', 'user-invocable-only', 'off'])
983
984export interface SetupPlan {
985 /** User skills the setup lists by name only (`name-only`). */
986 hide: string[]
987 /** Of those, the ones already name-only, user-invocable-only or off: nothing to change. */
988 alreadyHidden: string[]
989 /** A plugin's skills: `skillOverrides` cannot touch them, only `/plugin` can. */
990 locked: string[]
991}
992
993/**
994 * What the setup has to change, from the roster and the settings as they
995 * are. Every user-level command is listed, so the person sees the whole set
996 * before anything is written.
997 */
998export function setupPlan(
999 commands: readonly { name: string; source: string }[],
1000 settings: SkillSettings,
1001 excluded: ReadonlySet<string>,
1002): SetupPlan {
1003 const hide: string[] = []
1004 const alreadyHidden: string[] = []
1005 const locked: string[] = []
1006 const seen = new Set<string>()
1007 for (const command of commands) {
1008 if (seen.has(command.name) || excluded.has(command.name)) continue
1009 seen.add(command.name)
1010 if (command.source === 'plugin') locked.push(command.name)
1011 // A project's own skills are few and the ones that project needs most:
1012 // they keep their descriptions, and the person decides how many there are.
1013 else if (command.source === 'user') {
1014 const state = settings.skillOverrides[command.name] ?? 'on'
1015 if (TRIMMED.has(state)) alreadyHidden.push(command.name)
1016 else hide.push(command.name)
1017 }
1018 }
1019 return { hide, alreadyHidden, locked }
1020}
1021
1022/**
1023 * The prompt the model reads for `/jev-skill-suggestion:setup`: the plan in
1024 * full, the exact edit, and the rule that nothing is written before the
1025 * person has seen the list and said yes. The model edits the file with its
1026 * own tools, so the change shows as a diff and asks permission like any edit.
1027 */
1028export function setupInstructions(
1029 mode: 'apply' | 'restore',
1030 plan: SetupPlan,
1031 settings: SkillSettings,
1032 settingsPath: string,
1033 backupPath: string,
1034 /**
1035 * Whether a backup from an earlier run is already there. It holds the
1036 * values from before the first run, which the current settings no longer
1037 * do, so a rerun must leave it alone.
1038 */
1039 backupExists = false,
1040): string {
1041 const lines: string[] = ['<jev_skill_suggestion_setup>']
1042 if (mode === 'restore') {
1043 lines.push(
1044 `The user asked to undo jev-skill-suggestion's setup: put their skills back the way they were before it ran.`,
1045 `1. Read ${backupPath}. It holds {"skillOverrides": {...}, "disableBundledSkills": ...} as they were before the setup.`,
1046 ` If it does not exist, say so and stop: there is nothing to restore.`,
1047 `2. Show the user what will change in ${settingsPath}: the "skillOverrides" entries that go back to their saved value (an entry not in the backup is removed). Leave "disableBundledSkills" as it is: this setup never changes it.`,
1048 `3. Ask the user to confirm. Only after a clear yes, edit ${settingsPath} with the Edit tool, changing nothing else in the file, then remove the backup by running exactly this command with the Bash tool: rm ${JSON.stringify(backupPath)}`,
1049 `4. Tell the user to restart Claude Code for /skills and /context to show the change.`,
1050 '</jev_skill_suggestion_setup>',
1051 )
1052 return lines.join('\n')
1053 }
1054 const overrides: Record<string, string> = { ...settings.skillOverrides }
1055 for (const name of plan.hide) overrides[name] = SETUP_STATE
1056 const after = JSON.stringify({ skillOverrides: overrides }, null, 2)
1057 const backup = JSON.stringify(
1058 { skillOverrides: settings.skillOverrides, disableBundledSkills: settings.disableBundledSkills ?? null },
1059 null,
1060 2,
1061 )
1062 lines.push(
1063 `The user asked jev-skill-suggestion to trim the skill listing: every skill stays listed for the model by name only (state "name-only": the description leaves the listing, the model can still load the skill with the Skill tool, and the user can still type /name), and the mod injects the one skill each prompt most needs. Nothing here is written until the user has seen the list and said yes.`,
1064 '',
1065 `Skills that will be listed by name only (${plan.hide.length}):`,
1066 ...(plan.hide.length > 0 ? plan.hide.map((name) => `- ${name}`) : ['- (none)']),
1067 )
1068 if (plan.alreadyHidden.length > 0) {
1069 lines.push('', `Already trimmed or off, left as they are (${plan.alreadyHidden.length}):`, ...plan.alreadyHidden.map((name) => `- ${name}`))
1070 }
1071 lines.push('', `Claude Code's bundled skills (simplify, loop, init, ...) are left as they are.`)
1072 if (plan.locked.length > 0) {
1073 lines.push(
1074 '',
1075 `Plugin skills cannot be hidden by skillOverrides; they stay listed unless the plugin is disabled in /plugin (${plan.locked.length}):`,
1076 ...plan.locked.map((name) => `- ${name}`),
1077 )
1078 }
1079 lines.push(
1080 '',
1081 'Steps:',
1082 `1. Show the user the lists above, in their language, and say the change goes to ${settingsPath} (their user settings) and can be undone with /${SETUP_COMMAND} restore.`,
1083 '2. Ask them to confirm. Do not edit anything before a clear yes.',
1084 ...(backupExists
1085 ? [
1086 `3. ${backupPath} already exists from an earlier run and holds the values from before the first setup: do NOT overwrite or modify it.`,
1087 ]
1088 : [`3. After the yes, first write ${backupPath} with exactly this content (it is what restore reads):`, backup]),
1089 `4. Then edit ${settingsPath} with the Edit tool (read it first; create it as {} if it does not exist) so that its top-level "skillOverrides" becomes exactly:`,
1090 after,
1091 ' Change nothing else in the file. Keep every other top-level key as it is.',
1092 '5. Tell the user to restart Claude Code: /skills will then show these skills as name-only, and the model keeps loading any of them by name, including one skill that names another.',
1093 '</jev_skill_suggestion_setup>',
1094 )
1095 return lines.join('\n')
1096}
1097
1098/**
1099 * What the model reads when the setup cannot be planned: the roster or the
1100 * settings could not be read, so no edit is proposed — an edit planned from
1101 * a partial roster or empty settings would hide too little or back up the
1102 * wrong values.
1103 */
1104export function setupAborted(reason: string): string {
1105 return [
1106 '<jev_skill_suggestion_setup>',
1107 `The jev-skill-suggestion setup could not be prepared: ${reason}.`,
1108 'Tell the user, and do not edit any settings file. They can fix the cause and run the command again.',
1109 '</jev_skill_suggestion_setup>',
1110 ].join('\n')
1111}
1112
1113/** The hint logged while skills are still listed with their whole description. */
1114export function describeStillListed(count: number): string {
1115 return `${count} skill${count === 1 ? ' is' : 's are'} still listed with a full description; run /${SETUP_COMMAND} to list them by name only`
1116}
1117