selftune 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +1 -0
  2. package/apps/local-dashboard/dist/assets/index-Bk9vSHHd.js +15 -0
  3. package/apps/local-dashboard/dist/assets/index-CRtLkBTi.css +1 -0
  4. package/apps/local-dashboard/dist/assets/vendor-react-BQH_6WrG.js +60 -0
  5. package/apps/local-dashboard/dist/assets/{vendor-table-B7VF2Ipl.js → vendor-table-dK1QMLq9.js} +1 -1
  6. package/apps/local-dashboard/dist/assets/{vendor-ui-r2k_Ku_V.js → vendor-ui-CO2mrx6e.js} +60 -65
  7. package/apps/local-dashboard/dist/index.html +5 -5
  8. package/cli/selftune/activation-rules.ts +30 -9
  9. package/cli/selftune/agent-guidance.ts +96 -0
  10. package/cli/selftune/alpha-identity.ts +157 -0
  11. package/cli/selftune/alpha-upload/build-payloads.ts +151 -0
  12. package/cli/selftune/alpha-upload/client.ts +113 -0
  13. package/cli/selftune/alpha-upload/flush.ts +191 -0
  14. package/cli/selftune/alpha-upload/index.ts +194 -0
  15. package/cli/selftune/alpha-upload/queue.ts +252 -0
  16. package/cli/selftune/alpha-upload/stage-canonical.ts +242 -0
  17. package/cli/selftune/alpha-upload-contract.ts +52 -0
  18. package/cli/selftune/auth/device-code.ts +110 -0
  19. package/cli/selftune/auto-update.ts +130 -0
  20. package/cli/selftune/badge/badge.ts +19 -9
  21. package/cli/selftune/canonical-export.ts +16 -3
  22. package/cli/selftune/constants.ts +28 -8
  23. package/cli/selftune/contribute/bundle.ts +32 -5
  24. package/cli/selftune/dashboard-contract.ts +32 -1
  25. package/cli/selftune/dashboard-server.ts +256 -692
  26. package/cli/selftune/dashboard.ts +1 -1
  27. package/cli/selftune/eval/baseline.ts +11 -7
  28. package/cli/selftune/eval/hooks-to-evals.ts +27 -9
  29. package/cli/selftune/eval/synthetic-evals.ts +54 -1
  30. package/cli/selftune/evolution/audit.ts +24 -19
  31. package/cli/selftune/evolution/constitutional.ts +176 -0
  32. package/cli/selftune/evolution/evidence.ts +18 -13
  33. package/cli/selftune/evolution/evolve-body.ts +104 -7
  34. package/cli/selftune/evolution/evolve.ts +195 -22
  35. package/cli/selftune/evolution/propose-body.ts +18 -1
  36. package/cli/selftune/evolution/propose-description.ts +27 -2
  37. package/cli/selftune/evolution/rollback.ts +11 -15
  38. package/cli/selftune/export.ts +84 -0
  39. package/cli/selftune/grading/auto-grade.ts +13 -4
  40. package/cli/selftune/grading/grade-session.ts +16 -6
  41. package/cli/selftune/hooks/evolution-guard.ts +26 -9
  42. package/cli/selftune/hooks/prompt-log.ts +23 -9
  43. package/cli/selftune/hooks/session-stop.ts +78 -15
  44. package/cli/selftune/hooks/skill-eval.ts +189 -10
  45. package/cli/selftune/index.ts +274 -2
  46. package/cli/selftune/ingestors/claude-replay.ts +48 -21
  47. package/cli/selftune/init.ts +249 -47
  48. package/cli/selftune/last.ts +7 -7
  49. package/cli/selftune/localdb/db.ts +90 -10
  50. package/cli/selftune/localdb/direct-write.ts +531 -0
  51. package/cli/selftune/localdb/materialize.ts +296 -42
  52. package/cli/selftune/localdb/queries.ts +325 -32
  53. package/cli/selftune/localdb/schema.ts +109 -0
  54. package/cli/selftune/monitoring/watch.ts +26 -8
  55. package/cli/selftune/normalization.ts +85 -15
  56. package/cli/selftune/observability.ts +248 -2
  57. package/cli/selftune/orchestrate.ts +165 -20
  58. package/cli/selftune/quickstart.ts +34 -10
  59. package/cli/selftune/repair/skill-usage.ts +12 -2
  60. package/cli/selftune/routes/actions.ts +77 -0
  61. package/cli/selftune/routes/badge.ts +66 -0
  62. package/cli/selftune/routes/doctor.ts +12 -0
  63. package/cli/selftune/routes/index.ts +14 -0
  64. package/cli/selftune/routes/orchestrate-runs.ts +13 -0
  65. package/cli/selftune/routes/overview.ts +14 -0
  66. package/cli/selftune/routes/report.ts +293 -0
  67. package/cli/selftune/routes/skill-report.ts +230 -0
  68. package/cli/selftune/status.ts +203 -7
  69. package/cli/selftune/sync.ts +13 -1
  70. package/cli/selftune/types.ts +50 -0
  71. package/cli/selftune/utils/jsonl.ts +58 -1
  72. package/cli/selftune/utils/selftune-meta.ts +38 -0
  73. package/cli/selftune/utils/skill-log.ts +30 -4
  74. package/cli/selftune/utils/transcript.ts +15 -0
  75. package/cli/selftune/workflows/workflows.ts +7 -6
  76. package/package.json +10 -6
  77. package/packages/telemetry-contract/fixtures/complete-push.ts +184 -0
  78. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +58 -0
  79. package/packages/telemetry-contract/fixtures/golden.json +1 -0
  80. package/packages/telemetry-contract/fixtures/index.ts +4 -0
  81. package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +40 -0
  82. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +79 -0
  83. package/packages/telemetry-contract/package.json +6 -1
  84. package/packages/telemetry-contract/src/index.ts +1 -0
  85. package/packages/telemetry-contract/src/schemas.ts +215 -0
  86. package/packages/telemetry-contract/src/types.ts +3 -1
  87. package/packages/telemetry-contract/src/validators.ts +3 -1
  88. package/packages/telemetry-contract/tests/compatibility.test.ts +144 -0
  89. package/packages/ui/package.json +4 -0
  90. package/packages/ui/src/components/ActivityTimeline.tsx +61 -29
  91. package/packages/ui/src/components/section-cards.tsx +31 -14
  92. package/packages/ui/src/types.ts +1 -0
  93. package/skill/SKILL.md +214 -174
  94. package/skill/Workflows/AlphaUpload.md +45 -0
  95. package/skill/Workflows/Baseline.md +18 -12
  96. package/skill/Workflows/Composability.md +3 -3
  97. package/skill/Workflows/Dashboard.md +44 -91
  98. package/skill/Workflows/Doctor.md +93 -66
  99. package/skill/Workflows/Evals.md +49 -40
  100. package/skill/Workflows/Evolve.md +76 -28
  101. package/skill/Workflows/EvolveBody.md +37 -38
  102. package/skill/Workflows/Initialize.md +172 -26
  103. package/skill/Workflows/Orchestrate.md +11 -2
  104. package/skill/Workflows/Sync.md +23 -0
  105. package/skill/Workflows/Watch.md +2 -5
  106. package/skill/agents/diagnosis-analyst.md +163 -0
  107. package/skill/agents/evolution-reviewer.md +149 -0
  108. package/skill/agents/integration-guide.md +154 -0
  109. package/skill/agents/pattern-analyst.md +149 -0
  110. package/skill/assets/multi-skill-settings.json +1 -1
  111. package/skill/assets/single-skill-settings.json +1 -1
  112. package/skill/references/interactive-config.md +39 -0
  113. package/skill/references/invocation-taxonomy.md +34 -0
  114. package/skill/references/logs.md +9 -1
  115. package/skill/references/setup-patterns.md +3 -3
  116. package/skill/settings_snippet.json +1 -1
  117. package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +0 -1
  118. package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +0 -15
  119. package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +0 -60
