clementine-agent 1.18.163 → 1.18.164

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.
@@ -4546,6 +4546,38 @@ export async function cmdDashboard(opts) {
4546
4546
  res.status(500).json({ ok: false, error: String(err) });
4547
4547
  }
4548
4548
  });
4549
+ // 1.18.164 — skill quality scoring per Anthropic metrics. Computed on
4550
+ // demand from the cron run log; no schema, no persistence. Bulk
4551
+ // endpoint for the Skills page table; per-skill endpoint for the
4552
+ // detail pane. Both registered BEFORE /api/skills/:name to win route
4553
+ // precedence (literal segment 'quality' beats the :name placeholder).
4554
+ app.get('/api/skills/quality', async (req, res) => {
4555
+ try {
4556
+ const { computeAllSkillQuality } = await import('../memory/skill-quality.js');
4557
+ const windowDays = req.query.windowDays ? Math.max(1, Math.min(365, Number(req.query.windowDays))) : undefined;
4558
+ const scores = computeAllSkillQuality(windowDays ? { windowDays } : {});
4559
+ res.json({ ok: true, count: scores.length, scores });
4560
+ }
4561
+ catch (err) {
4562
+ res.status(500).json({ ok: false, error: String(err) });
4563
+ }
4564
+ });
4565
+ app.get('/api/skills/:name/quality', async (req, res) => {
4566
+ try {
4567
+ const name = req.params.name;
4568
+ if (!name) {
4569
+ res.status(400).json({ ok: false, error: 'name required' });
4570
+ return;
4571
+ }
4572
+ const { computeSkillQuality } = await import('../memory/skill-quality.js');
4573
+ const windowDays = req.query.windowDays ? Math.max(1, Math.min(365, Number(req.query.windowDays))) : undefined;
4574
+ const score = computeSkillQuality(name, windowDays ? { windowDays } : {});
4575
+ res.json({ ok: true, score });
4576
+ }
4577
+ catch (err) {
4578
+ res.status(500).json({ ok: false, error: String(err) });
4579
+ }
4580
+ });
4549
4581
  app.get('/api/skills/:name', async (req, res) => {
4550
4582
  try {
4551
4583
  const name = req.params.name;
@@ -29621,11 +29653,53 @@ async function showSkillDetail(name) {
29621
29653
  detailEl.innerHTML = renderSkillDetail(d.skill);
29622
29654
  if (typeof loadSkillSuppressionState === 'function') loadSkillSuppressionState(name);
29623
29655
  if (typeof loadSkillScheduleState === 'function') loadSkillScheduleState(name);
29656
+ if (typeof loadSkillQualityState === 'function') loadSkillQualityState(name);
29624
29657
  } catch (e) {
29625
29658
  detailEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Error: ' + esc(String(e)) + '</div>';
29626
29659
  }
29627
29660
  }
29628
29661
 
29662
+ // 1.18.164 — fetch + render the skill quality scorecard. Best-effort:
29663
+ // renders nothing if the container is absent or the fetch errors. We
29664
+ // hit the per-skill endpoint here (fast, single skill) instead of the
29665
+ // bulk one — the Skills page already has its own bulk render path.
29666
+ async function loadSkillQualityState(skillName) {
29667
+ var container = document.getElementById('skill-quality-' + encodeURIComponent(skillName));
29668
+ if (!container) return;
29669
+ try {
29670
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(skillName) + '/quality');
29671
+ var d = await r.json();
29672
+ if (!r.ok || d.ok === false || !d.score) return;
29673
+ var s = d.score;
29674
+ var gradeColors = { good: '#10b981', underperforming: '#ef4444', stale: '#f59e0b', 'no-data': '#6b7280' };
29675
+ var gradeLabel = (s.grade || 'no-data').replace(/-/g, ' ');
29676
+ var color = gradeColors[s.grade] || '#6b7280';
29677
+ var pct = function(v) { return v === null || v === undefined ? '—' : (v * 100).toFixed(0) + '%'; };
29678
+ var ms = function(v) { return v === null || v === undefined ? '—' : (v < 1000 ? v + 'ms' : (v / 1000).toFixed(1) + 's'); };
29679
+ var usd = function(v) { return v === null || v === undefined ? '—' : '$' + v.toFixed(4); };
29680
+ var rows = [
29681
+ ['Total runs', s.totalRuns],
29682
+ ['Pinned / auto', s.pinnedRuns + ' / ' + s.autoRuns],
29683
+ ['Success rate', pct(s.successRate)],
29684
+ ['Trigger accuracy', s.triggerAccuracy === null ? '— (no auto-matched runs)' : pct(s.triggerAccuracy)],
29685
+ ['Avg duration', ms(s.avgDurationMs)],
29686
+ ['Avg cost', usd(s.avgCostUsd)],
29687
+ ];
29688
+ container.innerHTML =
29689
+ '<div style="font-weight:600;margin-bottom:6px;display:flex;align-items:center;gap:8px">' +
29690
+ '<span>Quality (' + s.windowDays + 'd)</span>' +
29691
+ '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:' + color + ';color:#fff;text-transform:uppercase">' + esc(gradeLabel) + '</span>' +
29692
+ '</div>' +
29693
+ '<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">' + esc(s.gradeReason || '') + '</div>' +
29694
+ '<table style="width:100%;font-size:12px;border-collapse:collapse">' +
29695
+ rows.map(function(r) {
29696
+ return '<tr><td style="padding:3px 0;color:var(--text-muted);width:50%">' + esc(r[0]) + '</td>' +
29697
+ '<td style="padding:3px 0;text-align:right;font-family:ui-monospace,monospace">' + esc(String(r[1])) + '</td></tr>';
29698
+ }).join('') +
29699
+ '</table>';
29700
+ } catch (e) { /* best-effort — leave empty */ }
29701
+ }
29702
+
29629
29703
  // 1.18.127 — fetch the current suppression state and wire the checkbox.
29630
29704
  // Cached per call; the file is small enough that re-fetching on every
29631
29705
  // detail open is fine (and ensures consistency if the user just toggled
@@ -29983,6 +30057,14 @@ function renderSkillDetail(s) {
29983
30057
  html += '</div>';
29984
30058
  html += '</div>';
29985
30059
 
30060
+ // 1.18.164 — Quality scorecard (per Anthropic skill metrics).
30061
+ // Lazy-loaded: rendered as a placeholder, populated by
30062
+ // loadSkillQualityState() right after the detail pane mounts. The
30063
+ // grade chip (good / underperforming / stale / no-data) tells the
30064
+ // owner at a glance whether this skill is pulling its weight; the
30065
+ // table beneath has the supporting numbers for drilling in.
30066
+ html += '<div id="skill-quality-' + encodeURIComponent(fm.name) + '" style="margin-top:10px;padding:12px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-muted)">Loading quality…</div>';
30067
+
29986
30068
  // ── 2. Validation warnings (if any)
29987
30069
  if (Array.isArray(s.validation) && s.validation.length > 0) {
29988
30070
  var errors = s.validation.filter(function(v) { return v.severity === 'error'; });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Skill quality scoring per Anthropic skill metrics (1.18.164).
3
+ *
4
+ * Anthropic's skill spec calls for tracking per-skill quality with a few
5
+ * specific metrics: trigger accuracy, success rate, average tool calls,
6
+ * average tokens, failure rate per workflow. Today we have the raw data
7
+ * (CronRunEntry stamps `skillsApplied: [{name, source}]` on every run
8
+ * since 1.18.85) but never aggregate it into a "how is this skill
9
+ * actually performing?" view.
10
+ *
11
+ * This module computes the metrics on demand from the existing run log —
12
+ * no new schema, no new persistence. The Skills page card surfaces the
13
+ * scores so the owner can spot:
14
+ * - Skills that auto-trigger but don't help (low trigger accuracy)
15
+ * - Skills that are pinned but consistently fail (low success rate)
16
+ * - Skills with no recent activity ("stale")
17
+ * - Skills with no data at all ("no-data" — fresh; may be unused)
18
+ *
19
+ * The grade is a coarse 4-bucket label optimized for "what should the
20
+ * owner do about this skill?" rather than a precise number. Detailed
21
+ * stats accompany so the owner can drill in.
22
+ *
23
+ * Why no SQLite table:
24
+ * - The data already exists in CronRunLog jsonl files
25
+ * - Recompute is cheap (one-time scan over recent jsonl)
26
+ * - Avoids a new schema migration + the risk of double-counting if
27
+ * we forget to write to it from one of the run paths
28
+ * - Owner isn't running this 100×/sec — the dashboard hits it once
29
+ * when the Skills page renders
30
+ *
31
+ * If the volume ever grows past ~50 skills × 500 runs/day, we can
32
+ * promote to SQLite. Until then, keep it simple.
33
+ */
34
+ /** Default rolling window for quality computation. Anthropic suggests
35
+ * a 30-day evaluation horizon for skill metrics; that matches our
36
+ * cron-run-log retention so we read what we have. */
37
+ export declare const DEFAULT_WINDOW_DAYS = 30;
38
+ export interface SkillQualityScore {
39
+ /** Skill identifier (the `name` field from frontmatter). */
40
+ name: string;
41
+ /** Window the metrics cover. */
42
+ windowDays: number;
43
+ /** Total runs in the window where this skill was applied. */
44
+ totalRuns: number;
45
+ /** Of those, runs where the skill was explicitly pinned by the cron. */
46
+ pinnedRuns: number;
47
+ /** Of those, runs where the skill was auto-matched by the search layer. */
48
+ autoRuns: number;
49
+ /** Runs we count as successful (status='ok' AND goalCheck didn't fail). */
50
+ successRuns: number;
51
+ /** Runs we count as failed (status in error/timeout/lost OR goalCheck.fail). */
52
+ failureRuns: number;
53
+ /** successRuns / totalRuns — null when totalRuns is 0. */
54
+ successRate: number | null;
55
+ /** Among auto-matched runs only, what fraction succeeded. Anthropic's
56
+ * "trigger accuracy" — how often the auto-match was the right call.
57
+ * null when there are no auto-matched runs in the window. */
58
+ triggerAccuracy: number | null;
59
+ /** Average duration in ms across runs that completed (not 'running'). */
60
+ avgDurationMs: number | null;
61
+ /** Average cost in USD across runs that report it. */
62
+ avgCostUsd: number | null;
63
+ /** Most recent ISO timestamp this skill was applied to a run. */
64
+ lastUsedAt: string | null;
65
+ /**
66
+ * Coarse 4-bucket label for owner attention:
67
+ * - 'good' — enough runs, success rate above threshold
68
+ * - 'underperforming' — enough runs, success rate below threshold
69
+ * - 'stale' — no runs in the last STALE_DAYS regardless of past stats
70
+ * - 'no-data' — fewer than MIN_RUNS_FOR_GRADE runs in the window
71
+ */
72
+ grade: 'good' | 'underperforming' | 'stale' | 'no-data';
73
+ /** One-sentence reason for the grade — surfaces under the badge. */
74
+ gradeReason: string;
75
+ }
76
+ /**
77
+ * Compute quality scores for a single skill. Returns the aggregate even
78
+ * when there's no data — graded 'no-data' so the dashboard can render
79
+ * a clean empty state.
80
+ */
81
+ export declare function computeSkillQuality(skillName: string, options?: {
82
+ windowDays?: number;
83
+ baseDir?: string;
84
+ }): SkillQualityScore;
85
+ /**
86
+ * Compute scores for every skill that appeared in *any* run within the
87
+ * window. Returns one score per skill name, sorted by totalRuns desc
88
+ * (most-used first). Skills that exist in the vault but never ran will
89
+ * not appear — callers that need "every skill" should merge with the
90
+ * skill-store listing themselves.
91
+ */
92
+ export declare function computeAllSkillQuality(options?: {
93
+ windowDays?: number;
94
+ baseDir?: string;
95
+ }): SkillQualityScore[];
96
+ //# sourceMappingURL=skill-quality.d.ts.map
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Skill quality scoring per Anthropic skill metrics (1.18.164).
3
+ *
4
+ * Anthropic's skill spec calls for tracking per-skill quality with a few
5
+ * specific metrics: trigger accuracy, success rate, average tool calls,
6
+ * average tokens, failure rate per workflow. Today we have the raw data
7
+ * (CronRunEntry stamps `skillsApplied: [{name, source}]` on every run
8
+ * since 1.18.85) but never aggregate it into a "how is this skill
9
+ * actually performing?" view.
10
+ *
11
+ * This module computes the metrics on demand from the existing run log —
12
+ * no new schema, no new persistence. The Skills page card surfaces the
13
+ * scores so the owner can spot:
14
+ * - Skills that auto-trigger but don't help (low trigger accuracy)
15
+ * - Skills that are pinned but consistently fail (low success rate)
16
+ * - Skills with no recent activity ("stale")
17
+ * - Skills with no data at all ("no-data" — fresh; may be unused)
18
+ *
19
+ * The grade is a coarse 4-bucket label optimized for "what should the
20
+ * owner do about this skill?" rather than a precise number. Detailed
21
+ * stats accompany so the owner can drill in.
22
+ *
23
+ * Why no SQLite table:
24
+ * - The data already exists in CronRunLog jsonl files
25
+ * - Recompute is cheap (one-time scan over recent jsonl)
26
+ * - Avoids a new schema migration + the risk of double-counting if
27
+ * we forget to write to it from one of the run paths
28
+ * - Owner isn't running this 100×/sec — the dashboard hits it once
29
+ * when the Skills page renders
30
+ *
31
+ * If the volume ever grows past ~50 skills × 500 runs/day, we can
32
+ * promote to SQLite. Until then, keep it simple.
33
+ */
34
+ import path from 'node:path';
35
+ import pino from 'pino';
36
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
37
+ import { BASE_DIR } from '../config.js';
38
+ const logger = pino({ name: 'clementine.skill-quality' });
39
+ // ── Tunables ─────────────────────────────────────────────────────────
40
+ /** Default rolling window for quality computation. Anthropic suggests
41
+ * a 30-day evaluation horizon for skill metrics; that matches our
42
+ * cron-run-log retention so we read what we have. */
43
+ export const DEFAULT_WINDOW_DAYS = 30;
44
+ /** Minimum runs before we hand out a grade. Below this, the skill is
45
+ * marked 'no-data' regardless of pass/fail to avoid grading from a
46
+ * sample of 1. */
47
+ const MIN_RUNS_FOR_GRADE = 3;
48
+ /** Stale threshold — if the skill hasn't been used at all within
49
+ * this many days, the grade becomes 'stale' regardless of past stats. */
50
+ const STALE_DAYS = 30;
51
+ /** Below this success-rate threshold a skill with enough runs is graded
52
+ * 'underperforming'. 0.6 = "fails 4 in 10" — a reasonable trigger for
53
+ * the owner to investigate. */
54
+ const UNDERPERFORMING_SUCCESS_RATE = 0.6;
55
+ // ── Internals ────────────────────────────────────────────────────────
56
+ /** Scan all per-job run log files and yield every entry within the window. */
57
+ function* iterRecentRuns(windowDays, baseDir = BASE_DIR) {
58
+ const runsDir = path.join(baseDir, 'cron', 'runs');
59
+ if (!existsSync(runsDir))
60
+ return;
61
+ const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000;
62
+ let files;
63
+ try {
64
+ files = readdirSync(runsDir).filter(f => f.endsWith('.jsonl'));
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ for (const file of files) {
70
+ let lines;
71
+ try {
72
+ lines = readFileSync(path.join(runsDir, file), 'utf-8').trim().split('\n').filter(Boolean);
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ // Iterate newest-first; bail once we cross the cutoff (assumes
78
+ // append-only writes).
79
+ for (let i = lines.length - 1; i >= 0; i--) {
80
+ let entry;
81
+ try {
82
+ entry = JSON.parse(lines[i]);
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ const ts = Date.parse(entry.startedAt);
88
+ if (Number.isFinite(ts) && ts < cutoff)
89
+ break;
90
+ yield entry;
91
+ }
92
+ }
93
+ }
94
+ /** Did this run succeed for the purposes of skill scoring? Status='ok'
95
+ * combined with a non-failing goalCheck (when present). */
96
+ function isRunSuccess(entry) {
97
+ if (entry.status !== 'ok')
98
+ return false;
99
+ if (entry.goalCheck?.status === 'fail')
100
+ return false;
101
+ return true;
102
+ }
103
+ /** Did this run terminally fail? Excludes 'running'/'skipped' so they
104
+ * don't pull either ratio. */
105
+ function isRunFailure(entry) {
106
+ if (entry.status === 'error' || entry.status === 'timeout' || entry.status === 'lost')
107
+ return true;
108
+ if (entry.status === 'ok' && entry.goalCheck?.status === 'fail')
109
+ return true;
110
+ return false;
111
+ }
112
+ // ── Public API ────────────────────────────────────────────────────────
113
+ /**
114
+ * Compute quality scores for a single skill. Returns the aggregate even
115
+ * when there's no data — graded 'no-data' so the dashboard can render
116
+ * a clean empty state.
117
+ */
118
+ export function computeSkillQuality(skillName, options = {}) {
119
+ const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
120
+ let total = 0, pinned = 0, auto = 0, success = 0, failure = 0;
121
+ let durationSumMs = 0, durationN = 0;
122
+ let costSum = 0, costN = 0;
123
+ let autoSuccess = 0, autoTotal = 0;
124
+ let lastUsedAt = null;
125
+ for (const entry of iterRecentRuns(windowDays, options.baseDir)) {
126
+ const applied = (entry.skillsApplied ?? []).find(s => s.name === skillName);
127
+ if (!applied)
128
+ continue;
129
+ total++;
130
+ if (applied.source === 'pinned')
131
+ pinned++;
132
+ else if (applied.source === 'auto')
133
+ auto++;
134
+ if (isRunSuccess(entry))
135
+ success++;
136
+ if (isRunFailure(entry))
137
+ failure++;
138
+ if (applied.source === 'auto') {
139
+ autoTotal++;
140
+ if (isRunSuccess(entry))
141
+ autoSuccess++;
142
+ }
143
+ if (typeof entry.durationMs === 'number' && entry.durationMs > 0 && entry.status !== 'running') {
144
+ durationSumMs += entry.durationMs;
145
+ durationN++;
146
+ }
147
+ if (typeof entry.totalCostUsd === 'number') {
148
+ costSum += entry.totalCostUsd;
149
+ costN++;
150
+ }
151
+ if (!lastUsedAt || entry.startedAt > lastUsedAt) {
152
+ lastUsedAt = entry.startedAt;
153
+ }
154
+ }
155
+ const successRate = total > 0 ? success / total : null;
156
+ const triggerAccuracy = autoTotal > 0 ? autoSuccess / autoTotal : null;
157
+ const avgDurationMs = durationN > 0 ? Math.round(durationSumMs / durationN) : null;
158
+ const avgCostUsd = costN > 0 ? costSum / costN : null;
159
+ // Grade decision — order matters: 'no-data' beats everything for
160
+ // small samples; 'stale' beats 'underperforming' for skills that
161
+ // historically did fine but stopped firing.
162
+ let grade = 'no-data';
163
+ let gradeReason = `Only ${total} run${total === 1 ? '' : 's'} in the last ${windowDays}d — not enough to grade.`;
164
+ if (total >= MIN_RUNS_FOR_GRADE) {
165
+ if (lastUsedAt) {
166
+ const lastMs = Date.parse(lastUsedAt);
167
+ if (Number.isFinite(lastMs) && Date.now() - lastMs > STALE_DAYS * 24 * 60 * 60 * 1000) {
168
+ grade = 'stale';
169
+ gradeReason = `No runs in the last ${STALE_DAYS} days. Consider archiving or revisiting triggers.`;
170
+ }
171
+ else if (successRate !== null && successRate < UNDERPERFORMING_SUCCESS_RATE) {
172
+ grade = 'underperforming';
173
+ gradeReason = `${(successRate * 100).toFixed(0)}% success over ${total} runs — investigate failures + tighten triggers or body.`;
174
+ }
175
+ else {
176
+ grade = 'good';
177
+ gradeReason = `${successRate !== null ? (successRate * 100).toFixed(0) : '?'}% success over ${total} runs.`;
178
+ }
179
+ }
180
+ else {
181
+ // Defensive — shouldn't happen if total > 0, but keep fall-through.
182
+ grade = 'no-data';
183
+ }
184
+ }
185
+ return {
186
+ name: skillName,
187
+ windowDays,
188
+ totalRuns: total,
189
+ pinnedRuns: pinned,
190
+ autoRuns: auto,
191
+ successRuns: success,
192
+ failureRuns: failure,
193
+ successRate,
194
+ triggerAccuracy,
195
+ avgDurationMs,
196
+ avgCostUsd,
197
+ lastUsedAt,
198
+ grade,
199
+ gradeReason,
200
+ };
201
+ }
202
+ /**
203
+ * Compute scores for every skill that appeared in *any* run within the
204
+ * window. Returns one score per skill name, sorted by totalRuns desc
205
+ * (most-used first). Skills that exist in the vault but never ran will
206
+ * not appear — callers that need "every skill" should merge with the
207
+ * skill-store listing themselves.
208
+ */
209
+ export function computeAllSkillQuality(options = {}) {
210
+ const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
211
+ // First pass: collect every skill name that appears at least once.
212
+ const seen = new Set();
213
+ for (const entry of iterRecentRuns(windowDays, options.baseDir)) {
214
+ for (const s of entry.skillsApplied ?? []) {
215
+ if (s?.name)
216
+ seen.add(s.name);
217
+ }
218
+ }
219
+ // Second pass: full scoring per skill. Two passes is wasteful but
220
+ // simple; with ~50 skills × 2000-line files this is ms-cheap.
221
+ const scores = [];
222
+ for (const name of seen) {
223
+ scores.push(computeSkillQuality(name, options));
224
+ }
225
+ scores.sort((a, b) => b.totalRuns - a.totalRuns || a.name.localeCompare(b.name));
226
+ if (scores.length > 0) {
227
+ logger.debug({ count: scores.length, top: scores[0]?.name, topRuns: scores[0]?.totalRuns }, 'Skill quality scored');
228
+ }
229
+ return scores;
230
+ }
231
+ //# sourceMappingURL=skill-quality.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.163",
3
+ "version": "1.18.164",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",