openclaw-openviking-plugin 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/index.ts ADDED
@@ -0,0 +1,1290 @@
1
+ // @ts-ignore OpenClaw provides this module at plugin runtime.
2
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3
+ import {
4
+ OpenVikingClient,
5
+ type CommitSessionResult,
6
+ type FindResult,
7
+ type FindResultItem,
8
+ } from "./client.js";
9
+ import {
10
+ buildMemoryLinesWithBudget,
11
+ clampScore,
12
+ cleanupExpiredPrePromptCounts,
13
+ consumePrePromptCount,
14
+ dedupeMemoriesByUri,
15
+ estimateTokenCount,
16
+ formatMemoryLines,
17
+ rememberPrePromptCount,
18
+ selectToolMemories,
19
+ type PrePromptCountEntry,
20
+ } from "./src/helpers.js";
21
+
22
+ type HookContext = {
23
+ agentId?: string;
24
+ sessionId?: string;
25
+ };
26
+
27
+ type PluginLoggerLike = {
28
+ warn?: (message: string) => void;
29
+ };
30
+
31
+ type BeforePromptBuildEvent = {
32
+ prompt: string;
33
+ messages: unknown[];
34
+ };
35
+
36
+ type BeforePromptBuildResult = {
37
+ prependContext?: string;
38
+ };
39
+
40
+ type AgentEndEvent = {
41
+ messages: unknown[];
42
+ };
43
+
44
+ type ToolTextContent = {
45
+ type: "text";
46
+ text: string;
47
+ };
48
+
49
+ type ToolResult = {
50
+ content: ToolTextContent[];
51
+ details?: Record<string, unknown>;
52
+ };
53
+
54
+ type ToolDefinition = {
55
+ name: string;
56
+ label: string;
57
+ description: string;
58
+ parameters: unknown;
59
+ execute: (_toolCallId: string, params: Record<string, unknown>) => Promise<ToolResult>;
60
+ };
61
+
62
+ type ToolContext = HookContext;
63
+
64
+ type OpenClawPluginApiLike = {
65
+ pluginConfig?: Record<string, unknown>;
66
+ logger: PluginLoggerLike;
67
+ registerTool: {
68
+ (tool: ToolDefinition, opts?: { name?: string; names?: string[] }): void;
69
+ (
70
+ factory: (ctx: ToolContext) => ToolDefinition,
71
+ opts?: { name?: string; names?: string[] },
72
+ ): void;
73
+ };
74
+ on(
75
+ hookName: "before_prompt_build",
76
+ handler: (
77
+ event: BeforePromptBuildEvent,
78
+ ctx: HookContext,
79
+ ) => Promise<BeforePromptBuildResult | void> | BeforePromptBuildResult | void,
80
+ ): void;
81
+ on(
82
+ hookName: "agent_end",
83
+ handler: (event: AgentEndEvent, ctx: HookContext) => Promise<void> | void,
84
+ ): void;
85
+ };
86
+
87
+ type PluginConfig = {
88
+ baseUrl: string;
89
+ apiKey: string;
90
+ autoRecall: boolean;
91
+ autoCapture: boolean;
92
+ recallLimit: number;
93
+ recallScoreThreshold: number;
94
+ recallTokenBudget: number;
95
+ recallMaxContentChars: number;
96
+ commitTokenThreshold: number;
97
+ };
98
+
99
+ type CaptureMode = "semantic" | "keyword";
100
+
101
+ type CapturedTurnMessage = {
102
+ role: "user" | "assistant";
103
+ content: string;
104
+ };
105
+
106
+ const DEFAULT_CONFIG: PluginConfig = {
107
+ baseUrl: "http://127.0.0.1:1934",
108
+ apiKey: "",
109
+ autoRecall: true,
110
+ autoCapture: true,
111
+ recallLimit: 6,
112
+ recallScoreThreshold: 0.15,
113
+ recallTokenBudget: 2_000,
114
+ recallMaxContentChars: 500,
115
+ commitTokenThreshold: 0,
116
+ };
117
+
118
+ const DEFAULT_CAPTURE_MODE: CaptureMode = "semantic";
119
+ const DEFAULT_CAPTURE_MAX_LENGTH = 24_000;
120
+ const DEFAULT_RECALL_PREFER_ABSTRACT = true;
121
+ const PRE_PROMPT_COUNT_TTL_MS = 30 * 60_000;
122
+ const USER_MEMORIES_URI = "viking://user/memories";
123
+ const AGENT_MEMORIES_URI = "viking://agent/memories";
124
+
125
+ export default definePluginEntry({
126
+ id: "openclaw-openviking-plugin",
127
+ name: "OpenViking Memory",
128
+ description:
129
+ "Long-term memory via a running OpenViking HTTP server — hooks plus memory tools",
130
+ register(api: OpenClawPluginApiLike) {
131
+ const cfg = resolvePluginConfig(api.pluginConfig);
132
+ const client = new OpenVikingClient({
133
+ baseUrl: cfg.baseUrl,
134
+ apiKey: cfg.apiKey,
135
+ });
136
+ const prePromptCounts = new Map<string, PrePromptCountEntry>();
137
+
138
+ api.registerTool((ctx: ToolContext) => ({
139
+ name: "memory_recall",
140
+ label: "Memory Recall (OpenViking)",
141
+ description:
142
+ "Search OpenViking long-term memories for relevant user facts, preferences, and prior decisions.",
143
+ parameters: {
144
+ type: "object",
145
+ properties: {
146
+ query: {
147
+ type: "string",
148
+ description: "Search query",
149
+ },
150
+ limit: {
151
+ type: "number",
152
+ description: "Maximum number of results to return",
153
+ },
154
+ scoreThreshold: {
155
+ type: "number",
156
+ description: "Minimum score between 0 and 1",
157
+ },
158
+ targetUri: {
159
+ type: "string",
160
+ description:
161
+ "Optional search scope URI. Defaults to both viking://user/memories and viking://agent/memories",
162
+ },
163
+ },
164
+ required: ["query"],
165
+ },
166
+ async execute(_toolCallId: string, params: Record<string, unknown>): Promise<ToolResult> {
167
+ const query = normalizeNonEmptyString(params.query);
168
+ if (!query) {
169
+ return textToolResult("Provide a non-empty query.", {
170
+ error: "missing_query",
171
+ });
172
+ }
173
+
174
+ const limit = clampInteger(params.limit, cfg.recallLimit, 1, 50);
175
+ const scoreThreshold = clampNumber(
176
+ params.scoreThreshold,
177
+ cfg.recallScoreThreshold,
178
+ 0,
179
+ 1,
180
+ );
181
+ const targetUri = normalizeNonEmptyString(params.targetUri) || undefined;
182
+ const requestLimit = Math.max(limit * 4, 20);
183
+ const agentId = resolveToolAgentId(ctx);
184
+
185
+ try {
186
+ if (targetUri) {
187
+ const result = await client.find(query, targetUri, requestLimit, 0, agentId);
188
+ const memories = selectToolMemories(result.memories ?? [], {
189
+ limit,
190
+ scoreThreshold,
191
+ });
192
+ if (memories.length === 0) {
193
+ return textToolResult("No relevant OpenViking memories found.", {
194
+ count: 0,
195
+ scoreThreshold,
196
+ targetUri,
197
+ });
198
+ }
199
+ return textToolResult(
200
+ `Found ${memories.length} memories:\n\n${formatMemoryLines(memories)}`,
201
+ {
202
+ count: memories.length,
203
+ memories,
204
+ requestLimit,
205
+ scoreThreshold,
206
+ targetUri,
207
+ },
208
+ );
209
+ }
210
+
211
+ const [userSettled, agentSettled] = await Promise.allSettled([
212
+ client.find(query, USER_MEMORIES_URI, requestLimit, 0, agentId),
213
+ client.find(query, AGENT_MEMORIES_URI, requestLimit, 0, agentId),
214
+ ]);
215
+
216
+ if (userSettled.status === "rejected") {
217
+ api.logger.warn?.(
218
+ `openclaw-openviking-plugin: memory_recall user search failed: ${String(
219
+ userSettled.reason,
220
+ )}`,
221
+ );
222
+ }
223
+ if (agentSettled.status === "rejected") {
224
+ api.logger.warn?.(
225
+ `openclaw-openviking-plugin: memory_recall agent search failed: ${String(
226
+ agentSettled.reason,
227
+ )}`,
228
+ );
229
+ }
230
+ if (userSettled.status === "rejected" && agentSettled.status === "rejected") {
231
+ return textToolResult("OpenViking memory recall failed.", {
232
+ error: "both_searches_failed",
233
+ });
234
+ }
235
+
236
+ const memories = selectToolMemories(
237
+ dedupeMemoriesByUri([
238
+ ...(unwrapFindResult(userSettled).memories ?? []),
239
+ ...(unwrapFindResult(agentSettled).memories ?? []),
240
+ ]),
241
+ {
242
+ limit,
243
+ scoreThreshold,
244
+ },
245
+ );
246
+
247
+ if (memories.length === 0) {
248
+ return textToolResult("No relevant OpenViking memories found.", {
249
+ count: 0,
250
+ scoreThreshold,
251
+ targetUri: null,
252
+ });
253
+ }
254
+
255
+ return textToolResult(
256
+ `Found ${memories.length} memories:\n\n${formatMemoryLines(memories)}`,
257
+ {
258
+ count: memories.length,
259
+ memories,
260
+ requestLimit,
261
+ scoreThreshold,
262
+ targetUri: null,
263
+ },
264
+ );
265
+ } catch (error) {
266
+ api.logger.warn?.(
267
+ `openclaw-openviking-plugin: memory_recall failed: ${String(error)}`,
268
+ );
269
+ return textToolResult("OpenViking memory recall failed.", {
270
+ error: String(error),
271
+ });
272
+ }
273
+ },
274
+ }));
275
+
276
+ api.registerTool((ctx: ToolContext) => ({
277
+ name: "memory_store",
278
+ label: "Memory Store (OpenViking)",
279
+ description:
280
+ "Write text into an OpenViking session and run memory extraction immediately.",
281
+ parameters: {
282
+ type: "object",
283
+ properties: {
284
+ text: {
285
+ type: "string",
286
+ description: "Text to store",
287
+ },
288
+ role: {
289
+ type: "string",
290
+ description: "Message role, defaults to user",
291
+ },
292
+ sessionId: {
293
+ type: "string",
294
+ description: "Optional existing session ID. A temporary session ID is generated if omitted",
295
+ },
296
+ },
297
+ required: ["text"],
298
+ },
299
+ async execute(_toolCallId: string, params: Record<string, unknown>): Promise<ToolResult> {
300
+ const text = normalizeNonEmptyString(params.text);
301
+ if (!text) {
302
+ return textToolResult("Provide non-empty text to store.", {
303
+ error: "missing_text",
304
+ });
305
+ }
306
+
307
+ const role = normalizeNonEmptyString(params.role) || "user";
308
+ const providedSessionId = normalizeNonEmptyString(params.sessionId);
309
+ const sessionId =
310
+ providedSessionId ||
311
+ `memory-store-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
312
+ const usedTempSession = !providedSessionId;
313
+ const agentId = resolveToolAgentId(ctx);
314
+
315
+ try {
316
+ await client.addSessionMessage(sessionId, role, text, agentId);
317
+ const commitResult = await client.commitSession(sessionId, {
318
+ wait: true,
319
+ agentId,
320
+ });
321
+ const memoriesCount = totalCommitMemories(commitResult);
322
+
323
+ if (commitResult.status === "failed") {
324
+ return textToolResult(
325
+ `Memory extraction failed for session ${sessionId}: ${commitResult.error ?? "unknown"}`,
326
+ {
327
+ action: "failed",
328
+ error: commitResult.error ?? "unknown",
329
+ sessionId,
330
+ status: commitResult.status,
331
+ usedTempSession,
332
+ },
333
+ );
334
+ }
335
+
336
+ if (commitResult.status === "timeout") {
337
+ return textToolResult(
338
+ `Memory extraction timed out for session ${sessionId}. It may still complete in the background (task_id=${commitResult.task_id ?? "none"}).`,
339
+ {
340
+ action: "timeout",
341
+ sessionId,
342
+ status: commitResult.status,
343
+ taskId: commitResult.task_id ?? null,
344
+ usedTempSession,
345
+ },
346
+ );
347
+ }
348
+
349
+ return textToolResult(
350
+ `Stored text in OpenViking session ${sessionId}. Commit completed with ${memoriesCount} extracted memories.`,
351
+ {
352
+ action: "stored",
353
+ archived: commitResult.archived ?? false,
354
+ memoriesCount,
355
+ sessionId,
356
+ status: commitResult.status,
357
+ usedTempSession,
358
+ },
359
+ );
360
+ } catch (error) {
361
+ api.logger.warn?.(
362
+ `openclaw-openviking-plugin: memory_store failed: ${String(error)}`,
363
+ );
364
+ return textToolResult("OpenViking memory store failed.", {
365
+ error: String(error),
366
+ sessionId,
367
+ usedTempSession,
368
+ });
369
+ }
370
+ },
371
+ }));
372
+
373
+ api.registerTool((ctx: ToolContext) => ({
374
+ name: "memory_forget",
375
+ label: "Memory Forget (OpenViking)",
376
+ description:
377
+ "Delete a memory by URI, or search for a strong match and delete it after confirmation.",
378
+ parameters: {
379
+ type: "object",
380
+ properties: {
381
+ uri: {
382
+ type: "string",
383
+ description: "Exact memory URI to delete",
384
+ },
385
+ query: {
386
+ type: "string",
387
+ description: "Search query used to locate a memory before deletion",
388
+ },
389
+ confirm: {
390
+ type: "boolean",
391
+ description:
392
+ "Required when deleting based on a query match. The tool only auto-deletes a single strong match when confirm is true",
393
+ },
394
+ },
395
+ },
396
+ async execute(_toolCallId: string, params: Record<string, unknown>): Promise<ToolResult> {
397
+ const uri = normalizeNonEmptyString(params.uri);
398
+ const query = normalizeNonEmptyString(params.query);
399
+ const confirm = params.confirm === true;
400
+ const agentId = resolveToolAgentId(ctx);
401
+
402
+ try {
403
+ if (uri) {
404
+ await client.delete(uri, agentId);
405
+ return textToolResult(`Forgotten: ${uri}`, {
406
+ action: "deleted",
407
+ uri,
408
+ });
409
+ }
410
+
411
+ if (!query) {
412
+ return textToolResult("Provide either uri or query.", {
413
+ error: "missing_uri_or_query",
414
+ });
415
+ }
416
+
417
+ const requestLimit = 20;
418
+ const [userSettled, agentSettled] = await Promise.allSettled([
419
+ client.find(query, USER_MEMORIES_URI, requestLimit, 0, agentId),
420
+ client.find(query, AGENT_MEMORIES_URI, requestLimit, 0, agentId),
421
+ ]);
422
+
423
+ if (userSettled.status === "rejected") {
424
+ api.logger.warn?.(
425
+ `openclaw-openviking-plugin: memory_forget user search failed: ${String(
426
+ userSettled.reason,
427
+ )}`,
428
+ );
429
+ }
430
+ if (agentSettled.status === "rejected") {
431
+ api.logger.warn?.(
432
+ `openclaw-openviking-plugin: memory_forget agent search failed: ${String(
433
+ agentSettled.reason,
434
+ )}`,
435
+ );
436
+ }
437
+ if (userSettled.status === "rejected" && agentSettled.status === "rejected") {
438
+ return textToolResult("OpenViking memory forget failed.", {
439
+ error: "both_searches_failed",
440
+ });
441
+ }
442
+
443
+ const candidates = selectToolMemories(
444
+ dedupeMemoriesByUri([
445
+ ...(unwrapFindResult(userSettled).memories ?? []),
446
+ ...(unwrapFindResult(agentSettled).memories ?? []),
447
+ ]),
448
+ {
449
+ limit: 5,
450
+ scoreThreshold: 0,
451
+ },
452
+ );
453
+
454
+ if (candidates.length === 0) {
455
+ return textToolResult("No matching OpenViking memories found.", {
456
+ action: "none",
457
+ query,
458
+ });
459
+ }
460
+
461
+ const top = candidates[0];
462
+ const topScore = clampScore(top.score);
463
+ if (candidates.length === 1 && topScore > 0.8 && confirm) {
464
+ await client.delete(top.uri, agentId);
465
+ return textToolResult(`Forgotten: ${top.uri}`, {
466
+ action: "deleted",
467
+ query,
468
+ score: topScore,
469
+ uri: top.uri,
470
+ });
471
+ }
472
+
473
+ const confirmationHint =
474
+ candidates.length === 1 && topScore > 0.8
475
+ ? "Strong match found. Re-run with confirm=true to delete it, or pass uri to delete directly."
476
+ : "Multiple or weak matches found. Pass uri to delete the intended memory.";
477
+
478
+ return textToolResult(
479
+ `${confirmationHint}\n\nCandidates:\n${formatMemoryLines(candidates)}`,
480
+ {
481
+ action: "candidates",
482
+ candidates,
483
+ confirm,
484
+ query,
485
+ },
486
+ );
487
+ } catch (error) {
488
+ api.logger.warn?.(
489
+ `openclaw-openviking-plugin: memory_forget failed: ${String(error)}`,
490
+ );
491
+ return textToolResult("OpenViking memory forget failed.", {
492
+ error: String(error),
493
+ });
494
+ }
495
+ },
496
+ }));
497
+
498
+ api.on("before_prompt_build", async (event: BeforePromptBuildEvent, ctx: HookContext) => {
499
+ const sessionId = normalizeNonEmptyString(ctx.sessionId);
500
+ cleanupExpiredPrePromptCounts(prePromptCounts, PRE_PROMPT_COUNT_TTL_MS);
501
+ if (sessionId) {
502
+ rememberPrePromptCount(
503
+ prePromptCounts,
504
+ sessionId,
505
+ Array.isArray(event.messages) ? event.messages.length : 0,
506
+ );
507
+ }
508
+
509
+ if (!cfg.autoRecall) {
510
+ return;
511
+ }
512
+
513
+ const queryText =
514
+ extractLatestUserText(event.messages) || normalizeNonEmptyString(event.prompt) || "";
515
+ if (!queryText) {
516
+ return;
517
+ }
518
+
519
+ const runtimeAgentId = resolveRuntimeAgentId(ctx);
520
+
521
+ try {
522
+ const candidateLimit = Math.max(cfg.recallLimit * 4, 20);
523
+ const [userSettled, agentSettled] = await Promise.allSettled([
524
+ client.find(queryText, USER_MEMORIES_URI, candidateLimit, 0, runtimeAgentId),
525
+ client.find(queryText, AGENT_MEMORIES_URI, candidateLimit, 0, runtimeAgentId),
526
+ ]);
527
+
528
+ const userResult = unwrapFindResult(userSettled);
529
+ const agentResult = unwrapFindResult(agentSettled);
530
+ if (userSettled.status === "rejected") {
531
+ api.logger.warn?.(
532
+ `openclaw-openviking-plugin: user memory search failed: ${String(userSettled.reason)}`,
533
+ );
534
+ }
535
+ if (agentSettled.status === "rejected") {
536
+ api.logger.warn?.(
537
+ `openclaw-openviking-plugin: agent memory search failed: ${String(agentSettled.reason)}`,
538
+ );
539
+ }
540
+
541
+ const merged = dedupeMemoriesByUri([
542
+ ...(userResult.memories ?? []),
543
+ ...(agentResult.memories ?? []),
544
+ ]);
545
+ const leafOnly = merged.filter((item) => item.level === 2);
546
+ const processed = postProcessMemories(leafOnly, {
547
+ limit: candidateLimit,
548
+ scoreThreshold: cfg.recallScoreThreshold,
549
+ });
550
+ const selected = pickMemoriesForInjection(processed, cfg.recallLimit, queryText);
551
+ if (selected.length === 0) {
552
+ return;
553
+ }
554
+
555
+ const { lines } = await buildMemoryLinesWithBudget(
556
+ selected,
557
+ (uri) => client.read(uri, runtimeAgentId),
558
+ {
559
+ recallPreferAbstract: DEFAULT_RECALL_PREFER_ABSTRACT,
560
+ recallMaxContentChars: cfg.recallMaxContentChars,
561
+ recallTokenBudget: cfg.recallTokenBudget,
562
+ },
563
+ );
564
+ if (lines.length === 0) {
565
+ return;
566
+ }
567
+
568
+ return {
569
+ prependContext:
570
+ "<relevant-memories>\n" +
571
+ "The following OpenViking memories may be relevant:\n" +
572
+ `${lines.join("\n")}\n` +
573
+ "</relevant-memories>",
574
+ };
575
+ } catch (error) {
576
+ api.logger.warn?.(
577
+ `openclaw-openviking-plugin: autoRecall failed: ${String(error)}`,
578
+ );
579
+ }
580
+ });
581
+
582
+ api.on("agent_end", async (event: AgentEndEvent, ctx: HookContext) => {
583
+ if (!cfg.autoCapture) {
584
+ return;
585
+ }
586
+
587
+ const sessionId = normalizeNonEmptyString(ctx.sessionId);
588
+ if (!sessionId) {
589
+ return;
590
+ }
591
+
592
+ const messages = Array.isArray(event.messages) ? event.messages : [];
593
+ cleanupExpiredPrePromptCounts(prePromptCounts, PRE_PROMPT_COUNT_TTL_MS);
594
+ const recorded = consumePrePromptCount(prePromptCounts, sessionId);
595
+ const preCount =
596
+ recorded != null && recorded > 0
597
+ ? recorded
598
+ : Math.max(0, messages.length - 2);
599
+
600
+ try {
601
+ const { texts: newTexts } = extractNewTurnTexts(messages, preCount);
602
+ if (newTexts.length === 0) {
603
+ return;
604
+ }
605
+
606
+ const runtimeAgentId = resolveRuntimeAgentId(ctx);
607
+ const newMessages = extractNewTurnMessages(messages, preCount);
608
+ const captured = newMessages
609
+ .map((message) => {
610
+ const decision = getCaptureDecision(
611
+ message.content,
612
+ DEFAULT_CAPTURE_MODE,
613
+ DEFAULT_CAPTURE_MAX_LENGTH,
614
+ );
615
+ if (!decision.shouldCapture) {
616
+ return null;
617
+ }
618
+ return {
619
+ role: message.role,
620
+ content: decision.normalizedText,
621
+ };
622
+ })
623
+ .filter((value): value is CapturedTurnMessage => value !== null);
624
+
625
+ if (captured.length === 0) {
626
+ return;
627
+ }
628
+
629
+ for (const message of captured) {
630
+ await client.addSessionMessage(
631
+ sessionId,
632
+ message.role,
633
+ message.content,
634
+ runtimeAgentId,
635
+ );
636
+ }
637
+
638
+ const estimatedTokens = estimateTokenCount(
639
+ captured.map((message) => message.content).join("\n"),
640
+ );
641
+ if (estimatedTokens < cfg.commitTokenThreshold) {
642
+ return;
643
+ }
644
+
645
+ await client.commitSession(sessionId, runtimeAgentId);
646
+ } catch (error) {
647
+ api.logger.warn?.(
648
+ `openclaw-openviking-plugin: autoCapture via agent_end failed: ${String(error)}`,
649
+ );
650
+ }
651
+ });
652
+ },
653
+ });
654
+
655
+ function resolvePluginConfig(pluginConfig: Record<string, unknown> | undefined): PluginConfig {
656
+ const raw =
657
+ pluginConfig && typeof pluginConfig === "object" && !Array.isArray(pluginConfig)
658
+ ? pluginConfig
659
+ : {};
660
+
661
+ return {
662
+ baseUrl: normalizeNonEmptyString(raw.baseUrl) || DEFAULT_CONFIG.baseUrl,
663
+ apiKey: normalizeNonEmptyString(raw.apiKey) || DEFAULT_CONFIG.apiKey,
664
+ autoRecall: toBoolean(raw.autoRecall, DEFAULT_CONFIG.autoRecall),
665
+ autoCapture: toBoolean(raw.autoCapture, DEFAULT_CONFIG.autoCapture),
666
+ recallLimit: clampInteger(raw.recallLimit, DEFAULT_CONFIG.recallLimit, 1, 50),
667
+ recallScoreThreshold: clampNumber(
668
+ raw.recallScoreThreshold,
669
+ DEFAULT_CONFIG.recallScoreThreshold,
670
+ 0,
671
+ 1,
672
+ ),
673
+ recallTokenBudget: clampInteger(
674
+ raw.recallTokenBudget,
675
+ DEFAULT_CONFIG.recallTokenBudget,
676
+ 1,
677
+ 20_000,
678
+ ),
679
+ recallMaxContentChars: clampInteger(
680
+ raw.recallMaxContentChars,
681
+ DEFAULT_CONFIG.recallMaxContentChars,
682
+ 50,
683
+ 20_000,
684
+ ),
685
+ commitTokenThreshold: clampInteger(
686
+ raw.commitTokenThreshold,
687
+ DEFAULT_CONFIG.commitTokenThreshold,
688
+ 0,
689
+ 100_000,
690
+ ),
691
+ };
692
+ }
693
+
694
+ function resolveRuntimeAgentId(ctx: HookContext): string | undefined {
695
+ return normalizeNonEmptyString(ctx.agentId);
696
+ }
697
+
698
+ function resolveToolAgentId(ctx: ToolContext): string | undefined {
699
+ const sessionId = normalizeNonEmptyString(ctx.sessionId);
700
+ return sessionId || undefined;
701
+ }
702
+
703
+ function normalizeNonEmptyString(value: unknown): string {
704
+ return typeof value === "string" && value.trim() ? value.trim() : "";
705
+ }
706
+
707
+ function toBoolean(value: unknown, fallback: boolean): boolean {
708
+ if (typeof value === "boolean") {
709
+ return value;
710
+ }
711
+ if (typeof value === "string") {
712
+ const normalized = value.trim().toLowerCase();
713
+ if (normalized === "true") {
714
+ return true;
715
+ }
716
+ if (normalized === "false") {
717
+ return false;
718
+ }
719
+ }
720
+ return fallback;
721
+ }
722
+
723
+ function toNumber(value: unknown, fallback: number): number {
724
+ if (typeof value === "number" && Number.isFinite(value)) {
725
+ return value;
726
+ }
727
+ if (typeof value === "string" && value.trim() !== "") {
728
+ const parsed = Number(value);
729
+ if (Number.isFinite(parsed)) {
730
+ return parsed;
731
+ }
732
+ }
733
+ return fallback;
734
+ }
735
+
736
+ function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
737
+ const numeric = Math.floor(toNumber(value, fallback));
738
+ return Math.max(min, Math.min(max, numeric));
739
+ }
740
+
741
+ function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
742
+ const numeric = toNumber(value, fallback);
743
+ return Math.max(min, Math.min(max, numeric));
744
+ }
745
+
746
+ function unwrapFindResult(result: PromiseSettledResult<FindResult>): FindResult {
747
+ return result.status === "fulfilled" ? result.value : { memories: [] };
748
+ }
749
+
750
+ function textToolResult(text: string, details?: Record<string, unknown>): ToolResult {
751
+ return {
752
+ content: [{ type: "text", text }],
753
+ details,
754
+ };
755
+ }
756
+
757
+ function totalCommitMemories(result: CommitSessionResult): number {
758
+ let total = 0;
759
+ for (const value of Object.values(result.memories_extracted ?? {})) {
760
+ total += typeof value === "number" && Number.isFinite(value) ? value : 0;
761
+ }
762
+ return total;
763
+ }
764
+
765
+ function normalizeDedupeText(text: string): string {
766
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
767
+ }
768
+
769
+ function isEventOrCaseMemory(item: FindResultItem): boolean {
770
+ const category = (item.category ?? "").toLowerCase();
771
+ const uri = item.uri.toLowerCase();
772
+ return (
773
+ category === "events" ||
774
+ category === "cases" ||
775
+ uri.includes("/events/") ||
776
+ uri.includes("/cases/")
777
+ );
778
+ }
779
+
780
+ function getMemoryDedupeKey(item: FindResultItem): string {
781
+ const abstract = normalizeDedupeText(item.abstract ?? item.overview ?? "");
782
+ const category = (item.category ?? "").toLowerCase() || "unknown";
783
+ if (abstract && !isEventOrCaseMemory(item)) {
784
+ return `abstract:${category}:${abstract}`;
785
+ }
786
+ return `uri:${item.uri}`;
787
+ }
788
+
789
+ function postProcessMemories(
790
+ items: FindResultItem[],
791
+ options: {
792
+ limit: number;
793
+ scoreThreshold: number;
794
+ leafOnly?: boolean;
795
+ },
796
+ ): FindResultItem[] {
797
+ const deduped: FindResultItem[] = [];
798
+ const seen = new Set<string>();
799
+ const sorted = [...items].sort((a, b) => clampScore(b.score) - clampScore(a.score));
800
+ for (const item of sorted) {
801
+ if (options.leafOnly && item.level !== 2) {
802
+ continue;
803
+ }
804
+ if (clampScore(item.score) < options.scoreThreshold) {
805
+ continue;
806
+ }
807
+ const key = getMemoryDedupeKey(item);
808
+ if (seen.has(key)) {
809
+ continue;
810
+ }
811
+ seen.add(key);
812
+ deduped.push(item);
813
+ if (deduped.length >= options.limit) {
814
+ break;
815
+ }
816
+ }
817
+ return deduped;
818
+ }
819
+
820
+ function isPreferencesMemory(item: FindResultItem): boolean {
821
+ return (
822
+ item.category === "preferences" ||
823
+ item.uri.includes("/preferences/") ||
824
+ item.uri.endsWith("/preferences")
825
+ );
826
+ }
827
+
828
+ function isEventMemory(item: FindResultItem): boolean {
829
+ const category = (item.category ?? "").toLowerCase();
830
+ return category === "events" || item.uri.includes("/events/");
831
+ }
832
+
833
+ function isLeafLikeMemory(item: FindResultItem): boolean {
834
+ return item.level === 2;
835
+ }
836
+
837
+ const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
838
+ const TEMPORAL_QUERY_RE =
839
+ /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天|上周|下周|上个月|下个月|去年|明年/i;
840
+ const QUERY_TOKEN_RE = /[a-z0-9]{2,}/gi;
841
+ const QUERY_TOKEN_STOPWORDS = new Set([
842
+ "what",
843
+ "when",
844
+ "where",
845
+ "which",
846
+ "who",
847
+ "whom",
848
+ "whose",
849
+ "why",
850
+ "how",
851
+ "did",
852
+ "does",
853
+ "is",
854
+ "are",
855
+ "was",
856
+ "were",
857
+ "the",
858
+ "and",
859
+ "for",
860
+ "with",
861
+ "from",
862
+ "that",
863
+ "this",
864
+ "your",
865
+ "you",
866
+ ]);
867
+
868
+ type RecallQueryProfile = {
869
+ tokens: string[];
870
+ wantsPreference: boolean;
871
+ wantsTemporal: boolean;
872
+ };
873
+
874
+ function buildRecallQueryProfile(query: string): RecallQueryProfile {
875
+ const text = query.trim();
876
+ const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) ?? [];
877
+ const tokens = allTokens.filter((token) => !QUERY_TOKEN_STOPWORDS.has(token));
878
+ return {
879
+ tokens,
880
+ wantsPreference: PREFERENCE_QUERY_RE.test(text),
881
+ wantsTemporal: TEMPORAL_QUERY_RE.test(text),
882
+ };
883
+ }
884
+
885
+ function lexicalOverlapBoost(tokens: string[], text: string): number {
886
+ if (tokens.length === 0 || !text) {
887
+ return 0;
888
+ }
889
+ const haystack = ` ${text.toLowerCase()} `;
890
+ let matched = 0;
891
+ for (const token of tokens.slice(0, 8)) {
892
+ if (haystack.includes(` ${token} `) || haystack.includes(token)) {
893
+ matched += 1;
894
+ }
895
+ }
896
+ return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2);
897
+ }
898
+
899
+ function rankForInjection(item: FindResultItem, query: RecallQueryProfile): number {
900
+ const baseScore = clampScore(item.score);
901
+ const abstract = (item.abstract ?? item.overview ?? "").trim();
902
+ const leafBoost = isLeafLikeMemory(item) ? 0.12 : 0;
903
+ const eventBoost = query.wantsTemporal && isEventMemory(item) ? 0.1 : 0;
904
+ const preferenceBoost = query.wantsPreference && isPreferencesMemory(item) ? 0.08 : 0;
905
+ const overlapBoost = lexicalOverlapBoost(query.tokens, `${item.uri} ${abstract}`);
906
+ return baseScore + leafBoost + eventBoost + preferenceBoost + overlapBoost;
907
+ }
908
+
909
+ function pickMemoriesForInjection(
910
+ items: FindResultItem[],
911
+ limit: number,
912
+ queryText: string,
913
+ ): FindResultItem[] {
914
+ if (items.length === 0 || limit <= 0) {
915
+ return [];
916
+ }
917
+
918
+ const query = buildRecallQueryProfile(queryText);
919
+ const sorted = [...items].sort((a, b) => rankForInjection(b, query) - rankForInjection(a, query));
920
+ const deduped: FindResultItem[] = [];
921
+ const seen = new Set<string>();
922
+ for (const item of sorted) {
923
+ const abstractKey = (item.abstract ?? item.overview ?? "").trim().toLowerCase();
924
+ const key = abstractKey || item.uri;
925
+ if (seen.has(key)) {
926
+ continue;
927
+ }
928
+ seen.add(key);
929
+ deduped.push(item);
930
+ }
931
+
932
+ const leaves = deduped.filter((item) => isLeafLikeMemory(item));
933
+ if (leaves.length >= limit) {
934
+ return leaves.slice(0, limit);
935
+ }
936
+
937
+ const picked = [...leaves];
938
+ const used = new Set(leaves.map((item) => item.uri));
939
+ for (const item of deduped) {
940
+ if (picked.length >= limit) {
941
+ break;
942
+ }
943
+ if (used.has(item.uri)) {
944
+ continue;
945
+ }
946
+ picked.push(item);
947
+ }
948
+ return picked;
949
+ }
950
+
951
+ const MEMORY_TRIGGERS = [
952
+ /remember|preference|prefer|important|decision|decided|always|never/i,
953
+ /记住|偏好|喜欢|喜爱|崇拜|讨厌|害怕|重要|决定|总是|永远|优先|习惯|爱好|擅长|最爱|不喜欢/i,
954
+ /[\w.-]+@[\w.-]+\.\w+/,
955
+ /\+\d{10,}/,
956
+ /(?:我|my)\s*(?:是|叫|名字|name|住在|live|来自|from|生日|birthday|电话|phone|邮箱|email)/i,
957
+ /(?:我|i)\s*(?:喜欢|崇拜|讨厌|害怕|擅长|不会|爱|恨|想要|需要|希望|觉得|认为|相信)/i,
958
+ /(?:favorite|favourite|love|hate|enjoy|dislike|admire|idol|fan of)/i,
959
+ ];
960
+
961
+ const CJK_CHAR_REGEX = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
962
+ const RELEVANT_MEMORIES_BLOCK_RE = /<relevant-memories>[\s\S]*?<\/relevant-memories>/gi;
963
+ const CONVERSATION_METADATA_BLOCK_RE =
964
+ /(?:^|\n)\s*(?:Conversation info|Conversation metadata|会话信息|对话信息)\s*(?:\([^)]+\))?\s*:\s*```[\s\S]*?```/gi;
965
+ const SENDER_METADATA_BLOCK_RE = /Sender\s*\([^)]*\)\s*:\s*```[\s\S]*?```/gi;
966
+ const FENCED_JSON_BLOCK_RE = /```json\s*([\s\S]*?)```/gi;
967
+ const METADATA_JSON_KEY_RE =
968
+ /"(session|sessionid|sessionkey|conversationid|channel|sender|userid|agentid|timestamp|timezone)"\s*:/gi;
969
+ const LEADING_TIMESTAMP_PREFIX_RE = /^\s*\[[^\]\n]{1,120}\]\s*/;
970
+ const COMMAND_TEXT_RE = /^\/[a-z0-9_-]{1,64}\b/i;
971
+ const NON_CONTENT_TEXT_RE = /^[\p{P}\p{S}\s]+$/u;
972
+ const SUBAGENT_CONTEXT_RE = /^\s*\[Subagent Context\]/i;
973
+ const MEMORY_INTENT_RE = /记住|记下|remember|save|store|偏好|preference|规则|rule|事实|fact/i;
974
+ const QUESTION_CUE_RE =
975
+ /[??]|\b(?:what|when|where|who|why|how|which|can|could|would|did|does|is|are)\b|^(?:请问|能否|可否|怎么|如何|什么时候|谁|什么|哪|是否)/i;
976
+
977
+ function resolveCaptureMinLength(text: string): number {
978
+ return CJK_CHAR_REGEX.test(text) ? 4 : 10;
979
+ }
980
+
981
+ function looksLikeMetadataJsonBlock(content: string): boolean {
982
+ const matchedKeys = new Set<string>();
983
+ const matches = content.matchAll(METADATA_JSON_KEY_RE);
984
+ for (const match of matches) {
985
+ const key = (match[1] ?? "").toLowerCase();
986
+ if (key) {
987
+ matchedKeys.add(key);
988
+ }
989
+ }
990
+ return matchedKeys.size >= 3;
991
+ }
992
+
993
+ function sanitizeUserTextForCapture(text: string): string {
994
+ return text
995
+ .replace(RELEVANT_MEMORIES_BLOCK_RE, " ")
996
+ .replace(CONVERSATION_METADATA_BLOCK_RE, " ")
997
+ .replace(SENDER_METADATA_BLOCK_RE, " ")
998
+ .replace(FENCED_JSON_BLOCK_RE, (full, inner) =>
999
+ looksLikeMetadataJsonBlock(String(inner ?? "")) ? " " : full,
1000
+ )
1001
+ .replace(LEADING_TIMESTAMP_PREFIX_RE, "")
1002
+ .replace(/\u0000/g, "")
1003
+ .replace(/\s+/g, " ")
1004
+ .trim();
1005
+ }
1006
+
1007
+ function looksLikeQuestionOnlyText(text: string): boolean {
1008
+ if (!QUESTION_CUE_RE.test(text) || MEMORY_INTENT_RE.test(text)) {
1009
+ return false;
1010
+ }
1011
+ const speakerTags = text.match(/[A-Za-z\u4e00-\u9fa5]{2,20}:\s/g) ?? [];
1012
+ if (speakerTags.length >= 2 || text.length > 280) {
1013
+ return false;
1014
+ }
1015
+ return true;
1016
+ }
1017
+
1018
+ function getCaptureDecision(
1019
+ text: string,
1020
+ mode: CaptureMode,
1021
+ captureMaxLength: number,
1022
+ ): {
1023
+ shouldCapture: boolean;
1024
+ reason: string;
1025
+ normalizedText: string;
1026
+ } {
1027
+ const trimmed = text.trim();
1028
+ const normalizedText = sanitizeUserTextForCapture(trimmed);
1029
+ const hadSanitization = normalizedText !== trimmed;
1030
+
1031
+ if (!normalizedText) {
1032
+ return {
1033
+ shouldCapture: false,
1034
+ reason: /<relevant-memories>/i.test(trimmed) ? "injected_memory_context_only" : "empty_text",
1035
+ normalizedText: "",
1036
+ };
1037
+ }
1038
+
1039
+ const compactText = normalizedText.replace(/\s+/g, "");
1040
+ const minLength = resolveCaptureMinLength(compactText);
1041
+ if (compactText.length < minLength || normalizedText.length > captureMaxLength) {
1042
+ return {
1043
+ shouldCapture: false,
1044
+ reason: "length_out_of_range",
1045
+ normalizedText,
1046
+ };
1047
+ }
1048
+
1049
+ if (COMMAND_TEXT_RE.test(normalizedText)) {
1050
+ return {
1051
+ shouldCapture: false,
1052
+ reason: "command_text",
1053
+ normalizedText,
1054
+ };
1055
+ }
1056
+
1057
+ if (NON_CONTENT_TEXT_RE.test(normalizedText)) {
1058
+ return {
1059
+ shouldCapture: false,
1060
+ reason: "non_content_text",
1061
+ normalizedText,
1062
+ };
1063
+ }
1064
+
1065
+ if (SUBAGENT_CONTEXT_RE.test(normalizedText)) {
1066
+ return {
1067
+ shouldCapture: false,
1068
+ reason: "subagent_context",
1069
+ normalizedText,
1070
+ };
1071
+ }
1072
+
1073
+ if (looksLikeQuestionOnlyText(normalizedText)) {
1074
+ return {
1075
+ shouldCapture: false,
1076
+ reason: "question_text",
1077
+ normalizedText,
1078
+ };
1079
+ }
1080
+
1081
+ if (mode === "keyword") {
1082
+ for (const trigger of MEMORY_TRIGGERS) {
1083
+ if (trigger.test(normalizedText)) {
1084
+ return {
1085
+ shouldCapture: true,
1086
+ reason: hadSanitization
1087
+ ? `matched_trigger_after_sanitize:${trigger.toString()}`
1088
+ : `matched_trigger:${trigger.toString()}`,
1089
+ normalizedText,
1090
+ };
1091
+ }
1092
+ }
1093
+
1094
+ return {
1095
+ shouldCapture: false,
1096
+ reason: hadSanitization ? "no_trigger_matched_after_sanitize" : "no_trigger_matched",
1097
+ normalizedText,
1098
+ };
1099
+ }
1100
+
1101
+ return {
1102
+ shouldCapture: true,
1103
+ reason: hadSanitization ? "semantic_candidate_after_sanitize" : "semantic_candidate",
1104
+ normalizedText,
1105
+ };
1106
+ }
1107
+
1108
+ function extractTextsFromUserMessages(messages: unknown[]): string[] {
1109
+ const texts: string[] = [];
1110
+ for (const msg of messages) {
1111
+ if (!msg || typeof msg !== "object") {
1112
+ continue;
1113
+ }
1114
+ const msgObj = msg as Record<string, unknown>;
1115
+ if (msgObj.role !== "user") {
1116
+ continue;
1117
+ }
1118
+ const content = msgObj.content;
1119
+ if (typeof content === "string") {
1120
+ texts.push(content);
1121
+ continue;
1122
+ }
1123
+ if (Array.isArray(content)) {
1124
+ for (const block of content) {
1125
+ if (!block || typeof block !== "object") {
1126
+ continue;
1127
+ }
1128
+ const blockObj = block as Record<string, unknown>;
1129
+ if (blockObj.type === "text" && typeof blockObj.text === "string") {
1130
+ texts.push(blockObj.text);
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ return texts;
1136
+ }
1137
+
1138
+ function extractLatestUserText(messages: unknown[] | undefined): string {
1139
+ if (!messages || messages.length === 0) {
1140
+ return "";
1141
+ }
1142
+
1143
+ const texts = extractTextsFromUserMessages(messages);
1144
+ for (let i = texts.length - 1; i >= 0; i -= 1) {
1145
+ const normalized = sanitizeUserTextForCapture(texts[i] ?? "");
1146
+ if (normalized) {
1147
+ return normalized;
1148
+ }
1149
+ }
1150
+ return "";
1151
+ }
1152
+
1153
+ function formatToolUseBlock(block: Record<string, unknown>): string {
1154
+ const name = typeof block.name === "string" ? block.name : "unknown";
1155
+ let inputStr = "";
1156
+ if (block.input !== undefined && block.input !== null) {
1157
+ try {
1158
+ inputStr = typeof block.input === "string" ? block.input : JSON.stringify(block.input);
1159
+ } catch {
1160
+ inputStr = String(block.input);
1161
+ }
1162
+ }
1163
+ return inputStr ? `[toolUse: ${name}]\n${inputStr}` : `[toolUse: ${name}]`;
1164
+ }
1165
+
1166
+ function formatToolResultContent(content: unknown): string {
1167
+ if (typeof content === "string") {
1168
+ return content.trim();
1169
+ }
1170
+ if (Array.isArray(content)) {
1171
+ const parts: string[] = [];
1172
+ for (const block of content) {
1173
+ const blockObject = block as Record<string, unknown>;
1174
+ if (blockObject?.type === "text" && typeof blockObject.text === "string") {
1175
+ parts.push(blockObject.text.trim());
1176
+ }
1177
+ }
1178
+ return parts.join("\n");
1179
+ }
1180
+ if (content !== undefined && content !== null) {
1181
+ try {
1182
+ return JSON.stringify(content);
1183
+ } catch {
1184
+ return String(content);
1185
+ }
1186
+ }
1187
+ return "";
1188
+ }
1189
+
1190
+ function extractNewTurnTexts(
1191
+ messages: unknown[],
1192
+ startIndex: number,
1193
+ ): { texts: string[]; newCount: number } {
1194
+ const texts: string[] = [];
1195
+ let count = 0;
1196
+
1197
+ for (let index = startIndex; index < messages.length; index += 1) {
1198
+ const msg = messages[index] as Record<string, unknown>;
1199
+ if (!msg || typeof msg !== "object") {
1200
+ continue;
1201
+ }
1202
+ const role = msg.role as string;
1203
+ if (!role || role === "system") {
1204
+ continue;
1205
+ }
1206
+ count += 1;
1207
+
1208
+ if (role === "toolResult") {
1209
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : "tool";
1210
+ const resultText = formatToolResultContent(msg.content);
1211
+ if (resultText) {
1212
+ texts.push(`[${toolName} result]: ${resultText}`);
1213
+ }
1214
+ continue;
1215
+ }
1216
+
1217
+ const content = msg.content;
1218
+ if (typeof content === "string" && content.trim()) {
1219
+ texts.push(`[${role}]: ${content.trim()}`);
1220
+ continue;
1221
+ }
1222
+
1223
+ if (Array.isArray(content)) {
1224
+ for (const block of content) {
1225
+ const blockObject = block as Record<string, unknown>;
1226
+ if (blockObject?.type === "text" && typeof blockObject.text === "string") {
1227
+ texts.push(`[${role}]: ${blockObject.text.trim()}`);
1228
+ } else if (blockObject?.type === "toolUse") {
1229
+ texts.push(`[${role}]: ${formatToolUseBlock(blockObject)}`);
1230
+ }
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ return { texts, newCount: count };
1236
+ }
1237
+
1238
+ function extractNewTurnMessages(messages: unknown[], startIndex: number): CapturedTurnMessage[] {
1239
+ const captured: CapturedTurnMessage[] = [];
1240
+
1241
+ for (let index = startIndex; index < messages.length; index += 1) {
1242
+ const msg = messages[index] as Record<string, unknown>;
1243
+ if (!msg || typeof msg !== "object") {
1244
+ continue;
1245
+ }
1246
+ const role = msg.role;
1247
+ if (role !== "user" && role !== "assistant") {
1248
+ continue;
1249
+ }
1250
+
1251
+ const content = stringifyMessageContent(msg.content);
1252
+ if (!content) {
1253
+ continue;
1254
+ }
1255
+
1256
+ captured.push({
1257
+ role,
1258
+ content,
1259
+ });
1260
+ }
1261
+
1262
+ return captured;
1263
+ }
1264
+
1265
+ function stringifyMessageContent(content: unknown): string {
1266
+ if (typeof content === "string") {
1267
+ return content.trim();
1268
+ }
1269
+
1270
+ if (!Array.isArray(content)) {
1271
+ return "";
1272
+ }
1273
+
1274
+ const parts: string[] = [];
1275
+ for (const block of content) {
1276
+ const blockObject = block as Record<string, unknown>;
1277
+ if (blockObject?.type === "text" && typeof blockObject.text === "string") {
1278
+ const text = blockObject.text.trim();
1279
+ if (text) {
1280
+ parts.push(text);
1281
+ }
1282
+ continue;
1283
+ }
1284
+ if (blockObject?.type === "toolUse") {
1285
+ parts.push(formatToolUseBlock(blockObject));
1286
+ }
1287
+ }
1288
+
1289
+ return parts.join("\n").trim();
1290
+ }