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 +12 -6
- package/package.json +1 -1
- package/src/commands/suggest.js +21 -15
- package/src/search-core.js +106 -31
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
package/src/commands/suggest.js
CHANGED
|
@@ -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,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// never sent
|
|
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 {
|
|
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
|
|
61
|
-
if (!
|
|
61
|
+
const { fire, candidates } = suggestCandidates(flatRows, prompt, { installed, suggested });
|
|
62
|
+
if (!fire) { writeState(file, state); return; }
|
|
62
63
|
|
|
63
|
-
const
|
|
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
|
|
66
|
-
|
|
67
|
-
`
|
|
68
|
-
`
|
|
69
|
-
`fit,
|
|
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,
|
|
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
|
package/src/search-core.js
CHANGED
|
@@ -154,43 +154,118 @@ function searchRows(rows, opts = {}) {
|
|
|
154
154
|
return runSearch(rows, opts).rows;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
165
|
-
const
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
if (!
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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,
|
|
269
|
+
tokenize, searchRows, runSearch, scoreRow, buildFields, maxStars,
|
|
270
|
+
nameWordHit, matchedSegment, suggestCandidates, PERSONA_ALIAS,
|
|
196
271
|
};
|