@aiwg/cli 2026.7.24 → 2026.8.0

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.
@@ -0,0 +1,303 @@
1
+ import { z } from 'zod';
2
+ import { sha256 } from './contracts.js';
3
+ export const SESSION_ANALYTICS_VERSION = '1.0.0';
4
+ export const SessionAnalyticsCategorySchema = z.enum([
5
+ 'tool-call',
6
+ 'tool-result',
7
+ 'escalation',
8
+ 'hitl',
9
+ 'boundary',
10
+ 'indicator',
11
+ ]);
12
+ export const SessionAnalyticsStatusSchema = z.enum([
13
+ 'requested',
14
+ 'running',
15
+ 'succeeded',
16
+ 'failed',
17
+ 'granted',
18
+ 'denied',
19
+ 'timed-out',
20
+ 'unsupported',
21
+ 'provider-unknown',
22
+ 'observed',
23
+ ]);
24
+ export const SessionAnalyticsFactSchema = z.object({
25
+ analyticsVersion: z.literal(SESSION_ANALYTICS_VERSION),
26
+ factId: z.string().min(1),
27
+ category: SessionAnalyticsCategorySchema,
28
+ status: SessionAnalyticsStatusSchema,
29
+ provider: z.string().min(1),
30
+ workspaceId: z.string().min(1),
31
+ sessionId: z.string().min(1),
32
+ eventId: z.string().min(1),
33
+ sourceId: z.string().min(1),
34
+ importRunId: z.string().min(1),
35
+ occurredAt: z.string().datetime({ offset: true }).nullable(),
36
+ sequence: z.number().int().nonnegative(),
37
+ actor: z.string().min(1).nullable(),
38
+ participant: z.string().min(1).nullable(),
39
+ toolName: z.string().min(1).nullable(),
40
+ toolCallId: z.string().min(1).nullable(),
41
+ retryGroupId: z.string().min(1).nullable(),
42
+ retryOrdinal: z.number().int().positive().nullable(),
43
+ errorClass: z.string().min(1).nullable(),
44
+ capability: z.string().min(1).nullable(),
45
+ decision: z.string().min(1).nullable(),
46
+ promptType: z.string().min(1).nullable(),
47
+ latencyMs: z.number().int().nonnegative().nullable(),
48
+ transition: z.string().min(1).nullable(),
49
+ indicator: z.string().min(1).nullable(),
50
+ sensitivity: z.enum(['none', 'sensitive']),
51
+ extractionState: z.string().min(1).nullable(),
52
+ sourceCitation: z.object({
53
+ provider: z.string().min(1),
54
+ sessionId: z.string().min(1),
55
+ eventId: z.string().min(1),
56
+ importRunId: z.string().min(1),
57
+ sourceId: z.string().min(1),
58
+ locatorClass: z.string().min(1),
59
+ sequence: z.number().int().nonnegative(),
60
+ }).strict(),
61
+ }).strict();
62
+ /**
63
+ * Derive content-free analytics facts from normalized session events.
64
+ *
65
+ * Historical text is deliberately excluded. Classification uses the canonical
66
+ * event shape and already-sanitized native extension metadata only; commands,
67
+ * URLs, and provider payloads are never executed or copied into the index.
68
+ */
69
+ export function deriveSessionAnalytics(session, events) {
70
+ const ordered = [...events].sort((left, right) => left.sequence - right.sequence || left.eventId.localeCompare(right.eventId));
71
+ const facts = [];
72
+ const retries = new Map();
73
+ const calls = new Map();
74
+ for (const event of ordered) {
75
+ const metadata = flattenMetadata(event.extensions);
76
+ const normalizedKind = event.kind.toLowerCase();
77
+ const status = classifyStatus(normalizedKind, metadata);
78
+ const common = baseFact(session, event);
79
+ if (isToolCall(event, normalizedKind)) {
80
+ const retryKey = `${event.toolName ?? 'unknown'}\0${metadata.input_hash ?? metadata.arguments_digest ?? ''}`;
81
+ const prior = retries.get(retryKey);
82
+ const sameGroup = prior && event.sequence - prior.lastSequence <= 4;
83
+ const retry = sameGroup
84
+ ? { ...prior, count: prior.count + 1, lastSequence: event.sequence }
85
+ : {
86
+ groupId: sha256(['session-tool-retry', session.sessionId, retryKey, event.sequence].join('\0')),
87
+ count: 1,
88
+ lastSequence: event.sequence,
89
+ };
90
+ retries.set(retryKey, retry);
91
+ if (event.toolCallId) {
92
+ calls.set(event.toolCallId, { occurredAt: event.occurredAt, sequence: event.sequence });
93
+ }
94
+ facts.push({
95
+ ...common,
96
+ category: 'tool-call',
97
+ status: status === 'failed' ? 'failed' : 'requested',
98
+ retryGroupId: retry.groupId,
99
+ retryOrdinal: retry.count,
100
+ });
101
+ }
102
+ if (normalizedKind.includes('tool-result')) {
103
+ const call = event.toolCallId ? calls.get(event.toolCallId) : undefined;
104
+ facts.push({
105
+ ...common,
106
+ category: 'tool-result',
107
+ status: status === 'failed' ? 'failed' : 'succeeded',
108
+ latencyMs: elapsedMs(call?.occurredAt, event.occurredAt),
109
+ errorClass: status === 'failed'
110
+ ? (metadata.error_class ?? metadata.error_code ?? 'provider-error')
111
+ : null,
112
+ });
113
+ }
114
+ if (isEscalation(normalizedKind, metadata)) {
115
+ const decision = escalationDecision(status, metadata);
116
+ facts.push({
117
+ ...common,
118
+ category: 'escalation',
119
+ status: decision,
120
+ capability: metadata.capability ?? metadata.permission ?? metadata.scope ?? 'provider-unknown',
121
+ decision,
122
+ });
123
+ }
124
+ if (isHitl(normalizedKind, metadata)) {
125
+ facts.push({
126
+ ...common,
127
+ category: 'hitl',
128
+ status,
129
+ promptType: metadata.prompt_type ?? metadata.input_type ?? 'provider-unknown',
130
+ transition: metadata.transition ?? metadata.task_state ?? metadata.session_state ?? null,
131
+ latencyMs: integerMetadata(metadata.latency_ms),
132
+ });
133
+ }
134
+ if (event.activityBoundary) {
135
+ facts.push({
136
+ ...common,
137
+ category: 'boundary',
138
+ status: 'observed',
139
+ transition: event.activityBoundary,
140
+ });
141
+ }
142
+ const indicators = eventIndicators(event, normalizedKind, status, metadata);
143
+ for (const indicator of indicators) {
144
+ facts.push({
145
+ ...common,
146
+ category: 'indicator',
147
+ status: 'observed',
148
+ indicator,
149
+ errorClass: status === 'failed'
150
+ ? (metadata.error_class ?? metadata.error_code ?? 'provider-error')
151
+ : null,
152
+ });
153
+ }
154
+ }
155
+ for (const retry of retries.values()) {
156
+ if (retry.count < 3)
157
+ continue;
158
+ const anchor = facts.find((fact) => fact.retryGroupId === retry.groupId);
159
+ if (!anchor)
160
+ continue;
161
+ facts.push({
162
+ ...anchor,
163
+ category: 'indicator',
164
+ status: 'observed',
165
+ indicator: 'tool-quota-pressure',
166
+ factId: undefined,
167
+ });
168
+ }
169
+ return facts.map((fact) => SessionAnalyticsFactSchema.parse({
170
+ ...fact,
171
+ factId: sha256([
172
+ 'session-analytics',
173
+ SESSION_ANALYTICS_VERSION,
174
+ fact.category,
175
+ fact.eventId,
176
+ fact.indicator ?? '',
177
+ ].join('\0')),
178
+ }));
179
+ }
180
+ function baseFact(session, event) {
181
+ return {
182
+ analyticsVersion: SESSION_ANALYTICS_VERSION,
183
+ category: 'indicator',
184
+ status: 'observed',
185
+ provider: session.provider,
186
+ workspaceId: session.workspaceId,
187
+ sessionId: session.sessionId,
188
+ eventId: event.eventId,
189
+ sourceId: event.sourceId,
190
+ importRunId: event.importRunId,
191
+ occurredAt: event.occurredAt,
192
+ sequence: event.sequence,
193
+ actor: event.role,
194
+ participant: event.participant,
195
+ toolName: event.toolName,
196
+ toolCallId: event.toolCallId,
197
+ retryGroupId: null,
198
+ retryOrdinal: null,
199
+ errorClass: null,
200
+ capability: null,
201
+ decision: null,
202
+ promptType: null,
203
+ latencyMs: null,
204
+ transition: null,
205
+ indicator: null,
206
+ sensitivity: event.sensitivity.classification,
207
+ extractionState: event.extractionState,
208
+ sourceCitation: {
209
+ provider: session.provider,
210
+ sessionId: session.sessionId,
211
+ eventId: event.eventId,
212
+ importRunId: event.importRunId,
213
+ sourceId: event.sourceId,
214
+ locatorClass: event.rawReference.locatorClass,
215
+ sequence: event.sequence,
216
+ },
217
+ };
218
+ }
219
+ function flattenMetadata(value) {
220
+ const output = {};
221
+ const visit = (input, depth) => {
222
+ if (depth > 3 || !input || typeof input !== 'object' || Array.isArray(input))
223
+ return;
224
+ for (const [key, child] of Object.entries(input)) {
225
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]+/g, '_');
226
+ if (typeof child === 'string' || typeof child === 'number' || typeof child === 'boolean') {
227
+ if (!(normalized in output))
228
+ output[normalized] = String(child).slice(0, 160);
229
+ }
230
+ else {
231
+ visit(child, depth + 1);
232
+ }
233
+ }
234
+ };
235
+ visit(value, 0);
236
+ return output;
237
+ }
238
+ function classifyStatus(kind, metadata) {
239
+ const value = `${kind} ${metadata.status ?? ''} ${metadata.decision ?? ''}`.toLowerCase();
240
+ if (/(timeout|timed.out)/.test(value))
241
+ return 'timed-out';
242
+ if (/(unsupported|unavailable)/.test(value))
243
+ return 'unsupported';
244
+ if (/(deny|denied|reject|rejected)/.test(value))
245
+ return 'denied';
246
+ if (/(grant|granted|approve|approved|allow|allowed)/.test(value))
247
+ return 'granted';
248
+ if (/(fail|failed|error|errored)/.test(value))
249
+ return 'failed';
250
+ if (/(success|succeeded|complete|completed)/.test(value))
251
+ return 'succeeded';
252
+ if (/(request|requested|input.required|prompt)/.test(value))
253
+ return 'requested';
254
+ if (/(running|started|pending)/.test(value))
255
+ return 'running';
256
+ return 'provider-unknown';
257
+ }
258
+ function isToolCall(event, kind) {
259
+ return kind.includes('tool-call') || kind.includes('tool_use')
260
+ || Boolean(event.toolName && !kind.includes('tool-result'));
261
+ }
262
+ function isEscalation(kind, metadata) {
263
+ return /(escalat|permission|approval|sandbox|capability.required)/.test(kind)
264
+ || ['permission', 'capability', 'approval', 'scope'].some((key) => key in metadata);
265
+ }
266
+ function isHitl(kind, metadata) {
267
+ return /(hitl|human|input.required|input_required|operator.prompt)/.test(kind)
268
+ || ['prompt_type', 'input_type', 'reviewer'].some((key) => key in metadata);
269
+ }
270
+ function escalationDecision(status, metadata) {
271
+ if (['granted', 'denied', 'timed-out', 'unsupported'].includes(status))
272
+ return status;
273
+ if (metadata.decision)
274
+ return classifyStatus('', { decision: metadata.decision });
275
+ return status === 'requested' ? 'requested' : 'provider-unknown';
276
+ }
277
+ function eventIndicators(event, kind, status, metadata) {
278
+ const indicators = new Set();
279
+ if (event.opaque || /(unknown|malformed|unsupported)/.test(kind)) {
280
+ indicators.add('provider-schema-drift');
281
+ }
282
+ if (status === 'failed')
283
+ indicators.add('failed-operation');
284
+ if (event.sensitivity.classification === 'sensitive')
285
+ indicators.add('sensitive-field-redaction');
286
+ if (metadata.redaction_hit === 'true' || metadata.redacted === 'true') {
287
+ indicators.add('sensitive-field-redaction');
288
+ }
289
+ return [...indicators].sort();
290
+ }
291
+ function elapsedMs(start, end) {
292
+ if (!start || !end)
293
+ return null;
294
+ const value = Date.parse(end) - Date.parse(start);
295
+ return Number.isFinite(value) && value >= 0 ? value : null;
296
+ }
297
+ function integerMetadata(value) {
298
+ if (!value || !/^\d+$/.test(value))
299
+ return null;
300
+ const parsed = Number(value);
301
+ return Number.isSafeInteger(parsed) ? parsed : null;
302
+ }
303
+ //# sourceMappingURL=analytics.js.map
@@ -237,7 +237,7 @@ function normalizeBatch(request, records, importRunId, sourceObservedAt) {
237
237
  ? 'sensitive' : 'none',
238
238
  classes: [...new Set([...redacted.classes, ...native.classes])].sort(),
239
239
  },
