selftune 0.2.18 → 0.2.19

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.
Files changed (65) hide show
  1. package/README.md +9 -4
  2. package/apps/local-dashboard/dist/assets/index-DnhnXQm6.js +60 -0
  3. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +1 -0
  4. package/apps/local-dashboard/dist/assets/vendor-table-BIiI3YhS.js +1 -0
  5. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +12 -0
  6. package/apps/local-dashboard/dist/index.html +5 -5
  7. package/cli/selftune/alpha-upload/stage-canonical.ts +7 -6
  8. package/cli/selftune/constants.ts +10 -0
  9. package/cli/selftune/contribute/contribute.ts +30 -2
  10. package/cli/selftune/contribution-config.ts +249 -0
  11. package/cli/selftune/contribution-relay.ts +177 -0
  12. package/cli/selftune/contribution-signals.ts +219 -0
  13. package/cli/selftune/contribution-staging.ts +147 -0
  14. package/cli/selftune/contributions.ts +532 -0
  15. package/cli/selftune/creator-contributions.ts +333 -0
  16. package/cli/selftune/dashboard-contract.ts +205 -1
  17. package/cli/selftune/dashboard-server.ts +45 -11
  18. package/cli/selftune/eval/family-overlap.ts +395 -0
  19. package/cli/selftune/eval/hooks-to-evals.ts +182 -28
  20. package/cli/selftune/eval/synthetic-evals.ts +298 -11
  21. package/cli/selftune/export.ts +2 -2
  22. package/cli/selftune/index.ts +41 -5
  23. package/cli/selftune/ingestors/codex-rollout.ts +31 -35
  24. package/cli/selftune/ingestors/codex-wrapper.ts +32 -24
  25. package/cli/selftune/localdb/db.ts +2 -2
  26. package/cli/selftune/localdb/queries.ts +701 -30
  27. package/cli/selftune/localdb/schema.ts +20 -0
  28. package/cli/selftune/recover.ts +153 -0
  29. package/cli/selftune/repair/skill-usage.ts +363 -4
  30. package/cli/selftune/routes/actions.ts +35 -1
  31. package/cli/selftune/routes/analytics.ts +14 -0
  32. package/cli/selftune/routes/index.ts +1 -0
  33. package/cli/selftune/routes/overview.ts +112 -4
  34. package/cli/selftune/routes/skill-report.ts +569 -10
  35. package/cli/selftune/status.ts +81 -2
  36. package/cli/selftune/sync.ts +56 -2
  37. package/cli/selftune/trust-model.ts +66 -0
  38. package/cli/selftune/types.ts +49 -0
  39. package/cli/selftune/utils/skill-detection.ts +43 -0
  40. package/cli/selftune/watchlist.ts +65 -0
  41. package/package.json +1 -1
  42. package/packages/ui/src/components/ActivityTimeline.tsx +165 -150
  43. package/packages/ui/src/components/EvidenceViewer.tsx +335 -144
  44. package/packages/ui/src/components/EvolutionTimeline.tsx +58 -28
  45. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +33 -16
  46. package/packages/ui/src/components/RecentActivityFeed.tsx +72 -41
  47. package/packages/ui/src/components/section-cards.tsx +12 -9
  48. package/packages/ui/src/primitives/card.tsx +1 -1
  49. package/skill/SKILL.md +11 -1
  50. package/skill/Workflows/AlphaUpload.md +4 -0
  51. package/skill/Workflows/Composability.md +64 -0
  52. package/skill/Workflows/Contribute.md +6 -3
  53. package/skill/Workflows/Contributions.md +97 -0
  54. package/skill/Workflows/CreatorContributions.md +74 -0
  55. package/skill/Workflows/Dashboard.md +31 -0
  56. package/skill/Workflows/Evals.md +57 -8
  57. package/skill/Workflows/Ingest.md +7 -0
  58. package/skill/Workflows/Initialize.md +20 -1
  59. package/skill/Workflows/Recover.md +84 -0
  60. package/skill/Workflows/RepairSkillUsage.md +12 -4
  61. package/skill/Workflows/Sync.md +18 -12
  62. package/apps/local-dashboard/dist/assets/index-BMIS6uUh.css +0 -2
  63. package/apps/local-dashboard/dist/assets/index-DOu3iLD9.js +0 -16
  64. package/apps/local-dashboard/dist/assets/vendor-table-pHbDxq36.js +0 -8
  65. package/apps/local-dashboard/dist/assets/vendor-ui-DIwlrGlb.js +0 -12
