skills-atlas-cli 0.8.4 → 0.8.7
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/package.json +1 -1
- package/src/commands/suggest.js +9 -8
- package/src/gapstate.js +50 -4
- package/src/search-core.js +81 -2
package/package.json
CHANGED
package/src/commands/suggest.js
CHANGED
|
@@ -20,8 +20,7 @@ const transcripts = require('../transcripts');
|
|
|
20
20
|
const gapstate = require('../gapstate');
|
|
21
21
|
|
|
22
22
|
const COOLDOWN = 3; // min prompts between suggestions
|
|
23
|
-
const GAP_EVERY = 12;
|
|
24
|
-
const NUDGE_COOLDOWN_MS = 24 * 3600000; // and at most one gap nudge per day (across sessions)
|
|
23
|
+
const GAP_EVERY = 12; // a gap nudge is only considered at every Nth prompt (per session)
|
|
25
24
|
|
|
26
25
|
function stateFile(sessionId) {
|
|
27
26
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
@@ -58,12 +57,14 @@ module.exports = async function suggest() {
|
|
|
58
57
|
state.count = (state.count || 0) + 1;
|
|
59
58
|
const ap = registry.getAutopilot();
|
|
60
59
|
|
|
61
|
-
// --- Proactive gap nudge: periodic
|
|
60
|
+
// --- Proactive gap nudge: periodic, and refired when the recurring work shifts
|
|
61
|
+
// to something new (anti-spam floor + activity fingerprint, not a daily clock) ---
|
|
62
62
|
if (ap.gapAlerts && state.count % GAP_EVERY === 0) {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
const
|
|
66
|
-
|
|
63
|
+
const recent = transcripts.recentPrompts({ max: 20 });
|
|
64
|
+
if (recent.length >= 8) {
|
|
65
|
+
const gs = gapstate.read();
|
|
66
|
+
const { fire, sig } = gapstate.shouldNudge(gs, recent, Date.now());
|
|
67
|
+
if (fire) {
|
|
67
68
|
const dismissed = gs.dismissed || [];
|
|
68
69
|
const lines = recent.map(r => `- ${r.text.replace(/\s+/g, ' ').slice(0, 100)}`).join('\n');
|
|
69
70
|
const days = Math.max(1, Math.round((Date.now() - recent[recent.length - 1].ts) / 86400000));
|
|
@@ -73,7 +74,7 @@ module.exports = async function suggest() {
|
|
|
73
74
|
`\`skills-atlas info <skill>\` and install with \`skills-atlas use <skill> --yes\`.` +
|
|
74
75
|
(dismissed.length ? ` Already dismissed (skip): ${dismissed.join(', ')}.` : '') +
|
|
75
76
|
` If nothing clearly recurs or it doesn't fit right now, stay silent.`);
|
|
76
|
-
gapstate.touchNudge();
|
|
77
|
+
gapstate.touchNudge(sig);
|
|
77
78
|
writeState(file, state);
|
|
78
79
|
return;
|
|
79
80
|
}
|
package/src/gapstate.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// The only thing capability-gaps persists: which suggestions the user dismissed,
|
|
2
|
-
//
|
|
2
|
+
// when we last proactively nudged, and a fingerprint of what the user was doing at
|
|
3
|
+
// that nudge. (Judgment is Claude's; no prompt text is stored — only token stems.)
|
|
3
4
|
'use strict';
|
|
4
5
|
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const os = require('os');
|
|
7
8
|
const path = require('path');
|
|
9
|
+
const { tokenize } = require('./search-core');
|
|
10
|
+
|
|
11
|
+
// Gap nudges are gated by activity, not a wall clock: a short anti-spam floor, then
|
|
12
|
+
// a refire whenever the recurring work shifts to something new (so it can catch the
|
|
13
|
+
// user on their NEXT task), plus a long fallback so a persistent gap can resurface.
|
|
14
|
+
const MIN_INTERVAL_MS = 90 * 60 * 1000; // ~90 min: never two nudges in a burst
|
|
15
|
+
const REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000; // ~12 h: an unchanged gap may resurface
|
|
8
16
|
|
|
9
17
|
function file() {
|
|
10
18
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
@@ -19,7 +27,45 @@ function write(s) {
|
|
|
19
27
|
}
|
|
20
28
|
function dismiss(x) { const s = read(); if (x && !s.dismissed.includes(x)) s.dismissed.push(x); write(s); return s; }
|
|
21
29
|
function isDismissed(x) { return read().dismissed.includes(x); }
|
|
22
|
-
function touchNudge() { const s = read(); s.lastNudge = Date.now(); write(s); }
|
|
23
|
-
function clear() { write({ dismissed: [], lastNudge: 0 }); }
|
|
30
|
+
function touchNudge(sig) { const s = read(); s.lastNudge = Date.now(); if (sig) s.lastSig = sig; write(s); }
|
|
31
|
+
function clear() { write({ dismissed: [], lastNudge: 0, lastSig: [] }); }
|
|
32
|
+
|
|
33
|
+
// A coarse fingerprint of recent work: the most frequent contentful tokens across
|
|
34
|
+
// recent prompts. When this set shifts, the user has moved to a new kind of work.
|
|
35
|
+
function activitySignature(prompts, topN = 8) {
|
|
36
|
+
const freq = new Map();
|
|
37
|
+
for (const p of prompts || []) {
|
|
38
|
+
const text = typeof p === 'string' ? p : (p && p.text) || '';
|
|
39
|
+
for (const t of tokenize(String(text).toLowerCase())) freq.set(t, (freq.get(t) || 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
return [...freq.entries()]
|
|
42
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
43
|
+
.slice(0, topN).map(e => e[0]).sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Did the dominant activity shift by more than half (Jaccard < 0.5)? No prior → yes.
|
|
47
|
+
function signatureShifted(sig, prev) {
|
|
48
|
+
if (!prev || !prev.length) return true;
|
|
49
|
+
const a = new Set(sig);
|
|
50
|
+
if (!a.size) return false;
|
|
51
|
+
const b = new Set(prev);
|
|
52
|
+
let inter = 0; for (const x of a) if (b.has(x)) inter++;
|
|
53
|
+
const union = new Set([...a, ...b]).size || 1;
|
|
54
|
+
return inter / union < 0.5;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Smart-refire decision (pure). Fire when past the anti-spam floor AND either the
|
|
58
|
+
// activity shifted to something new, or the long fallback has elapsed.
|
|
59
|
+
function shouldNudge(state, recent, now) {
|
|
60
|
+
const sig = activitySignature(recent);
|
|
61
|
+
const since = now - ((state && state.lastNudge) || 0);
|
|
62
|
+
if (since < MIN_INTERVAL_MS) return { fire: false, sig };
|
|
63
|
+
const fire = signatureShifted(sig, state && state.lastSig) || since >= REFRESH_INTERVAL_MS;
|
|
64
|
+
return { fire, sig };
|
|
65
|
+
}
|
|
24
66
|
|
|
25
|
-
module.exports = {
|
|
67
|
+
module.exports = {
|
|
68
|
+
file, read, write, dismiss, isDismissed, touchNudge, clear,
|
|
69
|
+
activitySignature, signatureShifted, shouldNudge,
|
|
70
|
+
MIN_INTERVAL_MS, REFRESH_INTERVAL_MS,
|
|
71
|
+
};
|
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
|
|