skills-atlas-cli 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,14 +110,20 @@ just describe what you need, or use `/skills-atlas:skill-search`, `:skill-info`,
110
110
  skills-atlas hook on # enable (skills-atlas hook off / status)
111
111
  ```
112
112
 
113
- Registers a Claude Code `UserPromptSubmit` hook. When what you ask **strongly
114
- matches** a catalog skill you don't have, Claude gets a one-line note and can
115
- offer to install + activate it you don't have to know the skill exists. It's:
113
+ Registers a Claude Code `UserPromptSubmit` hook. When what you ask matches the
114
+ territory of a catalog skill you don't have, the hook hands Claude a short
115
+ shortlist of candidates and **Claude decides** whether any genuinely fits and
116
+ if so offers to install + activate it. You don't have to know the skill exists.
117
+ The split is deliberate: the hook does **recall** (a distinctive-word match
118
+ against the catalog, so the right skill is on the table), Claude does
119
+ **precision** (it understands your intent and stays silent unless one truly
120
+ fits, or searches further itself). It's:
116
121
 
117
122
  - **off by default** — you turn it on explicitly; `hook off` removes it cleanly.
118
- - **quiet** — only fires on a confident match, never for an already-installed
119
- skill, never the same skill twice, with a cooldown between suggestions, and
120
- **Claude still decides** whether it's relevant enough to mention.
123
+ - **quiet** — only fires on a distinctive match (greetings and generic actions
124
+ like "fix the typo" stay silent), never for an already-installed skill, never
125
+ the same skill twice, with a cooldown between suggestions — and Claude is the
126
+ final filter on relevance.
121
127
  - **local & private** — your prompt is matched against the bundled catalog
122
128
  on your machine; nothing is sent anywhere.
123
129
  - **safe** — never auto-installs (always your call), and fails open (a hook
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Search, install and learn AI agent skills from the terminal — powered by the Skills Atlas catalog.",
5
5
  "bin": {
6
6
  "skills-atlas": "bin/skills.js",
@@ -1,9 +1,10 @@
1
1
  // `skills-atlas suggest` — the autopilot. Runs as a Claude Code UserPromptSubmit
2
- // hook: reads the event JSON from stdin, matches the prompt against the catalog
3
- // locally, and (if a strong, fresh, not-installed match survives a cooldown)
4
- // emits a one-line additionalContext for Claude to weigh. ALWAYS exits 0 and
5
- // never blocks the user (fail-open). Matching is local-only the prompt is
6
- // never sent anywhere.
2
+ // hook: reads the event JSON from stdin, retrieves a SHORTLIST of catalog skills
3
+ // that may fit the prompt (recall), and injects them as additionalContext for
4
+ // Claude to judge (precision) Claude offers one only if it genuinely fits, and
5
+ // can run `skills-atlas search` itself to look further. ALWAYS exits 0 and never
6
+ // blocks the user (fail-open). Matching is local-only — the prompt is never sent
7
+ // anywhere.
7
8
  'use strict';
8
9
 
9
10
  const fs = require('fs');
@@ -11,7 +12,7 @@ const os = require('os');
11
12
  const path = require('path');
12
13
  const { loadData } = require('../data');
13
14
  const { buildIndices } = require('../index-build');
14
- const { pickSuggestion } = require('../search-core');
15
+ const { suggestCandidates } = require('../search-core');
15
16
  const manifest = require('../manifest');
16
17
  const fsu = require('../fsutil');
17
18
 
@@ -57,20 +58,25 @@ module.exports = async function suggest() {
57
58
  for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installed.add(e.skill);
58
59
  const suggested = new Set(state.suggested || []);
59
60
 
60
- const pick = pickSuggestion(flatRows, prompt, { installed, suggested });
61
- if (!pick) { writeState(file, state); return; }
61
+ const { fire, candidates } = suggestCandidates(flatRows, prompt, { installed, suggested });
62
+ if (!fire) { writeState(file, state); return; }
62
63
 
63
- const uc = pick.row.use_case_en || pick.row.use_case || '';
64
+ const lines = candidates.map(c => {
65
+ const uc = (c.row.use_case_en || c.row.use_case || '').replace(/\s+/g, ' ').trim().slice(0, 80);
66
+ return `- ${c.skill}${uc ? ` — ${uc}` : ''} (install+activate: \`skills-atlas use ${c.skill} --yes\`)`;
67
+ }).join('\n');
64
68
  const ctx =
65
- `[Skills Atlas autopilot] The user's task may fit the installable agent skill ` +
66
- `\`${pick.skill}\`${uc ? ` (catalog use-case: "${uc}")` : ''}. ONLY if it's clearly ` +
67
- `relevant to what they actually asked, briefly offer it — they can install and ` +
68
- `activate it now with \`skills-atlas use ${pick.skill} --yes\`. If it isn't a strong ` +
69
- `fit, don't mention it.`;
69
+ `[Skills Atlas autopilot] The user may be doing something one of these installable agent ` +
70
+ `skills is built for. Judge for yourself do NOT mention any of this unless one of them ` +
71
+ `genuinely fits what they actually asked:\n${lines}\n` +
72
+ `If one clearly fits, briefly offer it and (with the user's ok) install it via its command. ` +
73
+ `If none fit but the task plainly needs a specialized skill, you may run ` +
74
+ `\`skills-atlas search "<short intent>"\` to look further. If nothing fits, say nothing about ` +
75
+ `this at all — do not mention this hook, these skills, or that a suggestion was made.`;
70
76
  console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } }));