@@ -239,6 +239,21 @@ CREATE TABLE IF NOT EXISTS upload_queue (
239
239
  last_error TEXT
240
240
  )`;
241
241
 
242
+ // -- Creator contribution staging --------------------------------------------
243
+
244
+ export const CREATE_CREATOR_CONTRIBUTION_STAGING = `
245
+ CREATE TABLE IF NOT EXISTS creator_contribution_staging (
246
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
247
+ dedupe_key TEXT NOT NULL,
248
+ skill_name TEXT NOT NULL,
249
+ creator_id TEXT NOT NULL,
250
+ payload_json TEXT NOT NULL,
251
+ status TEXT NOT NULL DEFAULT 'pending',
252
+ staged_at TEXT NOT NULL,
253
+ updated_at TEXT NOT NULL,
254
+ last_error TEXT
255
+ )`;
256
+
242
257
  // -- Canonical upload staging -------------------------------------------------
243
258
 
244
259
  export const CREATE_CANONICAL_UPLOAD_STAGING = `
@@ -327,6 +342,10 @@ export const CREATE_INDEXES = [
327
342
  // -- Alpha upload queue indexes ---------------------------------------------
328
343
  `CREATE INDEX IF NOT EXISTS idx_upload_queue_status ON upload_queue(status)`,
329
344
  `CREATE INDEX IF NOT EXISTS idx_upload_queue_type_status ON upload_queue(payload_type, status)`,
345
+ // -- Creator contribution staging indexes -----------------------------------
346
+ `CREATE INDEX IF NOT EXISTS idx_creator_contrib_status ON creator_contribution_staging(status)`,
347
+ `CREATE INDEX IF NOT EXISTS idx_creator_contrib_skill ON creator_contribution_staging(skill_name)`,
348
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_creator_contrib_dedup ON creator_contribution_staging(dedupe_key)`,
330
349
  // -- Canonical upload staging indexes ---------------------------------------
331
350
  `CREATE INDEX IF NOT EXISTS idx_staging_kind ON canonical_upload_staging(record_kind)`,
332
351
  `CREATE INDEX IF NOT EXISTS idx_staging_session ON canonical_upload_staging(session_id)`,
@@ -422,6 +441,7 @@ export const ALL_DDL = [
422
441
  CREATE_GRADING_RESULTS,
423
442
  CREATE_IMPROVEMENT_SIGNALS,
424
443
  CREATE_UPLOAD_QUEUE,
444
+ CREATE_CREATOR_CONTRIBUTION_STAGING,
425
445
  CREATE_UPLOAD_WATERMARKS,
426
446
  CREATE_CANONICAL_UPLOAD_STAGING,
427
447
  CREATE_COMMIT_TRACKING,
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { parseArgs } from "node:util";
4
+
5
+ import {
6
+ CANONICAL_LOG,
7
+ EVOLUTION_AUDIT_LOG,
8
+ EVOLUTION_EVIDENCE_LOG,
9
+ ORCHESTRATE_RUN_LOG,
10
+ TELEMETRY_LOG,
11
+ } from "./constants.js";
12
+ import { getDb } from "./localdb/db.js";
13
+ import {
14
+ materializeFull,
15
+ materializeIncremental,
16
+ type MaterializeOptions,
17
+ type MaterializeResult,
18
+ } from "./localdb/materialize.js";
19
+ import { CLIError, handleCLIError } from "./utils/cli-error.js";
20
+
21
+ interface RecoverSummary {
22
+ mode: "incremental" | "full";
23
+ source: "legacy_jsonl_or_export_snapshot";
24
+ since: string | null;
25
+ force: boolean;
26
+ result: MaterializeResult;
27
+ }
28
+
29
+ function buildMaterializeOptions(values: Record<string, unknown>): MaterializeOptions {
30
+ return {
31
+ canonicalLogPath: (values["canonical-log"] as string | undefined) ?? CANONICAL_LOG,
32
+ telemetryLogPath: (values["telemetry-log"] as string | undefined) ?? TELEMETRY_LOG,
33
+ evolutionAuditPath:
34
+ (values["evolution-audit-log"] as string | undefined) ?? EVOLUTION_AUDIT_LOG,
35
+ evolutionEvidencePath:
36
+ (values["evolution-evidence-log"] as string | undefined) ?? EVOLUTION_EVIDENCE_LOG,
37
+ orchestrateRunLogPath:
38
+ (values["orchestrate-run-log"] as string | undefined) ?? ORCHESTRATE_RUN_LOG,
39
+ force: (values.force as boolean | undefined) ?? false,
40
+ };
41
+ }
42
+
43
+ function printHumanSummary(summary: RecoverSummary): void {
44
+ const rows = [
45
+ `mode: ${summary.mode}`,
46
+ "source: legacy JSONL or explicit export snapshot",
47
+ `sessions: ${summary.result.sessions}`,
48
+ `prompts: ${summary.result.prompts}`,
49
+ `skill invocations: ${summary.result.skillInvocations}`,
50
+ `execution facts: ${summary.result.executionFacts}`,
51
+ `session telemetry: ${summary.result.sessionTelemetry}`,
52
+ `legacy skill usage: ${summary.result.skillUsage}`,
53
+ `evolution audit: ${summary.result.evolutionAudit}`,
54
+ `evolution evidence: ${summary.result.evolutionEvidence}`,
55
+ `orchestrate runs: ${summary.result.orchestrateRuns}`,
56
+ ];
57
+ console.log(`selftune recover\n${rows.map((row) => ` ${row}`).join("\n")}`);
58
+ }
59
+
60
+ export function cliMain(): void {
61
+ const { values } = parseArgs({
62
+ args: process.argv.slice(2),
63
+ options: {
64
+ full: { type: "boolean", default: false },
65
+ force: { type: "boolean", default: false },
66
+ since: { type: "string" },
67
+ json: { type: "boolean", default: false },
68
+ "canonical-log": { type: "string", default: CANONICAL_LOG },
69
+ "telemetry-log": { type: "string", default: TELEMETRY_LOG },
70
+ "evolution-audit-log": { type: "string", default: EVOLUTION_AUDIT_LOG },
71
+ "evolution-evidence-log": { type: "string", default: EVOLUTION_EVIDENCE_LOG },
72
+ "orchestrate-run-log": { type: "string", default: ORCHESTRATE_RUN_LOG },
73
+ help: { type: "boolean", short: "h", default: false },
74
+ },
75
+ strict: true,
76
+ });
77
+
78
+ if (values.help) {
79
+ console.log(`selftune recover — Recover SQLite from legacy/exported JSONL
80
+
81
+ Usage:
82
+ selftune recover [options]
83
+
84
+ Use this only for legacy backfill or explicit export-based recovery. Normal
85
+ operation should use \`selftune sync\`, which replays native source data into
86
+ SQLite and preserves alpha-upload compatibility.
87
+
88
+ Options:
89
+ --full Rebuild SQLite tables from scratch
90
+ --force Skip preflight rebuild guard for SQLite-only rows
91
+ --since <date> Incrementally materialize records on/after date
92
+ --canonical-log <path> Canonical JSONL path
93
+ --telemetry-log <path> Session telemetry JSONL path
94
+ --evolution-audit-log <path> Evolution audit JSONL path
95
+ --evolution-evidence-log <path> Evolution evidence JSONL path
96
+ --orchestrate-run-log <path> Orchestrate runs JSONL path
97
+ --json Output JSON summary
98
+ -h, --help Show this help`);
99
+ process.exit(0);
100
+ }
101
+
102
+ if (values.full && values.since) {
103
+ throw new CLIError(
104
+ "Cannot combine --full with --since.",
105
+ "INVALID_FLAG",
106
+ "Use either `selftune recover --full` or `selftune recover --since 2026-01-01`.",
107
+ );
108
+ }
109
+
110
+ let sinceIso: string | null = null;
111
+ if (values.since) {
112
+ const parsed = new Date(values.since as string);
113
+ if (Number.isNaN(parsed.getTime())) {
114
+ throw new CLIError(
115
+ `Invalid --since date: ${values.since}`,
116
+ "INVALID_FLAG",
117
+ "selftune recover --since 2026-01-01",
118
+ );
119
+ }
120
+ sinceIso = parsed.toISOString();
121
+ }
122
+
123
+ const db = getDb();
124
+ const materializeOptions = buildMaterializeOptions(values);
125
+ if (!values.full) materializeOptions.since = sinceIso;
126
+
127
+ const result = values.full
128
+ ? materializeFull(db, materializeOptions)
129
+ : materializeIncremental(db, materializeOptions);
130
+
131
+ const summary: RecoverSummary = {
132
+ mode: values.full ? "full" : "incremental",
133
+ source: "legacy_jsonl_or_export_snapshot",
134
+ since: sinceIso,
135
+ force: (values.force as boolean | undefined) ?? false,
136
+ result,
137
+ };
138
+
139
+ if (values.json || !process.stdout.isTTY) {
140
+ console.log(JSON.stringify(summary, null, 2));
141
+ return;
142
+ }
143
+
144
+ printHumanSummary(summary);
145
+ }
146
+
147
+ if (import.meta.main) {
148
+ try {
149
+ cliMain();
150
+ } catch (error) {
151
+ handleCLIError(error);
152
+ }
153
+ }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import type { Database } from "bun:sqlite";
3
4
  import { existsSync, readFileSync, statSync } from "node:fs";
