cogmem 3.7.1 → 3.7.3

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 (87) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MEMORY_ATLAS.md +2 -2
  3. package/MEMORY_MODEL.md +1 -1
  4. package/README.md +6 -5
  5. package/RELEASE_CHECKLIST.md +5 -5
  6. package/dist/atlas/MemoryAtlasService.d.ts +1 -0
  7. package/dist/atlas/MemoryAtlasService.js +36 -4
  8. package/dist/atlas/MemoryAtlasTypes.d.ts +7 -0
  9. package/dist/belief/BeliefStore.d.ts +3 -1
  10. package/dist/belief/BeliefStore.js +10 -2
  11. package/dist/bin/episode.js +107 -16
  12. package/dist/bin/import-support.js +28 -11
  13. package/dist/config/CogmemConfig.d.ts +1 -0
  14. package/dist/config/CogmemConfig.js +45 -0
  15. package/dist/core/MemoryGraph.js +25 -8
  16. package/dist/dream/DreamScheduler.d.ts +6 -1
  17. package/dist/dream/DreamScheduler.js +36 -6
  18. package/dist/engine/CognitiveGraphCompiler.js +1 -1
  19. package/dist/engine/ConsolidationPipeline.js +4 -4
  20. package/dist/engine/DeepWritePromotionPolicy.d.ts +2 -0
  21. package/dist/engine/DeepWritePromotionPolicy.js +25 -4
  22. package/dist/engine/DreamCuratorWorker.d.ts +3 -0
  23. package/dist/engine/DreamCuratorWorker.js +18 -3
  24. package/dist/engine/FactCompiler.js +2 -2
  25. package/dist/entity/EntityGovernanceService.js +1 -1
  26. package/dist/episode/EpisodeActiveScopeGuard.d.ts +3 -0
  27. package/dist/episode/EpisodeActiveScopeGuard.js +46 -0
  28. package/dist/episode/EpisodeAssembler.d.ts +20 -2
  29. package/dist/episode/EpisodeAssembler.js +458 -98
  30. package/dist/episode/EpisodeBoundaryAuditService.d.ts +70 -0
  31. package/dist/episode/EpisodeBoundaryAuditService.js +276 -0
  32. package/dist/episode/EpisodeBoundaryPolicy.d.ts +75 -0
  33. package/dist/episode/EpisodeBoundaryPolicy.js +189 -0
  34. package/dist/episode/EpisodeBoundaryReplayEngine.d.ts +65 -0
  35. package/dist/episode/EpisodeBoundaryReplayEngine.js +272 -0
  36. package/dist/episode/EpisodeImportIdentity.d.ts +1 -0
  37. package/dist/episode/EpisodeImportIdentity.js +1 -0
  38. package/dist/episode/EpisodeInvariantValidator.d.ts +16 -0
  39. package/dist/episode/EpisodeInvariantValidator.js +99 -0
  40. package/dist/episode/EpisodeSplitPlanner.d.ts +89 -0
  41. package/dist/episode/EpisodeSplitPlanner.js +225 -0
  42. package/dist/episode/EpisodeStore.d.ts +67 -2
  43. package/dist/episode/EpisodeStore.js +443 -59
  44. package/dist/episode/EpisodeTypes.d.ts +2 -2
  45. package/dist/episode/TurnRelationClassifier.d.ts +9 -0
  46. package/dist/episode/TurnRelationClassifier.js +42 -12
  47. package/dist/episode/index.d.ts +3 -0
  48. package/dist/episode/index.js +3 -0
  49. package/dist/factory.d.ts +62 -3
  50. package/dist/factory.js +308 -58
  51. package/dist/mcp/CoreMcpTools.js +103 -8
  52. package/dist/mcp/server.js +1 -1
  53. package/dist/migrations/0028_episode_boundary_guardrails.d.ts +3 -0
  54. package/dist/migrations/0028_episode_boundary_guardrails.js +47 -0
  55. package/dist/migrations/0029_episode_active_scope_guard.d.ts +3 -0
  56. package/dist/migrations/0029_episode_active_scope_guard.js +16 -0
  57. package/dist/migrations/0030_memory_event_local_date_source.d.ts +3 -0
  58. package/dist/migrations/0030_memory_event_local_date_source.js +111 -0
  59. package/dist/migrations/0031_episode_boundary_integrity_repair.d.ts +7 -0
  60. package/dist/migrations/0031_episode_boundary_integrity_repair.js +124 -0
  61. package/dist/migrations/SchemaMigrationRunner.d.ts +2 -0
  62. package/dist/migrations/SchemaMigrationRunner.js +46 -15
  63. package/dist/migrations/index.d.ts +6 -1
  64. package/dist/migrations/index.js +15 -1
  65. package/dist/public.d.ts +1 -1
  66. package/dist/public.js +1 -1
  67. package/dist/recall/BrainRecall.js +1 -1
  68. package/dist/store/DeepWriteCandidateStore.d.ts +21 -3
  69. package/dist/store/DeepWriteCandidateStore.js +118 -11
  70. package/dist/store/EntityStore.d.ts +4 -0
  71. package/dist/store/EntityStore.js +24 -12
  72. package/dist/store/EventStore.d.ts +7 -2
  73. package/dist/store/EventStore.js +144 -23
  74. package/dist/store/FactStore.js +6 -5
  75. package/dist/store/MemoryAtlasStore.d.ts +1 -0
  76. package/dist/store/MemoryAtlasStore.js +54 -0
  77. package/dist/types/ExtensionPoints.d.ts +3 -1
  78. package/dist/types/index.d.ts +1 -0
  79. package/examples/hermes-backend/AGENTS.md +1 -1
  80. package/examples/hermes-backend/README.md +1 -1
  81. package/examples/hermes-backend/SKILL.md +2 -2
  82. package/examples/hermes-backend/references/operations.md +2 -2
  83. package/examples/openclaw-backend/AGENTS.md +1 -1
  84. package/examples/openclaw-backend/README.md +1 -1
  85. package/examples/openclaw-backend/SKILL.md +2 -2
  86. package/examples/openclaw-backend/references/operations.md +2 -2
  87. package/package.json +1 -1
@@ -1,14 +1,23 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { sealDuplicateOpenEpisodes } from './EpisodeActiveScopeGuard.js';
2
3
  import { summarizeEpisode } from './EpisodeSemanticSummarizer.js';