71
77
 
72
78
  state.lastSuggestedCount = state.count;
73
- state.suggested = [...suggested, pick.skill];
79
+ state.suggested = [...suggested, ...candidates.map(c => c.skill)];
74
80
  writeState(file, state);
75
81
  } catch {
76
82
  // fail-open: never break the user's workflow
@@ -154,43 +154,118 @@ function searchRows(rows, opts = {}) {
154
154
  return runSearch(rows, opts).rows;
155
155
  }
156
156
 
157
- // Autopilot: pick the single best skill to proactively suggest for a free-text
158
- // prompt, or null. Conservative on purpose requires a STRONG, multi-signal
159
- // match (an exact skill-name token, OR ≥2 distinct query tokens hitting the
160
- // high-signal fields name/group/use_case), so a single common word can't
161
- // trigger it. Skips already-installed and already-suggested skills.
162
- function pickSuggestion(rows, prompt, { installed = new Set(), suggested = new Set() } = {}) {
157
+ // Generic words that, on their OWN, must not make the autopilot fire: common
158
+ // English verbs/nouns that often appear inside skill names but carry no domain
159
+ // intent ("fix the typo", "build the app", "design a system" stay quiet). They
160
+ // can still ride along in a multi-word match; they just can't trigger alone.
161
+ const ANCHOR_STOP = new Set([
162
+ 'fix', 'make', 'build', 'create', 'add', 'remove', 'delete', 'update', 'change',
163
+ 'changes', 'set', 'run', 'clean', 'rename', 'move', 'copy', 'write', 'review',
164
+ 'improve', 'optimize', 'check', 'format', 'handle', 'manage', 'generate', 'edit',
165
+ 'open', 'start', 'stop', 'ship', 'render', 'page', 'file', 'files', 'line', 'load',
166
+ 'code', 'app', 'apps', 'work', 'thing', 'things', 'stuff', 'data', 'system',
167
+ 'design', 'document', 'component', 'components', 'feature', 'features', 'variable',
168
+ 'function', 'project', 'task', 'tool', 'name', 'names',
169
+ ]);
170
+
171
+ // Word-aware skill-NAME match: return the matched name segment (or null). Stricter
172
+ // than substring — generic fragments must NOT match inside a name ("line" ≠
173
+ // "guide-LINE-s"). Stem matching only for tokens/segments ≥5 chars so
174
+ // "debug"~"debugging" matches but "line"~"linear" does not. Plural-tolerant.
175
+ function matchedSegment(skillName, token) {
176
+ const segs = lc(skillName).split(/[^a-z0-9]+/).filter(Boolean);
177
+ let hit = null;
178
+ for (const seg of segs) {
179
+ if (seg === token) return seg; // exact wins
180
+ if ((token.length >= 5 && seg.startsWith(token)) || // debug → debugging
181
+ (seg.length >= 5 && token.startsWith(seg)) || // requesting ← request
182
+ (token.length > 3 && token.endsWith('s') && seg === token.slice(0, -1)) || // tests → test
183
+ (seg.length > 3 && seg.endsWith('s') && token === seg.slice(0, -1))) hit = seg; // test → tests
184
+ }
185
+ return hit;
186
+ }
187
+ const nameWordHit = (skillName, token) => matchedSegment(skillName, token) !== null;
188
+
189
+ // Document frequency of each name segment across all skills (memoized per rows
190
+ // array). Distinctive segments ("brainstorm", "mortem", "figma") appear in few
191
+ // skills → high IDF; generic ones ("user", "test") in many → low IDF.
192
+ const _dfCache = new WeakMap();
193
+ function segmentDf(rows) {
194
+ let info = _dfCache.get(rows);
195
+ if (info) return info;
196
+ const df = new Map();
197
+ let n = 0;
198
+ for (const r of rows) for (const s of r.skills || []) {
199
+ n++;
200
+ for (const seg of new Set(lc(s).split(/[^a-z0-9]+/).filter(Boolean))) df.set(seg, (df.get(seg) || 0) + 1);
201
+ }
202
+ info = { df, n: n || 1 };
203
+ _dfCache.set(rows, info);
204
+ return info;
205
+ }
206
+ const idfOf = (info, seg) => Math.log(1 + info.n / ((info.df.get(seg) || 0) + 1));
207
+
208
+ const FIRE_IDF = 4.2; // a single distinctive name word must clear this to fire alone
209
+
210
+ // Autopilot recall: collect a SHORTLIST of catalog skills that may fit a free-text
211
+ // prompt, for Claude to judge (we do recall; Claude does precision). Returns
212
+ // { fire, candidates: [{skill, row}], weak }. Sources, in order:
213
+ // 1. skill-NAME matches, scored by summed IDF of the matched words and sorted
214
+ // so the most distinctive multi-word match leads (paper-slide-deck beats a
215
+ // one-word "research" match), then
216
+ // 2. the general ranked search (runSearch) fills remaining slots.
217
+ // `fire` is true only when there's a distinctive anchor (a rare name word, or ≥2
218
+ // matched name words) or an otherwise strong (non-weak) match — so the hook stays
219
+ // quiet on greetings and generic dev actions ("fix the typo", "build the app").
220
+ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = new Set(), limit = 5 } = {}) {
163
221
  const tokens = tokenize(lc(prompt));
164
- if (tokens.length < 2) return null; // too vague to suggest confidently
165
- const inName = (name, ts) => { const n = lc(name); return ts.filter(t => n.includes(t)).length; };
222
+ if (tokens.length < 2) return { fire: false, candidates: [], weak: false };
223
+ const taken = new Set([...installed, ...suggested]);
224
+ const info = segmentDf(rows);
166
225
 
167
- let best = null;
168
- for (const r of rows) {
169
- const f = buildFields(r);
170
- const skillSet = new Set((r.skills || []).map(lc));
171
- let nameHits = 0, otherHits = 0, exact = false;
226
+ // 1. name anchors — ONLY contentful (non-generic) name words count, weighted by
227
+ // distinctiveness. A skill matched purely by a generic word ("optimize",
228
+ // "design", "system") is not an anchor at all, so generic words can neither
229
+ // fire the hook nor crowd the shortlist.
230
+ const anchors = [];
231
+ for (const r of rows) for (const s of r.skills || []) {
232
+ if (taken.has(s)) continue;
233
+ let weight = 0, strong = 0;
234
+ const usedSeg = new Set();
172
235
  for (const t of tokens) {
173
- if (skillSet.has(t)) exact = true;
174
- if (fieldHas(f.name, t)) nameHits++; // skill-name match = strongest signal
175
- else if (fieldHas(f.group, t) || fieldHas(f.use, t)) otherHits++;
236
+ const seg = matchedSegment(s, t);
237
+ if (!seg || usedSeg.has(seg)) continue;
238
+ usedSeg.add(seg);
239
+ if (ANCHOR_STOP.has(t) || ANCHOR_STOP.has(seg)) continue; // generic word — ignore
240
+ strong++;
241
+ weight += idfOf(info, seg);
176
242
  }
177
- // Require a skill-NAME signal (or an exact name token). Two ordinary words
178
- // co-occurring in some row's verbose prose is NOT enough — that's the noise.
179
- if (!exact && !(nameHits >= 1 && nameHits + otherHits >= 2)) continue;
180
- const score = nameHits * 15 + otherHits * 6 + (exact ? 50 : 0) + maxStars(r) / 1e7;
181
- if (!best || score > best.score) best = { row: r, score };
243
+ if (strong) anchors.push({ skill: s, row: r, weight, strong });
182
244
  }
183
- if (!best) return null;
184
-
185
- // pick the most on-point skill in the winning row that isn't already in play
186
- const cand = (best.row.skills || []).filter(s => !installed.has(s) && !suggested.has(s));
187
- if (!cand.length) return null;
188
- cand.sort((a, b) => inName(b, tokens) - inName(a, tokens));
189
- // the suggested skill's own name must overlap the prompt, else it's a mispick
190
- if (inName(cand[0], tokens) === 0) return null;
191
- return { skill: cand[0], row: best.row };
245
+ anchors.sort((a, b) => b.weight - a.weight || maxStars(b.row) - maxStars(a.row));
246
+
247
+ const out = [];
248
+ const seen = new Set(taken);
249
+ const push = (skill, row) => { if (!seen.has(skill)) { seen.add(skill); out.push({ skill, row }); } };
250
+ for (const a of anchors) { if (out.length >= limit) break; push(a.skill, a.row); }
251
+
252
+ // 2. fill from the general ranked search (one primary skill per row)
253
+ const { rows: ranked, weak } = runSearch(rows, { query: prompt });
254
+ for (const r of ranked) {
255
+ if (out.length >= limit) break;
256
+ const s = (r.skills || []).find(x => !seen.has(x));
257
+ if (s) push(s, r);
258
+ }
259
+
260
+ // fire decision: only on a real name anchor — two contentful name words, OR one
261
+ // distinctive (high-IDF) one. A prompt with mere prose overlap and no name
262
+ // signal stays silent (better a miss than noise on every generic prompt); the
263
+ // ranked search still ENRICHES the shortlist once an anchor has fired.
264
+ const fire = out.length > 0 && anchors.some(a => a.strong >= 2 || a.weight >= FIRE_IDF);
265
+ return { fire, candidates: out.slice(0, limit), weak };
192
266
  }
193
267
 
194
268
  module.exports = {
195
- tokenize, searchRows, runSearch, scoreRow, buildFields, maxStars, pickSuggestion, PERSONA_ALIAS,
269
+ tokenize, searchRows, runSearch, scoreRow, buildFields, maxStars,
270
+ nameWordHit, matchedSegment, suggestCandidates, PERSONA_ALIAS,
196
271
  };