Shows, while you type, the skills and subagents Claude will probably call for your prompt: reads the draft on every edit and lists them in the band above the…

Shows, while you type, the skills and subagents Claude will probably call for the prompt you are writing, in the band above the prompt box. The draft is read on every edit, so the band follows the box key by key; once you pause, Jev, TypeSafe's System One decision model, says which one will actually be called and the band marks it.
╭──────────────────────────────────────────────────────────────────────────────╮
│ ✦ Claude may call 2 of 24 skills · 1 of 3 subagents │
│ ▶ ◆ 82% ███████░ anthropic-skills:pptx will be called · deck, slides │
│ ▣ 21% ██░░░░░░ design-reviewer slides │
│ ● 9% █░░░░░░░ brand-guidelines deck │
│ Jev decided details: /jev-skill-typeahead │
╰──────────────────────────────────────────────────────────────────────────────╯
● user or project skill, ◆ plugin skill, ▣ subagent. The border turns green when a decision is in. Names are shown whole (the name column fits the longest one, up to 45 characters), and the detail column is cut at the band's edge. In Claude Desktop the meter is drawn with dots, since block glyphs are wider than a cell there.
/jev-skill-typeahead opens a side pane with more room: the prompt being read, the decision, and up to eight candidates, each with its full name, score, the whole description and the words that matched. It follows the prompt box live, like the band.
Only what the model calls on its own: skills (through the Skill tool) and subagent types (through the Agent tool). Slash commands are not suggested: you run those by typing /name, so nothing needs to guess them, and a draft starting with /, ! (shell) or # (memory note) gets no rows.
The candidates are what the engine itself offers the model, observed as it builds its listings: the skill_listing attachment (prompt.attachment) and every agent type offered (agent.offer). Both hooks only watch and pass the event on. The engine renders those listings at a turn's first request, so before the first prompt of a session the skills come from $.command.list() (which also holds commands only you can run) and subagents are not known yet; the listings replace that as soon as they arrive.
| You type | The band |
|---|---|
nothing, /…, !…, #…, one or two stray words | stays, with 0 of N skills and a line saying why: type a prompt, commands run as typed, or keep typing |
make me a deck for the board | ranks candidates by keyword match over name and description, as you type; the word still being typed matches as a prefix. Spanish works too (hazme una presentación, revisa la seguridad) through a small Spanish-to-English alias table |
| the same, and you stop for 600 ms | Jev decides which one will be called (see below) |
Fenced code and URLs in the draft are ignored when matching. A prose draft needs two content words (or 24 characters) before anything shows.
The band never claims more than it knows:
| Footer | Meaning |
|---|---|
keyword match · pause for Jev to decide | instant, local, a guess; the percentage is how much of your draft the name and description cover |
asking Jev… | a decision request is in flight |
Jev decided | the answer: the ▶ row is expected to be called, with the probability Jev reported (a dash when the backend reports none) |
nothing needed for this | the gate said prose is enough; no row is marked |
decision unavailable · keyword match | the request failed or timed out; the keyword match stays |
Typing again clears the mark at once: a decision is only ever shown for the draft it was made for.
One request per pause: a choice over every candidate's description (subagents as agent:name), plus three yes/no gate questions (does it act on your system, would an expert follow a documented procedure, could prose alone do it). This is the first request of TypeSafe's skill-suggestion cookbook, the same one jev-skill-suggestion sends; the second, re-reading request is left out because the draft changes under it and this answer is a preview. The mark needs the gate mean ≥ gateThreshold (0.3) and, when the backend reports one, a probability ≥ confidenceThreshold (0.35).
provider | Endpoint | Needs |
|---|---|---|
auto (default) | TypeSafe if its key is set, else the Gateway, else keywords only | |
typesafe | POST api.typesafe.ai/v1/systemone, model jev-latest | typesafeApiKey |
gateway | POST ai-gateway.vercel.sh/v4/ai/evaluation-model, model typesafe-ai/jev | gatewayApiKey |
builtin | Claude Code's own small model through $.model.classify, one request per pause | nothing |
keywords | never decides | nothing |
With no key and provider: auto the mod makes no network request at all: the band is the keyword match and says so in its footer.
attach)With attach on (the default), the candidate the band marked ▶ for exactly the text you submit is named to the model in a <skill_relevance> note ("load it with the Skill tool if it fits" for a skill, "consider delegating to it with the Agent tool" for a subagent), so what the band says will be called is what the model is told. Text edited after the decision, or submitted before one arrived, gets no note. Turn it off if jev-skill-suggestion is installed: that mod decides at submit with its own two-request pipeline and would say the same thing twice.
Claude Code 2.1.287 or newer, in a project you trust:
npx claude-code-templates@latest --mod productivity/jev-skill-typeahead
claude
The first session line reads [jev-skill-typeahead] ready: … decisions by …. Start typing a prompt: the band appears above the box.
Options live in pluginConfigs["jev-skill-typeahead@skills-dir"].options of your user settings (not project settings): typesafeApiKey / gatewayApiKey, provider, language (en or es, labels only), maxRows (4, clamped to 1–8), pauseMs (600, min 150), minWords (2), includeSubagents (true), timeoutMs (4000, min 500), attach, neverSuggested (comma-separated names), logDecisions (false; writes one transcript line per decision).
With a Jev key set, the prompt draft and every candidate's name and description are sent to the backend the key belongs to, once per pause while you type a prose prompt (never for /…, !… or #…). With provider: builtin they go to your Claude Code model instead. With no key, nothing leaves the machine. typesafeBaseUrl and gatewayBaseUrl redirect the key and the draft to whatever URL they hold. The session log records only the name of each decision, never the draft.
skillOverrides, or withheld by jev-skill-suggestion) is not in that listing; the mod then falls back to the command list until it has seen a listing.attach note.cd cli-tool/components/mods && npx -y -p typescript@5 tsc -p tsconfig.json
claude plugin validate productivity/jev-skill-typeahead
claude plugin test productivity/jev-skill-typeahead
prompt.edit cannot be raised from a test, so the tests cover the policy, the listing parser, the Jev request and answer shapes, the band on both surfaces and the submit path; the live typing path was checked by reading the engine's types, not by typing into a session.
hooks/register.tsx 559 lines1/**
2 * jev-skill-typeahead — Claude Mod
3 *
4 * Shows, in the band above the prompt box and while you type, the skills and
5 * subagents Claude will PROBABLY call for the prompt you are writing. Only
6 * what the model invokes on its own counts: skills (Skill tool) and subagent
7 * types (Agent tool). Slash commands are left out: you run those by typing
8 * `/name`, so a draft starting with `/`, `!` or `#` gets no band.
9 *
10 * The candidates are what the engine itself offers the model, observed as it
11 * builds the listings: the `skill_listing` attachment (`prompt.attachment`)
12 * and every agent type offered (`agent.offer`). Both hooks only watch and
13 * pass the event on. The engine renders those listings at a turn's first
14 * request, so before the first prompt of a session the skills come from
15 * `$.command.list()` instead (which also holds commands only you can run) and
16 * subagents are not known yet; the listings replace that as soon as they
17 * arrive.
18 *
19 * The draft is read on every edit (`prompt.edit`):
20 *
21 * keywords instant, local: candidates ranked by keyword match over name
22 * and description, English or Spanish; the word still being
23 * typed matches as a prefix
24 * decision once you pause, Jev decides which ONE will be called and the
25 * band marks it ▶ with the probability it answered
26 *
27 * Phases the footer tells apart, so the band never claims more than it knows:
28 * `keywords` (a guess), `asking Jev…`, `Jev decided` (with its confidence when
29 * the backend reports one), `no skill needed`, `offline` (the request failed;
30 * the keyword match stays).
31 *
32 * With `attach` on (the default) the candidate the band marked ▶ for exactly
33 * the text you submit is named to the model in a `<skill_relevance>` note.
34 * Text edited after the decision, or submitted before it, gets no note. Turn it
35 * off when jev-skill-suggestion is installed: that mod decides at submit.
36 *
37 * Privacy: with a Jev key set, the prompt draft and every candidate's name and
38 * description are sent to the backend the key belongs to, once per pause.
39 * Without a key nothing leaves the machine.
40 *
41 * Keys come from the plugin's options (userConfig); never hardcode them here.
42 * Needs Claude Code >= 2.1.287.
43 */
44import { atom, read, update } from 'claude-code'
45import type { Register, Timer } from 'claude-code'
46
47import type { Origin, Row, View } from '../types'
48import {
49 DEFAULT_BASE_URL,
50 DEFAULT_MODEL,
51 NONE,
52 classifyText,
53 endpoint,
54 questions,
55 readDecision,
56 requestBody,
57 requestHeaders,
58 selectProvider,
59 verdictOf,
60} from './jev.ts'
61import type { Provider } from './jev.ts'
62import {
63 buildIndex,
64 keyOf,
65 parseListing,
66 parseNames,
67 rankProse,
68 readDraft,
69 rosterOf,
70 skillsFromCommands,
71 toRow,
72} from './policy.ts'
73import type { Hit, Index, Skill } from './policy.ts'
74
75const EMPTY: View = { mode: 'idle', draft: '', rows: [], phase: 'live', by: '', skills: 0, agents: 0 }
76const view = atom({ plugin: 'jev-skill-typeahead', key: 'view' } as const, EMPTY)
77
78/** The command fallback is re-read this often: skills can be installed mid-session. */
79const FALLBACK_TTL_MS = 30_000
80/** Keys typed this close together are one redraw. */
81const LIVE_DELAY_MS = 60
82/** Cells of the score meter. */
83const METER = 8
84/** Dots of the meter off the terminal, where block glyphs are wider than a cell. */
85const DOTS = 5
86/** Rows kept per draft: the band shows `maxRows` of them, the details pane all. */
87const KEEP_ROWS = 8
88/** The details pane and the command that opens it. */
89const PANE = 'jev-skill-typeahead'
90/** Widest name column the band gives before cutting a name. */
91const NAME_MAX = 46
92
93/** How many of each kind the roster holds: the band's header counts against these. */
94const countsOf = (roster: Skill[]) => ({
95 skills: roster.filter((s) => s.origin !== 'agent').length,
96 agents: roster.filter((s) => s.origin === 'agent').length,
97})
98
99const ICON: Record<Origin, string> = { user: '●', plugin: '◆', agent: '▣' }
100const COLOR: Record<Origin, string> = { user: 'green', plugin: 'magenta', agent: 'blue' }
101
102const WORDS = {
103 en: {
104 title: 'Claude may call',
105 of: 'of',
106 skills: 'skills',
107 agents: 'subagents',
108 keywords: 'keyword match · pause for Jev to decide',
109 keywordsOnly: 'keyword match · set a Jev key to get a decision',
110 thinking: 'asking Jev…',
111 decidedJev: 'Jev decided',
112 decidedBuiltin: 'Claude Code decided',
113 none: 'nothing needed for this',
114 offline: 'decision unavailable · keyword match',
115 willUse: 'will be called',
116 legend: 'skill:user ● · skill:plugin ◆ · subagent ▣',
117 empty: 'type a prompt to see which skills or subagents Claude may call',
118 command: 'commands run as typed · nothing for Claude to pick',
119 short: 'keep typing…',
120 noMatch: 'no skill or subagent matches yet',
121 noneOffered: 'no skills or subagents are offered to Claude in this session',
122 details: 'details: /jev-skill-typeahead',
123 paneTitle: 'Skill typeahead',
124 draft: 'Prompt',
125 matched: 'matched',
126 noScore: 'no score reported',
127 opened: 'Skill typeahead details opened.',
128 commandHelp: 'Show the skills and subagents Claude may call for the prompt you are typing, with full names and descriptions',
129 },
130 es: {
131 title: 'Claude puede llamar',
132 of: 'de',
133 skills: 'skills',
134 agents: 'subagents',
135 keywords: 'coincidencia por palabras · pausa para que Jev decida',
136 keywordsOnly: 'coincidencia por palabras · configura una key de Jev para decidir',
137 thinking: 'consultando a Jev…',
138 decidedJev: 'Jev decidió',
139 decidedBuiltin: 'Claude Code decidió',
140 none: 'no hace falta ninguno',
141 offline: 'decisión no disponible · coincidencia por palabras',
142 willUse: 'se llamará',
143 legend: 'skill:user ● · skill:plugin ◆ · subagent ▣',
144 empty: 'escribe un prompt para ver qué skills o subagents puede llamar Claude',
145 command: 'los comandos se ejecutan tal cual · Claude no elige nada',
146 short: 'sigue escribiendo…',
147 noMatch: 'ningún skill o subagent coincide todavía',
148 noneOffered: 'Claude no tiene skills ni subagents en esta sesión',
149 details: 'detalles: /jev-skill-typeahead',
150 paneTitle: 'Skill typeahead',
151 draft: 'Prompt',
152 matched: 'coincide',
153 noScore: 'sin puntaje',
154 opened: 'Detalles de skill typeahead abiertos.',
155 commandHelp: 'Muestra los skills y subagents que Claude puede llamar para el prompt que escribes, con nombre y descripción completos',
156 },
157}
158
159export const register: Register = (on, options) => {
160 const text = (key: string, fallback: string) =>
161 typeof options[key] === 'string' && options[key] ? (options[key] as string) : fallback
162 const number = (key: string, fallback: number) =>
163 typeof options[key] === 'number' ? (options[key] as number) : fallback
164 const flag = (key: string, fallback: boolean) =>
165 typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback
166
167 const typesafeKey = text('typesafeApiKey', '')
168 const gatewayKey = text('gatewayApiKey', '')
169 const forced = text('provider', 'auto')
170 const provider: Provider | null = selectProvider(forced, typesafeKey, gatewayKey)
171 const isBuiltin = forced === 'builtin'
172 const apiKey = provider === 'typesafe' ? typesafeKey : gatewayKey
173 const modelId = provider === 'gateway' ? text('gatewayModel', DEFAULT_MODEL.gateway) : text('typesafeModel', DEFAULT_MODEL.typesafe)
174 const url = provider
175 ? provider === 'typesafe'
176 ? endpoint('typesafe', text('typesafeBaseUrl', DEFAULT_BASE_URL.typesafe))
177 : endpoint('gateway', text('gatewayBaseUrl', DEFAULT_BASE_URL.gateway))
178 : ''
179 const canDecide = provider !== null || isBuiltin
180
181 const maxRows = Math.max(1, Math.min(8, Math.round(number('maxRows', 4))))
182 const pauseMs = Math.max(150, number('pauseMs', 600))
183 const timeoutMs = Math.max(500, number('timeoutMs', 4000))
184 const minWords = Math.max(1, Math.round(number('minWords', 2)))
185 const attach = flag('attach', true)
186 const logDecisions = flag('logDecisions', false)
187 const includeAgents = flag('includeSubagents', true)
188 const words = WORDS[text('language', 'en') === 'es' ? 'es' : 'en']
189 const excluded = parseNames(text('neverSuggested', ''))
190 const limits = { gate: number('gateThreshold', 0.3), confidence: number('confidenceThreshold', 0.35) }
191
192 // What the engine offers the model, as seen in its own listings.
193 const listedSkills = new Map<string, Skill>()
194 const offeredAgents = new Map<string, Skill>()
195 let fallback: Skill[] = []
196 let fallbackAt = -Infinity
197
198 let roster: Skill[] = []
199 let index: Index = buildIndex([])
200 let latest = ''
201 let seq = 0
202 let liveTimer: Timer | null = null
203 let settleTimer: Timer | null = null
204 // The one decision still valid: for exactly this draft, this candidate (or none).
205 let decision: { draft: string; skill: Skill | null } | null = null
206
207 const stop = () => {
208 liveTimer?.cancel()
209 settleTimer?.cancel()
210 liveTimer = null
211 settleTimer = null
212 }
213
214 on('session.start', async ($, e, next) => {
215 const how = provider ? `Jev on ${provider}` : isBuiltin ? "Claude Code's classifier" : 'keyword match only (no Jev key)'
216 $.ui.log(`[jev-skill-typeahead] ready: suggesting what Claude may call above the prompt as you type · decisions by ${how}`)
217 const started = await next(e)
218 await $.command.register({ name: PANE, description: words.commandHelp }).catch((error: unknown) => {
219 $.ui.log(`[jev-skill-typeahead] could not register /${PANE}: ${String(error)}`, { to: 'debug' })
220 })
221 // The band shows from the start: until the engine lists skills, the commands stand in for the counts.
222 try {
223 fallback = skillsFromCommands(await $.command.list()).filter((c) => !c.name.includes(PANE))
224 fallbackAt = await $.clock.now()
225 roster = rosterOf(fallback, [], excluded)
226 index = buildIndex(roster)
227 await update($, view, () => ({ ...EMPTY, ...countsOf(roster) }))
228 } catch (error) {
229 $.ui.log(`[jev-skill-typeahead] could not read the commands: ${String(error)}`, { to: 'debug' })
230 }
231 return started
232 })
233
234 // The skills the engine lists for the model. Observed, never changed.
235 on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
236 for (const skill of parseListing(e.text)) listedSkills.set(skill.name, skill)
237 return next(e)
238 })
239
240 // The subagent types the engine offers the model. Observed, never changed.
241 on('agent.offer', async ($, e, next) => {
242 offeredAgents.set(e.agent, { name: e.agent, description: e.description, origin: 'agent' })
243 return next(e)
244 }).catch(async ($, e, next) => {
245 // A suggestion mod never withholds a subagent from the model.
246 $.ui.log(`[jev-skill-typeahead] agent.offer: ${next.error.kind}`, { to: 'debug' })
247 return next(e)
248 })
249
250 on('prompt.edit', async ($, e, next) => {
251 const box = await next(e)
252 latest = box.text
253 seq += 1
254 decision = null
255 stop()
256
257 // `$` may not be handed to a helper, so the work lives in closures of this hook.
258 const fail = (error: unknown) => $.ui.log(`[jev-skill-typeahead] ${String(error)}`)
259
260 /** The decision: one request to Jev (or the built-in classifier) for the draft as it stands. */
261 const settle = async (draftText: string, prose: string, liveRows: Row[], mySeq: number) => {
262 if (mySeq !== seq) return
263 const counts = countsOf(roster)
264 const base: View = { mode: 'prose', draft: draftText, rows: liveRows, phase: 'thinking', by: provider ? 'jev' : 'builtin', ...counts }
265 await update($, view, () => base)
266
267 // The candidates the keyword match likes go first: a backend that truncates keeps them.
268 const liked = new Set(liveRows.map((r) => keyOf(r)))
269 const candidates = [...roster.filter((s) => liked.has(keyOf(s))), ...roster.filter((s) => !liked.has(keyOf(s)))].slice(0, 250)
270 const known = new Set(candidates.map(keyOf))
271
272 let chosen: string | null = null
273 let probabilities = new Map<string, number | null>()
274 let failed = false
275 try {
276 if (provider) {
277 const response = await Promise.race([
278 $.http.fetch(url, {
279 method: 'POST',
280 headers: requestHeaders(provider, apiKey, modelId),
281 body: requestBody(provider, prose.trim(), questions(provider, candidates), modelId),
282 }),
283 $.clock.sleep(timeoutMs),
284 ])
285 const decided = response && response.ok ? readDecision(response.text) : null
286 if (!decided) throw new Error(response ? `${provider} answered ${response.status}` : `no answer in ${timeoutMs}ms`)
287 chosen = verdictOf(decided, known, limits)
288 probabilities = new Map(decided.ranked.filter((r) => known.has(r.name)).map((r) => [r.name, r.probability]))
289 } else {
290 const label = await $.model.classify(classifyText(prose.trim(), candidates), [...candidates.map(keyOf), NONE])
291 chosen = label && label !== NONE && known.has(label) ? label : null
292 if (chosen) probabilities = new Map([[chosen, null]])
293 }
294 } catch (error) {
295 failed = true
296 if (logDecisions) fail(`decision failed: ${String(error)}`)
297 }
298 if (mySeq !== seq) return
299
300 if (failed) {
301 await update($, view, () => ({ ...base, phase: 'offline' }))
302 return
303 }
304 const byKey = new Map(roster.map((s) => [keyOf(s), s]))
305 decision = { draft: draftText.trim(), skill: chosen ? (byKey.get(chosen) ?? null) : null }
306
307 // Rows: what the backend ranked (top few above 3%), else the keyword rows; the chosen one first.
308 const hitsOf = new Map(liveRows.map((r) => [keyOf(r), r.hits]))
309 const ranked: Hit[] = [...probabilities.entries()]
310 .filter(([, p]) => p === null || p >= 0.03)
311 .slice(0, Math.max(maxRows, KEEP_ROWS))
312 .map(([k, p]) => ({ skill: byKey.get(k) as Skill, score: p === null ? -1 : Math.round(p * 100), hits: hitsOf.get(k) ?? [] }))
313 const rows = (ranked.length > 0 ? ranked : liveRows.map((r): Hit => ({ skill: byKey.get(keyOf(r)) as Skill, score: r.score, hits: r.hits })))
314 .filter((h) => h.skill)
315 .map((h) => toRow(h, keyOf(h.skill) === chosen))
316 .sort((a, b) => Number(b.isChosen) - Number(a.isChosen))
317 if (logDecisions) $.ui.log(`[jev-skill-typeahead] ${chosen ?? 'nothing'} called for the draft`)
318 await update($, view, () => ({ ...base, rows, phase: chosen ? 'decided' : 'none' }))
319 }
320
321 /** The instant half: no network, runs a moment after the last key. */
322 const live = async (draftText: string, mySeq: number) => {
323 const draft = readDraft(draftText, minWords)
324
325 // The listings win; until the skill listing has been seen, the commands stand in.
326 let skills = [...listedSkills.values()]
327 if (skills.length === 0) {
328 const now = await $.clock.now()
329 if (now - fallbackAt >= FALLBACK_TTL_MS || fallback.length === 0) {
330 fallback = skillsFromCommands(await $.command.list()).filter((c) => !c.name.includes(PANE))
331 fallbackAt = now
332 }
333 skills = fallback
334 }
335 roster = rosterOf(skills, includeAgents ? [...offeredAgents.values()] : [], excluded)
336 index = buildIndex(roster)
337 if (mySeq !== seq) return
338 // Idle, or nothing to pick from: the band stays, saying so, with the counts.
339 if (draft.mode === 'idle' || roster.length === 0) {
340 const idle: View = { ...EMPTY, draft: draftText, ...countsOf(roster) }
341 return update($, view, (old) => (JSON.stringify(old) === JSON.stringify(idle) ? old : idle))
342 }
343
344 const rows = rankProse(index, draft.prose, Math.max(maxRows, KEEP_ROWS)).map((h) => toRow(h, false))
345 const shown: View = {
346 mode: 'prose',
347 draft: draftText,
348 rows,
349 phase: 'live',
350 by: '',
351 ...countsOf(roster),
352 }
353 await update($, view, (old) => (JSON.stringify(old) === JSON.stringify(shown) ? old : shown))
354
355 if (canDecide) {
356 settleTimer = $.clock.after(pauseMs, () => {
357 void settle(draftText, draft.prose, rows, mySeq).catch(fail)
358 })
359 }
360 }
361
362 liveTimer = $.clock.after(LIVE_DELAY_MS, () => {
363 void live(latest, seq).catch(fail)
364 })
365 return box
366 })
367
368 on('prompt.submit', async ($, e, next) => {
369 stop()
370 seq += 1
371 const decided = decision
372 decision = null
373 latest = ''
374 await update($, view, (old) => ({ ...EMPTY, skills: old.skills, agents: old.agents }))
375
376 if (!attach || !decided?.skill || decided.draft !== e.text.trim()) return next(e)
377 const pick = decided.skill
378 if (logDecisions) $.ui.log(`[jev-skill-typeahead] told the model about ${keyOf(pick)}`)
379 const advice =
380 pick.origin === 'agent'
381 ? `Relevant to the current request: the ${pick.name} subagent. Consider delegating to it with the Agent tool if it fits; ignore this if it does not fit what the user actually asked for.`
382 : `Relevant to the current request: the ${pick.name} skill. Load it with the Skill tool if it fits; ignore this if it does not fit what the user actually asked for.`
383 const note = ['<skill_relevance>', advice, '</skill_relevance>'].join('\n')
384 return next({ ...e, context: [...(e.context ?? []), note] })
385 }).catch(async ($, e, next) => {
386 // A suggestion mod never stops a prompt: on any failure it goes through as typed.
387 $.ui.log(`[jev-skill-typeahead] prompt.submit: ${next.error.kind}`, { to: 'debug' })
388 return next(e)
389 })
390
391 on('command.run', { command: PANE }, async ($) => {
392 await $.ui.open({ id: PANE, title: words.paneTitle, focus: true })
393 return { text: words.opened }
394 }).catch(async ($, e, next) => {
395 $.ui.log(`[jev-skill-typeahead] /${PANE}: ${next.error.kind}`, { to: 'debug' })
396 return { text: `jev-skill-typeahead: the details pane could not open (${next.error.kind}).` }
397 })
398
399 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
400 if (e.props.hasSurvey) return next(e)
401 const v = await read($, view)
402 const rows = v.rows.slice(0, maxRows)
403 // The band is one instance: whatever the plugins beneath draw (another mod's band) stays, under ours.
404 const below = await next(e)
405
406 const { Box, Text } = $.ui.resolve(e)
407 const isTerminal = e.surface === 'terminal'
408 // HTML collapses runs of spaces; a no-break space keeps them (desktop).
409 const pad = (s: string) => (isTerminal ? s : s.replace(/ /g, ' '))
410 const fit = (s: string, n: number) => pad(s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n))
411 // The name column fits the longest name shown, so names are whole.
412 const nameWidth = Math.min(NAME_MAX, Math.max(12, ...rows.map((r) => r.name.length + 2)))
413
414 const footer = footerOf(v)
415 const footerColor = v.mode === 'prose' && v.phase === 'decided' ? 'green' : v.phase === 'offline' ? 'yellow' : undefined
416
417 const table = rows.map((r, i) => {
418 const accent = r.isChosen ? 'green' : COLOR[r.origin]
419 const detail = r.isChosen ? `${words.willUse}${r.hits.length ? ` · ${r.hits.join(', ')}` : ''}` : r.hits.length > 0 ? r.hits.join(', ') : r.description
420 return (
421 <Box key={`row:${i}:${r.origin}:${r.name}`} flexDirection="row" overflow="hidden">
422 <Box key="mark" width={2} flexShrink={0}>
423 <Text bold color="green">{pad(r.isChosen ? '▶ ' : ' ')}</Text>
424 </Box>
425 <Box key="icon" width={2} flexShrink={0}>
426 <Text color={COLOR[r.origin]}>{pad(`${ICON[r.origin]} `)}</Text>
427 </Box>
428 <Box key="score" width={5} flexShrink={0} justifyContent="flex-end" overflow="hidden">
429 <Text bold={r.isChosen} color={r.isChosen ? 'green' : undefined} dimColor={!r.isChosen}>{pad(`${scoreOf(r.score)} `)}</Text>
430 </Box>
431 <Box key="meter" width={METER + 1} flexShrink={0} overflow="hidden">
432 <Text color={r.isChosen ? 'green' : 'cyan'} dimColor={!r.isChosen}>{pad(`${meterOf(r.score, isTerminal)} `)}</Text>
433 </Box>
434 <Box key="name" width={nameWidth} flexShrink={0} overflow="hidden">
435 <Text bold={r.isChosen} color={accent}>{fit(r.name, nameWidth - 1)}</Text>
436 </Box>
437 <Box key="detail" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
438 <Text dimColor={!r.isChosen} color={r.isChosen ? 'green' : undefined} wrap="truncate-end">{detail}</Text>
439 </Box>
440 </Box>
441 )
442 })
443
444 const band = (
445 <Box flexDirection="column" borderStyle="round" borderColor={v.phase === 'decided' && v.mode === 'prose' ? 'green' : 'cyan'} borderDimColor={!(v.phase === 'decided' && v.mode === 'prose')} paddingX={1} overflow="hidden">
446 <Box key="head" flexDirection="row" overflow="hidden">
447 <Text bold color="cyan">{pad(`✦ ${words.title} `)}</Text>
448 <Text dimColor wrap="truncate-end">{pad(headerOf(v, rows))}</Text>
449 </Box>
450 {table.length > 0 ? table : <Text key="empty" dimColor wrap="truncate-end">{hintOf(v)}</Text>}
451 <Box key="foot" flexDirection="row" overflow="hidden">
452 <Box key="state" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
453 <Text dimColor={!footerColor} color={footerColor} wrap="truncate-end">{footer}</Text>
454 </Box>
455 <Box key="more" flexShrink={0}>
456 <Text dimColor>{pad(` ${words.details}`)}</Text>
457 </Box>
458 </Box>
459 </Box>
460 )
461 return (
462 <Box flexDirection="column">
463 {band}
464 {below}
465 </Box>
466 )
467 })
468
469 // The details pane: every row kept for the draft, with whole names and descriptions.
470 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
471 const v = await read($, view)
472 const { Box, Text } = $.ui.resolve(e)
473 const isTerminal = e.surface === 'terminal'
474 const pad = (s: string) => (isTerminal ? s : s.replace(/ /g, ' '))
475 const footer = footerOf(v)
476 const footerColor = v.mode === 'prose' && v.phase === 'decided' ? 'green' : v.phase === 'offline' ? 'yellow' : undefined
477
478 return (
479 <Box flexDirection="column" paddingX={1}>
480 <Box key="head" flexDirection="row">
481 <Text bold color="cyan">{pad(`✦ ${words.title} `)}</Text>
482 <Text dimColor>{pad(headerOf(v, v.rows))}</Text>
483 </Box>
484 {v.draft.trim() ? (
485 <Box key="draft" flexDirection="row" marginTop={1}>
486 <Text dimColor>{pad(`${words.draft} `)}</Text>
487 <Text wrap="wrap">{v.draft.trim()}</Text>
488 </Box>
489 ) : null}
490 <Text key="state" dimColor={!footerColor} color={footerColor}>{footer}</Text>
491 {v.rows.length === 0 ? (
492 <Box key="empty" marginTop={1}>
493 <Text dimColor wrap="wrap">{hintOf(v)}</Text>
494 </Box>
495 ) : (
496 v.rows.map((r, i) => (
497 <Box key={`card:${i}:${r.origin}:${r.name}`} flexDirection="column" marginTop={1} borderStyle="round" borderColor={r.isChosen ? 'green' : 'gray'} borderDimColor={!r.isChosen} paddingX={1}>
498 <Box key="title" flexDirection="row">
499 <Text color={COLOR[r.origin]}>{pad(`${ICON[r.origin]} `)}</Text>
500 <Text bold color={r.isChosen ? 'green' : COLOR[r.origin]} wrap="wrap">{r.name}</Text>
501 </Box>
502 <Box key="score" flexDirection="row">
503 <Text color={r.isChosen ? 'green' : 'cyan'} dimColor={!r.isChosen}>{pad(`${meterOf(r.score, isTerminal)} `)}</Text>
504 <Text bold={r.isChosen} color={r.isChosen ? 'green' : undefined} dimColor={!r.isChosen}>{r.score < 0 ? words.noScore : `${r.score}%`}</Text>
505 {r.isChosen ? <Text color="green">{pad(` · ${words.willUse}`)}</Text> : null}
506 </Box>
507 {r.description ? <Text key="about" dimColor wrap="wrap">{r.description}</Text> : null}
508 {r.hits.length > 0 ? <Text key="hits" dimColor wrap="wrap">{`${words.matched}: ${r.hits.join(', ')}`}</Text> : null}
509 </Box>
510 ))
511 )}
512 <Box key="legend" marginTop={1}>
513 <Text dimColor>{words.legend}</Text>
514 </Box>
515 </Box>
516 )
517 })
518
519 /** The band's and the pane's status line. */
520 function footerOf(v: View): string {
521 if (v.mode === 'idle') return words.legend
522 if (v.phase === 'thinking') return words.thinking
523 if (v.phase === 'decided') return v.by === 'jev' ? words.decidedJev : words.decidedBuiltin
524 if (v.phase === 'none') return words.none
525 if (v.phase === 'offline') return words.offline
526 return canDecide ? words.keywords : words.keywordsOnly
527 }
528
529 /** What to say when there are no rows to show. */
530 function hintOf(v: View): string {
531 const typed = v.draft.trimStart()
532 if (v.skills + v.agents === 0) return words.noneOffered
533 if (v.mode === 'prose') return words.noMatch
534 if (typed === '') return words.empty
535 if (/^[\/!#]/.test(typed)) return words.command
536 return words.short
537 }
538
539 /** Shown out of available, per kind: "2 of 26 skills · 1 of 3 subagents". */
540 function headerOf(v: View, shown: Row[]): string {
541 const agents = shown.filter((r) => r.origin === 'agent').length
542 const skills = `${shown.length - agents} ${words.of} ${v.skills} ${words.skills}`
543 return v.agents > 0 ? `${skills} · ${agents} ${words.of} ${v.agents} ${words.agents}` : skills
544 }
545}
546
547/** A score as text: a percentage, or a dash when the backend reported none. */
548function scoreOf(score: number): string {
549 return score < 0 ? '—' : `${score}%`
550}
551
552/** The meter: block cells on the terminal, dots elsewhere (block glyphs are wider than a cell there). */
553function meterOf(score: number, isTerminal: boolean): string {
554 const cells = isTerminal ? METER : DOTS
555 if (score < 0) return '·'.repeat(cells)
556 const full = Math.max(score > 0 ? 1 : 0, Math.round((score / 100) * cells))
557 return isTerminal ? '█'.repeat(full) + '░'.repeat(cells - full) : '●'.repeat(full) + '○'.repeat(cells - full)
558}
559hooks/jev.ts 174 lines1/**
2 * jev-skill-typeahead — the decision: which one skill will this prompt use?
3 *
4 * One request to Jev, TypeSafe's System One decision model, per pause: a
5 * `choice` over every skill's and subagent's description ("which of these, if any, is the
6 * right one to load") plus three yes/no gate questions that say whether the
7 * prompt needs a skill at all. The same shapes jev-skill-suggestion sends as
8 * the first request of TypeSafe's skill-suggestion cookbook; the second,
9 * re-reading request is left out because the draft changes under it and this
10 * answer is a preview.
11 *
12 * Two backends, picked by whichever key is set: TypeSafe's own API
13 * (`POST /v1/systemone`, a calibrated confidence per answer) or the Vercel AI
14 * Gateway (`POST /evaluation-model`, no confidence). Without either the mod
15 * stays on the keyword match and says so; `provider: builtin` asks the
16 * engine's own `$.model.classify` instead, one small-model request per pause.
17 */
18import { keyOf } from './policy.ts'
19import type { Skill } from './policy.ts'
20
21export type Provider = 'typesafe' | 'gateway'
22
23export const DEFAULT_BASE_URL: Record<Provider, string> = {
24 typesafe: 'https://api.typesafe.ai',
25 gateway: 'https://ai-gateway.vercel.sh/v4/ai',
26}
27
28export const DEFAULT_MODEL: Record<Provider, string> = {
29 typesafe: 'jev-latest',
30 gateway: 'typesafe-ai/jev',
31}
32
33/** The label the built-in classifier answers when no skill applies. */
34export const NONE = 'none'
35
36/** The Gateway's own protocol version, sent as `ai-gateway-protocol-version`; without it the Gateway answers 400. */
37const AI_GATEWAY_PROTOCOL_VERSION = '0.0.1'
38
39/** A forced backend whose key is missing resolves to null, never to the other one's key. */
40export function selectProvider(forced: string, typesafeKey: string, gatewayKey: string): Provider | null {
41 if (forced === 'builtin' || forced === 'keywords') return null
42 if (forced === 'typesafe') return typesafeKey ? 'typesafe' : null
43 if (forced === 'gateway') return gatewayKey ? 'gateway' : null
44 if (typesafeKey) return 'typesafe'
45 if (gatewayKey) return 'gateway'
46 return null
47}
48
49export function endpoint(provider: Provider, baseUrl: string): string {
50 const root = baseUrl.replace(/\/+$/, '')
51 return provider === 'typesafe' ? `${root}/v1/systemone` : `${root}/evaluation-model`
52}
53
54/** Yes/no questions where a yes points away from needing a skill. */
55const GATE: Record<string, { text: string; isInverted: boolean }> = {
56 acts: {
57 text: "Is the assistant being asked to act on the user's files, accounts, devices, or online services, rather than only to explain or advise?",
58 isInverted: false,
59 },
60 procedure: {
61 text: 'Would a careful expert answering this consult a specific documented procedure or set of commands, rather than answering from general understanding?',
62 isInverted: false,
63 },
64 prose: {
65 text: "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?",
66 isInverted: true,
67 },
68}
69
70export function questions(provider: Provider, skills: readonly Skill[]): Record<string, unknown> {
71 const criteria: Record<string, string> = {}
72 for (const skill of skills) criteria[keyOf(skill)] = skill.description || `A ${skill.origin === 'agent' ? 'subagent' : 'skill'} named ${skill.name}.`
73 const out: Record<string, unknown> = {
74 which: {
75 type: 'choice',
76 instructions: "Which of these skills and subagents (agent:name), if any, is the right one for the model to call to help with the user's request?",
77 criteria,
78 },
79 }
80 for (const [key, g] of Object.entries(GATE)) {
81 out[`gate::${key}`] = { type: provider === 'typesafe' ? 'noul' : 'boolean', instructions: g.text }
82 }
83 return out
84}
85
86export function requestBody(provider: Provider, prompt: string, qs: Record<string, unknown>, model: string): string {
87 const state = { request: prompt, recent_context: '' }
88 return JSON.stringify(provider === 'typesafe' ? { model, state, questions: qs } : { state, questions: qs })
89}
90
91export function requestHeaders(provider: Provider, apiKey: string, model: string): Record<string, string> {
92 const common = { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }
93 if (provider === 'typesafe') return common
94 return {
95 ...common,
96 'ai-gateway-protocol-version': AI_GATEWAY_PROTOCOL_VERSION,
97 'ai-gateway-auth-method': 'api-key',
98 'ai-model-id': model,
99 'ai-evaluation-model-specification-version': '4',
100 }
101}
102
103/** What the request answered. */
104export interface Decision {
105 /** Skills with their probability, surest first; a lone choice when the backend sent no distribution. */
106 ranked: { name: string; probability: number | null }[]
107 /** Mean of the oriented gate answers, or null when none came back. */
108 gate: number | null
109}
110
111type Answers = Record<string, Record<string, unknown>>
112
113export function readDecision(responseText: string): Decision | null {
114 let answers: Answers | undefined
115 try {
116 answers = (JSON.parse(responseText) as { answers?: Answers }).answers
117 } catch {
118 return null
119 }
120 const which = answers?.which
121 if (!answers || !which || typeof which.choice !== 'string') return null
122
123 const ranked: Decision['ranked'] = []
124 const probabilities = which.probabilities as Record<string, number> | undefined
125 if (probabilities && typeof probabilities === 'object') {
126 for (const [name, probability] of Object.entries(probabilities)) {
127 if (typeof probability === 'number') ranked.push({ name, probability })
128 }
129 ranked.sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0))
130 }
131 if (ranked.length === 0) {
132 ranked.push({ name: which.choice, probability: typeof which.confidence === 'number' ? which.confidence : null })
133 }
134
135 const oriented: number[] = []
136 for (const [key, g] of Object.entries(GATE)) {
137 const a = answers[`gate::${key}`]
138 const value = typeof a?.noul === 'number' ? a.noul : typeof a?.probability === 'number' ? a.probability : null
139 if (value !== null) oriented.push(g.isInverted ? 1 - value : value)
140 }
141 const gate = oriented.length > 0 ? oriented.reduce((x, y) => x + y, 0) / oriented.length : null
142 return { ranked, gate }
143}
144
145export interface Thresholds {
146 /** Mean gate under which no skill is expected to be used. */
147 gate: number
148 /** Probability the top choice needs, when the backend reports one. */
149 confidence: number
150}
151
152/** The decision as the band shows it: the skill that will be used, or none. */
153export function verdictOf(decision: Decision, known: ReadonlySet<string>, limits: Thresholds): string | null {
154 if (decision.gate !== null && decision.gate < limits.gate) return null
155 const top = decision.ranked.find((r) => known.has(r.name))
156 if (!top) return null
157 if (top.probability !== null && top.probability < limits.confidence) return null
158 return top.name
159}
160
161/** The classifier text for `$.model.classify`: it takes bare labels, so the descriptions ride in the text. */
162export function classifyText(prompt: string, skills: readonly Skill[]): string {
163 return [
164 'Which skill or subagent (agent:name), going by its description, should the model call before working on the prompt below? Answer "none" unless the prompt is clearly the kind of task a description names.',
165 '',
166 'Candidates:',
167 ...skills.map((s) => `- ${keyOf(s)}: ${s.description.replace(/\s+/g, ' ').slice(0, 200)}`),
168 `- ${NONE}: nothing listed is about this prompt`,
169 '',
170 'Prompt:',
171 prompt,
172 ].join('\n')
173}
174hooks/policy.ts 265 lines1/**
2 * jev-skill-typeahead — the pure part: what the draft in the prompt box means
3 * and which skills and subagents it points at. No `$` in here; the hook module
4 * does the I/O and these functions are what the tests exercise.
5 *
6 * Only what the MODEL calls on its own is a candidate: skills (through the
7 * Skill tool) and subagent types (through the Agent tool). Slash commands the
8 * person runs by typing `/name` are not: nothing needs to guess those. A draft
9 * that starts with `/`, `!` or `#` is a command, a shell line or a memory note
10 * and gets no band.
11 *
12 * The keyword match is deliberately plain (IDF-weighted term overlap, name
13 * hits worth 2.5x a description hit, the word still being typed matched as a
14 * prefix): it runs on every keystroke with no network. Deciding which one the
15 * model WILL call is Jev's job once the person pauses (see jev.ts).
16 */
17import type { Origin, Row } from '../types'
18
19export interface Skill {
20 name: string
21 description: string
22 origin: Origin
23}
24
25/** What the draft is, as far as the band is concerned. */
26export interface Draft {
27 mode: 'idle' | 'prose'
28 /** The prose to match: code fences and URLs removed, the tail kept. */
29 prose: string
30}
31
32const IDLE: Draft = { mode: 'idle', prose: '' }
33
34/** Words of a prompt that point at nothing, English and Spanish. */
35const STOP = new Set(
36 (
37 'a about all also an and any are as at be been but by can could do does for from had has have how i if in into is it its just like make me my no not of on one or our out please so some that the their them then there these they this to up us use was we what when where which who will with would you your ' +
38 'al algo ahora aqui asi aunque como con cual cuando de del desde donde el ella ellos en es esa ese eso esta este esto estoy fue ha hay hacer hace la las le les lo los mas me mi muy nos para pero por porque que quiero se si sin sobre son su sus te tiene tu tus un una uno unos van ver voy ya yo'
39 ).split(' '),
40)
41
42/** A Spanish word and the English words skills are described in. */
43const ALIASES: Record<string, string> = {
44 prueba: 'test', pruebas: 'test', probar: 'test', testear: 'test',
45 documento: 'document doc', documentos: 'document doc', documentacion: 'documentation docs',
46 presentacion: 'presentation slides deck pptx', presentaciones: 'presentation slides deck pptx', diapositivas: 'slides deck pptx',
47 hoja: 'spreadsheet xlsx', planilla: 'spreadsheet xlsx', calculo: 'spreadsheet xlsx', excel: 'spreadsheet xlsx',
48 revisar: 'review', revision: 'review', revisa: 'review',
49 arreglar: 'fix bug', corregir: 'fix bug', arregla: 'fix bug', error: 'error bug', errores: 'error bug', falla: 'bug failure',
50 seguridad: 'security', vulnerabilidad: 'security vulnerability', vulnerabilidades: 'security vulnerability',
51 desplegar: 'deploy', despliegue: 'deploy', publicar: 'publish deploy release',
52 informe: 'report', reporte: 'report', diseno: 'design', disenar: 'design',
53 datos: 'data database', correo: 'email mail', rama: 'branch', fusionar: 'merge', cambios: 'diff changes',
54 limpiar: 'clean cleanup', refactorizar: 'refactor', rendimiento: 'performance optimize', optimizar: 'optimize performance',
55 migrar: 'migrate migration', migracion: 'migrate migration', imagen: 'image', imagenes: 'image',
56 grafico: 'chart plot', graficos: 'chart plot', escribir: 'write', resumir: 'summarize summary', resumen: 'summary summarize',
57 traducir: 'translate translation', buscar: 'search find', instalar: 'install', configurar: 'configure config setting',
58 configuracion: 'configuration config setting', contrasena: 'password', agente: 'agent', agentes: 'agent',
59 commit: 'commit git', confirmar: 'commit', subir: 'push upload', pdf: 'pdf', tabla: 'table', archivo: 'file', archivos: 'file',
60 tarea: 'task', tareas: 'task', plan: 'plan', planificar: 'plan planning', investigar: 'research', analizar: 'analyze analysis',
61 crear: 'create', generar: 'generate create', nuevo: 'new create', componente: 'component',
62}
63
64const SUFFIXES = [
65 'ations', 'ation', 'ciones', 'cion', 'ments', 'ment', 'ings', 'ing', 'ando', 'iendo',
66 'ados', 'adas', 'idos', 'idas', 'ado', 'ada', 'ido', 'ida', 'ed', 'es', 's',
67]
68
69/** Lowercase, accents off: `presentación` and `presentacion` are one word. */
70export function fold(text: string): string {
71 return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
72}
73
74/** A light stem, applied the same way to the draft and to every skill. */
75export function stem(word: string): string {
76 if (word.length <= 3) return word
77 for (const suffix of SUFFIXES) {
78 if (word.endsWith(suffix) && word.length - suffix.length >= 3) return word.slice(0, -suffix.length)
79 }
80 return word
81}
82
83function words(text: string): string[] {
84 return fold(text).split(/[^a-z0-9]+/).filter(Boolean)
85}
86
87/** What the draft is: see the header of this file. */
88export function readDraft(text: string, minWords = 2): Draft {
89 const t = text.replace(/^\s+/, '')
90 if (!t || /^[/!#]/.test(t)) return IDLE
91 const prose = t
92 .replace(/```[\s\S]*?(```|$)/g, ' ')
93 .replace(/https?:\/\/\S+/g, ' ')
94 .slice(-600)
95 const content = words(prose).filter((w) => !STOP.has(w))
96 return content.length >= minWords || prose.trim().length >= 24 ? { mode: 'prose', prose } : IDLE
97}
98
99/** The search structure of one roster: each skill's words and how common each word is. */
100export interface Index {
101 skills: Skill[]
102 docs: { name: Set<string>; desc: Set<string>; raw: string }[]
103 idf: Map<string, number>
104 /** Idf of a word no skill has. */
105 unseen: number
106}
107
108export function buildIndex(skills: readonly Skill[]): Index {
109 const docs = skills.map((skill) => ({
110 name: new Set(words(skill.name).map(stem)),
111 desc: new Set(words(skill.description).filter((w) => !STOP.has(w)).map(stem)),
112 raw: fold(skill.name),
113 }))
114 const df = new Map<string, number>()
115 for (const doc of docs) {
116 for (const term of new Set([...doc.name, ...doc.desc])) df.set(term, (df.get(term) ?? 0) + 1)
117 }
118 const n = Math.max(1, docs.length)
119 const idf = new Map<string, number>()
120 for (const [term, count] of df) idf.set(term, Math.log(1 + n / (1 + count)))
121 return { skills: [...skills], docs, idf, unseen: Math.log(1 + n) }
122}
123
124interface Term {
125 stem: string
126 /** The word as the person typed it, for display. */
127 shown: string
128 weight: number
129 isPartial: boolean
130}
131
132/** The query's terms: its words (the one still being typed as a prefix) and their English aliases. */
133export function termsOf(prose: string): Term[] {
134 const endsOpen = /[a-zA-Z0-9À-ſ]$/.test(prose)
135 const raw = fold(prose).split(/[^a-z0-9]+/).filter(Boolean)
136 const out = new Map<string, Term>()
137 raw.forEach((word, i) => {
138 if (STOP.has(word)) return
139 const isPartial = endsOpen && i === raw.length - 1 && word.length >= 3
140 const s = stem(word)
141 if (!out.has(s)) out.set(s, { stem: s, shown: word, weight: isPartial ? 0.6 : 1, isPartial })
142 for (const alias of (ALIASES[word] ?? '').split(' ').filter(Boolean)) {
143 const a = stem(alias)
144 if (!out.has(a)) out.set(a, { stem: a, shown: word, weight: 0.8, isPartial: false })
145 }
146 })
147 return [...out.values()]
148}
149
150export interface Hit {
151 skill: Skill
152 /** 0 to 100: how much of the draft this skill's name and description cover. */
153 score: number
154 hits: string[]
155}
156
157/** Keyword rank of the roster against a prose draft, best first. */
158export function rankProse(index: Index, prose: string, limit = 5, floor = 12): Hit[] {
159 const terms = termsOf(prose)
160 if (terms.length === 0) return []
161 const lower = fold(prose)
162 const total = terms.reduce((sum, t) => sum + (index.idf.get(t.stem) ?? index.unseen) * t.weight, 0)
163 const found: Hit[] = []
164 index.docs.forEach((doc, i) => {
165 let raw = 0
166 const hits = new Set<string>()
167 for (const t of terms) {
168 const idf = index.idf.get(t.stem) ?? index.unseen
169 let strength = 0
170 if (doc.name.has(t.stem)) strength = 2.5
171 else if (doc.desc.has(t.stem)) strength = 1
172 else if (t.isPartial) {
173 const starts = (set: Set<string>) => [...set].some((w) => w.startsWith(t.stem))
174 strength = starts(doc.name) ? 1.5 : starts(doc.desc) ? 0.6 : 0
175 }
176 if (strength > 0) {
177 raw += idf * t.weight * strength
178 hits.add(t.shown)
179 }
180 }
181 // The skill named outright ("use the pdf skill", "run commit").
182 if (doc.raw.length >= 3 && new RegExp(`(^|[^a-z0-9])${doc.raw.replace(/[^a-z0-9]/g, '.')}($|[^a-z0-9])`).test(lower)) {
183 raw += total * 1.5
184 hits.add(doc.raw)
185 }
186 if (raw === 0 || total === 0) return
187 const score = Math.min(100, Math.round((100 * (raw / total)) / 1.6))
188 if (score >= floor) found.push({ skill: index.skills[i], score, hits: [...hits] })
189 })
190 return found.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name)).slice(0, limit)
191}
192
193/** One band row from a hit. */
194export function toRow(hit: Hit, isChosen: boolean, descriptionChars = 140): Row {
195 const description = hit.skill.description.replace(/\s+/g, ' ').trim()
196 return {
197 name: hit.skill.name,
198 description: description.length > descriptionChars ? `${description.slice(0, descriptionChars - 1)}…` : description,
199 origin: hit.skill.origin,
200 score: hit.score,
201 hits: hit.hits.slice(0, 4),
202 isChosen,
203 }
204}
205
206/**
207 * The skills the model may call, from the engine's `skill_listing` attachment:
208 * a header line, then one `- name: description` per skill, a description
209 * possibly running over several lines. A name with a colon
210 * (`engineering:code-review`) belongs to a plugin.
211 */
212export function parseListing(text: string): Skill[] {
213 const skills: Skill[] = []
214 let current: Skill | null = null
215 for (const raw of text.split('\n')) {
216 const line = raw.trimEnd()
217 const entry = /^- (\S+?)(?::\s(.*))?$/.exec(line)
218 if (entry) {
219 const name = entry[1] as string
220 current = { name, description: (entry[2] ?? '').trim(), origin: name.includes(':') ? 'plugin' : 'user' }
221 skills.push(current)
222 } else if (current && line.trim()) {
223 current.description = `${current.description} ${line.trim()}`.trim()
224 }
225 }
226 return skills
227}
228
229/**
230 * The skills before the engine's listing has been seen (it is rendered at the
231 * turn's first request): the commands the engine offers, less the built-ins.
232 * That list also holds commands only the person can run, so this is the
233 * fallback, and the listing replaces it as soon as it arrives.
234 */
235export function skillsFromCommands(
236 commands: readonly { name: string; description: string; source: string }[],
237): Skill[] {
238 return commands
239 .filter((c) => c.source !== 'builtin' && !/\s/.test(c.name))
240 .map((c) => ({ name: c.name, description: (c.description ?? '').trim(), origin: c.source === 'plugin' ? 'plugin' as const : 'user' as const }))
241}
242
243/** The key a candidate answers to in a decision request: a subagent shares no namespace with a skill. */
244export function keyOf(item: { name: string; origin: Origin }): string {
245 return item.origin === 'agent' ? `agent:${item.name}` : item.name
246}
247
248/** What a roster is built from: the skills, the subagent types, and the names the person left out. */
249export function rosterOf(skills: readonly Skill[], agents: readonly Skill[], excluded: ReadonlySet<string>): Skill[] {
250 const seen = new Set<string>()
251 const out: Skill[] = []
252 for (const item of [...skills, ...agents]) {
253 const key = `${item.origin === 'agent' ? 'agent' : 'skill'}:${item.name}`
254 if (excluded.has(item.name) || seen.has(key)) continue
255 seen.add(key)
256 out.push(item)
257 }
258 return out
259}
260
261/** Comma-separated option → set of names. */
262export function parseNames(option: string): Set<string> {
263 return new Set(option.split(',').map((s) => s.trim().replace(/^\//, '')).filter(Boolean))
264}
265types/index.d.ts 41 lines1/**
2 * The contract of the values this mod keeps in `$.state`: what the band above
3 * the prompt draws from. The type is imported by the module from '../types'.
4 */
5
6/** Where a candidate comes from: a user or project skill, a plugin's skill, a subagent type. */
7export type Origin = 'user' | 'plugin' | 'agent'
8
9export interface Row {
10 name: string
11 description: string
12 origin: Origin
13 /** 0 to 100: Jev's probability when it answered, the keyword match otherwise; -1 when the backend reported none. */
14 score: number
15 /** The words of the draft that matched. */
16 hits: string[]
17 /** True for the one the mod expects the model to call. */
18 isChosen: boolean
19}
20
21export interface View {
22 /** `prose` while a draft is being matched; `idle` for an empty box, a command or a draft too short to read (the band says which). */
23 mode: 'idle' | 'prose'
24 /** The draft the rows were computed for. */
25 draft: string
26 rows: Row[]
27 /** `live` keyword match, `thinking` a decision is in flight, `decided` Jev (or the built-in classifier) answered, `none` it answered that nothing is needed, `offline` the decision failed. */
28 phase: 'live' | 'thinking' | 'decided' | 'none' | 'offline'
29 /** Who decided: 'jev' or 'builtin'; empty while live. */
30 by: string
31 /** How many skills and subagents were considered. */
32 skills: number
33 agents: number
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'jev-skill-typeahead': { view: View }
39 }
40}
41