4
+ import { replayEpisodeBoundaryState } from './EpisodeBoundaryReplayEngine.js';
3
5
  export class EpisodeStore {
4
6
  db;
5
7
  resolveEvent;
6
8
  constructor(db, resolveEvent, options = {}) {
7
9
  this.db = db;
8
10
  this.resolveEvent = resolveEvent;
11
+ this.db.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
12
+ const foreignKeys = this.db.prepare('PRAGMA foreign_keys').get();
13
+ if (foreignKeys?.foreign_keys !== 1)
14
+ throw new Error('episode_store_foreign_keys_disabled');
9
15
  if (options.initializeSchemaForTests !== false)
10
16
  this.initializeSchema();
11
17
  }
18
+ getDatabase() {
19
+ return this.db;
20
+ }
12
21
  createEpisode(input) {
13
22
  const episodeId = `episode-${randomUUID()}`;
14
23
  this.db.prepare(`
@@ -39,32 +48,25 @@ export class EpisodeStore {
39
48
  return legacy ? mapEpisode(legacy) : undefined;
40
49
  }
41
50
  findActiveEpisodeRow(projectId, sessionId, sourceAgent, conversationThreadId) {
42
- const where = [`project_id = ?`, `session_id = ?`, `status IN ('open', 'soft_sealed')`];
43
- const params = [projectId, sessionId];
44
- if (sourceAgent) {
45
- where.push('source_agent = ?');
46
- params.push(sourceAgent);
47
- }
48
- if (conversationThreadId) {
49
- where.push('conversation_thread_id = ?');
50
- params.push(conversationThreadId);
51
- }
52
51
  return this.db.prepare(`
53
- SELECT * FROM memory_episodes WHERE ${where.join(' AND ')}
52
+ SELECT * FROM memory_episodes
53
+ WHERE project_id = ? AND session_id = ? AND status IN ('open', 'soft_sealed')
54
+ AND COALESCE(source_agent, '') = ?
55
+ AND COALESCE(conversation_thread_id, '') = ?
54
56
  ORDER BY CASE status WHEN 'open' THEN 0 ELSE 1 END, updated_at DESC LIMIT 1
55
- `).get(...params);
57
+ `).get(projectId, sessionId, sourceAgent ?? '', conversationThreadId ?? '');
56
58
  }
57
59
  claimLegacyEpisodeScope(episodeId, sourceAgent, conversationThreadId) {
58
60
  if (!sourceAgent && !conversationThreadId)
59
61
  return this.getEpisode(episodeId);
60
62
  if (this.resolveEvent) {
61
- const events = this.listEventLinks(episodeId).slice(-10)
63
+ const events = this.listEventLinks(episodeId)
62
64
  .map((link) => this.resolveEvent(link.eventId)).filter((event) => Boolean(event));
63
65
  const mismatch = events.some((event) => {
64
66
  const metadata = event.payload?.metadata;
65
67
  const eventSourceAgent = typeof metadata?.sourceAgent === 'string' ? metadata.sourceAgent : undefined;
66
- return (sourceAgent && eventSourceAgent && eventSourceAgent !== sourceAgent)
67
- || (conversationThreadId && event.threadId && event.threadId !== conversationThreadId);
68
+ return (sourceAgent !== undefined && eventSourceAgent !== sourceAgent)
69
+ || (conversationThreadId !== undefined && (event.threadId || '') !== conversationThreadId);
68
70
  });
69
71
  if (mismatch)
70
72
  return undefined;
@@ -107,15 +109,56 @@ export class EpisodeStore {
107
109
  `).all(...params, Math.max(1, Math.min(Math.trunc(options.limit ?? 100), 1000)));
108
110
  return rows.map(mapEpisode);
109
111
  }
112
+ listEpisodesForBoundaryAudit(options) {
113
+ const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 100), 1000));
114
+ const where = [`project_id = ?`];
115
+ const params = [options.projectId];
116
+ if (options.statuses?.length) {
117
+ where.push(`status IN (${options.statuses.map(() => '?').join(', ')})`);
118
+ params.push(...options.statuses);
119
+ }
120
+ const cursor = parseAuditCursor(options.cursor);
121
+ if (cursor) {
122
+ where.push(`(updated_at < ? OR (updated_at = ? AND episode_id < ?))`);
123
+ params.push(cursor.updatedAt, cursor.updatedAt, cursor.episodeId);
124
+ }
125
+ const rows = this.db.prepare(`
126
+ SELECT * FROM memory_episodes
127
+ WHERE ${where.join(' AND ')}
128
+ ORDER BY updated_at DESC, episode_id DESC
129
+ LIMIT ?
130
+ `).all(...params, limit + 1);
131
+ const selected = rows.slice(0, limit);
132
+ const last = selected.at(-1);
133
+ return {
134
+ episodes: selected.map(mapEpisode),
135
+ nextCursor: rows.length > limit && last ? formatAuditCursor(last.updated_at, last.episode_id) : undefined,
136
+ };
137
+ }
110
138
  appendEvent(input) {
111
139
  const existing = this.getEventLink(input.eventId);
112
- if (existing)
113
- return existing;
140
+ if (existing) {
141
+ if (existing.episodeId === input.episodeId)
142
+ return existing;
143
+ throw new Error(`episode_event_link_conflict:${input.eventId}`);
144
+ }
114
145
  const episode = this.getEpisode(input.episodeId);
115
146
  if (!episode || episode.status !== 'open')
116
147
  throw new Error(`episode_not_open:${input.episodeId}`);
117
- const position = episode.eventCount + 1;
148
+ let created;
118
149
  this.db.transaction(() => {
150
+ const locked = this.db.prepare(`SELECT status FROM memory_episodes WHERE episode_id = ?`).get(input.episodeId);
151
+ if (locked?.status !== 'open')
152
+ throw new Error(`episode_not_open:${input.episodeId}`);
153
+ const positionState = this.db.prepare(`
154
+ SELECT COUNT(*) AS count, MIN(position) AS min_position, MAX(position) AS max_position
155
+ FROM memory_episode_events WHERE episode_id = ?
156
+ `).get(input.episodeId);
157
+ const count = Number(positionState?.count ?? 0);
158
+ if (count > 0 && (positionState?.min_position !== 1 || positionState?.max_position !== count)) {
159
+ throw new Error(`episode_positions_corrupt:${input.episodeId}`);
160
+ }
161
+ const position = count + 1;
119
162
  this.db.prepare(`
120
163
  INSERT INTO memory_episode_events (episode_id, event_id, position, relation, confidence, created_at)
121
164
  VALUES (?, ?, ?, ?, ?, ?)
@@ -124,14 +167,15 @@ export class EpisodeStore {
124
167
  const importanceSignals = [...new Set([...episode.importanceSignals, ...(input.importanceSignals || [])])];
125
168
  this.db.prepare(`
126
169
  UPDATE memory_episodes SET
127
- end_event_id = ?, end_seq = COALESCE(?, end_seq), event_count = ?, updated_at = ?,
170
+ end_event_id = ?, end_seq = COALESCE(?, end_seq), event_count = ?, updated_at = MAX(updated_at, ?),
128
171
  episode_type = COALESCE(?, episode_type), importance = MAX(importance, ?),
129
172
  summary = CASE WHEN ? IS NULL OR ? = '' THEN summary ELSE SUBSTR(COALESCE(summary || '\n', '') || ?, 1, 1600) END,
130
173
  candidate_types_json = ?, importance_signals_json = ?, importance_reason = COALESCE(?, importance_reason)
131
- WHERE episode_id = ?
174
+ WHERE episode_id = ? AND status = 'open'
132
175
  `).run(input.eventId, input.globalSeq ?? null, position, input.occurredAt, input.episodeType || null, input.importance ?? episode.importance, input.summaryText || null, input.summaryText || '', input.summaryText || '', JSON.stringify(candidateTypes), JSON.stringify(importanceSignals), input.importanceReason || null, input.episodeId);
176
+ created = { episodeId: input.episodeId, eventId: input.eventId, position, relation: input.relation, confidence: input.confidence, createdAt: input.occurredAt };
133
177
  })();
134
- return { episodeId: input.episodeId, eventId: input.eventId, position, relation: input.relation, confidence: input.confidence, createdAt: input.occurredAt };
178
+ return created;
135
179
  }
136
180
  getEventLink(eventId) {
137
181
  const row = this.db.prepare(`SELECT * FROM memory_episode_events WHERE event_id = ?`).get(eventId);
@@ -139,9 +183,63 @@ export class EpisodeStore {
139
183
  }
140
184
  listEventLinks(episodeId) {
141
185
  return this.db.prepare(`
142
- SELECT * FROM memory_episode_events WHERE episode_id = ? ORDER BY position
186
+ SELECT * FROM memory_episode_events WHERE episode_id = ? ORDER BY position, event_id
143
187
  `).all(episodeId).map(mapEventLink);
144
188
  }
189
+ getBoundarySnapshot(episodeId, timezone) {
190
+ const episode = this.getEpisode(episodeId);
191
+ if (!episode)
192
+ throw new Error(`episode_not_found:${episodeId}`);
193
+ if (!this.db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memory_events'`).get()) {
194
+ const actualLinkCount = this.listEventLinks(episodeId).length;
195
+ return { eventCount: actualLinkCount, actualLinkCount, storedEventCount: episode.eventCount, eventCountMismatch: actualLinkCount !== episode.eventCount, startedAt: episode.startedAt, updatedAt: episode.updatedAt, trustedLocalDates: [] };
196
+ }
197
+ const actualLinkCount = Number(this.db.prepare(`SELECT COUNT(*) AS count FROM memory_episode_events WHERE episode_id = ?`).get(episodeId)?.count ?? 0);
198
+ const hasLocalDateSource = this.db.prepare(`PRAGMA table_info(memory_events)`).all()
199
+ .some((row) => row.name === 'local_date_source');
200
+ const rows = this.db.prepare(`
201
+ SELECT ee.event_id AS event_id, ee.position AS position, ee.relation AS relation,
202
+ ee.confidence AS confidence, ee.created_at AS created_at, e.role AS role,
203
+ e.local_date AS local_date,
204
+ ${hasLocalDateSource ? 'e.local_date_source' : "'legacy_unknown'"} AS local_date_source,
205
+ e.occurred_at AS occurred_at
206
+ FROM memory_episode_events ee
207
+ JOIN memory_events e ON e.event_id = ee.event_id
208
+ WHERE ee.episode_id = ?
209
+ ORDER BY ee.position, ee.event_id
210
+ `).all(episodeId);
211
+ const state = replayEpisodeBoundaryState({
212
+ episode: { startedAt: episode.startedAt },
213
+ timezone,
214
+ pairs: rows.map((row) => ({
215
+ link: { episodeId, eventId: row.event_id, position: row.position, relation: row.relation, confidence: row.confidence, createdAt: row.created_at },
216
+ event: this.resolveEvent?.(row.event_id) || {
217
+ eventId: row.event_id,
218
+ role: row.role,
219
+ localDate: row.local_date ?? undefined,
220
+ localDateSource: row.local_date_source || 'legacy_unknown',
221
+ occurredAt: row.occurred_at ?? undefined,
222
+ payload: {},
223
+ },
224
+ })),
225
+ });
226
+ return {
227
+ eventCount: actualLinkCount,
228
+ actualLinkCount,
229
+ storedEventCount: episode.eventCount,
230
+ eventCountMismatch: actualLinkCount !== episode.eventCount,
231
+ startedAt: episode.startedAt,
232
+ updatedAt: state.lastEventAt ?? episode.updatedAt,
233
+ lastEventAt: state.lastEventAt,
234
+ lastTrustedUserLocalDate: state.lastTrustedUserLocalDate,
235
+ lastTrustedLocalDate: state.lastTrustedUserLocalDate,
236
+ trustedLocalDates: state.trustedLocalDates,
237
+ };
238
+ }
239
+ transaction(fn) {
240
+ const tx = this.db.transaction(fn);
241
+ return typeof tx.immediate === 'function' ? tx.immediate() : tx();
242
+ }
145
243
  isEpisodeEmpty(episodeId) {
146
244
  const episode = this.getEpisode(episodeId);
147
245
  if (!episode)
@@ -176,6 +274,11 @@ export class EpisodeStore {
176
274
  throw new Error('episode_not_found');
177
275
  if (source.projectId !== target.projectId)
178
276
  throw new Error('episode_project_mismatch');
277
+ if (source.sessionId !== target.sessionId
278
+ || (source.sourceAgent || '') !== (target.sourceAgent || '')
279
+ || (source.conversationThreadId || '') !== (target.conversationThreadId || '')) {
280
+ throw new Error('episode_repair_scope_mismatch');
281
+ }
179
282
  this.db.transaction(() => {
180
283
  const nextPosition = this.listEventLinks(targetEpisodeId).length + 1;
181
284
  this.db.prepare(`UPDATE memory_episode_events SET episode_id = ?, position = ?, created_at = ? WHERE event_id = ?`)
@@ -222,6 +325,46 @@ export class EpisodeStore {
222
325
  `).run(repairId, input.projectId, input.operation, JSON.stringify(input.payload), JSON.stringify(input.before), JSON.stringify(input.after), input.now ?? Date.now());
223
326
  return repairId;
224
327
  }
328
+ recordBoundaryDecision(input) {
329
+ const decisionId = `episode-boundary-${randomUUID()}`;
330
+ const result = this.db.prepare(`
331
+ INSERT INTO episode_boundary_decisions (
332
+ decision_id, project_id, session_id, source_agent, thread_id, primary_event_id,
333
+ previous_episode_id, resulting_episode_id, policy_version, mode, guard_action,
334
+ guard_codes_json, metrics_json, cpu_decision_json, reviewer_invoked,
335
+ reviewer_decision_json, final_decision_json, warnings_json, created_at
336
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
337
+ ON CONFLICT(project_id, primary_event_id, policy_version) DO NOTHING
338
+ `).run(decisionId, input.projectId, input.sessionId, input.sourceAgent || null, input.threadId || null, input.primaryEventId, input.previousEpisodeId || null, input.resultingEpisodeId || null, input.policyVersion, input.mode, input.guardAction, JSON.stringify(input.guardCodes), JSON.stringify(input.metrics), JSON.stringify(safeDecision(input.cpuDecision)), input.reviewerInvoked ? 1 : 0, input.reviewerDecision ? JSON.stringify(safeDecision(input.reviewerDecision)) : null, JSON.stringify(safeDecision(input.finalDecision)), JSON.stringify(input.warnings), input.createdAt ?? Date.now());
339
+ if (Number(result.changes || 0) > 0)
340
+ return { recorded: true, decisionId, status: 'inserted' };
341
+ const existingRow = this.db.prepare(`
342
+ SELECT * FROM episode_boundary_decisions
343
+ WHERE project_id = ? AND primary_event_id = ? AND policy_version = ?
344
+ LIMIT 1
345
+ `).get(input.projectId, input.primaryEventId, input.policyVersion);
346
+ const existing = existingRow ? mapBoundaryDecision(existingRow) : undefined;
347
+ if (existing && !sameBoundaryDecision(existing, input))
348
+ throw new Error(`episode_boundary_decision_conflict:${input.primaryEventId}`);
349
+ return { recorded: false, decisionId: existing?.decisionId, status: 'duplicate' };
350
+ }
351
+ listBoundaryDecisions(options = {}) {
352
+ const where = [];
353
+ const params = [];
354
+ if (options.projectId) {
355
+ where.push('project_id = ?');
356
+ params.push(options.projectId);
357
+ }
358
+ if (options.primaryEventId) {
359
+ where.push('primary_event_id = ?');
360
+ params.push(options.primaryEventId);
361
+ }
362
+ const rows = this.db.prepare(`
363
+ SELECT * FROM episode_boundary_decisions ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
364
+ ORDER BY created_at DESC LIMIT ?
365
+ `).all(...params, Math.max(1, Math.min(Math.trunc(options.limit ?? 100), 1000)));
366
+ return rows.map(mapBoundaryDecision);
367
+ }
225
368
  invalidateEpisodeDerivedState(episodeId, now) {
226
369
  this.db.prepare(`DELETE FROM episode_dream_jobs WHERE episode_id = ?`).run(episodeId);
227
370
  this.db.prepare(`
@@ -231,17 +374,30 @@ export class EpisodeStore {
231
374
  }
232
375
  resequenceEpisode(episodeId, now) {
233
376
  const links = this.listEventLinks(episodeId);
234
- for (const [index, link] of links.entries()) {
235
- if (link.position !== index + 1)
236
- this.db.prepare(`UPDATE memory_episode_events SET position = ? WHERE event_id = ?`).run(index + 1, link.eventId);
377
+ if (links.length === 0) {
378
+ this.deleteEmptyEpisode(episodeId);
379
+ return;
237
380
  }
238
- const events = links.map((link) => this.resolveEvent?.(link.eventId)).filter((event) => Boolean(event));
381
+ const eventById = new Map(links.map((link) => [link.eventId, this.resolveEvent?.(link.eventId)]));
382
+ links.sort((left, right) => canonicalRepairOrder(eventById.get(left.eventId), eventById.get(right.eventId), left, right));
383
+ const updatePosition = this.db.prepare(`UPDATE memory_episode_events SET position = ? WHERE event_id = ? AND episode_id = ?`);
384
+ for (const [index, link] of links.entries())
385
+ updatePosition.run(-1_000_000_000 - index, link.eventId, episodeId);
386
+ for (const [index, link] of links.entries())
387
+ updatePosition.run(index + 1, link.eventId, episodeId);
388
+ const events = links.map((link) => eventById.get(link.eventId)).filter((event) => Boolean(event));
239
389
  const first = events[0];
240
390
  const last = events.at(-1);
241
391
  this.db.prepare(`
242
- UPDATE memory_episodes SET event_count = ?, start_event_id = COALESCE(?, start_event_id),
243
- end_event_id = COALESCE(?, end_event_id), start_seq = ?, end_seq = ?, updated_at = ? WHERE episode_id = ?
244
- `).run(links.length, first?.eventId || null, last?.eventId || null, first?.globalSeq ?? null, last?.globalSeq ?? null, now, episodeId);
392
+ UPDATE memory_episodes SET event_count = ?, start_event_id = ?, end_event_id = ?,
393
+ start_seq = ?, end_seq = ?, updated_at = ? WHERE episode_id = ?
394
+ `).run(links.length, first?.eventId ?? links[0].eventId, last?.eventId ?? links.at(-1).eventId, first?.globalSeq ?? null, last?.globalSeq ?? null, now, episodeId);
395
+ }
396
+ deleteEmptyEpisode(episodeId) {
397
+ this.db.prepare(`DELETE FROM episode_closure_receipts WHERE episode_id = ?`).run(episodeId);
398
+ this.db.prepare(`DELETE FROM episode_dream_jobs WHERE episode_id = ?`).run(episodeId);
399
+ this.db.prepare(`DELETE FROM episode_cross_refs WHERE episode_id = ? OR referenced_episode_id = ?`).run(episodeId, episodeId);
400
+ this.db.prepare(`DELETE FROM memory_episodes WHERE episode_id = ?`).run(episodeId);
245
401
  }
246
402
  reopenSoftEpisode(episodeId, now) {
247
403
  const result = this.db.prepare(`
@@ -268,9 +424,11 @@ export class EpisodeStore {
268
424
  const links = this.listEventLinks(episodeId);
269
425
  if (links.length === 0 && input.mode !== 'soft')
270
426
  throw new Error(`episode_empty:${episodeId}`);
271
- const requiresReview = input.requiresReview === true || links.length === 0;
272
- const semanticSummary = input.semanticSummary || summarizeEpisode(episode, links.map((link) => this.resolveEvent?.(link.eventId)).filter((event) => Boolean(event)), links.map((link) => link.eventId));
273
- const dreamMode = episode.eventCount >= 100
427
+ const resolvedEvents = links.map((link) => this.resolveEvent?.(link.eventId));
428
+ const missingRawEvidence = Boolean(this.resolveEvent) && resolvedEvents.some((event) => !event);
429
+ const requiresReview = input.requiresReview === true || links.length === 0 || missingRawEvidence;
430
+ const semanticSummary = input.semanticSummary || summarizeEpisode(episode, resolvedEvents.filter((event) => Boolean(event)), links.map((link) => link.eventId));
431
+ const dreamMode = links.length >= 100
274
432
  ? 'deep'
275
433
  : episode.importance >= 0.8 || ['decision', 'correction', 'preference', 'goal', 'prospective'].includes(episode.episodeType)
276
434
  ? 'micro' : 'normal';
@@ -311,6 +469,10 @@ export class EpisodeStore {
311
469
  `).run(receipt.receiptId, episodeId, episode.projectId, input.mode, input.reason, JSON.stringify(receipt.sourceEventIds), receipt.startSeq ?? null, receipt.endSeq ?? null, receipt.topicPath || null, receipt.episodeType, receipt.importance, receipt.dreamRecommended ? 1 : 0, receipt.dreamMode, now, receipt.closureReasonCode, receipt.closureReasonDetail || null, receipt.requiresReview ? 1 : 0, JSON.stringify(receipt.ignoredNearbyEventIds), JSON.stringify(receipt.unassignedNearbyEventIds));
312
470
  if (status === 'sealed' && receipt.dreamRecommended && !receipt.requiresReview)
313
471
  this.enqueueDreamJob(episode, dreamMode, now);
472
+ if (status !== 'sealed') {
473
+ this.db.prepare(`DELETE FROM episode_dream_jobs WHERE episode_id = ?`).run(episodeId);
474
+ this.db.prepare(`UPDATE memory_episodes SET dream_status = 'none', dream_error = NULL, last_dream_run_id = NULL WHERE episode_id = ?`).run(episodeId);
475
+ }
314
476
  })();
315
477
  return receipt;
316
478
  }
@@ -400,6 +562,7 @@ export class EpisodeStore {
400
562
  JOIN memory_episodes e ON e.episode_id = j.episode_id
401
563
  WHERE ${where.join(' AND ')}
402
564
  AND e.event_count > 0
565
+ AND e.status = 'sealed'
403
566
  AND EXISTS (SELECT 1 FROM memory_episode_events ee WHERE ee.episode_id = j.episode_id)
404
567
  ORDER BY j.priority DESC, j.created_at LIMIT ?
405
568
  `).all(...params, Math.max(1, Math.min(Math.trunc(input.limit), 100)));
@@ -419,6 +582,7 @@ export class EpisodeStore {
419
582
  claimed.push({
420
583
  episodeId: row.episode_id, projectId: row.project_id, leaseId, modeHint: row.mode_hint,
421
584
  attempts: row.attempts + 1, createdAt: row.created_at,
585
+ leaseUntil: input.now + input.leaseMs, attemptGeneration: row.attempts + 1,
422
586
  });
423
587
  }
424
588
  }
@@ -450,30 +614,38 @@ export class EpisodeStore {
450
614
  this.markEmptyEpisodeDreamSkippedMany(episodeIds, now, 'episode_empty_skipped_no_raw_evidence');
451
615
  return episodeIds.length;
452
616
  }
453
- completeDreamJob(episodeId, leaseId, candidateIds, now) {
454
- const result = this.db.prepare(`
455
- UPDATE episode_dream_jobs SET state = 'processed', candidate_ids_json = ?, lease_id = NULL,
456
- lease_until = NULL, retry_after = NULL, failure_category = NULL, last_error = NULL, updated_at = ?
457
- WHERE episode_id = ? AND state = 'processing' AND lease_id = ?
458
- `).run(JSON.stringify(candidateIds), now, episodeId, leaseId);
459
- if (!result.changes)
460
- throw new Error(`episode_dream_lease_lost:${episodeId}`);
461
- this.db.prepare(`
462
- UPDATE memory_episodes SET dream_status = 'processed', last_dreamed_at = ?,
463
- dream_candidate_count = ?, dream_error = NULL WHERE episode_id = ?
464
- `).run(now, candidateIds.length, episodeId);
617
+ completeDreamJob(episodeId, leaseId, candidateIds, now, commitCandidates) {
618
+ this.transaction(() => {
619
+ commitCandidates?.();
620
+ const result = this.db.prepare(`
621
+ UPDATE episode_dream_jobs SET state = 'processed', candidate_ids_json = ?, lease_id = NULL,
622
+ lease_until = NULL, retry_after = NULL, failure_category = NULL, last_error = NULL, updated_at = ?
623
+ WHERE episode_id = ? AND state = 'processing' AND lease_id = ?
624
+ AND lease_until IS NOT NULL AND lease_until >= ?
625
+ `).run(JSON.stringify(candidateIds), now, episodeId, leaseId, now);
626
+ if (!result.changes)
627
+ throw new Error(`episode_dream_lease_lost:${episodeId}`);
628
+ const episode = this.db.prepare(`
629
+ UPDATE memory_episodes SET dream_status = 'processed', last_dreamed_at = ?,
630
+ dream_candidate_count = ?, dream_error = NULL WHERE episode_id = ? AND status = 'sealed'
631
+ `).run(now, candidateIds.length, episodeId);
632
+ if (!episode.changes)
633
+ throw new Error(`episode_dream_episode_not_sealed:${episodeId}`);
634
+ });
465
635
  }
466
636
  failDreamJob(episodeId, leaseId, error, input) {
467
- const state = input.terminal ? 'failed_terminal' : 'failed_retryable';
468
- const result = this.db.prepare(`
469
- UPDATE episode_dream_jobs SET state = ?, last_error = ?, failure_category = ?, retry_after = ?,
470
- lease_id = NULL, lease_until = NULL, updated_at = ?
471
- WHERE episode_id = ? AND state = 'processing' AND lease_id = ?
472
- `).run(state, error.slice(0, 2000), input.failureCategory, input.retryAfter ?? null, input.now, episodeId, leaseId);
473
- if (!result.changes)
474
- return;
475
- this.db.prepare(`UPDATE memory_episodes SET dream_status = 'failed', dream_error = ? WHERE episode_id = ?`)
476
- .run(error.slice(0, 2000), episodeId);
637
+ this.transaction(() => {
638
+ const state = input.terminal ? 'failed_terminal' : 'failed_retryable';
639
+ const result = this.db.prepare(`
640
+ UPDATE episode_dream_jobs SET state = ?, last_error = ?, failure_category = ?, retry_after = ?,
641
+ lease_id = NULL, lease_until = NULL, updated_at = ?
642
+ WHERE episode_id = ? AND state = 'processing' AND lease_id = ?
643
+ `).run(state, error.slice(0, 2000), input.failureCategory, input.retryAfter ?? null, input.now, episodeId, leaseId);
644
+ if (!result.changes)
645
+ return;
646
+ this.db.prepare(`UPDATE memory_episodes SET dream_status = 'failed', dream_error = ? WHERE episode_id = ?`)
647
+ .run(error.slice(0, 2000), episodeId);
648
+ });
477
649
  }
478
650
  retryFailed(projectId) {
479
651
  const result = projectId
@@ -504,7 +676,7 @@ export class EpisodeStore {
504
676
  AND state IN ('pending', 'processing', 'failed_retryable', 'retry_scheduled')
505
677
  `).run(reason, now, ...episodeIds);
506
678
  this.db.prepare(`
507
- UPDATE memory_episodes SET dream_status = 'failed', dream_error = ?, last_dream_run_id = NULL,
679
+ UPDATE memory_episodes SET dream_status = 'skipped', dream_error = ?, last_dream_run_id = NULL,
508
680
  dream_candidate_count = 0, updated_at = ?
509
681
  WHERE episode_id IN (${placeholders})
510
682
  `).run(reason, now, ...episodeIds);
@@ -537,6 +709,10 @@ export class EpisodeStore {
537
709
  status.failed = status.failedRetryable + status.failedTerminal;
538
710
  return status;
539
711
  }
712
+ getDreamJobState(episodeId) {
713
+ const row = this.db.prepare(`SELECT state FROM episode_dream_jobs WHERE episode_id = ?`).get(episodeId);
714
+ return row?.state;
715
+ }
540
716
  countUnassignedRawEvents(projectId) {
541
717
  const row = projectId
542
718
  ? this.db.prepare(`
@@ -604,6 +780,9 @@ export class EpisodeStore {
604
780
  const run = (sql) => { count += Number(this.db.prepare(sql).run(projectId).changes || 0); };
605
781
  run(`DELETE FROM episode_dream_runs WHERE project_id = ?`);
606
782
  run(`DELETE FROM episode_dream_jobs WHERE project_id = ?`);
783
+ run(`DELETE FROM episode_boundary_decisions WHERE project_id = ?`);
784
+ run(`DELETE FROM episode_cross_refs WHERE project_id = ?`);
785
+ run(`DELETE FROM episode_repair_audit WHERE project_id = ?`);
607
786
  run(`DELETE FROM episode_closure_receipts WHERE project_id = ?`);
608
787
  run(`DELETE FROM episode_ingest_keys WHERE project_id = ?`);
609
788
  run(`DELETE FROM episode_event_dispositions WHERE project_id = ?`);
@@ -620,12 +799,21 @@ export class EpisodeStore {
620
799
  this.db.prepare(`
621
800
  INSERT INTO episode_dream_jobs (episode_id, project_id, state, priority, mode_hint, created_at, updated_at)
622
801
  VALUES (?, ?, 'pending', ?, ?, ?, ?)
623
- ON CONFLICT(episode_id) DO NOTHING
802
+ ON CONFLICT(episode_id) DO UPDATE SET
803
+ state = 'pending', mode_hint = excluded.mode_hint, retry_after = NULL, lease_id = NULL, lease_until = NULL,
804
+ failure_category = NULL, last_error = NULL, updated_at = excluded.updated_at
805
+ WHERE episode_dream_jobs.state IN ('pending', 'failed_retryable', 'retry_scheduled')
624
806
  `).run(episode.episodeId, episode.projectId, priority, modeHint, now, now);
625
- this.db.prepare(`UPDATE memory_episodes SET dream_status = 'queued', dream_error = NULL WHERE episode_id = ?`)
626
- .run(episode.episodeId);
807
+ const job = this.db.prepare(`SELECT state FROM episode_dream_jobs WHERE episode_id = ?`).get(episode.episodeId);
808
+ if (job?.state === 'pending') {
809
+ this.db.prepare(`UPDATE memory_episodes SET dream_status = 'queued', dream_error = NULL WHERE episode_id = ?`)
810
+ .run(episode.episodeId);
811
+ }
627
812
  }
628
813
  initializeSchema() {
814
+ this.transaction(() => this.initializeSchemaUnsafe());
815
+ }
816
+ initializeSchemaUnsafe() {
629
817
  // Migration 22 is authoritative. This keeps direct store construction compatible in tests and embeddings.
630
818
  this.db.exec(`
631
819
  CREATE TABLE IF NOT EXISTS memory_episodes (
@@ -641,7 +829,9 @@ export class EpisodeStore {
641
829
  CREATE TABLE IF NOT EXISTS memory_episode_events (
642
830
  episode_id TEXT NOT NULL, event_id TEXT NOT NULL UNIQUE, position INTEGER NOT NULL,
643
831
  relation TEXT NOT NULL, confidence REAL NOT NULL, created_at INTEGER NOT NULL,
644
- PRIMARY KEY (episode_id, event_id)
832
+ PRIMARY KEY (episode_id, event_id),
833
+ UNIQUE(episode_id, position),
834
+ FOREIGN KEY (episode_id) REFERENCES memory_episodes(episode_id) ON DELETE CASCADE
645
835
  );
646
836
  CREATE TABLE IF NOT EXISTS episode_closure_receipts (
647
837
  receipt_id TEXT PRIMARY KEY, episode_id TEXT NOT NULL, project_id TEXT NOT NULL, closure_mode TEXT NOT NULL,
@@ -681,8 +871,89 @@ export class EpisodeStore {
681
871
  repair_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, operation TEXT NOT NULL, payload_json TEXT NOT NULL,
682
872
  before_json TEXT NOT NULL, after_json TEXT NOT NULL, created_at INTEGER NOT NULL
683
873
  );
874
+ CREATE TABLE IF NOT EXISTS episode_boundary_decisions (
875
+ decision_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, session_id TEXT NOT NULL,
876
+ source_agent TEXT, thread_id TEXT, primary_event_id TEXT NOT NULL,
877
+ previous_episode_id TEXT, resulting_episode_id TEXT, policy_version TEXT NOT NULL,
878
+ mode TEXT NOT NULL, guard_action TEXT NOT NULL, guard_codes_json TEXT NOT NULL,
879
+ metrics_json TEXT NOT NULL, cpu_decision_json TEXT NOT NULL, reviewer_invoked INTEGER NOT NULL,
880
+ reviewer_decision_json TEXT, final_decision_json TEXT NOT NULL, warnings_json TEXT NOT NULL,
881
+ created_at INTEGER NOT NULL, UNIQUE(project_id, primary_event_id, policy_version)
882
+ );
883
+ `);
884
+ this.ensureEpisodeCompatibilityColumns();
885
+ sealDuplicateOpenEpisodes(this.db);
886
+ this.db.exec(`
887
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_episodes_one_active_scope
888
+ ON memory_episodes(project_id, session_id, COALESCE(source_agent, ''), COALESCE(conversation_thread_id, ''))
889
+ WHERE status = 'open';
890
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_episode_events_episode_position_unique
891
+ ON memory_episode_events(episode_id, position);
684
892
  `);
685
893
  }
894
+ ensureEpisodeCompatibilityColumns() {
895
+ const addColumns = (table, definitions) => {
896
+ const exists = this.db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table);
897
+ if (!exists)
898
+ return;
899
+ const columns = new Set(this.db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
900
+ for (const [name, definition] of Object.entries(definitions)) {
901
+ if (!columns.has(name))
902
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
903
+ }
904
+ };
905
+ addColumns('memory_episodes', {
906
+ conversation_thread_id: 'TEXT', semantic_summary_json: 'TEXT', episode_tags_json: "TEXT NOT NULL DEFAULT '[]'",
907
+ candidate_types_json: "TEXT NOT NULL DEFAULT '[]'", importance_signals_json: "TEXT NOT NULL DEFAULT '[]'",
908
+ importance_reason: 'TEXT', linked_episode_id: 'TEXT', dream_status: "TEXT NOT NULL DEFAULT 'none'",
909
+ last_dream_run_id: 'TEXT', last_dreamed_at: 'INTEGER', dream_candidate_count: "INTEGER NOT NULL DEFAULT 0", dream_error: 'TEXT',
910
+ });
911
+ addColumns('episode_closure_receipts', {
912
+ closure_reason_code: "TEXT NOT NULL DEFAULT 'manual'", closure_reason_detail: 'TEXT', requires_review: 'INTEGER NOT NULL DEFAULT 0',
913
+ ignored_nearby_event_ids_json: "TEXT NOT NULL DEFAULT '[]'", unassigned_nearby_event_ids_json: "TEXT NOT NULL DEFAULT '[]'",
914
+ });
915
+ addColumns('episode_dream_jobs', {
916
+ retry_after: 'INTEGER', failure_category: 'TEXT', candidate_ids_json: "TEXT NOT NULL DEFAULT '[]'",
917
+ });
918
+ addColumns('episode_dream_runs', {
919
+ failed_episode_ids_json: "TEXT NOT NULL DEFAULT '[]'", failure_details_json: "TEXT NOT NULL DEFAULT '[]'",
920
+ });
921
+ addColumns('episode_ingest_keys', {
922
+ state: "TEXT NOT NULL DEFAULT 'committed'", updated_at: 'INTEGER', last_error: 'TEXT',
923
+ });
924
+ const episodeLinks = this.db.prepare(`
925
+ SELECT rowid, episode_id, event_id FROM memory_episode_events ORDER BY episode_id, position, event_id
926
+ `).all();
927
+ const movePosition = this.db.prepare(`UPDATE memory_episode_events SET position = ? WHERE rowid = ?`);
928
+ const nextPositions = new Map();
929
+ for (const link of episodeLinks) {
930
+ movePosition.run(-1_000_000_000 - link.rowid, link.rowid);
931
+ const next = (nextPositions.get(link.episode_id) || 0) + 1;
932
+ nextPositions.set(link.episode_id, next);
933
+ movePosition.run(next, link.rowid);
934
+ }
935
+ const eventColumns = Boolean(this.db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memory_events'`).get())
936
+ ? new Set(this.db.prepare(`PRAGMA table_info(memory_events)`).all().map((row) => row.name))
937
+ : new Set();
938
+ const hasGlobalSeq = eventColumns.has('global_seq');
939
+ const rebuild = hasGlobalSeq ? this.db.prepare(`
940
+ UPDATE memory_episodes SET event_count = (SELECT COUNT(*) FROM memory_episode_events WHERE episode_id = ?),
941
+ start_event_id = COALESCE((SELECT event_id FROM memory_episode_events WHERE episode_id = ? ORDER BY position, event_id LIMIT 1), start_event_id),
942
+ end_event_id = COALESCE((SELECT event_id FROM memory_episode_events WHERE episode_id = ? ORDER BY position DESC, event_id DESC LIMIT 1), end_event_id),
943
+ start_seq = COALESCE((SELECT e.global_seq FROM memory_episode_events ee JOIN memory_events e ON e.event_id = ee.event_id WHERE ee.episode_id = ? ORDER BY ee.position LIMIT 1), start_seq),
944
+ end_seq = COALESCE((SELECT e.global_seq FROM memory_episode_events ee JOIN memory_events e ON e.event_id = ee.event_id WHERE ee.episode_id = ? ORDER BY ee.position DESC LIMIT 1), end_seq)
945
+ WHERE episode_id = ?
946
+ `) : undefined;
947
+ const episodeRows = this.db.prepare(`SELECT episode_id FROM memory_episodes`).all();
948
+ for (const row of episodeRows) {
949
+ if (rebuild)
950
+ rebuild.run(row.episode_id, row.episode_id, row.episode_id, row.episode_id, row.episode_id, row.episode_id);
951
+ else
952
+ this.db.prepare(`UPDATE memory_episodes SET event_count = (SELECT COUNT(*) FROM memory_episode_events WHERE episode_id = ?) WHERE episode_id = ?`).run(row.episode_id, row.episode_id);
953
+ }
954
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_memory_episode_events_episode ON memory_episode_events(episode_id, position);`);
955
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_episode_dream_retry ON episode_dream_jobs(state, retry_after, priority DESC, created_at);`);
956
+ }
686
957
  }
687
958
  function mapEpisode(row) {
688
959
  return {
@@ -719,7 +990,38 @@ function mapClosure(row) {
719
990
  createdAt: row.created_at,
720
991
  };
721
992
  }
993
+ function mapBoundaryDecision(row) {
994
+ return {
995
+ decisionId: row.decision_id,
996
+ projectId: row.project_id,
997
+ sessionId: row.session_id,
998
+ sourceAgent: row.source_agent || undefined,
999
+ threadId: row.thread_id || undefined,
1000
+ primaryEventId: row.primary_event_id,
1001
+ previousEpisodeId: row.previous_episode_id || undefined,
1002
+ resultingEpisodeId: row.resulting_episode_id || undefined,
1003
+ policyVersion: row.policy_version,
1004
+ mode: row.mode,
1005
+ guardAction: row.guard_action,
1006
+ guardCodes: parseJson(row.guard_codes_json, []),
1007
+ metrics: parseJson(row.metrics_json, { activeEventCount: 0, trustedLocalDates: [] }),
1008
+ cpuDecision: parseJson(row.cpu_decision_json, {}),
1009
+ reviewerInvoked: row.reviewer_invoked === 1,
1010
+ reviewerDecision: parseJson(row.reviewer_decision_json, undefined),
1011
+ finalDecision: parseJson(row.final_decision_json, {}),
1012
+ warnings: parseJson(row.warnings_json, []),
1013
+ createdAt: row.created_at,
1014
+ };
1015
+ }
722
1016
  function normalizeClosureReasonCode(reason, mode) {
1017
+ if (/event_limit|max_events/u.test(reason))
1018
+ return 'event_limit';
1019
+ if (/duration_limit|max_duration/u.test(reason))
1020
+ return 'duration_limit';
1021
+ if (/idle_gap|max_idle/u.test(reason))
1022
+ return 'idle_gap';
1023
+ if (/local_date|trusted_local_date/u.test(reason))
1024
+ return 'local_date_boundary';
723
1025
  if (reason.includes('topic_switch'))
724
1026
  return 'topic_switch';
725
1027
  if (reason.includes('batch'))
@@ -734,6 +1036,19 @@ function normalizeClosureReasonCode(reason, mode) {
734
1036
  return 'explicit_user_closure';
735
1037
  return mode === 'manual' ? 'manual' : 'manual';
736
1038
  }
1039
+ function formatAuditCursor(updatedAt, episodeId) {
1040
+ return `${updatedAt}:${episodeId}`;
1041
+ }
1042
+ function parseAuditCursor(cursor) {
1043
+ if (!cursor)
1044
+ return undefined;
1045
+ const separator = cursor.indexOf(':');
1046
+ if (separator <= 0)
1047
+ return undefined;
1048
+ const updatedAt = Number(cursor.slice(0, separator));
1049
+ const episodeId = cursor.slice(separator + 1);
1050
+ return Number.isFinite(updatedAt) && episodeId ? { updatedAt, episodeId } : undefined;
1051
+ }
737
1052
  function parseJson(value, fallback) {
738
1053
  if (!value)
739
1054
  return fallback;
@@ -744,6 +1059,75 @@ function parseJson(value, fallback) {
744
1059
  return fallback;
745
1060
  }
746
1061
  }
1062
+ function canonicalRepairOrder(left, right, leftLink, rightLink) {
1063
+ const numeric = (value) => typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER;
1064
+ const keys = [
1065
+ [numeric(left?.turnSeq), numeric(right?.turnSeq)],
1066
+ [numeric(left?.eventOrdinal), numeric(right?.eventOrdinal)],
1067
+ [numeric(left?.globalSeq), numeric(right?.globalSeq)],
1068
+ [numeric(left?.occurredAt), numeric(right?.occurredAt)],
1069
+ [leftLink.position, rightLink.position],
1070
+ ];
1071
+ for (const [a, b] of keys)
1072
+ if (a !== b)
1073
+ return a - b;
1074
+ return leftLink.eventId.localeCompare(rightLink.eventId);
1075
+ }
1076
+ function safeDecision(decision) {
1077
+ return {
1078
+ relation: decision.relation,
1079
+ confidence: decision.confidence,
1080
+ signals: decision.signals?.slice(0, 20),
1081
+ needsLlmReview: decision.needsLlmReview,
1082
+ candidateTypes: decision.candidateTypes?.slice(0, 20),
1083
+ topicPath: decision.topicPath,
1084
+ closureCandidate: decision.closureCandidate,
1085
+ switchKind: decision.switchKind,
1086
+ episodeType: decision.episodeType,
1087
+ importance: decision.importance,
1088
+ importanceSignals: decision.importanceSignals?.slice(0, 20),
1089
+ rationale: decision.rationale?.slice(0, 240),
1090
+ };
1091
+ }
1092
+ function sameBoundaryDecision(existing, input) {
1093
+ return JSON.stringify({
1094
+ projectId: existing.projectId,
1095
+ sessionId: existing.sessionId,
1096
+ sourceAgent: existing.sourceAgent,
1097
+ threadId: existing.threadId,
1098
+ primaryEventId: existing.primaryEventId,
1099
+ previousEpisodeId: existing.previousEpisodeId,
1100
+ resultingEpisodeId: existing.resultingEpisodeId,
1101
+ policyVersion: existing.policyVersion,
1102
+ mode: existing.mode,
1103
+ guardAction: existing.guardAction,
1104
+ guardCodes: existing.guardCodes,
1105
+ metrics: existing.metrics,
1106
+ cpuDecision: safeDecision(existing.cpuDecision),
1107
+ reviewerInvoked: existing.reviewerInvoked,
1108
+ reviewerDecision: existing.reviewerDecision ? safeDecision(existing.reviewerDecision) : undefined,
1109
+ finalDecision: safeDecision(existing.finalDecision),
1110
+ warnings: existing.warnings,
1111
+ }) === JSON.stringify({
1112
+ projectId: input.projectId,
1113
+ sessionId: input.sessionId,
1114
+ sourceAgent: input.sourceAgent,
1115
+ threadId: input.threadId,
1116
+ primaryEventId: input.primaryEventId,
1117
+ previousEpisodeId: input.previousEpisodeId,
1118
+ resultingEpisodeId: input.resultingEpisodeId,
1119
+ policyVersion: input.policyVersion,
1120
+ mode: input.mode,
1121
+ guardAction: input.guardAction,
1122
+ guardCodes: input.guardCodes,
1123
+ metrics: input.metrics,
1124
+ cpuDecision: safeDecision(input.cpuDecision),
1125
+ reviewerInvoked: input.reviewerInvoked,
1126
+ reviewerDecision: input.reviewerDecision ? safeDecision(input.reviewerDecision) : undefined,
1127
+ finalDecision: safeDecision(input.finalDecision),
1128
+ warnings: input.warnings,
1129
+ });
1130
+ }
747
1131
  function retryDelayMs(attempts) {
748
1132
  return Math.min(60 * 60_000, 30_000 * 2 ** Math.max(0, attempts - 1));
749
1133
  }