UNPKG

@tencentdb-agent-memory/memory-tencentdb

Version:

Four-layer local memory system plugin for OpenClaw — auto-captures, structures, and profiles conversational knowledge using local LLM + SQLite vector search (L0→L1→L2→L3 pipeline)

746 lines (668 loc) 31.3 kB
/** * Offload Task Executor — async L1/L1.5/L2 execution via PipelineWorker. */ import type { StorageAdapter } from "../core/storage/adapter.js"; import type { IStateBackend, TaskPayload } from "../core/state/types.js"; import type { OffloadEntry, OffloadState, OffloadExecutorConfig, L2ParsedResponse, MmdMeta, ToolPair, } from "./types.js"; import { defaultOffloadState, defaultOffloadConfig } from "./types.js"; import { parseJsonl, serializeJsonl } from "./parsers/json-utils.js"; import { parseL1Response } from "./parsers/l1-parser.js"; import { parseL15Response } from "./parsers/l15-parser.js"; import { parseL2Response } from "./parsers/l2-parser.js"; import { L1_SYSTEM_PROMPT, buildL1UserPrompt } from "./prompts/l1-prompt.js"; import { L15_SYSTEM_PROMPT, buildL15UserPrompt } from "./prompts/l15-prompt.js"; import { L2_SYSTEM_PROMPT, buildL2UserPrompt } from "./prompts/l2-prompt.js"; import { handleTaskTransition, extractMmdMeta } from "./task-transition.js"; import { buildOffloadBasePath } from "./session-utils.js"; import { traceServerModelIo, traceServerTaskDecision } from "./opik-tracer.js"; // ─── LLM Client Interface ──────────────────────────────────────────────────── export interface LlmClient { chat(params: { model: string; messages: Array<{ role: "system" | "user"; content: string }>; temperature: number; max_tokens: number; timeoutMs?: number; }): Promise<string>; } // ─── Logger Interface ──────────────────────────────────────────────────────── export interface ExecutorLogger { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; } // ─── Executor Deps ─────────────────────────────────────────────────────────── export interface OffloadExecutorDeps { resolveStorage: (instanceId: string) => Promise<StorageAdapter | undefined>; llmClient: LlmClient; stateBackend: IStateBackend; config?: OffloadExecutorConfig; logger: ExecutorLogger; } // ─── Executor Class ────────────────────────────────────────────────────────── export class OffloadTaskExecutor { private deps: OffloadExecutorDeps; private config: OffloadExecutorConfig; constructor(deps: OffloadExecutorDeps) { this.deps = deps; this.config = deps.config ?? defaultOffloadConfig(); } // ═══════════════════════════════════════════════════════════════════════════ // L1: Summarize pending ToolPairs → OffloadEntry[] // ═══════════════════════════════════════════════════════════════════════════ async executeOffloadL1(task: TaskPayload, _signal?: AbortSignal): Promise<void> { const startMs = Date.now(); const sessionId = this.extractSessionId(task); if (!sessionId) return; const storage = await this.resolveStorageOrThrow(task.instanceId); const basePath = buildOffloadBasePath(sessionId); const pendingPath = `${basePath}/pending.jsonl`; // 1. Claim pending via rename (atomic: new ingest appends to fresh pending.jsonl) const processingPath = `${basePath}/pending-processing-${task.id}.jsonl`; const pendingRaw = await storage.readFile(pendingPath); if (!pendingRaw || !pendingRaw.trim()) return; try { await storage.rename(pendingPath, processingPath); } catch { // rename failed (file gone — another L1 claimed it, or doesn't exist) return; } // Re-read from processing file (rename succeeded, this is our exclusive copy) const claimedRaw = await storage.readFile(processingPath); if (!claimedRaw || !claimedRaw.trim()) { await storage.unlink(processingPath); return; } let toolPairs = parseJsonl<Record<string, unknown>>(claimedRaw, (line, err) => { this.deps.logger.warn(`[offload-server] L1: bad JSONL line in pending: ${line}`, err); }); if (toolPairs.length === 0) { await storage.unlink(processingPath); return; } // 2. Dedup: remove toolPairs whose toolCallId already exists in entries.jsonl const entriesPath = `${basePath}/entries.jsonl`; const existingEntriesRaw = await storage.readFile(entriesPath); if (existingEntriesRaw) { const existingIds = new Set<string>(); for (const e of parseJsonl<OffloadEntry>(existingEntriesRaw)) { if (e.tool_call_id) existingIds.add(e.tool_call_id); } const before = toolPairs.length; toolPairs = toolPairs.filter((tp) => { const id = tp.toolCallId as string; return !id || !existingIds.has(id); }); if (toolPairs.length < before) { this.deps.logger.info(`[offload-server] L1: dedup removed ${before - toolPairs.length}/${before} duplicate toolPairs`); } if (toolPairs.length === 0) { await storage.unlink(processingPath); return; } } // 3. Build prompt & call LLM const recentContext = await storage.readFile(`${basePath}/recent-context.txt`) ?? ""; const userPrompt = buildL1UserPrompt(recentContext, toolPairs as unknown as ToolPair[]); let newEntries: OffloadEntry[]; const l1LlmStart = Date.now(); let l1RawResponse: string | undefined; try { const response = await this.deps.llmClient.chat({ model: this.config.l1Model, messages: [ { role: "system", content: L1_SYSTEM_PROMPT }, { role: "user", content: userPrompt }, ], temperature: this.config.l1Temperature, max_tokens: this.config.l1MaxTokens, timeoutMs: this.config.l1TimeoutMs, }); l1RawResponse = response; newEntries = parseL1Response(response); traceServerModelIo({ sessionId, stage: "L1", model: this.config.l1Model, systemPrompt: L1_SYSTEM_PROMPT, userPrompt, responseContent: response, status: "ok", durationMs: Date.now() - l1LlmStart, logger: this.deps.logger, }); } catch (err) { traceServerModelIo({ sessionId, stage: "L1", model: this.config.l1Model, systemPrompt: L1_SYSTEM_PROMPT, userPrompt, responseContent: l1RawResponse ?? "", status: "error", errorMessage: String(err), durationMs: Date.now() - l1LlmStart, logger: this.deps.logger, }); this.deps.logger.error(`[offload-server] L1 LLM failed:`, err); throw err; // Let Worker retry (processing file remains for re-claim) } // 4. Ensure all toolPairs are covered — fallback for missing entries const parsedIds = new Set(newEntries.map((e) => e.tool_call_id)); // Build toolCallId → original timestamp map for reliable boundary matching const tpTimestampMap = new Map<string, string>(); for (const tp of toolPairs) { const id = tp.toolCallId as string; if (id && tp.timestamp) tpTimestampMap.set(id, tp.timestamp as string); } // Overwrite entry timestamps with original tool pair timestamps (don't trust LLM output) for (const entry of newEntries) { const origTs = tpTimestampMap.get(entry.tool_call_id); if (origTs) entry.timestamp = origTs; } for (const tp of toolPairs) { const id = tp.toolCallId as string; if (id && !parsedIds.has(id)) { newEntries.push({ tool_call_id: id, tool_call: (tp.toolName as string) ?? "", summary: "[L1 parse incomplete]", timestamp: (tp.timestamp as string) ?? new Date().toISOString(), score: 2, node_id: null, }); } } // 5. Write refs: store original tool result content for each pair for (const tp of toolPairs) { const id = tp.toolCallId as string; if (!id) continue; const result = tp.result ?? tp.error ?? ""; const resultStr = typeof result === "string" ? result : JSON.stringify(result, null, 2); if (!resultStr || resultStr.length < 20) continue; // skip trivially short results const toolName = (tp.toolName as string) ?? "unknown"; const timestamp = (tp.timestamp as string) ?? new Date().toISOString(); const header = `# Tool Result: ${toolName}\n\n**tool_call_id:** ${id}\n**Timestamp:** ${timestamp}\n\n---\n\n`; const refPath = `${basePath}/refs/${id}.md`; await storage.writeFile(refPath, header + resultStr); // Set result_ref on the matching entry (full relative path for tdai_read_cos) const entry = newEntries.find((e) => e.tool_call_id === id); if (entry) entry.result_ref = `${basePath}/refs/${id}.md`; } // 6. Write entries (atomic append) + delete processing file await storage.appendFile(entriesPath, serializeJsonl(newEntries)); await storage.unlink(processingPath); // 7. Check L2 trigger: group null entries by boundary targetMmd const state = await this.readState(storage, basePath); const allEntriesRaw = await storage.readFile(entriesPath); if (allEntriesRaw && state.boundaries.length > 0) { const allEntries = parseJsonl<OffloadEntry>(allEntriesRaw, (line, err) => { this.deps.logger.warn(`[offload-server] L1: bad JSONL line in entries: ${line}`, err); }); const nodeMapping = await this.readNodeMapping(storage, basePath); // Group null entries by resolved targetMmd const nullByMmd = new Map<string, number>(); for (const e of allEntries) { if (this.getEffectiveNodeId(e, nodeMapping) !== null || !e.timestamp) continue; const boundary = this.findBoundaryByTimestamp(state.boundaries, e.timestamp); if (!boundary) continue; // no boundary → ignore if (boundary.targetMmd === "_pending" || !boundary.targetMmd) continue; // pending → ignore nullByMmd.set(boundary.targetMmd, (nullByMmd.get(boundary.targetMmd) ?? 0) + 1); } // Trigger L2 for each MMD that reached threshold for (const [mmdFile, count] of nullByMmd) { if (count >= this.config.l2NullThreshold) { // Threshold met: short delay (1s) so concurrent L1s merge into one L2 await this.deps.stateBackend.setTimerIfEarlier( task.instanceId, `offload-l2:${task.instanceId}:${sessionId}:${mmdFile}`, Date.now() + 1_000, ); this.deps.logger.info(`[offload-server] L2 timer set (fast, mmd=${mmdFile}, nullCount=${count})`); } else if (count > 0) { await this.deps.stateBackend.setTimerIfEarlier( task.instanceId, `offload-l2:${task.instanceId}:${sessionId}:${mmdFile}`, Date.now() + 30_000, ); this.deps.logger.info(`[offload-server] L2 timer set (mmd=${mmdFile}, nullCount=${count})`); } } } this.deps.logger.info( `[offload-server] L1 complete: ${newEntries.length} entries produced (${Date.now() - startMs}ms)`, ); } // ═══════════════════════════════════════════════════════════════════════════ // L1.5: Task judgment — triggered by user new message // ═══════════════════════════════════════════════════════════════════════════ async executeOffloadL15(task: TaskPayload, _signal?: AbortSignal): Promise<void> { const startMs = Date.now(); const { sessionId, recentMessages, boundaryTimestamp } = task.data as { sessionId: string; recentMessages?: string; boundaryTimestamp: string; }; const storage = await this.resolveStorageOrThrow(task.instanceId); const basePath = buildOffloadBasePath(sessionId); // ─── Phase 1: Read-only snapshot + LLM call (NO LOCK) ───────────────── // Multiple L1.5 tasks can execute this phase concurrently. Each operates // on its own boundary and the LLM call (3-10s) does not block others. // 1. Read current active MMD (snapshot for prompt building) const preState = await this.readState(storage, basePath); let currentMmd: { filename: string; content: string } | null = null; if (preState.activeMmdFile) { const content = await storage.readFile(`${basePath}/mmds/${preState.activeMmdFile}`); if (content) { currentMmd = { filename: preState.activeMmdFile, content }; } } // 2. List available MMDs → extract metas const mmdsPrefix = `${basePath}/mmds/`; const mmdFiles = await storage.readdirNames(mmdsPrefix, ".mmd"); const metas: MmdMeta[] = []; for (const f of mmdFiles) { const content = await storage.readFile(`${mmdsPrefix}${f}`); if (content) { metas.push(extractMmdMeta(f, content)); } } // 3. Build prompt & call LLM (no lock held — this is the expensive part) const userPrompt = buildL15UserPrompt( recentMessages ?? "", currentMmd, metas, ); let judgment; let rawResponse: string | undefined; const l15LlmStart = Date.now(); try { const response = await this.deps.llmClient.chat({ model: this.config.l15Model, messages: [ { role: "system", content: L15_SYSTEM_PROMPT }, { role: "user", content: userPrompt }, ], temperature: this.config.l15Temperature, max_tokens: this.config.l15MaxTokens, timeoutMs: this.config.l15TimeoutMs, }); rawResponse = response; judgment = parseL15Response(response); traceServerModelIo({ sessionId, stage: "L1.5", model: this.config.l15Model, systemPrompt: L15_SYSTEM_PROMPT, userPrompt, responseContent: response, status: "ok", durationMs: Date.now() - l15LlmStart, logger: this.deps.logger, }); } catch (err) { traceServerModelIo({ sessionId, stage: "L1.5", model: this.config.l15Model, systemPrompt: L15_SYSTEM_PROMPT, userPrompt, responseContent: rawResponse ?? "", status: "error", errorMessage: String(err), durationMs: Date.now() - l15LlmStart, logger: this.deps.logger, }); this.deps.logger.error(`[offload-server] L1.5 LLM failed:`, err); throw err; } if (!judgment) { this.deps.logger.warn(`[offload-server] L1.5: null response (parse failed), raw=${rawResponse?.slice(0, 500) ?? "(empty)"}`); return; } this.deps.logger.info( `[offload-server] L1.5: completed=${judgment.taskCompleted}, long=${judgment.isLongTask}, cont=${judgment.isContinuation}, label=${judgment.newTaskLabel ?? "none"} (LLM=${Date.now() - l15LlmStart}ms)`, ); // Trace L1.5 decision traceServerTaskDecision({ sessionId, judgment: judgment as unknown as Record<string, unknown>, durationMs: Date.now() - startMs, logger: this.deps.logger, }); // ─── Phase 2: Write phase (SHORT LOCK) ──────────────────────────────── // Only lock during state.json read-modify-write. Lock TTL is short (10s) // because this phase only does file I/O (no LLM calls). const lockKey = `offload-state:${task.instanceId}:${sessionId}`; const lockOwner = task.id; let locked = false; for (let attempt = 0; attempt < 10; attempt++) { locked = await this.deps.stateBackend.acquireLock(lockKey, lockOwner, 10_000); if (locked) break; await new Promise((r) => setTimeout(r, 100 + attempt * 50)); } if (!locked) { this.deps.logger.warn(`[offload-server] L1.5: failed to acquire write lock after 10 attempts (ts=${boundaryTimestamp}), skipping`); return; } try { // Re-read state (fresh, may have been modified by another L1.5 that finished earlier) const state = await this.readState(storage, basePath); // CAS check: find boundary by timestamp const boundaryIdx = state.boundaries.findIndex((b) => b.timestamp === boundaryTimestamp); if (boundaryIdx < 0) { this.deps.logger.warn(`[offload-server] L1.5: boundary not found for ts=${boundaryTimestamp}, skipping`); return; } // CAS check: already backfilled by another concurrent L1.5? Skip. if (state.boundaries[boundaryIdx].targetMmd !== "_pending") { this.deps.logger.info(`[offload-server] L1.5: boundary[ts=${boundaryTimestamp}] already backfilled, skipping`); return; } // Apply task transition await handleTaskTransition(state, judgment, storage, basePath); // Backfill boundary targetMmd state.boundaries[boundaryIdx].targetMmd = state.activeMmdFile; // Update state await this.writeState(storage, basePath, state); // Check L2 trigger: scan ALL null entries grouped by targetMmd. // This covers the case where L1 completed before L1.5 backfill — // L1 skipped entries with _pending boundaries, so L1.5 must pick them up. const entriesRaw = await storage.readFile(`${basePath}/entries.jsonl`); if (entriesRaw) { const allEntries = parseJsonl<OffloadEntry>(entriesRaw); const l15NodeMapping = await this.readNodeMapping(storage, basePath); // Group null entries by their resolved targetMmd (using all boundaries, not just current) const nullByMmd = new Map<string, number>(); for (const e of allEntries) { if (this.getEffectiveNodeId(e, l15NodeMapping) !== null || !e.timestamp) continue; const boundary = this.findBoundaryByTimestamp(state.boundaries, e.timestamp); if (!boundary) continue; if (boundary.targetMmd === "_pending" || !boundary.targetMmd) continue; nullByMmd.set(boundary.targetMmd, (nullByMmd.get(boundary.targetMmd) ?? 0) + 1); } for (const [mmdFile, nullCount] of nullByMmd) { if (nullCount >= this.config.l2NullThreshold) { await this.deps.stateBackend.setTimerIfEarlier( task.instanceId, `offload-l2:${task.instanceId}:${sessionId}:${mmdFile}`, Date.now() + 1_000, ); this.deps.logger.info(`[offload-server] L1.5: L2 timer set (fast, mmd=${mmdFile}, nullCount=${nullCount})`); } else if (nullCount > 0) { await this.deps.stateBackend.setTimerIfEarlier( task.instanceId, `offload-l2:${task.instanceId}:${sessionId}:${mmdFile}`, Date.now() + 30_000, ); this.deps.logger.info(`[offload-server] L1.5: L2 timer set after backfill (mmd=${mmdFile}, nullCount=${nullCount})`); } } } } finally { if (locked) { await this.deps.stateBackend.releaseLock(lockKey, lockOwner); } } this.deps.logger.info( `[offload-server] L1.5 complete: boundary[ts=${boundaryTimestamp}] (total=${Date.now() - startMs}ms)`, ); } // ═══════════════════════════════════════════════════════════════════════════ // L2: MMD generation / update + node_id backfill // ═══════════════════════════════════════════════════════════════════════════ async executeOffloadL2(task: TaskPayload, _signal?: AbortSignal): Promise<void> { const startMs = Date.now(); const sessionId = this.extractSessionId(task); if (!sessionId) return; let targetMmdFile: string | undefined; const data = task.data as Record<string, unknown>; targetMmdFile = data.targetMmdFile as string | undefined; // Extract targetMmdFile from timer member if available // Format: "offload-l2:{instanceId}:{sessionId}:{mmdFile}" → extract last segment ending in .mmd if (!targetMmdFile && data.timerMember) { const timerMember = data.timerMember as string; const mmdMatch = timerMember.match(/(\d+-[^:]+\.mmd)$/); if (mmdMatch) { targetMmdFile = mmdMatch[1]; } } const storage = await this.resolveStorageOrThrow(task.instanceId); const basePath = buildOffloadBasePath(sessionId); const state = await this.readState(storage, basePath); // Resolve targetMmdFile: from task data, timer member, or state (fallback) if (!targetMmdFile) { targetMmdFile = state.activeMmdFile ?? undefined; } if (!targetMmdFile) return; // 1. Read all entries + node mapping const entriesRaw = await storage.readFile(`${basePath}/entries.jsonl`); if (!entriesRaw) return; const allEntries = parseJsonl<OffloadEntry>(entriesRaw); const nodeMapping = await this.readNodeMapping(storage, basePath); // 2. Filter: entries belonging to targetMmdFile with null node_id (after join) const relevantEntries = allEntries.filter((e) => { if (this.getEffectiveNodeId(e, nodeMapping) !== null) return false; if (!e.timestamp) return false; const boundary = this.findBoundaryByTimestamp(state.boundaries, e.timestamp); return boundary?.targetMmd === targetMmdFile; }); if (relevantEntries.length === 0) return; // Guard: if oldest null entry is older than 10 minutes, give up and assign fallback node_id // to prevent infinite L2 retries when LLM consistently fails to map certain entries. const L2_MAX_AGE_MS = 10 * 60 * 1000; const oldestTs = relevantEntries.reduce((min, e) => { const t = new Date(e.timestamp).getTime(); return t < min ? t : min; }, Infinity); if (Date.now() - oldestTs > L2_MAX_AGE_MS) { this.deps.logger.warn( `[offload-server] L2: ${relevantEntries.length} entries exceeded max age (10min), assigning fallback node_id`, ); const fallbackMappings = relevantEntries.map((e) => ({ tool_call_id: e.tool_call_id, node_id: `${targetMmdFile!.replace(/\.mmd$/, "")}-orphan`, })); const mappingPath = `${basePath}/node-mapping.jsonl`; await storage.appendFile(mappingPath, serializeJsonl(fallbackMappings)); return; } // 3. Read existing MMD const mmdPath = `${basePath}/mmds/${targetMmdFile}`; const existingMmd = await storage.readFile(mmdPath); // 4. Build prompt (include recent context for better MMD generation) const recentHistory = await storage.readFile(`${basePath}/recent-context.txt`) ?? null; const taskLabel = targetMmdFile.replace(/^\d+-/, "").replace(/\.mmd$/, "") || "task"; const prefixMatch = targetMmdFile.match(/^(\d+)-/); const mmdPrefix = prefixMatch ? prefixMatch[1] : "000"; const charCount = existingMmd?.length ?? 0; const userPrompt = buildL2UserPrompt({ existingMmd: existingMmd || null, entries: relevantEntries, recentHistory, taskLabel, mmdPrefix, charCount, }); // 5. Call LLM let result: L2ParsedResponse | null; let rawL2Response: string | undefined; const l2LlmStart = Date.now(); try { const response = await this.deps.llmClient.chat({ model: this.config.l2Model, messages: [ { role: "system", content: L2_SYSTEM_PROMPT }, { role: "user", content: userPrompt }, ], temperature: this.config.l2Temperature, max_tokens: this.config.l2MaxTokens, timeoutMs: this.config.l2TimeoutMs, }); rawL2Response = response; result = parseL2Response(response); traceServerModelIo({ sessionId: sessionId!, stage: "L2", model: this.config.l2Model, systemPrompt: L2_SYSTEM_PROMPT, userPrompt, responseContent: response, status: "ok", durationMs: Date.now() - l2LlmStart, logger: this.deps.logger, }); } catch (err) { traceServerModelIo({ sessionId: sessionId!, stage: "L2", model: this.config.l2Model, systemPrompt: L2_SYSTEM_PROMPT, userPrompt, responseContent: rawL2Response ?? "", status: "error", errorMessage: String(err), durationMs: Date.now() - l2LlmStart, logger: this.deps.logger, }); this.deps.logger.error(`[offload-server] L2 LLM failed:`, err); throw err; } if (!result) { this.deps.logger.warn(`[offload-server] L2: parse failed, raw=${rawL2Response?.slice(0, 500) ?? "(empty)"}`); // Schedule retry so remaining null entries are not orphaned await this.deps.stateBackend.setTimerIfEarlier( task.instanceId, `offload-l2:${task.instanceId}:${sessionId}:${targetMmdFile}`, Date.now() + 30_000, ); return; } // 6. Apply MMD update const updatedMmd = this.applyL2Result(existingMmd ?? "", result); await storage.writeFile(mmdPath, updatedMmd); // 7. Backfill: write node mappings to separate file (avoids overwriting entries.jsonl) const mappingEntries = Object.entries(result.nodeMapping).map(([toolCallId, nodeId]) => ({ tool_call_id: toolCallId, node_id: nodeId, })); if (mappingEntries.length > 0) { const mappingPath = `${basePath}/node-mapping.jsonl`; await storage.appendFile(mappingPath, serializeJsonl(mappingEntries)); } // 8. Check remaining null entries for this MMD — re-read node-mapping for accurate count // (another L2 may have concurrently written mappings, or LLM may have missed some) const freshNodeMapping = await this.readNodeMapping(storage, basePath); const remainingNull = relevantEntries.filter( (e) => !freshNodeMapping.has(e.tool_call_id) && !result!.nodeMapping[e.tool_call_id], ).length; if (remainingNull > 0) { await this.deps.stateBackend.setTimerIfEarlier( task.instanceId, `offload-l2:${task.instanceId}:${sessionId}:${targetMmdFile}`, Date.now() + 30_000, ); this.deps.logger.info(`[offload-server] L2 retry timer set (mmd=${targetMmdFile}, remainingNull=${remainingNull})`); } this.deps.logger.info( `[offload-server] L2 complete: ${Object.keys(result.nodeMapping).length} entries mapped, action=${result.fileAction} (${Date.now() - startMs}ms)`, ); } // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════ /** * Extract sessionId from task data or task.sessionId. */ private extractSessionId(task: TaskPayload): string | undefined { const data = task.data as Record<string, unknown> | undefined; // Prefer explicit sessionId in data if (data?.sessionId && typeof data.sessionId === "string") { return data.sessionId; } // Fallback: task-level sessionId if (task.sessionId) { return task.sessionId; } return undefined; } private async resolveStorageOrThrow(instanceId: string): Promise<StorageAdapter> { const storage = await this.deps.resolveStorage(instanceId); if (!storage) throw new Error(`Storage unavailable for instance ${instanceId}`); return storage; } private async readState(storage: StorageAdapter, basePath: string): Promise<OffloadState> { const raw = await storage.readFile(`${basePath}/state.json`); if (!raw) return defaultOffloadState(); try { return { ...defaultOffloadState(), ...JSON.parse(raw) }; } catch { return defaultOffloadState(); } } private async writeState(storage: StorageAdapter, basePath: string, state: OffloadState): Promise<void> { await storage.writeFile(`${basePath}/state.json`, JSON.stringify(state)); } private findBoundaryByTimestamp( boundaries: OffloadState["boundaries"], entryTimestamp: string, ): OffloadState["boundaries"][number] | null { if (boundaries.length === 0) return null; // Find the last boundary whose timestamp <= entryTimestamp let result: OffloadState["boundaries"][number] | null = null; for (const b of boundaries) { if (b.timestamp <= entryTimestamp) result = b; else break; } return result; } private applyL2Result(existingMmd: string, result: L2ParsedResponse): string { if (result.fileAction === "write" && result.mmdContent) { return result.mmdContent; } if (result.fileAction === "replace" && result.replaceBlocks?.length) { const lines = existingMmd.split("\n"); // Sort blocks by startLine descending to avoid offset issues const sorted = [...result.replaceBlocks] .filter((block) => block.startLine >= 1 && block.endLine >= block.startLine && block.startLine <= lines.length) .sort((a, b) => b.startLine - a.startLine); for (const block of sorted) { const start = block.startLine - 1; // 0-based const end = Math.min(block.endLine, lines.length); // clamp endLine const deleteCount = end - block.startLine + 1; const newLines = block.content.split("\n"); lines.splice(start, deleteCount, ...newLines); } return lines.join("\n"); } return existingMmd; } /** * Read node-mapping.jsonl and build a tool_call_id → node_id map. */ private async readNodeMapping(storage: StorageAdapter, basePath: string): Promise<Map<string, string>> { const raw = await storage.readFile(`${basePath}/node-mapping.jsonl`); const map = new Map<string, string>(); if (!raw) return map; const lines = parseJsonl<{ tool_call_id: string; node_id: string }>(raw); for (const line of lines) { if (line.tool_call_id && line.node_id) { map.set(line.tool_call_id, line.node_id); } } return map; } /** * Get effective node_id for an entry: check node-mapping first, then entry's own field. */ private getEffectiveNodeId(entry: OffloadEntry, nodeMapping: Map<string, string>): string | null { return nodeMapping.get(entry.tool_call_id) ?? entry.node_id; } }