@aiwg/cli 2026.7.21 → 2026.7.23

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.
@@ -1,7 +1,10 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { CandidateReviewReceiptSchema, DeletionReceiptSchema, IntelligenceCandidateSchema, PromotionDependencyDecisionSchema, PromotionReceiptSchema, SessionContractError, sha256, } from './contracts.js';
3
+ import { coverageFromBatchRun, } from './batch-contracts.js';
3
4
  import { redactSessionText, sanitizeNativeExtensions } from './policy.js';
4
5
  const require = createRequire(import.meta.url);
6
+ const POLICY_PROVIDER_MIGRATION = 'policy-provider-identity:v2';
7
+ const EVENT_ORIGIN_INTENT_MIGRATION = 'event-origin-intent:v1';
5
8
  export class SessionRepository {
6
9
  db;
7
10
  constructor(path = ':memory:') {
@@ -14,6 +17,7 @@ export class SessionRepository {
14
17
  }
15
18
  this.db.pragma('foreign_keys = ON');
16
19
  this.db.pragma('journal_mode = WAL');
20
+ this.db.pragma('busy_timeout = 5000');
17
21
  this.initialize();
18
22
  }
19
23
  initialize() {
@@ -24,6 +28,7 @@ export class SessionRepository {
24
28
  CREATE TABLE IF NOT EXISTS import_runs (
25
29
  import_run_id TEXT PRIMARY KEY, source_id TEXT NOT NULL, parser_version TEXT NOT NULL,
26
30
  status TEXT NOT NULL, checkpoint TEXT NOT NULL, data TEXT NOT NULL,
31
+ batch_run_id TEXT,
27
32
  FOREIGN KEY(source_id) REFERENCES session_sources(source_id)
28
33
  );
29
34
  CREATE TABLE IF NOT EXISTS sessions (
@@ -45,6 +50,12 @@ export class SessionRepository {
45
50
  outcome TEXT NOT NULL, counts TEXT NOT NULL, checkpoint TEXT NOT NULL,
46
51
  FOREIGN KEY(import_run_id) REFERENCES import_runs(import_run_id)
47
52
  );
53
+ CREATE TABLE IF NOT EXISTS batch_import_runs (
54
+ batch_run_id TEXT PRIMARY KEY, manifest_id TEXT NOT NULL,
55
+ workspace_id TEXT NOT NULL, status TEXT NOT NULL,
56
+ started_at TEXT NOT NULL, updated_at TEXT NOT NULL, data TEXT NOT NULL,
57
+ UNIQUE(manifest_id, workspace_id)
58
+ );
48
59
  CREATE TABLE IF NOT EXISTS mutation_audit (
49
60
  operation_id TEXT PRIMARY KEY, operation TEXT NOT NULL, actor TEXT NOT NULL,
50
61
  counts TEXT NOT NULL, adapter_version TEXT NOT NULL, policy_version TEXT NOT NULL,
@@ -89,6 +100,9 @@ export class SessionRepository {
89
100
  dependent_id TEXT NOT NULL, action TEXT NOT NULL, data TEXT NOT NULL,
90
101
  UNIQUE(operation_id, dependent_id)
91
102
  );
103
+ CREATE TABLE IF NOT EXISTS session_catalog_meta (
104
+ key TEXT PRIMARY KEY, value TEXT NOT NULL
105
+ );
92
106
  CREATE INDEX IF NOT EXISTS idx_session_workspace_provider
93
107
  ON sessions(workspace_id, source_id, lifecycle);
94
108
  CREATE INDEX IF NOT EXISTS idx_event_session_sequence
@@ -111,12 +125,20 @@ export class SessionRepository {
111
125
  if (!mutationColumns.includes('data')) {
112
126
  this.db.exec('ALTER TABLE mutation_audit ADD COLUMN data TEXT');
113
127
  }
128
+ const importRunColumns = this.db.prepare('PRAGMA table_info(import_runs)').all().map((row) => String(row.name));
129
+ if (!importRunColumns.includes('batch_run_id')) {
130
+ this.db.exec('ALTER TABLE import_runs ADD COLUMN batch_run_id TEXT');
131
+ }
114
132
  this.db.exec(`
115
133
  CREATE INDEX IF NOT EXISTS idx_mutation_audit_workspace
116
134
  ON mutation_audit(workspace_id, occurred_at, operation_id)
135
+ ;
136
+ CREATE INDEX IF NOT EXISTS idx_batch_import_workspace
137
+ ON batch_import_runs(workspace_id, updated_at, batch_run_id)
117
138
  `);
118
139
  this.alignSessionFtsRowids();
119
140
  this.migrateSessionPolicyAndProviderIdentity();
141
+ this.migrateEventOriginAndIntent();
120
142
  }
121
143
  alignSessionFtsRowids() {
122
144
  const eventCount = Number(this.db.prepare('SELECT COUNT(*) AS count FROM session_events').get()?.count ?? 0);
@@ -138,7 +160,7 @@ export class SessionRepository {
138
160
  rebuild();
139
161
  }
140
162
  migrateSessionPolicyAndProviderIdentity() {
141
- const migrate = this.db.transaction(() => {
163
+ this.runCatalogMigration(POLICY_PROVIDER_MIGRATION, () => {
142
164
  for (const row of this.db.prepare('SELECT source_id, provider, data FROM session_sources').all()) {
143
165
  const source = JSON.parse(String(row.data));
144
166
  const canonicalProvider = source.provider === 'windsurf'
@@ -153,7 +175,7 @@ export class SessionRepository {
153
175
  const session = JSON.parse(String(row.data));
154
176
  if (session.provider === 'windsurf')
155
177
  session.provider = 'devin-desktop';
156
- session.extensions = sanitizeNativeExtensions(renameNativeWindsurfEnvelope(session.extensions)).value;
178
+ session.extensions = sanitizeSessionExtensions(renameNativeWindsurfEnvelope(session.extensions), session.provider);
157
179
  this.db.prepare('UPDATE sessions SET data=? WHERE session_id=?')
158
180
  .run(JSON.stringify(session), String(row.session_id));
159
181
  }
@@ -223,7 +245,52 @@ export class SessionRepository {
223
245
  WHERE operation_id=?`).run(workspaceId, event.targetClass, JSON.stringify(event), event.operationId);
224
246
  }
225
247
  });
226
- migrate();
248
+ }
249
+ migrateEventOriginAndIntent() {
250
+ this.runCatalogMigration(EVENT_ORIGIN_INTENT_MIGRATION, () => {
251
+ for (const row of this.db.prepare('SELECT event_id, data FROM session_events').all()) {
252
+ const event = JSON.parse(String(row.data));
253
+ let changed = false;
254
+ if (typeof event.origin !== 'string') {
255
+ event.origin = 'unknown';
256
+ event.originRule = 'legacy:unknown';
257
+ event.originClassifierVersion = '1.0.0';
258
+ changed = true;
259
+ }
260
+ if (!Object.hasOwn(event, 'activityBoundary')) {
261
+ event.activityBoundary = null;
262
+ event.activityBoundaryBasis = null;
263
+ event.activityBoundaryConfidence = null;
264
+ changed = true;
265
+ }
266
+ if (changed) {
267
+ this.db.prepare('UPDATE session_events SET data=? WHERE event_id=?').run(JSON.stringify(event), String(row.event_id));
268
+ }
269
+ }
270
+ for (const row of this.db.prepare('SELECT session_id, data FROM sessions').all()) {
271
+ const session = JSON.parse(String(row.data));
272
+ if (session.intent !== undefined)
273
+ continue;
274
+ session.intent = {
275
+ status: 'unknown',
276
+ eventId: null,
277
+ sequence: null,
278
+ title: null,
279
+ summary: null,
280
+ };
281
+ this.db.prepare('UPDATE sessions SET data=? WHERE session_id=?').run(JSON.stringify(session), String(row.session_id));
282
+ }
283
+ });
284
+ }
285
+ runCatalogMigration(migration, migrate) {
286
+ const applied = this.db.prepare('SELECT 1 AS applied FROM session_catalog_meta WHERE key=?').get(migration);
287
+ if (applied)
288
+ return;
289
+ const transaction = this.db.transaction(() => {
290
+ migrate();
291
+ this.db.prepare('INSERT INTO session_catalog_meta (key, value) VALUES (?, ?)').run(migration, 'applied');
292
+ });
293
+ transaction();
227
294
  }
228
295
  emitMutation(input) {
229
296
  const unsigned = {
@@ -344,8 +411,9 @@ export class SessionRepository {
344
411
  }
345
412
  this.db.prepare(`INSERT INTO session_sources(source_id, provider, data) VALUES (?, ?, ?)
346
413
  ON CONFLICT(source_id) DO UPDATE SET data=excluded.data`).run(batch.source.sourceId, batch.source.provider, JSON.stringify(batch.source));
347
- this.db.prepare(`INSERT INTO import_runs(import_run_id, source_id, parser_version, status, checkpoint, data)
348
- VALUES (?, ?, ?, 'running', ?, ?)`).run(batch.run.importRunId, batch.source.sourceId, batch.run.parserVersion, JSON.stringify(checkpoint), JSON.stringify(batch.run));
414
+ this.db.prepare(`INSERT INTO import_runs(
415
+ import_run_id, source_id, parser_version, status, checkpoint, data, batch_run_id
416
+ ) VALUES (?, ?, ?, 'running', ?, ?, ?)`).run(batch.run.importRunId, batch.source.sourceId, batch.run.parserVersion, JSON.stringify(checkpoint), JSON.stringify(batch.run), batch.batchRunId ?? null);
349
417
  let sessionsInserted = 0;
350
418
  let eventsInserted = 0;
351
419
  for (const session of batch.sessions) {
@@ -372,7 +440,25 @@ export class SessionRepository {
372
440
  const prior = this.db.prepare('SELECT digest FROM session_events WHERE event_id=?')
373
441
  .get(event.eventId);
374
442
  if (prior && String(prior.digest) !== event.digest) {
375
- throw new SessionContractError('IMPORT_CONFLICT', `stable event identity changed content: ${event.eventId}`);
443
+ const priorEvent = this.db.prepare(`SELECT e.data AS event_data, src.provider AS provider, s.native_session_id AS native_session_id
444
+ FROM session_events e
445
+ JOIN sessions s ON s.session_id=e.session_id
446
+ JOIN session_sources src ON src.source_id=e.source_id
447
+ WHERE e.event_id=?`).get(event.eventId);
448
+ const priorCoordinates = priorEvent
449
+ ? safeEventCoordinates(String(priorEvent.event_data), {
450
+ provider: String(priorEvent.provider),
451
+ nativeSessionId: String(priorEvent.native_session_id),
452
+ })
453
+ : { provider: '<unknown>', sourceId: '<unknown>', nativeSessionId: '<unknown>', nativeEventId: event.eventId };
454
+ const incomingCoordinates = {
455
+ provider: batch.source.provider,
456
+ sourceId: event.sourceId,
457
+ nativeSessionId: batch.sessions.find((session) => session.sessionId === event.sessionId)
458
+ ?.nativeSessionId ?? event.sessionId,
459
+ nativeEventId: event.nativeId ?? `sequence:${event.sequence}`,
460
+ };
461
+ throw new SessionContractError('IMPORT_CONFLICT', `stable event identity changed content: ${event.eventId}; prior=${formatEventCoordinates(priorCoordinates)} incoming=${formatEventCoordinates(incomingCoordinates)}`);
376
462
  }
377
463
  const inserted = insertEvent.run(event.eventId, event.sessionId, event.sourceId, event.importRunId, event.sequence, event.digest, JSON.stringify(event)).changes;
378
464
  eventsInserted += inserted;
@@ -443,6 +529,53 @@ export class SessionRepository {
443
529
  });
444
530
  return commit();
445
531
  }
532
+ commitStagedBatch(batchRunId, sourceIds) {
533
+ if (sourceIds.length === 0)
534
+ return 0;
535
+ const placeholders = sourceIds.map(() => '?').join(', ');
536
+ const commit = this.db.transaction(() => {
537
+ const params = [batchRunId, ...sourceIds];
538
+ const staged = this.db.prepare(`SELECT import_run_id FROM import_runs
539
+ WHERE batch_run_id=? AND source_id IN (${placeholders}) AND status='staged'`).all(...params).map((row) => String(row.import_run_id));
540
+ const result = this.db.prepare(`UPDATE import_runs SET status='committed'
541
+ WHERE batch_run_id=? AND source_id IN (${placeholders}) AND status='staged'`).run(...params);
542
+ if (staged.length > 0) {
543
+ const runPlaceholders = staged.map(() => '?').join(', ');
544
+ this.db.prepare(`UPDATE import_receipts SET outcome='committed'
545
+ WHERE import_run_id IN (${runPlaceholders}) AND outcome='staged'`).run(...staged);
546
+ }
547
+ for (const operationId of staged) {
548
+ const row = this.db.prepare('SELECT data FROM mutation_audit WHERE operation_id=?').get(operationId);
549
+ if (!row?.data)
550
+ continue;
551
+ const prior = JSON.parse(String(row.data));
552
+ const { integrityDigest: _integrityDigest, ...unsigned } = {
553
+ ...prior,
554
+ outcome: 'committed',
555
+ observedAt: new Date().toISOString(),
556
+ };
557
+ const updated = {
558
+ ...unsigned,
559
+ integrityDigest: sha256(JSON.stringify(unsigned)),
560
+ };
561
+ this.db.prepare(`UPDATE mutation_audit SET outcome='committed', occurred_at=?, data=?
562
+ WHERE operation_id=?`).run(updated.observedAt, JSON.stringify(updated), operationId);
563
+ }
564
+ return result.changes;
565
+ });
566
+ return commit();
567
+ }
568
+ sourceImportTotals(sourceId, batchRunId) {
569
+ const events = Number(this.db.prepare(`SELECT COUNT(*) AS count
570
+ FROM session_events e
571
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
572
+ WHERE e.source_id=? AND r.batch_run_id=?`).get(sourceId, batchRunId)?.count ?? 0);
573
+ const sessions = Number(this.db.prepare(`SELECT COUNT(DISTINCT e.session_id) AS count
574
+ FROM session_events e
575
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
576
+ WHERE e.source_id=? AND r.batch_run_id=?`).get(sourceId, batchRunId)?.count ?? 0);
577
+ return { sessions, events };
578
+ }
446
579
  getSession(id, workspaceId) {
447
580
  const row = this.db.prepare(`SELECT s.data FROM sessions s
448
581
  WHERE s.session_id=? AND s.lifecycle != ?
@@ -458,6 +591,42 @@ export class SessionRepository {
458
591
  listSources() {
459
592
  return this.db.prepare('SELECT data FROM session_sources ORDER BY provider, source_id').all().map((row) => JSON.parse(String(row.data)));
460
593
  }
594
+ listWorkspaceIds() {
595
+ return this.db.prepare('SELECT DISTINCT workspace_id FROM sessions ORDER BY workspace_id').all().map((row) => String(row.workspace_id));
596
+ }
597
+ saveBatchImportRun(run) {
598
+ const save = this.db.transaction(() => {
599
+ const prior = this.db.prepare('SELECT manifest_id, workspace_id FROM batch_import_runs WHERE batch_run_id=?').get(run.runId);
600
+ if (prior && (String(prior.manifest_id) !== run.manifestId
601
+ || String(prior.workspace_id) !== run.workspaceId)) {
602
+ throw new SessionContractError('IMPORT_CONFLICT', 'batch run identity changed manifest or workspace scope');
603
+ }
604
+ this.db.prepare(`INSERT INTO batch_import_runs(
605
+ batch_run_id, manifest_id, workspace_id, status, started_at, updated_at, data
606
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
607
+ ON CONFLICT(batch_run_id) DO UPDATE SET
608
+ status=excluded.status, updated_at=excluded.updated_at, data=excluded.data`).run(run.runId, run.manifestId, run.workspaceId, run.status, run.startedAt, run.updatedAt, JSON.stringify(run));
609
+ });
610
+ save();
611
+ }
612
+ getBatchImportRun(runId) {
613
+ const row = this.db.prepare('SELECT data FROM batch_import_runs WHERE batch_run_id=?').get(runId);
614
+ return row ? JSON.parse(String(row.data)) : null;
615
+ }
616
+ getBatchImportRunForManifest(manifestId, workspaceId) {
617
+ const row = this.db.prepare(`SELECT data FROM batch_import_runs
618
+ WHERE manifest_id=? AND workspace_id=?`).get(manifestId, workspaceId);
619
+ return row ? JSON.parse(String(row.data)) : null;
620
+ }
621
+ getCoverage(workspaceId, now = new Date()) {
622
+ const row = this.db.prepare(`SELECT data FROM batch_import_runs
623
+ WHERE workspace_id=?
624
+ ORDER BY updated_at DESC, batch_run_id DESC LIMIT 1`).get(workspaceId);
625
+ const coverage = coverageFromBatchRun(row ? JSON.parse(String(row.data)) : null, now);
626
+ return coverage.status === 'unknown'
627
+ ? { ...coverage, workspaceId }
628
+ : coverage;
629
+ }
461
630
  listSessions(options) {
462
631
  const where = [`s.lifecycle != 'tombstoned'`, `EXISTS (
463
632
  SELECT 1 FROM session_events e
@@ -554,6 +723,23 @@ export class SessionRepository {
554
723
  ORDER BY e.sequence_no, e.event_id`).all(...(workspaceId ? [sessionId, workspaceId] : [sessionId]))
555
724
  .map((row) => JSON.parse(String(row.data)));
556
725
  }
726
+ listTimelineInputs(workspaceId) {
727
+ return this.db.prepare(`SELECT s.data AS session_data, e.data AS event_data
728
+ FROM session_events e
729
+ JOIN sessions s ON s.session_id=e.session_id
730
+ JOIN import_runs r ON r.import_run_id=e.import_run_id
731
+ WHERE s.workspace_id=? AND s.lifecycle!='tombstoned' AND r.status='committed'
732
+ ORDER BY
733
+ CASE WHEN json_extract(e.data, '$.occurredAt') IS NULL THEN 1 ELSE 0 END,
734
+ json_extract(e.data, '$.occurredAt'),
735
+ json_extract(s.data, '$.provider'),
736
+ e.session_id,
737
+ e.sequence_no,
738
+ e.event_id`).all(workspaceId).map((row) => ({
739
+ session: JSON.parse(String(row.session_data)),
740
+ event: JSON.parse(String(row.event_data)),
741
+ }));
742
+ }
557
743
  search(options) {
558
744
  const query = options.query.trim();
559
745
  if (!query)
@@ -889,6 +1075,13 @@ export class SessionRepository {
889
1075
  ORDER BY rowid DESC LIMIT 1`).get(sourceId, parserVersion);
890
1076
  return row ? JSON.parse(String(row.checkpoint)) : null;
891
1077
  }
1078
+ getBatchCheckpoint(sourceId, parserVersion, batchRunId) {
1079
+ const row = this.db.prepare(`SELECT checkpoint FROM import_runs
1080
+ WHERE source_id=? AND parser_version=? AND batch_run_id=?
1081
+ AND status IN ('staged', 'committed')
1082
+ ORDER BY rowid DESC LIMIT 1`).get(sourceId, parserVersion, batchRunId);
1083
+ return row ? JSON.parse(String(row.checkpoint)) : null;
1084
+ }
892
1085
  relocateSource(sourceId, redactedLocator, workspaceId) {
893
1086
  const relocate = this.db.transaction(() => {
894
1087
  const row = this.db.prepare(`SELECT src.data FROM session_sources src
@@ -1269,6 +1462,7 @@ function searchHit(row) {
1269
1462
  const citation = {
1270
1463
  provider: session.provider,
1271
1464
  sessionId: event.sessionId,
1465
+ origin: event.origin,
1272
1466
  eventId: event.eventId,
1273
1467
  importRunId: event.importRunId,
1274
1468
  sourceId: event.sourceId,
@@ -1281,6 +1475,7 @@ function searchHit(row) {
1281
1475
  provider: session.provider,
1282
1476
  workspaceId: session.workspaceId,
1283
1477
  sessionId: event.sessionId,
1478
+ origin: event.origin,
1284
1479
  eventId: event.eventId,
1285
1480
  importRunId: event.importRunId,
1286
1481
  sourceId: event.sourceId,
@@ -1417,6 +1612,20 @@ function authorizedDocumentScopeDigest(options) {
1417
1612
  return sha256(JSON.stringify(scope));
1418
1613
  }
1419
1614
  function appendMetadataFilters(where, params, options) {
1615
+ const controlEvents = options.controlEvents ?? 'exclude';
1616
+ const controlOrigins = [
1617
+ 'provider-bootstrap',
1618
+ 'workspace-instruction',
1619
+ 'tool-control',
1620
+ ];
1621
+ if (controlEvents === 'exclude') {
1622
+ where.push(`COALESCE(json_extract(e.data, '$.origin'), 'unknown') NOT IN (${controlOrigins.map(() => '?').join(',')})`);
1623
+ params.push(...controlOrigins);
1624
+ }
1625
+ else if (controlEvents === 'only') {
1626
+ where.push(`COALESCE(json_extract(e.data, '$.origin'), 'unknown') IN (${controlOrigins.map(() => '?').join(',')})`);
1627
+ params.push(...controlOrigins);
1628
+ }
1420
1629
  if (options.sessionIds?.length) {
1421
1630
  where.push(`e.session_id IN (${options.sessionIds.map(() => '?').join(',')})`);
1422
1631
  params.push(...options.sessionIds);
@@ -1517,26 +1726,158 @@ function mergeSessionAggregate(prior, incoming) {
1517
1726
  'consistent-snapshot': 1,
1518
1727
  complete: 2,
1519
1728
  };
1520
- const lifecycleRank = {
1521
- active: 0,
1522
- complete: 1,
1523
- tombstoned: 2,
1524
- };
1729
+ const lifecycle = mergeLifecycleAggregate(prior, incoming);
1730
+ const nativeKey = `native.${prior.provider}`;
1731
+ const priorNative = objectRecord(prior.extensions[nativeKey]);
1732
+ const incomingNative = objectRecord(incoming.extensions[nativeKey]);
1525
1733
  return {
1526
1734
  ...prior,
1527
1735
  startedAt: timestamp(prior.startedAt, incoming.startedAt, 'min'),
1528
1736
  updatedAt: timestamp(prior.updatedAt, incoming.updatedAt, 'max'),
1529
1737
  consistency: consistencyRank[prior.consistency] >= consistencyRank[incoming.consistency]
1530
1738
  ? prior.consistency : incoming.consistency,
1531
- lifecycle: lifecycleRank[prior.lifecycle] >= lifecycleRank[incoming.lifecycle]
1532
- ? prior.lifecycle : incoming.lifecycle,
1739
+ lifecycle: lifecycle.lifecycle,
1740
+ intent: mergeSessionIntent(prior.intent, incoming.intent),
1533
1741
  sourceDigest: incoming.sourceDigest,
1534
1742
  extensions: {
1535
1743
  ...prior.extensions,
1536
1744
  ...incoming.extensions,
1745
+ [nativeKey]: {
1746
+ ...priorNative,
1747
+ ...incomingNative,
1748
+ lifecycleEvidence: lifecycle.evidence,
1749
+ },
1537
1750
  },
1538
1751
  };
1539
1752
  }
1753
+ function mergeLifecycleAggregate(prior, incoming) {
1754
+ const nativeKey = `native.${prior.provider}`;
1755
+ const priorEvidence = objectRecord(objectRecord(prior.extensions[nativeKey]).lifecycleEvidence);
1756
+ const incomingEvidence = objectRecord(objectRecord(incoming.extensions[nativeKey]).lifecycleEvidence);
1757
+ if (prior.lifecycle === 'tombstoned') {
1758
+ return { lifecycle: prior.lifecycle, evidence: priorEvidence };
1759
+ }
1760
+ const rank = {
1761
+ 'open-provisional-source': 1,
1762
+ 'inactivity-threshold': 1,
1763
+ 'complete-source': 2,
1764
+ 'provider-explicit-event': 3,
1765
+ };
1766
+ const priorRank = rank[String(priorEvidence.basis)] ?? 0;
1767
+ const incomingRank = rank[String(incomingEvidence.basis)] ?? 0;
1768
+ const incomingIsNewer = lifecycleEvidenceTimestamp(incomingEvidence)
1769
+ > lifecycleEvidenceTimestamp(priorEvidence);
1770
+ return incomingRank > priorRank || (incomingRank === priorRank && incomingIsNewer)
1771
+ ? { lifecycle: incoming.lifecycle, evidence: incomingEvidence }
1772
+ : { lifecycle: prior.lifecycle, evidence: priorEvidence };
1773
+ }
1774
+ function objectRecord(value) {
1775
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1776
+ ? value
1777
+ : {};
1778
+ }
1779
+ function sanitizeSessionExtensions(extensions, provider) {
1780
+ const sanitized = sanitizeNativeExtensions(extensions).value;
1781
+ const nativeKey = `native.${provider}`;
1782
+ const evidence = safeLifecycleEvidence(objectRecord(objectRecord(extensions[nativeKey]).lifecycleEvidence));
1783
+ if (!evidence)
1784
+ return sanitized;
1785
+ sanitized[nativeKey] = {
1786
+ ...objectRecord(sanitized[nativeKey]),
1787
+ lifecycleEvidence: evidence,
1788
+ };
1789
+ return sanitized;
1790
+ }
1791
+ function safeLifecycleEvidence(evidence) {
1792
+ const bases = new Set([
1793
+ 'open-provisional-source',
1794
+ 'inactivity-threshold',
1795
+ 'complete-source',
1796
+ 'provider-explicit-event',
1797
+ ]);
1798
+ const states = new Set([
1799
+ 'active', 'inactive', 'paused', 'complete', 'interrupted', 'archived',
1800
+ 'unknown', 'tombstoned',
1801
+ ]);
1802
+ const confidences = new Set(['high', 'medium', 'low', 'provisional']);
1803
+ const basis = String(evidence.basis ?? '');
1804
+ const state = String(evidence.state ?? '');
1805
+ const confidence = String(evidence.confidence ?? '');
1806
+ const observedAt = String(evidence.observedAt ?? '');
1807
+ if (!bases.has(basis)
1808
+ || !states.has(state)
1809
+ || !confidences.has(confidence)
1810
+ || !Number.isFinite(Date.parse(observedAt))) {
1811
+ return null;
1812
+ }
1813
+ const output = {
1814
+ basis,
1815
+ state,
1816
+ observedAt,
1817
+ confidence,
1818
+ };
1819
+ if (typeof evidence.thresholdMs === 'number'
1820
+ && Number.isFinite(evidence.thresholdMs)
1821
+ && evidence.thresholdMs >= 0) {
1822
+ output.thresholdMs = evidence.thresholdMs;
1823
+ }
1824
+ return output;
1825
+ }
1826
+ function lifecycleEvidenceTimestamp(value) {
1827
+ const parsed = Date.parse(String(value.observedAt ?? ''));
1828
+ return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
1829
+ }
1830
+ function mergeSessionIntent(prior, incoming) {
1831
+ if (prior.status === 'selected' && incoming.status === 'selected') {
1832
+ const priorKey = [prior.sequence ?? Number.MAX_SAFE_INTEGER, prior.eventId ?? ''];
1833
+ const incomingKey = [incoming.sequence ?? Number.MAX_SAFE_INTEGER, incoming.eventId ?? ''];
1834
+ return priorKey[0] < incomingKey[0]
1835
+ || (priorKey[0] === incomingKey[0] && String(priorKey[1]) <= String(incomingKey[1]))
1836
+ ? prior
1837
+ : incoming;
1838
+ }
1839
+ if (prior.status === 'selected')
1840
+ return prior;
1841
+ if (incoming.status === 'selected')
1842
+ return incoming;
1843
+ if (prior.status === 'unknown' || incoming.status === 'unknown') {
1844
+ return {
1845
+ status: 'unknown',
1846
+ eventId: null,
1847
+ sequence: null,
1848
+ title: null,
1849
+ summary: null,
1850
+ };
1851
+ }
1852
+ return prior;
1853
+ }
1854
+ function safeEventCoordinates(eventData, fallback) {
1855
+ try {
1856
+ const event = JSON.parse(eventData);
1857
+ return {
1858
+ provider: fallback.provider,
1859
+ sourceId: event.sourceId,
1860
+ nativeSessionId: fallback.nativeSessionId,
1861
+ nativeEventId: event.nativeId ?? `sequence:${event.sequence}`,
1862
+ };
1863
+ }
1864
+ catch {
1865
+ return {
1866
+ provider: fallback.provider,
1867
+ sourceId: '<unreadable>',
1868
+ nativeSessionId: fallback.nativeSessionId,
1869
+ nativeEventId: '<unreadable>',
1870
+ };
1871
+ }
1872
+ }
1873
+ function formatEventCoordinates(input) {
1874
+ return [
1875
+ `provider=${input.provider}`,
1876
+ `source=${input.sourceId}`,
1877
+ `session=${input.nativeSessionId}`,
1878
+ `event=${input.nativeEventId}`,
1879
+ ].join(',');
1880
+ }
1540
1881
  function allowedCandidateTransition(from, to) {
1541
1882
  const transitions = {
1542
1883
  pending: new Set(['accepted', 'rejected', 'deferred']),
@@ -0,0 +1,148 @@
1
+ export const TIMELINE_SCHEMA_VERSION = '1.0.0';
2
+ export const DEFAULT_TIMELINE_GAP_MS = 30 * 60 * 1_000;
3
+ export function deriveSessionTimeline(input, gapMs = DEFAULT_TIMELINE_GAP_MS) {
4
+ if (!Number.isFinite(gapMs) || gapMs < 0) {
5
+ throw new Error('timeline gap must be a non-negative duration');
6
+ }
7
+ const grouped = new Map();
8
+ for (const item of input) {
9
+ const group = grouped.get(item.session.sessionId) ?? [];
10
+ group.push(item);
11
+ grouped.set(item.session.sessionId, group);
12
+ }
13
+ const result = [];
14
+ for (const items of grouped.values()) {
15
+ const timed = items
16
+ .filter((item) => item.event.occurredAt !== null)
17
+ .sort(compareTimelineInput);
18
+ const untimed = items
19
+ .filter((item) => item.event.occurredAt === null)
20
+ .sort(compareSequence);
21
+ if (timed.length === 0) {
22
+ const first = items[0];
23
+ result.push({
24
+ schemaVersion: TIMELINE_SCHEMA_VERSION,
25
+ provider: first.session.provider,
26
+ sessionId: first.session.sessionId,
27
+ segmentIndex: 0,
28
+ startAt: null,
29
+ endAt: null,
30
+ durationMs: null,
31
+ eventCount: untimed.length,
32
+ boundaryBasis: 'unknown-time',
33
+ boundaryEvidence: null,
34
+ confidence: 'low',
35
+ minSequence: untimed.at(0)?.event.sequence ?? 0,
36
+ maxSequence: untimed.at(-1)?.event.sequence ?? 0,
37
+ });
38
+ continue;
39
+ }
40
+ const sessionSegments = [];
41
+ let current = null;
42
+ let previous = null;
43
+ for (const item of timed) {
44
+ const boundary = previous ? segmentBoundary(previous.event, item.event, gapMs) : null;
45
+ if (!current || boundary) {
46
+ current = {
47
+ schemaVersion: TIMELINE_SCHEMA_VERSION,
48
+ provider: item.session.provider,
49
+ sessionId: item.session.sessionId,
50
+ segmentIndex: sessionSegments.length,
51
+ startAt: item.event.occurredAt,
52
+ endAt: item.event.occurredAt,
53
+ durationMs: 0,
54
+ eventCount: 0,
55
+ boundaryBasis: boundary?.basis ?? 'session-start',
56
+ boundaryEvidence: boundary?.evidence ?? null,
57
+ confidence: boundary?.confidence ?? 'high',
58
+ minSequence: item.event.sequence,
59
+ maxSequence: item.event.sequence,
60
+ };
61
+ sessionSegments.push(current);
62
+ }
63
+ current.eventCount += 1;
64
+ current.endAt = item.event.occurredAt;
65
+ current.durationMs = Math.max(0, Date.parse(current.endAt) - Date.parse(current.startAt));
66
+ current.minSequence = Math.min(current.minSequence, item.event.sequence);
67
+ current.maxSequence = Math.max(current.maxSequence, item.event.sequence);
68
+ previous = item;
69
+ }
70
+ for (const item of untimed) {
71
+ const destination = sessionSegments.find((segment) => item.event.sequence <= segment.maxSequence) ?? sessionSegments.at(-1);
72
+ destination.eventCount += 1;
73
+ destination.minSequence = Math.min(destination.minSequence, item.event.sequence);
74
+ destination.maxSequence = Math.max(destination.maxSequence, item.event.sequence);
75
+ }
76
+ result.push(...sessionSegments);
77
+ }
78
+ return result
79
+ .sort((left, right) => compareNullableTime(left.startAt, right.startAt)
80
+ || left.provider.localeCompare(right.provider)
81
+ || left.sessionId.localeCompare(right.sessionId)
82
+ || left.segmentIndex - right.segmentIndex)
83
+ .map(({ minSequence: _min, maxSequence: _max, ...segment }) => segment);
84
+ }
85
+ export function parseTimelineGap(value) {
86
+ if (value === undefined)
87
+ return DEFAULT_TIMELINE_GAP_MS;
88
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(value.trim());
89
+ if (!match)
90
+ throw new Error('timeline gap must use ms, s, m, h, or d (for example 30m)');
91
+ const factors = {
92
+ ms: 1,
93
+ s: 1_000,
94
+ m: 60_000,
95
+ h: 3_600_000,
96
+ d: 86_400_000,
97
+ };
98
+ const result = Number(match[1]) * factors[match[2]];
99
+ if (!Number.isSafeInteger(result) || result < 0) {
100
+ throw new Error('timeline gap is outside the supported duration range');
101
+ }
102
+ return result;
103
+ }
104
+ function segmentBoundary(previous, current, gapMs) {
105
+ if (current.activityBoundary === 'resume'
106
+ || current.activityBoundary === 'continuation') {
107
+ return {
108
+ basis: 'provider-explicit',
109
+ evidence: current.activityBoundaryBasis ?? current.activityBoundary,
110
+ confidence: current.activityBoundaryConfidence ?? 'high',
111
+ };
112
+ }
113
+ if (previous.activityBoundary === 'pause' || previous.activityBoundary === 'end') {
114
+ return {
115
+ basis: 'provider-explicit',
116
+ evidence: previous.activityBoundaryBasis ?? previous.activityBoundary,
117
+ confidence: previous.activityBoundaryConfidence ?? 'high',
118
+ };
119
+ }
120
+ const gap = Date.parse(current.occurredAt) - Date.parse(previous.occurredAt);
121
+ if (gap > gapMs) {
122
+ return {
123
+ basis: 'inferred-gap',
124
+ evidence: `inactivity>${gapMs}ms`,
125
+ confidence: 'medium',
126
+ };
127
+ }
128
+ return null;
129
+ }
130
+ function compareTimelineInput(left, right) {
131
+ return Date.parse(left.event.occurredAt) - Date.parse(right.event.occurredAt)
132
+ || left.event.sequence - right.event.sequence
133
+ || left.event.eventId.localeCompare(right.event.eventId);
134
+ }
135
+ function compareSequence(left, right) {
136
+ return left.event.sequence - right.event.sequence
137
+ || left.event.eventId.localeCompare(right.event.eventId);
138
+ }
139
+ function compareNullableTime(left, right) {
140
+ if (left === right)
141
+ return 0;
142
+ if (left === null)
143
+ return 1;
144
+ if (right === null)
145
+ return -1;
146
+ return Date.parse(left) - Date.parse(right);
147
+ }
148
+ //# sourceMappingURL=timeline.js.map