selftune 0.2.16 → 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 (91) hide show
  1. package/README.md +32 -22
  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/build-payloads.ts +14 -1
  8. package/cli/selftune/alpha-upload/client.ts +51 -1
  9. package/cli/selftune/alpha-upload/flush.ts +46 -5
  10. package/cli/selftune/alpha-upload/stage-canonical.ts +32 -10
  11. package/cli/selftune/alpha-upload-contract.ts +9 -0
  12. package/cli/selftune/constants.ts +92 -5
  13. package/cli/selftune/contribute/contribute.ts +30 -2
  14. package/cli/selftune/contribute/sanitize.ts +52 -5
  15. package/cli/selftune/contribution-config.ts +249 -0
  16. package/cli/selftune/contribution-relay.ts +177 -0
  17. package/cli/selftune/contribution-signals.ts +219 -0
  18. package/cli/selftune/contribution-staging.ts +147 -0
  19. package/cli/selftune/contributions.ts +532 -0
  20. package/cli/selftune/creator-contributions.ts +333 -0
  21. package/cli/selftune/dashboard-contract.ts +305 -1
  22. package/cli/selftune/dashboard-server.ts +47 -13
  23. package/cli/selftune/eval/family-overlap.ts +395 -0
  24. package/cli/selftune/eval/hooks-to-evals.ts +182 -28
  25. package/cli/selftune/eval/synthetic-evals.ts +298 -11
  26. package/cli/selftune/evolution/description-quality.ts +12 -11
  27. package/cli/selftune/evolution/evolve.ts +214 -51
  28. package/cli/selftune/evolution/validate-proposal.ts +9 -6
  29. package/cli/selftune/export.ts +2 -2
  30. package/cli/selftune/grading/grade-session.ts +20 -0
  31. package/cli/selftune/hooks/commit-track.ts +188 -0
  32. package/cli/selftune/hooks/prompt-log.ts +10 -1
  33. package/cli/selftune/hooks/session-stop.ts +2 -2
  34. package/cli/selftune/hooks/skill-eval.ts +15 -1
  35. package/cli/selftune/hooks/stdin-preview.ts +32 -0
  36. package/cli/selftune/index.ts +41 -5
  37. package/cli/selftune/ingestors/codex-rollout.ts +31 -35
  38. package/cli/selftune/ingestors/codex-wrapper.ts +32 -24
  39. package/cli/selftune/localdb/db.ts +2 -2
  40. package/cli/selftune/localdb/direct-write.ts +69 -6
  41. package/cli/selftune/localdb/queries.ts +1253 -37
  42. package/cli/selftune/localdb/schema.ts +66 -0
  43. package/cli/selftune/orchestrate.ts +32 -4
  44. package/cli/selftune/recover.ts +153 -0
  45. package/cli/selftune/repair/skill-usage.ts +363 -4
  46. package/cli/selftune/routes/actions.ts +35 -1
  47. package/cli/selftune/routes/analytics.ts +14 -0
  48. package/cli/selftune/routes/index.ts +1 -0
  49. package/cli/selftune/routes/overview.ts +150 -4
  50. package/cli/selftune/routes/skill-report.ts +648 -18
  51. package/cli/selftune/status.ts +81 -2
  52. package/cli/selftune/sync.ts +56 -2
  53. package/cli/selftune/trust-model.ts +66 -0
  54. package/cli/selftune/types.ts +80 -0
  55. package/cli/selftune/utils/skill-detection.ts +43 -0
  56. package/cli/selftune/utils/transcript.ts +210 -1
  57. package/cli/selftune/watchlist.ts +65 -0
  58. package/node_modules/@selftune/telemetry-contract/src/types.ts +11 -0
  59. package/package.json +1 -1
  60. package/packages/telemetry-contract/src/types.ts +11 -0
  61. package/packages/ui/src/components/ActivityTimeline.tsx +165 -150
  62. package/packages/ui/src/components/EvidenceViewer.tsx +335 -144
  63. package/packages/ui/src/components/EvolutionTimeline.tsx +58 -28
  64. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +33 -16
  65. package/packages/ui/src/components/RecentActivityFeed.tsx +72 -41
  66. package/packages/ui/src/components/section-cards.tsx +12 -9
  67. package/packages/ui/src/primitives/card.tsx +1 -1
  68. package/skill/SKILL.md +40 -2
  69. package/skill/Workflows/AlphaUpload.md +4 -0
  70. package/skill/Workflows/Composability.md +64 -0
  71. package/skill/Workflows/Contribute.md +6 -3
  72. package/skill/Workflows/Contributions.md +97 -0
  73. package/skill/Workflows/CreatorContributions.md +74 -0
  74. package/skill/Workflows/Dashboard.md +31 -0
  75. package/skill/Workflows/Evals.md +57 -8
  76. package/skill/Workflows/Evolve.md +31 -13
  77. package/skill/Workflows/ExportCanonical.md +121 -0
  78. package/skill/Workflows/Hook.md +131 -0
  79. package/skill/Workflows/Ingest.md +7 -0
  80. package/skill/Workflows/Initialize.md +29 -9
  81. package/skill/Workflows/Orchestrate.md +27 -5
  82. package/skill/Workflows/Quickstart.md +94 -0
  83. package/skill/Workflows/Recover.md +84 -0
  84. package/skill/Workflows/RepairSkillUsage.md +95 -0
  85. package/skill/Workflows/Sync.md +18 -12
  86. package/skill/Workflows/Uninstall.md +82 -0
  87. package/skill/settings_snippet.json +11 -0
  88. package/apps/local-dashboard/dist/assets/index-BMIS6uUh.css +0 -2
  89. package/apps/local-dashboard/dist/assets/index-DOu3iLD9.js +0 -16
  90. package/apps/local-dashboard/dist/assets/vendor-table-pHbDxq36.js +0 -8
  91. package/apps/local-dashboard/dist/assets/vendor-ui-DIwlrGlb.js +0 -12
