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,142 +1,348 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { eventTextForMemory } from './CogmemBlockStripper.js';
2
- import { classifyAssistantRelation, classifyTurnRelation, classifyTurnRelationHybrid } from './TurnRelationClassifier.js';
3
+ import { classifyAssistantRelation, classifyTurnRelation, classifyTurnRelationHybridTrace } from './TurnRelationClassifier.js';
4
+ import { EpisodeBoundaryPolicy } from './EpisodeBoundaryPolicy.js';
5
+ import { logicalTurnsFromPairs, replayPendingTurnBoundary } from './EpisodeBoundaryReplayEngine.js';
3
6
  export class EpisodeAssembler {
4
7
  store;
5
8
  resolveEvent;
6
9
  softReopenWindowMs;
7
10
  reviewer;
8
11
  resolveTopicContext;
9
- constructor(store, resolveEvent, softReopenWindowMs = 30 * 60_000, reviewer, resolveTopicContext) {
12
+ boundaryPolicy;
13
+ constructor(store, resolveEvent, softReopenWindowMs = 30 * 60_000, reviewer, resolveTopicContext, boundaryPolicy = new EpisodeBoundaryPolicy()) {
10
14
  this.store = store;
11
15
  this.resolveEvent = resolveEvent;
12
16
  this.softReopenWindowMs = softReopenWindowMs;
13
17
  this.reviewer = reviewer;
14
18
  this.resolveTopicContext = resolveTopicContext;
19
+ this.boundaryPolicy = boundaryPolicy;
15
20
  }
16
21
  appendTurn(events, input) {
17
22
  return this.appendTurnClassified(events, input);
18
23
  }
19
24
  async appendTurnAsync(events, input) {
20
25
  const ordered = [...events].sort((a, b) => (a.eventOrdinal || 0) - (b.eventOrdinal || 0));
21
- if (!ordered.length)
22
- return { assignedEventIds: [], unassignedEventIds: [], ignoredEventIds: [], reopened: false };
26
+ validateTurnBatch(ordered, input);
23
27
  const primary = ordered.find((event) => event.role === 'user') || ordered[0];
24
28
  const threadId = input.conversationThreadId || primary.threadId || input.sessionId;
25
29
  const episode = this.store.findActiveEpisode(input.projectId, input.sessionId, input.sourceAgent, threadId);
26
- const decision = primary.role === 'user'
27
- ? await classifyTurnRelationHybrid(this.classificationContext(primary, episode, ordered), this.reviewer)
28
- : this.classifyPrimary(primary, episode, ordered);
29
- return this.appendTurnClassified(ordered, input, decision);
30
+ const cpuDecision = this.classifyPrimary(primary, episode, ordered);
31
+ const guardResult = this.evaluateBoundary(episode, primary, ordered);
32
+ const reviewed = guardResult.guardAction === 'enforce_new_episode' || primary.role !== 'user'
33
+ ? {
34
+ cpuDecision,
35
+ reviewerInvoked: false,
36
+ reviewerRawResultStatus: 'not_invoked',
37
+ finalDecision: cpuDecision,
38
+ }
39
+ : await classifyTurnRelationHybridTrace(this.classificationContext(primary, episode, ordered), this.reviewer);
40
+ return this.appendTurnClassified(ordered, input, reviewed.finalDecision, {
41
+ cpuDecision: reviewed.cpuDecision,
42
+ guardResult,
43
+ reviewerInvoked: reviewed.reviewerInvoked,
44
+ reviewerRawResultStatus: reviewed.reviewerRawResultStatus,
45
+ reviewerDecision: reviewed.reviewerDecision,
46
+ finalDecision: reviewed.finalDecision,
47
+ observedEpisodeId: episode?.episodeId,
48
+ observedEpisodeEventCount: episode?.eventCount,
49
+ observedEpisodeFingerprint: episodeBoundaryFingerprint(episode),
50
+ });
30
51
  }
31
- appendTurnClassified(events, input, decisionOverride) {
52
+ appendTurnClassified(events, input, decisionOverride, trace) {
32
53
  const ordered = [...events].sort((a, b) => (a.eventOrdinal || 0) - (b.eventOrdinal || 0));
33
- if (!ordered.length)
34
- return { assignedEventIds: [], unassignedEventIds: [], ignoredEventIds: [], reopened: false };
35
- const mismatched = ordered.find((event) => event.projectId && event.projectId !== input.projectId);
36
- if (mismatched)
37
- throw new Error(`episode_project_mismatch:${mismatched.eventId}`);
54
+ validateTurnBatch(ordered, input);
38
55
  const primary = ordered.find((event) => event.role === 'user') || ordered[0];
39
56
  const conversationThreadId = input.conversationThreadId || primary.threadId || input.sessionId;
40
57
  let episode = this.store.findActiveEpisode(input.projectId, input.sessionId, input.sourceAgent, conversationThreadId);
41
58
  let legacyLinkedEpisodeId;
42
59
  if (episode && !episode.sourceAgent && !episode.conversationThreadId) {
43
- const legacyEpisodeId = episode.episodeId;
44
- episode = this.store.claimLegacyEpisodeScope(legacyEpisodeId, input.sourceAgent, conversationThreadId);
45
- if (!episode)
46
- legacyLinkedEpisodeId = legacyEpisodeId;
60
+ legacyLinkedEpisodeId = episode.episodeId;
61
+ }
62
+ const freshGuardResult = this.evaluateBoundary(episode, primary, ordered);
63
+ const currentEpisodeFingerprint = episodeBoundaryFingerprint(episode);
64
+ const staleTrace = Boolean(trace && (trace.observedEpisodeFingerprint !== undefined
65
+ ? trace.observedEpisodeFingerprint !== currentEpisodeFingerprint
66
+ : trace.observedEpisodeId !== episode?.episodeId || trace.observedEpisodeEventCount !== episode?.eventCount));
67
+ let cpuDecision = staleTrace ? this.classifyPrimary(primary, episode, ordered) : trace?.cpuDecision ?? this.classifyPrimary(primary, episode, ordered);
68
+ let guardResult = freshGuardResult;
69
+ let decision = staleTrace ? cpuDecision : decisionOverride ?? trace?.finalDecision ?? cpuDecision;
70
+ let reviewerRawResultStatus = trace?.reviewerRawResultStatus || 'not_invoked';
71
+ let reviewerDecision = trace?.reviewerDecision;
72
+ if (staleTrace && trace?.reviewerInvoked) {
73
+ reviewerRawResultStatus = 'stale_ignored';
74
+ reviewerDecision = undefined;
75
+ decision = cpuDecision;
47
76
  }
48
- const decision = decisionOverride ?? this.classifyPrimary(primary, episode, ordered);
49
77
  let reopened = false;
50
78
  let closureReceipt;
51
79
  let linkedEpisodeId = legacyLinkedEpisodeId;
52
- const now = input.now ?? Math.max(...ordered.map((event) => event.occurredAt || Date.now()));
80
+ let now = Math.max(input.now ?? 0, ...ordered.map((event) => Number.isFinite(event.occurredAt) ? event.occurredAt : Date.now()), episode?.updatedAt ?? 0);
81
+ let previousEpisodeId = episode?.episodeId;
82
+ const shouldAuditBoundary = primary.role === 'user' && this.boundaryPolicy.config.auditDecisions;
83
+ let guardWarnings = guardResult.warnings.map((warning) => warning.code);
53
84
  if (decision.relation === 'noise') {
54
- for (const event of ordered) {
55
- this.store.markEventDisposition({
56
- eventId: event.eventId, projectId: input.projectId, disposition: 'ignored', reason: 'deterministic_noise', now,
85
+ let audit = { status: 'not_applicable', warnings: [] };
86
+ try {
87
+ this.store.transaction(() => {
88
+ audit = this.recordBoundaryDecisionSafe(shouldAuditBoundary, {
89
+ projectId: input.projectId,
90
+ sessionId: input.sessionId,
91
+ sourceAgent: input.sourceAgent,
92
+ threadId: conversationThreadId,
93
+ primaryEventId: primary.eventId,
94
+ previousEpisodeId,
95
+ policyVersion: guardResult.policyVersion,
96
+ mode: guardResult.mode,
97
+ guardAction: guardResult.guardAction,
98
+ guardCodes: guardResult.guardCodes,
99
+ metrics: guardResult.metrics,
100
+ cpuDecision,
101
+ reviewerInvoked: trace?.reviewerInvoked || false,
102
+ reviewerDecision,
103
+ finalDecision: decision,
104
+ warnings: guardResult.warnings,
105
+ createdAt: now,
106
+ });
107
+ if (audit.status === 'failed')
108
+ throw new Error(audit.warnings[0] || 'boundary_audit_write_failed');
109
+ for (const event of ordered) {
110
+ this.store.markEventDisposition({
111
+ eventId: event.eventId, projectId: input.projectId, disposition: 'ignored', reason: 'deterministic_noise', now,
112
+ });
113
+ }
57
114
  });
58
115
  }
59
- return { assignedEventIds: [], unassignedEventIds: [], ignoredEventIds: ordered.map((event) => event.eventId), reopened: false };
60
- }
61
- if (episode?.status === 'open' && ['hard_topic_switch', 'starts_new_topic', 'switches_topic'].includes(decision.relation)) {
62
- closureReceipt = this.store.sealEpisode(episode.episodeId, {
63
- mode: 'hard', reason: 'explicit_topic_switch', reasonCode: 'topic_switch', now,
64
- });
65
- episode = undefined;
66
- }
67
- if (episode?.status === 'open' && decision.relation === 'ambiguous_shift') {
68
- linkedEpisodeId = episode.episodeId;
69
- closureReceipt = this.store.sealEpisode(episode.episodeId, {
70
- mode: 'soft', reason: 'ambiguous_topic_shift', reasonCode: 'topic_switch', requiresReview: true, now,
71
- });
72
- episode = undefined;
116
+ catch (error) {
117
+ const warning = error instanceof Error ? error.message : 'boundary_audit_write_failed';
118
+ audit = { status: 'failed', warnings: [warning] };
119
+ }
120
+ return {
121
+ assignedEventIds: [],
122
+ unassignedEventIds: audit.status === 'failed' ? ordered.map((event) => event.eventId) : [],
123
+ ignoredEventIds: audit.status === 'failed' ? [] : ordered.map((event) => event.eventId),
124
+ reopened: false,
125
+ boundaryTriggered: false,
126
+ boundaryDetected: guardResult.guardCodes.length > 0,
127
+ boundaryApplied: false,
128
+ boundaryMode: guardResult.mode,
129
+ boundaryDecisionId: audit.decisionId,
130
+ boundaryGuardCodes: guardResult.guardCodes,
131
+ boundaryAuditRecorded: audit.status === 'inserted',
132
+ boundaryAuditStatus: audit.status,
133
+ previousEpisodeId,
134
+ reviewerRawResultStatus,
135
+ warnings: [...guardWarnings, ...audit.warnings],
136
+ };
73
137
  }
74
- if (episode?.status === 'soft_sealed') {
75
- const mayReopen = new Set([
76
- 'continues_previous', 'clarifies_previous', 'corrects_previous', 'returns_to_old_topic',
77
- 'answers_assistant_question', 'accepts_assistant_proposal', 'rejects_assistant_proposal', 'confirms_assistant_fact',
78
- ]).has(decision.relation)
79
- && now - (episode.sealedAt || episode.updatedAt) <= this.softReopenWindowMs;
80
- if (mayReopen) {
81
- episode = this.store.reopenSoftEpisode(episode.episodeId, now);
82
- reopened = true;
138
+ return this.store.transaction(() => {
139
+ let lockedEpisode = this.store.findActiveEpisode(input.projectId, input.sessionId, input.sourceAgent, conversationThreadId);
140
+ let lockedLegacyLinkedEpisodeId;
141
+ if (lockedEpisode && !lockedEpisode.sourceAgent && !lockedEpisode.conversationThreadId) {
142
+ const legacyEpisodeId = lockedEpisode.episodeId;
143
+ lockedEpisode = this.store.claimLegacyEpisodeScope(legacyEpisodeId, input.sourceAgent, conversationThreadId);
144
+ if (!lockedEpisode)
145
+ lockedLegacyLinkedEpisodeId = legacyEpisodeId;
146
+ }
147
+ const lockedSnapshot = lockedEpisode ? this.store.getBoundarySnapshot(lockedEpisode.episodeId, this.boundaryPolicy.config.timezone) : undefined;
148
+ now = Math.max(now, lockedEpisode?.updatedAt ?? 0, lockedSnapshot?.lastEventAt ?? 0);
149
+ if (episodeBoundaryFingerprint(lockedEpisode) !== currentEpisodeFingerprint) {
150
+ episode = lockedEpisode;
151
+ linkedEpisodeId = lockedLegacyLinkedEpisodeId;
152
+ previousEpisodeId = episode?.episodeId;
153
+ guardResult = this.evaluateBoundary(episode, primary, ordered);
154
+ guardWarnings = guardResult.warnings.map((warning) => warning.code);
155
+ cpuDecision = this.classifyPrimary(primary, episode, ordered);
156
+ decision = cpuDecision;
157
+ if (trace?.reviewerInvoked) {
158
+ reviewerRawResultStatus = 'stale_ignored';
159
+ reviewerDecision = undefined;
160
+ }
161
+ }
162
+ let boundaryApplied = false;
163
+ let resultingEpisodeId;
164
+ const closureEpisode = primary.role === 'user' && decision.relation === 'closes_episode' ? episode : undefined;
165
+ const switchEpisode = primary.role === 'user' && isHardTopicSwitch(decision.relation) ? episode : undefined;
166
+ if (closureEpisode) {
167
+ let targetEpisode = closureEpisode;
168
+ if (targetEpisode.status === 'soft_sealed') {
169
+ targetEpisode = this.store.reopenSoftEpisode(targetEpisode.episodeId, now);
170
+ episode = targetEpisode;
171
+ reopened = true;
172
+ }
173
+ const assignedEventIds = this.appendOrderedEvents(targetEpisode, ordered, primary, decision, now);
174
+ closureReceipt = this.store.sealEpisode(targetEpisode.episodeId, {
175
+ mode: 'hard', reason: 'explicit_user_closure', reasonCode: 'explicit_user_closure', now,
176
+ });
177
+ resultingEpisodeId = targetEpisode.episodeId;
178
+ const audit = this.recordBoundaryDecisionSafe(shouldAuditBoundary, {
179
+ projectId: input.projectId,
180
+ sessionId: input.sessionId,
181
+ sourceAgent: input.sourceAgent,
182
+ threadId: conversationThreadId,
183
+ primaryEventId: primary.eventId,
184
+ previousEpisodeId,
185
+ resultingEpisodeId,
186
+ policyVersion: guardResult.policyVersion,
187
+ mode: guardResult.mode,
188
+ guardAction: guardResult.guardAction === 'shadow_new_episode' ? 'shadow_new_episode' : 'none',
189
+ guardCodes: guardResult.guardCodes,
190
+ metrics: guardResult.metrics,
191
+ cpuDecision,
192
+ reviewerInvoked: trace?.reviewerInvoked || false,
193
+ reviewerDecision,
194
+ finalDecision: decision,
195
+ warnings: guardResult.warnings,
196
+ createdAt: now,
197
+ });
198
+ return {
199
+ episode: this.store.getEpisode(targetEpisode.episodeId), assignedEventIds, unassignedEventIds: [], ignoredEventIds: [], closureReceipt, reopened,
200
+ boundaryTriggered: false,
201
+ boundaryDetected: guardResult.guardCodes.length > 0,
202
+ boundaryApplied: false,
203
+ boundaryMode: guardResult.mode,
204
+ boundaryDecisionId: audit.decisionId,
205
+ boundaryGuardCodes: guardResult.guardCodes,
206
+ boundaryAuditRecorded: audit.status === 'inserted',
207
+ boundaryAuditStatus: audit.status,
208
+ previousEpisodeId,
209
+ reviewerRawResultStatus,
210
+ warnings: [...guardWarnings, ...audit.warnings],
211
+ };
83
212
  }
84
- else {
213
+ if (switchEpisode) {
214
+ const previous = switchEpisode;
215
+ linkedEpisodeId = previous.episodeId;
216
+ closureReceipt = this.store.sealEpisode(previous.episodeId, {
217
+ mode: 'hard', reason: 'explicit_topic_switch', reasonCode: 'topic_switch', now,
218
+ });
85
219
  episode = undefined;
86
220
  }
87
- }
88
- if (!episode) {
89
- episode = this.store.createEpisode({
90
- projectId: input.projectId, sessionId: input.sessionId, sourceAgent: input.sourceAgent,
91
- conversationThreadId,
92
- topicPath: decision.topicPath,
93
- episodeType: decision.episodeType, importance: decision.importance,
94
- eventId: primary.eventId, globalSeq: primary.globalSeq, occurredAt: primary.occurredAt || now,
95
- episodeTags: [decision.episodeType, ...decision.candidateTypes],
96
- candidateTypes: decision.candidateTypes,
97
- importanceSignals: decision.importanceSignals,
98
- importanceReason: decision.rationale,
99
- linkedEpisodeId,
100
- });
101
- }
102
- const assignedEventIds = [];
103
- for (const event of ordered) {
104
- const existing = this.store.getEventLink(event.eventId);
105
- if (existing) {
106
- assignedEventIds.push(event.eventId);
107
- continue;
221
+ else if (episode && guardResult.guardAction === 'enforce_new_episode') {
222
+ linkedEpisodeId = episode.episodeId;
223
+ closureReceipt = this.store.sealEpisode(episode.episodeId, {
224
+ mode: 'hard',
225
+ reason: 'episode_boundary_guardrail',
226
+ reasonCode: boundaryReasonCode(guardResult),
227
+ reasonDetail: guardResult.guardCodes.join(','),
228
+ now,
229
+ });
230
+ episode = undefined;
231
+ boundaryApplied = true;
108
232
  }
109
- const relation = event.role === 'assistant' || event.role === 'agent'
110
- ? classifyAssistantRelation(eventText(event), 'assistant')
111
- : event.role === 'tool'
112
- ? 'tool_result_context'
113
- : decision.relation;
114
- this.store.appendEvent({
115
- episodeId: episode.episodeId, eventId: event.eventId, relation,
116
- confidence: event.eventId === primary.eventId ? decision.confidence : 0.9,
117
- globalSeq: event.globalSeq, occurredAt: event.occurredAt || now,
118
- episodeType: decision.episodeType, importance: decision.importance,
119
- summaryText: summaryLine(event),
120
- candidateTypes: decision.candidateTypes,
121
- importanceSignals: decision.importanceSignals,
122
- importanceReason: decision.rationale,
123
- });
124
- assignedEventIds.push(event.eventId);
125
- }
126
- if (input.batchSeal) {
127
- const confidence = averageConfidence(this.store.listEventLinks(episode.episodeId));
128
- const requiresReview = !input.forceBatchSeal && confidence < 0.6;
129
- closureReceipt = this.store.sealEpisode(episode.episodeId, {
130
- mode: requiresReview ? 'soft' : 'batch', reason: requiresReview ? 'batch_low_confidence_review' : 'batch_boundary',
131
- reasonCode: 'batch_boundary', requiresReview, now,
132
- });
133
- }
134
- else if (decision.relation === 'closes_episode') {
135
- closureReceipt = this.store.sealEpisode(episode.episodeId, {
136
- mode: 'hard', reason: 'explicit_user_closure', reasonCode: 'explicit_user_closure', now,
233
+ else if (episode?.status === 'open' && decision.relation === 'ambiguous_shift') {
234
+ linkedEpisodeId = episode.episodeId;
235
+ closureReceipt = this.store.sealEpisode(episode.episodeId, {
236
+ mode: 'soft', reason: 'ambiguous_topic_shift', reasonCode: 'topic_switch', requiresReview: true, now,
237
+ });
238
+ episode = undefined;
239
+ }
240
+ if (episode?.status === 'soft_sealed') {
241
+ const mayReopen = primary.role === 'user'
242
+ && new Set([
243
+ 'continues_previous', 'clarifies_previous', 'corrects_previous', 'returns_to_old_topic',
244
+ 'answers_assistant_question', 'accepts_assistant_proposal', 'rejects_assistant_proposal', 'confirms_assistant_fact',
245
+ ]).has(decision.relation)
246
+ && now - (episode.sealedAt ?? episode.updatedAt) <= this.softReopenWindowMs;
247
+ if (mayReopen) {
248
+ episode = this.store.reopenSoftEpisode(episode.episodeId, now);
249
+ reopened = true;
250
+ }
251
+ else {
252
+ if (primary.role === 'user') {
253
+ linkedEpisodeId = episode.episodeId;
254
+ closureReceipt = this.store.sealEpisode(episode.episodeId, {
255
+ mode: 'hard', reason: 'soft_seal_stabilized', reasonCode: 'soft_seal_stabilized', now,
256
+ });
257
+ }
258
+ episode = undefined;
259
+ }
260
+ }
261
+ if (!episode) {
262
+ if (primary.role !== 'user' && input.allowNonUserEpisodeStart !== true) {
263
+ return {
264
+ assignedEventIds: [], unassignedEventIds: ordered.map((event) => event.eventId), ignoredEventIds: [], closureReceipt, reopened,
265
+ boundaryTriggered: boundaryApplied,
266
+ boundaryDetected: guardResult.guardCodes.length > 0,
267
+ boundaryApplied,
268
+ boundaryMode: guardResult.mode,
269
+ boundaryGuardCodes: guardResult.guardCodes,
270
+ boundaryAuditRecorded: false,
271
+ boundaryAuditStatus: 'not_applicable',
272
+ previousEpisodeId,
273
+ reviewerRawResultStatus,
274
+ warnings: guardWarnings,
275
+ };
276
+ }
277
+ const episodeStart = ordered[0];
278
+ episode = this.store.createEpisode({
279
+ projectId: input.projectId, sessionId: input.sessionId, sourceAgent: input.sourceAgent,
280
+ conversationThreadId,
281
+ topicPath: decision.topicPath,
282
+ episodeType: decision.episodeType, importance: decision.importance,
283
+ eventId: episodeStart.eventId, globalSeq: episodeStart.globalSeq, occurredAt: Number.isFinite(episodeStart.occurredAt) ? episodeStart.occurredAt : now,
284
+ episodeTags: [decision.episodeType, ...decision.candidateTypes],
285
+ candidateTypes: decision.candidateTypes,
286
+ importanceSignals: decision.importanceSignals,
287
+ importanceReason: decision.rationale,
288
+ linkedEpisodeId,
289
+ });
290
+ }
291
+ const assignedEventIds = this.appendOrderedEvents(episode, ordered, primary, decision, now);
292
+ resultingEpisodeId = episode.episodeId;
293
+ if (primary.role === 'user' && decision.relation === 'closes_episode') {
294
+ closureReceipt = this.store.sealEpisode(episode.episodeId, {
295
+ mode: 'hard', reason: 'explicit_user_closure', reasonCode: 'explicit_user_closure', now,
296
+ });
297
+ }
298
+ if (input.batchSeal && !closureReceipt) {
299
+ const confidence = averageConfidence(this.store.listEventLinks(episode.episodeId));
300
+ const requiresReview = !input.forceBatchSeal && confidence < 0.6;
301
+ closureReceipt = this.store.sealEpisode(episode.episodeId, {
302
+ mode: requiresReview ? 'soft' : 'batch', reason: requiresReview ? 'batch_low_confidence_review' : 'batch_boundary',
303
+ reasonCode: 'batch_boundary', requiresReview, now,
304
+ });
305
+ }
306
+ const auditGuardAction = boundaryApplied
307
+ ? guardResult.guardAction
308
+ : guardResult.guardAction === 'shadow_new_episode'
309
+ ? 'shadow_new_episode'
310
+ : 'none';
311
+ const audit = this.recordBoundaryDecisionSafe(shouldAuditBoundary, {
312
+ projectId: input.projectId,
313
+ sessionId: input.sessionId,
314
+ sourceAgent: input.sourceAgent,
315
+ threadId: conversationThreadId,
316
+ primaryEventId: primary.eventId,
317
+ previousEpisodeId,
318
+ resultingEpisodeId,
319
+ policyVersion: guardResult.policyVersion,
320
+ mode: guardResult.mode,
321
+ guardAction: auditGuardAction,
322
+ guardCodes: guardResult.guardCodes,
323
+ metrics: guardResult.metrics,
324
+ cpuDecision,
325
+ reviewerInvoked: trace?.reviewerInvoked || false,
326
+ reviewerDecision,
327
+ finalDecision: decision,
328
+ warnings: guardResult.warnings,
329
+ createdAt: now,
137
330
  });
138
- }
139
- return { episode: this.store.getEpisode(episode.episodeId), assignedEventIds, unassignedEventIds: [], ignoredEventIds: [], closureReceipt, reopened };
331
+ return {
332
+ episode: this.store.getEpisode(episode.episodeId), assignedEventIds, unassignedEventIds: [], ignoredEventIds: [], closureReceipt, reopened,
333
+ boundaryTriggered: boundaryApplied,
334
+ boundaryDetected: guardResult.guardCodes.length > 0,
335
+ boundaryApplied,
336
+ boundaryMode: guardResult.mode,
337
+ boundaryDecisionId: audit.decisionId,
338
+ boundaryGuardCodes: guardResult.guardCodes,
339
+ boundaryAuditRecorded: audit.status === 'inserted',
340
+ boundaryAuditStatus: audit.status,
341
+ previousEpisodeId,
342
+ reviewerRawResultStatus,
343
+ warnings: [...guardWarnings, ...audit.warnings],
344
+ };
345
+ });
140
346
  }
141
347
  appendEvent(event, input) {
142
348
  const active = this.store.findActiveEpisode(input.projectId, input.sessionId, input.sourceAgent, event.threadId || input.sessionId);
@@ -190,6 +396,107 @@ export class EpisodeAssembler {
190
396
  rationale: 'non_user_event_requires_later_user_evidence',
191
397
  };
192
398
  }
399
+ appendOrderedEvents(episode, ordered, primary, decision, now) {
400
+ const assignedEventIds = [];
401
+ for (const event of ordered) {
402
+ const existing = this.store.getEventLink(event.eventId);
403
+ if (existing) {
404
+ if (existing.episodeId !== episode.episodeId)
405
+ throw new Error(`episode_event_link_conflict:${event.eventId}`);
406
+ assignedEventIds.push(event.eventId);
407
+ continue;
408
+ }
409
+ const relation = eventRelation(event, primary, decision);
410
+ this.store.appendEvent({
411
+ episodeId: episode.episodeId, eventId: event.eventId, relation,
412
+ confidence: event.eventId === primary.eventId ? decision.confidence : 0.9,
413
+ globalSeq: event.globalSeq, occurredAt: Number.isFinite(event.occurredAt) ? event.occurredAt : now,
414
+ episodeType: decision.episodeType, importance: decision.importance,
415
+ summaryText: summaryLine(event),
416
+ candidateTypes: decision.candidateTypes,
417
+ importanceSignals: decision.importanceSignals,
418
+ importanceReason: decision.rationale,
419
+ });
420
+ assignedEventIds.push(event.eventId);
421
+ }
422
+ return assignedEventIds;
423
+ }
424
+ evaluateBoundary(episode, primary, currentEvents) {
425
+ const imported = currentEvents.some((event) => {
426
+ const payload = event.payload;
427
+ return payload?.metadata?.imported === true || payload?.metadata?.sourceRef !== undefined;
428
+ });
429
+ const snapshot = episode ? this.store.getBoundarySnapshot(episode.episodeId, this.boundaryPolicy.config.timezone) : undefined;
430
+ const active = snapshot ? {
431
+ eventCount: snapshot.eventCount,
432
+ startedAt: snapshot.startedAt,
433
+ lastEventAt: snapshot.lastEventAt ?? snapshot.updatedAt,
434
+ trustedLocalDates: snapshot.trustedLocalDates,
435
+ lastTrustedUserLocalDate: snapshot.lastTrustedLocalDate,
436
+ } : undefined;
437
+ return replayPendingTurnBoundary({
438
+ config: this.boundaryPolicy.config,
439
+ active,
440
+ pendingPairs: currentEvents.map((event, index) => ({
441
+ link: {
442
+ episodeId: episode?.episodeId ?? 'pending',
443
+ eventId: event.eventId,
444
+ position: snapshot ? snapshot.eventCount + index + 1 : index + 1,
445
+ relation: event.eventId === primary.eventId ? 'continues_previous' : eventRelation(event, primary, fallbackDecision()),
446
+ confidence: 1,
447
+ createdAt: Number.isFinite(event.occurredAt) ? event.occurredAt : Date.now(),
448
+ },
449
+ event,
450
+ })),
451
+ primaryEvent: primary,
452
+ imported,
453
+ });
454
+ }
455
+ recordBoundaryDecisionSafe(enabled, input) {
456
+ if (!enabled)
457
+ return { status: this.boundaryPolicy.config.auditDecisions ? 'not_applicable' : 'disabled', warnings: [] };
458
+ try {
459
+ const result = this.store.recordBoundaryDecision(input);
460
+ return { status: result.status, decisionId: result.decisionId, warnings: [] };
461
+ }
462
+ catch (error) {
463
+ const message = error instanceof Error ? error.message : String(error);
464
+ return { status: 'failed', warnings: [message.startsWith('episode_boundary_decision_conflict:') ? 'boundary_audit_conflict' : 'boundary_audit_write_failed'] };
465
+ }
466
+ }
467
+ }
468
+ function isHardTopicSwitch(relation) {
469
+ return relation === 'hard_topic_switch' || relation === 'starts_new_topic' || relation === 'switches_topic';
470
+ }
471
+ function boundaryReasonCode(result) {
472
+ if (result.guardCodes.includes('max_events_exceeded'))
473
+ return 'event_limit';
474
+ if (result.guardCodes.includes('max_duration_exceeded'))
475
+ return 'duration_limit';
476
+ if (result.guardCodes.includes('max_idle_gap_exceeded'))
477
+ return 'idle_gap';
478
+ if (result.guardCodes.includes('trusted_local_date_changed'))
479
+ return 'local_date_boundary';
480
+ return 'manual';
481
+ }
482
+ function episodeBoundaryFingerprint(episode) {
483
+ if (!episode)
484
+ return undefined;
485
+ return createHash('sha256').update(JSON.stringify([
486
+ episode.episodeId,
487
+ episode.status,
488
+ episode.eventCount,
489
+ episode.endEventId,
490
+ episode.updatedAt,
491
+ episode.sealedAt,
492
+ ])).digest('hex');
493
+ }
494
+ function eventRelation(event, primary, decision) {
495
+ if (event.role === 'assistant' || event.role === 'agent')
496
+ return classifyAssistantRelation(eventText(event), 'assistant');
497
+ if (event.role === 'tool')
498
+ return 'tool_result_context';
499
+ return event.eventId === primary.eventId ? decision.relation : decision.relation;
193
500
  }
194
501
  function eventText(event) {
195
502
  return eventTextForMemory(event);
@@ -201,3 +508,56 @@ function summaryLine(event) {
201
508
  function averageConfidence(links) {
202
509
  return links.length ? links.reduce((total, link) => total + link.confidence, 0) / links.length : 0;
203
510
  }
511
+ function validateTurnBatch(events, input) {
512
+ if (!events.length)
513
+ throw new Error('episode_turn_empty');
514
+ const ids = new Set();
515
+ const expectedThread = input.conversationThreadId || events.find((event) => event.threadId)?.threadId || input.sessionId;
516
+ for (const event of events) {
517
+ if (ids.has(event.eventId))
518
+ throw new Error(`episode_duplicate_event_id:${event.eventId}`);
519
+ ids.add(event.eventId);
520
+ if (event.projectId !== input.projectId)
521
+ throw new Error(`episode_project_mismatch:${event.eventId}`);
522
+ if (event.sessionId !== input.sessionId)
523
+ throw new Error(`episode_session_mismatch:${event.eventId}`);
524
+ if (event.threadId !== expectedThread)
525
+ throw new Error(`episode_thread_mismatch:${event.eventId}`);
526
+ const metadata = event.payload?.metadata;
527
+ if (input.sourceAgent) {
528
+ const eventSourceAgent = typeof metadata?.sourceAgent === 'string' ? metadata.sourceAgent : undefined;
529
+ // Old raw ledgers without a source id are an explicit legacy-unscoped
530
+ // compatibility lane. Modern scoped evidence must carry exact source
531
+ // metadata and cannot silently join another agent's episode.
532
+ if (!eventSourceAgent && event.sourceId)
533
+ throw new Error(`episode_source_scope_missing:${event.eventId}`);
534
+ if (eventSourceAgent && eventSourceAgent !== input.sourceAgent)
535
+ throw new Error(`episode_source_scope_mismatch:${event.eventId}`);
536
+ }
537
+ }
538
+ const logicalTurns = logicalTurnsFromPairs(events.map((event, index) => ({
539
+ link: {
540
+ episodeId: 'pending', eventId: event.eventId, position: index + 1,
541
+ relation: 'continues_previous', confidence: 1, createdAt: event.occurredAt,
542
+ },
543
+ event,
544
+ })));
545
+ if (logicalTurns.length > 1)
546
+ throw new Error('episode_multiple_logical_turns_in_batch');
547
+ if (events.filter((event) => event.role === 'user').length > 1)
548
+ throw new Error('episode_multiple_primary_users_in_batch');
549
+ }
550
+ function fallbackDecision() {
551
+ return {
552
+ relation: 'continues_previous',
553
+ confidence: 1,
554
+ signals: [],
555
+ needsLlmReview: false,
556
+ candidateTypes: [],
557
+ closureCandidate: false,
558
+ episodeType: 'discussion',
559
+ importance: 0,
560
+ importanceSignals: [],
561
+ rationale: '',
562
+ };
563
+ }