skills-atlas-cli 0.8.5 → 0.8.8
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/bin/skills.js +3 -1
- package/package.json +1 -1
- package/src/commands/hook.js +7 -6
- package/src/commands/prune.js +107 -0
- package/src/commands/suggest.js +22 -0
- package/src/prunestate.js +26 -0
- package/src/registry.js +1 -1
- package/src/search-core.js +81 -2
package/bin/skills.js
CHANGED
|
@@ -15,6 +15,7 @@ const registry = require('../src/commands/registry');
|
|
|
15
15
|
const suggest = require('../src/commands/suggest');
|
|
16
16
|
const hook = require('../src/commands/hook');
|
|
17
17
|
const gaps = require('../src/commands/gaps');
|
|
18
|
+
const prune = require('../src/commands/prune');
|
|
18
19
|
const update = require('../src/commands/update');
|
|
19
20
|
const mcp = require('../src/commands/mcp');
|
|
20
21
|
const { categories, list } = require('../src/commands/categories');
|
|
@@ -22,7 +23,7 @@ const { categories, list } = require('../src/commands/categories');
|
|
|
22
23
|
const VERSION = require('../package.json').version;
|
|
23
24
|
// `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
|
|
24
25
|
const use = argv => install([...argv, '--inline']);
|
|
25
|
-
const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, gaps, update, categories, list, registry, mcp };
|
|
26
|
+
const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, gaps, prune, update, categories, list, registry, mcp };
|
|
26
27
|
|
|
27
28
|
const HELP = `skills-atlas — search, install & manage AI agent skills
|
|
28
29
|
|
|
@@ -46,6 +47,7 @@ manage what you've installed:
|
|
|
46
47
|
autopilot (opt-in):
|
|
47
48
|
hook on|off|status proactively suggest a skill in Claude when your prompt fits one
|
|
48
49
|
gaps kinds of work you keep doing without a skill (run: skills-atlas hook on)
|
|
50
|
+
prune installed skills you no longer use — Claude suggests removing them
|
|
49
51
|
|
|
50
52
|
catalog:
|
|
51
53
|
update refresh the catalog from the public data feed
|
package/package.json
CHANGED
package/src/commands/hook.js
CHANGED
|
@@ -23,18 +23,18 @@ const isOurs = e => e && Array.isArray(e.hooks)
|
|
|
23
23
|
|
|
24
24
|
module.exports = async function hook(argv) {
|
|
25
25
|
const { values, positionals } = parse(argv, ['json']);
|
|
26
|
-
if (values.help) { console.log('usage: skills-atlas hook <on|off|status|suggest on|off|gaps on|off>'); return; }
|
|
26
|
+
if (values.help) { console.log('usage: skills-atlas hook <on|off|status|suggest on|off|gaps on|off|prune on|off>'); return; }
|
|
27
27
|
const sub = positionals[0] || 'status';
|
|
28
28
|
const p = settingsPath();
|
|
29
29
|
|
|
30
|
-
if (sub === 'suggest' || sub === 'gaps') {
|
|
30
|
+
if (sub === 'suggest' || sub === 'gaps' || sub === 'prune') {
|
|
31
31
|
const onoff = positionals[1];
|
|
32
32
|
if (onoff !== 'on' && onoff !== 'off') {
|
|
33
33
|
console.error(`usage: skills-atlas hook ${sub} <on|off>`); process.exitCode = 1; return;
|
|
34
34
|
}
|
|
35
|
-
const key = sub === 'suggest' ? 'suggest' : 'gapAlerts';
|
|
35
|
+
const key = sub === 'suggest' ? 'suggest' : sub === 'gaps' ? 'gapAlerts' : 'prune';
|
|
36
36
|
registry.setAutopilot({ [key]: onoff === 'on' });
|
|
37
|
-
const label = sub === 'suggest' ? 'per-prompt autopilot' : 'gap alerts';
|
|
37
|
+
const label = sub === 'suggest' ? 'per-prompt autopilot' : sub === 'gaps' ? 'gap alerts' : 'prune suggestions';
|
|
38
38
|
console.log(`${green('✓')} ${label}: ${onoff === 'on' ? green('on') : dim('off')}`);
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
@@ -48,11 +48,12 @@ module.exports = async function hook(argv) {
|
|
|
48
48
|
if (on) {
|
|
49
49
|
console.log(` per-prompt suggest: ${ap.suggest ? green('on') : dim('off')} ${dim('(skills-atlas hook suggest on|off)')}`);
|
|
50
50
|
console.log(` gap alerts: ${ap.gapAlerts ? green('on') : dim('off')} ${dim('(skills-atlas hook gaps on|off)')}`);
|
|
51
|
-
console.log(
|
|
51
|
+
console.log(` prune suggestions: ${ap.prune ? green('on') : dim('off')} ${dim('(skills-atlas hook prune on|off)')}`);
|
|
52
|
+
console.log(dim(' review: skills-atlas gaps · skills-atlas prune'));
|
|
52
53
|
} else {
|
|
53
54
|
// Hook isn't registered, so these sub-toggles don't do anything yet — don't
|
|
54
55
|
// imply the autopilot is running. Show them dimmed with the caveat.
|
|
55
|
-
console.log(dim(` (suggest ${ap.suggest ? 'on' : 'off'}, gap alerts ${ap.gapAlerts ? 'on' : 'off'} — they take effect once you run 'skills-atlas hook on')`));
|
|
56
|
+
console.log(dim(` (suggest ${ap.suggest ? 'on' : 'off'}, gap alerts ${ap.gapAlerts ? 'on' : 'off'}, prune ${ap.prune ? 'on' : 'off'} — they take effect once you run 'skills-atlas hook on')`));
|
|
56
57
|
console.log(dim('enable: skills-atlas hook on'));
|
|
57
58
|
}
|
|
58
59
|
return;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `skills-atlas prune` — the inverse of `gaps`. Surface the user's INSTALLED skills
|
|
2
|
+
// (with install age + what each does) plus their recent activity, and a judging
|
|
3
|
+
// instruction, so CLAUDE spots skills that no longer fit the user's work and offers
|
|
4
|
+
// to remove them. We provide the data; Claude judges. NEVER deletes. No network.
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { parse } = require('../args');
|
|
8
|
+
const { loadData } = require('../data');
|
|
9
|
+
const { buildIndices, rowsFor } = require('../index-build');
|
|
10
|
+
const fsu = require('../fsutil');
|
|
11
|
+
const manifest = require('../manifest');
|
|
12
|
+
const transcripts = require('../transcripts');
|
|
13
|
+
const prunestate = require('../prunestate');
|
|
14
|
+
const { dim, green } = require('../format');
|
|
15
|
+
|
|
16
|
+
const FRESH_DAYS = 14; // never suggest removing something installed this recently
|
|
17
|
+
|
|
18
|
+
const HELP = `usage: skills-atlas prune [dismiss <skill> | clear]
|
|
19
|
+
|
|
20
|
+
Surface your installed skills + recent Claude Code activity so Claude can spot ones
|
|
21
|
+
you no longer use and offer to remove them. Run it in Claude Code (or ask Claude
|
|
22
|
+
"any skills I can clean up?"). Never deletes anything — you confirm each removal.
|
|
23
|
+
|
|
24
|
+
prune review installed skills vs recent activity → Claude suggests
|
|
25
|
+
prune dismiss <skill> stop suggesting to remove one
|
|
26
|
+
prune clear reset dismissals
|
|
27
|
+
--json`;
|
|
28
|
+
|
|
29
|
+
// Installed skills (both scopes) worth reviewing: older than FRESH_DAYS and not
|
|
30
|
+
// dismissed, annotated with install age + what each does (from the catalog).
|
|
31
|
+
function reviewList(data, dismissed, now) {
|
|
32
|
+
const idx = buildIndices(data);
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const s of fsu.scopesFor({})) {
|
|
35
|
+
for (const e of manifest.list(s.root)) {
|
|
36
|
+
if (dismissed.includes(e.skill)) continue;
|
|
37
|
+
const ageDays = e.installedAt ? Math.floor((now - Date.parse(e.installedAt)) / 86400000) : null;
|
|
38
|
+
if (ageDays !== null && ageDays >= 0 && ageDays < FRESH_DAYS) continue; // give new installs time
|
|
39
|
+
const row = rowsFor(idx.skillIndex, e.skill)[0];
|
|
40
|
+
out.push({
|
|
41
|
+
skill: e.skill,
|
|
42
|
+
scope: s.name,
|
|
43
|
+
ageDays,
|
|
44
|
+
use: row ? (row.use_case_en || row.use_case || '') : '',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const instruction = dismissed =>
|
|
52
|
+
`Each skill above is installed but might no longer fit what the user is doing. From their recent ` +
|
|
53
|
+
`activity, flag any installed skill whose domain hasn't come up lately and they're unlikely to need ` +
|
|
54
|
+
`right now: name it, say briefly why it looks unused, and offer to remove it with ` +
|
|
55
|
+
`\`skills-atlas remove <skill>\` (add --project for a project-scoped one). NEVER remove anything ` +
|
|
56
|
+
`yourself — always let the user decide.` +
|
|
57
|
+
(dismissed.length ? ` Already dismissed (skip these): ${dismissed.join(', ')}.` : '') +
|
|
58
|
+
` If every installed skill still fits their work, say nothing needs pruning.`;
|
|
59
|
+
|
|
60
|
+
// The full text block handed to Claude (shared by the command and the hook nudge).
|
|
61
|
+
function digestText(installed, recent, dismissed) {
|
|
62
|
+
const skills = installed.map(s =>
|
|
63
|
+
` - ${s.skill} [${s.scope}]${s.ageDays != null ? ` · installed ${s.ageDays}d ago` : ''}` +
|
|
64
|
+
`${s.use ? ` · ${s.use.replace(/\s+/g, ' ').slice(0, 70)}` : ''}`).join('\n');
|
|
65
|
+
const activity = recent.length
|
|
66
|
+
? recent.slice(0, 30).map(r => ` - ${r.text.replace(/\s+/g, ' ').slice(0, 100)}`).join('\n')
|
|
67
|
+
: ' (no recent activity found)';
|
|
68
|
+
return `[Skills Atlas — prune] Installed skills (older than ${FRESH_DAYS}d, newest activity below):\n${skills}\n\n` +
|
|
69
|
+
`The user's recent activity (newest first):\n${activity}\n\n${instruction(dismissed)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = async function pruneCmd(argv) {
|
|
73
|
+
const { values, positionals } = parse(argv, ['json']);
|
|
74
|
+
if (values.help) { console.log(HELP); return; }
|
|
75
|
+
const sub = positionals[0];
|
|
76
|
+
|
|
77
|
+
if (sub === 'clear') {
|
|
78
|
+
prunestate.clear();
|
|
79
|
+
console.log(values.json ? JSON.stringify({ cleared: true }) : `${green('✓')} dismissals reset.`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (sub === 'dismiss') {
|
|
83
|
+
const x = positionals.slice(1).join(' ');
|
|
84
|
+
if (!x) { console.error('usage: skills-atlas prune dismiss <skill>'); process.exitCode = 1; return; }
|
|
85
|
+
prunestate.dismiss(x);
|
|
86
|
+
console.log(values.json ? JSON.stringify({ dismissed: x }) : `${green('✓')} won't suggest removing: ${x}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { data } = loadData({ quiet: values.json });
|
|
91
|
+
const dismissed = prunestate.read().dismissed || [];
|
|
92
|
+
const installed = reviewList(data, dismissed, Date.now());
|
|
93
|
+
const recent = transcripts.recentPrompts({ max: 60 });
|
|
94
|
+
|
|
95
|
+
if (values.json) { console.log(JSON.stringify({ installed, recent, dismissed }, null, 2)); return; }
|
|
96
|
+
|
|
97
|
+
if (!installed.length) {
|
|
98
|
+
console.log(dim(`nothing to review — no skills installed longer than ${FRESH_DAYS} days.`));
|
|
99
|
+
console.log(dim('see what you have: skills-atlas installed'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
console.log('\n' + digestText(installed, recent, dismissed));
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
module.exports.reviewList = reviewList;
|
|
106
|
+
module.exports.digestText = digestText;
|
|
107
|
+
module.exports.FRESH_DAYS = FRESH_DAYS;
|
package/src/commands/suggest.js
CHANGED
|
@@ -18,6 +18,8 @@ const fsu = require('../fsutil');
|
|
|
18
18
|
const registry = require('../registry');
|
|
19
19
|
const transcripts = require('../transcripts');
|
|
20
20
|
const gapstate = require('../gapstate');
|
|
21
|
+
const prunestate = require('../prunestate');
|
|
22
|
+
const prune = require('./prune');
|
|
21
23
|
|
|
22
24
|
const COOLDOWN = 3; // min prompts between suggestions
|
|
23
25
|
const GAP_EVERY = 12; // a gap nudge is only considered at every Nth prompt (per session)
|
|
@@ -81,6 +83,26 @@ module.exports = async function suggest() {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// --- Proactive prune nudge: installed skills that may no longer fit the user's
|
|
87
|
+
// work. Offset from the gap check so the two never fire on the same prompt. ---
|
|
88
|
+
if (ap.prune && state.count % GAP_EVERY === Math.floor(GAP_EVERY / 2)) {
|
|
89
|
+
const recent = transcripts.recentPrompts({ max: 30 });
|
|
90
|
+
if (recent.length >= 8) {
|
|
91
|
+
const ps = prunestate.read();
|
|
92
|
+
const { fire, sig } = gapstate.shouldNudge(ps, recent, Date.now());
|
|
93
|
+
if (fire) {
|
|
94
|
+
const { data } = loadData({ quiet: true });
|
|
95
|
+
const installed = prune.reviewList(data, ps.dismissed || [], Date.now());
|
|
96
|
+
if (installed.length) {
|
|
97
|
+
emit(prune.digestText(installed, recent, ps.dismissed || []));
|
|
98
|
+
prunestate.touchNudge(sig);
|
|
99
|
+
writeState(file, state);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
84
106
|
// --- Per-prompt autopilot (gated by the suggest toggle + cooldown) ---
|
|
85
107
|
if (!ap.suggest) { writeState(file, state); return; }
|
|
86
108
|
if (state.count - (state.lastSuggestedCount ?? -COOLDOWN) < COOLDOWN) { writeState(file, state); return; }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Persistence for prune suggestions: which removals the user dismissed, when we
|
|
2
|
+
// last nudged, and the activity fingerprint at that nudge (so a nudge can refire
|
|
3
|
+
// when work shifts to a new task). The refire policy is shared with gaps.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
function file() {
|
|
11
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
12
|
+
return path.join(base, 'skills-atlas', 'prune.json');
|
|
13
|
+
}
|
|
14
|
+
function read() {
|
|
15
|
+
try { const s = JSON.parse(fs.readFileSync(file(), 'utf8')); if (!Array.isArray(s.dismissed)) s.dismissed = []; return s; }
|
|
16
|
+
catch { return { dismissed: [], lastNudge: 0 }; }
|
|
17
|
+
}
|
|
18
|
+
function write(s) {
|
|
19
|
+
try { fs.mkdirSync(path.dirname(file()), { recursive: true }); fs.writeFileSync(file(), JSON.stringify(s)); } catch { /* ignore */ }
|
|
20
|
+
}
|
|
21
|
+
function dismiss(x) { const s = read(); if (x && !s.dismissed.includes(x)) s.dismissed.push(x); write(s); return s; }
|
|
22
|
+
function isDismissed(x) { return read().dismissed.includes(x); }
|
|
23
|
+
function touchNudge(sig) { const s = read(); s.lastNudge = Date.now(); if (sig) s.lastSig = sig; write(s); }
|
|
24
|
+
function clear() { write({ dismissed: [], lastNudge: 0, lastSig: [] }); }
|
|
25
|
+
|
|
26
|
+
module.exports = { file, read, write, dismiss, isDismissed, touchNudge, clear };
|
package/src/registry.js
CHANGED
|
@@ -76,7 +76,7 @@ function removeCachedSource(url) {
|
|
|
76
76
|
// config.json; read-modify-write the full object so registry `sources` is preserved.
|
|
77
77
|
function getAutopilot() {
|
|
78
78
|
const c = readConfig();
|
|
79
|
-
return { suggest: true, gapAlerts: true, ...(c.autopilot || {}) };
|
|
79
|
+
return { suggest: true, gapAlerts: true, prune: false, ...(c.autopilot || {}) };
|
|
80
80
|
}
|
|
81
81
|
function setAutopilot(patch) {
|
|
82
82
|
const c = readConfig();
|
package/src/search-core.js
CHANGED
|
@@ -215,6 +215,43 @@ const idfOf = (info, seg) => Math.log(1 + info.n / ((info.df.get(seg) || 0) + 1)
|
|
|
215
215
|
|
|
216
216
|
const FIRE_IDF = 4.2; // a single distinctive name word must clear this to fire alone
|
|
217
217
|
|
|
218
|
+
// --- Content anchors (match by FUNCTION, not just name) ----------------------
|
|
219
|
+
// ~a third of catalog skills have opaque names (sentry/grill-me/get-shit-done) that
|
|
220
|
+
// don't contain their function, and Chinese prompts never match an English name at
|
|
221
|
+
// all. So besides the skill NAME, anchor on the curated function text — use_case /
|
|
222
|
+
// group / when, in BOTH languages — with the same distinctiveness gate as names.
|
|
223
|
+
// A content match must include at least one DISTINCTIVE function word to fire, so
|
|
224
|
+
// generic prose overlap stays silent.
|
|
225
|
+
const CONTENT_FIRE_IDF = 4.6; // weight bar when only one distinctive word matched (+ a 2nd word)
|
|
226
|
+
const CONTENT_DISTINCT_IDF = 3.5; // a word this distinctive (~≤10 rows) counts toward "strong"
|
|
227
|
+
|
|
228
|
+
// Generic Chinese words that must not fire on their own — the CJK analog of
|
|
229
|
+
// ANCHOR_STOP (casual verbs + generic nouns that carry no domain intent).
|
|
230
|
+
const CJK_ANCHOR_STOP = new Set([
|
|
231
|
+
'看看', '看下', '看一', '帮忙', '处理', '解决', '完成', '搞定', '试试', '弄一', '做个', '做一',
|
|
232
|
+
'写个', '写一', '加个', '改改', '改一', '删掉', '运行', '创建', '生成', '修改', '优化', '检查',
|
|
233
|
+
'一下', '一个', '这个', '那个', '东西', '问题', '代码', '文件', '内容', '功能', '项目', '任务',
|
|
234
|
+
'系统', '方法', '工具', '数据', '需要', '想要', '怎么', '如何', '可以', '应该', '一些', '这些',
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
// Tokens of a row's curated short function text (NOT the long description — keep it
|
|
238
|
+
// distinctive), both languages, for the corpus DF and for matching a query.
|
|
239
|
+
const rowContent = r =>
|
|
240
|
+
tokenize(lc([r.use_case, r.use_case_en, r.group, r.group_en, r.when_to_use, r.when_to_use_en].filter(Boolean).join(' ')));
|
|
241
|
+
const contentHas = (set, t) => set.has(t) || (t.length > 3 && t.endsWith('s') && set.has(t.slice(0, -1)));
|
|
242
|
+
|
|
243
|
+
const _contentDfCache = new WeakMap();
|
|
244
|
+
function contentDf(rows) {
|
|
245
|
+
let info = _contentDfCache.get(rows);
|
|
246
|
+
if (info) return info;
|
|
247
|
+
const df = new Map();
|
|
248
|
+
let n = 0;
|
|
249
|
+
for (const r of rows) { n++; for (const t of new Set(rowContent(r))) df.set(t, (df.get(t) || 0) + 1); }
|
|
250
|
+
info = { df, n: n || 1 };
|
|
251
|
+
_contentDfCache.set(rows, info);
|
|
252
|
+
return info;
|
|
253
|
+
}
|
|
254
|
+
|
|
218
255
|
// Autopilot recall: collect a SHORTLIST of catalog skills that may fit a free-text
|
|
219
256
|
// prompt, for Claude to judge (we do recall; Claude does precision). Returns
|
|
220
257
|
// { fire, candidates: [{skill, row}], weak }. Sources, in order:
|
|
@@ -252,10 +289,49 @@ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = ne
|
|
|
252
289
|
}
|
|
253
290
|
anchors.sort((a, b) => b.weight - a.weight || maxStars(b.row) - maxStars(a.row));
|
|
254
291
|
|
|
292
|
+
// 1b. content anchors — match the curated FUNCTION text (use_case / group / when),
|
|
293
|
+
// so opaque-named skills are findable by what they do and Chinese prompts match at
|
|
294
|
+
// all. Generic words (ANCHOR_STOP / CJK_ANCHOR_STOP) are excluded up front.
|
|
295
|
+
const contentTokens = tokens.filter(t => !ANCHOR_STOP.has(t) && !CJK_ANCHOR_STOP.has(t));
|
|
296
|
+
const contentAnchors = [];
|
|
297
|
+
if (contentTokens.length) {
|
|
298
|
+
const cdf = contentDf(rows);
|
|
299
|
+
for (const r of rows) {
|
|
300
|
+
const content = new Set(rowContent(r));
|
|
301
|
+
let weight = 0, strong = 0, matched = 0;
|
|
302
|
+
for (const t of new Set(contentTokens)) {
|
|
303
|
+
if (!contentHas(content, t)) continue;
|
|
304
|
+
matched++;
|
|
305
|
+
const idf = idfOf(cdf, t);
|
|
306
|
+
weight += idf;
|
|
307
|
+
if (idf >= CONTENT_DISTINCT_IDF) strong++; // only distinctive words count as "strong"
|
|
308
|
+
}
|
|
309
|
+
if (strong) contentAnchors.push({ row: r, weight, strong, matched });
|
|
310
|
+
}
|
|
311
|
+
// Prefer rows matching MORE distinctive function words over an incidental hit.
|
|
312
|
+
contentAnchors.sort((a, b) => b.strong - a.strong || b.weight - a.weight || maxStars(b.row) - maxStars(a.row));
|
|
313
|
+
}
|
|
314
|
+
// Fire/qualify only with a distinctive function match: two distinctive words, or
|
|
315
|
+
// one distinctive word backed by a second matched word and enough total weight.
|
|
316
|
+
// (A single distinctive word alone never fires — too easy to hit by coincidence.)
|
|
317
|
+
const contentQualifies = a => a.strong >= 2 || (a.strong >= 1 && a.matched >= 2 && a.weight >= CONTENT_FIRE_IDF);
|
|
318
|
+
|
|
319
|
+
// Merge name + qualifying content anchors into ONE shortlist ranked by strength,
|
|
320
|
+
// so a strong function match (grill-me: interrogate + stress-test) outranks a
|
|
321
|
+
// single-word name match (launch) when it's the better fit. runSearch backfills.
|
|
322
|
+
const ranked0 = [];
|
|
323
|
+
for (const a of anchors) ranked0.push({ skill: a.skill, row: a.row, strong: a.strong, weight: a.weight });
|
|
324
|
+
for (const a of contentAnchors) {
|
|
325
|
+
if (!contentQualifies(a)) continue;
|
|
326
|
+
const s = (a.row.skills || []).find(x => !taken.has(x));
|
|
327
|
+
if (s) ranked0.push({ skill: s, row: a.row, strong: a.strong, weight: a.weight });
|
|
328
|
+
}
|
|
329
|
+
ranked0.sort((a, b) => b.strong - a.strong || b.weight - a.weight || maxStars(b.row) - maxStars(a.row));
|
|
330
|
+
|
|
255
331
|
const out = [];
|
|
256
332
|
const seen = new Set(taken);
|
|
257
333
|
const push = (skill, row) => { if (!seen.has(skill)) { seen.add(skill); out.push({ skill, row }); } };
|
|
258
|
-
for (const a of
|
|
334
|
+
for (const a of ranked0) { if (out.length >= limit) break; push(a.skill, a.row); }
|
|
259
335
|
|
|
260
336
|
// 2. fill remaining slots from the general ranked search — but only with rows
|
|
261
337
|
// that are actually on-topic (a name/group hit, or strong coverage). A lone
|
|
@@ -276,7 +352,10 @@ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = ne
|
|
|
276
352
|
// distinctive (high-IDF) one. A prompt with mere prose overlap and no name
|
|
277
353
|
// signal stays silent (better a miss than noise on every generic prompt); the
|
|
278
354
|
// ranked search still ENRICHES the shortlist once an anchor has fired.
|
|
279
|
-
const fire = out.length > 0 &&
|
|
355
|
+
const fire = out.length > 0 && (
|
|
356
|
+
anchors.some(a => a.strong >= 2 || a.weight >= FIRE_IDF) ||
|
|
357
|
+
contentAnchors.some(contentQualifies)
|
|
358
|
+
);
|
|
280
359
|
return { fire, candidates: out.slice(0, limit), weak };
|
|
281
360
|
}
|
|
282
361
|
|