@openviking/openclaw-plugin 2026.5.21 → 2026.5.26

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/context-engine.ts CHANGED
@@ -1,1737 +1,1737 @@
1
- import { createHash } from "node:crypto";
2
- import { DEFAULT_PHASE2_POLL_TIMEOUT_MS } from "./client.js";
3
- import type { OpenVikingClient, OVMessage } from "./client.js";
4
- import type { MemoryOpenVikingConfig } from "./config.js";
5
- import {
6
- AUTO_RECALL_SOURCE_MARKER,
7
- buildAutoRecallContext,
8
- prepareRecallQuery,
9
- } from "./auto-recall.js";
10
- import {
11
- compileSessionPatterns,
12
- getCaptureDecision,
13
- extractNewTurnMessages,
14
- shouldBypassSession,
15
- } from "./text-utils.js";
16
- import {
17
- trimForLog,
18
- toJsonLog,
19
- } from "./memory-ranking.js";
20
- import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
21
-
22
- type AgentMessage = {
23
- role?: string;
24
- content?: unknown;
25
- timestamp?: unknown;
26
- };
27
-
28
- type ExtractedTurnMessage = ReturnType<typeof extractNewTurnMessages>["messages"][number];
29
-
30
- type ContextEngineInfo = {
31
- id: string;
32
- name: string;
33
- version?: string;
34
- ownsCompaction: true;
35
- };
36
-
37
- type AssembleResult = {
38
- messages: AgentMessage[];
39
- estimatedTokens: number;
40
- systemPromptAddition?: string;
41
- };
42
-
43
- type IngestResult = {
44
- ingested: boolean;
45
- };
46
-
47
- export function toRoleId(senderId: string | undefined): string | undefined {
48
- if (!senderId) {
49
- return undefined;
50
- }
51
- const normalized = senderId
52
- .trim()
53
- .replace(/[^a-zA-Z0-9_-]+/g, "_")
54
- .replace(/^_+|_+$/g, "")
55
- .replace(/_+/g, "_");
56
- return normalized || undefined;
57
- }
58
-
59
- type IngestBatchResult = {
60
- ingestedCount: number;
61
- };
62
-
63
- type CompactResult = {
64
- ok: boolean;
65
- compacted: boolean;
66
- reason?: string;
67
- result?: {
68
- summary?: string;
69
- firstKeptEntryId?: string;
70
- tokensBefore: number;
71
- tokensAfter?: number;
72
- details?: unknown;
73
- };
74
- };
75
-
76
- type ContextEngine = {
77
- info: ContextEngineInfo;
78
- ingest: (params: { sessionId: string; message: AgentMessage; isHeartbeat?: boolean }) => Promise<IngestResult>;
79
- ingestBatch?: (params: {
80
- sessionId: string;
81
- messages: AgentMessage[];
82
- isHeartbeat?: boolean;
83
- }) => Promise<IngestBatchResult>;
84
- afterTurn?: (params: {
85
- sessionId: string;
86
- sessionFile: string;
87
- messages: AgentMessage[];
88
- prePromptMessageCount: number;
89
- autoCompactionSummary?: string;
90
- isHeartbeat?: boolean;
91
- tokenBudget?: number;
92
- runtimeContext?: Record<string, unknown>;
93
- sessionKey?: string;
94
- }) => Promise<void>;
95
- assemble: (params: {
96
- sessionId: string;
97
- sessionKey?: string;
98
- messages: AgentMessage[];
99
- prompt?: string;
100
- tokenBudget?: number;
101
- runtimeContext?: Record<string, unknown>;
102
- }) => Promise<AssembleResult>;
103
- compact: (params: {
104
- sessionId: string;
105
- sessionKey?: string;
106
- sessionFile: string;
107
- tokenBudget?: number;
108
- force?: boolean;
109
- currentTokenCount?: number;
110
- compactionTarget?: "budget" | "threshold";
111
- customInstructions?: string;
112
- runtimeContext?: Record<string, unknown>;
113
- }) => Promise<CompactResult>;
114
- };
115
-
116
- export type ContextEngineWithCommit = ContextEngine & {
117
- /** Commit (archive + extract) the OV session. Returns true on success. */
118
- commitOVSession: (params: {
119
- sessionId: string;
120
- sessionKey?: string;
121
- runtimeContext?: Record<string, unknown>;
122
- }) => Promise<boolean>;
123
- };
124
-
125
- type Logger = {
126
- info: (msg: string) => void;
127
- warn?: (msg: string) => void;
128
- error: (msg: string) => void;
129
- };
130
-
131
- type ExtractedSender = {
132
- found: boolean;
133
- senderId?: string;
134
- };
135
-
136
- interface ContextBudgets {
137
- archiveMemory: number;
138
- sessionContext: number;
139
- reserved: number;
140
- }
141
-
142
- const BUDGET_UNLIMITED = -1;
143
- const ARCHIVE_BUDGET_RATIO = 0.15;
144
- const ARCHIVE_BUDGET_CAP = 8_000;
145
- const RESERVED_MIN = 20_000;
146
- const RESERVED_RATIO = 0.15;
147
- const ARCHIVE_INDEX_TRIM_LIMIT = 10;
148
-
149
- function allocateContextBudget(totalBudget: number, instructionTokens = 0): ContextBudgets {
150
- const reserveFloor = totalBudget >= RESERVED_MIN * 2 ? RESERVED_MIN : 0;
151
- const reserved = Math.min(totalBudget, Math.max(totalBudget * RESERVED_RATIO, reserveFloor));
152
- const usableBudget = Math.max(totalBudget - reserved - instructionTokens, 0);
153
- const archiveMemory = Math.min(usableBudget * ARCHIVE_BUDGET_RATIO, ARCHIVE_BUDGET_CAP);
154
- const sessionContext = Math.max(usableBudget - archiveMemory, 0);
155
- return { archiveMemory, sessionContext, reserved };
156
- }
157
-
158
- function roughEstimate(messages: AgentMessage[]): number {
159
- return Math.ceil(JSON.stringify(messages).length / 4);
160
- }
161
-
162
- function msgTokenEstimate(msg: AgentMessage): number {
163
- const raw = (msg as Record<string, unknown>).content;
164
- if (typeof raw === "string") return Math.ceil(raw.length / 4);
165
- if (Array.isArray(raw)) return Math.ceil(JSON.stringify(raw).length / 4);
166
- return 1;
167
- }
168
-
169
- function normalizeTimestamp(value: unknown): string | undefined {
170
- if (typeof value === "number" && Number.isFinite(value)) {
171
- const timestampMs = Math.abs(value) < 100_000_000_000 ? value * 1000 : value;
172
- return new Date(timestampMs).toISOString();
173
- }
174
- return undefined;
175
- }
176
-
177
- function pickLatestCreatedAt(messages: AgentMessage[]): string | undefined {
178
- for (let i = messages.length - 1; i >= 0; i -= 1) {
179
- const message = messages[i] as Record<string, unknown>;
180
- const role = typeof message.role === "string" ? message.role : "";
181
- if (!role || role === "system") {
182
- continue;
183
- }
184
- const normalized = normalizeTimestamp(message.timestamp);
185
- if (normalized) {
186
- return normalized;
187
- }
188
- }
189
- return undefined;
190
- }
191
-
192
- function messageDigest(messages: AgentMessage[], maxCharsPerMsg = 2000): Array<{role: string; content: string; tokens: number; truncated: boolean}> {
193
- return messages.map((msg) => {
194
- const m = msg as Record<string, unknown>;
195
- const role = String(m.role ?? "unknown");
196
- const raw = m.content;
197
- let text: string;
198
- if (typeof raw === "string") {
199
- text = raw;
200
- } else if (Array.isArray(raw)) {
201
- text = (raw as Record<string, unknown>[])
202
- .map((b) => {
203
- if (b.type === "text") return String(b.text ?? "");
204
- if (b.type === "toolCall") return `[toolCall: ${String(b.name)}(${JSON.stringify(b.arguments ?? {}).slice(0, 200)})]`;
205
- if (b.type === "toolResult") return `[toolResult: ${JSON.stringify(b.content ?? "").slice(0, 200)}]`;
206
- return `[${String(b.type)}]`;
207
- })
208
- .join("\n");
209
- } else {
210
- text = JSON.stringify(raw) ?? "";
211
- }
212
- const truncated = text.length > maxCharsPerMsg;
213
- return {
214
- role,
215
- content: truncated ? text.slice(0, maxCharsPerMsg) + "..." : text,
216
- tokens: msgTokenEstimate(msg),
217
- truncated,
218
- };
219
- });
220
- }
221
-
222
- function extractAgentMessageText(message: AgentMessage | undefined): string {
223
- if (!message) {
224
- return "";
225
- }
226
- const raw = message.content;
227
- if (typeof raw === "string") {
228
- return raw;
229
- }
230
- if (Array.isArray(raw)) {
231
- return raw
232
- .map((block) => {
233
- if (!block || typeof block !== "object") {
234
- return "";
235
- }
236
- const b = block as Record<string, unknown>;
237
- if (b.type === "text" && typeof b.text === "string") {
238
- return b.text;
239
- }
240
- return "";
241
- })
242
- .filter(Boolean)
243
- .join("\n");
244
- }
245
- return "";
246
- }
247
-
248
- function hasAutoRecallBlock(message: AgentMessage | undefined): boolean {
249
- return extractAgentMessageText(message).includes(AUTO_RECALL_SOURCE_MARKER);
250
- }
251
-
252
- function prependTextToMessageContent(content: unknown, text: string): unknown {
253
- if (typeof content === "string") {
254
- return `${text}\n\n${content}`;
255
- }
256
- if (Array.isArray(content)) {
257
- if (content.length === 0) {
258
- return [{ type: "text", text }];
259
- }
260
- const first = content[0];
261
- if (
262
- first &&
263
- typeof first === "object" &&
264
- (first as Record<string, unknown>).type === "text" &&
265
- typeof (first as Record<string, unknown>).text === "string"
266
- ) {
267
- return [
268
- {
269
- ...(first as Record<string, unknown>),
270
- text: `${text}\n\n${(first as Record<string, unknown>).text as string}`,
271
- },
272
- ...content.slice(1),
273
- ];
274
- }
275
- return [{ type: "text", text }, ...content];
276
- }
277
- return text;
278
- }
279
-
280
- function prependRecallToLatestUserMessage(messages: AgentMessage[], recallBlock: string): AgentMessage[] {
281
- const latest = messages.at(-1);
282
- if (!latest || latest.role !== "user" || hasAutoRecallBlock(latest)) {
283
- return messages;
284
- }
285
- return [
286
- ...messages.slice(0, -1),
287
- {
288
- ...latest,
289
- content: prependTextToMessageContent(latest.content, recallBlock),
290
- },
291
- ];
292
- }
293
-
294
- function emitDiag(log: Logger, stage: string, sessionId: string, data: Record<string, unknown>, enabled = true): void {
295
- if (!enabled) return;
296
- log.info(`openviking: diag ${JSON.stringify({ ts: Date.now(), stage, sessionId, data })}`);
297
- }
298
-
299
- function totalExtractedMemories(memories?: Record<string, number>): number {
300
- if (!memories || typeof memories !== "object") {
301
- return 0;
302
- }
303
- return Object.values(memories).reduce((sum, count) => sum + (count ?? 0), 0);
304
- }
305
-
306
- function validTokenBudget(raw: unknown): number | undefined {
307
- if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
308
- return raw;
309
- }
310
- return undefined;
311
- }
312
-
313
- function extractRuntimeSenderId(
314
- runtimeContext: Record<string, unknown> | undefined,
315
- ): ExtractedSender {
316
- if (runtimeContext) {
317
- const senderId = runtimeContext.senderId;
318
- if (typeof senderId === "string") {
319
- const trimmed = senderId.trim();
320
- if (trimmed) {
321
- return {
322
- found: true,
323
- senderId: trimmed,
324
- };
325
- }
326
- }
327
- }
328
- return { found: false };
329
- }
330
-
331
- /** OpenClaw session UUID (path-safe on Windows). */
332
- const OPENVIKING_OV_SESSION_UUID =
333
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
334
-
335
- const WINDOWS_BAD_SESSION_SEGMENT = /[:<>"\\/|?\u0000-\u001f]/;
336
-
337
- /**
338
- * Map OpenClaw session identity to an OpenViking session_id that is safe as a single
339
- * AGFS path segment on Windows (no `:` etc.). Prefer UUID sessionId when present;
340
- * otherwise derive a stable sha256 from sessionKey.
341
- */
342
- export function openClawSessionToOvStorageId(
343
- sessionId: string | undefined,
344
- sessionKey: string | undefined,
345
- ): string {
346
- const sid = typeof sessionId === "string" ? sessionId.trim() : "";
347
- const key = typeof sessionKey === "string" ? sessionKey.trim() : "";
348
-
349
- if (sid && OPENVIKING_OV_SESSION_UUID.test(sid)) {
350
- return sid.toLowerCase();
351
- }
352
- if (key) {
353
- return createHash("sha256").update(key, "utf8").digest("hex");
354
- }
355
- if (sid) {
356
- if (WINDOWS_BAD_SESSION_SEGMENT.test(sid)) {
357
- return createHash("sha256").update(`openclaw-session:${sid}`, "utf8").digest("hex");
358
- }
359
- return sid;
360
- }
361
- throw new Error("openviking: need sessionId or sessionKey for OV session path");
362
- }
363
-
364
- /** Normalize a hook/tool session ref (uuid, sessionKey, or already-safe id) for OV storage. */
365
- export function openClawSessionRefToOvStorageId(ref: string): string {
366
- const t = ref.trim();
367
- if (!t) {
368
- throw new Error("openviking: empty session ref");
369
- }
370
- if (OPENVIKING_OV_SESSION_UUID.test(t)) {
371
- return t.toLowerCase();
372
- }
373
- if (WINDOWS_BAD_SESSION_SEGMENT.test(t)) {
374
- return createHash("sha256").update(t, "utf8").digest("hex");
375
- }
376
- return t;
377
- }
378
-
379
- /**
380
- * Convert an OpenViking stored message (parts-based format) into one or more
381
- * OpenClaw AgentMessages (content-blocks format).
382
- *
383
- * For assistant messages with ToolParts, this produces:
384
- * 1. The assistant message with canonical toolCall blocks in its content array
385
- * 2. A separate toolResult message per ToolPart (carrying tool_output)
386
- */
387
- export function convertToAgentMessages(msg: { role: string; parts: unknown[] }): AgentMessage[] {
388
- const parts = msg.parts ?? [];
389
- const contentBlocks: Record<string, unknown>[] = [];
390
- const toolCallBlocks: Record<string, unknown>[] = [];
391
- const toolResults: AgentMessage[] = [];
392
-
393
- for (const part of parts) {
394
- if (!part || typeof part !== "object") continue;
395
- const p = part as Record<string, unknown>;
396
-
397
- if (p.type === "text" && typeof p.text === "string") {
398
- contentBlocks.push({ type: "text", text: p.text });
399
- } else if (p.type === "context") {
400
- if (typeof p.abstract === "string" && p.abstract) {
401
- contentBlocks.push({ type: "text", text: p.abstract });
402
- }
403
- } else if (p.type === "tool") {
404
- const toolId = typeof p.tool_id === "string" ? p.tool_id : "";
405
- const toolName = typeof p.tool_name === "string" ? p.tool_name : undefined;
406
- const status = typeof p.tool_status === "string" ? p.tool_status : "unknown";
407
- const output = typeof p.tool_output === "string" ? p.tool_output : "";
408
- const ref = typeof p.tool_output_ref === "string" ? p.tool_output_ref : "";
409
- const originalChars = typeof p.tool_output_original_chars === "number"
410
- ? p.tool_output_original_chars
411
- : undefined;
412
-
413
- if (toolId) {
414
- // Structured path: emit canonical toolCall + toolResult pair (works for any role)
415
- toolCallBlocks.push({
416
- type: "toolCall",
417
- id: toolId,
418
- name: toolName ?? "unknown",
419
- arguments: p.tool_input ?? {},
420
- });
421
-
422
- let resultText = (status === "completed" || status === "error")
423
- ? (output || "(no output)")
424
- : "(interrupted — tool did not complete)";
425
- if (ref && !resultText.includes(ref)) {
426
- const suffix = originalChars !== undefined
427
- ? `\n[tool-result-ref] ${ref} original_chars=${originalChars}`
428
- : `\n[tool-result-ref] ${ref}`;
429
- resultText += suffix;
430
- }
431
- const resultPayload: Record<string, unknown> = {
432
- role: "toolResult",
433
- toolCallId: toolId,
434
- content: [{ type: "text", text: resultText }],
435
- isError: status === "error",
436
- };
437
- if (toolName) {
438
- resultPayload.toolName = toolName;
439
- }
440
- toolResults.push(resultPayload as unknown as AgentMessage);
441
- } else {
442
- // No tool_id: degrade to text block
443
- const fallbackName = toolName ?? "unknown";
444
- const segments = [`[${fallbackName}] (${status})`];
445
- if (p.tool_input) {
446
- try {
447
- segments.push(`Input: ${JSON.stringify(p.tool_input)}`);
448
- } catch {
449
- // non-serializable input, skip
450
- }
451
- }
452
- if (output) {
453
- segments.push(`Output: ${output}`);
454
- }
455
- contentBlocks.push({ type: "text", text: segments.join("\n") });
456
- }
457
- }
458
- }
459
-
460
- const result: AgentMessage[] = [];
461
-
462
- if (msg.role === "assistant") {
463
- // Assistant: text + toolCall in one message, then toolResults
464
- result.push({ role: "assistant", content: [...contentBlocks, ...toolCallBlocks] });
465
- result.push(...toolResults);
466
- } else {
467
- // Non-assistant: emit text as original role, then synthesize assistant(toolCall) + toolResult
468
- const texts = contentBlocks
469
- .filter((b) => b.type === "text")
470
- .map((b) => b.text as string);
471
- if (texts.length > 0) {
472
- result.push({ role: msg.role, content: texts.join("\n") });
473
- } else if (toolCallBlocks.length === 0) {
474
- result.push({ role: msg.role, content: "" });
475
- }
476
- if (toolCallBlocks.length > 0) {
477
- result.push({ role: "assistant", content: toolCallBlocks });
478
- result.push(...toolResults);
479
- }
480
- }
481
-
482
- return result;
483
- }
484
-
485
- function isToolOnlyMessage(msg: ExtractedTurnMessage): boolean {
486
- return msg.role === "user" && msg.parts.length > 0 && msg.parts.every((part) => part.type === "tool");
487
- }
488
-
489
- function coalesceConsecutiveToolMessages(messages: ExtractedTurnMessage[]): ExtractedTurnMessage[] {
490
- const result: ExtractedTurnMessage[] = [];
491
- let pendingTools: ExtractedTurnMessage | undefined;
492
-
493
- const flush = () => {
494
- if (pendingTools) {
495
- result.push(pendingTools);
496
- pendingTools = undefined;
497
- }
498
- };
499
-
500
- for (const msg of messages) {
501
- if (isToolOnlyMessage(msg)) {
502
- if (!pendingTools) {
503
- pendingTools = { role: "user", parts: [] };
504
- }
505
- pendingTools.parts.push(...msg.parts);
506
- continue;
507
- }
508
- flush();
509
- result.push(msg);
510
- }
511
- flush();
512
- return result;
513
- }
514
-
515
- function normalizeAssistantContent(messages: AgentMessage[]): void {
516
- for (let i = 0; i < messages.length; i++) {
517
- const msg = messages[i];
518
- if (msg?.role === "assistant" && typeof msg.content === "string") {
519
- messages[i] = {
520
- ...msg,
521
- content: [{ type: "text", text: msg.content }],
522
- };
523
- }
524
- }
525
- }
526
-
527
- function canonicalizeAssistantBlock(block: unknown): unknown {
528
- if (!block || typeof block !== "object") {
529
- return block;
530
- }
531
-
532
- const rec = block as Record<string, unknown>;
533
- const type = typeof rec.type === "string" ? rec.type : "";
534
- if (type === "toolCall") {
535
- if (rec.arguments !== undefined) {
536
- return rec;
537
- }
538
- return {
539
- ...rec,
540
- arguments: rec.input ?? rec.toolInput ?? {},
541
- };
542
- }
543
-
544
- if (type === "toolUse" || type === "functionCall" || type === "tool_call") {
545
- return {
546
- type: "toolCall",
547
- id: rec.id ?? rec.toolCallId ?? rec.toolUseId,
548
- name: rec.name,
549
- arguments: rec.arguments ?? rec.input ?? rec.toolInput ?? {},
550
- };
551
- }
552
-
553
- return rec;
554
- }
555
-
556
- function canonicalizeAgentMessages(messages: AgentMessage[]): AgentMessage[] {
557
- let changed = false;
558
- const next = messages.map((msg) => {
559
- if (!msg || typeof msg !== "object") {
560
- return msg;
561
- }
562
-
563
- if (msg.role === "assistant") {
564
- const content = Array.isArray(msg.content)
565
- ? msg.content.map((block) => canonicalizeAssistantBlock(block))
566
- : typeof msg.content === "string"
567
- ? [{ type: "text", text: msg.content }]
568
- : msg.content;
569
-
570
- if (content !== msg.content) {
571
- changed = true;
572
- return { ...msg, content };
573
- }
574
- return msg;
575
- }
576
-
577
- if (msg.role === "toolResult") {
578
- const raw = msg as Record<string, unknown>;
579
- const toolCallId =
580
- (typeof raw.toolCallId === "string" && raw.toolCallId) ||
581
- (typeof raw.toolUseId === "string" && raw.toolUseId) ||
582
- undefined;
583
- const toolName =
584
- typeof raw.toolName === "string" && raw.toolName.trim()
585
- ? raw.toolName.trim()
586
- : undefined;
587
-
588
- const nextMsg = {
589
- ...msg,
590
- ...(toolCallId ? { toolCallId } : {}),
591
- ...(toolName ? { toolName } : {}),
592
- } as AgentMessage;
593
-
594
- if (nextMsg !== msg) {
595
- changed = true;
596
- }
597
- return nextMsg;
598
- }
599
-
600
- return msg;
601
- });
602
-
603
- return changed ? next : messages;
604
- }
605
-
606
- export function formatMessageFaithful(msg: OVMessage): string {
607
- const roleTag = `[${msg.role}]`;
608
- if (!msg.parts || msg.parts.length === 0) {
609
- return `${roleTag}: (empty)`;
610
- }
611
-
612
- const sections: string[] = [];
613
- for (const part of msg.parts) {
614
- if (!part || typeof part !== "object") continue;
615
- switch (part.type) {
616
- case "text":
617
- if (part.text) sections.push(part.text);
618
- break;
619
- case "tool": {
620
- const status = part.tool_status ?? "unknown";
621
- const header = `[Tool: ${part.tool_name ?? "unknown"}] (${status})`;
622
- const inputStr = part.tool_input
623
- ? `Input: ${JSON.stringify(part.tool_input, null, 2)}`
624
- : "";
625
- const outputStr = part.tool_output ? `Output:\n${part.tool_output}` : "";
626
- sections.push([header, inputStr, outputStr].filter(Boolean).join("\n"));
627
- break;
628
- }
629
- case "context":
630
- sections.push(
631
- `[Context: ${part.uri ?? "?"}]${part.abstract ? ` ${part.abstract}` : ""}`,
632
- );
633
- break;
634
- default:
635
- sections.push(`[${part.type}]: ${JSON.stringify(part)}`);
636
- }
637
- }
638
-
639
- return `${roleTag}:\n${sections.join("\n\n")}`;
640
- }
641
-
642
- function buildSystemPromptAddition(): string {
643
- return [
644
- "## Session Context Guide",
645
- "",
646
- "Your conversation history includes two layers:",
647
- "",
648
- "1. **[Session History Summary]** — A compressed summary of all prior turns",
649
- " in this session. It is organized into structured sections (Key Facts,",
650
- " Timeline, People, etc.). Use it for background and continuity.",
651
- " The summary is lossy: specific details (exact dates, numbers, names,",
652
- " small events) may have been compressed away.",
653
- "",
654
- "2. **Active messages** — The most recent uncompressed turns.",
655
- "",
656
- "**Rules:**",
657
- "- When active messages conflict with the Summary, trust active messages",
658
- " as the newer source of truth.",
659
- "- Do not fabricate details the Summary does not state explicitly.",
660
- "- **CRITICAL: Before answering 'no information' or 'not mentioned',",
661
- " you MUST carefully re-read EVERY section of the [Session History Summary].",
662
- " The answer may be expressed with different wording than the question.",
663
- " Look for synonyms, related facts, and indirect references.**",
664
- "- If the Summary mentions a topic but lacks the specific detail asked,",
665
- " use the `ov_archive_search` tool to grep the original archived messages",
666
- " for the exact detail. Try 2-3 different keywords extracted from the question.",
667
- "- Only conclude information is unavailable AFTER both checking the Summary",
668
- " thoroughly AND searching the archives with at least 2 keyword variations.",
669
- ].join("\n");
670
- }
671
-
672
- function buildInstructionPrompt(): { text: string; tokens: number } {
673
- const text = buildSystemPromptAddition();
674
- return { text, tokens: Math.ceil(text.length / 4) };
675
- }
676
-
677
- function buildArchiveMemory(
678
- archiveOverview: string | undefined,
679
- _preAbstracts: Array<{ archive_id: string; abstract: string }>,
680
- _budget: number,
681
- ): { messages: AgentMessage[]; tokens: number } {
682
- const messages: AgentMessage[] = [];
683
-
684
- if (archiveOverview) {
685
- messages.push({
686
- role: "user",
687
- content: `[Session History Summary]\n${archiveOverview}`,
688
- });
689
- }
690
-
691
- return { messages, tokens: roughEstimate(messages) };
692
- }
693
-
694
- /** Merge consecutive assistant messages by concatenating their content arrays. */
695
- export function mergeConsecutiveAssistants(messages: AgentMessage[]): AgentMessage[] {
696
- const result: AgentMessage[] = [];
697
- for (const msg of messages) {
698
- const prev = result[result.length - 1];
699
- if (msg.role === "assistant" && prev?.role === "assistant") {
700
- const prevContent = Array.isArray(prev.content) ? prev.content : [{ type: "text", text: prev.content }];
701
- const currContent = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }];
702
- prev.content = [...prevContent, ...currContent] as typeof prev.content;
703
- } else {
704
- result.push({ ...msg });
705
- }
706
- }
707
- return result;
708
- }
709
-
710
- /**
711
- * Hoist tool_result blocks to the front of a content array.
712
- *
713
- * The Anthropic / Bedrock / Gemini APIs require tool_result blocks to appear
714
- * at the START of a user message's content array (a tool_result must follow
715
- * the assistant tool_use that produced it). When mergeConsecutiveUsers
716
- * merges two user messages, the previous content's text blocks may end up
717
- * before tool_results from the second message — this function fixes the order.
718
- *
719
- * Same pattern as Claude Code's hoistToolResults in src/utils/messages.ts.
720
- */
721
- function hoistToolResults<T>(content: T[]): T[] {
722
- const toolResults: T[] = [];
723
- const others: T[] = [];
724
- for (const block of content) {
725
- if (
726
- block &&
727
- typeof block === "object" &&
728
- "type" in block &&
729
- (block as { type?: string }).type === "tool_result"
730
- ) {
731
- toolResults.push(block);
732
- } else {
733
- others.push(block);
734
- }
735
- }
736
- return [...toolResults, ...others];
737
- }
738
-
739
- /**
740
- * Merge consecutive user messages by concatenating their content arrays.
741
- *
742
- * Mirror of mergeConsecutiveAssistants. Required because Gemini and Anthropic
743
- * APIs reject consecutive same-role messages with stopReason=stop payloads=0
744
- * (empty response). Three independent sources can inject role: "user":
745
- *
746
- * 1. Archive commit: "[Session History Summary]" via buildArchiveMemory
747
- * 2. OpenClaw yield events: "[sessions_yield interrupt]" / "Turn yielded. ..."
748
- * 3. Audio/Telegram metadata: "[Audio] User text: [Telegram <name>...]"
749
- *
750
- * Without merging, these can stack into 2-5 consecutive user turns. The
751
- * 1P Anthropic API would merge server-side, but Bedrock/Gemini won't —
752
- * we merge client-side for wire-format consistency.
753
- *
754
- * Note: this MUST run AFTER sanitizeToolUseResultPairing because that pass
755
- * may strip orphaned tool_use / tool_result blocks and thereby create new
756
- * user-user adjacencies that didn't exist in the input.
757
- *
758
- * Tracks issue #1724.
759
- */
760
- export function mergeConsecutiveUsers(messages: AgentMessage[]): AgentMessage[] {
761
- const result: AgentMessage[] = [];
762
- for (const msg of messages) {
763
- const prev = result[result.length - 1];
764
- if (msg.role === "user" && prev?.role === "user") {
765
- const prevContent = Array.isArray(prev.content) ? prev.content : [{ type: "text", text: prev.content }];
766
- const currContent = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }];
767
- prev.content = hoistToolResults([...prevContent, ...currContent]) as typeof prev.content;
768
- } else {
769
- result.push({ ...msg });
770
- }
771
- }
772
- return result;
773
- }
774
-
775
- /**
776
- * Defensive role-alternation invariant check.
777
- *
778
- * After mergeConsecutiveUsers + mergeConsecutiveAssistants, the message stream
779
- * should already alternate user/assistant. But sanitizeToolUseResultPairing
780
- * can in rare cases strip a user_with_tool_result message that was the only
781
- * thing separating two assistant messages, leaving an assistant-assistant
782
- * adjacency that upstream merge passes can't fix.
783
- *
784
- * When detected, we insert a placeholder user message — matching Claude Code's
785
- * NO_CONTENT_MESSAGE pattern (see CC src/utils/messages.ts:5375-5388) — to
786
- * preserve the alternation contract that Gemini / Anthropic require.
787
- */
788
- export function ensureAlternation(messages: AgentMessage[]): AgentMessage[] {
789
- const result: AgentMessage[] = [];
790
- for (const msg of messages) {
791
- const prev = result[result.length - 1];
792
- if (prev && prev.role === "assistant" && msg.role === "assistant") {
793
- result.push({
794
- role: "user",
795
- content: "(no content)",
796
- });
797
- }
798
- result.push(msg);
799
- }
800
- return result;
801
- }
802
-
803
- function buildSessionContext(
804
- ovMessages: OVMessage[],
805
- budget: number,
806
- ): { messages: AgentMessage[]; tokens: number } {
807
- const raw = ovMessages.flatMap((m) => convertToAgentMessages(m));
808
- const messages = mergeConsecutiveAssistants(raw);
809
- const tokens = roughEstimate(messages);
810
- if (budget === BUDGET_UNLIMITED || tokens <= budget) {
811
- return { messages, tokens };
812
- }
813
- const trimmed = [...messages];
814
- while (trimmed.length > 0 && roughEstimate(trimmed) > budget) {
815
- trimmed.shift();
816
- }
817
- return { messages: trimmed, tokens: roughEstimate(trimmed) };
818
- }
819
-
820
- function sleep(ms: number): Promise<void> {
821
- return new Promise((resolve) => setTimeout(resolve, ms));
822
- }
823
-
824
- const PHASE2_POLL_INTERVAL_MS = 800;
825
- const PHASE2_POLL_MAX_MS = DEFAULT_PHASE2_POLL_TIMEOUT_MS;
826
-
827
- /**
828
- * After wait=false commit, Phase2 runs on the server. Poll task until completed/failed/timeout
829
- * so logs show memories_extracted (otherwise it looks like "nothing was saved").
830
- */
831
- async function pollPhase2ExtractionOutcome(
832
- getClient: () => Promise<OpenVikingClient>,
833
- taskId: string,
834
- agentId: string,
835
- logger: Logger,
836
- sessionLabel: string,
837
- ): Promise<void> {
838
- const deadline = Date.now() + PHASE2_POLL_MAX_MS;
839
- try {
840
- const client = await getClient();
841
- while (Date.now() < deadline) {
842
- await sleep(PHASE2_POLL_INTERVAL_MS);
843
- const task = await client.getTask(taskId, agentId).catch((e) => {
844
- logger.warn?.(`openviking: phase2 getTask failed task_id=${taskId}: ${String(e)}`);
845
- return null;
846
- });
847
- if (!task) {
848
- return;
849
- }
850
- const { status } = task;
851
- if (status === "completed") {
852
- logger.info(
853
- `openviking: phase2 completed task_id=${taskId} session=${sessionLabel} ` +
854
- `result=${toJsonLog(task.result ?? {})}`,
855
- );
856
- return;
857
- }
858
- if (status === "failed") {
859
- logger.warn?.(
860
- `openviking: phase2 failed task_id=${taskId} session=${sessionLabel} error=${task.error ?? "unknown"}`,
861
- );
862
- return;
863
- }
864
- }
865
- logger.warn?.(
866
- `openviking: phase2 poll timeout (${PHASE2_POLL_MAX_MS / 1000}s) task_id=${taskId} session=${sessionLabel} — ` +
867
- `check GET /api/v1/tasks/${taskId}`,
868
- );
869
- } catch (e) {
870
- logger.warn?.(`openviking: phase2 poll exception task_id=${taskId}: ${String(e)}`);
871
- }
872
- }
873
-
874
- export function createMemoryOpenVikingContextEngine(params: {
875
- id: string;
876
- name: string;
877
- version?: string;
878
- cfg: Required<MemoryOpenVikingConfig>;
879
- logger: Logger;
880
- getClient: () => Promise<OpenVikingClient>;
881
- /** Extra args help match hook-populated routing when OpenClaw provides sessionKey / OV session id. */
882
- resolveAgentId: (sessionId: string, sessionKey?: string, ovSessionId?: string) => string;
883
- rememberSessionAgentId?: (ctx: {
884
- agentId?: string;
885
- sessionId?: string;
886
- sessionKey?: string;
887
- ovSessionId?: string;
888
- }) => void;
889
- }): ContextEngineWithCommit {
890
- const {
891
- id,
892
- name,
893
- version,
894
- cfg,
895
- logger,
896
- getClient,
897
- resolveAgentId,
898
- rememberSessionAgentId,
899
- } = params;
900
-
901
- const diagEnabled = cfg.emitStandardDiagnostics;
902
- const bypassSessionPatterns = compileSessionPatterns(cfg.bypassSessionPatterns);
903
- const diag = (stage: string, sessionId: string, data: Record<string, unknown>) =>
904
- emitDiag(logger, stage, sessionId, data, diagEnabled);
905
-
906
- const isBypassedSession = (params: { sessionId?: string; sessionKey?: string }): boolean =>
907
- shouldBypassSession(params, bypassSessionPatterns);
908
-
909
- async function doCommitOVSession(params: {
910
- sessionId: string;
911
- sessionKey?: string;
912
- runtimeContext?: Record<string, unknown>;
913
- }): Promise<boolean> {
914
- const { sessionId } = params;
915
- const { sessionKey, ovSessionId: ovId } = resolveSessionIdentity(params);
916
- if (isBypassedSession({ sessionId, sessionKey })) {
917
- logger.warn?.(
918
- `openviking: commit skipped because session is bypassed (sessionId=${sessionId}, sessionKey=${sessionKey ?? "none"})`,
919
- );
920
- return false;
921
- }
922
- try {
923
- const client = await getClient();
924
- rememberSessionAgentId?.({
925
- sessionId,
926
- sessionKey,
927
- ovSessionId: ovId,
928
- });
929
- const agentId = resolveAgentId(sessionId, sessionKey, ovId);
930
- const commitResult = await client.commitSession(ovId, {
931
- wait: true,
932
- agentId,
933
- keepRecentCount: 0,
934
- });
935
- const memCount = totalExtractedMemories(commitResult.memories_extracted);
936
- if (commitResult.status === "failed") {
937
- logger.warn?.(`openviking: commit Phase 2 failed for session=${sessionId}: ${commitResult.error ?? "unknown"}`);
938
- return false;
939
- }
940
- if (commitResult.status === "timeout") {
941
- logger.warn?.(`openviking: commit Phase 2 timed out for session=${sessionId}, task_id=${commitResult.task_id ?? "none"}`);
942
- return false;
943
- }
944
- logger.info(
945
- `openviking: committed OV session=${sessionId} ovId=${ovId}, archived=${commitResult.archived ?? false}, memories=${memCount}, task_id=${commitResult.task_id ?? "none"}, trace_id=${commitResult.trace_id ?? "none"}`,
946
- );
947
- return true;
948
- } catch (err) {
949
- logger.warn?.(`openviking: commit failed for session=${sessionId}: ${String(err)}`);
950
- return false;
951
- }
952
- }
953
-
954
- function extractSessionKey(runtimeContext: Record<string, unknown> | undefined): string | undefined {
955
- if (!runtimeContext) {
956
- return undefined;
957
- }
958
- const key = runtimeContext.sessionKey;
959
- return typeof key === "string" && key.trim() ? key.trim() : undefined;
960
- }
961
-
962
- function resolveSessionKey(params: {
963
- sessionKey?: string;
964
- runtimeContext?: Record<string, unknown>;
965
- }): string | undefined {
966
- const direct = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
967
- if (direct) {
968
- return direct;
969
- }
970
- return extractSessionKey(params.runtimeContext);
971
- }
972
-
973
- function resolveSessionIdentity(params: {
974
- sessionId: string;
975
- sessionKey?: string;
976
- runtimeContext?: Record<string, unknown>;
977
- }): { sessionKey: string | undefined; ovSessionId: string } {
978
- const sessionKey = resolveSessionKey(params);
979
- return {
980
- sessionKey,
981
- ovSessionId: openClawSessionToOvStorageId(params.sessionId, sessionKey),
982
- };
983
- }
984
-
985
- function extractRuntimeAgentId(
986
- runtimeContext: Record<string, unknown> | undefined,
987
- ): string | undefined {
988
- if (!runtimeContext) {
989
- return undefined;
990
- }
991
- const agentId = runtimeContext.agentId;
992
- return typeof agentId === "string" && agentId.trim() ? agentId.trim() : undefined;
993
- }
994
-
995
- function assemblePassthrough(
996
- ovSessionId: string,
997
- reason: string,
998
- liveMessages: AgentMessage[],
999
- originalTokens: number,
1000
- extra?: Record<string, unknown>,
1001
- ): AssembleResult {
1002
- diag("assemble_result", ovSessionId, {
1003
- passthrough: true,
1004
- reason,
1005
- outputMessagesCount: liveMessages.length,
1006
- inputTokenEstimate: originalTokens,
1007
- estimatedTokens: originalTokens,
1008
- tokensSaved: 0,
1009
- savingPct: 0,
1010
- ...extra,
1011
- });
1012
- return { messages: liveMessages, estimatedTokens: originalTokens };
1013
- }
1014
-
1015
- function buildAssembledContext(
1016
- overview: string | undefined,
1017
- preAbstracts: Array<{ archive_id: string; abstract: string }>,
1018
- ovMessages: OVMessage[],
1019
- tokenBudget: number,
1020
- ovSessionId: string,
1021
- ): {
1022
- sanitized: AgentMessage[];
1023
- archive: { messages: AgentMessage[]; tokens: number };
1024
- session: { messages: AgentMessage[]; tokens: number };
1025
- budgets: ContextBudgets;
1026
- instruction: { text: string; tokens: number };
1027
- } {
1028
- const hasArchives = Boolean(overview) || preAbstracts.length > 0;
1029
- const instruction = hasArchives ? buildInstructionPrompt() : { text: "", tokens: 0 };
1030
-
1031
- // 4-layer context partitioning:
1032
- // Instruction — system prompt guide (Archive Index / Session History usage)
1033
- // Archive — session history summary + per-archive one-line abstracts
1034
- // Session — active OV messages converted to AgentMessage format
1035
- // Reserved — headroom for model output (not consumed here)
1036
- const budgets = allocateContextBudget(tokenBudget, instruction.tokens);
1037
- const archive = buildArchiveMemory(overview, preAbstracts, budgets.archiveMemory);
1038
- const sessionBudget = Math.max(
1039
- tokenBudget - budgets.reserved - instruction.tokens - archive.tokens,
1040
- 0,
1041
- );
1042
- const session = buildSessionContext(ovMessages, sessionBudget);
1043
- const assembled = [...archive.messages, ...session.messages];
1044
-
1045
- logger.info(
1046
- `openviking: assemble entering session content for ${ovSessionId}: ` +
1047
- JSON.stringify(assembled.map((m) => ({
1048
- role: m.role,
1049
- content: typeof m.content === "string" ? m.content.substring(0, 100) : "[complex]",
1050
- })), null, 2),
1051
- );
1052
-
1053
- normalizeAssistantContent(assembled);
1054
- const canonical = canonicalizeAgentMessages(assembled);
1055
- // Defense in depth (issue #1724):
1056
- // 1) sanitizeToolUseResultPairing may strip orphaned tool_use/tool_result,
1057
- // potentially creating new user-user or assistant-assistant adjacencies.
1058
- // 2) mergeConsecutiveUsers fixes user-user (mirror of mergeConsecutiveAssistants
1059
- // already running inside buildSessionContext).
1060
- // 3) ensureAlternation is a final invariant check for the rare
1061
- // assistant-assistant case that the merges can't reach.
1062
- const sanitized = ensureAlternation(
1063
- mergeConsecutiveUsers(
1064
- sanitizeToolUseResultPairing(canonical as never[]) as AgentMessage[],
1065
- ),
1066
- );
1067
-
1068
- return { sanitized, archive, session, budgets, instruction };
1069
- }
1070
-
1071
- return {
1072
- info: {
1073
- id,
1074
- name,
1075
- version,
1076
- ownsCompaction: true,
1077
- },
1078
-
1079
- commitOVSession: doCommitOVSession,
1080
-
1081
- // --- standard ContextEngine methods ---
1082
-
1083
- async ingest(): Promise<IngestResult> {
1084
- return { ingested: false };
1085
- },
1086
-
1087
- async ingestBatch(): Promise<IngestBatchResult> {
1088
- return { ingestedCount: 0 };
1089
- },
1090
-
1091
- async assemble(assembleParams): Promise<AssembleResult> {
1092
- const { messages } = assembleParams;
1093
- const tokenBudget = validTokenBudget(assembleParams.tokenBudget) ?? 128_000;
1094
- const { sessionKey, ovSessionId: OVSessionId } = resolveSessionIdentity(assembleParams);
1095
- const sender = extractRuntimeSenderId(assembleParams.runtimeContext);
1096
- const latestMessage = messages.at(-1);
1097
- const isMainAssemble =
1098
- Object.prototype.hasOwnProperty.call(assembleParams, "availableTools") ||
1099
- Object.prototype.hasOwnProperty.call(assembleParams, "citationsMode") ||
1100
- Object.prototype.hasOwnProperty.call(assembleParams, "prompt");
1101
- const isTransformContextAssemble = !isMainAssemble;
1102
-
1103
- const originalTokens = roughEstimate(messages);
1104
-
1105
- rememberSessionAgentId?.({
1106
- sessionId: assembleParams.sessionId,
1107
- sessionKey,
1108
- agentId: extractRuntimeAgentId(assembleParams.runtimeContext),
1109
- ovSessionId: OVSessionId,
1110
- });
1111
- diag("assemble_entry", OVSessionId, {
1112
- messagesCount: messages.length,
1113
- inputTokenEstimate: originalTokens,
1114
- tokenBudget,
1115
- sessionKey: sessionKey ?? null,
1116
- senderIdFound: sender.found,
1117
- senderId: sender.senderId ?? null,
1118
- messages: messageDigest(messages),
1119
- });
1120
-
1121
- if (isBypassedSession({ sessionId: assembleParams.sessionId, sessionKey })) {
1122
- return assemblePassthrough(OVSessionId, "session_bypassed", messages, originalTokens);
1123
- }
1124
-
1125
- if (isTransformContextAssemble) {
1126
- if (latestMessage?.role !== "user") {
1127
- return assemblePassthrough(OVSessionId, "transform_context_non_user_tail", messages, originalTokens, {
1128
- latestRole: latestMessage?.role ?? null,
1129
- });
1130
- }
1131
- if (!cfg.autoRecall) {
1132
- return assemblePassthrough(OVSessionId, "transform_context_auto_recall_disabled", messages, originalTokens);
1133
- }
1134
- if (hasAutoRecallBlock(latestMessage)) {
1135
- return assemblePassthrough(OVSessionId, "transform_context_recall_already_injected", messages, originalTokens);
1136
- }
1137
-
1138
- const recallQuery = prepareRecallQuery(extractAgentMessageText(latestMessage));
1139
- if (!recallQuery.query || recallQuery.query.length < 5) {
1140
- return assemblePassthrough(OVSessionId, "transform_context_empty_recall_query", messages, originalTokens);
1141
- }
1142
- if (recallQuery.truncated) {
1143
- logger.info(
1144
- `openviking: recall query truncated (` +
1145
- `chars=${recallQuery.originalChars}->${recallQuery.finalChars})`,
1146
- );
1147
- }
1148
-
1149
- try {
1150
- const client = await getClient();
1151
- const routingRef = assembleParams.sessionId ?? sessionKey ?? OVSessionId;
1152
- const agentId = resolveAgentId(routingRef, sessionKey, OVSessionId);
1153
- const recall = await buildAutoRecallContext({
1154
- cfg,
1155
- client,
1156
- agentId,
1157
- queryText: recallQuery.query,
1158
- logger,
1159
- verbose: (message) => logger.info(message),
1160
- });
1161
-
1162
- if (!recall.block) {
1163
- return assemblePassthrough(OVSessionId, "transform_context_no_recall_hits", messages, originalTokens, {
1164
- memoryCount: recall.memoryCount,
1165
- });
1166
- }
1167
-
1168
- const withRecall = prependRecallToLatestUserMessage(messages, recall.block);
1169
- const estimatedTokens = roughEstimate(withRecall);
1170
- diag("assemble_result", OVSessionId, {
1171
- passthrough: false,
1172
- phase: "transform_context",
1173
- outputMessagesCount: withRecall.length,
1174
- inputTokenEstimate: originalTokens,
1175
- estimatedTokens,
1176
- autoRecallMemoryCount: recall.memoryCount,
1177
- autoRecallTokens: recall.estimatedTokens,
1178
- messages: messageDigest(withRecall),
1179
- });
1180
- return { messages: withRecall, estimatedTokens };
1181
- } catch (err) {
1182
- logger.warn?.(`openviking: auto-recall failed: ${String(err)}`);
1183
- return assemblePassthrough(OVSessionId, "transform_context_recall_failed", messages, originalTokens, {
1184
- error: String(err),
1185
- });
1186
- }
1187
- }
1188
-
1189
- try {
1190
- const client = await getClient();
1191
- const routingRef = assembleParams.sessionId ?? sessionKey ?? OVSessionId;
1192
- const agentId = resolveAgentId(routingRef, sessionKey, OVSessionId);
1193
- const ctx = await client.getSessionContext(OVSessionId, tokenBudget, agentId);
1194
-
1195
- const preAbstracts = ctx?.pre_archive_abstracts ?? [];
1196
- const hasArchives = !!ctx?.latest_archive_overview || preAbstracts.length > 0;
1197
- const activeCount = ctx?.messages?.length ?? 0;
1198
-
1199
- if (!ctx || (!hasArchives && activeCount === 0)) {
1200
- return assemblePassthrough(OVSessionId, "no_ov_data", messages, originalTokens, {
1201
- archiveCount: 0, activeCount: 0,
1202
- });
1203
- }
1204
- if (!hasArchives && ctx.messages.length < messages.length) {
1205
- return assemblePassthrough(OVSessionId, "ov_msgs_fewer_than_input", messages, originalTokens, {
1206
- archiveCount: 0, activeCount,
1207
- });
1208
- }
1209
-
1210
- const { sanitized, archive, session, budgets, instruction } = buildAssembledContext(
1211
- ctx.latest_archive_overview,
1212
- preAbstracts,
1213
- ctx.messages,
1214
- tokenBudget,
1215
- OVSessionId,
1216
- );
1217
-
1218
- if (sanitized.length === 0 && messages.length > 0) {
1219
- return assemblePassthrough(OVSessionId, "sanitized_empty", messages, originalTokens, {
1220
- archiveCount: preAbstracts.length, activeCount,
1221
- });
1222
- }
1223
-
1224
- const assembledTokens = roughEstimate(sanitized) + instruction.tokens;
1225
- const tokensSaved = originalTokens - assembledTokens;
1226
- const savingPct = originalTokens > 0 ? Math.round((tokensSaved / originalTokens) * 100) : 0;
1227
-
1228
- diag("assemble_result", OVSessionId, {
1229
- passthrough: false,
1230
- archiveCount: preAbstracts.length,
1231
- activeCount,
1232
- outputMessagesCount: sanitized.length,
1233
- inputTokenEstimate: originalTokens,
1234
- estimatedTokens: assembledTokens,
1235
- tokensSaved,
1236
- savingPct,
1237
- archiveTokens: archive.tokens,
1238
- archiveBudget: budgets.archiveMemory,
1239
- sessionTokens: session.tokens,
1240
- sessionBudget: budgets.sessionContext,
1241
- reservedBudget: budgets.reserved,
1242
- senderIdFound: sender.found,
1243
- senderId: sender.senderId ?? null,
1244
- messages: messageDigest(sanitized),
1245
- });
1246
-
1247
- return {
1248
- messages: sanitized,
1249
- estimatedTokens: assembledTokens,
1250
- ...(instruction.text ? { systemPromptAddition: instruction.text } : {}),
1251
- };
1252
- } catch (err) {
1253
- logger.warn?.(
1254
- `openviking: assemble failed for session=${OVSessionId}, ` +
1255
- `tokenBudget=${tokenBudget}, agentId=${resolveAgentId(OVSessionId)}: ${String(err)}`,
1256
- );
1257
- diag("assemble_error", OVSessionId, {
1258
- error: String(err),
1259
- tokenBudget,
1260
- agentId: resolveAgentId(OVSessionId),
1261
- senderIdFound: sender.found,
1262
- senderId: sender.senderId ?? null,
1263
- });
1264
- return { messages, estimatedTokens: roughEstimate(messages) };
1265
- }
1266
- },
1267
-
1268
- async afterTurn(afterTurnParams): Promise<void> {
1269
- if (!cfg.autoCapture) {
1270
- return;
1271
- }
1272
-
1273
- if (afterTurnParams.isHeartbeat) {
1274
- return;
1275
- }
1276
-
1277
- try {
1278
- const sender = extractRuntimeSenderId(afterTurnParams.runtimeContext);
1279
- const { sessionKey, ovSessionId: OVSessionId } = resolveSessionIdentity(afterTurnParams);
1280
- const runtimeAgentId = extractRuntimeAgentId(afterTurnParams.runtimeContext);
1281
- if (runtimeAgentId) {
1282
- rememberSessionAgentId?.({
1283
- agentId: runtimeAgentId,
1284
- sessionId: afterTurnParams.sessionId,
1285
- sessionKey,
1286
- ovSessionId: OVSessionId,
1287
- });
1288
- }
1289
- const routingRef =
1290
- afterTurnParams.sessionId ?? sessionKey ?? OVSessionId;
1291
- const agentId = resolveAgentId(routingRef, sessionKey, OVSessionId);
1292
-
1293
- if (isBypassedSession({ sessionId: afterTurnParams.sessionId, sessionKey })) {
1294
- diag("afterTurn_skip", OVSessionId, {
1295
- reason: "session_bypassed",
1296
- totalMessages: afterTurnParams.messages?.length ?? 0,
1297
- senderIdFound: sender.found,
1298
- senderId: sender.senderId ?? null,
1299
- });
1300
- return;
1301
- }
1302
-
1303
- const messages = afterTurnParams.messages ?? [];
1304
- if (messages.length === 0) {
1305
- diag("afterTurn_skip", OVSessionId, {
1306
- reason: "no_messages",
1307
- totalMessages: 0,
1308
- senderIdFound: sender.found,
1309
- senderId: sender.senderId ?? null,
1310
- });
1311
- return;
1312
- }
1313
-
1314
- const start =
1315
- typeof afterTurnParams.prePromptMessageCount === "number" &&
1316
- afterTurnParams.prePromptMessageCount >= 0
1317
- ? afterTurnParams.prePromptMessageCount
1318
- : 0;
1319
-
1320
- const { messages: extractedMessagesRaw, newCount } = extractNewTurnMessages(messages, start);
1321
- const extractedMessages = coalesceConsecutiveToolMessages(extractedMessagesRaw);
1322
-
1323
- if (extractedMessages.length === 0) {
1324
- diag("afterTurn_skip", OVSessionId, {
1325
- reason: "no_new_turn_messages",
1326
- totalMessages: messages.length,
1327
- prePromptMessageCount: start,
1328
- senderIdFound: sender.found,
1329
- senderId: sender.senderId ?? null,
1330
- });
1331
- return;
1332
- }
1333
-
1334
- const turnMessages = messages.slice(start) as AgentMessage[];
1335
- const newMessages = turnMessages.filter((m: any) => {
1336
- const r = (m as Record<string, unknown>).role as string;
1337
- return r === "user" || r === "assistant";
1338
- }) as AgentMessage[];
1339
- const newMsgFull = messageDigest(newMessages);
1340
- const newTurnTokens = newMsgFull.reduce((s, d) => s + d.tokens, 0);
1341
-
1342
- diag("afterTurn_entry", OVSessionId, {
1343
- totalMessages: messages.length,
1344
- newMessageCount: newCount,
1345
- prePromptMessageCount: start,
1346
- newTurnTokens,
1347
- senderIdFound: sender.found,
1348
- senderId: sender.senderId ?? null,
1349
- messages: newMsgFull,
1350
- });
1351
-
1352
- const client = await getClient();
1353
- const createdAt = pickLatestCreatedAt(turnMessages);
1354
- const senderRoleId = toRoleId(sender.senderId);
1355
- // 发送结构化消息:统一 role 为 user,通过 parts 区分类型
1356
- for (const msg of extractedMessages) {
1357
- const ovParts = msg.parts.map((part) => {
1358
- if (part.type === "text") {
1359
- // 清理 relevant-memories 块
1360
- const cleaned = part.text
1361
- .replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ")
1362
- .replace(/\s+/g, " ")
1363
- .trim();
1364
- return { type: "text" as const, text: cleaned };
1365
- } else {
1366
- return {
1367
- type: "tool" as const,
1368
- tool_id: part.toolCallId,
1369
- tool_name: part.toolName,
1370
- tool_input: part.toolInput,
1371
- tool_output: part.toolOutput,
1372
- tool_status: part.toolStatus,
1373
- };
1374
- }
1375
- });
1376
-
1377
- if (ovParts.length > 0) {
1378
- await client.addSessionMessage(
1379
- OVSessionId,
1380
- msg.role, // 统一是 "user"
1381
- ovParts,
1382
- agentId,
1383
- createdAt,
1384
- msg.role === "user" ? senderRoleId : undefined,
1385
- );
1386
- }
1387
- }
1388
-
1389
- const session = await client.getSession(OVSessionId, agentId);
1390
- const pendingTokens = session.pending_tokens ?? 0;
1391
-
1392
- if (pendingTokens < cfg.commitTokenThreshold) {
1393
- diag("afterTurn_skip", OVSessionId, {
1394
- reason: "below_threshold",
1395
- pendingTokens,
1396
- commitTokenThreshold: cfg.commitTokenThreshold,
1397
- senderIdFound: sender.found,
1398
- senderId: sender.senderId ?? null,
1399
- });
1400
- return;
1401
- }
1402
-
1403
- const commitResult = await client.commitSession(OVSessionId, {
1404
- wait: false,
1405
- agentId,
1406
- keepRecentCount: cfg.commitKeepRecentCount,
1407
- });
1408
- logger.info(
1409
- `openviking: committed session=${OVSessionId}, ` +
1410
- `status=${commitResult.status}, archived=${commitResult.archived ?? false}, ` +
1411
- `task_id=${commitResult.task_id ?? "none"}, trace_id=${commitResult.trace_id ?? "none"}`,
1412
- );
1413
-
1414
- diag("afterTurn_commit", OVSessionId, {
1415
- pendingTokens,
1416
- commitTokenThreshold: cfg.commitTokenThreshold,
1417
- status: commitResult.status,
1418
- archived: commitResult.archived ?? false,
1419
- taskId: commitResult.task_id ?? null,
1420
- extractedMemories: totalExtractedMemories(commitResult.memories_extracted),
1421
- senderIdFound: sender.found,
1422
- senderId: sender.senderId ?? null,
1423
- });
1424
- if (commitResult.task_id && cfg.logFindRequests) {
1425
- logger.info(
1426
- `openviking: Phase2 memory extraction runs asynchronously on the server (task_id=${commitResult.task_id}). ` +
1427
- "memories_extracted appears only after that task completes — not in this immediate response.",
1428
- );
1429
- void pollPhase2ExtractionOutcome(
1430
- getClient,
1431
- commitResult.task_id,
1432
- agentId,
1433
- logger,
1434
- OVSessionId,
1435
- );
1436
- }
1437
- } catch (err) {
1438
- logger.warn?.(`openviking: afterTurn failed: ${String(err)}`);
1439
- const sender = extractRuntimeSenderId(afterTurnParams.runtimeContext);
1440
- diag("afterTurn_error", afterTurnParams.sessionId ?? "(unknown)", {
1441
- error: String(err),
1442
- senderIdFound: sender.found,
1443
- senderId: sender.senderId ?? null,
1444
- });
1445
- }
1446
- },
1447
-
1448
- async compact(compactParams): Promise<CompactResult> {
1449
- const { sessionKey, ovSessionId: OVSessionId } = resolveSessionIdentity(compactParams);
1450
- const tokenBudget = validTokenBudget(compactParams.tokenBudget) ?? 128_000;
1451
- diag("compact_entry", OVSessionId, {
1452
- tokenBudget,
1453
- force: compactParams.force ?? false,
1454
- currentTokenCount: compactParams.currentTokenCount ?? null,
1455
- compactionTarget: compactParams.compactionTarget ?? null,
1456
- hasCustomInstructions: typeof compactParams.customInstructions === "string" &&
1457
- compactParams.customInstructions.trim().length > 0,
1458
- });
1459
-
1460
- if (isBypassedSession({ sessionId: compactParams.sessionId, sessionKey })) {
1461
- diag("compact_result", OVSessionId, {
1462
- ok: true,
1463
- compacted: false,
1464
- reason: "session_bypassed",
1465
- });
1466
- return {
1467
- ok: true,
1468
- compacted: false,
1469
- reason: "session_bypassed",
1470
- };
1471
- }
1472
-
1473
- const client = await getClient();
1474
- const agentId = resolveAgentId(compactParams.sessionId, sessionKey, OVSessionId);
1475
- const tokensBeforeOriginal = validTokenBudget(compactParams.currentTokenCount);
1476
- let preCommitEstimatedTokens: number | undefined;
1477
- if (typeof tokensBeforeOriginal !== "number") {
1478
- try {
1479
- const preCtx = await client.getSessionContext(OVSessionId, tokenBudget, agentId);
1480
- if (
1481
- typeof preCtx.estimatedTokens === "number" &&
1482
- Number.isFinite(preCtx.estimatedTokens)
1483
- ) {
1484
- preCommitEstimatedTokens = preCtx.estimatedTokens;
1485
- }
1486
- } catch (preCtxErr) {
1487
- logger.info(
1488
- `openviking: compact pre-ctx fetch failed for session=${OVSessionId}, ` +
1489
- `tokenBudget=${tokenBudget}, agentId=${agentId}: ${String(preCtxErr)}`,
1490
- );
1491
- }
1492
- }
1493
-
1494
- const tokensBefore = tokensBeforeOriginal ?? preCommitEstimatedTokens ?? -1;
1495
-
1496
- try {
1497
- logger.info(
1498
- `openviking: compact committing session=${OVSessionId} (wait=true, tokenBudget=${tokenBudget})`,
1499
- );
1500
- const commitResult = await client.commitSession(OVSessionId, {
1501
- wait: true,
1502
- agentId,
1503
- keepRecentCount: 0,
1504
- });
1505
- const memCount = totalExtractedMemories(commitResult.memories_extracted);
1506
-
1507
- if (commitResult.status === "failed") {
1508
- logger.warn?.(
1509
- `openviking: compact commit Phase 2 failed for session=${OVSessionId}: ${commitResult.error ?? "unknown"}`,
1510
- );
1511
- diag("compact_result", OVSessionId, {
1512
- ok: false,
1513
- compacted: false,
1514
- reason: "commit_failed",
1515
- status: commitResult.status,
1516
- archived: commitResult.archived ?? false,
1517
- taskId: commitResult.task_id ?? null,
1518
- error: commitResult.error ?? null,
1519
- });
1520
- return {
1521
- ok: false,
1522
- compacted: false,
1523
- reason: "commit_failed",
1524
- result: {
1525
- summary: "",
1526
- firstKeptEntryId: "",
1527
- tokensBefore: tokensBefore,
1528
- tokensAfter: undefined,
1529
- details: {
1530
- commit: commitResult,
1531
- },
1532
- },
1533
- };
1534
- }
1535
-
1536
- if (commitResult.status === "timeout") {
1537
- logger.warn?.(
1538
- `openviking: compact commit Phase 2 timed out for session=${OVSessionId}, task_id=${commitResult.task_id ?? "none"}`,
1539
- );
1540
- diag("compact_result", OVSessionId, {
1541
- ok: false,
1542
- compacted: false,
1543
- reason: "commit_timeout",
1544
- status: commitResult.status,
1545
- archived: commitResult.archived ?? false,
1546
- taskId: commitResult.task_id ?? null,
1547
- });
1548
- return {
1549
- ok: false,
1550
- compacted: false,
1551
- reason: "commit_timeout",
1552
- result: {
1553
- summary: "",
1554
- firstKeptEntryId: "",
1555
- tokensBefore: tokensBefore,
1556
- tokensAfter: undefined,
1557
- details: {
1558
- commit: commitResult,
1559
- },
1560
- },
1561
- };
1562
- }
1563
-
1564
- logger.info(
1565
- `openviking: compact committed session=${OVSessionId}, archived=${commitResult.archived ?? false}, memories=${memCount}, task_id=${commitResult.task_id ?? "none"}, trace_id=${commitResult.trace_id ?? "none"}`,
1566
- );
1567
-
1568
- if (!commitResult.archived) {
1569
- logger.info(
1570
- `openviking: compact no archive for session=${OVSessionId}, ` +
1571
- `tokensBefore=${tokensBefore}, tokensAfter=${tokensBefore}`,
1572
- );
1573
- diag("compact_result", OVSessionId, {
1574
- ok: true,
1575
- compacted: false,
1576
- reason: "commit_no_archive",
1577
- status: commitResult.status,
1578
- archived: commitResult.archived ?? false,
1579
- taskId: commitResult.task_id ?? null,
1580
- memories: memCount,
1581
- tokensBefore: tokensBefore,
1582
- });
1583
- return {
1584
- ok: true,
1585
- compacted: false,
1586
- reason: "commit_no_archive",
1587
- result: {
1588
- summary: "",
1589
- tokensBefore: tokensBefore,
1590
- tokensAfter: tokensBefore >= 0 ? tokensBefore : undefined,
1591
- details: {
1592
- commit: commitResult,
1593
- },
1594
- },
1595
- };
1596
- }
1597
-
1598
- let summary = "";
1599
- let firstKeptEntryId = commitResult.archive_uri?.split("/").pop() ?? "";
1600
- let tokensAfter: number | undefined;
1601
- let contextFetchError: string | undefined;
1602
-
1603
- let ctx: Awaited<ReturnType<typeof client.getSessionContext>> | undefined;
1604
- try {
1605
- ctx = await client.getSessionContext(OVSessionId, tokenBudget, agentId);
1606
- // 打印完整的 getSessionContext 结果
1607
- logger.info(
1608
- `openviking: compact getSessionContext raw result for ${OVSessionId}: ` +
1609
- JSON.stringify(ctx, null, 2),
1610
- );
1611
- if (typeof ctx.latest_archive_overview === "string") {
1612
- summary = ctx.latest_archive_overview.trim();
1613
- }
1614
- if (
1615
- typeof ctx.estimatedTokens === "number" &&
1616
- Number.isFinite(ctx.estimatedTokens)
1617
- ) {
1618
- tokensAfter = ctx.estimatedTokens;
1619
- }
1620
- // 打印 compact 后重新写入 session 的完整内容
1621
- logger.info(
1622
- `openviking: compact restored session content for ${OVSessionId}: ` +
1623
- `messages=${ctx.messages?.length ?? 0}, ` +
1624
- `latestArchiveOverview=${summary.length > 0 ? "present" : "empty"} (${summary.length} chars), ` +
1625
- `preArchiveAbstracts=${ctx.pre_archive_abstracts?.length ?? 0}, ` +
1626
- `estimatedTokens=${ctx.estimatedTokens}`,
1627
- );
1628
- if (summary.length > 0) {
1629
- logger.info(
1630
- `openviking: compact latest_archive_overview for ${OVSessionId}: ${summary.substring(0, 200)}...`,
1631
- );
1632
- }
1633
- if (ctx.messages && ctx.messages.length > 0) {
1634
- // 打印所有消息的 role 和 content 摘要
1635
- const msgSummary = ctx.messages.map((m: { role?: string; content?: string; parts?: Array<{ type?: string; text?: string }> }) => {
1636
- const role = m.role ?? "unknown";
1637
- let textPreview = "";
1638
- if (m.content) {
1639
- textPreview = m.content.substring(0, 80);
1640
- } else if (m.parts && m.parts.length > 0) {
1641
- const textPart = m.parts.find((p: { type?: string }) => p.type === "text");
1642
- textPreview = textPart?.text?.substring(0, 80) ?? JSON.stringify(m.parts).substring(0, 80);
1643
- }
1644
- return { role, textPreview };
1645
- });
1646
- logger.info(
1647
- `openviking: compact restored messages for ${OVSessionId}: ` +
1648
- JSON.stringify(msgSummary),
1649
- );
1650
- }
1651
- } catch (ctxErr) {
1652
- contextFetchError = String(ctxErr);
1653
- logger.info(
1654
- `openviking: compact context fetch failed for session=${OVSessionId}, ` +
1655
- `tokenBudget=${tokenBudget}, agentId=${agentId}: ${contextFetchError}`,
1656
- );
1657
- }
1658
-
1659
- logger.info(
1660
- `openviking: compact tokens session=${OVSessionId}, ` +
1661
- `tokensBefore=${tokensBefore}, tokensAfter=${tokensAfter ?? "unknown"}, ` +
1662
- `latestArchiveId=${firstKeptEntryId || "none"}`,
1663
- );
1664
-
1665
- diag("compact_result", OVSessionId, {
1666
- ok: true,
1667
- compacted: true,
1668
- reason: "commit_completed",
1669
- status: commitResult.status,
1670
- archived: commitResult.archived ?? false,
1671
- taskId: commitResult.task_id ?? null,
1672
- memories: memCount,
1673
- tokensBefore: tokensBefore,
1674
- tokensAfter: tokensAfter ?? null,
1675
- latestArchiveId: firstKeptEntryId || null,
1676
- summaryPresent: summary.length > 0,
1677
- });
1678
- return {
1679
- ok: true,
1680
- compacted: true,
1681
- reason: "commit_completed",
1682
- result: {
1683
- summary,
1684
- firstKeptEntryId,
1685
- tokensBefore,
1686
- tokensAfter,
1687
- details: contextFetchError
1688
- ? {
1689
- commit: commitResult,
1690
- contextError: contextFetchError,
1691
- }
1692
- : {
1693
- commit: commitResult,
1694
- },
1695
- },
1696
- };
1697
- } catch (err) {
1698
- const errorMessage = String(err);
1699
- if (errorMessage.includes("[NOT_FOUND]") && errorMessage.includes("Session not found")) {
1700
- logger.info(
1701
- `openviking: compact skipped because OV session does not exist ` +
1702
- `(session=${OVSessionId}, agentId=${agentId})`,
1703
- );
1704
- diag("compact_result", OVSessionId, {
1705
- ok: true,
1706
- compacted: false,
1707
- reason: "session_not_found",
1708
- error: errorMessage,
1709
- });
1710
- return {
1711
- ok: true,
1712
- compacted: false,
1713
- reason: "session_not_found",
1714
- };
1715
- }
1716
- logger.warn?.(`openviking: compact commit failed for session=${OVSessionId}: ${errorMessage}`);
1717
- diag("compact_error", OVSessionId, {
1718
- error: errorMessage,
1719
- });
1720
- return {
1721
- ok: false,
1722
- compacted: false,
1723
- reason: "commit_error",
1724
- result: {
1725
- summary: "",
1726
- firstKeptEntryId: "",
1727
- tokensBefore: tokensBefore,
1728
- tokensAfter: undefined,
1729
- details: {
1730
- error: errorMessage,
1731
- },
1732
- },
1733
- };
1734
- }
1735
- },
1736
- };
1737
- }
1
+ import { createHash } from "node:crypto";
2
+ import { DEFAULT_PHASE2_POLL_TIMEOUT_MS } from "./client.js";
3
+ import type { OpenVikingClient, OVMessage } from "./client.js";
4
+ import type { MemoryOpenVikingConfig } from "./config.js";
5
+ import {
6
+ AUTO_RECALL_SOURCE_MARKER,
7
+ buildAutoRecallContext,
8
+ prepareRecallQuery,
9
+ } from "./auto-recall.js";
10
+ import {
11
+ compileSessionPatterns,
12
+ getCaptureDecision,
13
+ extractNewTurnMessages,
14
+ shouldBypassSession,
15
+ } from "./text-utils.js";
16
+ import {
17
+ trimForLog,
18
+ toJsonLog,
19
+ } from "./memory-ranking.js";
20
+ import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
21
+
22
+ type AgentMessage = {
23
+ role?: string;
24
+ content?: unknown;
25
+ timestamp?: unknown;
26
+ };
27
+
28
+ type ExtractedTurnMessage = ReturnType<typeof extractNewTurnMessages>["messages"][number];
29
+
30
+ type ContextEngineInfo = {
31
+ id: string;
32
+ name: string;
33
+ version?: string;
34
+ ownsCompaction: true;
35
+ };
36
+
37
+ type AssembleResult = {
38
+ messages: AgentMessage[];
39
+ estimatedTokens: number;
40
+ systemPromptAddition?: string;
41
+ };
42
+
43
+ type IngestResult = {
44
+ ingested: boolean;
45
+ };
46
+
47
+ export function toRoleId(senderId: string | undefined): string | undefined {
48
+ if (!senderId) {
49
+ return undefined;
50
+ }
51
+ const normalized = senderId
52
+ .trim()
53
+ .replace(/[^a-zA-Z0-9_-]+/g, "_")
54
+ .replace(/^_+|_+$/g, "")
55
+ .replace(/_+/g, "_");
56
+ return normalized || undefined;
57
+ }
58
+
59
+ type IngestBatchResult = {
60
+ ingestedCount: number;
61
+ };
62
+
63
+ type CompactResult = {
64
+ ok: boolean;
65
+ compacted: boolean;
66
+ reason?: string;
67
+ result?: {
68
+ summary?: string;
69
+ firstKeptEntryId?: string;
70
+ tokensBefore: number;
71
+ tokensAfter?: number;
72
+ details?: unknown;
73
+ };
74
+ };
75
+
76
+ type ContextEngine = {
77
+ info: ContextEngineInfo;
78
+ ingest: (params: { sessionId: string; message: AgentMessage; isHeartbeat?: boolean }) => Promise<IngestResult>;
79
+ ingestBatch?: (params: {
80
+ sessionId: string;
81
+ messages: AgentMessage[];
82
+ isHeartbeat?: boolean;
83
+ }) => Promise<IngestBatchResult>;
84
+ afterTurn?: (params: {
85
+ sessionId: string;
86
+ sessionFile: string;
87
+ messages: AgentMessage[];
88
+ prePromptMessageCount: number;
89
+ autoCompactionSummary?: string;
90
+ isHeartbeat?: boolean;
91
+ tokenBudget?: number;
92
+ runtimeContext?: Record<string, unknown>;
93
+ sessionKey?: string;
94
+ }) => Promise<void>;
95
+ assemble: (params: {
96
+ sessionId: string;
97
+ sessionKey?: string;
98
+ messages: AgentMessage[];
99
+ prompt?: string;
100
+ tokenBudget?: number;
101
+ runtimeContext?: Record<string, unknown>;
102
+ }) => Promise<AssembleResult>;
103
+ compact: (params: {
104
+ sessionId: string;
105
+ sessionKey?: string;
106
+ sessionFile: string;
107
+ tokenBudget?: number;
108
+ force?: boolean;
109
+ currentTokenCount?: number;
110
+ compactionTarget?: "budget" | "threshold";
111
+ customInstructions?: string;
112
+ runtimeContext?: Record<string, unknown>;
113
+ }) => Promise<CompactResult>;
114
+ };
115
+
116
+ export type ContextEngineWithCommit = ContextEngine & {
117
+ /** Commit (archive + extract) the OV session. Returns true on success. */
118
+ commitOVSession: (params: {
119
+ sessionId: string;
120
+ sessionKey?: string;
121
+ runtimeContext?: Record<string, unknown>;
122
+ }) => Promise<boolean>;
123
+ };
124
+
125
+ type Logger = {
126
+ info: (msg: string) => void;
127
+ warn?: (msg: string) => void;
128
+ error: (msg: string) => void;
129
+ };
130
+
131
+ type ExtractedSender = {
132
+ found: boolean;
133
+ senderId?: string;
134
+ };
135
+
136
+ interface ContextBudgets {
137
+ archiveMemory: number;
138
+ sessionContext: number;
139
+ reserved: number;
140
+ }
141
+
142
+ const BUDGET_UNLIMITED = -1;
143
+ const ARCHIVE_BUDGET_RATIO = 0.15;
144
+ const ARCHIVE_BUDGET_CAP = 8_000;
145
+ const RESERVED_MIN = 20_000;
146
+ const RESERVED_RATIO = 0.15;
147
+ const ARCHIVE_INDEX_TRIM_LIMIT = 10;
148
+
149
+ function allocateContextBudget(totalBudget: number, instructionTokens = 0): ContextBudgets {
150
+ const reserveFloor = totalBudget >= RESERVED_MIN * 2 ? RESERVED_MIN : 0;
151
+ const reserved = Math.min(totalBudget, Math.max(totalBudget * RESERVED_RATIO, reserveFloor));
152
+ const usableBudget = Math.max(totalBudget - reserved - instructionTokens, 0);
153
+ const archiveMemory = Math.min(usableBudget * ARCHIVE_BUDGET_RATIO, ARCHIVE_BUDGET_CAP);
154
+ const sessionContext = Math.max(usableBudget - archiveMemory, 0);
155
+ return { archiveMemory, sessionContext, reserved };
156
+ }
157
+
158
+ function roughEstimate(messages: AgentMessage[]): number {
159
+ return Math.ceil(JSON.stringify(messages).length / 4);
160
+ }
161
+
162
+ function msgTokenEstimate(msg: AgentMessage): number {
163
+ const raw = (msg as Record<string, unknown>).content;
164
+ if (typeof raw === "string") return Math.ceil(raw.length / 4);
165
+ if (Array.isArray(raw)) return Math.ceil(JSON.stringify(raw).length / 4);
166
+ return 1;
167
+ }
168
+
169
+ function normalizeTimestamp(value: unknown): string | undefined {
170
+ if (typeof value === "number" && Number.isFinite(value)) {
171
+ const timestampMs = Math.abs(value) < 100_000_000_000 ? value * 1000 : value;
172
+ return new Date(timestampMs).toISOString();
173
+ }
174
+ return undefined;
175
+ }
176
+
177
+ function pickLatestCreatedAt(messages: AgentMessage[]): string | undefined {
178
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
179
+ const message = messages[i] as Record<string, unknown>;
180
+ const role = typeof message.role === "string" ? message.role : "";
181
+ if (!role || role === "system") {
182
+ continue;
183
+ }
184
+ const normalized = normalizeTimestamp(message.timestamp);
185
+ if (normalized) {
186
+ return normalized;
187
+ }
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ function messageDigest(messages: AgentMessage[], maxCharsPerMsg = 2000): Array<{role: string; content: string; tokens: number; truncated: boolean}> {
193
+ return messages.map((msg) => {
194
+ const m = msg as Record<string, unknown>;
195
+ const role = String(m.role ?? "unknown");
196
+ const raw = m.content;
197
+ let text: string;
198
+ if (typeof raw === "string") {
199
+ text = raw;
200
+ } else if (Array.isArray(raw)) {
201
+ text = (raw as Record<string, unknown>[])
202
+ .map((b) => {
203
+ if (b.type === "text") return String(b.text ?? "");
204
+ if (b.type === "toolCall") return `[toolCall: ${String(b.name)}(${JSON.stringify(b.arguments ?? {}).slice(0, 200)})]`;
205
+ if (b.type === "toolResult") return `[toolResult: ${JSON.stringify(b.content ?? "").slice(0, 200)}]`;
206
+ return `[${String(b.type)}]`;
207
+ })
208
+ .join("\n");
209
+ } else {
210
+ text = JSON.stringify(raw) ?? "";
211
+ }
212
+ const truncated = text.length > maxCharsPerMsg;
213
+ return {
214
+ role,
215
+ content: truncated ? text.slice(0, maxCharsPerMsg) + "..." : text,
216
+ tokens: msgTokenEstimate(msg),
217
+ truncated,
218
+ };
219
+ });
220
+ }
221
+
222
+ function extractAgentMessageText(message: AgentMessage | undefined): string {
223
+ if (!message) {
224
+ return "";
225
+ }
226
+ const raw = message.content;
227
+ if (typeof raw === "string") {
228
+ return raw;
229
+ }
230
+ if (Array.isArray(raw)) {
231
+ return raw
232
+ .map((block) => {
233
+ if (!block || typeof block !== "object") {
234
+ return "";
235
+ }
236
+ const b = block as Record<string, unknown>;
237
+ if (b.type === "text" && typeof b.text === "string") {
238
+ return b.text;
239
+ }
240
+ return "";
241
+ })
242
+ .filter(Boolean)
243
+ .join("\n");
244
+ }
245
+ return "";
246
+ }
247
+
248
+ function hasAutoRecallBlock(message: AgentMessage | undefined): boolean {
249
+ return extractAgentMessageText(message).includes(AUTO_RECALL_SOURCE_MARKER);
250
+ }
251
+
252
+ function prependTextToMessageContent(content: unknown, text: string): unknown {
253
+ if (typeof content === "string") {
254
+ return `${text}\n\n${content}`;
255
+ }
256
+ if (Array.isArray(content)) {
257
+ if (content.length === 0) {
258
+ return [{ type: "text", text }];
259
+ }
260
+ const first = content[0];
261
+ if (
262
+ first &&
263
+ typeof first === "object" &&
264
+ (first as Record<string, unknown>).type === "text" &&
265
+ typeof (first as Record<string, unknown>).text === "string"
266
+ ) {
267
+ return [
268
+ {
269
+ ...(first as Record<string, unknown>),
270
+ text: `${text}\n\n${(first as Record<string, unknown>).text as string}`,
271
+ },
272
+ ...content.slice(1),
273
+ ];
274
+ }
275
+ return [{ type: "text", text }, ...content];
276
+ }
277
+ return text;
278
+ }
279
+
280
+ function prependRecallToLatestUserMessage(messages: AgentMessage[], recallBlock: string): AgentMessage[] {
281
+ const latest = messages.at(-1);
282
+ if (!latest || latest.role !== "user" || hasAutoRecallBlock(latest)) {
283
+ return messages;
284
+ }
285
+ return [
286
+ ...messages.slice(0, -1),
287
+ {
288
+ ...latest,
289
+ content: prependTextToMessageContent(latest.content, recallBlock),
290
+ },
291
+ ];
292
+ }
293
+
294
+ function emitDiag(log: Logger, stage: string, sessionId: string, data: Record<string, unknown>, enabled = true): void {
295
+ if (!enabled) return;
296
+ log.info(`openviking: diag ${JSON.stringify({ ts: Date.now(), stage, sessionId, data })}`);
297
+ }
298
+
299
+ function totalExtractedMemories(memories?: Record<string, number>): number {
300
+ if (!memories || typeof memories !== "object") {
301
+ return 0;
302
+ }
303
+ return Object.values(memories).reduce((sum, count) => sum + (count ?? 0), 0);
304
+ }
305
+
306
+ function validTokenBudget(raw: unknown): number | undefined {
307
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
308
+ return raw;
309
+ }
310
+ return undefined;
311
+ }
312
+
313
+ function extractRuntimeSenderId(
314
+ runtimeContext: Record<string, unknown> | undefined,
315
+ ): ExtractedSender {
316
+ if (runtimeContext) {
317
+ const senderId = runtimeContext.senderId;
318
+ if (typeof senderId === "string") {
319
+ const trimmed = senderId.trim();
320
+ if (trimmed) {
321
+ return {
322
+ found: true,
323
+ senderId: trimmed,
324
+ };
325
+ }
326
+ }
327
+ }
328
+ return { found: false };
329
+ }
330
+
331
+ /** OpenClaw session UUID (path-safe on Windows). */
332
+ const OPENVIKING_OV_SESSION_UUID =
333
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
334
+
335
+ const WINDOWS_BAD_SESSION_SEGMENT = /[:<>"\\/|?\u0000-\u001f]/;
336
+
337
+ /**
338
+ * Map OpenClaw session identity to an OpenViking session_id that is safe as a single
339
+ * AGFS path segment on Windows (no `:` etc.). Prefer UUID sessionId when present;
340
+ * otherwise derive a stable sha256 from sessionKey.
341
+ */
342
+ export function openClawSessionToOvStorageId(
343
+ sessionId: string | undefined,
344
+ sessionKey: string | undefined,
345
+ ): string {
346
+ const sid = typeof sessionId === "string" ? sessionId.trim() : "";
347
+ const key = typeof sessionKey === "string" ? sessionKey.trim() : "";
348
+
349
+ if (sid && OPENVIKING_OV_SESSION_UUID.test(sid)) {
350
+ return sid.toLowerCase();
351
+ }
352
+ if (key) {
353
+ return createHash("sha256").update(key, "utf8").digest("hex");
354
+ }
355
+ if (sid) {
356
+ if (WINDOWS_BAD_SESSION_SEGMENT.test(sid)) {
357
+ return createHash("sha256").update(`openclaw-session:${sid}`, "utf8").digest("hex");
358
+ }
359
+ return sid;
360
+ }
361
+ throw new Error("openviking: need sessionId or sessionKey for OV session path");
362
+ }
363
+
364
+ /** Normalize a hook/tool session ref (uuid, sessionKey, or already-safe id) for OV storage. */
365
+ export function openClawSessionRefToOvStorageId(ref: string): string {
366
+ const t = ref.trim();
367
+ if (!t) {
368
+ throw new Error("openviking: empty session ref");
369
+ }
370
+ if (OPENVIKING_OV_SESSION_UUID.test(t)) {
371
+ return t.toLowerCase();
372
+ }
373
+ if (WINDOWS_BAD_SESSION_SEGMENT.test(t)) {
374
+ return createHash("sha256").update(t, "utf8").digest("hex");
375
+ }
376
+ return t;
377
+ }
378
+
379
+ /**
380
+ * Convert an OpenViking stored message (parts-based format) into one or more
381
+ * OpenClaw AgentMessages (content-blocks format).
382
+ *
383
+ * For assistant messages with ToolParts, this produces:
384
+ * 1. The assistant message with canonical toolCall blocks in its content array
385
+ * 2. A separate toolResult message per ToolPart (carrying tool_output)
386
+ */
387
+ export function convertToAgentMessages(msg: { role: string; parts: unknown[] }): AgentMessage[] {
388
+ const parts = msg.parts ?? [];
389
+ const contentBlocks: Record<string, unknown>[] = [];
390
+ const toolCallBlocks: Record<string, unknown>[] = [];
391
+ const toolResults: AgentMessage[] = [];
392
+
393
+ for (const part of parts) {
394
+ if (!part || typeof part !== "object") continue;
395
+ const p = part as Record<string, unknown>;
396
+
397
+ if (p.type === "text" && typeof p.text === "string") {
398
+ contentBlocks.push({ type: "text", text: p.text });
399
+ } else if (p.type === "context") {
400
+ if (typeof p.abstract === "string" && p.abstract) {
401
+ contentBlocks.push({ type: "text", text: p.abstract });
402
+ }
403
+ } else if (p.type === "tool") {
404
+ const toolId = typeof p.tool_id === "string" ? p.tool_id : "";
405
+ const toolName = typeof p.tool_name === "string" ? p.tool_name : undefined;
406
+ const status = typeof p.tool_status === "string" ? p.tool_status : "unknown";
407
+ const output = typeof p.tool_output === "string" ? p.tool_output : "";
408
+ const ref = typeof p.tool_output_ref === "string" ? p.tool_output_ref : "";
409
+ const originalChars = typeof p.tool_output_original_chars === "number"
410
+ ? p.tool_output_original_chars
411
+ : undefined;
412
+
413
+ if (toolId) {
414
+ // Structured path: emit canonical toolCall + toolResult pair (works for any role)
415
+ toolCallBlocks.push({
416
+ type: "toolCall",
417
+ id: toolId,
418
+ name: toolName ?? "unknown",
419
+ arguments: p.tool_input ?? {},
420
+ });
421
+
422
+ let resultText = (status === "completed" || status === "error")
423
+ ? (output || "(no output)")
424
+ : "(interrupted — tool did not complete)";
425
+ if (ref && !resultText.includes(ref)) {
426
+ const suffix = originalChars !== undefined
427
+ ? `\n[tool-result-ref] ${ref} original_chars=${originalChars}`
428
+ : `\n[tool-result-ref] ${ref}`;
429
+ resultText += suffix;
430
+ }
431
+ const resultPayload: Record<string, unknown> = {
432
+ role: "toolResult",
433
+ toolCallId: toolId,
434
+ content: [{ type: "text", text: resultText }],
435
+ isError: status === "error",
436
+ };
437
+ if (toolName) {
438
+ resultPayload.toolName = toolName;
439
+ }
440
+ toolResults.push(resultPayload as unknown as AgentMessage);
441
+ } else {
442
+ // No tool_id: degrade to text block
443
+ const fallbackName = toolName ?? "unknown";
444
+ const segments = [`[${fallbackName}] (${status})`];
445
+ if (p.tool_input) {
446
+ try {
447
+ segments.push(`Input: ${JSON.stringify(p.tool_input)}`);
448
+ } catch {
449
+ // non-serializable input, skip
450
+ }
451
+ }
452
+ if (output) {
453
+ segments.push(`Output: ${output}`);
454
+ }
455
+ contentBlocks.push({ type: "text", text: segments.join("\n") });
456
+ }
457
+ }
458
+ }
459
+
460
+ const result: AgentMessage[] = [];
461
+
462
+ if (msg.role === "assistant") {
463
+ // Assistant: text + toolCall in one message, then toolResults
464
+ result.push({ role: "assistant", content: [...contentBlocks, ...toolCallBlocks] });
465
+ result.push(...toolResults);
466
+ } else {
467
+ // Non-assistant: emit text as original role, then synthesize assistant(toolCall) + toolResult
468
+ const texts = contentBlocks
469
+ .filter((b) => b.type === "text")
470
+ .map((b) => b.text as string);
471
+ if (texts.length > 0) {
472
+ result.push({ role: msg.role, content: texts.join("\n") });
473
+ } else if (toolCallBlocks.length === 0) {
474
+ result.push({ role: msg.role, content: "" });
475
+ }
476
+ if (toolCallBlocks.length > 0) {
477
+ result.push({ role: "assistant", content: toolCallBlocks });
478
+ result.push(...toolResults);
479
+ }
480
+ }
481
+
482
+ return result;
483
+ }
484
+
485
+ function isToolOnlyMessage(msg: ExtractedTurnMessage): boolean {
486
+ return msg.role === "user" && msg.parts.length > 0 && msg.parts.every((part) => part.type === "tool");
487
+ }
488
+
489
+ function coalesceConsecutiveToolMessages(messages: ExtractedTurnMessage[]): ExtractedTurnMessage[] {
490
+ const result: ExtractedTurnMessage[] = [];
491
+ let pendingTools: ExtractedTurnMessage | undefined;
492
+
493
+ const flush = () => {
494
+ if (pendingTools) {
495
+ result.push(pendingTools);
496
+ pendingTools = undefined;
497
+ }
498
+ };
499
+
500
+ for (const msg of messages) {
501
+ if (isToolOnlyMessage(msg)) {
502
+ if (!pendingTools) {
503
+ pendingTools = { role: "user", parts: [] };
504
+ }
505
+ pendingTools.parts.push(...msg.parts);
506
+ continue;
507
+ }
508
+ flush();
509
+ result.push(msg);
510
+ }
511
+ flush();
512
+ return result;
513
+ }
514
+
515
+ function normalizeAssistantContent(messages: AgentMessage[]): void {
516
+ for (let i = 0; i < messages.length; i++) {
517
+ const msg = messages[i];
518
+ if (msg?.role === "assistant" && typeof msg.content === "string") {
519
+ messages[i] = {
520
+ ...msg,
521
+ content: [{ type: "text", text: msg.content }],
522
+ };
523
+ }
524
+ }
525
+ }
526
+
527
+ function canonicalizeAssistantBlock(block: unknown): unknown {
528
+ if (!block || typeof block !== "object") {
529
+ return block;
530
+ }
531
+
532
+ const rec = block as Record<string, unknown>;
533
+ const type = typeof rec.type === "string" ? rec.type : "";
534
+ if (type === "toolCall") {
535
+ if (rec.arguments !== undefined) {
536
+ return rec;
537
+ }
538
+ return {
539
+ ...rec,
540
+ arguments: rec.input ?? rec.toolInput ?? {},
541
+ };
542
+ }
543
+
544
+ if (type === "toolUse" || type === "functionCall" || type === "tool_call") {
545
+ return {
546
+ type: "toolCall",
547
+ id: rec.id ?? rec.toolCallId ?? rec.toolUseId,
548
+ name: rec.name,
549
+ arguments: rec.arguments ?? rec.input ?? rec.toolInput ?? {},
550
+ };
551
+ }
552
+
553
+ return rec;
554
+ }
555
+
556
+ function canonicalizeAgentMessages(messages: AgentMessage[]): AgentMessage[] {
557
+ let changed = false;
558
+ const next = messages.map((msg) => {
559
+ if (!msg || typeof msg !== "object") {
560
+ return msg;
561
+ }
562
+
563
+ if (msg.role === "assistant") {
564
+ const content = Array.isArray(msg.content)
565
+ ? msg.content.map((block) => canonicalizeAssistantBlock(block))
566
+ : typeof msg.content === "string"
567
+ ? [{ type: "text", text: msg.content }]
568
+ : msg.content;
569
+
570
+ if (content !== msg.content) {
571
+ changed = true;
572
+ return { ...msg, content };
573
+ }
574
+ return msg;
575
+ }
576
+
577
+ if (msg.role === "toolResult") {
578
+ const raw = msg as Record<string, unknown>;
579
+ const toolCallId =
580
+ (typeof raw.toolCallId === "string" && raw.toolCallId) ||
581
+ (typeof raw.toolUseId === "string" && raw.toolUseId) ||
582
+ undefined;
583
+ const toolName =
584
+ typeof raw.toolName === "string" && raw.toolName.trim()
585
+ ? raw.toolName.trim()
586
+ : undefined;
587
+
588
+ const nextMsg = {
589
+ ...msg,
590
+ ...(toolCallId ? { toolCallId } : {}),
591
+ ...(toolName ? { toolName } : {}),
592
+ } as AgentMessage;
593
+
594
+ if (nextMsg !== msg) {
595
+ changed = true;
596
+ }
597
+ return nextMsg;
598
+ }
599
+
600
+ return msg;
601
+ });
602
+
603
+ return changed ? next : messages;
604
+ }
605
+
606
+ export function formatMessageFaithful(msg: OVMessage): string {
607
+ const roleTag = `[${msg.role}]`;
608
+ if (!msg.parts || msg.parts.length === 0) {
609
+ return `${roleTag}: (empty)`;
610
+ }
611
+
612
+ const sections: string[] = [];
613
+ for (const part of msg.parts) {
614
+ if (!part || typeof part !== "object") continue;
615
+ switch (part.type) {
616
+ case "text":
617
+ if (part.text) sections.push(part.text);
618
+ break;
619
+ case "tool": {
620
+ const status = part.tool_status ?? "unknown";
621
+ const header = `[Tool: ${part.tool_name ?? "unknown"}] (${status})`;
622
+ const inputStr = part.tool_input
623
+ ? `Input: ${JSON.stringify(part.tool_input, null, 2)}`
624
+ : "";
625
+ const outputStr = part.tool_output ? `Output:\n${part.tool_output}` : "";
626
+ sections.push([header, inputStr, outputStr].filter(Boolean).join("\n"));
627
+ break;
628
+ }
629
+ case "context":
630
+ sections.push(
631
+ `[Context: ${part.uri ?? "?"}]${part.abstract ? ` ${part.abstract}` : ""}`,
632
+ );
633
+ break;
634
+ default:
635
+ sections.push(`[${part.type}]: ${JSON.stringify(part)}`);
636
+ }
637
+ }
638
+
639
+ return `${roleTag}:\n${sections.join("\n\n")}`;
640
+ }
641
+
642
+ function buildSystemPromptAddition(): string {
643
+ return [
644
+ "## Session Context Guide",
645
+ "",
646
+ "Your conversation history includes two layers:",
647
+ "",
648
+ "1. **[Session History Summary]** — A compressed summary of all prior turns",
649
+ " in this session. It is organized into structured sections (Key Facts,",
650
+ " Timeline, People, etc.). Use it for background and continuity.",
651
+ " The summary is lossy: specific details (exact dates, numbers, names,",
652
+ " small events) may have been compressed away.",
653
+ "",
654
+ "2. **Active messages** — The most recent uncompressed turns.",
655
+ "",
656
+ "**Rules:**",
657
+ "- When active messages conflict with the Summary, trust active messages",
658
+ " as the newer source of truth.",
659
+ "- Do not fabricate details the Summary does not state explicitly.",
660
+ "- **CRITICAL: Before answering 'no information' or 'not mentioned',",
661
+ " you MUST carefully re-read EVERY section of the [Session History Summary].",
662
+ " The answer may be expressed with different wording than the question.",
663
+ " Look for synonyms, related facts, and indirect references.**",
664
+ "- If the Summary mentions a topic but lacks the specific detail asked,",
665
+ " use the `ov_archive_search` tool to grep the original archived messages",
666
+ " for the exact detail. Try 2-3 different keywords extracted from the question.",
667
+ "- Only conclude information is unavailable AFTER both checking the Summary",
668
+ " thoroughly AND searching the archives with at least 2 keyword variations.",
669
+ ].join("\n");
670
+ }
671
+
672
+ function buildInstructionPrompt(): { text: string; tokens: number } {
673
+ const text = buildSystemPromptAddition();
674
+ return { text, tokens: Math.ceil(text.length / 4) };
675
+ }
676
+
677
+ function buildArchiveMemory(
678
+ archiveOverview: string | undefined,
679
+ _preAbstracts: Array<{ archive_id: string; abstract: string }>,
680
+ _budget: number,
681
+ ): { messages: AgentMessage[]; tokens: number } {
682
+ const messages: AgentMessage[] = [];
683
+
684
+ if (archiveOverview) {
685
+ messages.push({
686
+ role: "user",
687
+ content: `[Session History Summary]\n${archiveOverview}`,
688
+ });
689
+ }
690
+
691
+ return { messages, tokens: roughEstimate(messages) };
692
+ }
693
+
694
+ /** Merge consecutive assistant messages by concatenating their content arrays. */
695
+ export function mergeConsecutiveAssistants(messages: AgentMessage[]): AgentMessage[] {
696
+ const result: AgentMessage[] = [];
697
+ for (const msg of messages) {
698
+ const prev = result[result.length - 1];
699
+ if (msg.role === "assistant" && prev?.role === "assistant") {
700
+ const prevContent = Array.isArray(prev.content) ? prev.content : [{ type: "text", text: prev.content }];
701
+ const currContent = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }];
702
+ prev.content = [...prevContent, ...currContent] as typeof prev.content;
703
+ } else {
704
+ result.push({ ...msg });
705
+ }
706
+ }
707
+ return result;
708
+ }
709
+
710
+ /**
711
+ * Hoist tool_result blocks to the front of a content array.
712
+ *
713
+ * The Anthropic / Bedrock / Gemini APIs require tool_result blocks to appear
714
+ * at the START of a user message's content array (a tool_result must follow
715
+ * the assistant tool_use that produced it). When mergeConsecutiveUsers
716
+ * merges two user messages, the previous content's text blocks may end up
717
+ * before tool_results from the second message — this function fixes the order.
718
+ *
719
+ * Same pattern as Claude Code's hoistToolResults in src/utils/messages.ts.
720
+ */
721
+ function hoistToolResults<T>(content: T[]): T[] {
722
+ const toolResults: T[] = [];
723
+ const others: T[] = [];
724
+ for (const block of content) {
725
+ if (
726
+ block &&
727
+ typeof block === "object" &&
728
+ "type" in block &&
729
+ (block as { type?: string }).type === "tool_result"
730
+ ) {
731
+ toolResults.push(block);
732
+ } else {
733
+ others.push(block);
734
+ }
735
+ }
736
+ return [...toolResults, ...others];
737
+ }
738
+
739
+ /**
740
+ * Merge consecutive user messages by concatenating their content arrays.
741
+ *
742
+ * Mirror of mergeConsecutiveAssistants. Required because Gemini and Anthropic
743
+ * APIs reject consecutive same-role messages with stopReason=stop payloads=0
744
+ * (empty response). Three independent sources can inject role: "user":
745
+ *
746
+ * 1. Archive commit: "[Session History Summary]" via buildArchiveMemory
747
+ * 2. OpenClaw yield events: "[sessions_yield interrupt]" / "Turn yielded. ..."
748
+ * 3. Audio/Telegram metadata: "[Audio] User text: [Telegram <name>...]"
749
+ *
750
+ * Without merging, these can stack into 2-5 consecutive user turns. The
751
+ * 1P Anthropic API would merge server-side, but Bedrock/Gemini won't —
752
+ * we merge client-side for wire-format consistency.
753
+ *
754
+ * Note: this MUST run AFTER sanitizeToolUseResultPairing because that pass
755
+ * may strip orphaned tool_use / tool_result blocks and thereby create new
756
+ * user-user adjacencies that didn't exist in the input.
757
+ *
758
+ * Tracks issue #1724.
759
+ */
760
+ export function mergeConsecutiveUsers(messages: AgentMessage[]): AgentMessage[] {
761
+ const result: AgentMessage[] = [];
762
+ for (const msg of messages) {
763
+ const prev = result[result.length - 1];
764
+ if (msg.role === "user" && prev?.role === "user") {
765
+ const prevContent = Array.isArray(prev.content) ? prev.content : [{ type: "text", text: prev.content }];
766
+ const currContent = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }];
767
+ prev.content = hoistToolResults([...prevContent, ...currContent]) as typeof prev.content;
768
+ } else {
769
+ result.push({ ...msg });
770
+ }
771
+ }
772
+ return result;
773
+ }
774
+
775
+ /**
776
+ * Defensive role-alternation invariant check.
777
+ *
778
+ * After mergeConsecutiveUsers + mergeConsecutiveAssistants, the message stream
779
+ * should already alternate user/assistant. But sanitizeToolUseResultPairing
780
+ * can in rare cases strip a user_with_tool_result message that was the only
781
+ * thing separating two assistant messages, leaving an assistant-assistant
782
+ * adjacency that upstream merge passes can't fix.
783
+ *
784
+ * When detected, we insert a placeholder user message — matching Claude Code's
785
+ * NO_CONTENT_MESSAGE pattern (see CC src/utils/messages.ts:5375-5388) — to
786
+ * preserve the alternation contract that Gemini / Anthropic require.
787
+ */
788
+ export function ensureAlternation(messages: AgentMessage[]): AgentMessage[] {
789
+ const result: AgentMessage[] = [];
790
+ for (const msg of messages) {
791
+ const prev = result[result.length - 1];
792
+ if (prev && prev.role === "assistant" && msg.role === "assistant") {
793
+ result.push({
794
+ role: "user",
795
+ content: "(no content)",
796
+ });
797
+ }
798
+ result.push(msg);
799
+ }
800
+ return result;
801
+ }
802
+
803
+ function buildSessionContext(
804
+ ovMessages: OVMessage[],
805
+ budget: number,
806
+ ): { messages: AgentMessage[]; tokens: number } {
807
+ const raw = ovMessages.flatMap((m) => convertToAgentMessages(m));
808
+ const messages = mergeConsecutiveAssistants(raw);
809
+ const tokens = roughEstimate(messages);
810
+ if (budget === BUDGET_UNLIMITED || tokens <= budget) {
811
+ return { messages, tokens };
812
+ }
813
+ const trimmed = [...messages];
814
+ while (trimmed.length > 0 && roughEstimate(trimmed) > budget) {
815
+ trimmed.shift();
816
+ }
817
+ return { messages: trimmed, tokens: roughEstimate(trimmed) };
818
+ }
819
+
820
+ function sleep(ms: number): Promise<void> {
821
+ return new Promise((resolve) => setTimeout(resolve, ms));
822
+ }
823
+
824
+ const PHASE2_POLL_INTERVAL_MS = 800;
825
+ const PHASE2_POLL_MAX_MS = DEFAULT_PHASE2_POLL_TIMEOUT_MS;
826
+
827
+ /**
828
+ * After wait=false commit, Phase2 runs on the server. Poll task until completed/failed/timeout
829
+ * so logs show memories_extracted (otherwise it looks like "nothing was saved").
830
+ */
831
+ async function pollPhase2ExtractionOutcome(
832
+ getClient: () => Promise<OpenVikingClient>,
833
+ taskId: string,
834
+ agentId: string,
835
+ logger: Logger,
836
+ sessionLabel: string,
837
+ ): Promise<void> {
838
+ const deadline = Date.now() + PHASE2_POLL_MAX_MS;
839
+ try {
840
+ const client = await getClient();
841
+ while (Date.now() < deadline) {
842
+ await sleep(PHASE2_POLL_INTERVAL_MS);
843
+ const task = await client.getTask(taskId, agentId).catch((e) => {
844
+ logger.warn?.(`openviking: phase2 getTask failed task_id=${taskId}: ${String(e)}`);
845
+ return null;
846
+ });
847
+ if (!task) {
848
+ return;
849
+ }
850
+ const { status } = task;
851
+ if (status === "completed") {
852
+ logger.info(
853
+ `openviking: phase2 completed task_id=${taskId} session=${sessionLabel} ` +
854
+ `result=${toJsonLog(task.result ?? {})}`,
855
+ );
856
+ return;
857
+ }
858
+ if (status === "failed") {
859
+ logger.warn?.(
860
+ `openviking: phase2 failed task_id=${taskId} session=${sessionLabel} error=${task.error ?? "unknown"}`,
861
+ );
862
+ return;
863
+ }
864
+ }
865
+ logger.warn?.(
866
+ `openviking: phase2 poll timeout (${PHASE2_POLL_MAX_MS / 1000}s) task_id=${taskId} session=${sessionLabel} — ` +
867
+ `check GET /api/v1/tasks/${taskId}`,
868
+ );
869
+ } catch (e) {
870
+ logger.warn?.(`openviking: phase2 poll exception task_id=${taskId}: ${String(e)}`);
871
+ }
872
+ }
873
+
874
+ export function createMemoryOpenVikingContextEngine(params: {
875
+ id: string;
876
+ name: string;
877
+ version?: string;
878
+ cfg: Required<MemoryOpenVikingConfig>;
879
+ logger: Logger;
880
+ getClient: () => Promise<OpenVikingClient>;
881
+ /** Extra args help match hook-populated routing when OpenClaw provides sessionKey / OV session id. */
882
+ resolveAgentId: (sessionId: string, sessionKey?: string, ovSessionId?: string) => string;
883
+ rememberSessionAgentId?: (ctx: {
884
+ agentId?: string;
885
+ sessionId?: string;
886
+ sessionKey?: string;
887
+ ovSessionId?: string;
888
+ }) => void;
889
+ }): ContextEngineWithCommit {
890
+ const {
891
+ id,
892
+ name,
893
+ version,
894
+ cfg,
895
+ logger,
896
+ getClient,
897
+ resolveAgentId,
898
+ rememberSessionAgentId,
899
+ } = params;
900
+
901
+ const diagEnabled = cfg.emitStandardDiagnostics;
902
+ const bypassSessionPatterns = compileSessionPatterns(cfg.bypassSessionPatterns);
903
+ const diag = (stage: string, sessionId: string, data: Record<string, unknown>) =>
904
+ emitDiag(logger, stage, sessionId, data, diagEnabled);
905
+
906
+ const isBypassedSession = (params: { sessionId?: string; sessionKey?: string }): boolean =>
907
+ shouldBypassSession(params, bypassSessionPatterns);
908
+
909
+ async function doCommitOVSession(params: {
910
+ sessionId: string;
911
+ sessionKey?: string;
912
+ runtimeContext?: Record<string, unknown>;
913
+ }): Promise<boolean> {
914
+ const { sessionId } = params;
915
+ const { sessionKey, ovSessionId: ovId } = resolveSessionIdentity(params);
916
+ if (isBypassedSession({ sessionId, sessionKey })) {
917
+ logger.warn?.(
918
+ `openviking: commit skipped because session is bypassed (sessionId=${sessionId}, sessionKey=${sessionKey ?? "none"})`,
919
+ );
920
+ return false;
921
+ }
922
+ try {
923
+ const client = await getClient();
924
+ rememberSessionAgentId?.({
925
+ sessionId,
926
+ sessionKey,
927
+ ovSessionId: ovId,
928
+ });
929
+ const agentId = resolveAgentId(sessionId, sessionKey, ovId);
930
+ const commitResult = await client.commitSession(ovId, {
931
+ wait: true,
932
+ agentId,
933
+ keepRecentCount: 0,
934
+ });
935
+ const memCount = totalExtractedMemories(commitResult.memories_extracted);
936
+ if (commitResult.status === "failed") {
937
+ logger.warn?.(`openviking: commit Phase 2 failed for session=${sessionId}: ${commitResult.error ?? "unknown"}`);
938
+ return false;
939
+ }
940
+ if (commitResult.status === "timeout") {
941
+ logger.warn?.(`openviking: commit Phase 2 timed out for session=${sessionId}, task_id=${commitResult.task_id ?? "none"}`);
942
+ return false;
943
+ }
944
+ logger.info(
945
+ `openviking: committed OV session=${sessionId} ovId=${ovId}, archived=${commitResult.archived ?? false}, memories=${memCount}, task_id=${commitResult.task_id ?? "none"}, trace_id=${commitResult.trace_id ?? "none"}`,
946
+ );
947
+ return true;
948
+ } catch (err) {
949
+ logger.warn?.(`openviking: commit failed for session=${sessionId}: ${String(err)}`);
950
+ return false;
951
+ }
952
+ }
953
+
954
+ function extractSessionKey(runtimeContext: Record<string, unknown> | undefined): string | undefined {
955
+ if (!runtimeContext) {
956
+ return undefined;
957
+ }
958
+ const key = runtimeContext.sessionKey;
959
+ return typeof key === "string" && key.trim() ? key.trim() : undefined;
960
+ }
961
+
962
+ function resolveSessionKey(params: {
963
+ sessionKey?: string;
964
+ runtimeContext?: Record<string, unknown>;
965
+ }): string | undefined {
966
+ const direct = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
967
+ if (direct) {
968
+ return direct;
969
+ }
970
+ return extractSessionKey(params.runtimeContext);
971
+ }
972
+
973
+ function resolveSessionIdentity(params: {
974
+ sessionId: string;
975
+ sessionKey?: string;
976
+ runtimeContext?: Record<string, unknown>;
977
+ }): { sessionKey: string | undefined; ovSessionId: string } {
978
+ const sessionKey = resolveSessionKey(params);
979
+ return {
980
+ sessionKey,
981
+ ovSessionId: openClawSessionToOvStorageId(params.sessionId, sessionKey),
982
+ };
983
+ }
984
+
985
+ function extractRuntimeAgentId(
986
+ runtimeContext: Record<string, unknown> | undefined,
987
+ ): string | undefined {
988
+ if (!runtimeContext) {
989
+ return undefined;
990
+ }
991
+ const agentId = runtimeContext.agentId;
992
+ return typeof agentId === "string" && agentId.trim() ? agentId.trim() : undefined;
993
+ }
994
+
995
+ function assemblePassthrough(
996
+ ovSessionId: string,
997
+ reason: string,
998
+ liveMessages: AgentMessage[],
999
+ originalTokens: number,
1000
+ extra?: Record<string, unknown>,
1001
+ ): AssembleResult {
1002
+ diag("assemble_result", ovSessionId, {
1003
+ passthrough: true,
1004
+ reason,
1005
+ outputMessagesCount: liveMessages.length,
1006
+ inputTokenEstimate: originalTokens,
1007
+ estimatedTokens: originalTokens,
1008
+ tokensSaved: 0,
1009
+ savingPct: 0,
1010
+ ...extra,
1011
+ });
1012
+ return { messages: liveMessages, estimatedTokens: originalTokens };
1013
+ }
1014
+
1015
+ function buildAssembledContext(
1016
+ overview: string | undefined,
1017
+ preAbstracts: Array<{ archive_id: string; abstract: string }>,
1018
+ ovMessages: OVMessage[],
1019
+ tokenBudget: number,
1020
+ ovSessionId: string,
1021
+ ): {
1022
+ sanitized: AgentMessage[];
1023
+ archive: { messages: AgentMessage[]; tokens: number };
1024
+ session: { messages: AgentMessage[]; tokens: number };
1025
+ budgets: ContextBudgets;
1026
+ instruction: { text: string; tokens: number };
1027
+ } {
1028
+ const hasArchives = Boolean(overview) || preAbstracts.length > 0;
1029
+ const instruction = hasArchives ? buildInstructionPrompt() : { text: "", tokens: 0 };
1030
+
1031
+ // 4-layer context partitioning:
1032
+ // Instruction — system prompt guide (Archive Index / Session History usage)
1033
+ // Archive — session history summary + per-archive one-line abstracts
1034
+ // Session — active OV messages converted to AgentMessage format
1035
+ // Reserved — headroom for model output (not consumed here)
1036
+ const budgets = allocateContextBudget(tokenBudget, instruction.tokens);
1037
+ const archive = buildArchiveMemory(overview, preAbstracts, budgets.archiveMemory);
1038
+ const sessionBudget = Math.max(
1039
+ tokenBudget - budgets.reserved - instruction.tokens - archive.tokens,
1040
+ 0,
1041
+ );
1042
+ const session = buildSessionContext(ovMessages, sessionBudget);
1043
+ const assembled = [...archive.messages, ...session.messages];
1044
+
1045
+ logger.info(
1046
+ `openviking: assemble entering session content for ${ovSessionId}: ` +
1047
+ JSON.stringify(assembled.map((m) => ({
1048
+ role: m.role,
1049
+ content: typeof m.content === "string" ? m.content.substring(0, 100) : "[complex]",
1050
+ })), null, 2),
1051
+ );
1052
+
1053
+ normalizeAssistantContent(assembled);
1054
+ const canonical = canonicalizeAgentMessages(assembled);
1055
+ // Defense in depth (issue #1724):
1056
+ // 1) sanitizeToolUseResultPairing may strip orphaned tool_use/tool_result,
1057
+ // potentially creating new user-user or assistant-assistant adjacencies.
1058
+ // 2) mergeConsecutiveUsers fixes user-user (mirror of mergeConsecutiveAssistants
1059
+ // already running inside buildSessionContext).
1060
+ // 3) ensureAlternation is a final invariant check for the rare
1061
+ // assistant-assistant case that the merges can't reach.
1062
+ const sanitized = ensureAlternation(
1063
+ mergeConsecutiveUsers(
1064
+ sanitizeToolUseResultPairing(canonical as never[]) as AgentMessage[],
1065
+ ),
1066
+ );
1067
+
1068
+ return { sanitized, archive, session, budgets, instruction };
1069
+ }
1070
+
1071
+ return {
1072
+ info: {
1073
+ id,
1074
+ name,
1075
+ version,
1076
+ ownsCompaction: true,
1077
+ },
1078
+
1079
+ commitOVSession: doCommitOVSession,
1080
+
1081
+ // --- standard ContextEngine methods ---
1082
+
1083
+ async ingest(): Promise<IngestResult> {
1084
+ return { ingested: false };
1085
+ },
1086
+
1087
+ async ingestBatch(): Promise<IngestBatchResult> {
1088
+ return { ingestedCount: 0 };
1089
+ },
1090
+
1091
+ async assemble(assembleParams): Promise<AssembleResult> {
1092
+ const { messages } = assembleParams;
1093
+ const tokenBudget = validTokenBudget(assembleParams.tokenBudget) ?? 128_000;
1094
+ const { sessionKey, ovSessionId: OVSessionId } = resolveSessionIdentity(assembleParams);
1095
+ const sender = extractRuntimeSenderId(assembleParams.runtimeContext);
1096
+ const latestMessage = messages.at(-1);
1097
+ const isMainAssemble =
1098
+ Object.prototype.hasOwnProperty.call(assembleParams, "availableTools") ||
1099
+ Object.prototype.hasOwnProperty.call(assembleParams, "citationsMode") ||
1100
+ Object.prototype.hasOwnProperty.call(assembleParams, "prompt");
1101
+ const isTransformContextAssemble = !isMainAssemble;
1102
+
1103
+ const originalTokens = roughEstimate(messages);
1104
+
1105
+ rememberSessionAgentId?.({
1106
+ sessionId: assembleParams.sessionId,
1107
+ sessionKey,
1108
+ agentId: extractRuntimeAgentId(assembleParams.runtimeContext),
1109
+ ovSessionId: OVSessionId,
1110
+ });
1111
+ diag("assemble_entry", OVSessionId, {
1112
+ messagesCount: messages.length,
1113
+ inputTokenEstimate: originalTokens,
1114
+ tokenBudget,
1115
+ sessionKey: sessionKey ?? null,
1116
+ senderIdFound: sender.found,
1117
+ senderId: sender.senderId ?? null,
1118
+ messages: messageDigest(messages),
1119
+ });
1120
+
1121
+ if (isBypassedSession({ sessionId: assembleParams.sessionId, sessionKey })) {
1122
+ return assemblePassthrough(OVSessionId, "session_bypassed", messages, originalTokens);
1123
+ }
1124
+
1125
+ if (isTransformContextAssemble) {
1126
+ if (latestMessage?.role !== "user") {
1127
+ return assemblePassthrough(OVSessionId, "transform_context_non_user_tail", messages, originalTokens, {
1128
+ latestRole: latestMessage?.role ?? null,
1129
+ });
1130
+ }
1131
+ if (!cfg.autoRecall) {
1132
+ return assemblePassthrough(OVSessionId, "transform_context_auto_recall_disabled", messages, originalTokens);
1133
+ }
1134
+ if (hasAutoRecallBlock(latestMessage)) {
1135
+ return assemblePassthrough(OVSessionId, "transform_context_recall_already_injected", messages, originalTokens);
1136
+ }
1137
+
1138
+ const recallQuery = prepareRecallQuery(extractAgentMessageText(latestMessage));
1139
+ if (!recallQuery.query || recallQuery.query.length < 5) {
1140
+ return assemblePassthrough(OVSessionId, "transform_context_empty_recall_query", messages, originalTokens);
1141
+ }
1142
+ if (recallQuery.truncated) {
1143
+ logger.info(
1144
+ `openviking: recall query truncated (` +
1145
+ `chars=${recallQuery.originalChars}->${recallQuery.finalChars})`,
1146
+ );
1147
+ }
1148
+
1149
+ try {
1150
+ const client = await getClient();
1151
+ const routingRef = assembleParams.sessionId ?? sessionKey ?? OVSessionId;
1152
+ const agentId = resolveAgentId(routingRef, sessionKey, OVSessionId);
1153
+ const recall = await buildAutoRecallContext({
1154
+ cfg,
1155
+ client,
1156
+ agentId,
1157
+ queryText: recallQuery.query,
1158
+ logger,
1159
+ verbose: (message) => logger.info(message),
1160
+ });
1161
+
1162
+ if (!recall.block) {
1163
+ return assemblePassthrough(OVSessionId, "transform_context_no_recall_hits", messages, originalTokens, {
1164
+ memoryCount: recall.memoryCount,
1165
+ });
1166
+ }
1167
+
1168
+ const withRecall = prependRecallToLatestUserMessage(messages, recall.block);
1169
+ const estimatedTokens = roughEstimate(withRecall);
1170
+ diag("assemble_result", OVSessionId, {
1171
+ passthrough: false,
1172
+ phase: "transform_context",
1173
+ outputMessagesCount: withRecall.length,
1174
+ inputTokenEstimate: originalTokens,
1175
+ estimatedTokens,
1176
+ autoRecallMemoryCount: recall.memoryCount,
1177
+ autoRecallTokens: recall.estimatedTokens,
1178
+ messages: messageDigest(withRecall),
1179
+ });
1180
+ return { messages: withRecall, estimatedTokens };
1181
+ } catch (err) {
1182
+ logger.warn?.(`openviking: auto-recall failed: ${String(err)}`);
1183
+ return assemblePassthrough(OVSessionId, "transform_context_recall_failed", messages, originalTokens, {
1184
+ error: String(err),
1185
+ });
1186
+ }
1187
+ }
1188
+
1189
+ try {
1190
+ const client = await getClient();
1191
+ const routingRef = assembleParams.sessionId ?? sessionKey ?? OVSessionId;
1192
+ const agentId = resolveAgentId(routingRef, sessionKey, OVSessionId);
1193
+ const ctx = await client.getSessionContext(OVSessionId, tokenBudget, agentId);
1194
+
1195
+ const preAbstracts = ctx?.pre_archive_abstracts ?? [];
1196
+ const hasArchives = !!ctx?.latest_archive_overview || preAbstracts.length > 0;
1197
+ const activeCount = ctx?.messages?.length ?? 0;
1198
+
1199
+ if (!ctx || (!hasArchives && activeCount === 0)) {
1200
+ return assemblePassthrough(OVSessionId, "no_ov_data", messages, originalTokens, {
1201
+ archiveCount: 0, activeCount: 0,
1202
+ });
1203
+ }
1204
+ if (!hasArchives && ctx.messages.length < messages.length) {
1205
+ return assemblePassthrough(OVSessionId, "ov_msgs_fewer_than_input", messages, originalTokens, {
1206
+ archiveCount: 0, activeCount,
1207
+ });
1208
+ }
1209
+
1210
+ const { sanitized, archive, session, budgets, instruction } = buildAssembledContext(
1211
+ ctx.latest_archive_overview,
1212
+ preAbstracts,
1213
+ ctx.messages,
1214
+ tokenBudget,
1215
+ OVSessionId,
1216
+ );
1217
+
1218
+ if (sanitized.length === 0 && messages.length > 0) {
1219
+ return assemblePassthrough(OVSessionId, "sanitized_empty", messages, originalTokens, {
1220
+ archiveCount: preAbstracts.length, activeCount,
1221
+ });
1222
+ }
1223
+
1224
+ const assembledTokens = roughEstimate(sanitized) + instruction.tokens;
1225
+ const tokensSaved = originalTokens - assembledTokens;
1226
+ const savingPct = originalTokens > 0 ? Math.round((tokensSaved / originalTokens) * 100) : 0;
1227
+
1228
+ diag("assemble_result", OVSessionId, {
1229
+ passthrough: false,
1230
+ archiveCount: preAbstracts.length,
1231
+ activeCount,
1232
+ outputMessagesCount: sanitized.length,
1233
+ inputTokenEstimate: originalTokens,
1234
+ estimatedTokens: assembledTokens,
1235
+ tokensSaved,
1236
+ savingPct,
1237
+ archiveTokens: archive.tokens,
1238
+ archiveBudget: budgets.archiveMemory,
1239
+ sessionTokens: session.tokens,
1240
+ sessionBudget: budgets.sessionContext,
1241
+ reservedBudget: budgets.reserved,
1242
+ senderIdFound: sender.found,
1243
+ senderId: sender.senderId ?? null,
1244
+ messages: messageDigest(sanitized),
1245
+ });
1246
+
1247
+ return {
1248
+ messages: sanitized,
1249
+ estimatedTokens: assembledTokens,
1250
+ ...(instruction.text ? { systemPromptAddition: instruction.text } : {}),
1251
+ };
1252
+ } catch (err) {
1253
+ logger.warn?.(
1254
+ `openviking: assemble failed for session=${OVSessionId}, ` +
1255
+ `tokenBudget=${tokenBudget}, agentId=${resolveAgentId(OVSessionId)}: ${String(err)}`,
1256
+ );
1257
+ diag("assemble_error", OVSessionId, {
1258
+ error: String(err),
1259
+ tokenBudget,
1260
+ agentId: resolveAgentId(OVSessionId),
1261
+ senderIdFound: sender.found,
1262
+ senderId: sender.senderId ?? null,
1263
+ });
1264
+ return { messages, estimatedTokens: roughEstimate(messages) };
1265
+ }
1266
+ },
1267
+
1268
+ async afterTurn(afterTurnParams): Promise<void> {
1269
+ if (!cfg.autoCapture) {
1270
+ return;
1271
+ }
1272
+
1273
+ if (afterTurnParams.isHeartbeat) {
1274
+ return;
1275
+ }
1276
+
1277
+ try {
1278
+ const sender = extractRuntimeSenderId(afterTurnParams.runtimeContext);
1279
+ const { sessionKey, ovSessionId: OVSessionId } = resolveSessionIdentity(afterTurnParams);
1280
+ const runtimeAgentId = extractRuntimeAgentId(afterTurnParams.runtimeContext);
1281
+ if (runtimeAgentId) {
1282
+ rememberSessionAgentId?.({
1283
+ agentId: runtimeAgentId,
1284
+ sessionId: afterTurnParams.sessionId,
1285
+ sessionKey,
1286
+ ovSessionId: OVSessionId,
1287
+ });
1288
+ }
1289
+ const routingRef =
1290
+ afterTurnParams.sessionId ?? sessionKey ?? OVSessionId;
1291
+ const agentId = resolveAgentId(routingRef, sessionKey, OVSessionId);
1292
+
1293
+ if (isBypassedSession({ sessionId: afterTurnParams.sessionId, sessionKey })) {
1294
+ diag("afterTurn_skip", OVSessionId, {
1295
+ reason: "session_bypassed",
1296
+ totalMessages: afterTurnParams.messages?.length ?? 0,
1297
+ senderIdFound: sender.found,
1298
+ senderId: sender.senderId ?? null,
1299
+ });
1300
+ return;
1301
+ }
1302
+
1303
+ const messages = afterTurnParams.messages ?? [];
1304
+ if (messages.length === 0) {
1305
+ diag("afterTurn_skip", OVSessionId, {
1306
+ reason: "no_messages",
1307
+ totalMessages: 0,
1308
+ senderIdFound: sender.found,
1309
+ senderId: sender.senderId ?? null,
1310
+ });
1311
+ return;
1312
+ }
1313
+
1314
+ const start =
1315
+ typeof afterTurnParams.prePromptMessageCount === "number" &&
1316
+ afterTurnParams.prePromptMessageCount >= 0
1317
+ ? afterTurnParams.prePromptMessageCount
1318
+ : 0;
1319
+
1320
+ const { messages: extractedMessagesRaw, newCount } = extractNewTurnMessages(messages, start);
1321
+ const extractedMessages = coalesceConsecutiveToolMessages(extractedMessagesRaw);
1322
+
1323
+ if (extractedMessages.length === 0) {
1324
+ diag("afterTurn_skip", OVSessionId, {
1325
+ reason: "no_new_turn_messages",
1326
+ totalMessages: messages.length,
1327
+ prePromptMessageCount: start,
1328
+ senderIdFound: sender.found,
1329
+ senderId: sender.senderId ?? null,
1330
+ });
1331
+ return;
1332
+ }
1333
+
1334
+ const turnMessages = messages.slice(start) as AgentMessage[];
1335
+ const newMessages = turnMessages.filter((m: any) => {
1336
+ const r = (m as Record<string, unknown>).role as string;
1337
+ return r === "user" || r === "assistant";
1338
+ }) as AgentMessage[];
1339
+ const newMsgFull = messageDigest(newMessages);
1340
+ const newTurnTokens = newMsgFull.reduce((s, d) => s + d.tokens, 0);
1341
+
1342
+ diag("afterTurn_entry", OVSessionId, {
1343
+ totalMessages: messages.length,
1344
+ newMessageCount: newCount,
1345
+ prePromptMessageCount: start,
1346
+ newTurnTokens,
1347
+ senderIdFound: sender.found,
1348
+ senderId: sender.senderId ?? null,
1349
+ messages: newMsgFull,
1350
+ });
1351
+
1352
+ const client = await getClient();
1353
+ const createdAt = pickLatestCreatedAt(turnMessages);
1354
+ const senderRoleId = toRoleId(sender.senderId);
1355
+ // 发送结构化消息:统一 role 为 user,通过 parts 区分类型
1356
+ for (const msg of extractedMessages) {
1357
+ const ovParts = msg.parts.map((part) => {
1358
+ if (part.type === "text") {
1359
+ // 清理 relevant-memories 块
1360
+ const cleaned = part.text
1361
+ .replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ")
1362
+ .replace(/\s+/g, " ")
1363
+ .trim();
1364
+ return { type: "text" as const, text: cleaned };
1365
+ } else {
1366
+ return {
1367
+ type: "tool" as const,
1368
+ tool_id: part.toolCallId,
1369
+ tool_name: part.toolName,
1370
+ tool_input: part.toolInput,
1371
+ tool_output: part.toolOutput,
1372
+ tool_status: part.toolStatus,
1373
+ };
1374
+ }
1375
+ });
1376
+
1377
+ if (ovParts.length > 0) {
1378
+ await client.addSessionMessage(
1379
+ OVSessionId,
1380
+ msg.role, // 统一是 "user"
1381
+ ovParts,
1382
+ agentId,
1383
+ createdAt,
1384
+ msg.role === "user" ? senderRoleId : undefined,
1385
+ );
1386
+ }
1387
+ }
1388
+
1389
+ const session = await client.getSession(OVSessionId, agentId);
1390
+ const pendingTokens = session.pending_tokens ?? 0;
1391
+
1392
+ if (pendingTokens < cfg.commitTokenThreshold) {
1393
+ diag("afterTurn_skip", OVSessionId, {
1394
+ reason: "below_threshold",
1395
+ pendingTokens,
1396
+ commitTokenThreshold: cfg.commitTokenThreshold,
1397
+ senderIdFound: sender.found,
1398
+ senderId: sender.senderId ?? null,
1399
+ });
1400
+ return;
1401
+ }
1402
+
1403
+ const commitResult = await client.commitSession(OVSessionId, {
1404
+ wait: false,
1405
+ agentId,
1406
+ keepRecentCount: cfg.commitKeepRecentCount,
1407
+ });
1408
+ logger.info(
1409
+ `openviking: committed session=${OVSessionId}, ` +
1410
+ `status=${commitResult.status}, archived=${commitResult.archived ?? false}, ` +
1411
+ `task_id=${commitResult.task_id ?? "none"}, trace_id=${commitResult.trace_id ?? "none"}`,
1412
+ );
1413
+
1414
+ diag("afterTurn_commit", OVSessionId, {
1415
+ pendingTokens,
1416
+ commitTokenThreshold: cfg.commitTokenThreshold,
1417
+ status: commitResult.status,
1418
+ archived: commitResult.archived ?? false,
1419
+ taskId: commitResult.task_id ?? null,
1420
+ extractedMemories: totalExtractedMemories(commitResult.memories_extracted),
1421
+ senderIdFound: sender.found,
1422
+ senderId: sender.senderId ?? null,
1423
+ });
1424
+ if (commitResult.task_id && cfg.logFindRequests) {
1425
+ logger.info(
1426
+ `openviking: Phase2 memory extraction runs asynchronously on the server (task_id=${commitResult.task_id}). ` +
1427
+ "memories_extracted appears only after that task completes — not in this immediate response.",
1428
+ );
1429
+ void pollPhase2ExtractionOutcome(
1430
+ getClient,
1431
+ commitResult.task_id,
1432
+ agentId,
1433
+ logger,
1434
+ OVSessionId,
1435
+ );
1436
+ }
1437
+ } catch (err) {
1438
+ logger.warn?.(`openviking: afterTurn failed: ${String(err)}`);
1439
+ const sender = extractRuntimeSenderId(afterTurnParams.runtimeContext);
1440
+ diag("afterTurn_error", afterTurnParams.sessionId ?? "(unknown)", {
1441
+ error: String(err),
1442
+ senderIdFound: sender.found,
1443
+ senderId: sender.senderId ?? null,
1444
+ });
1445
+ }
1446
+ },
1447
+
1448
+ async compact(compactParams): Promise<CompactResult> {
1449
+ const { sessionKey, ovSessionId: OVSessionId } = resolveSessionIdentity(compactParams);
1450
+ const tokenBudget = validTokenBudget(compactParams.tokenBudget) ?? 128_000;
1451
+ diag("compact_entry", OVSessionId, {
1452
+ tokenBudget,
1453
+ force: compactParams.force ?? false,
1454
+ currentTokenCount: compactParams.currentTokenCount ?? null,
1455
+ compactionTarget: compactParams.compactionTarget ?? null,
1456
+ hasCustomInstructions: typeof compactParams.customInstructions === "string" &&
1457
+ compactParams.customInstructions.trim().length > 0,
1458
+ });
1459
+
1460
+ if (isBypassedSession({ sessionId: compactParams.sessionId, sessionKey })) {
1461
+ diag("compact_result", OVSessionId, {
1462
+ ok: true,
1463
+ compacted: false,
1464
+ reason: "session_bypassed",
1465
+ });
1466
+ return {
1467
+ ok: true,
1468
+ compacted: false,
1469
+ reason: "session_bypassed",
1470
+ };
1471
+ }
1472
+
1473
+ const client = await getClient();
1474
+ const agentId = resolveAgentId(compactParams.sessionId, sessionKey, OVSessionId);
1475
+ const tokensBeforeOriginal = validTokenBudget(compactParams.currentTokenCount);
1476
+ let preCommitEstimatedTokens: number | undefined;
1477
+ if (typeof tokensBeforeOriginal !== "number") {
1478
+ try {
1479
+ const preCtx = await client.getSessionContext(OVSessionId, tokenBudget, agentId);
1480
+ if (
1481
+ typeof preCtx.estimatedTokens === "number" &&
1482
+ Number.isFinite(preCtx.estimatedTokens)
1483
+ ) {
1484
+ preCommitEstimatedTokens = preCtx.estimatedTokens;
1485
+ }
1486
+ } catch (preCtxErr) {
1487
+ logger.info(
1488
+ `openviking: compact pre-ctx fetch failed for session=${OVSessionId}, ` +
1489
+ `tokenBudget=${tokenBudget}, agentId=${agentId}: ${String(preCtxErr)}`,
1490
+ );
1491
+ }
1492
+ }
1493
+
1494
+ const tokensBefore = tokensBeforeOriginal ?? preCommitEstimatedTokens ?? -1;
1495
+
1496
+ try {
1497
+ logger.info(
1498
+ `openviking: compact committing session=${OVSessionId} (wait=true, tokenBudget=${tokenBudget})`,
1499
+ );
1500
+ const commitResult = await client.commitSession(OVSessionId, {
1501
+ wait: true,
1502
+ agentId,
1503
+ keepRecentCount: 0,
1504
+ });
1505
+ const memCount = totalExtractedMemories(commitResult.memories_extracted);
1506
+
1507
+ if (commitResult.status === "failed") {
1508
+ logger.warn?.(
1509
+ `openviking: compact commit Phase 2 failed for session=${OVSessionId}: ${commitResult.error ?? "unknown"}`,
1510
+ );
1511
+ diag("compact_result", OVSessionId, {
1512
+ ok: false,
1513
+ compacted: false,
1514
+ reason: "commit_failed",
1515
+ status: commitResult.status,
1516
+ archived: commitResult.archived ?? false,
1517
+ taskId: commitResult.task_id ?? null,
1518
+ error: commitResult.error ?? null,
1519
+ });
1520
+ return {
1521
+ ok: false,
1522
+ compacted: false,
1523
+ reason: "commit_failed",
1524
+ result: {
1525
+ summary: "",
1526
+ firstKeptEntryId: "",
1527
+ tokensBefore: tokensBefore,
1528
+ tokensAfter: undefined,
1529
+ details: {
1530
+ commit: commitResult,
1531
+ },
1532
+ },
1533
+ };
1534
+ }
1535
+
1536
+ if (commitResult.status === "timeout") {
1537
+ logger.warn?.(
1538
+ `openviking: compact commit Phase 2 timed out for session=${OVSessionId}, task_id=${commitResult.task_id ?? "none"}`,
1539
+ );
1540
+ diag("compact_result", OVSessionId, {
1541
+ ok: false,
1542
+ compacted: false,
1543
+ reason: "commit_timeout",
1544
+ status: commitResult.status,
1545
+ archived: commitResult.archived ?? false,
1546
+ taskId: commitResult.task_id ?? null,
1547
+ });
1548
+ return {
1549
+ ok: false,
1550
+ compacted: false,
1551
+ reason: "commit_timeout",
1552
+ result: {
1553
+ summary: "",
1554
+ firstKeptEntryId: "",
1555
+ tokensBefore: tokensBefore,
1556
+ tokensAfter: undefined,
1557
+ details: {
1558
+ commit: commitResult,
1559
+ },
1560
+ },
1561
+ };
1562
+ }
1563
+
1564
+ logger.info(
1565
+ `openviking: compact committed session=${OVSessionId}, archived=${commitResult.archived ?? false}, memories=${memCount}, task_id=${commitResult.task_id ?? "none"}, trace_id=${commitResult.trace_id ?? "none"}`,
1566
+ );
1567
+
1568
+ if (!commitResult.archived) {
1569
+ logger.info(
1570
+ `openviking: compact no archive for session=${OVSessionId}, ` +
1571
+ `tokensBefore=${tokensBefore}, tokensAfter=${tokensBefore}`,
1572
+ );
1573
+ diag("compact_result", OVSessionId, {
1574
+ ok: true,
1575
+ compacted: false,
1576
+ reason: "commit_no_archive",
1577
+ status: commitResult.status,
1578
+ archived: commitResult.archived ?? false,
1579
+ taskId: commitResult.task_id ?? null,
1580
+ memories: memCount,
1581
+ tokensBefore: tokensBefore,
1582
+ });
1583
+ return {
1584
+ ok: true,
1585
+ compacted: false,
1586
+ reason: "commit_no_archive",
1587
+ result: {
1588
+ summary: "",
1589
+ tokensBefore: tokensBefore,
1590
+ tokensAfter: tokensBefore >= 0 ? tokensBefore : undefined,
1591
+ details: {
1592
+ commit: commitResult,
1593
+ },
1594
+ },
1595
+ };
1596
+ }
1597
+
1598
+ let summary = "";
1599
+ let firstKeptEntryId = commitResult.archive_uri?.split("/").pop() ?? "";
1600
+ let tokensAfter: number | undefined;
1601
+ let contextFetchError: string | undefined;
1602
+
1603
+ let ctx: Awaited<ReturnType<typeof client.getSessionContext>> | undefined;
1604
+ try {
1605
+ ctx = await client.getSessionContext(OVSessionId, tokenBudget, agentId);
1606
+ // 打印完整的 getSessionContext 结果
1607
+ logger.info(
1608
+ `openviking: compact getSessionContext raw result for ${OVSessionId}: ` +
1609
+ JSON.stringify(ctx, null, 2),
1610
+ );
1611
+ if (typeof ctx.latest_archive_overview === "string") {
1612
+ summary = ctx.latest_archive_overview.trim();
1613
+ }
1614
+ if (
1615
+ typeof ctx.estimatedTokens === "number" &&
1616
+ Number.isFinite(ctx.estimatedTokens)
1617
+ ) {
1618
+ tokensAfter = ctx.estimatedTokens;
1619
+ }
1620
+ // 打印 compact 后重新写入 session 的完整内容
1621
+ logger.info(
1622
+ `openviking: compact restored session content for ${OVSessionId}: ` +
1623
+ `messages=${ctx.messages?.length ?? 0}, ` +
1624
+ `latestArchiveOverview=${summary.length > 0 ? "present" : "empty"} (${summary.length} chars), ` +
1625
+ `preArchiveAbstracts=${ctx.pre_archive_abstracts?.length ?? 0}, ` +
1626
+ `estimatedTokens=${ctx.estimatedTokens}`,
1627
+ );
1628
+ if (summary.length > 0) {
1629
+ logger.info(
1630
+ `openviking: compact latest_archive_overview for ${OVSessionId}: ${summary.substring(0, 200)}...`,
1631
+ );
1632
+ }
1633
+ if (ctx.messages && ctx.messages.length > 0) {
1634
+ // 打印所有消息的 role 和 content 摘要
1635
+ const msgSummary = ctx.messages.map((m: { role?: string; content?: string; parts?: Array<{ type?: string; text?: string }> }) => {
1636
+ const role = m.role ?? "unknown";
1637
+ let textPreview = "";
1638
+ if (m.content) {
1639
+ textPreview = m.content.substring(0, 80);
1640
+ } else if (m.parts && m.parts.length > 0) {
1641
+ const textPart = m.parts.find((p: { type?: string }) => p.type === "text");
1642
+ textPreview = textPart?.text?.substring(0, 80) ?? JSON.stringify(m.parts).substring(0, 80);
1643
+ }
1644
+ return { role, textPreview };
1645
+ });
1646
+ logger.info(
1647
+ `openviking: compact restored messages for ${OVSessionId}: ` +
1648
+ JSON.stringify(msgSummary),
1649
+ );
1650
+ }
1651
+ } catch (ctxErr) {
1652
+ contextFetchError = String(ctxErr);
1653
+ logger.info(
1654
+ `openviking: compact context fetch failed for session=${OVSessionId}, ` +
1655
+ `tokenBudget=${tokenBudget}, agentId=${agentId}: ${contextFetchError}`,
1656
+ );
1657
+ }
1658
+
1659
+ logger.info(
1660
+ `openviking: compact tokens session=${OVSessionId}, ` +
1661
+ `tokensBefore=${tokensBefore}, tokensAfter=${tokensAfter ?? "unknown"}, ` +
1662
+ `latestArchiveId=${firstKeptEntryId || "none"}`,
1663
+ );
1664
+
1665
+ diag("compact_result", OVSessionId, {
1666
+ ok: true,
1667
+ compacted: true,
1668
+ reason: "commit_completed",
1669
+ status: commitResult.status,
1670
+ archived: commitResult.archived ?? false,
1671
+ taskId: commitResult.task_id ?? null,
1672
+ memories: memCount,
1673
+ tokensBefore: tokensBefore,
1674
+ tokensAfter: tokensAfter ?? null,
1675
+ latestArchiveId: firstKeptEntryId || null,
1676
+ summaryPresent: summary.length > 0,
1677
+ });
1678
+ return {
1679
+ ok: true,
1680
+ compacted: true,
1681
+ reason: "commit_completed",
1682
+ result: {
1683
+ summary,
1684
+ firstKeptEntryId,
1685
+ tokensBefore,
1686
+ tokensAfter,
1687
+ details: contextFetchError
1688
+ ? {
1689
+ commit: commitResult,
1690
+ contextError: contextFetchError,
1691
+ }
1692
+ : {
1693
+ commit: commitResult,
1694
+ },
1695
+ },
1696
+ };
1697
+ } catch (err) {
1698
+ const errorMessage = String(err);
1699
+ if (errorMessage.includes("[NOT_FOUND]") && errorMessage.includes("Session not found")) {
1700
+ logger.info(
1701
+ `openviking: compact skipped because OV session does not exist ` +
1702
+ `(session=${OVSessionId}, agentId=${agentId})`,
1703
+ );
1704
+ diag("compact_result", OVSessionId, {
1705
+ ok: true,
1706
+ compacted: false,
1707
+ reason: "session_not_found",
1708
+ error: errorMessage,
1709
+ });
1710
+ return {
1711
+ ok: true,
1712
+ compacted: false,
1713
+ reason: "session_not_found",
1714
+ };
1715
+ }
1716
+ logger.warn?.(`openviking: compact commit failed for session=${OVSessionId}: ${errorMessage}`);
1717
+ diag("compact_error", OVSessionId, {
1718
+ error: errorMessage,
1719
+ });
1720
+ return {
1721
+ ok: false,
1722
+ compacted: false,
1723
+ reason: "commit_error",
1724
+ result: {
1725
+ summary: "",
1726
+ firstKeptEntryId: "",
1727
+ tokensBefore: tokensBefore,
1728
+ tokensAfter: undefined,
1729
+ details: {
1730
+ error: errorMessage,
1731
+ },
1732
+ },
1733
+ };
1734
+ }
1735
+ },
1736
+ };
1737
+ }