@@ -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
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  export type { ActionRunner } from "./actions.js";
8
8
  export { handleAction, runAction } from "./actions.js";
9
+ export { handleAnalytics } from "./analytics.js";
9
10
  export { handleBadge } from "./badge.js";
10
11
  export { handleDoctor } from "./doctor.js";
11
12
  export { handleOrchestrateRuns } from "./orchestrate-runs.js";
@@ -2,14 +2,160 @@
2
2
  * Route handler: GET /api/v2/overview
3
3
  *
4
4
  * Returns SQLite-backed overview payload with skill listing and version info.
5
+ * Supports optional cursor-based pagination via query params:
6
+ * ?telemetry_cursor=<json>&telemetry_limit=N&skills_cursor=<json>&skills_limit=N
5
7
  */
6
8
 
7
9
  import type { Database } from "bun:sqlite";
8
10
 
9
- import { getOverviewPayload, getSkillsList } from "../localdb/queries.js";
11
+ import type {
12
+ AttentionItem,
13
+ AutonomousDecision,
14
+ AutonomyStatus,
15
+ AutonomyStatusLevel,
16
+ OverviewResponse,
17
+ } from "../dashboard-contract.js";
18
+ import { parseCursorParam, parseIntParam } from "../dashboard-contract.js";
19
+ import {
20
+ getAttentionQueue,
21
+ getOverviewPayload,
22
+ getOverviewPayloadPaginated,
23
+ getRecentDecisions,
24
+ getSkillTrustSummaries,
25
+ getSkillsList,
26
+ } from "../localdb/queries.js";
27
+ import { buildTrustWatchlist } from "../trust-model.js";
28
+ import { loadWatchedSkills } from "../watchlist.js";
10
29
 