4
5
  import { basename, dirname, join } from "node:path";
5
6
  import { parseArgs } from "node:util";
@@ -19,7 +20,9 @@ import {
19
20
  parseRolloutFile,
20
21
  } from "../ingestors/codex-rollout.js";
21
22
  import { getDb } from "../localdb/db.js";
23
+ import { writeSkillCheckToDb } from "../localdb/direct-write.js";
22
24
  import { queryQueryLog, querySkillUsageRecords } from "../localdb/queries.js";
25
+ import { buildCanonicalSkillInvocation, deriveInvocationMode } from "../normalization.js";
23
26
  import type { QueryLogRecord, SkillUsageRecord } from "../types.js";
24
27
  import { readJsonl } from "../utils/jsonl.js";
25
28
  import { isActionableQueryText } from "../utils/query-filter.js";
@@ -60,6 +63,47 @@ interface ResolvedSkillPath {
60
63
  resolutionSource: NonNullable<SkillUsageRecord["skill_path_resolution_source"]>;
61
64
  }
62
65
 
66
+ export interface RepairSQLiteResult {
67
+ deleted_legacy_rows: number;
68
+ deleted_prior_repair_rows: number;
69
+ inserted_repair_rows: number;
70
+ skipped_pairs_with_canonical: number;
71
+ repaired_pairs_inserted: number;
72
+ }
73
+
74
+ function deleteRedundantLegacyRows(db: Database): number {
75
+ const deleteTriggered = db.run(`
76
+ DELETE FROM skill_invocations
77
+ WHERE skill_invocation_id LIKE '%:su:%'
78
+ AND triggered = 1
79
+ AND EXISTS (
80
+ SELECT 1
81
+ FROM skill_invocations current
82
+ WHERE current.session_id = skill_invocations.session_id
83
+ AND lower(current.skill_name) = lower(skill_invocations.skill_name)
84
+ AND current.skill_invocation_id NOT LIKE '%:su:%'
85
+ AND current.triggered = 1
86
+ )
87
+ `);
88
+
89
+ const deleteMisses = db.run(`
90
+ DELETE FROM skill_invocations
91
+ WHERE skill_invocation_id LIKE '%:su:%'
92
+ AND triggered = 0
93
+ AND EXISTS (
94
+ SELECT 1
95
+ FROM skill_invocations current
96
+ WHERE current.session_id = skill_invocations.session_id
97
+ AND lower(current.skill_name) = lower(skill_invocations.skill_name)
98
+ AND COALESCE(current.query, '') = COALESCE(skill_invocations.query, '')
99
+ AND current.skill_invocation_id NOT LIKE '%:su:%'
100
+ AND current.triggered = 0
101
+ )
102
+ `);
103
+
104
+ return deleteTriggered.changes + deleteMisses.changes;
105
+ }
106
+
63
107
  function isEphemeralLauncherProjectRoot(projectRoot: string): boolean {
64
108
  return projectRoot.startsWith("/tmp/") || projectRoot.startsWith("/private/tmp/");
65
109
  }