240
- opaque: !KNOWN_EVENT_KINDS.has(record.kind),
240
+ opaque: !isKnownEventKind(record.kind),
241
241
  extensions,
242
242
  });
243
243
  if (!sessions.has(sessionId)) {
@@ -281,6 +281,12 @@ function normalizeBatch(request, records, importRunId, sourceObservedAt) {
281
281
  return { sessions: [...sessions.values()], events };
282
282
  }
283
283
  const KNOWN_EVENT_KINDS = new Set(['message', 'tool-call', 'tool-result', 'artifact', 'attachment', 'summary']);
284
+ function isKnownEventKind(kind) {
285
+ const normalized = kind.toLowerCase();
286
+ return KNOWN_EVENT_KINDS.has(normalized)
287
+ || /^(?:tool-call|tool-result)(?:[._-]|$)/.test(normalized)
288
+ || /^(?:sandbox|permission|approval|hitl|human|lifecycle)(?:[._-]|$)/.test(normalized);
289
+ }
284
290
  function earlierTimestamp(left, right) {
285
291
  if (!left)
286
292
  return right;
@@ -16,6 +16,7 @@ export * from './batch-import.js';
16
16
  export * from './workspace-discovery.js';
17
17
  export * from './timeline.js';
18
18
  export * from './origin.js';
19
+ export * from './analytics.js';
19
20
  export * from './adapters/generic.js';
20
21
  export * from './adapters/claude.js';
21
22
  export * from './adapters/codex.js';
@@ -70,7 +70,7 @@ const SECRET_PATTERNS = [
70
70
  const SENSITIVE_KEY = /(?:authorization|auth[_-]?header|cookie|credential|password|passwd|secret|token|api[_-]?key|private[_-]?key|connection[_-]?string)/i;
71
71
  const CONTENT_KEY = /(?:^|[_-])(?:text|content|prompt|command|arguments?|args|result|output|body|request|response|source|code|query)(?:$|[_-])/i;
72
72
  const PATH_KEY = /(?:^|[_-])(?:path|cwd|directory|filename|file)(?:$|[_-])/i;
73
- const SAFE_STRING_KEY = /(?:^|[_-])(?:id|kind|type|role|status|state|lifecycle|reason|name|product|provider|version|schema|model|format|class|mode|event|operation|tool|method|language|scope|consistency|disposition|phase|visibility|protocol|category|classification)(?:$|[_-])/i;
73
+ const SAFE_STRING_KEY = /(?:^|[_-])(?:id|kind|type|role|status|state|lifecycle|reason|name|product|provider|version|schema|model|format|class|mode|event|operation|tool|method|language|scope|consistency|disposition|phase|visibility|protocol|category|classification|decision|capability|permission|transition|hash|digest)(?:$|[_-])/i;
74
74
  const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
75
75
  const DEFAULT_NATIVE_LIMITS = {
76
76
  maxDepth: 8,
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module';
2
2
  import { CandidateReviewReceiptSchema, DeletionReceiptSchema, IntelligenceCandidateSchema, PromotionDependencyDecisionSchema, PromotionReceiptSchema, SessionContractError, sha256, } from './contracts.js';
3
3
  import { coverageFromBatchRun, } from './batch-contracts.js';
4
4
  import { redactSessionText, sanitizeNativeExtensions } from './policy.js';
5
+ import { deriveSessionAnalytics, } from './analytics.js';
5
6
  const require = createRequire(import.meta.url);
6
7
  const POLICY_PROVIDER_MIGRATION = 'policy-provider-identity:v2';
7
8
  const EVENT_ORIGIN_INTENT_MIGRATION = 'event-origin-intent:v1';
@@ -103,10 +104,23 @@ export class SessionRepository {
103
104
  CREATE TABLE IF NOT EXISTS session_catalog_meta (
104
105
  key TEXT PRIMARY KEY, value TEXT NOT NULL
105
106
  );
107
+ CREATE TABLE IF NOT EXISTS session_analytics_facts (
108
+ fact_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL,
109
+ session_id TEXT NOT NULL, event_id TEXT NOT NULL,
110
+ category TEXT NOT NULL, status TEXT NOT NULL,
111
+ provider TEXT NOT NULL, tool_name TEXT, occurred_at TEXT,
112
+ data TEXT NOT NULL,
113
+ FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
114
+ FOREIGN KEY(event_id) REFERENCES session_events(event_id) ON DELETE CASCADE
115
+ );
106
116
  CREATE INDEX IF NOT EXISTS idx_session_workspace_provider
107
117
  ON sessions(workspace_id, source_id, lifecycle);
108
118
  CREATE INDEX IF NOT EXISTS idx_event_session_sequence
109
119
  ON session_events(session_id, sequence_no);
120
+ CREATE INDEX IF NOT EXISTS idx_session_analytics_scope
121
+ ON session_analytics_facts(
122
+ workspace_id, category, provider, status, occurred_at, fact_id
123
+ );
110
124
  CREATE VIRTUAL TABLE IF NOT EXISTS session_event_fts USING fts5(
111
125
  event_id UNINDEXED, searchable_text, tokenize='unicode61'
112
126
  );
@@ -139,6 +153,23 @@ export class SessionRepository {
139
153
  this.alignSessionFtsRowids();
140
154
  this.migrateSessionPolicyAndProviderIdentity();
141
155
  this.migrateEventOriginAndIntent();
156
+ this.alignSessionAnalytics();
157
+ }
158
+ alignSessionAnalytics() {
159
+ const eventCount = Number(this.db.prepare(`SELECT COUNT(*) AS count
160
+ FROM session_events e
161
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
162
+ JOIN sessions s ON s.session_id=e.session_id
163
+ WHERE r.status='committed' AND s.lifecycle!='tombstoned'`).get()?.count ?? 0);
164
+ const factSessions = Number(this.db.prepare('SELECT COUNT(DISTINCT session_id) AS count FROM session_analytics_facts').get()?.count ?? 0);
165
+ const sourceSessions = Number(this.db.prepare(`SELECT COUNT(DISTINCT e.session_id) AS count
166
+ FROM session_events e
167
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
168
+ JOIN sessions s ON s.session_id=e.session_id
169
+ WHERE r.status='committed' AND s.lifecycle!='tombstoned'`).get()?.count ?? 0);
170
+ if (eventCount === 0 || factSessions === sourceSessions)
171
+ return;
172
+ this.rebuildAnalytics();
142
173
  }
143
174
  alignSessionFtsRowids() {
144
175
  const eventCount = Number(this.db.prepare('SELECT COUNT(*) AS count FROM session_events').get()?.count ?? 0);
@@ -470,6 +501,11 @@ export class SessionRepository {
470
501
  }
471
502
  const outcome = publish ? 'committed' : 'staged';
472
503
  this.db.prepare(`UPDATE import_runs SET status=?, checkpoint=? WHERE import_run_id=?`).run(outcome, JSON.stringify(checkpoint), batch.run.importRunId);
504
+ if (publish) {
505
+ for (const session of batch.sessions) {
506
+ this.rebuildAnalytics(session.workspaceId, session.sessionId);
507
+ }
508
+ }
473
509
  const counts = { sessions: sessionsInserted, events: eventsInserted };
474
510
  this.db.prepare(`INSERT INTO import_receipts(operation_id, import_run_id, outcome, counts, checkpoint)
475
511
  VALUES (?, ?, ?, ?, ?)`).run(batch.run.importRunId, batch.run.importRunId, outcome, JSON.stringify(counts), JSON.stringify(checkpoint));
@@ -525,6 +561,8 @@ export class SessionRepository {
525
561
  this.db.prepare(`UPDATE mutation_audit SET outcome='committed', occurred_at=?, data=?
526
562
  WHERE operation_id=?`).run(updated.observedAt, JSON.stringify(updated), operationId);
527
563
  }
564
+ if (result.changes > 0)
565
+ this.rebuildAnalytics();
528
566
  return result.changes;
529
567
  });
530
568
  return commit();
@@ -561,6 +599,8 @@ export class SessionRepository {
561
599
  this.db.prepare(`UPDATE mutation_audit SET outcome='committed', occurred_at=?, data=?
562
600
  WHERE operation_id=?`).run(updated.observedAt, JSON.stringify(updated), operationId);
563
601
  }
602
+ if (result.changes > 0)
603
+ this.rebuildAnalytics();
564
604
  return result.changes;
565
605
  });
566
606
  return commit();
@@ -876,6 +916,163 @@ export class SessionRepository {
876
916
  snapshotRowid,
877
917
  };
878
918
  }
919
+ rebuildAnalytics(workspaceId, sessionId) {
920
+ const where = [
921
+ `r.status='committed'`,
922
+ `s.lifecycle!='tombstoned'`,
923
+ ];
924
+ const params = [];
925
+ if (workspaceId) {
926
+ where.push('s.workspace_id=?');
927
+ params.push(workspaceId);
928
+ }
929
+ if (sessionId) {
930
+ where.push('s.session_id=?');
931
+ params.push(sessionId);
932
+ }
933
+ const sessions = this.db.prepare(`SELECT s.session_id, s.data
934
+ FROM sessions s
935
+ WHERE s.lifecycle!='tombstoned'
936
+ ${workspaceId ? 'AND s.workspace_id=?' : ''}
937
+ ${sessionId ? 'AND s.session_id=?' : ''}
938
+ AND EXISTS (
939
+ SELECT 1 FROM session_events e
940
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
941
+ WHERE e.session_id=s.session_id AND r.status='committed'
942
+ )
943
+ ORDER BY s.session_id`).all(...[
944
+ ...(workspaceId ? [workspaceId] : []),
945
+ ...(sessionId ? [sessionId] : []),
946
+ ]);
947
+ if (!workspaceId && !sessionId) {
948
+ this.db.exec('DELETE FROM session_analytics_facts');
949
+ }
950
+ else if (sessionId) {
951
+ this.db.prepare('DELETE FROM session_analytics_facts WHERE session_id=?').run(sessionId);
952
+ }
953
+ else {
954
+ this.db.prepare('DELETE FROM session_analytics_facts WHERE workspace_id=?').run(workspaceId);
955
+ }
956
+ const eventQuery = this.db.prepare(`SELECT e.data
957
+ FROM session_events e
958
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
959
+ JOIN sessions s ON s.session_id=e.session_id
960
+ WHERE e.session_id=? AND ${where.join(' AND ')}
961
+ ORDER BY e.sequence_no, e.event_id`);
962
+ const insert = this.db.prepare(`INSERT OR REPLACE INTO session_analytics_facts(
963
+ fact_id, workspace_id, session_id, event_id, category, status,
964
+ provider, tool_name, occurred_at, data
965
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
966
+ let inserted = 0;
967
+ for (const row of sessions) {
968
+ const session = JSON.parse(String(row.data));
969
+ const events = eventQuery.all(session.sessionId, ...params)
970
+ .map((eventRow) => JSON.parse(String(eventRow.data)));
971
+ for (const fact of deriveSessionAnalytics(session, events)) {
972
+ inserted += insert.run(fact.factId, fact.workspaceId, fact.sessionId, fact.eventId, fact.category, fact.status, fact.provider, fact.toolName, fact.occurredAt, JSON.stringify(fact)).changes;
973
+ }
974
+ }
975
+ return inserted;
976
+ }
977
+ listAnalyticsFacts(options) {
978
+ const where = [
979
+ 'f.workspace_id=?',
980
+ `s.lifecycle!='tombstoned'`,
981
+ `r.status='committed'`,
982
+ ];
983
+ const params = [options.workspaceId];
984
+ if (options.categories?.length) {
985
+ where.push(`f.category IN (${options.categories.map(() => '?').join(', ')})`);
986
+ params.push(...options.categories);
987
+ }
988
+ const scalar = [
989
+ ['f.provider', options.provider],
990
+ ['f.session_id', options.sessionId],
991
+ ['f.tool_name', options.tool],
992
+ ['f.status', options.status],
993
+ [`json_extract(f.data, '$.actor')`, options.actor],
994
+ [`json_extract(f.data, '$.sensitivity')`, options.sensitivity],
995
+ [`json_extract(f.data, '$.extractionState')`, options.extractionState],
996
+ ];
997
+ for (const [column, value] of scalar) {
998
+ if (value === undefined)
999
+ continue;
1000
+ where.push(`${column}=?`);
1001
+ params.push(value);
1002
+ }
1003
+ if (options.dateFrom) {
1004
+ where.push('f.occurred_at>=?');
1005
+ params.push(options.dateFrom);
1006
+ }
1007
+ if (options.dateTo) {
1008
+ where.push('f.occurred_at<=?');
1009
+ params.push(options.dateTo);
1010
+ }
1011
+ if (options.tag) {
1012
+ where.push(`EXISTS (
1013
+ SELECT 1 FROM session_tags t
1014
+ WHERE t.session_id=f.session_id AND t.tag=?
1015
+ )`);
1016
+ params.push(options.tag);
1017
+ }
1018
+ const limit = options.limit ?? 500;
1019
+ if (limit < 1 || limit > 5_000) {
1020
+ throw new SessionContractError('RESOURCE_LIMIT_EXCEEDED', 'analytics limit must be between 1 and 5000');
1021
+ }
1022
+ return this.db.prepare(`SELECT f.data
1023
+ FROM session_analytics_facts f
1024
+ JOIN sessions s ON s.session_id=f.session_id
1025
+ JOIN session_events e ON e.event_id=f.event_id
1026
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
1027
+ WHERE ${where.join(' AND ')}
1028
+ ORDER BY
1029
+ CASE WHEN f.occurred_at IS NULL THEN 1 ELSE 0 END,
1030
+ f.occurred_at, f.session_id,
1031
+ json_extract(f.data, '$.sequence'), f.fact_id
1032
+ LIMIT ?`).all(...params, limit)
1033
+ .map((row) => JSON.parse(String(row.data)));
1034
+ }
1035
+ analyticsSummary(options) {
1036
+ const facts = this.listAnalyticsFacts({ ...options, limit: options.limit ?? 5_000 });
1037
+ const byCategory = countBy(facts, (fact) => fact.category);
1038
+ const byStatus = countBy(facts, (fact) => fact.status);
1039
+ const byProvider = countBy(facts, (fact) => fact.provider);
1040
+ const byTool = countBy(facts.filter((fact) => fact.toolName), (fact) => fact.toolName);
1041
+ return {
1042
+ analyticsVersion: '1.0.0',
1043
+ workspaceId: options.workspaceId,
1044
+ totals: {
1045
+ facts: facts.length,
1046
+ sessions: new Set(facts.map((fact) => fact.sessionId)).size,
1047
+ toolCalls: facts.filter((fact) => fact.category === 'tool-call').length,
1048
+ toolFailures: facts.filter((fact) => fact.category === 'tool-result' && fact.status === 'failed').length,
1049
+ escalations: facts.filter((fact) => fact.category === 'escalation').length,
1050
+ hitlDecisions: facts.filter((fact) => fact.category === 'hitl').length,
1051
+ indicators: facts.filter((fact) => fact.category === 'indicator').length,
1052
+ retryGroups: new Set(facts.map((fact) => fact.retryGroupId).filter(Boolean)).size,
1053
+ },
1054
+ byCategory,
1055
+ byStatus,
1056
+ byProvider,
1057
+ byTool,
1058
+ };
1059
+ }
1060
+ getAnalyticsEvidence(id, workspaceId) {
1061
+ const row = this.db.prepare(`SELECT f.data AS fact_data, e.data AS event_data
1062
+ FROM session_analytics_facts f
1063
+ JOIN sessions s ON s.session_id=f.session_id
1064
+ JOIN session_events e ON e.event_id=f.event_id
1065
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
1066
+ WHERE (f.fact_id=? OR f.event_id=?)
1067
+ AND f.workspace_id=? AND s.lifecycle!='tombstoned' AND r.status='committed'
1068
+ ORDER BY f.fact_id LIMIT 1`).get(id, id, workspaceId);
1069
+ return row
1070
+ ? {
1071
+ fact: JSON.parse(String(row.fact_data)),
1072
+ event: JSON.parse(String(row.event_data)),
1073
+ }
1074
+ : { fact: null, event: null };
1075
+ }
879
1076
  saveCandidates(candidates) {
880
1077
  const save = this.db.transaction(() => {
881
1078
  let inserted = 0;
@@ -1161,6 +1358,9 @@ export class SessionRepository {
1161
1358
  counts: { sessions: 1 },
1162
1359
  });
1163
1360
  }
1361
+ if (changed) {
1362
+ this.db.prepare('DELETE FROM session_analytics_facts WHERE session_id=?').run(sessionId);
1363
+ }
1164
1364
  return changed;
1165
1365
  });
1166
1366
  return tombstone();
@@ -1187,6 +1387,8 @@ export class SessionRepository {
1187
1387
  counts: { sessions: 1 },
1188
1388
  });
1189
1389
  }
1390
+ if (changed)
1391
+ this.rebuildAnalytics(workspaceId, sessionId);
1190
1392
  return changed;
1191
1393
  });
1192
1394
  return restore();
@@ -1385,6 +1587,7 @@ export class SessionRepository {
1385
1587
  dependencyDispositions: validatedDecisions.length,
1386
1588
  },
1387
1589
  });
1590
+ this.db.prepare('DELETE FROM session_analytics_facts WHERE session_id=?').run(input.preview.sessionId);
1388
1591
  return receipt;
1389
1592
  });
1390
1593
  return apply();
@@ -1400,6 +1603,7 @@ export class SessionRepository {
1400
1603
  SELECT rowid, event_id, json_extract(data, '$.searchableText') FROM session_events
1401
1604
  `);
1402
1605
  indexed = Number(this.db.prepare('SELECT COUNT(*) AS count FROM session_events').get()?.count ?? 0);
1606
+ this.rebuildAnalytics();
1403
1607
  }
1404
1608
  else {
1405
1609
  this.db.prepare(`DELETE FROM session_event_fts WHERE rowid IN (
@@ -1412,6 +1616,7 @@ export class SessionRepository {
1412
1616
  FROM session_events e
1413
1617
  JOIN sessions s ON s.session_id=e.session_id
1414
1618
  WHERE s.workspace_id=?`).run(workspaceId).changes;
1619
+ this.rebuildAnalytics(workspaceId);
1415
1620
  }
1416
1621
  this.emitRepositoryMutation({
1417
1622
  operationId: sha256([
@@ -1506,6 +1711,14 @@ function canonicalDevinLocator(value) {
1506
1711
  ? `devin-desktop-${value.slice('windsurf-'.length)}`
1507
1712
  : value;
1508
1713
  }
1714
+ function countBy(items, key) {
1715
+ const counts = {};
1716
+ for (const item of items) {
1717
+ const value = key(item);
1718
+ counts[value] = (counts[value] ?? 0) + 1;
1719
+ }
1720
+ return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
1721
+ }
1509
1722
  function encodeMutationCursor(rowid, workspaceId) {
1510
1723
  const unsigned = { rowid, workspaceId };
1511
1724
  return Buffer.from(JSON.stringify({