@@ -7,13 +7,20 @@
7
7
  * - Incremental: only inserts records newer than last materialization
8
8
  */
9
9
 
10
+ // NOTE: With dual-write active (Phase 1+), hooks insert directly into SQLite.
11
+ // The materializer is only needed for:
12
+ // 1. Initial startup (to catch pre-existing JSONL data from before dual-write)
13
+ // 2. Manual recovery after exporting JSONL and recreating the DB file
14
+ // 3. Backfill from batch ingestors that don't yet dual-write
15
+
10
16
  import type { Database } from "bun:sqlite";
11
- import type {
12
- CanonicalExecutionFactRecord,
13
- CanonicalPromptRecord,
14
- CanonicalRecord,
15
- CanonicalSessionRecord,
16
- CanonicalSkillInvocationRecord,
17
+ import {
18
+ type CanonicalExecutionFactRecord,
19
+ type CanonicalPromptRecord,
20
+ type CanonicalRecord,
21
+ type CanonicalSessionRecord,
22
+ type CanonicalSkillInvocationRecord,
23
+ isCanonicalRecord,
17
24
  } from "@selftune/telemetry-contract";
18
25
  import {
19
26
  CANONICAL_LOG,
@@ -23,7 +30,6 @@ import {
23
30
  TELEMETRY_LOG,
24
31
  } from "../constants.js";
25
32
  import type { OrchestrateRunReport } from "../dashboard-contract.js";
26
- import { readEvidenceTrail } from "../evolution/evidence.js";
27
33
  import type {
28
34
  EvolutionAuditEntry,
29
35
  EvolutionEvidenceEntry,
@@ -31,19 +37,134 @@ import type {
31
37
  SkillUsageRecord,
32
38
  } from "../types.js";
33
39
  import { readCanonicalRecords } from "../utils/canonical-log.js";
34
- import { readJsonl } from "../utils/jsonl.js";
40
+ import { readJsonl, readJsonlFrom } from "../utils/jsonl.js";
35
41
  import { readEffectiveSkillUsageRecords } from "../utils/skill-log.js";
36
42
  import { getMeta, setMeta } from "./db.js";
37
43
 
44
+ /** Tables that contain SQLite-only data (written by hooks, not just materialized from JSONL). */
45
+ const _PROTECTED_TABLES = [
46
+ { table: "evolution_audit", tsColumn: "timestamp", jsonlLog: EVOLUTION_AUDIT_LOG },
47
+ { table: "evolution_evidence", tsColumn: "timestamp", jsonlLog: EVOLUTION_EVIDENCE_LOG },
48
+ { table: "orchestrate_runs", tsColumn: "timestamp", jsonlLog: ORCHESTRATE_RUN_LOG },
49
+ ] as const;
50
+
51
+ /**
52
+ * Preflight check before full rebuild: detect tables where SQLite has rows
53
+ * newer than the corresponding JSONL file. If found and `force` is not set,
54
+ * throw an error so the user can export first.
55
+ */
56
+ function preflightRebuildGuard(db: Database, options?: MaterializeOptions): void {
57
+ if (options?.force) return;
58
+
59
+ const protectedTables = [
60
+ {
61
+ table: "evolution_audit",
62
+ tsColumn: "timestamp",
63
+ jsonlLog: options?.evolutionAuditPath ?? EVOLUTION_AUDIT_LOG,
64
+ },
65
+ {
66
+ table: "evolution_evidence",
67
+ tsColumn: "timestamp",
68
+ jsonlLog: options?.evolutionEvidencePath ?? EVOLUTION_EVIDENCE_LOG,
69
+ },
70
+ {
71
+ table: "orchestrate_runs",
72
+ tsColumn: "timestamp",
73
+ jsonlLog: options?.orchestrateRunLogPath ?? ORCHESTRATE_RUN_LOG,
74
+ },
75
+ ];
76
+
77
+ const warnings: string[] = [];
78
+ for (const { table, tsColumn, jsonlLog } of protectedTables) {
79
+ // Get newest timestamp in SQLite
80
+ let sqliteMax: string | null = null;
81
+ try {
82
+ const row = db.query(`SELECT MAX(${tsColumn}) AS max_ts FROM ${table}`).get() as {
83
+ max_ts: string | null;
84
+ } | null;
85
+ sqliteMax = row?.max_ts ?? null;
86
+ } catch {
87
+ continue; // table doesn't exist yet — safe to rebuild
88
+ }
89
+
90
+ if (!sqliteMax) continue; // no rows in SQLite — safe
91
+
92
+ // Get newest timestamp from JSONL
93
+ let jsonlMax: string | null = null;
94
+ let jsonlBoundaryCount = 0;
95
+ try {
96
+ const records = readJsonl<{ timestamp: string }>(jsonlLog);
97
+ if (records.length > 0) {
98
+ jsonlMax = records.reduce(
99
+ (max, r) => (r.timestamp > max ? r.timestamp : max),
100
+ records[0].timestamp,
101
+ );
102
+ jsonlBoundaryCount = records.filter((record) => record.timestamp === jsonlMax).length;
103
+ }
104
+ } catch {
105
+ // JSONL file doesn't exist or is empty — SQLite has data JSONL doesn't
106
+ jsonlMax = null;
107
+ }
108
+
109
+ let newerCount = 0;
110
+ let sqliteBoundaryCount = 0;
111
+ try {
112
+ if (!jsonlMax) {
113
+ const row = db.query(`SELECT COUNT(*) AS newer_count FROM ${table}`).get() as {
114
+ newer_count: number;
115
+ } | null;
116
+ newerCount = row?.newer_count ?? 0;
117
+ } else if (sqliteMax > jsonlMax) {
118
+ const row = db
119
+ .query(`SELECT COUNT(*) AS newer_count FROM ${table} WHERE ${tsColumn} > ?`)
120
+ .get(jsonlMax) as {
121
+ newer_count: number;
122
+ } | null;
123
+ newerCount = row?.newer_count ?? 0;
124
+ }
125
+ if (jsonlMax) {
126
+ const boundaryRow = db
127
+ .query(`SELECT COUNT(*) AS boundary_count FROM ${table} WHERE ${tsColumn} = ?`)
128
+ .get(jsonlMax) as {
129
+ boundary_count: number;
130
+ } | null;
131
+ sqliteBoundaryCount = boundaryRow?.boundary_count ?? 0;
132
+ }
133
+ } catch {
134
+ newerCount = 0;
135
+ sqliteBoundaryCount = 0;
136
+ }
137
+
138
+ if (
139
+ !jsonlMax ||
140
+ newerCount > 0 ||
141
+ (sqliteMax === jsonlMax && sqliteBoundaryCount !== jsonlBoundaryCount)
142
+ ) {
143
+ warnings.push(
144
+ ` - ${table}: ${newerCount} SQLite-only row(s), SQLite max=${sqliteMax}, JSONL max=${jsonlMax ?? "(empty)"}, boundary_count(SQLite=${sqliteBoundaryCount}, JSONL=${jsonlBoundaryCount})`,
145
+ );
146
+ }
147
+ }
148
+
149
+ if (warnings.length > 0) {
150
+ throw new Error(
151
+ `Rebuild blocked: the following tables have SQLite-only rows that would be lost:\n${warnings.join("\n")}\n\nRun \`selftune export\` first to preserve this data, then retry with --force.`,
152
+ );
153
+ }
154
+ }
155
+
38
156
  /** Meta key tracking last materialization timestamp. */
39
157
  const META_LAST_MATERIALIZED = "last_materialized_at";
158
+ /** Meta key prefix for per-file byte offsets (append-only incremental reads). */
159
+ const META_OFFSET_PREFIX = "file_offset:";
40
160
 
41
161
  /**
42
162
  * Full rebuild: drop all data tables, then re-insert everything.
43
163
  */
44
164
  export function materializeFull(db: Database, options?: MaterializeOptions): MaterializeResult {
165
+ preflightRebuildGuard(db, options);
166
+
45
167
  const tables = [
46
- "skill_usage",
47
168
  "session_telemetry",
48
169
  "evolution_audit",
49
170
  "evolution_evidence",
@@ -56,6 +177,8 @@ export function materializeFull(db: Database, options?: MaterializeOptions): Mat
56
177
  for (const table of tables) {
57
178
  db.run(`DELETE FROM ${table}`);
58
179
  }
180
+ // Clear byte offsets so full rebuild reads from start of each file
181
+ db.run("DELETE FROM _meta WHERE key LIKE ?", [`${META_OFFSET_PREFIX}%`]);
59
182
 
60
183
  return materializeIncremental(db, { ...options, since: null });
61
184
  }
@@ -67,6 +190,8 @@ export interface MaterializeOptions {
67
190
  evolutionEvidencePath?: string;
68
191
  orchestrateRunLogPath?: string;
69
192
  since?: string | null;
193
+ /** Skip the preflight rebuild guard (use after `selftune export`). */
194
+ force?: boolean;
70
195
  }
71
196
 
72
197
  export interface MaterializeResult {
@@ -105,11 +230,30 @@ export function materializeIncremental(
105
230
  orchestrateRuns: 0,
106
231
  };
107
232
 
108
- // -- Read all data BEFORE opening the transaction ---------------------------
109
- // This keeps file I/O out of the write lock for better concurrency.
233
+ // -- Read only NEW data using byte offsets -----------------------------------
234
+ // Append-only JSONL files: track byte offset per file in _meta so we only
235
+ // read bytes appended since the last materialization. Falls back to full
236
+ // read when since is null (first run / full rebuild).
110
237
 
111
- const canonical = readCanonicalRecords(options?.canonicalLogPath ?? CANONICAL_LOG);
112
- const filteredCanonical = since ? canonical.filter((r) => r.normalized_at > since) : canonical;
238
+ function getOffset(filePath: string): number {
239
+ if (!since) return 0; // full rebuild read everything
240
+ const raw = getMeta(db, `${META_OFFSET_PREFIX}${filePath}`);
241
+ return raw ? Number.parseInt(raw, 10) : 0;
242
+ }
243
+ const newOffsets: Array<[string, number]> = [];
244
+
245
+ const canonicalPath = options?.canonicalLogPath ?? CANONICAL_LOG;
246
+ let filteredCanonical: CanonicalRecord[];
247
+ if (!since) {
248
+ filteredCanonical = readCanonicalRecords(canonicalPath);
249
+ } else {
250
+ const { records, newOffset } = readJsonlFrom<CanonicalRecord>(
251
+ canonicalPath,
252
+ getOffset(canonicalPath),
253
+ );
254
+ filteredCanonical = records.filter(isCanonicalRecord);
255
+ newOffsets.push([canonicalPath, newOffset]);
256
+ }
113
257
 
114
258
  // Pre-partition canonical records by kind (single pass instead of 4x full scan)
115
259
  const byKind = new Map<string, CanonicalRecord[]>();
@@ -119,27 +263,63 @@ export function materializeIncremental(
119
263
  else byKind.set(r.record_kind, [r]);
120
264
  }
121
265
 
122
- const telemetry = readJsonl<SessionTelemetryRecord>(options?.telemetryLogPath ?? TELEMETRY_LOG);
123
- const filteredTelemetry = since ? telemetry.filter((r) => r.timestamp > since) : telemetry;
266
+ const telemetryPath = options?.telemetryLogPath ?? TELEMETRY_LOG;
267
+ let filteredTelemetry: SessionTelemetryRecord[];
268
+ if (!since) {
269
+ filteredTelemetry = readJsonl<SessionTelemetryRecord>(telemetryPath);
270
+ } else {
271
+ const { records, newOffset } = readJsonlFrom<SessionTelemetryRecord>(
272
+ telemetryPath,
273
+ getOffset(telemetryPath),
274
+ );
275
+ filteredTelemetry = records;
276
+ newOffsets.push([telemetryPath, newOffset]);
277
+ }
124
278
 
279
+ // Skill usage uses a merge of raw + repaired logs — always full read
280
+ // since readEffectiveSkillUsageRecords handles dedup internally.
281
+ // However, when doing incremental, filter by timestamp.
125
282
  const skills = readEffectiveSkillUsageRecords();
126
283
  const filteredSkills = since ? skills.filter((r) => r.timestamp > since) : skills;
127
284
 
128
- const audit = readJsonl<EvolutionAuditEntry>(options?.evolutionAuditPath ?? EVOLUTION_AUDIT_LOG);
129
- const filteredAudit = since ? audit.filter((r) => r.timestamp > since) : audit;
285
+ const auditPath = options?.evolutionAuditPath ?? EVOLUTION_AUDIT_LOG;
286
+ let filteredAudit: EvolutionAuditEntry[];
287
+ if (!since) {
288
+ filteredAudit = readJsonl<EvolutionAuditEntry>(auditPath);
289
+ } else {
290
+ const { records, newOffset } = readJsonlFrom<EvolutionAuditEntry>(
291
+ auditPath,
292
+ getOffset(auditPath),
293
+ );
294
+ filteredAudit = records;
295
+ newOffsets.push([auditPath, newOffset]);
296
+ }
130
297
 
131
- const evidence = readEvidenceTrail(
132
- undefined,
133
- options?.evolutionEvidencePath ?? EVOLUTION_EVIDENCE_LOG,
134
- );
135
- const filteredEvidence = since ? evidence.filter((r) => r.timestamp > since) : evidence;
298
+ const evidencePath = options?.evolutionEvidencePath ?? EVOLUTION_EVIDENCE_LOG;
299
+ let filteredEvidence: EvolutionEvidenceEntry[];
300
+ if (!since) {
301
+ filteredEvidence = readJsonl<EvolutionEvidenceEntry>(evidencePath);
302
+ } else {
303
+ const { records, newOffset } = readJsonlFrom<EvolutionEvidenceEntry>(
304
+ evidencePath,
305
+ getOffset(evidencePath),
306
+ );
307
+ filteredEvidence = records;
308
+ newOffsets.push([evidencePath, newOffset]);
309
+ }
136
310
 
137
- const orchestrateRuns = readJsonl<OrchestrateRunReport>(
138
- options?.orchestrateRunLogPath ?? ORCHESTRATE_RUN_LOG,
139
- );
140
- const filteredOrchestrateRuns = since
141
- ? orchestrateRuns.filter((r) => r.timestamp > since)
142
- : orchestrateRuns;
311
+ const orchestratePath = options?.orchestrateRunLogPath ?? ORCHESTRATE_RUN_LOG;
312
+ let filteredOrchestrateRuns: OrchestrateRunReport[];
313
+ if (!since) {
314
+ filteredOrchestrateRuns = readJsonl<OrchestrateRunReport>(orchestratePath);
315
+ } else {
316
+ const { records, newOffset } = readJsonlFrom<OrchestrateRunReport>(
317
+ orchestratePath,
318
+ getOffset(orchestratePath),
319
+ );
320
+ filteredOrchestrateRuns = records;
321
+ newOffsets.push([orchestratePath, newOffset]);
322
+ }
143
323
 
144
324
  // -- Insert everything inside a single transaction --------------------------
145
325
  db.run("BEGIN TRANSACTION");
@@ -154,6 +334,10 @@ export function materializeIncremental(
154
334
  result.evolutionEvidence = insertEvolutionEvidence(db, filteredEvidence);
155
335
  result.orchestrateRuns = insertOrchestrateRuns(db, filteredOrchestrateRuns);
156
336
 
337
+ // Persist byte offsets so next incremental run skips already-read data
338
+ for (const [filePath, offset] of newOffsets) {
339
+ setMeta(db, `${META_OFFSET_PREFIX}${filePath}`, String(offset));
340
+ }
157
341
  setMeta(db, META_LAST_MATERIALIZED, now);
158
342
  db.run("COMMIT");
159
343
  } catch (err) {
@@ -167,12 +351,24 @@ export function materializeIncremental(
167
351
  // -- Insert helpers -----------------------------------------------------------
168
352
 
169
353
  function insertSessions(db: Database, records: CanonicalRecord[]): number {
354
+ // Use upsert to merge non-null fields from duplicate session records.
355
+ // Multiple canonical records may exist for the same session (e.g., Stop hook
356
+ // writes one without model, replay ingestor writes another with model).
170
357
  const stmt = db.prepare(`
171
- INSERT OR IGNORE INTO sessions
358
+ INSERT INTO sessions
172
359
  (session_id, started_at, ended_at, platform, model, completion_status,
173
360
  source_session_kind, agent_cli, workspace_path, repo_remote, branch,
174
361
  schema_version, normalized_at)
175
362
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
363
+ ON CONFLICT(session_id) DO UPDATE SET
364
+ started_at = COALESCE(sessions.started_at, excluded.started_at),
365
+ ended_at = COALESCE(sessions.ended_at, excluded.ended_at),
366
+ model = COALESCE(sessions.model, excluded.model),
367
+ completion_status = COALESCE(sessions.completion_status, excluded.completion_status),
368
+ agent_cli = COALESCE(sessions.agent_cli, excluded.agent_cli),
369
+ repo_remote = COALESCE(sessions.repo_remote, excluded.repo_remote),
370
+ branch = COALESCE(sessions.branch, excluded.branch),
371
+ workspace_path = COALESCE(sessions.workspace_path, excluded.workspace_path)
176
372
  `);
177
373
 
178
374
  let count = 0;
@@ -223,16 +419,31 @@ function insertPrompts(db: Database, records: CanonicalRecord[]): number {
223
419
  }
224
420
 
225
421
  function insertSkillInvocations(db: Database, records: CanonicalRecord[]): number {
422
+ // Ensure session stubs exist for FK satisfaction — hooks may write
423
+ // skill_invocation records before a full session record is available.
424
+ const sessionStub = db.prepare(`
425
+ INSERT OR IGNORE INTO sessions
426
+ (session_id, platform, schema_version, normalized_at)
427
+ VALUES (?, ?, ?, ?)
428
+ `);
429
+
226
430
  const stmt = db.prepare(`
227
431
  INSERT OR IGNORE INTO skill_invocations
228
432
  (skill_invocation_id, session_id, occurred_at, skill_name, invocation_mode,
229
- triggered, confidence, tool_name, matched_prompt_id)
230
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
433
+ triggered, confidence, tool_name, matched_prompt_id, agent_type,
434
+ query, skill_path, skill_scope, source)
435
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
231
436
  `);
232
437
 
233
438
  let count = 0;
234
439
  for (const r of records) {
235
440
  const si = r as CanonicalSkillInvocationRecord;
441
+ sessionStub.run(
442
+ si.session_id,
443
+ si.platform ?? "unknown",
444
+ si.schema_version ?? "1.0.0",
445
+ si.normalized_at ?? new Date().toISOString(),
446
+ );
236
447
  stmt.run(
237
448
  si.skill_invocation_id,
238
449
  si.session_id,
@@ -243,6 +454,11 @@ function insertSkillInvocations(db: Database, records: CanonicalRecord[]): numbe
243
454
  si.confidence,
244
455
  si.tool_name ?? null,
245
456
  si.matched_prompt_id ?? null,
457
+ si.agent_type ?? null,
458
+ ((si as Record<string, unknown>).query as string) ?? null,
459
+ ((si as Record<string, unknown>).skill_path as string) ?? null,
460
+ ((si as Record<string, unknown>).skill_scope as string) ?? null,
461
+ ((si as Record<string, unknown>).source as string) ?? null,
246
462
  );
247
463
  count++;
248
464
  }
@@ -281,12 +497,29 @@ function insertExecutionFacts(db: Database, records: CanonicalRecord[]): number
281
497
 
282
498
  function insertSessionTelemetry(db: Database, records: SessionTelemetryRecord[]): number {
283
499
  const stmt = db.prepare(`
284
- INSERT OR IGNORE INTO session_telemetry
500
+ INSERT INTO session_telemetry
285
501
  (session_id, timestamp, cwd, transcript_path, tool_calls_json,
286
502
  total_tool_calls, bash_commands_json, skills_triggered_json,
287
503
  skills_invoked_json, assistant_turns, errors_encountered,
288
504
  transcript_chars, last_user_query, source, input_tokens, output_tokens)
289
505
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
506
+ ON CONFLICT(session_id) DO UPDATE SET
507
+ timestamp = excluded.timestamp,
508
+ cwd = COALESCE(excluded.cwd, session_telemetry.cwd),
509
+ transcript_path = COALESCE(excluded.transcript_path, session_telemetry.transcript_path),
510
+ source = COALESCE(excluded.source, session_telemetry.source),
511
+ tool_calls_json = excluded.tool_calls_json,
512
+ total_tool_calls = excluded.total_tool_calls,
513
+ bash_commands_json = excluded.bash_commands_json,
514
+ skills_triggered_json = COALESCE(excluded.skills_triggered_json, session_telemetry.skills_triggered_json),
515
+ skills_invoked_json = COALESCE(excluded.skills_invoked_json, session_telemetry.skills_invoked_json),
516
+ assistant_turns = excluded.assistant_turns,
517
+ errors_encountered = excluded.errors_encountered,
518
+ transcript_chars = excluded.transcript_chars,
519
+ last_user_query = excluded.last_user_query,
520
+ input_tokens = COALESCE(excluded.input_tokens, session_telemetry.input_tokens),
521
+ output_tokens = COALESCE(excluded.output_tokens, session_telemetry.output_tokens)
522
+ WHERE session_telemetry.timestamp IS NULL OR excluded.timestamp >= session_telemetry.timestamp
290
523
  `);
291
524
 
292
525
  let count = 0;
@@ -315,24 +548,44 @@ function insertSessionTelemetry(db: Database, records: SessionTelemetryRecord[])
315
548
  }
316
549
 
317
550
  function insertSkillUsage(db: Database, records: SkillUsageRecord[]): number {
318
- // Uses INSERT OR IGNORE with a UNIQUE index on the dedup composite key
319
- // (idx_skill_usage_dedup defined in schema.ts).
551
+ // Skill usage records now go into the unified skill_invocations table.
552
+ // Uses INSERT OR IGNORE with the dedup index on skill_invocations.
553
+ const sessionStub = db.prepare(`
554
+ INSERT OR IGNORE INTO sessions
555
+ (session_id, platform, schema_version, normalized_at)
556
+ VALUES (?, ?, ?, ?)
557
+ `);
558
+
320
559
  const stmt = db.prepare(`
321
- INSERT OR IGNORE INTO skill_usage
322
- (timestamp, session_id, skill_name, skill_path, skill_scope, query, triggered, source)
323
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
560
+ INSERT OR IGNORE INTO skill_invocations
561
+ (skill_invocation_id, session_id, occurred_at, skill_name, invocation_mode,
562
+ triggered, confidence, tool_name, matched_prompt_id, agent_type,
563
+ query, skill_path, skill_scope, source)
564
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
324
565
  `);
325
566
 
326
567
  let count = 0;
327
568
  for (const r of records) {
569
+ // Ensure session stub exists for FK satisfaction
570
+ sessionStub.run(r.session_id, "unknown", "1.0.0", new Date().toISOString());
571
+
572
+ // Derive a unique skill_invocation_id for skill_usage records
573
+ const invocationId = `${r.session_id}:su:${r.timestamp}:${r.skill_name}`;
574
+
328
575
  stmt.run(
329
- r.timestamp,
576
+ invocationId,
330
577
  r.session_id,
578
+ r.timestamp, // timestamp → occurred_at
331
579
  r.skill_name,
580
+ null, // invocation_mode — not available from skill_usage
581
+ r.triggered ? 1 : 0,
582
+ null, // confidence — not available from skill_usage
583
+ null, // tool_name — not available from skill_usage
584
+ null, // matched_prompt_id — not available from skill_usage
585
+ null, // agent_type — not available from skill_usage
586
+ r.query,
332
587
  r.skill_path,
333
588
  r.skill_scope ?? null,
334
- r.query,
335
- r.triggered ? 1 : 0,
336
589
  r.source ?? null,
337
590
  );
338
591
  count++;
@@ -345,8 +598,8 @@ function insertEvolutionAudit(db: Database, records: EvolutionAuditEntry[]): num
345
598
  // (idx_evo_audit_dedup defined in schema.ts).
346
599
  const stmt = db.prepare(`
347
600
  INSERT OR IGNORE INTO evolution_audit
348
- (timestamp, proposal_id, skill_name, action, details, eval_snapshot_json)
349
- VALUES (?, ?, ?, ?, ?, ?)
601
+ (timestamp, proposal_id, skill_name, action, details, eval_snapshot_json, iterations_used)
602
+ VALUES (?, ?, ?, ?, ?, ?, ?)
350
603
  `);
351
604
 
352
605
  let count = 0;
@@ -358,6 +611,7 @@ function insertEvolutionAudit(db: Database, records: EvolutionAuditEntry[]): num
358
611
  r.action,
359
612
  r.details,
360
613
  r.eval_snapshot ? JSON.stringify(r.eval_snapshot) : null,
614
+ r.iterations_used ?? null,
361
615
  );
362
616
  count++;
363
617
  }