@@ -238,6 +282,7 @@ function extractSessionSkillUsage(
238
282
  let lastUserMessage: ActionableUserMessage | null = null;
239
283
  let sessionCwd: string | undefined;
240
284
  const seen = new Set<string>();
285
+ const pendingContextualReads = new Map<string, SkillUsageRecord>();
241
286
  const pendingSkillCalls = new Map<string, { skillName: string; recordIndex?: number }>();
242
287
  const repaired: SkillUsageRecord[] = [];
243
288
 
@@ -336,9 +381,25 @@ function extractSessionSkillUsage(
336
381
  if (toolName === "Read") {
337
382
  const filePath = (input.file_path as string) ?? "";
338
383
  if (filePath.endsWith("SKILL.md")) {
339
- const inferredSkillName = basename(dirname(filePath)).trim().toLowerCase();
384
+ const inferredSkillName = basename(dirname(filePath)).trim();
340
385
  if (inferredSkillName && !skillPathLookup.has(inferredSkillName)) {
341
- skillPathLookup.set(inferredSkillName, filePath);
386
+ skillPathLookup.set(inferredSkillName.toLowerCase(), filePath);
387
+ }
388
+ if (lastUserMessage && inferredSkillName) {
389
+ const dedupeKey = invocationKey(sessionId, inferredSkillName, lastUserMessage.query);
390
+ if (!seen.has(dedupeKey) && !pendingContextualReads.has(dedupeKey)) {
391
+ pendingContextualReads.set(dedupeKey, {
392
+ timestamp: timestamp || lastUserMessage.timestamp || fallbackTimestamp,
393
+ session_id: sessionId,
394
+ skill_name: inferredSkillName,
395
+ skill_path: filePath,
396
+ ...classifySkillPath(filePath, homeDir, codexHome),
397
+ skill_path_resolution_source: "raw_log",
398
+ query: lastUserMessage.query,
399
+ triggered: false,
400
+ source: "claude_code_repair",
401
+ });
402
+ }
342
403
  }
343
404
  }
344
405
  continue;
@@ -350,9 +411,10 @@ function extractSessionSkillUsage(
350
411
  if (!skillName) continue;
351
412
  const toolUseId = optionalString(toolUse.id);
352
413
 
353
- const dedupeKey = [sessionId, skillName, lastUserMessage.query].join("\u0000");
414
+ const dedupeKey = invocationKey(sessionId, skillName, lastUserMessage.query);
354
415
  if (seen.has(dedupeKey)) continue;
355
416
  seen.add(dedupeKey);
417
+ pendingContextualReads.delete(dedupeKey);
356
418
 
357
419
  const knownSkillPath = skillPathLookup.get(skillName.toLowerCase());
358
420
  const { skillPath, resolutionSource } = knownSkillPath
@@ -378,6 +440,10 @@ function extractSessionSkillUsage(
378
440
  }
379
441
  }
380
442
 
443
+ if (pendingContextualReads.size > 0) {
444
+ repaired.push(...pendingContextualReads.values());
445
+ }
446
+
381
447
  return { processed: true, records: repaired };
382
448
  }
383
449
 
@@ -465,6 +531,297 @@ export function rebuildSkillUsageFromTranscripts(
465
531
  return { repairedRecords, repairedSessionIds };
466
532
  }
467
533
 
534
+ function inferRepairPlatform(source: string | undefined): "claude_code" | "codex" {
535
+ return source?.includes("codex") ? "codex" : "claude_code";
536
+ }
537
+
538
+ function normalizeRepairSkillName(skillName: string): string {
539
+ return skillName.trim().toLowerCase();
540
+ }
541
+
542
+ function pairKey(sessionId: string, skillName: string): string {
543
+ return `${sessionId}\u0000${normalizeRepairSkillName(skillName)}`;
544
+ }
545
+
546
+ function splitPairKey(key: string): { sessionId: string; skillName: string } {
547
+ const [sessionId, skillName] = key.split("\u0000");
548
+ return { sessionId, skillName: normalizeRepairSkillName(skillName) };
549
+ }
550
+
551
+ function compareRepairRecords(a: SkillUsageRecord, b: SkillUsageRecord): number {
552
+ return (
553
+ a.timestamp.localeCompare(b.timestamp) ||
554
+ a.query.localeCompare(b.query) ||
555
+ a.skill_path.localeCompare(b.skill_path) ||
556
+ Number(a.triggered) - Number(b.triggered)
557
+ );
558
+ }
559
+
560
+ function invocationKey(sessionId: string, skillName: string, query: string): string {
561
+ return `${sessionId}\u0000${skillName.trim().toLowerCase()}\u0000${query}`;
562
+ }
563
+
564
+ function stableKeyHash(value: string): string {
565
+ let hash = 2166136261;
566
+ for (let i = 0; i < value.length; i++) {
567
+ hash ^= value.charCodeAt(i);
568
+ hash = Math.imul(hash, 16777619);
569
+ }
570
+ return (hash >>> 0).toString(16);
571
+ }
572
+
573
+ /**
574
+ * Persist repaired skill usage into the canonical skill_invocations table.
575
+ *
576
+ * Strategy:
577
+ * - delete legacy triggered :su: rows for repaired session/skill pairs
578
+ * - delete prior capture_mode=repair rows for those pairs (idempotent reruns)
579
+ * - insert repaired rows only when the pair has no canonical triggered rows
580
+ *
581
+ * This lets repair improve SQLite without overriding source-truth canonical
582
+ * replay/hook rows, while also removing duplicate legacy trigger rows from
583
+ * mixed historical sessions.
584
+ */
585
+ export function persistRepairedSkillUsageToDb(
586
+ db: Database,
587
+ records: SkillUsageRecord[],
588
+ ): RepairSQLiteResult {
589
+ const triggeredRecords = records.filter((record) => record.triggered);
590
+ const missedRecords = records.filter((record) => !record.triggered);
591
+ const recordsByPair = new Map<string, SkillUsageRecord[]>();
592
+ const missedRecordsByKey = new Map<string, SkillUsageRecord>();
593
+
594
+ for (const record of triggeredRecords) {
595
+ const key = pairKey(record.session_id, record.skill_name);
596
+ const bucket = recordsByPair.get(key);
597
+ if (bucket) {
598
+ bucket.push(record);
599
+ } else {
600
+ recordsByPair.set(key, [record]);
601
+ }
602
+ }
603
+ for (const record of missedRecords) {
604
+ const key = invocationKey(record.session_id, record.skill_name, record.query);
605
+ const existing = missedRecordsByKey.get(key);
606
+ if (!existing || compareRepairRecords(record, existing) < 0) {
607
+ missedRecordsByKey.set(key, record);
608
+ }
609
+ }
610
+
611
+ if (recordsByPair.size === 0 && missedRecordsByKey.size === 0) {
612
+ return {
613
+ deleted_legacy_rows: 0,
614
+ deleted_prior_repair_rows: 0,
615
+ inserted_repair_rows: 0,
616
+ skipped_pairs_with_canonical: 0,
617
+ repaired_pairs_inserted: 0,
618
+ };
619
+ }
620
+
621
+ const selectExisting = db.prepare(`
622
+ SELECT
623
+ COALESCE(SUM(CASE WHEN skill_invocation_id LIKE '%:su:%' AND triggered = 1 THEN 1 ELSE 0 END), 0) AS legacy_rows,
624
+ COALESCE(SUM(CASE WHEN capture_mode = 'repair' AND triggered = 1 THEN 1 ELSE 0 END), 0) AS repair_rows,
625
+ COALESCE(
626
+ SUM(
627
+ CASE
628
+ WHEN skill_invocation_id NOT LIKE '%:su:%'
629
+ AND COALESCE(capture_mode, '') != 'repair'
630
+ AND triggered = 1
631
+ THEN 1 ELSE 0
632
+ END
633
+ ),
634
+ 0
635
+ ) AS canonical_rows
636
+ FROM skill_invocations
637
+ WHERE session_id = ? AND LOWER(skill_name) = ?
638
+ `);
639
+ const deleteLegacyTriggered = db.prepare(`
640
+ DELETE FROM skill_invocations
641
+ WHERE session_id = ? AND LOWER(skill_name) = ? AND skill_invocation_id LIKE '%:su:%' AND triggered = 1
642
+ `);
643
+ const deleteRepairTriggered = db.prepare(`
644
+ DELETE FROM skill_invocations
645
+ WHERE session_id = ? AND LOWER(skill_name) = ? AND capture_mode = 'repair' AND triggered = 1
646
+ `);
647
+ const selectExistingMiss = db.prepare(`
648
+ SELECT
649
+ COALESCE(SUM(CASE WHEN skill_invocation_id LIKE '%:su:%' AND triggered = 0 THEN 1 ELSE 0 END), 0) AS legacy_rows,
650
+ COALESCE(SUM(CASE WHEN capture_mode = 'repair' AND triggered = 0 THEN 1 ELSE 0 END), 0) AS repair_rows,
651
+ COALESCE(
652
+ SUM(
653
+ CASE
654
+ WHEN skill_invocation_id NOT LIKE '%:su:%'
655
+ AND COALESCE(capture_mode, '') != 'repair'
656
+ AND triggered = 0
657
+ THEN 1 ELSE 0
658
+ END
659
+ ),
660
+ 0
661
+ ) AS canonical_rows
662
+ FROM skill_invocations
663
+ WHERE session_id = ? AND LOWER(skill_name) = ? AND query = ?
664
+ `);
665
+ const deleteLegacyMiss = db.prepare(`
666
+ DELETE FROM skill_invocations
667
+ WHERE session_id = ? AND LOWER(skill_name) = ? AND query = ? AND skill_invocation_id LIKE '%:su:%' AND triggered = 0
668
+ `);
669
+ const deleteRepairMiss = db.prepare(`
670
+ DELETE FROM skill_invocations
671
+ WHERE session_id = ? AND LOWER(skill_name) = ? AND query = ? AND capture_mode = 'repair' AND triggered = 0
672
+ `);
673
+
674
+ const result: RepairSQLiteResult = {
675
+ deleted_legacy_rows: 0,
676
+ deleted_prior_repair_rows: 0,
677
+ inserted_repair_rows: 0,
678
+ skipped_pairs_with_canonical: 0,
679
+ repaired_pairs_inserted: 0,
680
+ };
681
+
682
+ db.run("BEGIN IMMEDIATE");
683
+ try {
684
+ const redundantLegacyRows = deleteRedundantLegacyRows(db);
685
+ result.deleted_legacy_rows += redundantLegacyRows;
686
+
687
+ for (const [key, pairRecords] of recordsByPair.entries()) {
688
+ const { sessionId, skillName } = splitPairKey(key);
689
+ const existing = selectExisting.get(sessionId, skillName) as
690
+ | { legacy_rows: number; repair_rows: number; canonical_rows: number }
691
+ | undefined;
692
+
693
+ const legacyRows = existing?.legacy_rows ?? 0;
694
+ const repairRows = existing?.repair_rows ?? 0;
695
+ const canonicalRows = existing?.canonical_rows ?? 0;
696
+
697
+ if (repairRows > 0) {
698
+ deleteRepairTriggered.run(sessionId, skillName);
699
+ result.deleted_prior_repair_rows += repairRows;
700
+ }
701
+ if (legacyRows > 0) {
702
+ deleteLegacyTriggered.run(sessionId, skillName);
703
+ result.deleted_legacy_rows += legacyRows;
704
+ }
705
+ if (canonicalRows > 0) {
706
+ result.skipped_pairs_with_canonical += 1;
707
+ continue;
708
+ }
709
+
710
+ const sortedRecords = [...pairRecords].sort(compareRepairRecords);
711
+ const canonicalSkillName = sortedRecords[0]?.skill_name.trim() || skillName;
712
+ const { invocation_mode, confidence } = deriveInvocationMode({ is_repaired: true });
713
+
714
+ for (let index = 0; index < sortedRecords.length; index++) {
715
+ const record = sortedRecords[index];
716
+ const platform = inferRepairPlatform(record.source);
717
+ const canonical = buildCanonicalSkillInvocation({
718
+ platform,
719
+ capture_mode: "repair",
720
+ source_session_kind: "repaired",
721
+ session_id: record.session_id,
722
+ raw_source_ref: {
723
+ event_type: "repair-skill-usage",
724
+ metadata: {
725
+ source: record.source ?? null,
726
+ skill_path_resolution_source: record.skill_path_resolution_source ?? null,
727
+ skill_project_root: record.skill_project_root ?? null,
728
+ skill_registry_dir: record.skill_registry_dir ?? null,
729
+ },
730
+ },
731
+ skill_invocation_id: `${record.session_id}:r:${canonicalSkillName}:${index}`,
732
+ occurred_at: record.timestamp,
733
+ skill_name: canonicalSkillName,
734
+ skill_path: record.skill_path,
735
+ invocation_mode,
736
+ triggered: true,
737
+ confidence,
738
+ });
739
+
740
+ writeSkillCheckToDb({
741
+ ...canonical,
742
+ query: record.query,
743
+ skill_path: record.skill_path,
744
+ skill_scope: record.skill_scope,
745
+ source: record.source,
746
+ });
747
+ result.inserted_repair_rows += 1;
748
+ }
749
+
750
+ result.repaired_pairs_inserted += 1;
751
+ }
752
+
753
+ for (const record of missedRecordsByKey.values()) {
754
+ const canonicalSkillName = record.skill_name.trim();
755
+ const normalizedSkillName = normalizeRepairSkillName(canonicalSkillName);
756
+ const existing = selectExistingMiss.get(
757
+ record.session_id,
758
+ normalizedSkillName,
759
+ record.query,
760
+ ) as { legacy_rows: number; repair_rows: number; canonical_rows: number } | undefined;
761
+
762
+ const legacyRows = existing?.legacy_rows ?? 0;
763
+ const repairRows = existing?.repair_rows ?? 0;
764
+ const canonicalRows = existing?.canonical_rows ?? 0;
765
+
766
+ if (repairRows > 0) {
767
+ deleteRepairMiss.run(record.session_id, normalizedSkillName, record.query);
768
+ result.deleted_prior_repair_rows += repairRows;
769
+ }
770
+ if (legacyRows > 0) {
771
+ deleteLegacyMiss.run(record.session_id, normalizedSkillName, record.query);
772
+ result.deleted_legacy_rows += legacyRows;
773
+ }
774
+ if (canonicalRows > 0) {
775
+ result.skipped_pairs_with_canonical += 1;
776
+ continue;
777
+ }
778
+
779
+ const platform = inferRepairPlatform(record.source);
780
+ const { invocation_mode, confidence } = deriveInvocationMode({ is_repaired: true });
781
+ const canonical = buildCanonicalSkillInvocation({
782
+ platform,
783
+ capture_mode: "repair",
784
+ source_session_kind: "repaired",
785
+ session_id: record.session_id,
786
+ raw_source_ref: {
787
+ event_type: "repair-skill-usage",
788
+ metadata: {
789
+ source: record.source ?? null,
790
+ skill_path_resolution_source: record.skill_path_resolution_source ?? null,
791
+ skill_project_root: record.skill_project_root ?? null,
792
+ skill_registry_dir: record.skill_registry_dir ?? null,
793
+ miss_type: "contextual_read",
794
+ },
795
+ },
796
+ skill_invocation_id: `${record.session_id}:rmiss:${canonicalSkillName}:${stableKeyHash(record.query)}`,
797
+ occurred_at: record.timestamp,
798
+ skill_name: canonicalSkillName,
799
+ skill_path: record.skill_path,
800
+ invocation_mode,
801
+ triggered: false,
802
+ confidence,
803
+ });
804
+
805
+ writeSkillCheckToDb({
806
+ ...canonical,
807
+ query: record.query,
808
+ skill_path: record.skill_path,
809
+ skill_scope: record.skill_scope,
810
+ source: record.source,
811
+ });
812
+ result.inserted_repair_rows += 1;
813
+ result.repaired_pairs_inserted += 1;
814
+ }
815
+
816
+ db.run("COMMIT");
817
+ } catch (error) {
818
+ db.run("ROLLBACK");
819
+ throw error;
820
+ }
821
+
822
+ return result;
823
+ }
824
+
468
825
  export function cliMain(): void {
469
826
  try {
470
827
  const { values } = parseArgs({
@@ -571,13 +928,15 @@ Options:
571
928
  return;
572
929
  }
573
930
 
931
+ const sqlite = persistRepairedSkillUsageToDb(getDb(), repairedRecords);
932
+
574
933
  writeRepairedSkillUsageRecords(
575
934
  repairedRecords,
576
935
  repairedSessionIds,
577
936
  values.out ?? REPAIRED_SKILL_LOG,
578
937
  values["sessions-marker"] ?? REPAIRED_SKILL_SESSIONS_MARKER,
579
938
  );
580
- console.log(JSON.stringify(summary, null, 2));
939
+ console.log(JSON.stringify({ ...summary, sqlite }, null, 2));
581
940
  } catch (error) {
582
941
  const message = error instanceof Error ? error.message : String(error);
583
942
  console.error(`[ERROR] Failed to repair skill usage: ${message}`);
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Route handler: POST /api/actions/{watch,evolve,rollback}
2
+ * Route handler: POST /api/actions/{watch,evolve,rollback,watchlist}
3
3
  *
4
4
  * Triggers selftune CLI commands as child processes and returns the result.
5
5
  */
6
6
 
7
7
  import { join } from "node:path";
8
8
 
9
+ import { saveWatchedSkills } from "../watchlist.js";
10
+
9
11
  export type ActionRunner = (
10
12
  command: string,
11
13
  args: string[],
@@ -41,6 +43,38 @@ export async function handleAction(
41
43
  body: Record<string, unknown>,
42
44
  executeAction: ActionRunner = runAction,
43
45
  ): Promise<Response> {
46
+ if (action === "watchlist") {
47
+ const skills = body.skills;
48
+ if (skills === undefined || skills === null) {
49
+ return Response.json(
50
+ { success: false, error: "Missing required field: skills[]" },
51
+ { status: 400 },
52
+ );
53
+ }
54
+ if (!Array.isArray(skills) || !skills.every((skill) => typeof skill === "string")) {
55
+ return Response.json(
56
+ {
57
+ success: false,
58
+ error: "Invalid type for skills: expected array of strings",
59
+ },
60
+ { status: 400 },
61
+ );
62
+ }
63
+ try {
64
+ const saved = saveWatchedSkills(skills);
65
+ return Response.json({ success: true, watched_skills: saved, error: null });
66
+ } catch (error: unknown) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ return Response.json(
69
+ {
70
+ success: false,
71
+ error: `Failed to save watched skills. Check your selftune config directory and try again. ${message}`,
72
+ },
73
+ { status: 500 },
74
+ );
75
+ }
76
+ }
77
+
44
78
  if (action === "watch" || action === "evolve") {
45
79
  const skill = body.skill as string | undefined;
46
80
  const skillPath = body.skillPath as string | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Route handler: GET /api/v2/analytics
3
+ *
4
+ * Returns performance analytics payload from SQLite.
5
+ */
6
+
7
+ import type { Database } from "bun:sqlite";
8
+
9
+ import { getAnalyticsPayload } from "../localdb/queries.js";
10
+
11
+ export function handleAnalytics(db: Database): Response {
12
+ const analytics = getAnalyticsPayload(db);
13
+ return Response.json(analytics);
14
+ }