Context Offload is a separate, independently-toggled multi-layer context compression subsystem that is disabled by default (offload.enabled: false) and does not affect Memory pipeline functionality when off.[1] Enabling short-term compression requires plugin version ≥ 0.3.4 and is opt-in via config.offload.enabled: true in the plugin configuration.[2] src/offload/index.ts is the merged entry point for the Context Offload module, equivalent to the standalone context-offload-plugin's index.js, adapted to co-exist with the memory-tencentdb plugin.[3] registerOffload(api, offloadConfig) in src/offload/index.ts is the public entry point for conditional offload registration, called from the main plugin index.ts when offload.enabled: true.[3] The Context Offload pipeline is organized into four layers — L1, L1.5, L2, and L3 — each performing progressively heavier compression or graph-building work. A context window is the maximum number of tokens an LLM can process in a single call; when conversation history fills this window, older content must be discarded or compressed before new messages can be sent.
OffloadConfig.mode in src/config.ts has three values: "local" (calls LLM directly via AI SDK), "backend" (routes through a remote backend service), and "collect" (data collection only — runs L1/L1.5/L2 async but disables L3 compression and does not occupy the contextEngine slot).[4] The offload.mode: "collect" configuration executes only data collection (L0 capture + vector writes) without triggering L3 compression, useful for pure data accumulation or debugging.[5] PluginConfig.model in src/offload/types.ts selects the LLM for offload tasks using the format "provider/model-id" (e.g. "dashscope/kimi-k2.5").[6]
The offload.dataDir option overrides the default Context Offload data directory (default ~/.openclaw/context-offload); the value must be an absolute path.[1] PLUGIN_DEFAULTS in src/offload/types.ts sets compression thresholds: mild offload triggers at 50% context fill (mildOffloadRatio: 0.5), aggressive compress at 85% (aggressiveCompressRatio: 0.85), and emergency at 95% (emergencyCompressRatio: 0.95) targeting down to 60% (emergencyTargetRatio: 0.6).[6] PLUGIN_DEFAULTS in src/offload/types.ts caps injected Mermaid MMD content to at most 20% of the total context window token budget (mmdMaxTokenRatio: 0.2).[6] PLUGIN_DEFAULTS in src/offload/types.ts sets defaultSystemOverheadRatio: 0.12, assuming 12% of the context window is consumed by the system prompt and tool schemas when no cached overhead is available from the llm_input hook.[6] PLUGIN_DEFAULTS in src/offload/types.ts sets the default l3TiktokenEncoding to "cl100k_base" (matching DeepSeek/GLM/MiniMax tokenizers), changed from o200k_base in v0.3.6.[6] PLUGIN_DEFAULTS in src/offload/types.ts sets the default l3TokenCountMode to "tiktoken" (exact BPE counting); the alternative is "heuristic" (CJK/1.7 + others/4).[6] PLUGIN_DEFAULTS in src/offload/types.ts sets L2 to trigger when there are 4 or more node_id=null entries (l2NullThreshold: 4), or after 300 seconds without a run (l2TimeoutSeconds: 300); node_id="wait" entries are retried after 120 seconds (l2WaitRetrySeconds: 120).[6] PLUGIN_DEFAULTS in src/offload/types.ts sets l2TimeTriggerRequiresNewOffload: true, meaning time-based L2 only fires when at least one node_id=null entry has a timestamp after lastL2TriggerTime; set to false for legacy behavior that retries stale nulls.[6]
OffloadEntry in src/offload/types.ts is the record stored in offload.jsonl; the node_id field is null until L2 runs and assigns a Mermaid flowchart node ID.[6] OffloadEntry.score in src/offload/types.ts is a replaceability score (0–10) assigned by the L1 LLM: higher scores mean the LLM summary can better replace the original tool result.[6]
registerOffload() in src/offload/index.ts carries no idempotency guard — OpenClaw calls it multiple times during its lifecycle (plugin scan → gateway start → config reload); each call receives a different api instance, and only the last one is the live runtime API.[3] When registerContextEngine returns ok=false or throws, _contextEngineRejected is set to true and all offload functions are disabled for the lifetime of the module.[3] The L2 scheduler state (_l2Running, _l2PollHandle, _l2FirstNotifyAt) and the L1.5 retry-loop dispose flag (_l15Disposed) in src/offload/index.ts are module-level and shared across all registerOffload() invocations.[3] Internal memory-pipeline sessions matching the pattern memory-{taskId}-session-{ts} are detected by INTERNAL_SESSION_RE in src/offload/index.ts and excluded from offload processing.[3]
_isHeartbeatText() in src/offload/index.ts identifies both user heartbeat prompts and assistant HEARTBEAT_OK replies by checking for the substrings "HEARTBEAT" or "heartbeat"; heartbeat messages are skipped in history extraction and current-prompt context building.[3] _buildL1RecentContext() formats the offload state for L1 requests as ## current msg:\n{prompt}\n\n## history msg:\n{history}, falling back to (none) for missing sections.[3] _buildL15RecentContext() formats the offload state for L1.5 requests in Chinese, placing history first as reference and the latest user message last as focus: 历史消息,可作为参考:\n{history}\n\n最新user message:\n{currentLine}.[3] _msgFingerprint() in src/offload/index.ts computes a message identity hash from its role and the first 200 characters of its content using a DJB2-variant hash.[3] The /create-skill command parsed by parseCreateSkillCommand() in src/offload/index.ts accepts an optional MMD name and skill focus as space-separated arguments: /create-skill [mmdName] [skillFocus...].[3]
src/offload/index.ts imports compression utilities from ./hooks/llm-input-l3.js including compressByScoreCascade, aggressiveCompressUntilBelowThreshold, emergencyCompress, and EMERGENCY_MIN_MESSAGES_TO_KEEP, and uses isTokenOverflowError to detect LLM context-overflow errors.[3] Token-counting is sourced from ./context-token-tracker.js (buildTiktokenContextSnapshot, configureTokenTracker, tiktokenCount, jsonReplacer) and a fast estimator from ./fast-token-estimate.js (fastEstimateMessages).[3]
OffloadStateManager in src/offload/state-manager.ts is session-scoped: each instance is bound to a single session through an immutable StorageContext, and the class contains no global mutable state — all I/O goes through the frozen ctx.[7] OffloadStateManager.init() creates a StorageContext, ensures required directories exist, and loads persistent state from state.json before the manager is usable.[7] Accessing OffloadStateManager.ctx before calling init() or switchSession() throws Error: "OffloadStateManager: ctx not initialized, call init() or switchSession() first".[7] The default persistent state in src/offload/state-manager.ts initializes all fields to null or 0: activeMmdFile: null, activeMmdId: null, mmdCounter: 0, lastSessionKey: null, lastOffloadedToolCallId: null, lastL2TriggerTime: null, estimatedSystemOverhead: null.[7] OffloadStateManager exposes a readonly _instanceId (monotonically incrementing static counter) on each instance for debugging.[7] OffloadStateManager.addToolPair() is idempotent: it silently drops a ToolPair whose toolCallId is already in processedToolCallIds.[7] OffloadStateManager.takePending() removes up to max tool pairs from the front of the pending buffer and marks their toolCallIds as processed in one atomic step, preventing double-processing.[7] OffloadStateManager.nextMmdNumber() reconciles the in-memory mmdCounter with the highest numeric prefix found among MMD files on disk, preventing counter regression after restarts.[7] OffloadStateManager exposes a mutex field l1Lock (a Promise<unknown>, initialized to Promise.resolve()) for the L1 pipeline to prevent concurrent runs.[7] The l15Settled flag on OffloadStateManager gates L2 triggering: L2 must wait for l15Settled === true before it can run, ensuring L1.5 completes first.[7] The _lastAggressiveBoundary field on OffloadStateManager caches boundary info from the last aggressive deletion — originalIndex, fingerprint, keptMsgCount, and remainingTokens — to enable O(1) head-delete on replay instead of O(N×rounds) re-scanning.[7]
OffloadStateManager.switchSession() accepts a full session key (e.g. "agent:main:session-123"), rebuilds the StorageContext, and reloads persistent state from disk; the realSessionId parameter optionally overrides the parsed session ID.[7] On switchSession(), persistent state is only reloaded when the agent name changes; switching between sessions of the same agent preserves the current in-memory state object.[7] switchSession() resets all session-scoped runtime state — pendingToolPairs, injectedMmdVersions, mmdInjectionReady, l15Settled, lastMmdInjectedTokens, cachedUserPrompt, lastL15PromptHash, l15Boundaries, and P1 quick-skip counters — while preserving cachedSystemPrompt and its token count across session switches within the same agent.[7] switchSession() reconstructs processedToolCallIds from persisted offload entries, including both the raw tool_call_id and its underscore-stripped normalized form, to handle ID format variations.[7]
The before_prompt_build hook in src/offload/hooks/before-prompt-build.ts runs a three-phase context cleanup pipeline before each LLM call: (1) fast-path re-apply of confirmed mild replacements and deletion of aggressive-deleted messages, (2) a token guard that triggers full L3 (Aggressive + Mild) compression inline if still above thresholds, and (3) MMD injection of active/history memory into the message list.[8] The before_prompt_build handler skips processing entirely for internal memory-pipeline sessions whose session key matches the pattern /memory-.*-session-\d+/.[8] When there are no confirmed or deleted offload IDs, the handler skips all compression phases and goes directly to MMD injection with waitForL15: true, then returns.[8] The token guard computes aggressive and mild thresholds as floor(contextWindow * aggressiveRatio) and floor(contextWindow * mildRatio), using PLUGIN_DEFAULTS values when pluginConfig does not supply them.[8] Aggressive compression performance is monitored: if a round takes longer than 10 000 ms, a warning is logged with round count, deleted count, and remaining token estimate.[8] When aggressive compression stalls because user messages are protected and the token count is still at or above the aggressive threshold, stateManager._forceEmergencyNext is set to true to trigger an emergency fallback on the next cycle.[8] Mixed assistant messages that contain both text and tool_use blocks have deleted tool_use blocks stripped inline (by splicing from content) before the message list is sent to the LLM, preventing Anthropic 400 errors from orphaned tool_use without a matching tool_result.[8] After aggressive compression, src/offload/hooks/before-prompt-build.ts calls buildHistoryMmdInjection and splices the resulting MMD messages into the message list at the history insertion point, adding their token cost to workingTokens before the mild-compression threshold check.[8] Both aggressive and mild compression outcomes update persistent offload status via markOffloadStatus, with aggressive deletions stored as "deleted" and mild replacements stored as true; failures are logged but do not interrupt the pipeline.[8]
parseL1Response in src/offload/local-llm/parsers/l1-parser.ts converts raw LLM output into an OffloadEntry[], tolerating markdown wrapping and missing fields.[9] parseL1Response requires tool_call_id to be non-empty — any entry missing this field is silently skipped — and defaults the score field to 5 when the raw LLM output does not supply a numeric value, setting node_id to null.[9]
resolveApiKeyFromAuthProfile in src/offload/auth-profile-key.ts returns a plaintext API key only for profiles of type api_key that have a key string; profiles with only a keyRef (e.g., keychain-backed) return undefined.[10] resolveApiKeyFromAuthProfile returns undefined for providers whose profiles are of type oauth or token (non-API-key auth flows), and also returns undefined gracefully when the provider-auth SDK is unavailable (e.g., older host versions that do not supply it).[10]
Example: canonical usage of resolveApiKeyFromAuthProfile — pass the agent API object, provider name, optional override, and an SDK factory; receive the plaintext key or undefined.
const key = resolveApiKeyFromAuthProfile(api, "xiaomi", undefined, () => sdk);
expect(key).toBe("sk-xiaomi-123");
Sources