@parkgogogo/openclaw-reflection 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,862 @@
1
+ import type { SessionBufferManager } from "./session-manager.js";
2
+ import type { Logger, ReflectionMessage } from "./types.js";
3
+ import { MemoryGateAnalyzer, type MemoryGateOutput } from "./memory-gate/index.js";
4
+ import { FileCurator } from "./file-curator/index.js";
5
+ import { ulid } from "ulid";
6
+
7
+ const DEFAULT_MEMORY_GATE_WINDOW_SIZE = 10;
8
+
9
+ interface MessageEvent {
10
+ role?: string;
11
+ message?: {
12
+ id?: string;
13
+ content?: string;
14
+ text?: string;
15
+ channelId?: string;
16
+ };
17
+ content?: string;
18
+ text?: string;
19
+ from?: string;
20
+ to?: string;
21
+ success?: boolean;
22
+ sessionKey?: string;
23
+ sessionId?: string;
24
+ conversationId?: string;
25
+ accountId?: string;
26
+ channelId?: string;
27
+ }
28
+
29
+ interface MessageHookContext {
30
+ channelId?: string;
31
+ accountId?: string;
32
+ conversationId?: string;
33
+ }
34
+
35
+ interface MessageReceivedHookEvent {
36
+ from?: string;
37
+ content?: string;
38
+ timestamp?: number;
39
+ metadata?: Record<string, unknown>;
40
+ }
41
+
42
+ interface BeforeMessageWriteEvent {
43
+ message?: {
44
+ role?: string;
45
+ content?: unknown;
46
+ text?: string;
47
+ timestamp?: number;
48
+ };
49
+ sessionKey?: string;
50
+ }
51
+
52
+ function isEventDebugEnabled(): boolean {
53
+ const value = process.env.OPENCLAW_REFLECTION_DEBUG_EVENTS;
54
+ if (typeof value !== "string") {
55
+ return false;
56
+ }
57
+
58
+ const normalizedValue = value.trim().toLowerCase();
59
+ return (
60
+ normalizedValue === "1" ||
61
+ normalizedValue === "true" ||
62
+ normalizedValue === "yes" ||
63
+ normalizedValue === "on"
64
+ );
65
+ }
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === "object" && value !== null;
69
+ }
70
+
71
+ function getNonEmptyString(value: unknown): string | undefined {
72
+ if (typeof value !== "string") {
73
+ return undefined;
74
+ }
75
+
76
+ const trimmed = value.trim();
77
+ return trimmed !== "" ? trimmed : undefined;
78
+ }
79
+
80
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
81
+ return isRecord(value) ? value : undefined;
82
+ }
83
+
84
+ function extractTextFromMessageContent(content: unknown): string | undefined {
85
+ if (typeof content === "string") {
86
+ return getNonEmptyString(content);
87
+ }
88
+
89
+ if (!Array.isArray(content)) {
90
+ return undefined;
91
+ }
92
+
93
+ const textParts = content
94
+ .map((entry) => {
95
+ const record = toRecord(entry);
96
+ if (record?.type !== "text") {
97
+ return undefined;
98
+ }
99
+
100
+ return getNonEmptyString(record.text);
101
+ })
102
+ .filter((entry): entry is string => entry !== undefined);
103
+
104
+ if (textParts.length === 0) {
105
+ return undefined;
106
+ }
107
+
108
+ return textParts.join("\n");
109
+ }
110
+
111
+ function deriveChannelIdFromSessionKey(sessionKey: string | undefined): string | undefined {
112
+ const normalized = getNonEmptyString(sessionKey);
113
+ if (!normalized) {
114
+ return undefined;
115
+ }
116
+
117
+ const parts = normalized.split(":");
118
+ if (parts.length >= 3 && parts[0] === "agent") {
119
+ return getNonEmptyString(parts[2]);
120
+ }
121
+
122
+ return undefined;
123
+ }
124
+
125
+ function deriveChannelIdFromAddress(address: string | undefined): string | undefined {
126
+ const normalized = getNonEmptyString(address);
127
+ if (!normalized) {
128
+ return undefined;
129
+ }
130
+
131
+ const parts = normalized.split(":");
132
+ if (parts.length >= 2) {
133
+ return getNonEmptyString(parts[0]);
134
+ }
135
+
136
+ return undefined;
137
+ }
138
+
139
+ function deriveConversationTargetFromSessionKey(sessionKey: string | undefined): string | undefined {
140
+ const normalized = getNonEmptyString(sessionKey);
141
+ if (!normalized) {
142
+ return undefined;
143
+ }
144
+
145
+ const parts = normalized.split(":");
146
+ if (parts.length >= 5 && parts[0] === "agent") {
147
+ return getNonEmptyString(parts.slice(3).join(":"));
148
+ }
149
+
150
+ return undefined;
151
+ }
152
+
153
+ function deriveConversationTargetFromAddress(address: string | undefined): string | undefined {
154
+ const normalized = getNonEmptyString(address);
155
+ if (!normalized) {
156
+ return undefined;
157
+ }
158
+
159
+ const parts = normalized.split(":");
160
+ if (parts.length >= 2) {
161
+ return getNonEmptyString(parts.slice(1).join(":"));
162
+ }
163
+
164
+ return undefined;
165
+ }
166
+
167
+ function sanitizeDebugValue(
168
+ value: unknown,
169
+ depth = 0
170
+ ): unknown {
171
+ if (value === undefined) {
172
+ return undefined;
173
+ }
174
+
175
+ if (depth >= 4) {
176
+ return "[MaxDepth]";
177
+ }
178
+
179
+ if (
180
+ value === null ||
181
+ typeof value === "string" ||
182
+ typeof value === "number" ||
183
+ typeof value === "boolean"
184
+ ) {
185
+ return value;
186
+ }
187
+
188
+ if (Array.isArray(value)) {
189
+ return value
190
+ .slice(0, 20)
191
+ .map((item) => sanitizeDebugValue(item, depth + 1))
192
+ .filter((item) => item !== undefined);
193
+ }
194
+
195
+ if (isRecord(value)) {
196
+ const sanitizedEntries = Object.entries(value)
197
+ .slice(0, 40)
198
+ .map(([key, nestedValue]) => [key, sanitizeDebugValue(nestedValue, depth + 1)] as const)
199
+ .filter(([, nestedValue]) => nestedValue !== undefined);
200
+
201
+ return Object.fromEntries(sanitizedEntries);
202
+ }
203
+
204
+ return String(value);
205
+ }
206
+
207
+ export function logHookPayloadDebug(
208
+ logger: Logger,
209
+ hookName: string,
210
+ event: unknown,
211
+ hookContext: unknown,
212
+ normalizedEvent?: MessageEvent
213
+ ): void {
214
+ if (!isEventDebugEnabled()) {
215
+ return;
216
+ }
217
+
218
+ const details: Record<string, unknown> = {
219
+ hookName,
220
+ rawEvent: sanitizeDebugValue(event),
221
+ hookContext: sanitizeDebugValue(hookContext),
222
+ };
223
+
224
+ if (normalizedEvent !== undefined) {
225
+ details.normalizedEvent = sanitizeDebugValue(normalizedEvent);
226
+ }
227
+
228
+ logger.info("MessageHandler", "Hook payload debug", details);
229
+ }
230
+
231
+ function buildChannelSessionKey(event: MessageEvent): string | null {
232
+ const channelId =
233
+ event.channelId ??
234
+ event.message?.channelId ??
235
+ deriveChannelIdFromSessionKey(event.sessionKey) ??
236
+ deriveChannelIdFromAddress(event.from);
237
+
238
+ const conversationTarget =
239
+ event.to ??
240
+ event.conversationId ??
241
+ deriveConversationTargetFromAddress(event.from) ??
242
+ deriveConversationTargetFromSessionKey(event.sessionKey);
243
+
244
+ if (!channelId || !conversationTarget) {
245
+ return null;
246
+ }
247
+
248
+ return `channel:${channelId}:${conversationTarget}`;
249
+ }
250
+
251
+ function normalizeReceivedEvent(
252
+ event: unknown,
253
+ hookContext?: unknown
254
+ ): MessageEvent {
255
+ if (!isRecord(event)) {
256
+ const contextRecord = toRecord(hookContext) as MessageHookContext | undefined;
257
+ return {
258
+ channelId: getNonEmptyString(contextRecord?.channelId),
259
+ conversationId: getNonEmptyString(contextRecord?.conversationId),
260
+ accountId: getNonEmptyString(contextRecord?.accountId),
261
+ };
262
+ }
263
+
264
+ const receivedEvent = event as MessageReceivedHookEvent;
265
+ const contextRecord = toRecord(hookContext) as MessageHookContext | undefined;
266
+ const rawMetadata = toRecord(receivedEvent.metadata);
267
+ const content = getNonEmptyString(receivedEvent.content);
268
+ const channelId = getNonEmptyString(contextRecord?.channelId);
269
+ const conversationId = getNonEmptyString(contextRecord?.conversationId);
270
+ const accountId = getNonEmptyString(contextRecord?.accountId);
271
+ const from = getNonEmptyString(receivedEvent.from);
272
+ const to = getNonEmptyString(rawMetadata?.to);
273
+ const messageId = getNonEmptyString(rawMetadata?.messageId);
274
+
275
+ return {
276
+ conversationId,
277
+ accountId,
278
+ from,
279
+ to,
280
+ channelId,
281
+ message:
282
+ content !== undefined
283
+ ? {
284
+ id: messageId,
285
+ content,
286
+ channelId,
287
+ }
288
+ : undefined,
289
+ };
290
+ }
291
+
292
+ function normalizeSentEvent(event: unknown, hookContext?: unknown): MessageEvent {
293
+ if (!isRecord(event)) {
294
+ const contextRecord = toRecord(hookContext);
295
+ return {
296
+ sessionKey: getNonEmptyString(contextRecord?.sessionKey),
297
+ sessionId: getNonEmptyString(contextRecord?.sessionId),
298
+ channelId: getNonEmptyString(contextRecord?.channelId),
299
+ conversationId: getNonEmptyString(contextRecord?.conversationId),
300
+ accountId: getNonEmptyString(contextRecord?.accountId),
301
+ };
302
+ }
303
+
304
+ const rawMessage = toRecord(event.message);
305
+ const rawEventContext = toRecord(event.context);
306
+ const rawHookContext = toRecord(hookContext);
307
+ const rawContext: Record<string, unknown> = {
308
+ ...(rawHookContext ?? {}),
309
+ ...(rawEventContext ?? {}),
310
+ };
311
+ const rawSession = toRecord(event.session);
312
+ const rawMetadata = toRecord(event.metadata);
313
+
314
+ const content =
315
+ getNonEmptyString(rawMessage?.content) ??
316
+ getNonEmptyString(rawMessage?.text) ??
317
+ getNonEmptyString(event.content) ??
318
+ getNonEmptyString(event.text) ??
319
+ getNonEmptyString(event.bodyForAgent) ??
320
+ getNonEmptyString(event.body) ??
321
+ getNonEmptyString(rawContext.content) ??
322
+ getNonEmptyString(rawContext.text) ??
323
+ getNonEmptyString(event.transcript);
324
+
325
+ const sessionKey =
326
+ getNonEmptyString(event.sessionKey) ??
327
+ getNonEmptyString(rawContext?.sessionKey) ??
328
+ getNonEmptyString(rawSession?.key);
329
+
330
+ const sessionId =
331
+ getNonEmptyString(event.sessionId) ??
332
+ getNonEmptyString(rawContext?.sessionId) ??
333
+ getNonEmptyString(rawSession?.id);
334
+
335
+ const from =
336
+ getNonEmptyString(event.from) ??
337
+ getNonEmptyString(rawMetadata?.from) ??
338
+ getNonEmptyString(rawContext?.from);
339
+
340
+ const to =
341
+ getNonEmptyString(event.to) ??
342
+ getNonEmptyString(rawMetadata?.to) ??
343
+ getNonEmptyString(rawContext?.to);
344
+
345
+ const channelId =
346
+ getNonEmptyString(event.channelId) ??
347
+ getNonEmptyString(rawContext?.channelId) ??
348
+ getNonEmptyString(rawMetadata?.channelId) ??
349
+ deriveChannelIdFromSessionKey(sessionKey) ??
350
+ deriveChannelIdFromAddress(from);
351
+
352
+ const conversationId =
353
+ getNonEmptyString(event.conversationId) ??
354
+ getNonEmptyString(rawContext?.conversationId);
355
+
356
+ const accountId =
357
+ getNonEmptyString(event.accountId) ??
358
+ getNonEmptyString(rawContext?.accountId);
359
+
360
+ const success =
361
+ typeof event.success === "boolean"
362
+ ? event.success
363
+ : typeof rawMetadata?.success === "boolean"
364
+ ? rawMetadata.success
365
+ : undefined;
366
+
367
+ const messageId =
368
+ getNonEmptyString(rawMessage?.id) ??
369
+ getNonEmptyString(rawContext?.messageId) ??
370
+ getNonEmptyString(rawMetadata?.messageId);
371
+
372
+ const messageChannelId =
373
+ getNonEmptyString(rawMessage?.channelId) ??
374
+ getNonEmptyString(rawContext?.channelId) ??
375
+ getNonEmptyString(rawMetadata?.channelId);
376
+
377
+ return {
378
+ role: getNonEmptyString(rawMessage?.role),
379
+ sessionKey,
380
+ sessionId,
381
+ conversationId,
382
+ accountId,
383
+ from,
384
+ to,
385
+ success,
386
+ channelId,
387
+ message:
388
+ content !== undefined
389
+ ? {
390
+ id: messageId,
391
+ content,
392
+ channelId: messageChannelId,
393
+ }
394
+ : undefined,
395
+ };
396
+ }
397
+
398
+ function normalizeBeforeMessageWriteEvent(
399
+ event: unknown,
400
+ hookContext?: unknown
401
+ ): MessageEvent {
402
+ const rawEvent = toRecord(event) as BeforeMessageWriteEvent | undefined;
403
+ const rawMessage = toRecord(rawEvent?.message);
404
+ const rawContext = toRecord(hookContext);
405
+ const role = getNonEmptyString(rawMessage?.role);
406
+ const sessionKey =
407
+ getNonEmptyString(rawEvent?.sessionKey) ??
408
+ getNonEmptyString(rawContext?.sessionKey);
409
+ const content =
410
+ extractTextFromMessageContent(rawMessage?.content) ??
411
+ getNonEmptyString(rawMessage?.text);
412
+ const channelId =
413
+ getNonEmptyString(rawContext?.channelId) ??
414
+ deriveChannelIdFromSessionKey(sessionKey);
415
+
416
+ return {
417
+ role,
418
+ sessionKey,
419
+ channelId,
420
+ message:
421
+ content !== undefined
422
+ ? {
423
+ content,
424
+ channelId,
425
+ }
426
+ : undefined,
427
+ };
428
+ }
429
+
430
+ function resolveSessionKey(
431
+ event: MessageEvent,
432
+ logger: Logger,
433
+ hookName: string
434
+ ): string | null {
435
+ const canonicalSessionKey = buildChannelSessionKey(event);
436
+ if (canonicalSessionKey) {
437
+ if (event.sessionKey && event.sessionKey !== canonicalSessionKey) {
438
+ logger.info("MessageHandler", "Canonicalized session key to channel scope", {
439
+ hookName,
440
+ originalSessionKey: event.sessionKey,
441
+ canonicalSessionKey,
442
+ });
443
+ }
444
+
445
+ return canonicalSessionKey;
446
+ }
447
+
448
+ if (event.sessionId) {
449
+ logger.warn("MessageHandler", "SessionKey missing, fallback to sessionId", {
450
+ hookName,
451
+ sessionId: event.sessionId,
452
+ });
453
+ return event.sessionId;
454
+ }
455
+
456
+ if (event.sessionKey) {
457
+ logger.warn("MessageHandler", "Using non-canonical session key fallback", {
458
+ hookName,
459
+ sessionKey: event.sessionKey,
460
+ channelId: event.channelId,
461
+ });
462
+ return event.sessionKey;
463
+ }
464
+
465
+ logger.warn("MessageHandler", "Skip event without sessionKey", { hookName });
466
+ return null;
467
+ }
468
+
469
+ function resolveChannelId(event: MessageEvent): string {
470
+ return event.channelId ?? event.message?.channelId ?? "unknown";
471
+ }
472
+
473
+ function createReflectionMessage(
474
+ event: MessageEvent,
475
+ role: "user" | "agent",
476
+ sessionKey: string,
477
+ channelId: string
478
+ ): ReflectionMessage {
479
+ const metadata: ReflectionMessage["metadata"] = {
480
+ messageId: event.message?.id,
481
+ from: event.from,
482
+ to: event.to,
483
+ success: event.success,
484
+ };
485
+
486
+ return {
487
+ id: ulid(),
488
+ role,
489
+ message: event.message?.content ?? "",
490
+ timestamp: Date.now(),
491
+ sessionKey,
492
+ channelId,
493
+ metadata,
494
+ };
495
+ }
496
+
497
+ function findLatestMessageByRole(
498
+ messages: ReflectionMessage[],
499
+ role: ReflectionMessage["role"]
500
+ ): string {
501
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
502
+ if (messages[index].role === role) {
503
+ return messages[index].message;
504
+ }
505
+ }
506
+
507
+ return "";
508
+ }
509
+
510
+ function isUpdateDecision(
511
+ decision: MemoryGateOutput["decision"]
512
+ ): decision is
513
+ | "UPDATE_MEMORY"
514
+ | "UPDATE_USER"
515
+ | "UPDATE_SOUL"
516
+ | "UPDATE_IDENTITY"
517
+ | "UPDATE_TOOLS" {
518
+ return (
519
+ decision === "UPDATE_MEMORY" ||
520
+ decision === "UPDATE_USER" ||
521
+ decision === "UPDATE_SOUL" ||
522
+ decision === "UPDATE_IDENTITY" ||
523
+ decision === "UPDATE_TOOLS"
524
+ );
525
+ }
526
+
527
+ async function triggerMemoryGate(
528
+ sessionKey: string,
529
+ bufferManager: SessionBufferManager,
530
+ memoryGate: MemoryGateAnalyzer,
531
+ fileCurator: FileCurator | undefined,
532
+ logger: Logger,
533
+ memoryGateWindowSize: number
534
+ ): Promise<void> {
535
+ const normalizedWindowSize = Number.isInteger(memoryGateWindowSize)
536
+ ? Math.max(memoryGateWindowSize, 1)
537
+ : DEFAULT_MEMORY_GATE_WINDOW_SIZE;
538
+
539
+ const sessionMessages = bufferManager.getMessages(sessionKey);
540
+ const recentMessages = sessionMessages
541
+ .slice(-normalizedWindowSize)
542
+ .map((message) => ({
543
+ role: message.role,
544
+ message: message.message,
545
+ timestamp: message.timestamp,
546
+ }));
547
+
548
+ const currentUserMessage = findLatestMessageByRole(sessionMessages, "user");
549
+ const currentAgentReply = findLatestMessageByRole(sessionMessages, "agent");
550
+
551
+ try {
552
+ const output: MemoryGateOutput = await memoryGate.analyze({
553
+ recentMessages,
554
+ currentUserMessage,
555
+ currentAgentReply,
556
+ });
557
+
558
+ logger.info(
559
+ "MessageHandler",
560
+ "Memory gate decision evaluated",
561
+ {
562
+ decision: output.decision,
563
+ reason: output.reason,
564
+ hasCandidateFact: Boolean(output.candidateFact),
565
+ },
566
+ sessionKey
567
+ );
568
+
569
+ if (isUpdateDecision(output.decision)) {
570
+ if (fileCurator) {
571
+ const writeResult = await fileCurator.write(output);
572
+ if (writeResult.status === "written") {
573
+ logger.info(
574
+ "MessageHandler",
575
+ "Writer guardian applied update",
576
+ {
577
+ decision: output.decision,
578
+ },
579
+ sessionKey
580
+ );
581
+ } else if (writeResult.status === "refused") {
582
+ logger.info(
583
+ "MessageHandler",
584
+ "Writer guardian refused update",
585
+ {
586
+ decision: output.decision,
587
+ reason: writeResult.reason,
588
+ },
589
+ sessionKey
590
+ );
591
+ } else if (writeResult.status === "failed") {
592
+ logger.error(
593
+ "MessageHandler",
594
+ "Writer guardian failed",
595
+ {
596
+ decision: output.decision,
597
+ reason: writeResult.reason,
598
+ },
599
+ sessionKey
600
+ );
601
+ } else {
602
+ logger.warn(
603
+ "MessageHandler",
604
+ "Writer guardian skipped update",
605
+ {
606
+ decision: output.decision,
607
+ reason: writeResult.reason,
608
+ },
609
+ sessionKey
610
+ );
611
+ }
612
+ } else {
613
+ logger.warn(
614
+ "MessageHandler",
615
+ "UPDATE_* skipped because FileCurator is unavailable",
616
+ {
617
+ decision: output.decision,
618
+ },
619
+ sessionKey
620
+ );
621
+ }
622
+ }
623
+ } catch (error) {
624
+ const reason = error instanceof Error ? error.message : String(error);
625
+ logger.error(
626
+ "MessageHandler",
627
+ "Memory gate trigger failed",
628
+ { reason },
629
+ sessionKey
630
+ );
631
+ }
632
+ }
633
+
634
+ // index.ts passes FileLogger here; handlers should only use this injected logger.
635
+ export function handleMessageReceived(
636
+ event: unknown,
637
+ bufferManager: SessionBufferManager,
638
+ logger: Logger,
639
+ hookContext?: unknown
640
+ ): void {
641
+ const normalizedEvent = normalizeReceivedEvent(event, hookContext);
642
+ logHookPayloadDebug(
643
+ logger,
644
+ "message:received",
645
+ event,
646
+ hookContext,
647
+ normalizedEvent
648
+ );
649
+ const sessionKey = resolveSessionKey(
650
+ normalizedEvent,
651
+ logger,
652
+ "message:received"
653
+ );
654
+
655
+ if (!sessionKey) {
656
+ return;
657
+ }
658
+
659
+ const channelId = resolveChannelId(normalizedEvent);
660
+
661
+ const message = createReflectionMessage(
662
+ normalizedEvent,
663
+ "user",
664
+ sessionKey,
665
+ channelId
666
+ );
667
+
668
+ if (message.message.trim() === "") {
669
+ logger.debug(
670
+ "MessageHandler",
671
+ "Skipped empty user message",
672
+ {
673
+ hookName: "message:received",
674
+ },
675
+ sessionKey
676
+ );
677
+ return;
678
+ }
679
+
680
+ logger.info(
681
+ "MessageHandler",
682
+ "Buffer message snapshot",
683
+ {
684
+ hookName: "message:received",
685
+ bufferMessage: message,
686
+ },
687
+ sessionKey
688
+ );
689
+
690
+ bufferManager.push(sessionKey, message);
691
+ }
692
+
693
+ function handleAgentMessage(
694
+ event: unknown,
695
+ bufferManager: SessionBufferManager,
696
+ logger: Logger,
697
+ hookName: string,
698
+ hookContext?: unknown,
699
+ memoryGate?: MemoryGateAnalyzer,
700
+ fileCurator?: FileCurator,
701
+ memoryGateWindowSize = DEFAULT_MEMORY_GATE_WINDOW_SIZE
702
+ ): void {
703
+ const normalizedEvent = normalizeSentEvent(event, hookContext);
704
+ logHookPayloadDebug(
705
+ logger,
706
+ hookName,
707
+ event,
708
+ hookContext,
709
+ normalizedEvent
710
+ );
711
+ const sessionKey = resolveSessionKey(normalizedEvent, logger, hookName);
712
+
713
+ if (!sessionKey) {
714
+ return;
715
+ }
716
+
717
+ const channelId = resolveChannelId(normalizedEvent);
718
+
719
+ const message = createReflectionMessage(
720
+ normalizedEvent,
721
+ "agent",
722
+ sessionKey,
723
+ channelId
724
+ );
725
+
726
+ if (message.message.trim() === "") {
727
+ logger.debug(
728
+ "MessageHandler",
729
+ "Skipped empty agent message",
730
+ {
731
+ hookName,
732
+ },
733
+ sessionKey
734
+ );
735
+ return;
736
+ }
737
+
738
+ logger.info(
739
+ "MessageHandler",
740
+ "Buffer message snapshot",
741
+ {
742
+ hookName,
743
+ bufferMessage: message,
744
+ },
745
+ sessionKey
746
+ );
747
+
748
+ const messageId = message.metadata?.messageId;
749
+ if (messageId && bufferManager.hasProcessedAgentMessage(sessionKey, messageId)) {
750
+ logger.info(
751
+ "MessageHandler",
752
+ "Skipped duplicate agent message event",
753
+ {
754
+ hookName,
755
+ messageId,
756
+ },
757
+ sessionKey
758
+ );
759
+ return;
760
+ }
761
+
762
+ bufferManager.push(sessionKey, message);
763
+
764
+ if (memoryGate) {
765
+ if (messageId) {
766
+ bufferManager.markProcessedAgentMessage(sessionKey, messageId);
767
+ }
768
+
769
+ void bufferManager.runExclusive(sessionKey, () =>
770
+ triggerMemoryGate(
771
+ sessionKey,
772
+ bufferManager,
773
+ memoryGate,
774
+ fileCurator,
775
+ logger,
776
+ memoryGateWindowSize
777
+ )
778
+ );
779
+ }
780
+ }
781
+
782
+ export function handleMessageSent(
783
+ event: unknown,
784
+ bufferManager: SessionBufferManager,
785
+ logger: Logger,
786
+ hookContext?: unknown,
787
+ memoryGate?: MemoryGateAnalyzer,
788
+ fileCurator?: FileCurator,
789
+ memoryGateWindowSize = DEFAULT_MEMORY_GATE_WINDOW_SIZE
790
+ ): void {
791
+ handleAgentMessage(
792
+ event,
793
+ bufferManager,
794
+ logger,
795
+ "message:sent",
796
+ hookContext,
797
+ memoryGate,
798
+ fileCurator,
799
+ memoryGateWindowSize
800
+ );
801
+ }
802
+
803
+ export function handleBeforeMessageWrite(
804
+ event: unknown,
805
+ bufferManager: SessionBufferManager,
806
+ logger: Logger,
807
+ hookContext?: unknown,
808
+ memoryGate?: MemoryGateAnalyzer,
809
+ fileCurator?: FileCurator,
810
+ memoryGateWindowSize = DEFAULT_MEMORY_GATE_WINDOW_SIZE
811
+ ): void {
812
+ const normalizedEvent = normalizeBeforeMessageWriteEvent(event, hookContext);
813
+ logHookPayloadDebug(
814
+ logger,
815
+ "before_message_write",
816
+ event,
817
+ hookContext,
818
+ normalizedEvent
819
+ );
820
+
821
+ if (normalizedEvent.role !== "assistant") {
822
+ logger.debug("MessageHandler", "Skipped non-assistant before_message_write event", {
823
+ hookName: "before_message_write",
824
+ role: normalizedEvent.role ?? "unknown",
825
+ });
826
+ return;
827
+ }
828
+
829
+ handleAgentMessage(
830
+ normalizedEvent,
831
+ bufferManager,
832
+ logger,
833
+ "before_message_write",
834
+ hookContext,
835
+ memoryGate,
836
+ fileCurator,
837
+ memoryGateWindowSize
838
+ );
839
+ }
840
+
841
+ export function handleSessionEnd(
842
+ event: unknown,
843
+ bufferManager: SessionBufferManager,
844
+ logger: Logger,
845
+ hookName = "session:end",
846
+ hookContext?: unknown
847
+ ): void {
848
+ const normalizedEvent = normalizeSentEvent(event, hookContext);
849
+ const sessionKey = resolveSessionKey(normalizedEvent, logger, hookName);
850
+
851
+ if (!sessionKey) {
852
+ return;
853
+ }
854
+
855
+ logger.info(
856
+ "MessageHandler",
857
+ "Session cleared by lifecycle command/hook",
858
+ { sessionKey, hookName },
859
+ sessionKey
860
+ );
861
+ bufferManager.clearSession(sessionKey);
862
+ }