SLOPSHOPPER

jev-skill-typeahead

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…

newpanebandcommandpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-skill-typeahead
│ ┃ Skill typeahead ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ Claude may call 0 of 0 skills │ ┃ skill:user ● · skill:plugin ◆ · subagent ▣ ● jev-skill-typeahead: [jev-skill-typeahead] ready: suggesting what C │ ┃ ⏺ Read(src/auth.ts) │ ┃ no skills or subagents are offered to ⎿ Read 6 lines │ ┃ Claude in this session ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ skill:user ● · skill:plugin ◆ · subagent ▣ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /jev-skill-typeahead │ ⎿ jev-skill-typeahead: Skill typeahead details opened. │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ✦ Claude may call 0 of 0 skills │ │ no skills or subagents are offered to Claude in this session │ │ skill:user ● · skill:plugin ◆ · subagent ▣ details: /jev-skill-typeahead │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ✦ Claude may call 0 of 0 skills │ │ no skills or subagents are offered to Claude in this session │ │ skill:user ● · skill:plugin ◆ · subagent ▣ details: /jev-skill-typeahead │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
Pane · Skill typeahead
✦ Claude may call 0 of 0 skills skill:user ● · skill:plugin ◆ · subagent ▣ no skills or subagents are offered to Claude in this session skill:user ● · skill:plugin ◆ · subagent ▣
README

jev-skill-typeahead

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.

Details pane

/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.

What counts

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.

What the band does while you type

You typeThe band
nothing, /…, !…, #…, one or two stray wordsstays, 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 boardranks 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 msJev 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.

What the footer tells you

The band never claims more than it knows:

FooterMeaning
keyword match · pause for Jev to decideinstant, 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 decidedthe answer: the ▶ row is expected to be called, with the probability Jev reported (a dash when the backend reports none)
nothing needed for thisthe gate said prose is enough; no row is marked
decision unavailable · keyword matchthe 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.

Deciding what will be called

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).

providerEndpointNeeds
auto (default)TypeSafe if its key is set, else the Gateway, else keywords only
typesafePOST api.typesafe.ai/v1/systemone, model jev-latesttypesafeApiKey
gatewayPOST ai-gateway.vercel.sh/v4/ai/evaluation-model, model typesafe-ai/jevgatewayApiKey
builtinClaude Code's own small model through $.model.classify, one request per pausenothing
keywordsnever decidesnothing

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.

Telling the model (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.

Install

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).

Privacy

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.

Limits

  • It suggests what the engine lists for the model. A skill hidden from the model (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.
  • Keyword matching is IDF-weighted term overlap, not understanding; the Spanish table covers common task words, not the language. Jev's decision is the part that understands.
  • The band is drawn on the terminal and desktop surfaces. Rows use fixed-width cells, with no-break spaces off the terminal so desktop HTML keeps the alignment.
  • Pasted text is expanded at submit, so a pasted draft never matches its decision and gets no attach note.

Development

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.

Source 4 files
hooks/register.tsx 559 lines
1/**
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}
559
hooks/jev.ts 174 lines
1/**
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}
174
hooks/policy.ts 265 lines
1/**
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}
265
types/index.d.ts 41 lines
1/**
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