11
- export function handleOverview(db: Database, version: string): Response {
12
- const overview = getOverviewPayload(db);
30
+ export function handleOverview(
31
+ db: Database,
32
+ version: string,
33
+ searchParams?: URLSearchParams,
34
+ ): Response {
13
35
  const skills = getSkillsList(db);
14
- return Response.json({ overview, skills, version });
36
+
37
+ // -- Autonomy-first enrichment fields ----------------------------------------
38
+ const attentionQueue = getAttentionQueue(db);
39
+ const recentDecisions = getRecentDecisions(db);
40
+ const trustSummaries = getSkillTrustSummaries(db);
41
+ const pendingReviews = attentionQueue.filter((a) => a.category === "needs_review").length;
42
+
43
+ const trustWatchlist = buildTrustWatchlist(trustSummaries);
44
+ const autonomyStatus = buildAutonomyStatus(
45
+ db,
46
+ attentionQueue,
47
+ recentDecisions,
48
+ skills.length,
49
+ pendingReviews,
50
+ );
51
+
52
+ const enrichment = {
53
+ watched_skills: loadWatchedSkills(),
54
+ autonomy_status: autonomyStatus,
55
+ attention_queue: attentionQueue,
56
+ trust_watchlist: trustWatchlist,
57
+ recent_decisions: recentDecisions,
58
+ };
59
+
60
+ // -- Standard overview payload -----------------------------------------------
61
+ const hasPaginationParams =
62
+ searchParams &&
63
+ (searchParams.has("telemetry_cursor") ||
64
+ searchParams.has("telemetry_limit") ||
65
+ searchParams.has("skills_cursor") ||
66
+ searchParams.has("skills_limit"));
67
+ const hasSkillsPagination =
68
+ searchParams && (searchParams.has("skills_cursor") || searchParams.has("skills_limit"));
69
+
70
+ if (!hasPaginationParams) {
71
+ const overview = getOverviewPayload(db);
72
+ const response: OverviewResponse = { overview, skills, version, ...enrichment };
73
+ return Response.json(response);
74
+ }
75
+
76
+ // Parse pagination params
77
+ const telemetryCursor = parseCursorParam(searchParams.get("telemetry_cursor"));
78
+ const telemetryLimit = parseIntParam(searchParams.get("telemetry_limit"), 1000);
79
+ const skillsCursor = parseCursorParam(searchParams.get("skills_cursor"));
80
+ const skillsLimit = parseIntParam(searchParams.get("skills_limit"), 2000);
81
+
82
+ const overview = getOverviewPayloadPaginated(db, {
83
+ telemetry_cursor: telemetryCursor,
84
+ telemetry_limit: telemetryLimit,
85
+ skills_cursor: skillsCursor,
86
+ skills_limit: skillsLimit,
87
+ });
88
+
89
+ const paginatedSkillNames = new Set(overview.skills_page.items.map((row) => row.skill_name));
90
+ const paginatedSkills = hasSkillsPagination
91
+ ? skills.filter((skill) => paginatedSkillNames.has(skill.skill_name))
92
+ : skills;
93
+
94
+ return Response.json({ overview, skills: paginatedSkills, version, ...enrichment });
95
+ }
96
+
97
+ // -- Internal helpers ----------------------------------------------------------
98
+
99
+ function buildAutonomyStatus(
100
+ db: Database,
101
+ attentionQueue: AttentionItem[],
102
+ recentDecisions: AutonomousDecision[],
103
+ skillsObserved: number,
104
+ pendingReviews: number,
105
+ ): AutonomyStatus {
106
+ let lastRun: string | null = null;
107
+ try {
108
+ const row = db
109
+ .query(`SELECT timestamp FROM orchestrate_runs ORDER BY timestamp DESC LIMIT 1`)
110
+ .get() as { timestamp: string } | null;
111
+ lastRun = row?.timestamp ?? null;
112
+ } catch {
113
+ // Table may not exist
114
+ }
115
+
116
+ const hasCritical = attentionQueue.some((a) => a.severity === "critical");
117
+
118
+ // "watching" means recent autonomous activity — last run within 24 hours
119
+ // or recent decisions within the 7-day freshness window
120
+ const hasRecentActivity =
121
+ (lastRun != null && Date.now() - new Date(lastRun).getTime() < 24 * 60 * 60 * 1000) ||
122
+ recentDecisions.length > 0;
123
+
124
+ let level: AutonomyStatusLevel;
125
+ if (hasCritical) {
126
+ level = "blocked";
127
+ } else if (pendingReviews > 0) {
128
+ level = "needs_review";
129
+ } else if (hasRecentActivity) {
130
+ level = "watching";
131
+ } else {
132
+ level = "healthy";
133
+ }
134
+
135
+ let summary: string;
136
+ switch (level) {
137
+ case "healthy":
138
+ summary = "No action needed. System is healthy.";
139
+ break;
140
+ case "blocked": {
141
+ const critCount = attentionQueue.filter((a) => a.severity === "critical").length;
142
+ summary = `${critCount} skill${critCount !== 1 ? "s" : ""} need${critCount === 1 ? "s" : ""} urgent attention after rollback.`;
143
+ break;
144
+ }
145
+ case "needs_review":
146
+ summary = `selftune is watching ${skillsObserved} skill${skillsObserved !== 1 ? "s" : ""} and needs review on ${pendingReviews} proposal${pendingReviews !== 1 ? "s" : ""}.`;
147
+ break;
148
+ case "watching":
149
+ summary = `selftune is actively watching ${skillsObserved} skill${skillsObserved !== 1 ? "s" : ""}. No action needed.`;
150
+ break;
151
+ }
152
+
153
+ return {
154
+ level,
155
+ summary,
156
+ last_run: lastRun,
157
+ skills_observed: skillsObserved,
158
+ pending_reviews: pendingReviews,
159
+ attention_required: attentionQueue.length,
160
+ };
15
161
  }