@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.
- package/INSTALL.md +78 -0
- package/README.md +195 -0
- package/openclaw.plugin.json +67 -0
- package/package.json +52 -0
- package/src/buffer.ts +40 -0
- package/src/config.ts +254 -0
- package/src/consolidation/consolidator.ts +316 -0
- package/src/consolidation/index.ts +9 -0
- package/src/consolidation/prompt.ts +58 -0
- package/src/consolidation/scheduler.ts +153 -0
- package/src/consolidation/types.ts +25 -0
- package/src/evals/cli.ts +45 -0
- package/src/evals/datasets.ts +39 -0
- package/src/evals/runner.ts +446 -0
- package/src/file-curator/index.ts +204 -0
- package/src/index.ts +323 -0
- package/src/llm/index.ts +11 -0
- package/src/llm/service.ts +447 -0
- package/src/llm/types.ts +87 -0
- package/src/logger.ts +125 -0
- package/src/memory-gate/analyzer.ts +191 -0
- package/src/memory-gate/index.ts +7 -0
- package/src/memory-gate/prompt.ts +85 -0
- package/src/memory-gate/types.ts +23 -0
- package/src/message-handler.ts +862 -0
- package/src/proper-lockfile.d.ts +25 -0
- package/src/session-manager.ts +114 -0
- package/src/types.ts +109 -0
- package/src/utils/file-utils.ts +228 -0
|
@@ -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
|
+
}
|