feat: release v0.3.3 — Hermes adapter, context offload, core refactor

This commit is contained in:
chrishuan
2026-05-13 01:58:18 +08:00
parent a74b0b3e43
commit db8f3e516a
100 changed files with 29832 additions and 454 deletions
+359
View File
@@ -0,0 +1,359 @@
/**
* Backend HTTP Client for Context Offload.
*
* When `backendUrl` is configured, L1/L1.5/L2/L4 LLM calls are routed
* through this client to the backend service. The backend handles
* prompt construction + LLM invocation; the client handles data
* collection and file I/O.
*
* All methods throw on failure — callers are responsible for fallback.
*/
import type { OffloadEntry, ToolPair, TaskJudgment, PluginLogger } from "./types.js";
import { traceOffloadModelIo } from "./opik-tracer.js";
import * as https from "node:https";
import * as http from "node:http";
// ─── Request / Response Types ────────────────────────────────────────────────
export interface L1Request {
recentMessages: string;
toolPairs: Array<{
toolName: string;
toolCallId: string;
params: unknown;
result: unknown;
timestamp: string;
}>;
pluginConfig?: Record<string, unknown>;
}
export interface L1Response {
entries: OffloadEntry[];
}
export interface L15Request {
recentMessages: string;
currentMmd?: {
filename: string;
content: string;
path: string;
} | null;
availableMmdMetas: Array<{
filename: string;
path: string;
taskGoal: string;
doneCount: number;
doingCount: number;
todoCount: number;
updatedTime?: string | null;
nodeSummaries?: Array<{ nodeId: string; status: string; summary: string }>;
}>;
}
export interface L15Response extends TaskJudgment {}
export interface L2Request {
existingMmd: string | null;
newEntries: Array<{
tool_call_id: string;
tool_call: string;
summary: string;
timestamp: string;
}>;
recentHistory: string | null;
currentTurn: string | null;
taskLabel: string;
mmdPrefix: string;
mmdCharCount: number;
}
export interface L2Response {
fileAction: "write" | "replace";
mmdContent?: string;
replaceBlocks?: Array<{
startLine: number;
endLine: number;
content: string;
}>;
nodeMapping: Record<string, string>;
}
export interface L4Request {
mmdFilename: string;
mmdContent: string;
offloadEntries: OffloadEntry[];
skillFocus: string | null;
}
export interface L4Response {
skillName: string;
skillDescription: string;
skillContent: string;
}
/**
* Arbitrary key/value payload uploaded to the backend `/offload/v1/store` endpoint.
* The backend stores the raw JSON body verbatim; see `internal/handler/store.go`.
*/
export type StoreStatePayload = Record<string, unknown>;
export interface StoreStateResponse {
insertedId?: string;
}
// ─── BackendClient ───────────────────────────────────────────────────────────
export class BackendClient {
private baseUrl: string;
private apiKey: string | undefined;
/** Hardcoded timeout for all backend calls (L1/L1.5/L2/L4) */
private static readonly TIMEOUT_MS = 120_000;
private logger: PluginLogger;
private sessionKeyFn: () => string | null;
/** Resolves the value of the `X-User-Id` header sent on every call. */
private userIdFn: () => string | null;
/** Resolves the value of the `X-Task-Id` header sent on every call (optional). */
private taskIdFn: () => string | null;
constructor(
baseUrl: string,
logger: PluginLogger,
apiKey?: string,
_defaultTimeoutMs?: number, // kept for backward compat, ignored
sessionKeyFn?: () => string | null,
userIdFn?: () => string | null,
taskIdFn?: () => string | null,
) {
this.baseUrl = baseUrl.replace(/\/+$/, "");
this.apiKey = apiKey;
this.logger = logger;
this.sessionKeyFn = sessionKeyFn ?? (() => null);
this.userIdFn = userIdFn ?? (() => null);
this.taskIdFn = taskIdFn ?? (() => null);
}
/** L1 Summarize — synchronous await (used by assemble flush + force trigger) */
async l1Summarize(req: L1Request): Promise<L1Response> {
const pairNames = req.toolPairs.map((p) => `${p.toolName}(${p.toolCallId})`).join(", ");
this.logger.info(`[context-offload] L1 >>> summarize ${req.toolPairs.length} pairs: [${pairNames}]`);
const startMs = Date.now();
const resp = await this.post<L1Response>("/offload/v1/l1/summarize", req, BackendClient.TIMEOUT_MS);
const durationMs = Date.now() - startMs;
const entryCount = resp.entries?.length ?? 0;
const scores = resp.entries?.map((e) => `${e.tool_call_id}:score=${e.score}`).join(", ") ?? "";
this.logger.info(`[context-offload] L1 <<< ${entryCount} entries [${scores}]`);
traceOffloadModelIo({
sessionKey: this.sessionKeyFn(),
stage: "L1.backend",
provider: "backend",
model: `backend:${this.baseUrl}`,
url: `${this.baseUrl}/offload/v1/l1/summarize`,
systemPrompt: "(constructed by backend)",
userPrompt: JSON.stringify(req),
responseContent: JSON.stringify(resp),
usage: { entriesCount: entryCount },
status: "ok",
durationMs,
logger: this.logger,
});
return resp;
}
/** L1.5 Task Judgment — synchronous await, uses unified timeout */
async l15Judge(req: L15Request): Promise<L15Response> {
this.logger.info(
`[context-offload] L1.5 >>> judge: currentMmd=${req.currentMmd?.filename ?? "null"}, availableMmds=${req.availableMmdMetas.length}, recentMessages=${req.recentMessages.length} chars`,
);
const startMs = Date.now();
const resp = await this.post<L15Response>("/offload/v1/l15/judge", req, BackendClient.TIMEOUT_MS);
const durationMs = Date.now() - startMs;
this.logger.info(
`[context-offload] L1.5 <<< completed=${resp.taskCompleted}, continuation=${resp.isContinuation}, continuationFile=${resp.continuationMmdFile ?? "null"}, newLabel=${resp.newTaskLabel ?? "null"}, longTask=${resp.isLongTask}`,
);
traceOffloadModelIo({
sessionKey: this.sessionKeyFn(),
stage: "L1.5.backend",
provider: "backend",
model: `backend:${this.baseUrl}`,
url: `${this.baseUrl}/offload/v1/l15/judge`,
systemPrompt: "(constructed by backend)",
userPrompt: JSON.stringify(req),
responseContent: JSON.stringify(resp),
status: "ok",
durationMs,
logger: this.logger,
});
return resp;
}
/** L2 MMD Generation — async background, uses unified timeout */
async l2Generate(req: L2Request): Promise<L2Response> {
const entryIds = req.newEntries.map((e) => e.tool_call_id).join(", ");
this.logger.info(
`[context-offload] L2 >>> generate: task=${req.taskLabel}, prefix=${req.mmdPrefix}, entries=${req.newEntries.length} [${entryIds}], existingMmd=${req.existingMmd ? `${req.mmdCharCount} chars` : "null (new)"}`,
);
const startMs = Date.now();
const resp = await this.post<L2Response>("/offload/v1/l2/generate", req, BackendClient.TIMEOUT_MS);
const durationMs = Date.now() - startMs;
const mappingCount = Object.keys(resp.nodeMapping ?? {}).length;
const mappingStr = Object.entries(resp.nodeMapping ?? {}).map(([k, v]) => `${k}->${v}`).join(", ");
this.logger.info(
`[context-offload] L2 <<< action=${resp.fileAction}, mmdContent=${resp.mmdContent ? `${resp.mmdContent.length} chars` : "null"}, replaceBlocks=${resp.replaceBlocks?.length ?? 0}, nodeMapping=${mappingCount} [${mappingStr}]`,
);
traceOffloadModelIo({
sessionKey: this.sessionKeyFn(),
stage: "L2.backend",
provider: "backend",
model: `backend:${this.baseUrl}`,
url: `${this.baseUrl}/offload/v1/l2/generate`,
systemPrompt: "(constructed by backend)",
userPrompt: JSON.stringify(req),
responseContent: JSON.stringify(resp),
status: "ok",
durationMs,
logger: this.logger,
});
return resp;
}
/** L4 Skill Generation — synchronous await, uses unified timeout */
async l4Generate(req: L4Request): Promise<L4Response> {
this.logger.info(
`[context-offload] L4 >>> generate: mmd=${req.mmdFilename}, entries=${req.offloadEntries.length}, skillFocus=${req.skillFocus ?? "null"}`,
);
const startMs = Date.now();
const resp = await this.post<L4Response>("/offload/v1/l4/generate", req, BackendClient.TIMEOUT_MS);
const durationMs = Date.now() - startMs;
this.logger.info(
`[context-offload] L4 <<< skill="${resp.skillName}", content=${resp.skillContent?.length ?? 0} chars`,
);
traceOffloadModelIo({
sessionKey: this.sessionKeyFn(),
stage: "L4.backend",
provider: "backend",
model: `backend:${this.baseUrl}`,
url: `${this.baseUrl}/offload/v1/l4/generate`,
systemPrompt: "(constructed by backend)",
userPrompt: JSON.stringify(req),
responseContent: JSON.stringify(resp),
status: "ok",
durationMs,
logger: this.logger,
});
return resp;
}
/**
* Upload an arbitrary state payload to the backend `/offload/v1/store` endpoint.
* Fire-and-forget style — the caller is expected to `.catch(...)` rejections.
* Uses a short timeout so reporting never blocks hook execution meaningfully.
*/
async storeState(payload: StoreStatePayload): Promise<StoreStateResponse> {
// Short timeout — reporting must never stall the plugin
const timeoutMs = 10_000;
const startMs = Date.now();
try {
const resp = await this.post<StoreStateResponse>("/offload/v1/store", payload, timeoutMs);
const durationMs = Date.now() - startMs;
this.logger.info(
`[context-offload] store <<< insertedId=${resp.insertedId ?? "?"} (${durationMs}ms)`,
);
return resp;
} catch (err) {
const durationMs = Date.now() - startMs;
this.logger.warn(`[context-offload] store !!! failed after ${durationMs}ms: ${err}`);
throw err;
}
}
// ─── Internal ──────────────────────────────────────────────────────────
private async post<T>(path: string, body: unknown, timeoutMs: number): Promise<T> {
const url = `${this.baseUrl}${path}`;
const startMs = Date.now();
const bodyStr = JSON.stringify(body);
this.logger.info(`[context-offload] HTTP >>> POST ${url} (${bodyStr.length} bytes, timeout=${timeoutMs}ms)`);
const reqHeaders: Record<string, string> = {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(bodyStr)),
};
if (this.apiKey) {
reqHeaders["Authorization"] = `Bearer ${this.apiKey}`;
}
// Propagate identity headers so the backend can key stored state by
// `X-User-Id` (used as Mongo `_id` in /store) and scope by task.
try {
const uid = this.userIdFn();
if (uid) reqHeaders["X-User-Id"] = uid;
} catch { /* ignore — identity headers are best-effort */ }
try {
const tid = this.taskIdFn();
if (tid) reqHeaders["X-Task-Id"] = tid;
} catch { /* ignore */ }
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const transport = isHttps ? https : http;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
req.destroy(new Error("timeout"));
}, timeoutMs);
const req = transport.request(
{
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: "POST",
headers: reqHeaders,
...(isHttps ? { rejectUnauthorized: false } : {}),
},
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
res.on("end", () => {
clearTimeout(timer);
const durationMs = Date.now() - startMs;
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
this.logger.warn(
`[context-offload] HTTP <<< ${path}: ${res.statusCode} ${res.statusMessage} (${durationMs}ms) body=${data.slice(0, 500)}`,
);
reject(new Error(`Backend API error ${res.statusCode}: ${data}`));
return;
}
try {
const parsed = JSON.parse(data) as T;
this.logger.info(
`[context-offload] HTTP <<< ${path}: ${res.statusCode} (${durationMs}ms, ${data.length} bytes)`,
);
resolve(parsed);
} catch {
reject(new Error(`Backend response JSON parse error: ${data.slice(0, 500)}`));
}
});
},
);
req.on("error", (err: Error) => {
clearTimeout(timer);
const durationMs = Date.now() - startMs;
const errMsg = err.message;
const isTimeout = errMsg.includes("timeout");
this.logger.warn(
`[context-offload] HTTP !!! ${path}: ${isTimeout ? "TIMEOUT" : "ERROR"} after ${durationMs}ms — ${errMsg}`,
);
reject(err);
});
req.write(bodyStr);
req.end();
});
}
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Context Token Tracker
*
* Prefers API-reported input_tokens when available, supplements with tiktoken
* for message deltas and full fallback. Encoding is configurable via configure().
*/
import { getEncoding, type Tiktoken } from "js-tiktoken";
let ENCODING_NAME = "o200k_base";
let encoder: Tiktoken | null = null;
/**
* Configure the tiktoken encoding used for token counting.
* Call once at startup before any snapshot calls.
* If the encoding changes, the cached encoder is invalidated.
*/
export function configureTokenTracker(encodingName?: string): void {
if (encodingName && encodingName !== ENCODING_NAME) {
ENCODING_NAME = encodingName;
encoder = null; // invalidate cached encoder
}
}
function getEncoder(): Tiktoken {
if (!encoder) {
encoder = getEncoding(ENCODING_NAME as any);
}
return encoder;
}
/** Count tokens for a text string using tiktoken BPE encoding. */
export function tiktokenCount(text: string): number {
if (!text || text.length === 0) return 0;
try {
return getEncoder().encode(text).length;
} catch {
return Math.ceil(text.length / 4);
}
}
function extractLastUserText(messages: any[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
const wrapped = m.type === "message" ? m.message : m;
if (!wrapped || wrapped.role !== "user") continue;
const c = wrapped.content;
if (typeof c === "string") return c;
if (Array.isArray(c)) {
const parts: string[] = [];
for (const block of c) {
if (block.type === "text" && typeof block.text === "string")
parts.push(block.text);
}
return parts.length > 0 ? parts.join("\n") : null;
}
return null;
}
return null;
}
export interface ContextSnapshot {
timestamp: string;
stage: string;
encoding: string;
totalTokens: number;
systemTokens: number;
messagesTokens: number;
userPromptTokens: number;
messageCount: number;
}
// Internal metadata keys that should NOT be counted as tokens.
// These are plugin-internal markers that the LLM never sees.
const INTERNAL_KEYS = new Set([
"_offloaded",
"_mmdContextMessage",
"_mmdInjection",
"_contextOffloadProcessed",
]);
/** JSON replacer that strips internal metadata keys from serialization. */
export function jsonReplacer(key: string, value: unknown): unknown {
if (INTERNAL_KEYS.has(key)) return undefined;
return value;
}
// ─── Per-message token cache (WeakMap) ─────────────────────────────────────
// Cache token counts per message object. Entries are automatically GC'd when
// the message object is no longer referenced. Cache invalidation is triggered
// by _offloaded flag changes or explicit invalidateTokenCache() calls.
const msgTokenCache = new WeakMap<object, { tokens: number; offloaded: boolean }>();
function cachedMessageTokens(msg: any): number {
const offloaded = !!msg._offloaded;
const cached = msgTokenCache.get(msg);
if (cached && cached.offloaded === offloaded) return cached.tokens;
const str = JSON.stringify(msg, jsonReplacer);
const tokens = tiktokenCount(str);
msgTokenCache.set(msg, { tokens, offloaded });
return tokens;
}
/**
* Invalidate the token cache for a message whose content was mutated in-place
* (e.g. by replaceWithSummary). Must be called after any content mutation.
*/
export function invalidateTokenCache(msg: any): void {
msgTokenCache.delete(msg);
}
/**
* Tiktoken-only snapshot (messages JSON + optional user prompt dedupe).
* Does not write logs.
* Internal metadata keys (_offloaded, _mmdContextMessage, etc.) are stripped
* before serialization so they don't inflate the token count.
*
* Uses per-message WeakMap cache: unchanged messages (same object reference
* and same _offloaded flag) reuse previously computed token counts.
*/
export function buildTiktokenContextSnapshot(
stage: string,
messages: any[],
systemPromptText: string | null,
userPromptText: string | null,
precomputed?: { systemTokens?: number; userPromptTokens?: number },
): ContextSnapshot {
const systemTokens =
precomputed?.systemTokens != null
? precomputed.systemTokens
: tiktokenCount(systemPromptText ?? "");
// Per-message cached token counting (replaces full JSON.stringify + tiktoken)
let messagesTokens = 0;
for (const msg of messages) {
messagesTokens += cachedMessageTokens(msg);
}
// Compensate for JSON array structure overhead ([, commas, ])
messagesTokens += Math.ceil(messages.length * 0.5);
let userPromptTokens = 0;
if (precomputed?.userPromptTokens != null) {
userPromptTokens = precomputed.userPromptTokens;
} else if (userPromptText && userPromptText.trim()) {
const lastUserText = extractLastUserText(messages);
const alreadyInMessages =
lastUserText !== null && lastUserText.trim() === userPromptText.trim();
if (!alreadyInMessages) {
userPromptTokens = tiktokenCount(userPromptText);
}
}
const totalTokens = systemTokens + messagesTokens + userPromptTokens;
return {
timestamp: new Date().toISOString(),
stage,
encoding: ENCODING_NAME,
totalTokens,
systemTokens,
messagesTokens,
userPromptTokens,
messageCount: messages.length,
};
}
+584
View File
@@ -0,0 +1,584 @@
/**
* after_tool_call hook handler.
* Collects tool call + result pairs into the pending buffer.
* Post-tool token snapshot via tiktoken + inline L3 compression.
*/
import { nowChinaISO } from "../time-utils.js";
import { buildTiktokenContextSnapshot, type ContextSnapshot } from "../context-token-tracker.js";
import { traceOffloadDecision, traceMessagesSnapshot } from "../opik-tracer.js";
import { PLUGIN_DEFAULTS } from "../types.js";
import { readOffloadEntries, markOffloadStatus, readMmd } from "../storage.js";
import { createL3TokenCounter } from "../l3-token-counter.js";
import {
normalizeToolCallIdForLookup,
populateOffloadLookupMap,
getCurrentTaskNodeIds,
extractToolCallId,
isToolResultMessage,
isToolUseInAssistant,
extractToolUseIdFromAssistant,
} from "../l3-helpers.js";
import {
compressByScoreCascade,
aggressiveCompressUntilBelowThreshold,
buildHistoryMmdInjection,
removeExistingMmdInjections,
emergencyCompress,
EMERGENCY_MIN_MESSAGES_TO_KEEP,
isTokenOverflowError,
dumpMessagesSnapshot,
} from "./llm-input-l3.js";
import { MMD_MESSAGE_MARKER, findActiveMmdInsertionPoint, findHistoryMmdInsertionPoint } from "../mmd-injector.js";
import type { OffloadStateManager } from "../state-manager.js";
import type { PluginConfig, PluginLogger, ToolPair } from "../types.js";
import type { BackendClient } from "../backend-client.js";
import {
buildL3TriggerReport,
classifyPatchEffectiveness,
reportL3Trigger,
recordToolCall,
REPORT_TYPE_L3,
L3_FIXED_PATCH_COST_TOKENS,
} from "../state-reporter.js";
function isHeartbeatToolCall(event: any, cachedParams: any): boolean {
try {
const params = event.params ?? cachedParams;
if (!params) return false;
const raw = typeof params === "string" ? params : JSON.stringify(params);
return raw.includes("HEARTBEAT.md");
} catch {
return false;
}
}
function _extractParamsFromMessages(messages: any[], toolCallId: string): any {
if (!messages || !Array.isArray(messages) || !toolCallId) return null;
const normId = toolCallId.replace(/_/g, "");
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
const role = msg.role ?? msg.message?.role ?? msg.type;
if (role !== "assistant") continue;
const content = msg.content ?? msg.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
if (
(block.type === "tool_use" || block.type === "toolCall") &&
(block.id === toolCallId || block.id?.replace(/_/g, "") === normId)
) {
const input = block.input ?? _tryParseArgs(block.arguments);
if (input && typeof input === "object" && (input as any)._offloaded) continue;
return input ?? null;
}
}
}
const toolCalls = msg.tool_calls ?? msg.message?.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls) {
if (tc.id === toolCallId || tc.id?.replace(/_/g, "") === normId) {
return _tryParseArgs(tc.function?.arguments) ?? tc.function?.parameters ?? tc.input ?? null;
}
}
}
}
return null;
}
function _tryParseArgs(args: any): any {
if (args == null) return null;
if (typeof args === "object") return args;
if (typeof args !== "string") return null;
try { return JSON.parse(args); } catch { return null; }
}
export function createAfterToolCallHandler(
stateManager: OffloadStateManager,
logger: PluginLogger,
getContextWindow: (() => number) | undefined,
pluginConfig: Partial<PluginConfig> | undefined,
backendClient?: BackendClient | null,
) {
return async (event: any, ctx: any) => {
// Skip internal memory-pipeline sessions
const _sk = stateManager.getLastSessionKey() ?? ctx?.sessionKey;
if (typeof _sk === "string" && /memory-.*-session-\d+/.test(_sk)) return;
// Count every observed tool call for cumulative reporting. Done before
// any early-return branch so the counter reflects the real invocation
// rate, not just the cases where L3 actually runs.
recordToolCall();
const eventKeys = event ? Object.keys(event) : [];
const hasMsgsKey = "messages" in (event ?? {});
const msgsValue = event?.messages;
const hasMsgs = msgsValue && Array.isArray(msgsValue);
logger.info(`[context-offload] after_tool_call event keys=[${eventKeys.join(",")}], hasMsgsKey=${hasMsgsKey}, msgsType=${typeof msgsValue}, isArray=${Array.isArray(msgsValue)}, len=${hasMsgs ? msgsValue.length : "N/A"}`);
// ── Patch-effectiveness detection ──
// The upstream runtime patch is expected to populate event.messages with
// the current conversation. If it is missing/empty the patch is NOT in
// effect and L3 compression cannot run from this hook. Report that
// explicitly so operators can detect misconfigurations.
const _patchStatus = classifyPatchEffectiveness(event, "after_tool_call");
if (_patchStatus.status !== "effective") {
logger.warn(
`[context-offload] after_tool_call patch check: NOT EFFECTIVE (status=${_patchStatus.status}). ` +
`event.messages is ${Array.isArray(msgsValue) ? "empty array" : typeof msgsValue}. ` +
`L3 compression will be skipped this turn.`,
);
if (backendClient) {
try {
backendClient
.storeState({
reportType: REPORT_TYPE_L3,
reportedAt: new Date().toISOString(),
sessionKey: _sk ?? null,
stage: "after_tool_call",
triggerReason: "patch_not_effective",
patch: _patchStatus,
pluginState: {
l15Settled: stateManager.l15Settled === true,
pendingCount: stateManager.getPendingCount(),
activeMmdFile: stateManager.getActiveMmdFile?.() ?? null,
},
fixedPatchCostTokens: L3_FIXED_PATCH_COST_TOKENS,
})
.catch((err) => logger.warn(`[context-offload] patch-miss report failed: ${err}`));
} catch { /* ignore */ }
}
}
const toolCallId = event.toolCallId ?? ctx.toolCallId ?? `auto-${Date.now()}`;
const cachedParams = stateManager.consumeToolParams(toolCallId);
const messagesParams =
!event.params && !cachedParams
? _extractParamsFromMessages(event.messages, toolCallId)
: null;
const resolvedParams = event.params ?? cachedParams ?? messagesParams ?? {};
if (stateManager.isProcessed(toolCallId)) return;
if (isHeartbeatToolCall(event, resolvedParams)) {
stateManager.processedToolCallIds.add(toolCallId);
return;
}
// Skip tool calls that are stuck at approval-pending — they have no useful
// result and would waste L1 LLM tokens generating meaningless summaries.
// Only check the structured status field to avoid false positives from
// tool results that happen to contain "Approval required" in their text.
const isApprovalPending = event.result?.details?.status === "approval-pending";
if (isApprovalPending) {
logger.info(`[context-offload] after_tool_call: SKIP approval-pending tool ${event.toolName} (${toolCallId})`);
stateManager.processedToolCallIds.add(toolCallId);
return;
}
const pair: ToolPair = {
toolName: event.toolName,
toolCallId,
params: resolvedParams,
result: event.result,
error: event.error,
timestamp: nowChinaISO(),
durationMs: event.durationMs,
};
stateManager.addToolPair(pair);
logger.info(`[context-offload] after_tool_call: buffered ${event.toolName} (${toolCallId}), pending=${stateManager.getPendingCount()}, duration=${event.durationMs ?? "N/A"}ms`);
// Cache latest user context for L2
if (event.messages && Array.isArray(event.messages) && event.messages.length > 0 && !stateManager.cachedLatestTurnMessages) {
const turn = _extractLatestTurnFromMessages(event.messages);
if (turn) stateManager.cachedLatestTurnMessages = turn;
}
// In-loop active MMD injection / update.
// Only inject after L1.5 has settled (task boundary determined, activeMmdFile set).
// This also picks up L2 MMD content updates (L2 runs async and may patch the MMD
// file between tool calls).
if (event.messages && Array.isArray(event.messages)) {
try {
const l15Settled = stateManager.l15Settled;
const activeMmdFile = stateManager.getActiveMmdFile();
if (!l15Settled) {
logger.info(`[context-offload] after_tool_call MMD: SKIP (L1.5 not settled yet)`);
} else if (!activeMmdFile) {
logger.info(`[context-offload] after_tool_call MMD: SKIP (no active MMD file)`);
} else {
const mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
if (mmdContent) {
let taskGoal = "";
const metaMatch = mmdContent.match(/^%%\{\s*(.*?)\s*\}%%/);
if (metaMatch) {
try { const meta = JSON.parse(`{${metaMatch[1]}}`); taskGoal = meta.taskGoal || ""; } catch { /* */ }
}
const mmdText = [
`<current_task_context>`,
`【当前活跃任务的mermaid流程图】这是你最近正在执行的任务的阶段性记录(此条下方的tool use未被汇总,进程可能有延迟,仅供参考)。`,
taskGoal ? `**任务目标:** ${taskGoal}` : "",
`**任务文件:** ${activeMmdFile}`,
"```mermaid", mmdContent, "```",
`标记为 "doing" 的节点是近期焦点(注:可能有延迟,下方的tool use未被统计,仅供参考),"done" 的已完成。请参考此保持方向感,避免重复已完成的工作。`,
`</current_task_context>`,
].filter((line) => line !== "").join("\n");
const existingIdx = event.messages.findIndex((m: any) => m._mmdContextMessage === "active");
const newMsg = { role: "user", content: [{ type: "text", text: mmdText }], _mmdContextMessage: "active" };
if (existingIdx >= 0) {
// Check if content changed (L1.5 switched file or L2 updated content)
const oldContent = Array.isArray(event.messages[existingIdx].content)
? event.messages[existingIdx].content.map((c: any) => c.text ?? "").join("")
: (event.messages[existingIdx].content ?? "");
const contentChanged = !oldContent.includes(activeMmdFile) || oldContent !== mmdText;
if (contentChanged) {
event.messages[existingIdx] = newMsg;
logger.info(`[context-offload] after_tool_call MMD: UPDATED at [${existingIdx}], file=${activeMmdFile}, contentChanged=true`);
_dumpMessagesAfterMmd(event.messages, "UPDATED", logger);
} else {
logger.info(`[context-offload] after_tool_call MMD: unchanged, skip update`);
}
} else {
const insertIdx = findActiveMmdInsertionPoint(event.messages);
event.messages.splice(insertIdx, 0, newMsg);
logger.info(`[context-offload] after_tool_call MMD: INJECTED at [${insertIdx}], file=${activeMmdFile}, msgs=${event.messages.length}`);
_dumpMessagesAfterMmd(event.messages, "INJECTED", logger);
}
} else {
logger.info(`[context-offload] after_tool_call MMD: file=${activeMmdFile} content is null`);
}
}
} catch (err) {
logger.warn(`[context-offload] after_tool_call MMD error: ${err}`);
}
}
// Post-tool token snapshot + inline L3 compression
const _compStart = Date.now();
const _msgsBefore = event.messages?.length ?? 0;
const _contextWindow = typeof getContextWindow === "function" ? getContextWindow() : PLUGIN_DEFAULTS.defaultContextWindow;
const _mildThreshold = Math.floor(_contextWindow * (pluginConfig?.mildOffloadRatio ?? PLUGIN_DEFAULTS.mildOffloadRatio));
const _aggressiveThreshold = Math.floor(_contextWindow * (pluginConfig?.aggressiveCompressRatio ?? PLUGIN_DEFAULTS.aggressiveCompressRatio));
// P0.5: checkAndCompressAfterToolCall now returns snapBefore/snapAfter
// so we no longer need separate buildTiktokenContextSnapshot calls here
const _compResult = await checkAndCompressAfterToolCall(event, stateManager, logger, pluginConfig, getContextWindow);
const _compDuration = Date.now() - _compStart;
const _msgsAfter = event.messages?.length ?? 0;
logger.info(`[context-offload] after_tool_call L3 check completed: ${_compDuration}ms`);
// QUICK-SKIP: no snapshots, skip trace
if (_compResult) {
const _snapBefore = _compResult.snapBefore ?? null;
const _snapAfter = _compResult.snapAfter ?? null;
const _tokensBefore = _snapBefore?.totalTokens ?? 0;
const _tokensAfter = _snapAfter?.totalTokens ?? 0;
const _tokensSaved = _tokensBefore - _tokensAfter;
const _utilisation = _contextWindow > 0 ? _tokensAfter / _contextWindow : 0;
traceOffloadDecision({
sessionKey: stateManager.getLastSessionKey(),
stage: "L3.after_tool_call.completed",
input: {
toolName: event.toolName,
toolCallId,
messagesBefore: _msgsBefore,
tokensBefore: _tokensBefore,
durationMs: _compDuration,
contextWindow: _contextWindow,
mildThreshold: _mildThreshold,
aggressiveThreshold: _aggressiveThreshold,
},
output: {
messagesAfter: _msgsAfter,
messagesRemoved: _msgsBefore - _msgsAfter,
pendingCount: stateManager.getPendingCount(),
tokensBefore: _tokensBefore,
tokensAfter: _tokensAfter,
tokensSaved: _tokensSaved,
utilisation: `${(_utilisation * 100).toFixed(1)}%`,
aboveMild: _tokensAfter >= _mildThreshold,
aboveAggressive: _tokensAfter >= _aggressiveThreshold,
offloadMapAvailable: stateManager.confirmedOffloadIds?.size ?? 0,
mildReplacedCount: _compResult.mildReplacedCount ?? 0,
mildReplacedDetails: _compResult.mildReplacedDetails ?? [],
},
logger,
});
// Upload plugin state + L3 token accounting to backend /store.
// Only report when a real compression check happened (i.e. we have a snapshot).
// Trigger reason is derived from the threshold that fired first.
const _triggerReason = _tokensBefore >= _aggressiveThreshold
? "above_aggressive"
: _tokensBefore >= _mildThreshold
? "above_mild"
: "below_mild";
try {
const report = buildL3TriggerReport({
stage: "after_tool_call",
triggerReason: _triggerReason,
stateManager,
event,
contextWindow: _contextWindow,
mildThreshold: _mildThreshold,
aggressiveThreshold: _aggressiveThreshold,
tokensBefore: _tokensBefore,
tokensAfter: _tokensAfter,
messagesBefore: _msgsBefore,
messagesAfter: _msgsAfter,
durationMs: _compDuration,
aboveMild: _tokensBefore >= _mildThreshold,
aboveAggressive: _tokensBefore >= _aggressiveThreshold,
mildReplacedCount: _compResult.mildReplacedCount ?? 0,
aggressiveDeletedCount: _compResult.aggressiveDeletedCount ?? 0,
emergencyTriggered: _compResult.emergencyTriggered ?? false,
emergencyDeletedCount: _compResult.emergencyDeletedCount ?? 0,
});
reportL3Trigger(backendClient ?? null, report, logger);
} catch (reportErr) {
logger.warn(`[context-offload] build L3 report failed: ${reportErr}`);
}
}
// Trace full messages snapshot at end of after_tool_call
if (event.messages && Array.isArray(event.messages)) {
traceMessagesSnapshot({
sessionKey: stateManager.getLastSessionKey(),
stage: "after_tool_call.end",
messages: event.messages,
label: `tool=${event.toolName}`,
extra: {
toolName: event.toolName,
toolCallId,
pendingCount: stateManager.getPendingCount(),
activeMmdFile: stateManager.getActiveMmdFile() ?? null,
l15Settled: stateManager.l15Settled,
},
logger,
});
}
};
}
/** P1: Quick heuristic token estimate to skip full tiktoken when clearly below threshold. */
function quickTokenEstimate(messages: any[], stateManager: OffloadStateManager): number {
if (stateManager.lastKnownTotalTokens <= 0) return Infinity;
const newMsgCount = messages.length - stateManager.lastKnownMessageCount;
if (newMsgCount <= 0) return stateManager.lastKnownTotalTokens;
let newTokensEst = 0;
for (let i = messages.length - newMsgCount; i < messages.length; i++) {
const c = messages[i]?.content ?? messages[i]?.message?.content;
const text = typeof c === "string" ? c : Array.isArray(c) ? JSON.stringify(c) : "";
newTokensEst += text ? _quickCountTokens(text) : 50;
}
return stateManager.lastKnownTotalTokens + newTokensEst;
}
/** CJK-aware quick token estimate: CJK chars ~1.5 tok/char, rest ~0.25 tok/char. */
function _quickCountTokens(text: string): number {
let cjk = 0;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if ((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) || (c >= 0xf900 && c <= 0xfaff)) cjk++;
}
const rest = text.length - cjk;
return Math.ceil(cjk * 1.5 + rest / 4);
}
async function checkAndCompressAfterToolCall(
event: any,
stateManager: OffloadStateManager,
logger: PluginLogger,
pluginConfig: Partial<PluginConfig> | undefined,
getContextWindow: (() => number) | undefined,
): Promise<{
mildReplacedCount: number;
mildReplacedDetails: Array<{ toolCallId: string; score: number; summaryPreview: string; originalLength?: number; summaryLength?: number }>;
aggressiveDeletedCount: number;
emergencyTriggered: boolean;
emergencyDeletedCount: number;
snapBefore: ContextSnapshot | null;
snapAfter: ContextSnapshot | null;
} | null> {
try {
const messages = event.messages;
if (!messages || !Array.isArray(messages) || messages.length === 0) return null;
const sysPrompt = stateManager.cachedSystemPrompt ?? null;
const precomputed = stateManager.cachedSystemPromptTokens != null
? { systemTokens: stateManager.cachedSystemPromptTokens, userPromptTokens: 0 }
: undefined;
const contextWindow = typeof getContextWindow === "function" ? getContextWindow() : PLUGIN_DEFAULTS.defaultContextWindow;
const mildRatio = pluginConfig?.mildOffloadRatio ?? PLUGIN_DEFAULTS.mildOffloadRatio;
const mildThreshold = Math.floor(contextWindow * mildRatio);
// P1: Quick heuristic skip — avoid full tiktoken when clearly below threshold
// Every MAX_CONSECUTIVE_QUICK_SKIPS, force a precise calculation to prevent drift
const MAX_CONSECUTIVE_QUICK_SKIPS = 5;
const quickEst = quickTokenEstimate(messages, stateManager);
if (quickEst < mildThreshold * 0.85 && stateManager.consecutiveQuickSkips < MAX_CONSECUTIVE_QUICK_SKIPS) {
stateManager.consecutiveQuickSkips++;
logger.info(`[context-offload] L3(after_tool_call) QUICK-SKIP: est≈${quickEst} < ${Math.floor(mildThreshold * 0.85)} (85% mild), streak=${stateManager.consecutiveQuickSkips}/${MAX_CONSECUTIVE_QUICK_SKIPS}`);
return null;
}
const snap = buildTiktokenContextSnapshot("after_tool_call", messages, sysPrompt, null, precomputed);
// Update stateManager with precise values and reset skip counter
stateManager.lastKnownTotalTokens = snap.totalTokens;
stateManager.lastKnownMessageCount = messages.length;
stateManager.consecutiveQuickSkips = 0;
const aggressiveRatio = pluginConfig?.aggressiveCompressRatio ?? PLUGIN_DEFAULTS.aggressiveCompressRatio;
const aggressiveThreshold = Math.floor(contextWindow * aggressiveRatio);
const utilisation = snap.totalTokens / contextWindow;
const aboveMild = snap.totalTokens >= mildThreshold;
const aboveAggressive = snap.totalTokens >= aggressiveThreshold;
logger.info(
`[context-offload] L3(after_tool_call) token snapshot: tool=${event.toolName} total=${snap.totalTokens} ` +
`msgCount=${messages.length} utilisation=${(utilisation * 100).toFixed(1)}% ` +
`${aboveAggressive ? "⚠ ABOVE_AGGRESSIVE" : aboveMild ? "⚠ ABOVE_MILD" : "✓ OK"}`,
);
if (snap.totalTokens < mildThreshold) return { mildReplacedCount: 0, mildReplacedDetails: [], aggressiveDeletedCount: 0, emergencyTriggered: false, emergencyDeletedCount: 0, snapBefore: snap, snapAfter: snap };
// L3 compression
const offloadEntries = await readOffloadEntries(stateManager.ctx);
const offloadMap = new Map();
populateOffloadLookupMap(offloadMap, offloadEntries);
const currentTaskNodeIds = await getCurrentTaskNodeIds(stateManager);
const countTokens = createL3TokenCounter(pluginConfig, logger);
const aggressiveDeleteRatio = (pluginConfig as any)?.aggressiveDeleteRatio ?? PLUGIN_DEFAULTS.aggressiveDeleteRatio;
const mildScanRatio = (pluginConfig as any)?.mildOffloadScanRatio ?? PLUGIN_DEFAULTS.mildOffloadScanRatio;
let workingTokens = snap.totalTokens;
let _aggDeletedCount = 0;
// Aggressive
if (workingTokens >= aggressiveThreshold) {
logger.info(`[context-offload] L3(after_tool_call) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}`);
const result = await aggressiveCompressUntilBelowThreshold(
messages, offloadMap, currentTaskNodeIds, aggressiveDeleteRatio,
stateManager, logger, aggressiveThreshold, countTokens, sysPrompt, null,
);
workingTokens = result.remainingTokens;
_aggDeletedCount = result.deletedCount ?? result.allDeletedToolCallIds.length;
logger.info(`[context-offload] L3(after_tool_call) AGGRESSIVE done: rounds=${result.rounds ?? "?"}, deleted=${result.allDeletedToolCallIds.length}, remaining≈${workingTokens}, stalledByUserMsg=${result.stalledByUserMsg ?? false}`);
dumpMessagesSnapshot("atc-after-aggressive", messages, logger);
if (result.allDeletedToolCallIds.length > 0) {
const statusUpdates = new Map<string, string | boolean>();
for (const id of result.allDeletedToolCallIds) {
statusUpdates.set(id, "deleted");
stateManager.confirmedOffloadIds.add(id);
stateManager.deletedOffloadIds.add(id);
}
markOffloadStatus(stateManager.ctx, statusUpdates).catch(() => {});
const mmdInjection = await buildHistoryMmdInjection(
result.allDeletedToolCallIds, offloadMap, offloadEntries,
stateManager, logger, countTokens, contextWindow, pluginConfig,
);
if (mmdInjection.injectedMessages.length > 0) {
removeExistingMmdInjections(messages);
const histInsertIdx = findHistoryMmdInsertionPoint(messages);
messages.splice(histInsertIdx, 0, ...mmdInjection.injectedMessages);
workingTokens += mmdInjection.totalMmdTokens;
dumpMessagesSnapshot("atc-after-aggressive-mmd-injection", messages, logger);
}
}
// If aggressive stalled due to user message protection and still above threshold,
// force emergency to make progress
if (result.stalledByUserMsg && workingTokens >= aggressiveThreshold) {
logger.warn(`[context-offload] L3(after_tool_call) AGGRESSIVE stalled, forcing emergency fallback`);
stateManager._forceEmergencyNext = true;
}
}
// Mild
let _mildResult: { mildReplacedCount: number; mildReplacedDetails: Array<{ toolCallId: string; score: number; summaryPreview: string; originalLength?: number; summaryLength?: number }> } = { mildReplacedCount: 0, mildReplacedDetails: [] };
if (workingTokens >= mildThreshold) {
logger.info(`[context-offload] L3(after_tool_call) MILD: tokens≈${workingTokens} >= ${mildThreshold}`);
const cascadeResult = compressByScoreCascade(messages, offloadMap, currentTaskNodeIds, mildScanRatio, logger);
const detailStr = cascadeResult.replacedDetails.map((d) => `${d.toolCallId}(score=${d.score}): "${d.summaryPreview}"`).join(" | ");
logger.info(`[context-offload] L3(after_tool_call) MILD done: replaced=${cascadeResult.replacedCount}, threshold=${cascadeResult.finalThreshold}${detailStr ? `, details=[${detailStr}]` : ""}`);
_mildResult = { mildReplacedCount: cascadeResult.replacedCount, mildReplacedDetails: cascadeResult.replacedDetails };
if (cascadeResult.replacedCount > 0) {
for (const id of cascadeResult.replacedToolCallIds) {
stateManager.confirmedOffloadIds.add(id);
}
const mildStatusUpdates = new Map<string, string | boolean>();
for (const id of cascadeResult.replacedToolCallIds) {
mildStatusUpdates.set(id, true);
}
markOffloadStatus(stateManager.ctx, mildStatusUpdates).catch(() => {});
}
dumpMessagesSnapshot("atc-after-mild", messages, logger);
}
const emergencyRatio = pluginConfig?.emergencyCompressRatio ?? PLUGIN_DEFAULTS.emergencyCompressRatio;
const emergencyTargetRatio = pluginConfig?.emergencyTargetRatio ?? PLUGIN_DEFAULTS.emergencyTargetRatio;
const emergencyThreshold = Math.floor(contextWindow * emergencyRatio);
const emergencyTarget = Math.floor(contextWindow * emergencyTargetRatio);
const preEmergencySnap = buildTiktokenContextSnapshot("after_tool_call_pre_emergency", messages, sysPrompt, null, precomputed);
workingTokens = preEmergencySnap.totalTokens;
const forceEmergency = stateManager._forceEmergencyNext === true;
if (forceEmergency) stateManager._forceEmergencyNext = false;
let _emergencyTriggered = false;
let _emergencyDeletedCount = 0;
if ((workingTokens >= emergencyThreshold || forceEmergency) && messages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
_emergencyTriggered = true;
const emergencyResult = emergencyCompress(messages, emergencyTarget, countTokens, sysPrompt, null, logger);
_emergencyDeletedCount = emergencyResult.deletedCount;
if (emergencyResult.deletedToolCallIds.length > 0) {
const statusUpdates = new Map<string, string | boolean>();
for (const id of emergencyResult.deletedToolCallIds) {
statusUpdates.set(id, "deleted");
stateManager.confirmedOffloadIds.add(id);
stateManager.deletedOffloadIds.add(id);
}
markOffloadStatus(stateManager.ctx, statusUpdates).catch(() => {});
}
dumpMessagesSnapshot("atc-after-emergency", messages, logger);
}
if (stateManager.isLoaded()) await stateManager.save();
// Update stateManager with final token count for future quick estimates
stateManager.lastKnownTotalTokens = preEmergencySnap.totalTokens;
stateManager.lastKnownMessageCount = messages.length;
return { ..._mildResult, aggressiveDeletedCount: _aggDeletedCount, emergencyTriggered: _emergencyTriggered, emergencyDeletedCount: _emergencyDeletedCount, snapBefore: snap, snapAfter: preEmergencySnap };
} catch (err) {
logger.warn?.(`[context-offload] after_tool_call L3 error: ${String(err)}`);
if (isTokenOverflowError(err)) stateManager._forceEmergencyNext = true;
return null;
}
}
function _extractLatestTurnFromMessages(messages: any[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg._mmdContextMessage || msg._mmdInjection) continue;
const role = msg.role ?? msg.message?.role ?? msg.type;
if (role !== "user") continue;
const text = _extractText(msg);
if (text && text.length > 10) return `[User]: ${text.slice(0, 500)}`;
}
return null;
}
function _extractText(msg: any): string {
const content = msg.content ?? msg.message?.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.filter((c: any) => c.type === "text" && typeof c.text === "string").map((c: any) => c.text).join(" ");
}
return "";
}
/** Dump all messages after MMD injection for diagnostics (debug-level only). */
function _dumpMessagesAfterMmd(messages: any[], action: string, logger: PluginLogger): void {
const mmdCount = messages.filter((m: any) => m._mmdContextMessage || m._mmdInjection).length;
const offloadedCount = messages.filter((m: any) => m._offloaded).length;
logger.info(`[context-offload] POST-MMD-${action} (after_tool_call): ${messages.length} msgs, mmd=${mmdCount}, offloaded=${offloadedCount}`);
}
+131
View File
@@ -0,0 +1,131 @@
/**
* before_agent_start hook handler.
* Implements L1.5: Task completion judgment and active MMD management.
*
* Backend-only mode: local LLM judge has been removed.
* Only normalizeJudgment and handleTaskTransition are exported for use by index.ts.
*/
import { readMmd, writeMmd, deleteMmd, type StorageContext } from "../storage.js";
import type { OffloadStateManager } from "../state-manager.js";
import type { PluginLogger, TaskJudgment } from "../types.js";
/**
* Normalize a raw L1.5 judgment response (from backend)
* into a safe TaskJudgment with guaranteed boolean fields.
* Handles null/undefined values from backend fallback responses.
*/
export function normalizeJudgment(raw: Record<string, unknown>): TaskJudgment | null {
// All-null response from backend means "LLM unavailable" — treat as no judgment
if (raw.taskCompleted == null && raw.isContinuation == null && raw.isLongTask == null) {
return null;
}
return {
taskCompleted: Boolean(raw.taskCompleted),
isContinuation: Boolean(raw.isContinuation),
continuationMmdFile:
typeof raw.continuationMmdFile === "string" ? raw.continuationMmdFile : undefined,
newTaskLabel:
typeof raw.newTaskLabel === "string" ? raw.newTaskLabel : undefined,
isLongTask: Boolean(raw.isLongTask),
};
}
export async function handleTaskTransition(
stateManager: OffloadStateManager,
judgment: TaskJudgment,
logger: PluginLogger,
): Promise<void> {
const currentMmd = stateManager.getActiveMmdFile();
const ctx = stateManager.ctx;
const isEmptyShellMmd = async (filename: string | null): Promise<boolean> => {
if (!filename) return false;
try {
const content = await readMmd(ctx, filename);
if (!content) return false;
const trimmed = content.trim();
if (trimmed.includes("%%{")) return false;
const lines = trimmed.split("\n").filter((l) => l.trim().length > 0);
return lines.length <= 3;
} catch {
return false;
}
};
const cleanupIfEmptyShell = async (oldFilename: string | null) => {
if (!oldFilename) return;
const isShell = await isEmptyShellMmd(oldFilename);
if (isShell) {
try {
await deleteMmd(ctx, oldFilename);
} catch {
/* ignore */
}
}
};
const createNewMmd = async (label: string) => {
const num = await stateManager.nextMmdNumber();
const paddedNum = String(num).padStart(3, "0");
const filename = `${paddedNum}-${label}.mmd`;
logger.info(`[context-offload] L1.5: Creating new MMD: ${filename} (replacing ${currentMmd ?? "(none)"})`);
await cleanupIfEmptyShell(currentMmd);
stateManager.setActiveMmd(filename, label);
const initialMmd = `flowchart TD\n ${paddedNum}-N1["${label}"]\n`;
await writeMmd(ctx, filename, initialMmd);
logger.info(`[context-offload] L1.5: New MMD created and activated: ${filename}`);
};
const reactivateMmd = async (contFile: string) => {
logger.info(`[context-offload] L1.5: Reactivating MMD: ${contFile} (current=${currentMmd ?? "(none)"})`);
if (currentMmd && currentMmd !== contFile) {
await cleanupIfEmptyShell(currentMmd);
}
const mmdId = contFile.replace(/^\d+-/, "").replace(/\.mmd$/, "");
stateManager.setActiveMmd(contFile, mmdId);
const existing = await readMmd(ctx, contFile);
if (existing === null) {
const prefixMatch = contFile.match(/^(\d+)-/);
const prefix = prefixMatch ? prefixMatch[1] : "000";
const initialMmd = `flowchart TD\n ${prefix}-N1["${mmdId}"]\n`;
await writeMmd(ctx, contFile, initialMmd);
logger.warn(`[context-offload] L1.5: Reactivated MMD file was missing, wrote initial template: ${contFile}`);
}
};
if (judgment.taskCompleted) {
logger.info(`[context-offload] L1.5: Task COMPLETED — continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, contFile=${judgment.continuationMmdFile ?? "N/A"}, newLabel=${judgment.newTaskLabel ?? "N/A"}`);
if (judgment.isContinuation && judgment.continuationMmdFile) {
await reactivateMmd(judgment.continuationMmdFile);
} else if (judgment.isLongTask && judgment.newTaskLabel) {
const currentLabel = currentMmd
? currentMmd.replace(/^\d+-/, "").replace(/\.mmd$/, "")
: null;
if (currentLabel !== judgment.newTaskLabel) {
await createNewMmd(judgment.newTaskLabel);
}
} else if (judgment.isContinuation && !judgment.continuationMmdFile) {
if (!currentMmd) {
stateManager.setActiveMmd(null, null);
}
} else {
logger.info("[context-offload] L1.5: No MMD needed (casual/short), clearing active MMD");
stateManager.setActiveMmd(null, null);
}
} else {
logger.info(`[context-offload] L1.5: Task NOT completed — continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, current=${currentMmd ?? "(none)"}`);
if (judgment.isContinuation) {
if (!currentMmd && judgment.continuationMmdFile) {
await reactivateMmd(judgment.continuationMmdFile);
}
} else if (judgment.isLongTask && judgment.newTaskLabel) {
const currentLabel = currentMmd
? currentMmd.replace(/^\d+-/, "").replace(/\.mmd$/, "")
: null;
if (currentLabel !== judgment.newTaskLabel) {
await createNewMmd(judgment.newTaskLabel);
}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
/**
* before_prompt_build hook handler.
*
* Three-phase context cleanup before llm_input:
* 1. Fast-path re-apply: re-offload confirmed mild replacements + delete aggressive-deleted messages
* 2. Token guard: if still above thresholds, run full L3 (Aggressive + Mild) inline
* 3. MMD injection: injects active/history MMD into messages
*/
import { PLUGIN_DEFAULTS } from "../types.js";
import { readOffloadEntries, markOffloadStatus } from "../storage.js";
import { buildTiktokenContextSnapshot } from "../context-token-tracker.js";
import { traceOffloadDecision } from "../opik-tracer.js";
import { injectMmdIntoMessages, findHistoryMmdInsertionPoint } from "../mmd-injector.js";
import { createL3TokenCounter } from "../l3-token-counter.js";
import {
normalizeToolCallIdForLookup,
getOffloadEntry,
populateOffloadLookupMap,
isToolResultMessage,
extractToolCallId,
isOnlyToolUseAssistant,
extractAllToolUseIds,
isAssistantMessageWithToolUse,
replaceWithSummary,
replaceAssistantToolUseWithSummary,
compressNonCurrentToolUseBlocks,
getCurrentTaskNodeIds,
} from "../l3-helpers.js";
import {
compressByScoreCascade,
aggressiveCompressUntilBelowThreshold,
buildHistoryMmdInjection,
removeExistingMmdInjections,
emergencyCompress,
EMERGENCY_MIN_MESSAGES_TO_KEEP,
isTokenOverflowError,
filterHeartbeatMessages,
dumpMessagesSnapshot,
} from "./llm-input-l3.js";
import type { OffloadStateManager } from "../state-manager.js";
import type { PluginConfig, PluginLogger } from "../types.js";
export function createBeforePromptBuildHandler(
stateManager: OffloadStateManager,
logger: PluginLogger,
getContextWindow: (() => number) | undefined,
pluginConfig: Partial<PluginConfig> | undefined,
) {
return async (event: any, _ctx: any) => {
// Skip internal memory-pipeline sessions
const _sk = stateManager.getLastSessionKey() ?? _ctx?.sessionKey;
if (typeof _sk === "string" && /memory-.*-session-\d+/.test(_sk)) return;
logger.info(`[context-offload] before_prompt_build CALLED, msgs=${event?.messages?.length ?? "?"}`);
try {
const messages = event.messages;
if (!messages || !Array.isArray(messages) || messages.length === 0) return;
filterHeartbeatMessages(messages, logger);
const sessionKey = stateManager.getLastSessionKey();
const hasConfirmed = stateManager.confirmedOffloadIds && stateManager.confirmedOffloadIds.size > 0;
const hasDeleted = stateManager.deletedOffloadIds && stateManager.deletedOffloadIds.size > 0;
if (!hasConfirmed && !hasDeleted) {
await injectMmdIntoMessages(messages, stateManager, logger, getContextWindow, pluginConfig, { waitForL15: true });
return undefined;
}
// Phase 1: Fast-path
const snapBefore = buildTiktokenContextSnapshot("before_prompt_pre", messages, null, null);
const tokensBefore = snapBefore.totalTokens;
const offloadEntries = await readOffloadEntries(stateManager.ctx);
const offloadMap = new Map();
populateOffloadLookupMap(offloadMap, offloadEntries);
stateManager.setCachedOffloadMap(offloadMap);
let fastReplaceApplied = 0;
const indicesToDelete: number[] = [];
const deletedToolCallIdsForMmd: string[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const tid = extractToolCallId(msg);
const tidNorm = tid ? normalizeToolCallIdForLookup(tid) : null;
if (tid && hasDeleted && (stateManager.deletedOffloadIds.has(tid) || (tidNorm && stateManager.deletedOffloadIds.has(tidNorm)))) {
indicesToDelete.push(i);
if (isToolResultMessage(msg)) deletedToolCallIdsForMmd.push(tid);
continue;
}
if (hasDeleted && isOnlyToolUseAssistant(msg)) {
const tuIds = extractAllToolUseIds(msg);
const allDeleted = tuIds.length > 0 && tuIds.every((id) =>
stateManager.deletedOffloadIds.has(id) || stateManager.deletedOffloadIds.has(normalizeToolCallIdForLookup(id)));
if (allDeleted) { indicesToDelete.push(i); continue; }
}
// FIX: For mixed assistant messages (text + tool_use), strip deleted tool_use
// blocks to prevent orphaned tool_use without matching tool_result (Anthropic 400).
if (hasDeleted && isAssistantMessageWithToolUse(msg) && !isOnlyToolUseAssistant(msg)) {
const content = msg.type === "message" ? msg.message?.content : msg.content;
if (Array.isArray(content)) {
for (let j = content.length - 1; j >= 0; j--) {
const block = content[j] as any;
if ((block.type === "tool_use" || block.type === "toolCall") && block.id) {
const blockIdNorm = normalizeToolCallIdForLookup(block.id);
if (stateManager.deletedOffloadIds.has(block.id) || stateManager.deletedOffloadIds.has(blockIdNorm)) {
content.splice(j, 1);
}
}
}
}
}
if (msg._offloaded) continue;
if (tid && hasConfirmed && (stateManager.confirmedOffloadIds.has(tid) || (tidNorm && stateManager.confirmedOffloadIds.has(tidNorm)))) {
const entry = getOffloadEntry(offloadMap, tid);
if (entry && isToolResultMessage(msg)) {
replaceWithSummary(msg, entry);
msg._offloaded = true;
fastReplaceApplied++;
}
}
if (isOnlyToolUseAssistant(msg)) {
const tuIds = extractAllToolUseIds(msg);
const allConfirmed = tuIds.length > 0 && tuIds.every((id) =>
stateManager.confirmedOffloadIds.has(id) || stateManager.confirmedOffloadIds.has(normalizeToolCallIdForLookup(id)));
if (allConfirmed) {
const tuEntries = tuIds.map((id) => getOffloadEntry(offloadMap, id)).filter(Boolean) as any[];
if (tuEntries.length === tuIds.length) {
replaceAssistantToolUseWithSummary(msg, tuEntries);
msg._offloaded = true;
fastReplaceApplied++;
}
}
} else if (isAssistantMessageWithToolUse(msg)) {
compressNonCurrentToolUseBlocks(msg, offloadMap, new Set(), stateManager.confirmedOffloadIds);
}
}
if (indicesToDelete.length > 0) {
for (let k = indicesToDelete.length - 1; k >= 0; k--) {
messages.splice(indicesToDelete[k], 1);
}
}
// Phase 2: Token guard
const contextWindow = typeof getContextWindow === "function" ? getContextWindow() : PLUGIN_DEFAULTS.defaultContextWindow;
const mildRatio = pluginConfig?.mildOffloadRatio ?? PLUGIN_DEFAULTS.mildOffloadRatio;
const aggressiveRatio = pluginConfig?.aggressiveCompressRatio ?? PLUGIN_DEFAULTS.aggressiveCompressRatio;
const mildThreshold = Math.floor(contextWindow * mildRatio);
const aggressiveThreshold = Math.floor(contextWindow * aggressiveRatio);
const snapGuard = buildTiktokenContextSnapshot("before_prompt_guard", messages, null, null);
let workingTokens = snapGuard.totalTokens;
if (workingTokens >= aggressiveThreshold) {
const countTokens = createL3TokenCounter(pluginConfig, logger);
const aggressiveDeleteRatio = (pluginConfig as any)?.aggressiveDeleteRatio ?? PLUGIN_DEFAULTS.aggressiveDeleteRatio;
const currentTaskNodeIds = await getCurrentTaskNodeIds(stateManager);
const result = await aggressiveCompressUntilBelowThreshold(
messages, offloadMap, currentTaskNodeIds, aggressiveDeleteRatio,
stateManager, logger, aggressiveThreshold, countTokens, null, null,
);
workingTokens = result.remainingTokens;
dumpMessagesSnapshot("bpb-after-aggressive", messages, logger);
if (result.allDeletedToolCallIds.length > 0) {
const statusUpdates = new Map<string, string | boolean>();
for (const id of result.allDeletedToolCallIds) {
statusUpdates.set(id, "deleted");
statusUpdates.set(normalizeToolCallIdForLookup(id), "deleted");
stateManager.confirmedOffloadIds.add(id);
stateManager.deletedOffloadIds.add(id);
}
markOffloadStatus(stateManager.ctx, statusUpdates).catch((err: any) =>
logger.error(`[context-offload] markOffloadStatus error: ${err}`));
const mmdInjection = await buildHistoryMmdInjection(
result.allDeletedToolCallIds, offloadMap, offloadEntries,
stateManager, logger, countTokens, contextWindow, pluginConfig,
);
if (mmdInjection.injectedMessages.length > 0) {
removeExistingMmdInjections(messages);
const histInsertIdx = findHistoryMmdInsertionPoint(messages);
messages.splice(histInsertIdx, 0, ...mmdInjection.injectedMessages);
workingTokens += mmdInjection.totalMmdTokens;
dumpMessagesSnapshot("bpb-after-aggressive-mmd-injection", messages, logger);
}
}
// If aggressive stalled due to user message protection, force emergency
if (result.stalledByUserMsg && workingTokens >= aggressiveThreshold) {
logger.warn(`[context-offload] before_prompt_build AGGRESSIVE stalled, forcing emergency fallback`);
stateManager._forceEmergencyNext = true;
}
}
if (workingTokens >= mildThreshold) {
const currentTaskNodeIds = await getCurrentTaskNodeIds(stateManager);
const mildScanRatio = (pluginConfig as any)?.mildOffloadScanRatio ?? PLUGIN_DEFAULTS.mildOffloadScanRatio;
const cascadeResult = compressByScoreCascade(messages, offloadMap, currentTaskNodeIds, mildScanRatio, logger);
if (cascadeResult.replacedCount > 0) {
for (const id of cascadeResult.replacedToolCallIds) {
stateManager.confirmedOffloadIds.add(id);
}
const mildStatusUpdates = new Map<string, string | boolean>();
for (const id of cascadeResult.replacedToolCallIds) {
mildStatusUpdates.set(id, true);
}
markOffloadStatus(stateManager.ctx, mildStatusUpdates).catch((err: any) =>
logger.error(`[context-offload] markOffloadStatus error: ${err}`));
}
dumpMessagesSnapshot("bpb-after-mild", messages, logger);
}
{
const emergencyRatio = pluginConfig?.emergencyCompressRatio ?? PLUGIN_DEFAULTS.emergencyCompressRatio;
const emergencyTargetRatio = pluginConfig?.emergencyTargetRatio ?? PLUGIN_DEFAULTS.emergencyTargetRatio;
const emergencyThreshold = Math.floor(contextWindow * emergencyRatio);
const emergencyTarget = Math.floor(contextWindow * emergencyTargetRatio);
const preEmergencySnap = buildTiktokenContextSnapshot("before_prompt_pre_emergency", messages, null, null);
workingTokens = preEmergencySnap.totalTokens;
const forceEmergency = stateManager._forceEmergencyNext === true;
if (forceEmergency) stateManager._forceEmergencyNext = false;
if ((workingTokens >= emergencyThreshold || forceEmergency) && messages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
const countTokensBpb = createL3TokenCounter(pluginConfig, logger);
const emergencyResult = emergencyCompress(messages, emergencyTarget, countTokensBpb, null, null, logger);
workingTokens = emergencyResult.remainingTokens;
if (emergencyResult.deletedToolCallIds.length > 0) {
const emergencyStatusUpdates = new Map<string, string | boolean>();
for (const id of emergencyResult.deletedToolCallIds) {
emergencyStatusUpdates.set(id, "deleted");
stateManager.confirmedOffloadIds.add(id);
stateManager.deletedOffloadIds.add(id);
}
markOffloadStatus(stateManager.ctx, emergencyStatusUpdates).catch((err: any) =>
logger.error(`[context-offload] markOffloadStatus error: ${err}`));
}
dumpMessagesSnapshot("bpb-after-emergency", messages, logger);
}
}
// Phase 3: MMD Injection
await injectMmdIntoMessages(messages, stateManager, logger, getContextWindow, pluginConfig, { waitForL15: true });
traceOffloadDecision({
sessionKey: stateManager.getLastSessionKey(),
stage: "L3.before_prompt_build.completed",
input: {
phase: "before_prompt_build",
confirmedOffloadIds: stateManager.confirmedOffloadIds.size,
deletedOffloadIds: stateManager.deletedOffloadIds.size,
},
output: {
messagesAfter: messages.length,
},
logger,
});
return undefined;
} catch (err) {
logger.error(`[context-offload] before_prompt_build error: ${err}`);
if (isTokenOverflowError(err)) {
stateManager._forceEmergencyNext = true;
}
return;
}
};
}
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
/**
* llm_output hook handler.
* Detects when L1 should be force-triggered based on pending pair count.
*
* Backend-only mode: local LLM pipeline references removed.
*/
import type { OffloadStateManager } from "../state-manager.js";
import type { PluginConfig } from "../types.js";
const DEFAULT_FORCE_TRIGGER_THRESHOLD = 4;
/**
* Check if L1 should be force-triggered (called from after_tool_call when
* pending count exceeds threshold).
*/
export function shouldForceL1(
stateManager: OffloadStateManager,
pluginConfig: Partial<PluginConfig> | undefined,
): boolean {
const threshold =
pluginConfig?.forceTriggerThreshold ?? DEFAULT_FORCE_TRIGGER_THRESHOLD;
return stateManager.getPendingCount() >= threshold;
}
+1922
View File
File diff suppressed because it is too large Load Diff
+320
View File
@@ -0,0 +1,320 @@
/**
* L3 shared helper functions.
* Used by both before-prompt-build (fast-path re-apply) and llm-input-l3 (compression).
*/
import { readMmd, type StorageContext } from "./storage.js";
import { invalidateTokenCache } from "./context-token-tracker.js";
import type { OffloadEntry } from "./types.js";
import type { OffloadStateManager } from "./state-manager.js";
/**
* Anthropic-style tool ids sometimes appear as `toolu_bdrk_01...` (underscores)
* in offload.jsonl while the live session uses `toolubdrk01...`. Normalize for lookup.
*/
export function normalizeToolCallIdForLookup(id: string): string {
return id.replace(/_/g, "");
}
export function getOffloadEntry(
map: Map<string, OffloadEntry>,
toolCallId: string,
): OffloadEntry | undefined {
return (
map.get(toolCallId) ?? map.get(normalizeToolCallIdForLookup(toolCallId))
);
}
/** Index offload entries by canonical id and by underscore-free form when they differ. */
export function populateOffloadLookupMap(
map: Map<string, OffloadEntry>,
entries: OffloadEntry[],
): void {
for (const entry of entries) {
map.set(entry.tool_call_id, entry);
const alt = normalizeToolCallIdForLookup(entry.tool_call_id);
if (alt !== entry.tool_call_id && !map.has(alt)) {
map.set(alt, entry);
}
}
}
/** Check if a message is a tool result */
export function isToolResultMessage(msg: any): boolean {
if (msg.type === "message") {
const message = msg.message;
if (message?.role === "toolResult" || message?.role === "tool") {
return true;
}
}
if (msg.role === "toolResult" || msg.role === "tool") {
return true;
}
return false;
}
/** Extract tool call ID from a tool result message */
export function extractToolCallId(msg: any): string | null {
if (msg.type === "message") {
const message = msg.message;
if (message?.toolCallId) return message.toolCallId;
if (message?.tool_call_id) return message.tool_call_id;
}
if (msg.toolCallId) return msg.toolCallId;
if (msg.tool_call_id) return msg.tool_call_id;
return null;
}
/** Check if a content block is a tool use block */
export function isToolUseBlock(block: any): boolean {
return block.type === "tool_use" || block.type === "toolCall";
}
/** Get message content (handles transcript wrapper format) */
export function getMessageContent(msg: any): any {
if (msg.type === "message") {
const message = msg.message;
return message?.content;
}
return msg.content;
}
/** Check if an assistant message contains tool_use blocks */
export function isAssistantMessageWithToolUse(msg: any): boolean {
const content = getMessageContent(msg);
if (!Array.isArray(content)) return false;
return content.some((block: any) => isToolUseBlock(block));
}
/** Check if message contains tool_use (alias) */
export function isToolUseInAssistant(msg: any): boolean {
return isAssistantMessageWithToolUse(msg);
}
/** Extract tool_use ID from an assistant message (first tool_use block) */
export function extractToolUseIdFromAssistant(msg: any): string | null {
const content = getMessageContent(msg);
if (!Array.isArray(content)) return null;
for (const block of content) {
const b = block as any;
if (isToolUseBlock(b) && b.id) return b.id;
}
return null;
}
/**
* Check if an assistant message contains ONLY tool_use blocks (no text or other content).
*/
export function isOnlyToolUseAssistant(msg: any): boolean {
const wrapped = msg.type === "message" ? msg.message : msg;
const role = wrapped?.role;
if (role !== "assistant") return false;
const content = getMessageContent(msg);
if (!Array.isArray(content) || content.length === 0) return false;
return content.every((block: any) => isToolUseBlock(block));
}
/** Extract ALL tool_use block IDs from an assistant message */
export function extractAllToolUseIds(msg: any): string[] {
const content = getMessageContent(msg);
if (!Array.isArray(content)) return [];
const ids: string[] = [];
for (const block of content) {
const b = block as any;
if (isToolUseBlock(b) && b.id) ids.push(b.id);
}
return ids;
}
const COMPACT_TOOL_CALL_MAX_TOTAL = 300;
const COMPACT_ARG_TRUNCATE_AT = 60;
/** Truncate a tool_call string to a compact form */
export function compactToolCall(toolCall: string | null | undefined): string {
if (!toolCall || typeof toolCall !== "string") return toolCall ?? "";
if (toolCall.length <= COMPACT_TOOL_CALL_MAX_TOTAL) return toolCall;
const parenIdx = toolCall.indexOf("(");
if (parenIdx < 0) {
return toolCall.slice(0, COMPACT_TOOL_CALL_MAX_TOTAL) + "…";
}
const toolName = toolCall.slice(0, parenIdx);
const argsStr = toolCall.endsWith(")")
? toolCall.slice(parenIdx + 1, -1)
: toolCall.slice(parenIdx + 1);
let args: any;
try {
args = JSON.parse(argsStr);
} catch {
return (
toolName +
"(" +
argsStr.slice(0, COMPACT_TOOL_CALL_MAX_TOTAL - toolName.length - 5) +
"…)"
);
}
if (typeof args !== "object" || args === null || Array.isArray(args)) {
return (
toolName +
"(" +
argsStr.slice(0, COMPACT_TOOL_CALL_MAX_TOTAL - toolName.length - 5) +
"…)"
);
}
const compacted: Record<string, any> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string" && value.length > COMPACT_ARG_TRUNCATE_AT) {
compacted[key] = value.slice(0, COMPACT_ARG_TRUNCATE_AT) + "…";
} else if (typeof value === "object" && value !== null) {
const s = JSON.stringify(value);
compacted[key] = s.length > COMPACT_ARG_TRUNCATE_AT ? "[object]" : value;
} else {
compacted[key] = value;
}
}
let result = `${toolName}(${JSON.stringify(compacted)})`;
if (result.length > COMPACT_TOOL_CALL_MAX_TOTAL) {
result = result.slice(0, COMPACT_TOOL_CALL_MAX_TOTAL) + "…";
}
return result;
}
/**
* Compress a pure tool_use assistant message by replacing each tool_use block's
* input/arguments with a compact offload summary.
*/
export function replaceAssistantToolUseWithSummary(
msg: any,
entries: OffloadEntry[],
): void {
const content = getMessageContent(msg);
if (!Array.isArray(content)) return;
const entryById = new Map<string, OffloadEntry>();
for (const entry of entries) {
const id = entry.tool_call_id;
if (id) {
entryById.set(id, entry);
entryById.set(normalizeToolCallIdForLookup(id), entry);
}
}
let idx = 0;
for (const block of content) {
const b = block as any;
if (!isToolUseBlock(b)) continue;
const entry =
(b.id && entryById.get(b.id)) ??
(b.id && entryById.get(normalizeToolCallIdForLookup(b.id))) ??
entries[idx];
idx++;
if (!entry) continue;
const compactInput = {
_offloaded: true,
node_id: entry.node_id ?? "N/A",
tool_call: compactToolCall(entry.tool_call),
};
if (b.arguments !== undefined) {
b.arguments = compactInput;
} else {
b.input = compactInput;
}
}
invalidateTokenCache(msg);
}
/** Replace a tool result message's content with the offload summary.
* Returns original and summary content lengths for diagnostics. */
export function replaceWithSummary(msg: any, entry: OffloadEntry): { originalLength: number; summaryLength: number } {
const summaryContent = [
`[Offloaded Tool Result | node: ${entry.node_id ?? "N/A"}]`,
`Summary: ${entry.summary}`,
`result_ref: ${entry.result_ref} (read this file for full tool call and raw result)`,
].join("\n");
// Measure original content length
let originalLength = 0;
const extractLength = (content: any): number => {
if (typeof content === "string") return content.length;
if (Array.isArray(content)) return content.reduce((acc: number, c: any) => acc + (typeof c === "string" ? c.length : (c.text?.length ?? 0)), 0);
return 0;
};
if (msg.type === "message") {
const message = msg.message;
if (message) {
originalLength = extractLength(message.content);
if (Array.isArray(message.content)) {
message.content = [{ type: "text", text: summaryContent }];
} else {
message.content = summaryContent;
}
}
} else {
originalLength = extractLength(msg.content);
if (Array.isArray(msg.content)) {
msg.content = [{ type: "text", text: summaryContent }];
} else {
msg.content = summaryContent;
}
}
invalidateTokenCache(msg);
return { originalLength, summaryLength: summaryContent.length };
}
/**
* Compress non-current-task tool_use blocks inside an assistant message.
*/
export function compressNonCurrentToolUseBlocks(
msg: any,
offloadMap: Map<string, OffloadEntry>,
currentTaskNodeIds: Set<string>,
replacedIds?: Set<string>,
): void {
const content = getMessageContent(msg);
if (!Array.isArray(content)) return;
for (const block of content) {
const b = block as any;
if (!isToolUseBlock(b)) continue;
const id = b.id;
if (!id) continue;
if (
replacedIds &&
!replacedIds.has(id) &&
!replacedIds.has(normalizeToolCallIdForLookup(id))
) {
continue;
}
const entry = getOffloadEntry(offloadMap, id);
if (!entry) continue;
const idInReplacedIds =
replacedIds &&
(replacedIds.has(id) || replacedIds.has(normalizeToolCallIdForLookup(id)));
if (!idInReplacedIds && entry.node_id && currentTaskNodeIds.has(entry.node_id))
continue;
const compactInput = {
_offloaded: true,
node_id: entry.node_id ?? "N/A",
tool_call: compactToolCall(entry.tool_call),
};
if (b.arguments !== undefined) {
b.arguments = compactInput;
} else {
b.input = compactInput;
}
}
invalidateTokenCache(msg);
}
/** Get the set of node_ids belonging to the current active task */
export async function getCurrentTaskNodeIds(
stateManager: OffloadStateManager,
): Promise<Set<string>> {
const nodeIds = new Set<string>();
const activeMmdFile = stateManager.getActiveMmdFile();
if (!activeMmdFile) return nodeIds;
const mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
if (!mmdContent) return nodeIds;
const nodePattern = /\b(\d+-N\d+|N\d+)\b/g;
let match: RegExpExecArray | null;
while ((match = nodePattern.exec(mmdContent)) !== null) {
nodeIds.add(match[1]);
}
return nodeIds;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* L3 token counting: prefer tiktoken (exact for OpenAI-style BPE), with heuristic fallback.
*/
import { getEncoding, type Tiktoken } from "js-tiktoken";
import { PLUGIN_DEFAULTS, type PluginConfig, type PluginLogger } from "./types.js";
import { estimateL3MixedTokensHeuristic } from "./l3-token-helpers.js";
export function createL3TokenCounter(
pluginConfig: Partial<PluginConfig> | undefined,
logger: PluginLogger | undefined,
): (text: string) => number {
const mode =
(pluginConfig as any)?.l3TokenCountMode ?? PLUGIN_DEFAULTS.l3TokenCountMode;
if (mode === "heuristic") {
return (text: string) => estimateL3MixedTokensHeuristic(text);
}
const encodingName: string =
((pluginConfig as any)?.l3TiktokenEncoding ??
PLUGIN_DEFAULTS.l3TiktokenEncoding) as string;
let enc: Tiktoken | null = null;
return (text: string): number => {
try {
if (!enc) {
enc = getEncoding(encodingName as any);
logger?.debug?.(`[context-offload] L3 token counter: tiktoken encoding=${encodingName}`);
}
return enc!.encode(text).length;
} catch (err) {
logger?.warn?.(
`[context-offload] tiktoken encode failed (${String(err)}), falling back to heuristic`,
);
return estimateL3MixedTokensHeuristic(text);
}
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Heuristic token estimate (中文/1.7 + 非中文/4) when tiktoken is disabled or fails.
*/
function countCjkChars(text: string): number {
let n = 0;
for (const ch of text) {
const c = ch.codePointAt(0)!;
if (
(c >= 0x4e00 && c <= 0x9fff) ||
(c >= 0x3400 && c <= 0x4dbf) ||
(c >= 0xf900 && c <= 0xfaff)
) {
n++;
}
}
return n;
}
export function estimateL3MixedTokensHeuristic(text: string): number {
const cjk = countCjkChars(text);
const rest = Math.max(0, text.length - cjk);
return Math.ceil(cjk / 1.7 + rest / 4);
}
+374
View File
@@ -0,0 +1,374 @@
/**
* Unified MMD injector.
*
* Maintains a single marked message in event.messages containing the active
* MMD (+ history MMDs). Used by both before_prompt_build (full inject after
* L1.5 judgment) and after_tool_call (incremental update when L2 refreshes
* the MMD file during the tool loop).
*
* The marker property `_mmdContextMessage` is used to locate the message for
* replacement. L3 compression must skip messages carrying this marker.
*/
import { readMmd, listMmds } from "./storage.js";
import { PLUGIN_DEFAULTS, type PluginConfig, type PluginLogger } from "./types.js";
import { createL3TokenCounter } from "./l3-token-counter.js";
import { traceOffloadDecision } from "./opik-tracer.js";
import { isToolResultMessage, isAssistantMessageWithToolUse } from "./l3-helpers.js";
import type { OffloadStateManager } from "./state-manager.js";
/** Marker property on the injected message object. */
export const MMD_MESSAGE_MARKER = "_mmdContextMessage";
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Full inject — called from assemble / before_prompt_build (every user-message round)
* and from llm_input (every LLM call).
*
* Only injects the ACTIVE MMD (determined by L1.5).
* History MMDs are NOT injected here — they are only injected by L3 aggressive
* compression (buildHistoryMmdInjection) after messages are deleted, as a
* replacement for lost conversation context.
*/
export async function injectMmdIntoMessages(
messages: any[],
stateManager: OffloadStateManager,
logger: PluginLogger,
getContextWindow: (() => number) | undefined,
pluginConfig: Partial<PluginConfig> | undefined,
options?: { waitForL15?: boolean },
): Promise<{ mmdTokens: number }> {
// When waitForL15 is set (assemble path), skip injection entirely if L1.5 hasn't settled yet.
// This preserves any previously injected MMD messages without removing or replacing them.
if (options?.waitForL15 && !stateManager.l15Settled) {
logger.info(
`[context-offload] mmd-injector inject: SKIPPED — L1.5 not settled yet (waitForL15=true), msgs=${messages.length}`,
);
return { mmdTokens: stateManager.lastMmdInjectedTokens };
}
const injReady = stateManager.isMmdInjectionReady();
const actFile = stateManager.getActiveMmdFile();
logger.info(
`[context-offload] mmd-injector inject: injectionReady=${injReady}, activeMmdFile=${actFile ?? "null"}, msgs=${messages.length}`,
);
if (!injReady) {
removeMmdMessages(messages);
stateManager.lastMmdInjectedTokens = 0;
return { mmdTokens: 0 };
}
const contextWindow =
typeof getContextWindow === "function"
? getContextWindow()
: PLUGIN_DEFAULTS.defaultContextWindow;
const mmdMaxTokenRatio =
pluginConfig?.mmdMaxTokenRatio ?? PLUGIN_DEFAULTS.mmdMaxTokenRatio;
const countTokens = createL3TokenCounter(pluginConfig, logger);
const activeMmdText = await buildActiveMmdText(stateManager, logger);
logger.info(
`[context-offload] mmd-injector inject: activeMmdText=${activeMmdText ? `${activeMmdText.length} chars` : "null"}, contextWindow=${contextWindow}`,
);
removeMmdMessages(messages);
let totalMmdTokens = 0;
if (activeMmdText) {
const activeMsg: any = {
role: "user",
content: [{ type: "text", text: activeMmdText }],
[MMD_MESSAGE_MARKER]: "active",
};
const insertIdx = findActiveMmdInsertionPoint(messages);
messages.splice(insertIdx, 0, activeMsg);
totalMmdTokens += countTokens(activeMmdText);
}
stateManager.lastMmdInjectedTokens = totalMmdTokens;
const activeMmd = stateManager.getActiveMmdFile();
logger.info(
`[context-offload] mmd-injector: injected active MMD into messages (${totalMmdTokens} tokens, file=${activeMmd})`,
);
// Summary after active MMD injection (was full dump, now aggregated)
if (totalMmdTokens > 0) {
const mmdCount = messages.filter((m: any) => m[MMD_MESSAGE_MARKER] === "active" || m._mmdInjection).length;
const offloadedCount = messages.filter((m: any) => m._offloaded).length;
logger.info(`[context-offload] POST-ACTIVE-MMD-INJECT: ${messages.length} msgs, mmd=${mmdCount}, offloaded=${offloadedCount}`);
}
traceOffloadDecision({
sessionKey: stateManager.getLastSessionKey(),
stage: "mmd-injector.inject",
input: {
activeMmd,
mmdInjectionReady: true,
contextWindow,
mmdMaxTokenRatio,
},
output: {
result: `MMD 注入 messages${totalMmdTokens} tokens (active only)`,
mmdTokens: totalMmdTokens,
hasActive: !!activeMmdText,
hasHistory: false,
mmdTokenBudget: Math.floor(contextWindow * mmdMaxTokenRatio),
},
logger,
});
return { mmdTokens: totalMmdTokens };
}
/**
* Incremental update — called from after_tool_call (every tool-loop iteration).
*/
export async function maybeUpdateMmdInMessages(
messages: any[],
stateManager: OffloadStateManager,
logger: PluginLogger,
getContextWindow: (() => number) | undefined,
pluginConfig: Partial<PluginConfig> | undefined,
): Promise<boolean> {
const injectionReady = stateManager.isMmdInjectionReady();
const activeMmdFile = stateManager.getActiveMmdFile();
logger.info(
`[context-offload] mmd-injector maybeUpdate: injectionReady=${injectionReady}, activeMmdFile=${activeMmdFile ?? "null"}, msgs=${messages.length}`,
);
if (!injectionReady) return false;
if (!activeMmdFile) return false;
let mmdContent: string | null;
try {
mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
logger.info(
`[context-offload] mmd-injector maybeUpdate: readMmd result=${mmdContent ? `${mmdContent.length} chars` : "null"}`,
);
} catch (e) {
logger.info(`[context-offload] mmd-injector maybeUpdate: readMmd error=${e}`);
return false;
}
if (!mmdContent) return false;
const newFp = computeFingerprint(mmdContent);
const lastFp = stateManager.getInjectedMmdVersion(activeMmdFile);
if (newFp === lastFp) return false;
logger.info(
`[context-offload] mmd-injector: MMD updated (${activeMmdFile}), refreshing in-loop`,
);
await injectMmdIntoMessages(
messages,
stateManager,
logger,
getContextWindow,
pluginConfig,
);
return true;
}
// ─── Insertion point helpers (exported for after-tool-call & llm-input-l3) ──
function findLatestUserMessageIndex(messages: any[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg[MMD_MESSAGE_MARKER]) continue;
if (msg._mmdInjection) continue;
const role = msg.role ?? msg.message?.role ?? msg.type;
if (role === "user") return i;
}
return -1;
}
/**
* Find the best insertion point for the active MMD message.
*
* Strategy: insert AFTER the latest user message (in the second half of the
* conversation), so the MMD sits between the user's question and the ongoing
* tool loop — not at position 0 which pollutes the oldest context.
*
* Fallback: if the latest user message is in the first half (unlikely during
* active tool loops), insert at the start of the trailing tool-result/assistant
* block, clamped to within 30 messages from the tail.
*
* IMPORTANT: The insertion point must NOT split a tool_call / tool_result pair.
* If the candidate position is between an assistant message containing tool_use
* and its corresponding tool_result(s), shift backwards to before the assistant
* message so the pair stays intact.
*/
export function findActiveMmdInsertionPoint(messages: any[]): number {
if (messages.length <= 2) return 0;
const halfIdx = Math.floor(messages.length / 2);
const latestUserIdx = findLatestUserMessageIndex(messages);
let insertIdx: number;
if (latestUserIdx >= halfIdx) {
insertIdx = latestUserIdx + 1;
} else {
let loopStart = messages.length;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg[MMD_MESSAGE_MARKER]) continue;
if (msg._mmdInjection) continue;
const role = msg.role ?? msg.message?.role ?? msg.type;
if (role === "toolResult" || role === "tool" || role === "assistant") {
loopStart = i;
} else {
break;
}
}
const maxDistFromTail = 30;
const minInsertIdx = Math.max(0, messages.length - maxDistFromTail);
insertIdx = Math.max(loopStart, minInsertIdx);
insertIdx = Math.min(insertIdx, Math.max(0, messages.length - 1));
}
// Guard: don't insert between an assistant tool_use message and its tool_result(s).
// If the message at insertIdx is a tool_result, walk backwards past the tool_result
// cluster and the preceding assistant tool_use message.
insertIdx = adjustForToolCallPair(messages, insertIdx);
return insertIdx;
}
/**
* Adjusts an insertion index so it does not land between an assistant message
* containing tool_use blocks and the subsequent tool_result messages.
*
* Walk backwards: if we see tool_result messages at `idx`, keep going back;
* if we then land on an assistant message with tool_use, step before it too.
*/
function adjustForToolCallPair(messages: any[], idx: number): number {
if (idx <= 0 || idx >= messages.length) return idx;
// Check if the message AT idx (or the preceding context) forms a tool pair boundary.
// Case 1: idx points at a tool_result → we're inside a tool pair, walk back.
let cur = idx;
while (cur > 0 && cur < messages.length) {
const msg = messages[cur];
if (msg[MMD_MESSAGE_MARKER] || msg._mmdInjection) { cur--; continue; }
if (!isToolResultMessage(msg)) break;
cur--;
}
// After skipping tool_results, check if the message at `cur` is an assistant with tool_use.
// If so, we must insert BEFORE this assistant message to keep the pair intact.
if (cur >= 0 && cur < messages.length) {
const msg = messages[cur];
if (!msg[MMD_MESSAGE_MARKER] && !msg._mmdInjection && isAssistantMessageWithToolUse(msg)) {
return cur;
}
}
// Also check the message just BEFORE idx — if it's an assistant with tool_use,
// and idx's message is a tool_result, we already handled above. But if idx-1 is
// assistant with tool_use and idx is tool_result, the while-loop above would
// have caught it. This covers the edge case where idx is right after an assistant
// tool_use (before any tool_result arrives yet).
if (idx > 0 && idx < messages.length) {
const prevMsg = messages[idx - 1];
if (!prevMsg[MMD_MESSAGE_MARKER] && !prevMsg._mmdInjection && isAssistantMessageWithToolUse(prevMsg)) {
const curMsg = messages[idx];
if (isToolResultMessage(curMsg)) {
return idx - 1;
}
}
}
// If we moved backward, return the adjusted position; otherwise return original.
return cur < idx ? cur : idx;
}
/**
* Find insertion point for history MMD messages (injected after AGGRESSIVE deletion).
*
* Strategy: insert BEFORE the active MMD (if present) or at the same position
* where the active MMD would go. History context should precede active context
* so the LLM reads chronologically: history → active → recent tool loop.
*
* Unlike active MMD, history MMD should NOT go to index 0 — it should sit in
* the middle of the conversation, just before the active task context.
*/
export function findHistoryMmdInsertionPoint(messages: any[]): number {
// If there's an existing active MMD, insert just before it
for (let i = 0; i < messages.length; i++) {
if (messages[i][MMD_MESSAGE_MARKER] === "active") return i;
}
// No active MMD — use the same heuristic as active MMD insertion
return findActiveMmdInsertionPoint(messages);
}
function removeMmdMessages(messages: any[]): void {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i][MMD_MESSAGE_MARKER]) {
messages.splice(i, 1);
}
}
}
async function buildActiveMmdText(
stateManager: OffloadStateManager,
logger: PluginLogger,
): Promise<string | null> {
const activeMmdFile = stateManager.getActiveMmdFile();
if (!activeMmdFile) return null;
return await buildActiveMmdBlock(activeMmdFile, stateManager, logger);
}
async function buildActiveMmdBlock(
activeMmdFile: string,
stateManager: OffloadStateManager,
logger: PluginLogger,
): Promise<string | null> {
try {
const mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
if (!mmdContent) return null;
stateManager.setInjectedMmdVersion(
activeMmdFile,
computeFingerprint(mmdContent),
);
const metaMatch = mmdContent.match(/^%%\{\s*(.*?)\s*\}%%/);
let taskGoal = "";
if (metaMatch) {
try {
const meta = JSON.parse(`{${metaMatch[1]}}`);
taskGoal = meta.taskGoal || "";
} catch {
/* ignore */
}
}
const nodePattern = /\b(\d+-N\d+|N\d+)\b/g;
const nodeIds: string[] = [];
let match: RegExpExecArray | null;
while ((match = nodePattern.exec(mmdContent)) !== null) {
if (!nodeIds.includes(match[1])) nodeIds.push(match[1]);
}
return [
`<current_task_context>`,
`【当前活跃任务的mermaid流程图】这是你最近正在执行的任务的阶段性记录(此条下方的tool use未被汇总,进程可能有延迟,仅供参考)。`,
taskGoal ? `**任务目标:** ${taskGoal}` : "",
`**任务文件:** ${activeMmdFile}`,
nodeIds.length > 0
? `**节点索引:** 可通过 node_id 在 offload.{sessionid}.jsonl 中查找对应的工具调用记录。如需查看某个节点对应的原始工具调用与完整结果,请在 offload.{sessionid}.jsonl 中找到对应条目的 result_ref 并读取该文件。`
: "",
"```mermaid",
mmdContent,
"```",
`标记为 "doing" 的节点是近期焦点(注:可能有延迟,下方的tool use未被统计,仅供参考),"done" 的已完成。请参考此保持方向感,避免重复已完成的工作。`,
`</current_task_context>`,
]
.filter((line) => line !== "")
.join("\n");
} catch (err) {
logger.error(
`[context-offload] mmd-injector: Error building active MMD block: ${err}`,
);
return null;
}
}
function computeFingerprint(content: string): string {
return `${content.length}:${content.slice(0, 64)}`;
}
+66
View File
@@ -0,0 +1,66 @@
/**
* MMD metadata parsing utility.
* Extracted from prompts/l15.ts — pure data parsing, not a prompt.
*/
export interface MmdMeta {
filename: string;
path: string;
taskGoal: string;
createdTime: string | null;
updatedTime: string | null;
doneCount: number;
doingCount: number;
todoCount: number;
nodeSummaries: Array<{ nodeId: string; status: string; summary: string }>;
}
export function parseMmdMeta(
filename: string,
mmdPath: string,
content: string,
): MmdMeta {
const meta: MmdMeta = {
filename,
path: mmdPath,
taskGoal: "",
createdTime: null,
updatedTime: null,
doneCount: 0,
doingCount: 0,
todoCount: 0,
nodeSummaries: [],
};
const metaMatch = content.match(/^%%\{\s*(.*?)\s*\}%%/);
if (metaMatch) {
try {
const p = JSON.parse(`{${metaMatch[1]}}`) as Record<string, unknown>;
meta.taskGoal = (p.taskGoal as string) || "";
meta.createdTime = (p.createdTime as string) || null;
meta.updatedTime = (p.updatedTime as string) || null;
} catch {
/* ignore */
}
}
meta.doneCount = (content.match(/status:\s*done/gi) || []).length;
meta.doingCount = (content.match(/status:\s*doing/gi) || []).length;
meta.todoCount = (content.match(/status:\s*todo/gi) || []).length;
const nodeRe = /(\d{3}-N\d+)\["([^"]*?)"\]/g;
let m: RegExpExecArray | null;
while ((m = nodeRe.exec(content)) !== null) {
const nodeText = m[2];
const summaryMatch = nodeText.match(/summary:\s*(.+?)(?:<br\/>|$)/i);
const statusMatch = nodeText.match(/status:\s*(\w+)/i);
if (summaryMatch) {
meta.nodeSummaries.push({
nodeId: m[1],
status: statusMatch ? statusMatch[1] : "unknown",
summary: summaryMatch[1].trim().slice(0, 100),
});
}
}
if (meta.nodeSummaries.length > 2) {
meta.nodeSummaries = meta.nodeSummaries.slice(-2);
}
return meta;
}
+373
View File
@@ -0,0 +1,373 @@
/**
* Opik observability tracer for context offload plugin.
* Wraps the opik npm package with graceful degradation when not installed.
*/
import type { PluginLogger } from "./types.js";
import { getEnv } from "../utils/env.js";
// Opik client types (minimal shape to avoid hard dependency)
interface OpikClient {
trace(params: Record<string, unknown>): OpikTrace;
flush(): Promise<void>;
}
interface OpikTrace {
update(params: Record<string, unknown>): void;
end(): void;
span(params: Record<string, unknown>): OpikSpan;
}
interface OpikSpan {
update(params: Record<string, unknown>): void;
end(): void;
}
let client: OpikClient | null = null;
let tracerEnabled = false;
let tracerInitTried = false;
function extractLayerTag(stage: string): string {
const match = stage.match(/^(L\d+(?:\.\d+)?)/i);
if (!match) return "Lx-unknown";
return match[1].toUpperCase();
}
function extractL3TriggerSource(stage: string): string | undefined {
if (!stage || !stage.startsWith("L3")) return undefined;
if (stage.includes("after_tool_call")) return "after_tool_call";
if (stage.includes("llm_input")) return "llm_input";
if (stage.includes("before_prompt")) return "before_prompt_reapply";
return "L3_unknown";
}
function isInLoopStage(stage: string): boolean {
return typeof stage === "string" && stage.includes("after_tool_call");
}
function durationBucketTag(ms: number): string {
if (typeof ms !== "number" || ms < 0) return "duration:unknown";
if (ms < 1000) return "duration:<1s";
if (ms < 5000) return "duration:1-5s";
if (ms < 15000) return "duration:5-15s";
if (ms < 30000) return "duration:15-30s";
return "duration:>30s";
}
function formatDuration(ms: number): string {
if (typeof ms !== "number" || ms < 0) return "?";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function getOpikConfigFromOpenClawConfig(config: Record<string, unknown>): {
enabled: boolean;
apiUrl?: string;
apiKey?: string;
workspaceName: string;
projectName: string;
} {
const plugins = config.plugins as Record<string, unknown> | undefined;
const entries = plugins?.entries as Record<string, Record<string, unknown>> | undefined;
const opikEntry = entries?.["opik-openclaw"];
const opikCfg = opikEntry?.config as Record<string, unknown> | undefined;
const enabled = opikEntry?.enabled !== false && opikCfg?.enabled !== false;
const apiUrl =
typeof opikCfg?.apiUrl === "string" ? opikCfg.apiUrl : getEnv("OPIK_URL_OVERRIDE");
const apiKey =
typeof opikCfg?.apiKey === "string" ? opikCfg.apiKey : getEnv("OPIK_API_KEY");
const workspaceName =
typeof opikCfg?.workspaceName === "string" && (opikCfg.workspaceName as string).trim()
? (opikCfg.workspaceName as string)
: getEnv("OPIK_WORKSPACE") ?? "default";
const projectName =
typeof opikCfg?.projectName === "string" && (opikCfg.projectName as string).trim()
? (opikCfg.projectName as string)
: getEnv("OPIK_PROJECT_NAME") ?? "openclaw";
return { enabled, apiUrl, apiKey, workspaceName, projectName };
}
export function initOffloadOpikTracer(
openClawConfig: Record<string, unknown>,
logger: PluginLogger,
): void {
if (tracerInitTried) return;
tracerInitTried = true;
try {
const cfg = getOpikConfigFromOpenClawConfig(openClawConfig);
if (!cfg.enabled) return;
// Dynamic import — graceful when opik is not installed
let OpikConstructor: new (params: Record<string, unknown>) => OpikClient;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const opikModule = require("opik") as { Opik: new (params: Record<string, unknown>) => OpikClient };
OpikConstructor = opikModule.Opik;
} catch {
logger.debug?.("[context-offload] opik package not available, tracer disabled");
return;
}
client = new OpikConstructor({
...(cfg.apiKey ? { apiKey: cfg.apiKey } : {}),
...(cfg.apiUrl ? { apiUrl: cfg.apiUrl } : {}),
workspaceName: cfg.workspaceName,
projectName: cfg.projectName,
});
tracerEnabled = true;
logger.debug?.(
`[context-offload] Opik tracer enabled: project=${cfg.projectName}, workspace=${cfg.workspaceName}`,
);
} catch (err) {
tracerEnabled = false;
client = null;
logger.warn(`[context-offload] Opik tracer init failed: ${String(err)}`);
}
}
export function traceOffloadDecision(params: {
sessionKey?: string | null;
stage: string;
input: Record<string, unknown>;
output: Record<string, unknown>;
logger?: PluginLogger;
}): void {
if (!tracerEnabled || !client) return;
try {
const layerTag = extractLayerTag(params.stage);
const l3TriggerSource = extractL3TriggerSource(params.stage);
const threadId =
params.sessionKey && params.sessionKey.trim()
? params.sessionKey
: `offload-${Date.now()}`;
const inLoop = isInLoopStage(params.stage);
const out = params.output ?? {};
const phase = typeof params.input.phase === "string" ? params.input.phase : undefined;
const skTag = params.sessionKey ? `session:${params.sessionKey}` : "session:unknown";
const trace = client.trace({
name: `context-offload:${params.stage} [${params.sessionKey ?? "no-session"}]`,
threadId,
input: params.input,
metadata: {
plugin: "openclaw-context-offload",
category: "decision",
stage: params.stage,
layer: layerTag,
sessionKey: params.sessionKey ?? undefined,
...(inLoop ? { inloop: true } : {}),
...(l3TriggerSource ? { l3TriggerSource } : {}),
...(phase ? { phase } : {}),
},
tags: [
"context-offload",
"decision",
layerTag,
skTag,
...(inLoop ? ["inloop"] : []),
...(l3TriggerSource ? [`trigger:${l3TriggerSource}`] : []),
...(phase ? [`phase:${phase}`] : []),
],
});
trace.update({ output: out });
trace.end();
void client.flush().catch(() => undefined);
} catch (err) {
params.logger?.warn?.(`[context-offload] Opik decision trace failed: ${String(err)}`);
}
}
/**
* Serialize a single message into a diagnostic object for tracing.
* Outputs full content text (no truncation) for debugging purposes.
*/
function serializeMessageForTrace(msg: any, index: number): Record<string, unknown> {
const role = msg.role ?? msg.message?.role ?? msg.type ?? "unknown";
const flags: string[] = [];
if (msg._mmdContextMessage) flags.push(`mmdCtx=${msg._mmdContextMessage}`);
if (msg._mmdInjection) flags.push("mmdInj");
if (msg._offloaded) flags.push("offloaded");
const content = msg.content ?? msg.message?.content;
let contentText: string;
let contentLength: number;
if (typeof content === "string") {
contentLength = content.length;
contentText = content;
} else if (Array.isArray(content)) {
const parts: string[] = [];
for (const c of content) {
if (typeof c !== "object" || c === null) continue;
if (c.type === "text" && typeof c.text === "string") {
parts.push(c.text);
} else if (c.type === "tool_use") {
const inputStr = c.input != null ? JSON.stringify(c.input) : "";
parts.push(`[tool_use: ${c.name ?? "?"} id=${c.id ?? "?"} input=${inputStr}]`);
} else if (c.type === "tool_result") {
const resultStr = typeof c.content === "string" ? c.content : JSON.stringify(c.content ?? "");
parts.push(`[tool_result: id=${c.tool_use_id ?? "?"} content=${resultStr}]`);
} else {
parts.push(`[${c.type ?? "unknown_block"}]`);
}
}
contentText = parts.join("\n");
contentLength = contentText.length;
} else {
contentLength = 0;
contentText = "(empty)";
}
const toolCallId = msg.toolCallId ?? msg.tool_call_id ?? msg.message?.toolCallId ?? msg.message?.tool_call_id;
return {
i: index,
role,
...(flags.length > 0 ? { flags } : {}),
len: contentLength,
content: contentText,
...(toolCallId ? { toolCallId } : {}),
};
}
/**
* Trace a full messages snapshot — used for debugging message state at key points.
* Creates a separate "messages-snapshot" category trace.
*/
export function traceMessagesSnapshot(params: {
sessionKey?: string | null;
stage: string;
messages: any[];
label?: string;
extra?: Record<string, unknown>;
logger?: PluginLogger;
}): void {
if (!tracerEnabled || !client) return;
try {
const threadId =
params.sessionKey && params.sessionKey.trim()
? params.sessionKey
: `offload-${Date.now()}`;
const skTag = params.sessionKey ? `session:${params.sessionKey}` : "session:unknown";
const msgs = params.messages ?? [];
const serialized = msgs.map((m, i) => serializeMessageForTrace(m, i));
// Aggregate stats
const mmdCount = msgs.filter((m: any) => m._mmdContextMessage || m._mmdInjection).length;
const offloadedCount = msgs.filter((m: any) => m._offloaded).length;
const roleBreakdown: Record<string, number> = {};
for (const m of msgs) {
const role = m.role ?? m.message?.role ?? m.type ?? "unknown";
roleBreakdown[role] = (roleBreakdown[role] ?? 0) + 1;
}
const trace = client.trace({
name: `messages-snapshot:${params.stage}${params.label ? ` (${params.label})` : ""} [${params.sessionKey ?? "no-session"}]`,
threadId,
input: {
stage: params.stage,
label: params.label,
messageCount: msgs.length,
mmdCount,
offloadedCount,
roleBreakdown,
...(params.extra ?? {}),
},
metadata: {
plugin: "openclaw-context-offload",
category: "messages-snapshot",
stage: params.stage,
sessionKey: params.sessionKey ?? undefined,
},
tags: ["context-offload", "messages-snapshot", skTag],
});
trace.update({
output: {
messages: serialized,
messageCount: msgs.length,
mmdCount,
offloadedCount,
roleBreakdown,
},
});
trace.end();
void client.flush().catch(() => undefined);
} catch (err) {
params.logger?.warn?.(`[context-offload] Opik messages-snapshot trace failed: ${String(err)}`);
}
}
export function traceOffloadModelIo(params: {
sessionKey?: string | null;
stage: string;
provider?: string;
model: string;
url: string;
systemPrompt: string;
userPrompt: string;
responseContent: string;
usage?: Record<string, unknown>;
status: "ok" | "error";
errorMessage?: string;
durationMs: number;
logger?: PluginLogger;
}): void {
if (!tracerEnabled || !client) return;
try {
const layerTag = extractLayerTag(params.stage);
const threadId =
params.sessionKey && params.sessionKey.trim()
? params.sessionKey
: `offload-${Date.now()}`;
const dur = params.durationMs;
const durStr = formatDuration(dur);
const durBucket = durationBucketTag(dur);
const skTag = params.sessionKey ? `session:${params.sessionKey}` : "session:unknown";
const trace = client.trace({
name: `${params.model} · context-offload · ${durStr} [${params.sessionKey ?? "no-session"}]`,
threadId,
metadata: {
plugin: "openclaw-context-offload",
category: "llm",
stage: params.stage,
layer: layerTag,
provider: params.provider,
model: params.model,
sessionKey: params.sessionKey ?? undefined,
durationMs: dur,
duration: durStr,
},
tags: ["context-offload", "llm", layerTag, durBucket, skTag],
});
const span = trace.span({
name: `${params.model} · ${durStr}`,
type: "llm",
model: params.model,
provider: params.provider,
input: {
url: params.url,
systemPrompt: params.systemPrompt,
userPrompt: params.userPrompt,
},
metadata: {
stage: params.stage,
layer: layerTag,
sessionKey: params.sessionKey ?? undefined,
durationMs: dur,
duration: durStr,
},
});
span.update({
output: {
responseContent: params.responseContent,
usage: params.usage,
durationMs: dur,
duration: durStr,
error: params.errorMessage,
},
metadata: {
status: params.status,
durationMs: dur,
duration: durStr,
},
});
span.end();
trace.end();
void client.flush().catch(() => undefined);
} catch (err) {
params.logger?.warn?.(`[context-offload] Opik model I/O trace failed: ${String(err)}`);
}
}
+286
View File
@@ -0,0 +1,286 @@
/**
* L2 Mermaid Generation Pipeline (Independent Trigger):
*
* L2 is NO LONGER triggered directly from L1. Instead it runs independently:
* - Trigger condition A: offload.jsonl has >= l2NullThreshold entries with node_id=null
* - Trigger condition B: time since last L2 trigger exceeds l2TimeoutSeconds
*/
import { PLUGIN_DEFAULTS, type OffloadEntry, type PluginConfig, type PluginLogger } from "../types.js";
import {
readAllOffloadEntries,
rewriteAllOffloadEntries,
type StorageContext,
} from "../storage.js";
import type { OffloadStateManager } from "../state-manager.js";
function isHeartbeatEntry(entry: OffloadEntry): boolean {
try {
const tc = entry.tool_call ?? "";
return tc.includes("HEARTBEAT.md");
} catch {
return false;
}
}
function hasNullEntryAfterLastL2(
nullEntries: OffloadEntry[],
lastL2Iso: string,
): boolean {
const lastMs = new Date(lastL2Iso).getTime();
if (Number.isNaN(lastMs)) return true;
return nullEntries.some((e) => {
if (!e.timestamp) return true;
const ts = new Date(e.timestamp).getTime();
if (Number.isNaN(ts)) return true;
return ts > lastMs;
});
}
const MMD_NODE_ID_RE = /\b(\d{3}-N\d+)\b/g;
function normalizeNodeMapping(raw: any): Record<string, string> {
const out: Record<string, string> = Object.create(null);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
for (const [k, v] of Object.entries(raw)) {
if (typeof k !== "string" || !k) continue;
const s = typeof v === "string" ? v.trim() : v != null ? String(v).trim() : "";
if (s) out[k] = s;
}
return out;
}
function extractMmdNodeIdsFromText(text: string | null | undefined): string[] {
if (text == null || typeof text !== "string") return [];
const seen = new Set<string>();
const out: string[] = [];
let m: RegExpExecArray | null;
MMD_NODE_ID_RE.lastIndex = 0;
while ((m = MMD_NODE_ID_RE.exec(text)) !== null) {
const id = m[1];
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
}
return out;
}
function pickMmdDerivedFallbackNodeId(
mmdText: string,
mmdPrefix: string,
): string | null {
const ids = extractMmdNodeIdsFromText(mmdText);
if (ids.length === 0) return null;
const prefix =
typeof mmdPrefix === "string" && /^\d{3}$/.test(mmdPrefix)
? `${mmdPrefix}-`
: null;
const pool = prefix ? ids.filter((id) => id.startsWith(prefix)) : ids;
const candidates = pool.length > 0 ? pool : ids;
let best: string | null = null;
let bestN = -1;
for (const id of candidates) {
const mm = id.match(/^(\d{3})-N(\d+)$/);
if (!mm) continue;
const n = Number(mm[2]);
if (Number.isFinite(n) && n > bestN) {
bestN = n;
best = id;
}
}
return best;
}
// ─── L2 Independent Trigger Check ─────────────────────────────────────────────
export async function checkL2Trigger(
stateManager: OffloadStateManager,
pluginConfig: Partial<PluginConfig> | undefined,
logger: PluginLogger,
): Promise<{
shouldTrigger: boolean;
reason: string;
entriesByMmd: Map<string, OffloadEntry[]>;
}> {
const nullThreshold =
pluginConfig?.l2NullThreshold ?? PLUGIN_DEFAULTS.l2NullThreshold;
const timeoutSeconds =
pluginConfig?.l2TimeoutSeconds ?? PLUGIN_DEFAULTS.l2TimeoutSeconds;
const timeNeedsNewOffload =
(pluginConfig as any)?.l2TimeTriggerRequiresNewOffload ??
PLUGIN_DEFAULTS.l2TimeTriggerRequiresNewOffload;
const waitRetrySeconds =
(pluginConfig as any)?.l2WaitRetrySeconds ??
PLUGIN_DEFAULTS.l2WaitRetrySeconds;
const emptyResult = { shouldTrigger: false as const, reason: "", entriesByMmd: new Map<string, OffloadEntry[]>() };
const allEntries = await readAllOffloadEntries(stateManager.ctx);
const nowMs = Date.now();
// Collect eligible null entries using boundary-based grouping
const entriesByMmd = new Map<string, OffloadEntry[]>();
let eligibleNullCount = 0;
for (let i = 0; i < allEntries.length; i++) {
const entry = allEntries[i];
if (isHeartbeatEntry(entry)) continue;
if (entry.node_id !== null && entry.node_id !== "wait") continue;
// For "wait" entries, only include if they exceeded retry timeout
if (entry.node_id === "wait") {
const tsIso = entry.timestamp;
if (tsIso) {
const tsMs = new Date(tsIso).getTime();
if (!Number.isNaN(tsMs) && (nowMs - tsMs) / 1000 < waitRetrySeconds) continue;
}
}
// Use boundary to determine which mmd this entry belongs to
const boundary = stateManager.resolveEntryBoundary(i);
if (!boundary) continue; // no boundary coverage → skip
if (boundary.result !== "long") continue; // short task → skip
if (!boundary.targetMmd) continue; // no target mmd → skip
if (entry.node_id === null) eligibleNullCount++;
const mmd = boundary.targetMmd;
let bucket = entriesByMmd.get(mmd);
if (!bucket) { bucket = []; entriesByMmd.set(mmd, bucket); }
// Dedup by tool_call_id within the same bucket
if (entry.tool_call_id && bucket.some((e) => e.tool_call_id === entry.tool_call_id)) continue;
bucket.push(entry);
}
const totalEligible = Array.from(entriesByMmd.values()).reduce((sum, arr) => sum + arr.length, 0);
if (totalEligible === 0) {
return { ...emptyResult, reason: "no eligible entries (boundary-filtered)" };
}
// Condition A: null count threshold
if (eligibleNullCount >= nullThreshold) {
return {
shouldTrigger: true,
reason: `null_count=${eligibleNullCount} >= threshold=${nullThreshold} (${entriesByMmd.size} mmd(s))`,
entriesByMmd,
};
}
// Condition B: timeout
const lastL2Time = stateManager.getLastL2TriggerTime();
if (lastL2Time) {
const elapsed = (Date.now() - new Date(lastL2Time).getTime()) / 1000;
if (elapsed >= timeoutSeconds) {
if (timeNeedsNewOffload) {
// Check if any null entry is newer than last L2
const nullEntries = allEntries.filter((e) => e.node_id === null && !isHeartbeatEntry(e));
if (!hasNullEntryAfterLastL2(nullEntries, lastL2Time) && totalEligible === eligibleNullCount) {
return { ...emptyResult, reason: "timeout but no new offload rows" };
}
}
return {
shouldTrigger: true,
reason: `timeout: ${elapsed.toFixed(0)}s >= ${timeoutSeconds}s (${entriesByMmd.size} mmd(s))`,
entriesByMmd,
};
}
} else {
// No prior L2: check retry-wait entries or oldest null age
const hasRetryWait = totalEligible > eligibleNullCount;
if (hasRetryWait) {
return {
shouldTrigger: true,
reason: `no prior L2 + retry-wait entries (${entriesByMmd.size} mmd(s))`,
entriesByMmd,
};
}
const nullEntries = allEntries.filter((e) => e.node_id === null && !isHeartbeatEntry(e));
if (nullEntries.length > 0) {
const oldestTs = nullEntries[0]?.timestamp;
if (oldestTs) {
const elapsed = (Date.now() - new Date(oldestTs).getTime()) / 1000;
if (elapsed >= timeoutSeconds) {
return {
shouldTrigger: true,
reason: `no prior L2 + oldest null entry age=${elapsed.toFixed(0)}s`,
entriesByMmd,
};
}
}
}
}
return {
...emptyResult,
reason: `null_count=${eligibleNullCount} < ${nullThreshold}, timeout not reached`,
};
}
export async function backfillNodeIds(
ctx: StorageContext,
nodeMapping: Record<string, string>,
waitIds: Set<string>,
logger: PluginLogger,
options?: { mmdFallbackText?: string | null; mmdPrefix?: string },
): Promise<void> {
const mapping = normalizeNodeMapping(nodeMapping);
const mmdFallbackText = options?.mmdFallbackText ?? null;
const mmdPrefix = options?.mmdPrefix ?? "000";
const allEntries = await readAllOffloadEntries(ctx);
let changed = false;
const mappedNodeIds = Object.values(mapping);
const fallbackFromMapping = getMostFrequent(mappedNodeIds);
const fallbackFromMmd = pickMmdDerivedFallbackNodeId(
mmdFallbackText ?? "",
mmdPrefix,
);
const effectiveFallback = fallbackFromMapping || fallbackFromMmd;
let mappedCount = 0;
let fallbackCount = 0;
let skippedCount = 0;
for (const entry of allEntries) {
const mapped = mapping[entry.tool_call_id];
if (mapped) {
entry.node_id = mapped;
changed = true;
mappedCount++;
continue;
}
if (entry.node_id === "wait" && waitIds.has(entry.tool_call_id)) {
if (effectiveFallback) {
entry.node_id = effectiveFallback;
changed = true;
fallbackCount++;
} else {
skippedCount++;
}
}
}
if (changed) {
await rewriteAllOffloadEntries(ctx, allEntries);
}
logger.info(`[context-offload] L2 backfill: mapped=${mappedCount}, fallback=${fallbackCount} (to ${effectiveFallback ?? "N/A"}), skipped=${skippedCount}, total=${waitIds.size}`);
}
function getMostFrequent(arr: string[]): string | null {
if (arr.length === 0) return null;
const freq = new Map<string, number>();
for (const v of arr) {
freq.set(v, (freq.get(v) ?? 0) + 1);
}
let maxKey = arr[0];
let maxCount = 0;
for (const [key, count] of freq) {
if (count > maxCount) {
maxCount = count;
maxKey = key;
}
}
return maxKey;
}
// Local runL2Pipeline removed — all L2 processing goes through backend (index.ts → backendClient.l2Generate).
+487
View File
@@ -0,0 +1,487 @@
/**
* OffloadReclaimer: periodic cleanup of stale offload data files.
*
* Reclaims disk space by removing:
* Step 1 — Expired session JSONL files (offload-*.jsonl)
* Step 2 — Orphaned ref MD files (refs/*.md)
* Step 3 — Expired MMD files (mmds/*.mmd), protecting active MMD
* Step 4 — Oversized debug log files (*.log truncation)
* Step 5 — Stale sessions-registry.json entries
*
* Each step is independently try/caught — a failure in one step
* does not prevent subsequent steps from running.
*
* All file-age checks use mtime (last modification time).
*/
import { readdir, stat, unlink, readFile, writeFile, rename, truncate } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, basename } from "node:path";
import { randomBytes } from "node:crypto";
import { parseJsonlSafe } from "./storage.js";
import type { PluginLogger } from "./types.js";
// ============================
// Public types
// ============================
/** Configuration for the reclaim operation. */
export interface ReclaimConfig {
/** Retention period in days. Values < 3 disable reclamation entirely. */
retentionDays: number;
/** Max total size in MB for debug log files. 0 = no log rotation. */
logMaxSizeMb: number;
}
/** Statistics returned after a reclaim run. */
export interface ReclaimStats {
deletedJsonl: number;
deletedRefs: number;
deletedMmds: number;
truncatedLogs: number;
prunedRegistryEntries: number;
}
// ============================
// Constants
// ============================
const TAG = "[context-offload][reclaim]";
const MS_PER_DAY = 86_400_000;
// ============================
// Main entry
// ============================
/**
* Run a full reclamation pass over the offload data directory.
*
* Safe to call concurrently (each step is idempotent) but designed
* for single-caller-per-process via a 24h setInterval.
*/
export async function reclaimOffloadData(
dataRoot: string,
config: ReclaimConfig,
logger: PluginLogger,
): Promise<ReclaimStats> {
const stats: ReclaimStats = {
deletedJsonl: 0,
deletedRefs: 0,
deletedMmds: 0,
truncatedLogs: 0,
prunedRegistryEntries: 0,
};
if (config.retentionDays < 3) {
logger.info(`${TAG} Skipped: retentionDays=${config.retentionDays} (min effective: 3)`);
return stats;
}
if (!existsSync(dataRoot)) {
logger.info(`${TAG} Skipped: dataRoot does not exist: ${dataRoot}`);
return stats;
}
const nowMs = Date.now();
const cutoffMs = nowMs - config.retentionDays * MS_PER_DAY;
// Discover agent subdirectories (directories inside dataRoot)
const agentDirs = await discoverAgentDirs(dataRoot);
// Step 1: Clean expired session JSONL
try {
stats.deletedJsonl = await reclaimExpiredJsonl(dataRoot, agentDirs, cutoffMs, logger);
} catch (err) {
logger.warn(`${TAG} Step 1 (JSONL) failed: ${err instanceof Error ? err.message : String(err)}`);
}
// Step 2: Clean orphan ref MD
try {
stats.deletedRefs = await reclaimOrphanRefs(agentDirs, cutoffMs, logger);
} catch (err) {
logger.warn(`${TAG} Step 2 (refs) failed: ${err instanceof Error ? err.message : String(err)}`);
}
// Step 3: Clean expired MMD
try {
stats.deletedMmds = await reclaimExpiredMmds(agentDirs, cutoffMs, logger);
} catch (err) {
logger.warn(`${TAG} Step 3 (MMDs) failed: ${err instanceof Error ? err.message : String(err)}`);
}
// Step 4: Log rotation
try {
stats.truncatedLogs = await rotateDebugLogs(dataRoot, config.logMaxSizeMb, logger);
} catch (err) {
logger.warn(`${TAG} Step 4 (logs) failed: ${err instanceof Error ? err.message : String(err)}`);
}
// Step 5: Registry pruning
try {
stats.prunedRegistryEntries = await pruneRegistries(agentDirs, cutoffMs, logger);
} catch (err) {
logger.warn(`${TAG} Step 5 (registry) failed: ${err instanceof Error ? err.message : String(err)}`);
}
return stats;
}
// ============================
// Step helpers
// ============================
/** Discover agent subdirectories under dataRoot. */
async function discoverAgentDirs(dataRoot: string): Promise<string[]> {
const entries = await readdir(dataRoot, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory())
.map((e) => join(dataRoot, e.name));
}
// ─── Step 1: Expired JSONL ───────────────────────────────────────────────────
async function reclaimExpiredJsonl(
dataRoot: string,
agentDirs: string[],
cutoffMs: number,
logger: PluginLogger,
): Promise<number> {
let deleted = 0;
// Scan dataRoot for root-level offload-*.jsonl (legacy layout)
deleted += await deleteExpiredJsonlInDir(dataRoot, cutoffMs, logger);
// Scan each agent directory
for (const dir of agentDirs) {
deleted += await deleteExpiredJsonlInDir(dir, cutoffMs, logger);
}
return deleted;
}
async function deleteExpiredJsonlInDir(
dir: string,
cutoffMs: number,
logger: PluginLogger,
): Promise<number> {
let deleted = 0;
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return 0;
}
const jsonlFiles = entries.filter((f) => f.startsWith("offload-") && f.endsWith(".jsonl"));
for (const file of jsonlFiles) {
const filePath = join(dir, file);
try {
const s = await stat(filePath);
if (s.mtimeMs < cutoffMs) {
await unlink(filePath);
deleted++;
logger.info(`${TAG} Step 1: deleted expired JSONL: ${filePath} (mtime=${new Date(s.mtimeMs).toISOString()})`);
}
} catch (err) {
logger.warn(`${TAG} Step 1: failed to process ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
// Sync-clean sessions-registry.json: remove entries whose offloadFile was deleted
if (deleted > 0) {
await syncRegistryAfterJsonlDeletion(dir, logger);
}
return deleted;
}
/** Remove registry entries whose offloadFile no longer exists on disk. */
async function syncRegistryAfterJsonlDeletion(dir: string, logger: PluginLogger): Promise<void> {
const registryPath = join(dir, "sessions-registry.json");
if (!existsSync(registryPath)) return;
try {
const raw = await readFile(registryPath, "utf-8");
const registry = JSON.parse(raw) as Record<string, Record<string, unknown>>;
let changed = false;
for (const [key, val] of Object.entries(registry)) {
const offloadFile = val.offloadFile as string | undefined;
if (offloadFile && !existsSync(join(dir, offloadFile))) {
delete registry[key];
changed = true;
}
}
if (changed) {
await atomicWriteJson(registryPath, registry);
}
} catch {
/* best-effort */
}
}
// ─── Step 2: Orphan refs ─────────────────────────────────────────────────────
async function reclaimOrphanRefs(
agentDirs: string[],
cutoffMs: number,
logger: PluginLogger,
): Promise<number> {
let deleted = 0;
for (const agentDir of agentDirs) {
const refsDir = join(agentDir, "refs");
if (!existsSync(refsDir)) continue;
// Build set of referenced ref filenames from surviving JSONL files
let referencedRefs: Set<string> | null = null;
try {
referencedRefs = await buildReferencedRefSet(agentDir);
} catch {
// Fall through: if we can't build the set, use mtime-only fallback
logger.warn(`${TAG} Step 2: failed to build ref set for ${agentDir}, using mtime-only fallback`);
}
let refFiles: string[];
try {
refFiles = (await readdir(refsDir)).filter((f) => f.endsWith(".md"));
} catch {
continue;
}
for (const file of refFiles) {
const filePath = join(refsDir, file);
try {
const isReferenced = referencedRefs !== null && referencedRefs.has(file);
if (isReferenced) continue;
// Not referenced (or ref set unavailable) — check mtime
const s = await stat(filePath);
if (s.mtimeMs < cutoffMs) {
await unlink(filePath);
deleted++;
logger.info(`${TAG} Step 2: deleted orphan ref: ${filePath}`);
}
} catch {
/* skip individual file errors */
}
}
}
return deleted;
}
/** Parse all offload-*.jsonl in an agent dir, collect referenced ref filenames. */
async function buildReferencedRefSet(agentDir: string): Promise<Set<string>> {
const refs = new Set<string>();
let files: string[];
try {
files = await readdir(agentDir);
} catch {
return refs;
}
const jsonlFiles = files.filter((f) => f.startsWith("offload-") && f.endsWith(".jsonl"));
for (const file of jsonlFiles) {
try {
const content = await readFile(join(agentDir, file), "utf-8");
const { entries } = parseJsonlSafe(content, { skipValidation: true });
for (const entry of entries) {
const resultRef = entry.result_ref;
if (typeof resultRef === "string" && resultRef.length > 0) {
// result_ref format: "refs/2026-04-12T17-26-08-123p08-00.md"
refs.add(basename(resultRef));
}
}
} catch {
/* skip corrupt files */
}
}
return refs;
}
// ─── Step 3: Expired MMDs ────────────────────────────────────────────────────
/** Minimum number of MMD files to keep per agent, regardless of age. */
const MIN_KEEP_MMDS = 15;
async function reclaimExpiredMmds(
agentDirs: string[],
cutoffMs: number,
logger: PluginLogger,
): Promise<number> {
let deleted = 0;
for (const agentDir of agentDirs) {
const mmdsDir = join(agentDir, "mmds");
if (!existsSync(mmdsDir)) continue;
// Read activeMmdFile from state.json to protect it
let activeMmdFile: string | null = null;
try {
const stateFile = join(agentDir, "state.json");
if (existsSync(stateFile)) {
const stateRaw = await readFile(stateFile, "utf-8");
const state = JSON.parse(stateRaw) as Record<string, unknown>;
activeMmdFile = typeof state.activeMmdFile === "string" ? state.activeMmdFile : null;
}
} catch {
/* state.json unreadable — proceed without protection (conservative: skip all) */
}
let mmdFiles: string[];
try {
mmdFiles = (await readdir(mmdsDir)).filter((f) => f.endsWith(".mmd"));
} catch {
continue;
}
// If total count <= MIN_KEEP_MMDS, nothing to delete
if (mmdFiles.length <= MIN_KEEP_MMDS) continue;
// Stat all files to get mtime, then sort oldest-first
const fileMetas: Array<{ name: string; mtimeMs: number }> = [];
for (const file of mmdFiles) {
try {
const s = await stat(join(mmdsDir, file));
fileMetas.push({ name: file, mtimeMs: s.mtimeMs });
} catch {
/* skip unstat-able files */
}
}
fileMetas.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
// Walk oldest-first, delete expired ones but stop when we'd drop below MIN_KEEP_MMDS
let remaining = fileMetas.length;
for (const meta of fileMetas) {
if (remaining <= MIN_KEEP_MMDS) break;
if (meta.name === activeMmdFile) continue; // never delete active MMD
if (meta.mtimeMs >= cutoffMs) continue; // not expired
const filePath = join(mmdsDir, meta.name);
try {
await unlink(filePath);
deleted++;
remaining--;
logger.info(`${TAG} Step 3: deleted expired MMD: ${filePath}`);
} catch {
/* skip */
}
}
}
return deleted;
}
// ─── Step 4: Log rotation ────────────────────────────────────────────────────
async function rotateDebugLogs(
dataRoot: string,
logMaxSizeMb: number,
logger: PluginLogger,
): Promise<number> {
if (logMaxSizeMb <= 0) return 0;
const maxBytes = logMaxSizeMb * 1024 * 1024;
let entries: string[];
try {
entries = await readdir(dataRoot);
} catch {
return 0;
}
// Collect *.log and debug *.jsonl files (NOT offload-*.jsonl which are data)
const logFiles: Array<{ name: string; path: string; size: number }> = [];
for (const name of entries) {
const isLog = name.endsWith(".log");
const isDebugJsonl = name.endsWith(".jsonl") && !name.startsWith("offload-");
if (!isLog && !isDebugJsonl) continue;
const filePath = join(dataRoot, name);
try {
const s = await stat(filePath);
if (s.isFile()) {
logFiles.push({ name, path: filePath, size: s.size });
}
} catch {
/* skip */
}
}
let totalSize = logFiles.reduce((sum, f) => sum + f.size, 0);
if (totalSize <= maxBytes) return 0;
// Sort by size descending — truncate largest first
logFiles.sort((a, b) => b.size - a.size);
let truncated = 0;
for (const file of logFiles) {
if (totalSize <= maxBytes) break;
if (file.size === 0) continue;
try {
await truncate(file.path, 0);
totalSize -= file.size;
truncated++;
logger.info(`${TAG} Step 4: truncated log: ${file.path} (was ${file.size} bytes)`);
} catch (err) {
logger.warn(`${TAG} Step 4: failed to truncate ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return truncated;
}
// ─── Step 5: Registry pruning ────────────────────────────────────────────────
async function pruneRegistries(
agentDirs: string[],
cutoffMs: number,
logger: PluginLogger,
): Promise<number> {
let pruned = 0;
for (const agentDir of agentDirs) {
const registryPath = join(agentDir, "sessions-registry.json");
if (!existsSync(registryPath)) continue;
try {
const raw = await readFile(registryPath, "utf-8");
const registry = JSON.parse(raw) as Record<string, Record<string, unknown>>;
const originalCount = Object.keys(registry).length;
let changed = false;
for (const [key, val] of Object.entries(registry)) {
const updatedAt = val.updatedAt;
if (typeof updatedAt !== "string") continue;
const updatedMs = new Date(updatedAt).getTime();
if (Number.isNaN(updatedMs)) continue;
if (updatedMs < cutoffMs) {
delete registry[key];
changed = true;
}
}
if (changed) {
const removedCount = originalCount - Object.keys(registry).length;
pruned += removedCount;
await atomicWriteJson(registryPath, registry);
logger.info(`${TAG} Step 5: pruned ${removedCount} expired entries from ${registryPath}`);
}
} catch (err) {
logger.warn(`${TAG} Step 5: failed to prune ${registryPath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return pruned;
}
// ============================
// Helpers
// ============================
/** Atomic JSON write: write to tmp file, then rename into place. */
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tmp = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
await writeFile(tmp, JSON.stringify(data, null, 2), "utf-8");
await rename(tmp, filePath);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* SessionRegistry: Per-session OffloadStateManager routing.
*
* Maps sessionKey → { manager, lastAccessMs } with LRU eviction.
* Eliminates the global singleton stateManager — each session gets
* its own isolated OffloadStateManager + StorageContext.
*/
import { OffloadStateManager } from "./state-manager.js";
import { parseSessionKey } from "./storage.js";
/** Matches internal memory-pipeline sessions (e.g. memory-{taskId}-session-{ts}). */
const INTERNAL_SESSION_RE = /memory-.*-session-\d+/;
/** Returns true if the sessionKey belongs to an internal memory-pipeline session. */
function isInternalMemorySession(sessionKey: string): boolean {
return INTERNAL_SESSION_RE.test(sessionKey);
}
/** Per-session context entry held by the registry. */
export interface SessionCtx {
readonly sessionKey: string;
readonly manager: OffloadStateManager;
lastAccessMs: number;
}
/** Maximum number of cached sessions before LRU eviction kicks in. */
const MAX_CACHED_SESSIONS = 20;
/** Routes sessionKey → per-session OffloadStateManager with LRU eviction. */
export class SessionRegistry {
private _sessions = new Map<string, SessionCtx>();
private _dataRoot: string;
readonly _registryId = ++SessionRegistry._registryCounter;
private static _registryCounter = 0;
constructor(dataRoot: string) {
this._dataRoot = dataRoot;
}
/** Get the configured data root. */
get dataRoot(): string {
return this._dataRoot;
}
/**
* Get or create a per-session manager.
* First access will create a new OffloadStateManager, call init() + switchSession()
* to fully initialize storage paths and rebuild in-memory state from offload files.
*/
async resolve(sessionKey: string, realSessionId?: string): Promise<SessionCtx> {
let entry = this._sessions.get(sessionKey);
if (entry) {
entry.lastAccessMs = Date.now();
return entry;
}
// New session — create manager and fully initialize
const mgr = new OffloadStateManager();
const parsed = parseSessionKey(sessionKey);
if (parsed) {
const effectiveSessionId = realSessionId || parsed.sessionId;
await mgr.init(this._dataRoot, parsed.agentName, effectiveSessionId);
// switchSession rebuilds confirmedOffloadIds, deletedOffloadIds,
// processedToolCallIds from offload JSONL, registers sessionKey mapping,
// and resets session-level runtime state.
await mgr.switchSession(sessionKey, this._dataRoot, realSessionId);
} else {
// sessionKey doesn't match "agent:<name>:<id>" format.
// Use a sanitized sessionKey as both agentName and sessionId
// so ctx is always initialized (avoids "ctx not initialized" errors).
const fallbackName = sessionKey.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").slice(0, 64) || "unknown";
const fallbackSessionId = realSessionId || `fallback-${Date.now()}`;
await mgr.init(this._dataRoot, fallbackName, fallbackSessionId);
}
entry = { sessionKey, manager: mgr, lastAccessMs: Date.now() };
this._sessions.set(sessionKey, entry);
// LRU eviction
if (this._sessions.size > MAX_CACHED_SESSIONS) {
this._evictOldest();
}
return entry;
}
/**
* Resolve a session only if it is NOT an internal memory-pipeline session.
*
* Returns null for memory sessions (e.g. `memory-{taskId}-session-{ts}`),
* preventing unnecessary OffloadStateManager creation, disk I/O, and LRU
* cache slot pollution for sessions that should never run offload.
*
* Callers that need unconditional resolve (e.g. tests) can still use resolve().
*/
async resolveIfAllowed(sessionKey: string, realSessionId?: string): Promise<SessionCtx | null> {
if (isInternalMemorySession(sessionKey)) return null;
return this.resolve(sessionKey, realSessionId);
}
/** Look up an existing session (does not create). Updates lastAccessMs. */
get(sessionKey: string): SessionCtx | undefined {
const entry = this._sessions.get(sessionKey);
if (entry) entry.lastAccessMs = Date.now();
return entry;
}
/** Number of cached sessions. */
get size(): number {
return this._sessions.size;
}
/** Iterate over all session keys. */
keys(): IterableIterator<string> {
return this._sessions.keys();
}
/** Iterate over all session entries. */
values(): IterableIterator<SessionCtx> {
return this._sessions.values();
}
/** Evict the least-recently-accessed session. */
private _evictOldest(): void {
let oldestKey: string | null = null;
let oldestMs = Infinity;
for (const [key, entry] of this._sessions) {
if (entry.lastAccessMs < oldestMs) {
oldestMs = entry.lastAccessMs;
oldestKey = key;
}
}
if (oldestKey) this._sessions.delete(oldestKey);
}
}
+448
View File
@@ -0,0 +1,448 @@
/**
* OffloadStateManager: In-memory state + persistent state.json coordination.
* Manages pendingToolPairs buffer, active MMD tracking, and processed IDs.
*
* Each instance is bound to a single session via StorageContext.
* No global mutable state — all I/O goes through the frozen ctx.
*/
import {
readStateFile,
writeStateFile,
ensureDirs,
createStorageContext,
parseSessionKey,
readOffloadEntries,
extractConfirmedIdsFromEntries,
extractDeletedIdsFromEntries,
registerSession,
listMmds,
} from "./storage.js";
import type { StorageContext } from "./storage.js";
import type { ToolPair, PluginState, OffloadEntry, L15Boundary } from "./types.js";
const DEFAULT_STATE: PluginState & { estimatedSystemOverhead: number | null } = {
activeMmdFile: null,
activeMmdId: null,
mmdCounter: 0,
lastSessionKey: null,
lastOffloadedToolCallId: null,
lastL2TriggerTime: null,
estimatedSystemOverhead: null,
};
export class OffloadStateManager {
/** Immutable storage path context — set by init() or switchSession() */
private _ctx: StorageContext | null = null;
/** Buffered tool pairs waiting to be processed by L1 */
pendingToolPairs: Array<ToolPair & { _sessionId?: string | null }> = [];
/** Set of already-processed tool call IDs to prevent duplicates */
processedToolCallIds = new Set<string>();
/** Persistent state (synced with state.json) */
private state: PluginState & { estimatedSystemOverhead: number | null } = { ...DEFAULT_STATE };
/** Whether state has been loaded from disk */
private loaded = false;
/** Mutex for L1 pipeline to prevent concurrent runs */
private l1Lock: Promise<unknown> = Promise.resolve();
// ─── Runtime-only flags (not persisted) ──────────────────────────────────
private mmdInjectionReady = false;
private injectedMmdVersions: Record<string, string> = {};
/** Whether L1.5 has successfully executed for the current session/prompt.
* L2 must wait for this to be true before triggering. */
l15Settled = false;
/** Unique instance ID for debugging (each new OffloadStateManager gets a new id). */
readonly _instanceId = ++OffloadStateManager._instanceCounter;
private static _instanceCounter = 0;
/** Set of toolCallIds confirmed offloaded in previous rounds. */
confirmedOffloadIds = new Set<string>();
/** Set of toolCallIds that were aggressively DELETED. */
deletedOffloadIds = new Set<string>();
/** Reconciliation retry counter */
_reconcileRetries = new Map<string, number>();
/** Cached offload entries map */
_cachedOffloadMap: Map<string, OffloadEntry> | null = null;
/** Monotonic version counter */
_offloadMapVersion = 0;
/** Last MMD injection token count */
lastMmdInjectedTokens = 0;
/** Cached system prompt from last llm_input */
cachedSystemPrompt: string | null = null;
/** Cached user prompt from last llm_input */
cachedUserPrompt: string | null = null;
/** Cached latest turn messages for L2 */
cachedLatestTurnMessages: string | null = null;
/** Cached recent history for L2 background triggers */
cachedRecentHistory: string | null = null;
/** Cached system prompt token count */
cachedSystemPromptTokens: number | null = null;
/** Cached user prompt token count */
cachedUserPromptTokens: number | null = null;
/** Force emergency compression on next L3 entry */
_forceEmergencyNext = false;
/** Last known total token count from precise tiktoken calculation (P1 quick-skip) */
lastKnownTotalTokens = 0;
/** Message count at last precise tiktoken calculation (P1 quick-skip) */
lastKnownMessageCount = 0;
/** Consecutive QUICK-SKIP count; reset to 0 on each precise calculation */
consecutiveQuickSkips = 0;
/** Cached tool params from before_tool_call hook */
_pendingParams = new Map<string, Record<string, unknown>>();
/** Last L1.5 prompt hash — per-session to avoid cross-session re-trigger skip */
lastL15PromptHash: number | null = null;
// ─── Fault tolerance fields ─────────────────────────────────────────────
/** Per-chunk consecutive L1 failure count. Key = first toolCallId of the chunk. */
_l1ChunkFailCounts = new Map<string, number>();
/** Consecutive L1.5 all-null response count. Reset to 0 on successful judgment. */
l15ConsecutiveNullCount = 0;
// ─── L1.5 Boundary (runtime-only, per-session) ────────────────────────
/** Global entry counter, incremented after each appendOffloadEntries. */
entryCounter = 0;
/** Settled boundaries (ascending by startIndex). */
l15Boundaries: L15Boundary[] = [];
// ─── StorageContext accessor ─────────────────────────────────────────────
/** Get the current session's StorageContext. Throws if not initialized. */
get ctx(): StorageContext {
if (!this._ctx) {
throw new Error("OffloadStateManager: ctx not initialized, call init() or switchSession() first");
}
return this._ctx;
}
/** Get agent name from ctx (null if not initialized) */
get agentName(): string | null {
return this._ctx?.agentName ?? null;
}
/** Get session id from ctx (null if not initialized) */
get sessionId(): string | null {
return this._ctx?.sessionId ?? null;
}
// ─── Initialization ──────────────────────────────────────────────────────
/**
* Initialize the manager for a specific agent + session.
* Creates StorageContext, ensures directories, and loads persistent state.
*/
async init(dataRoot: string, agentName: string, sessionId: string): Promise<void> {
this._ctx = createStorageContext(dataRoot, agentName, sessionId);
await ensureDirs(this._ctx);
const loadedState = await readStateFile(this._ctx, DEFAULT_STATE);
this.state = { ...DEFAULT_STATE, ...loadedState };
this.loaded = true;
}
async save(): Promise<void> {
await writeStateFile(this.ctx, this.state);
}
// ─── Tool Pair Buffer ────────────────────────────────────────────────────
addToolPair(pair: ToolPair): void {
if (this.processedToolCallIds.has(pair.toolCallId)) return;
(pair as ToolPair & { _sessionId?: string | null })._sessionId = this._ctx?.sessionId ?? null;
this.pendingToolPairs.push(pair as ToolPair & { _sessionId?: string | null });
}
getPendingCount(): number {
return this.pendingToolPairs.length;
}
hasPending(): boolean {
return this.pendingToolPairs.length > 0;
}
takePending(max: number): Array<ToolPair & { _sessionId?: string | null }> {
const taken = this.pendingToolPairs.splice(0, max);
for (const pair of taken) {
this.processedToolCallIds.add(pair.toolCallId);
}
return taken;
}
isProcessed(toolCallId: string): boolean {
return this.processedToolCallIds.has(toolCallId);
}
// ─── Active MMD ──────────────────────────────────────────────────────────
getActiveMmdFile(): string | null {
return this.state.activeMmdFile;
}
getActiveMmdId(): string | null {
return this.state.activeMmdId;
}
setActiveMmd(file: string | null, id: string | null): void {
this.state.activeMmdFile = file;
this.state.activeMmdId = id;
}
async nextMmdNumber(): Promise<number> {
try {
const existingFiles = await listMmds(this.ctx);
let maxOnDisk = 0;
for (const f of existingFiles) {
const m = f.match(/^(\d+)-/);
if (m) {
const num = parseInt(m[1], 10);
if (num > maxOnDisk) maxOnDisk = num;
}
}
if (maxOnDisk >= this.state.mmdCounter) {
this.state.mmdCounter = maxOnDisk;
}
} catch {
/* If listing fails, fall through with in-memory counter */
}
this.state.mmdCounter += 1;
return this.state.mmdCounter;
}
getMmdCounter(): number {
return this.state.mmdCounter;
}
// ─── Session / Multi-Agent ──────────────────────────────────────────────
getLastSessionKey(): string | null {
return this.state.lastSessionKey;
}
setLastSessionKey(key: string | null): void {
this.state.lastSessionKey = key;
}
/**
* Switch to a new session. Rebuilds StorageContext and reloads state.
* @param sessionKey - Full session key (e.g. "agent:main:session-123")
* @param dataRoot - Storage root directory
* @param realSessionId - Optional override for the parsed sessionId
*/
async switchSession(
sessionKey: string,
dataRoot: string,
realSessionId?: string,
): Promise<boolean> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return false;
const prevAgent = this._ctx?.agentName;
const effectiveSessionId = realSessionId || parsed.sessionId;
// Create new immutable StorageContext
this._ctx = createStorageContext(dataRoot, parsed.agentName, effectiveSessionId);
await ensureDirs(this._ctx);
if (realSessionId) {
await registerSession(this._ctx, sessionKey, realSessionId).catch(() => {});
}
if (prevAgent !== parsed.agentName) {
const loadedState = await readStateFile(this._ctx, DEFAULT_STATE);
this.state = { ...DEFAULT_STATE, ...loadedState };
}
try {
const entries = await readOffloadEntries(this._ctx);
this.confirmedOffloadIds = extractConfirmedIdsFromEntries(
entries as Array<OffloadEntry & { offloaded?: unknown }>,
);
this.deletedOffloadIds = extractDeletedIdsFromEntries(
entries as Array<OffloadEntry & { offloaded?: unknown }>,
);
this.processedToolCallIds = new Set<string>();
for (const e of entries) {
if (e.tool_call_id) {
this.processedToolCallIds.add(e.tool_call_id);
const norm = e.tool_call_id.replace(/_/g, "");
if (norm !== e.tool_call_id) {
this.processedToolCallIds.add(norm);
}
}
}
this.pendingToolPairs = [];
this.injectedMmdVersions = {};
this.mmdInjectionReady = false;
this.l15Settled = false;
this.lastMmdInjectedTokens = 0;
this.cachedUserPrompt = null;
this.lastL15PromptHash = null;
// Restore entryCounter from persisted entries; reset boundaries
this.entryCounter = entries.length;
this.l15Boundaries = [];
// Reset P1 quick-skip state
this.lastKnownTotalTokens = 0;
this.lastKnownMessageCount = 0;
this.consecutiveQuickSkips = 0;
this._forceEmergencyNext = false;
// Keep cachedSystemPrompt/Tokens across switchSession within the same agent
if (prevAgent !== parsed.agentName) {
this.cachedSystemPrompt = null;
this.cachedSystemPromptTokens = null;
this.cachedUserPromptTokens = null;
}
this._cachedOffloadMap = null;
this._offloadMapVersion++;
this.cachedLatestTurnMessages = null;
this.cachedRecentHistory = null;
this._reconcileRetries = new Map();
this._pendingParams = new Map();
this._l1ChunkFailCounts = new Map();
this.l15ConsecutiveNullCount = 0;
} catch {
this.confirmedOffloadIds = new Set();
this.deletedOffloadIds = new Set();
this.processedToolCallIds = new Set();
this.pendingToolPairs = [];
}
this.state.lastSessionKey = sessionKey;
await this.save();
return true;
}
getLastOffloadedToolCallId(): string | null {
return this.state.lastOffloadedToolCallId;
}
setLastOffloadedToolCallId(toolCallId: string | null): void {
this.state.lastOffloadedToolCallId = toolCallId;
}
// ─── L1 Mutex ────────────────────────────────────────────────────────────
acquireL1Lock(): Promise<() => void> {
let release!: () => void;
const prev = this.l1Lock;
this.l1Lock = new Promise<void>((resolve) => {
release = () => resolve();
});
return prev.then(() => release);
}
// ─── L2 Trigger Tracking ───────────────────────────────────────────────
getLastL2TriggerTime(): string | null {
return this.state.lastL2TriggerTime;
}
setLastL2TriggerTime(time: string | null): void {
this.state.lastL2TriggerTime = time;
}
// ─── Full State Access ───────────────────────────────────────────────────
getState(): Readonly<PluginState> {
return { ...this.state };
}
isLoaded(): boolean {
return this.loaded;
}
// ─── MMD Injection Control ──────────────────────────────────────────────
setMmdInjectionReady(ready: boolean): void {
this.mmdInjectionReady = ready;
}
isMmdInjectionReady(): boolean {
return this.mmdInjectionReady;
}
// ─── Injected MMD Version Tracking ──────────────────────────────────────
setInjectedMmdVersion(filename: string, fingerprint: string): void {
this.injectedMmdVersions[filename] = fingerprint;
}
getInjectedMmdVersion(filename: string): string | null {
return this.injectedMmdVersions[filename] ?? null;
}
removeInjectedMmdVersion(filename: string): void {
delete this.injectedMmdVersions[filename];
}
getAllInjectedMmdVersions(): Record<string, string> {
return { ...this.injectedMmdVersions };
}
clearInjectedMmdVersions(): void {
this.injectedMmdVersions = {};
}
// ─── Token Tracking ─────────────────────────────────────────────────────
setEstimatedSystemOverhead(tokens: number): void {
(this.state as unknown as Record<string, unknown>).estimatedSystemOverhead = tokens;
}
getEstimatedSystemOverhead(): number | null {
return (this.state as unknown as Record<string, unknown>).estimatedSystemOverhead as number | null;
}
// ─── Offload Map Cache ──────────────────────────────────────────────────
invalidateOffloadMapCache(): void {
this._cachedOffloadMap = null;
this._offloadMapVersion++;
}
getCachedOffloadMap(): Map<string, OffloadEntry> | null {
return this._cachedOffloadMap;
}
setCachedOffloadMap(map: Map<string, OffloadEntry>): void {
this._cachedOffloadMap = map;
}
getOffloadMapVersion(): number {
return this._offloadMapVersion;
}
// ─── Before Tool Call Params Cache ───────────────────────────────────────
cacheToolParams(toolCallId: string, params: Record<string, unknown>): void {
this._pendingParams.set(toolCallId, params);
if (this._pendingParams.size > 100) {
const oldest = this._pendingParams.keys().next().value;
if (oldest !== undefined) this._pendingParams.delete(oldest);
}
}
consumeToolParams(toolCallId: string): Record<string, unknown> | null {
const params = this._pendingParams.get(toolCallId);
if (params !== undefined) {
this._pendingParams.delete(toolCallId);
}
return params ?? null;
}
// ─── L1.5 Boundary Helpers ─────────────────────────────────────────────
/**
* Append a new boundary (must be in ascending startIndex order).
* If the last boundary has the same startIndex, overwrite it instead of
* appending — this happens during fast task switching when no tool calls
* (and thus no L1 entries) are produced between consecutive L1.5 judgments.
*/
pushBoundary(boundary: L15Boundary): void {
const last = this.l15Boundaries.at(-1);
if (last && last.startIndex === boundary.startIndex) {
this.l15Boundaries[this.l15Boundaries.length - 1] = boundary;
} else {
this.l15Boundaries.push(boundary);
}
}
/**
* Find the boundary that covers the given entry index.
* Returns the last boundary whose startIndex <= entryIndex,
* or null if no boundary covers it (entry predates all boundaries).
*/
resolveEntryBoundary(entryIndex: number): L15Boundary | null {
let matched: L15Boundary | null = null;
for (const b of this.l15Boundaries) {
if (b.startIndex <= entryIndex) {
matched = b;
} else {
break; // boundaries are ascending by startIndex
}
}
return matched;
}
}
+348
View File
@@ -0,0 +1,348 @@
/**
* Plugin state & L3 token consumption reporter.
*
* Uploads runtime diagnostics to the backend `/offload/v1/store` endpoint
* so operators can inspect plugin activity and L3 compression efficiency
* off-host.
*
* The backend keys stored documents by `X-User-Id` (upsert semantics), so
* every report represents the latest snapshot for that user. We therefore
* include BOTH:
* - `cumulative`: monotonically-increasing counters (total tokens saved,
* total tool calls, total L3 triggers) maintained as module-level
* globals so they survive across per-trigger reports.
* - `recent`: the most recent L3 trigger's detailed accounting
* (tokens/msgs before and after) for spot inspection.
*
* Four pieces of information are reported on every L3 trigger:
* 1. Plugin state snapshot (active MMD, pending pairs, L1.5 settled, etc.)
* 2. L3 token accounting (tokensBefore/After, savings, fixed overhead)
* 3. Cumulative + recent counters
* 4. Patch-health signal — only meaningful for `after_tool_call` hook:
* the upstream runtime patch is expected to populate `event.messages`
* with the current conversation. If `event.messages` is missing/empty
* the patch did NOT take effect and L3 cannot operate from this hook.
*
* All reporting is fire-and-forget — rejection is logged but never thrown
* back to the caller so hook execution stays unaffected.
*/
import type { BackendClient, StoreStatePayload } from "./backend-client.js";
import type { OffloadStateManager } from "./state-manager.js";
import type { PluginLogger } from "./types.js";
import { nowChinaISO } from "./time-utils.js";
// ─── Fixed overhead constants ────────────────────────────────────────────────
/**
* Fixed L3 "patch overhead" charged per trigger.
*
* The context-offload runtime patch injects a small amount of boilerplate
* (scanner loops, message-mutation wrappers, sentinel fields like
* `_offloaded` / `_mmdContextMessage`) before the compression routine runs.
* That boilerplate adds a roughly constant token cost per invocation that
* is NOT captured by the tiktoken snapshot delta (which only measures
* compressed vs uncompressed messages).
*
* We account for it here with a single fixed constant so cost/benefit
* tracking on the backend is monotonic. The value is a conservative estimate
* that can be tuned as the runtime patch evolves.
*/
export const L3_FIXED_PATCH_COST_TOKENS = 80;
/** L3 trigger site — matches the three places that invoke L3 compression. */
export type L3TriggerStage = "after_tool_call" | "llm_input" | "assemble";
/**
* Patch-effectiveness signal derived from the after_tool_call event.
*
* The upstream runtime patch is expected to attach the current `messages`
* array to the event object. When the patch is missing, `event.messages`
* is undefined and L3 cannot inspect or mutate the conversation.
*/
export type PatchEffective = "effective" | "missing_field" | "empty_messages" | "n/a";
/** Inspects `event.messages` to classify patch health for after_tool_call. */
export function classifyPatchEffectiveness(
event: unknown,
stage: L3TriggerStage,
): { status: PatchEffective; messagesLen: number } {
// Only after_tool_call depends on the runtime patch for event.messages.
if (stage !== "after_tool_call") return { status: "n/a", messagesLen: 0 };
if (!event || typeof event !== "object") {
return { status: "missing_field", messagesLen: 0 };
}
const msgs = (event as { messages?: unknown }).messages;
if (!Array.isArray(msgs)) return { status: "missing_field", messagesLen: 0 };
if (msgs.length === 0) return { status: "empty_messages", messagesLen: 0 };
return { status: "effective", messagesLen: msgs.length };
}
// ─── Global cumulative counters ──────────────────────────────────────────────
//
// Module-level globals that accumulate over the lifetime of the host
// process. They survive across OpenClaw's repeated `registerOffload()` calls
// (which rebuild hook closures but do not reload the module).
interface CumulativeCounters {
/** Total tokens saved by L3 compression (sum of max(0, before-after)). */
totalTokensSaved: number;
/** Net savings after subtracting fixed patch cost from each trigger. */
totalNetTokensSaved: number;
/** Total number of after_tool_call events observed (incl. heartbeats/skips). */
totalToolCalls: number;
/** Total number of L3 trigger reports emitted across all stages. */
totalL3Triggers: number;
/** Per-stage L3 trigger counts. */
totalL3TriggersByStage: Record<L3TriggerStage, number>;
/** Total messages deleted by aggressive compression. */
totalAggressiveDeleted: number;
/** Total messages replaced by mild compression. */
totalMildReplaced: number;
/** Total emergency compression triggers. */
totalEmergencyTriggered: number;
/** Total messages deleted by emergency compression. */
totalEmergencyDeleted: number;
/** Timestamp when counters started accumulating. */
startedAt: string;
}
const _counters: CumulativeCounters = {
totalTokensSaved: 0,
totalNetTokensSaved: 0,
totalToolCalls: 0,
totalL3Triggers: 0,
totalL3TriggersByStage: { after_tool_call: 0, llm_input: 0, assemble: 0 },
totalAggressiveDeleted: 0,
totalMildReplaced: 0,
totalEmergencyTriggered: 0,
totalEmergencyDeleted: 0,
startedAt: nowChinaISO(),
};
/**
* Record a tool-call observation. Called from the `after_tool_call` hook
* entry regardless of whether L3 compression fires — it counts *all* tool
* invocations the plugin has seen.
*/
export function recordToolCall(): void {
_counters.totalToolCalls += 1;
}
/** Returns a shallow copy of the current cumulative counters. */
export function getCumulativeCounters(): CumulativeCounters {
return {
..._counters,
totalL3TriggersByStage: { ..._counters.totalL3TriggersByStage },
};
}
/** Testing hook — wipes counters so unit tests stay isolated. */
export function _resetCumulativeCountersForTests(): void {
_counters.totalTokensSaved = 0;
_counters.totalNetTokensSaved = 0;
_counters.totalToolCalls = 0;
_counters.totalL3Triggers = 0;
_counters.totalL3TriggersByStage = { after_tool_call: 0, llm_input: 0, assemble: 0 };
_counters.totalAggressiveDeleted = 0;
_counters.totalMildReplaced = 0;
_counters.totalEmergencyTriggered = 0;
_counters.totalEmergencyDeleted = 0;
_counters.startedAt = nowChinaISO();
}
// ─── Report payload types ────────────────────────────────────────────────────
/** Stable report type tag — one line per reporting category. */
export const REPORT_TYPE_L3 = "offload.l3.trigger" as const;
/** Per-L3-trigger report payload. */
export interface L3TriggerReport {
reportType: typeof REPORT_TYPE_L3;
reportedAt: string;
sessionKey: string | null;
stage: L3TriggerStage;
triggerReason: string;
pluginState: {
activeMmdFile: string | null;
l15Settled: boolean;
pendingCount: number;
confirmedOffloadCount: number;
deletedOffloadCount: number;
};
/** Detailed accounting for THIS trigger only. */
recent: {
tokensBefore: number;
tokensAfter: number;
tokensSaved: number;
netTokensSaved: number;
messagesBefore: number;
messagesAfter: number;
messagesRemoved: number;
durationMs: number;
};
/** Threshold context so the report is self-describing. */
thresholds: {
contextWindow: number;
mildThreshold: number;
aggressiveThreshold: number;
fixedPatchCostTokens: number;
utilisationBeforePct: number;
utilisationAfterPct: number;
};
compression: {
aboveMild: boolean;
aboveAggressive: boolean;
mildReplacedCount: number;
aggressiveDeletedCount: number;
emergencyTriggered: boolean;
emergencyDeletedCount: number;
};
/** Process-lifetime cumulative counters (not per-report). */
cumulative: CumulativeCounters;
patch: {
status: PatchEffective;
messagesLen: number;
};
}
// ─── Builder & sender ────────────────────────────────────────────────────────
export interface BuildL3ReportInput {
stage: L3TriggerStage;
triggerReason: string;
stateManager: OffloadStateManager;
event?: unknown;
contextWindow: number;
mildThreshold: number;
aggressiveThreshold: number;
tokensBefore: number;
tokensAfter: number;
/** Message count before L3 compression ran. */
messagesBefore: number;
/** Message count after L3 compression ran. */
messagesAfter: number;
durationMs: number;
aboveMild: boolean;
aboveAggressive: boolean;
mildReplacedCount?: number;
aggressiveDeletedCount?: number;
emergencyTriggered?: boolean;
emergencyDeletedCount?: number;
}
export function buildL3TriggerReport(input: BuildL3ReportInput): L3TriggerReport {
const {
stage,
triggerReason,
stateManager,
event,
contextWindow,
mildThreshold,
aggressiveThreshold,
tokensBefore,
tokensAfter,
messagesBefore,
messagesAfter,
durationMs,
aboveMild,
aboveAggressive,
mildReplacedCount = 0,
aggressiveDeletedCount = 0,
emergencyTriggered = false,
emergencyDeletedCount = 0,
} = input;
const tokensSaved = Math.max(0, tokensBefore - tokensAfter);
const netTokensSaved = tokensSaved - L3_FIXED_PATCH_COST_TOKENS;
const patch = classifyPatchEffectiveness(event, stage);
// ── Cumulative update (side effect — counters persist across triggers) ──
_counters.totalTokensSaved += tokensSaved;
_counters.totalNetTokensSaved += netTokensSaved;
_counters.totalL3Triggers += 1;
_counters.totalL3TriggersByStage[stage] =
(_counters.totalL3TriggersByStage[stage] ?? 0) + 1;
_counters.totalAggressiveDeleted += aggressiveDeletedCount;
_counters.totalMildReplaced += mildReplacedCount;
if (emergencyTriggered) _counters.totalEmergencyTriggered += 1;
_counters.totalEmergencyDeleted += emergencyDeletedCount;
// Safe read: stateManager is private-field-heavy, use only public getters.
let activeMmdFile: string | null = null;
try { activeMmdFile = stateManager.getActiveMmdFile?.() ?? null; } catch { /* ignore */ }
let sessionKey: string | null = null;
try { sessionKey = stateManager.getLastSessionKey?.() ?? null; } catch { /* ignore */ }
let pendingCount = 0;
try { pendingCount = stateManager.getPendingCount?.() ?? 0; } catch { /* ignore */ }
return {
reportType: REPORT_TYPE_L3,
reportedAt: nowChinaISO(),
sessionKey,
stage,
triggerReason,
pluginState: {
activeMmdFile,
l15Settled: stateManager.l15Settled === true,
pendingCount,
confirmedOffloadCount: stateManager.confirmedOffloadIds?.size ?? 0,
deletedOffloadCount: stateManager.deletedOffloadIds?.size ?? 0,
},
recent: {
tokensBefore,
tokensAfter,
tokensSaved,
netTokensSaved,
messagesBefore,
messagesAfter,
messagesRemoved: Math.max(0, messagesBefore - messagesAfter),
durationMs,
},
thresholds: {
contextWindow,
mildThreshold,
aggressiveThreshold,
fixedPatchCostTokens: L3_FIXED_PATCH_COST_TOKENS,
utilisationBeforePct: contextWindow > 0 ? +((tokensBefore / contextWindow) * 100).toFixed(2) : 0,
utilisationAfterPct: contextWindow > 0 ? +((tokensAfter / contextWindow) * 100).toFixed(2) : 0,
},
compression: {
aboveMild,
aboveAggressive,
mildReplacedCount,
aggressiveDeletedCount,
emergencyTriggered,
emergencyDeletedCount,
},
cumulative: getCumulativeCounters(),
patch,
};
}
/**
* Fire-and-forget upload of an L3 report to the backend store endpoint.
* Must never throw — rejection is logged at warn level only.
*/
export function reportL3Trigger(
backendClient: BackendClient | null,
report: L3TriggerReport,
logger: PluginLogger,
): void {
if (!backendClient) return;
try {
backendClient
.storeState(report as unknown as StoreStatePayload)
.then(() => {
logger.info(
`[context-offload] state-report OK: stage=${report.stage} reason=${report.triggerReason} ` +
`recentSaved=${report.recent.tokensSaved} cumSaved=${report.cumulative.totalTokensSaved} ` +
`toolCalls=${report.cumulative.totalToolCalls} patch=${report.patch.status}`,
);
})
.catch((err) => {
logger.warn(`[context-offload] state-report FAILED: stage=${report.stage}${err}`);
});
} catch (err) {
logger.warn(`[context-offload] state-report schedule FAILED: ${err}`);
}
}
+664
View File
@@ -0,0 +1,664 @@
/**
* File I/O layer for the context offload plugin.
*
* Multi-agent / multi-session storage isolation:
* - Different agents get separate subdirectories under dataRoot
* - Same agent shares mmds/, refs/, state.json
* - offload is per-session: offload-<sessionId>.jsonl
* - L2 aggregation reads all offload-*.jsonl in the agent dir
* - All I/O functions require a StorageContext (no global mutable state)
*/
import { readFile, writeFile, appendFile, mkdir, readdir, unlink } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, dirname, basename } from "node:path";
import { homedir } from "node:os";
import type { OffloadEntry, PluginLogger } from "./types.js";
/** Default root data directory (parent of all agent subdirectories) */
export const DEFAULT_DATA_ROOT = join(homedir(), ".openclaw", "context-offload");
// ─── StorageContext ──────────────────────────────────────────────────────────
/** Immutable per-session storage path context. Created once per session switch. */
export interface StorageContext {
readonly dataRoot: string;
readonly dataDir: string;
readonly refsDir: string;
readonly mmdsDir: string;
readonly offloadJsonl: string;
readonly stateFile: string;
readonly agentName: string;
readonly sessionId: string;
}
/**
* Build an immutable StorageContext for a given agent + session.
* Once created, paths are frozen and cannot be affected by other sessions.
*/
export function createStorageContext(
dataRoot: string,
agentName: string,
sessionId: string,
): StorageContext {
const dataDir = join(dataRoot, agentName);
return Object.freeze({
dataRoot,
dataDir,
refsDir: join(dataDir, "refs"),
mmdsDir: join(dataDir, "mmds"),
offloadJsonl: join(dataDir, `offload-${sessionId}.jsonl`),
stateFile: join(dataDir, "state.json"),
agentName,
sessionId,
});
}
// ─── SessionKey Parsing ──────────────────────────────────────────────────────
/** Sanitize a string for use as a directory/file name */
function sanitizePath(s: string): string {
return s.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/\.{2,}/g, "_");
}
/**
* Parse a sessionKey into agentName and sessionId.
* Expected format: "agent:<agent-name>:<session-id>"
*
* Worker isolation: if the sessionId contains a "swebench-w{N}" pattern
* (from multi-worker inference), the worker suffix is merged into agentName
* so each worker gets its own dataDir (state.json, mmds/, refs/).
*
* Returns null if format doesn't match.
*/
export function parseSessionKey(
sessionKey: string,
): { agentName: string; sessionId: string } | null {
if (typeof sessionKey !== "string") return null;
const parts = sessionKey.split(":");
if (parts.length < 3 || parts[0] !== "agent" || !parts[1]) return null;
let agentName = parts[1];
const sessionId = parts.slice(2).join(":");
if (!sessionId) return null;
const workerMatch = sessionId.match(/swebench-w(\d+)/);
if (workerMatch) {
agentName = `${agentName}-w${workerMatch[1]}`;
}
return {
agentName: sanitizePath(agentName),
sessionId: sanitizePath(sessionId),
};
}
// ─── Directory Operations ────────────────────────────────────────────────────
/** Ensure all required directories exist for the given context */
export async function ensureDirs(ctx: StorageContext): Promise<void> {
await mkdir(ctx.dataRoot, { recursive: true });
await mkdir(ctx.dataDir, { recursive: true });
await mkdir(ctx.refsDir, { recursive: true });
await mkdir(ctx.mmdsDir, { recursive: true });
}
// ─── Session Registry ────────────────────────────────────────────────────────
/** Record a sessionKey → realSessionId mapping in the agent's registry. */
export async function registerSession(
ctx: StorageContext,
sessionKey: string,
realSessionId: string,
): Promise<void> {
if (!sessionKey || !realSessionId || !existsSync(ctx.dataDir)) return;
const registryPath = join(ctx.dataDir, "sessions-registry.json");
let registry: Record<string, unknown> = {};
try {
if (existsSync(registryPath)) {
registry = JSON.parse(await readFile(registryPath, "utf-8"));
}
} catch {
/* corrupt file, start fresh */
}
registry[sessionKey] = {
sessionId: realSessionId,
offloadFile: `offload-${realSessionId}.jsonl`,
updatedAt: new Date().toISOString(),
};
await writeFile(registryPath, JSON.stringify(registry, null, 2), "utf-8");
}
/** Look up the real sessionId for a given sessionKey from the registry. */
export async function lookupSessionId(
ctx: StorageContext,
sessionKey: string,
): Promise<string | null> {
if (!sessionKey || !existsSync(ctx.dataDir)) return null;
const registryPath = join(ctx.dataDir, "sessions-registry.json");
try {
if (!existsSync(registryPath)) return null;
const registry = JSON.parse(await readFile(registryPath, "utf-8")) as Record<string, { sessionId?: string }>;
return registry[sessionKey]?.sessionId ?? null;
} catch {
return null;
}
}
/** List all registered sessions for the given context. */
export async function listRegisteredSessions(
ctx: StorageContext,
): Promise<Array<{ sessionKey: string; [key: string]: unknown }>> {
if (!existsSync(ctx.dataDir)) return [];
const registryPath = join(ctx.dataDir, "sessions-registry.json");
try {
if (!existsSync(registryPath)) return [];
const registry = JSON.parse(await readFile(registryPath, "utf-8")) as Record<string, Record<string, unknown>>;
return Object.entries(registry).map(([key, val]) => ({
sessionKey: key,
...val,
}));
} catch {
return [];
}
}
// ─── JSONL Defense Layer ─────────────────────────────────────────────────────
const UNSAFE_CHAR_RE =
/[\uFFFD\u0000-\u0008\u000B\u000C\u000E-\u001F\u0080-\u009F\uD800-\uDFFF\u200B-\u200F\u2028\u2029\uFEFF]/g;
/** Layer 0 — Source text sanitize. Strips unsafe characters from arbitrary text. */
export function sanitizeText(text: string): string {
if (typeof text !== "string") return text;
return text.replace(UNSAFE_CHAR_RE, "");
}
/** Layer 1 — Write sanitize. Strips unsafe characters from a JSON string with roundtrip verification. */
export function sanitizeJsonLine(jsonStr: string): string {
let cleaned = jsonStr.replace(UNSAFE_CHAR_RE, "");
try {
JSON.parse(cleaned);
return cleaned;
} catch {
/* fall through */
}
cleaned = jsonStr.replace(
/[^\x09\x0A\x0D\x20-\x7E\u00A0-\u024F\u3400-\u4DBF\u4E00-\u9FFF\uFF00-\uFFEF]/g,
"",
);
try {
JSON.parse(cleaned);
return cleaned;
} catch {
/* fall through */
}
try {
const obj = JSON.parse(jsonStr.replace(/[^\x20-\x7E\t\n\r]/g, ""));
return JSON.stringify(obj);
} catch {
return "{}";
}
}
/** Layer 3 — Entry schema validation. */
export function validateEntry(entry: unknown): boolean {
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
return false;
const e = entry as Record<string, unknown>;
if (typeof e.tool_call_id !== "string" || (e.tool_call_id as string).length === 0)
return false;
return true;
}
/** Layer 2+3+4 — Safe JSONL parser with tolerance, validation, and metrics. */
export function parseJsonlSafe(
content: string,
options?: { sourceLabel?: string; skipValidation?: boolean },
): {
entries: Array<Record<string, unknown>>;
corruptCount: number;
invalidCount: number;
corruptSample: string | null;
} {
const entries: Array<Record<string, unknown>> = [];
let corruptCount = 0;
let invalidCount = 0;
let corruptSample: string | null = null;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
try {
parsed = JSON.parse(trimmed.replace(UNSAFE_CHAR_RE, ""));
} catch {
corruptCount++;
if (corruptSample === null) {
corruptSample = trimmed.slice(0, 200);
}
continue;
}
}
if (!options?.skipValidation && !validateEntry(parsed)) {
invalidCount++;
continue;
}
entries.push(parsed as Record<string, unknown>);
}
return { entries, corruptCount, invalidCount, corruptSample };
}
function safeStringifyEntry(entry: Record<string, unknown>): string {
return sanitizeJsonLine(JSON.stringify(entry));
}
// ─── JSONL Operations (current session) ──────────────────────────────────────
/** Append one or more entries to an offload JSONL with write-time dedup. */
export async function appendOffloadEntries(
ctx: StorageContext,
entries: OffloadEntry[],
targetSessionId?: string,
logger?: PluginLogger,
): Promise<void> {
const filePath =
targetSessionId && targetSessionId !== ctx.sessionId
? join(ctx.dataDir, `offload-${targetSessionId}.jsonl`)
: ctx.offloadJsonl;
let newEntries: OffloadEntry[] = entries;
if (existsSync(filePath)) {
try {
const existingContent = await readFile(filePath, "utf-8");
const existingIds = new Set<string>();
for (const line of existingContent.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof parsed.tool_call_id === "string") {
existingIds.add(parsed.tool_call_id);
const norm = (parsed.tool_call_id as string).replace(/_/g, "");
if (norm !== parsed.tool_call_id) existingIds.add(norm);
}
} catch {
/* skip corrupt lines */
}
}
if (existingIds.size > 0) {
const before = newEntries.length;
const duplicates: string[] = [];
newEntries = entries.filter((e) => {
const id = e.tool_call_id;
if (!id) return true;
const norm = id.replace(/_/g, "");
if (existingIds.has(id) || existingIds.has(norm)) {
duplicates.push(id);
return false;
}
return true;
});
if (duplicates.length > 0) {
logger?.warn?.(
`[context-offload] appendOffloadEntries DEDUP: ${duplicates.length}/${before} entries are duplicates, writing ${newEntries.length}. file=${basename(filePath)} duplicateIds=[${duplicates.join(",")}]`,
);
}
}
} catch {
/* If reading existing file fails, proceed without dedup */
}
}
if (newEntries.length === 0) {
logger?.info?.(
`[context-offload] appendOffloadEntries: all ${entries.length} entries deduped, nothing to write`,
);
return;
}
const lines = newEntries.map((e) => safeStringifyEntry(e as unknown as Record<string, unknown>)).join("\n") + "\n";
await appendFile(filePath, lines, "utf-8");
}
/** Read all entries from the current session's offload JSONL. */
export async function readOffloadEntries(
ctx: StorageContext,
logger?: PluginLogger,
): Promise<OffloadEntry[]> {
if (!existsSync(ctx.offloadJsonl)) return [];
let content: string;
try {
content = await readFile(ctx.offloadJsonl, "utf-8");
} catch (err) {
logger?.warn?.(
`[context-offload] readOffloadEntries: failed to read ${ctx.offloadJsonl}: ${(err as Error).message}`,
);
return [];
}
const { entries, corruptCount, invalidCount, corruptSample } = parseJsonlSafe(
content,
{ sourceLabel: basename(ctx.offloadJsonl) },
);
if (corruptCount > 0 || invalidCount > 0) {
logger?.warn?.(
`[context-offload] readOffloadEntries: skipped ${corruptCount} corrupt + ${invalidCount} invalid lines in ${basename(ctx.offloadJsonl)}. Sample: ${corruptSample?.slice(0, 100)}`,
);
}
return entries as unknown as OffloadEntry[];
}
/** Rewrite the current session's offload JSONL with the given entries (sanitized) */
export async function rewriteOffloadEntries(
ctx: StorageContext,
entries: OffloadEntry[],
): Promise<void> {
const content =
entries.map((e) => safeStringifyEntry(e as unknown as Record<string, unknown>)).join("\n") +
(entries.length > 0 ? "\n" : "");
await writeFile(ctx.offloadJsonl, content, "utf-8");
}
/** Mark offload entries by tool_call_id with an `offloaded` status. */
export async function markOffloadStatus(
ctx: StorageContext,
updates: Map<string, string | boolean>,
): Promise<void> {
if (!existsSync(ctx.offloadJsonl) || updates.size === 0) return;
const entries = (await readOffloadEntries(ctx)) as Array<OffloadEntry & { offloaded?: string | boolean }>;
let changed = false;
for (const entry of entries) {
const status = updates.get(entry.tool_call_id);
if (status !== undefined && entry.offloaded !== status) {
entry.offloaded = status;
changed = true;
}
}
if (changed) {
await rewriteOffloadEntries(ctx, entries);
}
}
/** Extract confirmed (offloaded) tool_call_ids from entries. */
export function extractConfirmedIdsFromEntries(
entries: Array<OffloadEntry & { offloaded?: unknown }>,
): Set<string> {
const ids = new Set<string>();
for (const entry of entries) {
if (entry.offloaded) {
const id = entry.tool_call_id;
if (!id) continue;
ids.add(id);
const normalized = id.replace(/_/g, "");
if (normalized !== id) ids.add(normalized);
}
}
return ids;
}
/** Extract aggressively deleted tool_call_ids from entries. */
export function extractDeletedIdsFromEntries(
entries: Array<OffloadEntry & { offloaded?: unknown }>,
): Set<string> {
const ids = new Set<string>();
for (const entry of entries) {
if (entry.offloaded === "deleted") {
const id = entry.tool_call_id;
if (!id) continue;
ids.add(id);
const normalized = id.replace(/_/g, "");
if (normalized !== id) ids.add(normalized);
}
}
return ids;
}
// ─── JSONL Operations (all sessions under current agent) ─────────────────────
/** Read offload entries from ALL session files under ctx.dataDir. */
export async function readAllOffloadEntries(
ctx: StorageContext,
logger?: PluginLogger,
): Promise<Array<OffloadEntry & { _sourceFile?: string }>> {
if (!existsSync(ctx.dataDir)) return [];
let files: string[];
try {
files = await readdir(ctx.dataDir);
} catch (err) {
logger?.warn?.(
`[context-offload] readAllOffloadEntries: failed to readdir ${ctx.dataDir}: ${(err as Error).message}`,
);
return [];
}
const offloadFiles = files
.filter((f) => f.startsWith("offload-") && f.endsWith(".jsonl"))
.sort();
if (offloadFiles.length === 0) return [];
const allEntries: Array<OffloadEntry & { _sourceFile?: string }> = [];
let totalCorrupt = 0;
let totalInvalid = 0;
await Promise.all(
offloadFiles.map(async (filename) => {
try {
const filePath = join(ctx.dataDir, filename);
const content = await readFile(filePath, "utf-8");
const { entries, corruptCount, invalidCount } = parseJsonlSafe(content, {
sourceLabel: filename,
});
totalCorrupt += corruptCount;
totalInvalid += invalidCount;
for (const entry of entries) {
(entry as Record<string, unknown>)._sourceFile = filename;
allEntries.push(entry as unknown as OffloadEntry & { _sourceFile?: string });
}
} catch (err) {
logger?.warn?.(
`[context-offload] readAllOffloadEntries: failed to read ${filename}: ${(err as Error).message}`,
);
}
}),
);
if (totalCorrupt > 0 || totalInvalid > 0) {
logger?.warn?.(
`[context-offload] readAllOffloadEntries: skipped ${totalCorrupt} corrupt + ${totalInvalid} invalid lines across ${offloadFiles.length} files`,
);
}
return allEntries;
}
/** Write entries back to their respective source files. */
export async function rewriteAllOffloadEntries(
ctx: StorageContext,
entries: Array<Record<string, unknown> | any>,
): Promise<void> {
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const entry of entries) {
const sourceFile = (entry._sourceFile as string) ?? basename(ctx.offloadJsonl);
if (!groups.has(sourceFile)) {
groups.set(sourceFile, []);
}
const clean = { ...entry };
delete clean._sourceFile;
groups.get(sourceFile)!.push(clean);
}
if (existsSync(ctx.dataDir)) {
const files = await readdir(ctx.dataDir);
const offloadFiles = files.filter(
(f) => f.startsWith("offload-") && f.endsWith(".jsonl"),
);
for (const f of offloadFiles) {
if (!groups.has(f)) {
groups.set(f, []);
}
}
}
await Promise.all(
Array.from(groups.entries()).map(async ([filename, fileEntries]) => {
const filePath = join(ctx.dataDir, filename);
const content =
fileEntries.map(safeStringifyEntry).join("\n") +
(fileEntries.length > 0 ? "\n" : "");
await writeFile(filePath, content, "utf-8");
}),
);
}
/** Update specific entries by tool_call_id across ALL session files (L2 backfill). */
export async function updateOffloadNodeIds(
ctx: StorageContext,
updates: Map<string, string>,
): Promise<void> {
const entries = await readAllOffloadEntries(ctx);
let changed = false;
for (const entry of entries) {
const newNodeId = updates.get(entry.tool_call_id);
if (newNodeId !== undefined) {
entry.node_id = newNodeId;
changed = true;
}
}
if (changed) {
await rewriteAllOffloadEntries(ctx, entries as unknown as Array<Record<string, unknown>>);
}
}
// ─── MD (Tool Result Refs) Operations ────────────────────────────────────────
/** Convert ISO 8601 timestamp to a safe filename (replace special chars) */
export function isoToFilename(iso: string): string {
return iso.replace(/:/g, "-").replace(/\./g, "-").replace(/\+/g, "p");
}
/** Write tool result content to a ref MD file, return relative path */
export async function writeRefMd(
ctx: StorageContext,
timestamp: string,
toolName: string,
content: string,
): Promise<string> {
const filename = `${isoToFilename(timestamp)}.md`;
const filePath = join(ctx.refsDir, filename);
const safeContent = (content ?? "").replace(UNSAFE_CHAR_RE, "");
const header = `# Tool Result: ${toolName}\n\n**Timestamp:** ${timestamp}\n\n---\n\n`;
await writeFile(filePath, header + safeContent, "utf-8");
return `refs/${filename}`;
}
/** Read a ref MD file by relative path */
export async function readRefMd(
ctx: StorageContext,
refPath: string,
): Promise<string | null> {
const filePath = join(ctx.dataDir, refPath);
if (!existsSync(filePath)) return null;
return readFile(filePath, "utf-8");
}
// ─── MMD (Mermaid) Operations ────────────────────────────────────────────────
/** A single replace block for patchMmd */
export interface MmdReplaceBlock {
/** 1-based start line number (inclusive) */
startLine: number;
/** 1-based end line number (inclusive). If endLine < startLine, treat as pure insertion */
endLine: number;
/** Replacement content (may contain newlines) */
content: string;
}
/** Write/overwrite an MMD file */
export async function writeMmd(
ctx: StorageContext,
filename: string,
content: string,
): Promise<void> {
const filePath = join(ctx.mmdsDir, filename);
await writeFile(filePath, content, "utf-8");
}
/** Apply incremental line-based replace blocks to an existing MMD file. */
export async function patchMmd(
ctx: StorageContext,
filename: string,
blocks: MmdReplaceBlock[],
): Promise<boolean> {
const filePath = join(ctx.mmdsDir, filename);
const original = await readMmd(ctx, filename);
if (original === null) return false;
const lines = original.split("\n");
let allValid = true;
const sorted = [...blocks].sort((a, b) => b.startLine - a.startLine);
for (const block of sorted) {
const start = block.startLine;
const end = block.endLine;
if (start < 1 || start > lines.length + 1) {
allValid = false;
continue;
}
const newContentLines = block.content ? block.content.split("\n") : [];
if (end < start) {
lines.splice(start - 1, 0, ...newContentLines);
} else {
const clampedEnd = Math.min(end, lines.length);
const deleteCount = clampedEnd - start + 1;
lines.splice(start - 1, deleteCount, ...newContentLines);
}
}
const newContent = lines.join("\n");
if (newContent !== original) {
await writeFile(filePath, newContent, "utf-8");
}
return allValid;
}
/** Read an MMD file */
export async function readMmd(
ctx: StorageContext,
filename: string,
): Promise<string | null> {
const filePath = join(ctx.mmdsDir, filename);
if (!existsSync(filePath)) return null;
return readFile(filePath, "utf-8");
}
/** Delete an MMD file */
export async function deleteMmd(
ctx: StorageContext,
filename: string,
): Promise<boolean> {
const filePath = join(ctx.mmdsDir, filename);
if (!existsSync(filePath)) return false;
await unlink(filePath);
return true;
}
/** List all MMD files in the mmds directory */
export async function listMmds(ctx: StorageContext): Promise<string[]> {
if (!existsSync(ctx.mmdsDir)) return [];
const files = await readdir(ctx.mmdsDir);
return files.filter((f) => f.endsWith(".mmd")).sort();
}
// ─── State File Operations ───────────────────────────────────────────────────
/** Read the state.json file */
export async function readStateFile<T>(
ctx: StorageContext,
defaultValue: T,
): Promise<T> {
if (!existsSync(ctx.stateFile)) return defaultValue;
try {
const content = await readFile(ctx.stateFile, "utf-8");
return JSON.parse(content) as T;
} catch {
return defaultValue;
}
}
/** Write the state.json file */
export async function writeStateFile<T>(
ctx: StorageContext,
state: T,
): Promise<void> {
await mkdir(dirname(ctx.stateFile), { recursive: true });
await writeFile(ctx.stateFile, JSON.stringify(state, null, 2), "utf-8");
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Time utilities — all ISO 8601 timestamps use China Standard Time (UTC+08:00).
*/
/** China timezone offset in minutes (+8 hours) */
const CST_OFFSET_MINUTES = 8 * 60;
/**
* Get the current time as an ISO 8601 string in China Standard Time (UTC+08:00).
* Format: "2026-03-25T16:53:51.178+08:00"
*/
export function nowChinaISO(): string {
return toChinaISO(new Date());
}
/**
* Convert any Date object to an ISO 8601 string in China Standard Time.
* Format: "YYYY-MM-DDTHH:mm:ss.SSS+08:00"
*/
export function toChinaISO(date: Date): string {
const utcMs = date.getTime();
const cstMs = utcMs + CST_OFFSET_MINUTES * 60 * 1000;
const cst = new Date(cstMs);
const year = cst.getUTCFullYear();
const month = String(cst.getUTCMonth() + 1).padStart(2, "0");
const day = String(cst.getUTCDate()).padStart(2, "0");
const hours = String(cst.getUTCHours()).padStart(2, "0");
const minutes = String(cst.getUTCMinutes()).padStart(2, "0");
const seconds = String(cst.getUTCSeconds()).padStart(2, "0");
const ms = String(cst.getUTCMilliseconds()).padStart(3, "0");
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}+08:00`;
}
+252
View File
@@ -0,0 +1,252 @@
/**
* Core type definitions for the context offload plugin.
* Ported from context-offload-plugin with updated runtime defaults.
*/
// ============================
// Data types
// ============================
/** A single offloaded tool call/result summary stored in offload.jsonl */
export interface OffloadEntry {
/** ISO timestamp inherited from the original tool result */
timestamp: string;
/** Mermaid node ID assigned by L2, null until L2 runs */
node_id: string | null;
/** Short description of the tool call command */
tool_call: string;
/** LLM-generated summary of the tool result */
summary: string;
/** Relative path to the MD file containing the full tool result */
result_ref: string;
/** The original tool call ID from the provider */
tool_call_id: string;
/** Session key this entry belongs to */
session_key?: string;
/** Replaceability score (0-10). Higher = summary can better replace original. Assigned by L1 LLM. */
score?: number;
}
/** A buffered tool call + result pair waiting to be processed by L1 */
export interface ToolPair {
toolName: string;
toolCallId: string;
params: Record<string, unknown> | string;
result: unknown;
error?: string;
timestamp: string;
durationMs?: number;
}
/** Persistent plugin state saved to state.json */
export interface PluginState {
/** Path to the currently active MMD file (relative to mmds/) */
activeMmdFile: string | null;
/** Identifier/label for the active MMD */
activeMmdId: string | null;
/** Counter for auto-incrementing MMD filenames */
mmdCounter: number;
/** Last session key the plugin was active in */
lastSessionKey: string | null;
/** Last tool_call_id that was successfully offloaded into compact context (L3 cursor) */
lastOffloadedToolCallId: string | null;
/** ISO timestamp of the last successful L2 trigger */
lastL2TriggerTime: string | null;
}
/** Metadata block embedded in MMD files */
export interface MmdMetadata {
taskGoal: string;
createdTime: string;
updatedTime: string;
}
/** A node in the Mermaid flowchart */
export interface MmdNode {
id: string;
label: string;
status: "done" | "doing" | "todo";
summary: string;
timestamp: string;
}
// ============================
// LLM types
// ============================
/** Configuration for the LLM client */
export interface LlmConfig {
baseUrl: string;
apiKey: string;
model: string;
}
/** Result from L1.5 task judgment */
export interface TaskJudgment {
/** Whether the current task is completed */
taskCompleted: boolean;
/** Whether the new task is a continuation of a recent task */
isContinuation: boolean;
/** If continuation, which MMD file to reactivate */
continuationMmdFile?: string;
/** Short label for new task (used in MMD filename) */
newTaskLabel?: string;
/** Whether this is a long task (vs. casual chat) */
isLongTask: boolean;
}
/** L1.5 boundary marker: divides entries into task-attributed segments.
* Each boundary defines the ownership of entries from startIndex onward
* until the next boundary's startIndex. */
export interface L15Boundary {
/** Entry counter value when L1.5 judgment started.
* Entries at this index and beyond belong to this boundary's result. */
startIndex: number;
/** L1.5 judgment result for this segment */
result: "long" | "short" | "pending";
/** If result="long", the target MMD file for L2 to construct into */
targetMmd: string | null;
}
/** Result from an LLM call */
export interface LlmResponse {
content: string;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
}
/** OpenClaw config model provider shape (minimal) */
export interface ModelProvider {
baseUrl?: string;
apiKey?: string;
models?: Record<string, unknown>;
}
// ============================
// Plugin configuration
// ============================
/**
* Plugin configuration, read from openclaw.json -> plugins.entries config.
* All fields are optional; defaults are used when not specified.
*/
export interface PluginConfig {
/** Explicit LLM model for offload tasks, format: "provider/model-id" (e.g. "dashscope/kimi-k2.5") */
model?: string;
/** LLM temperature for offload tasks. Default: 0.2 */
temperature?: number;
/** Force-trigger L1 when pending tool pairs >= this threshold. Default: 4 */
forceTriggerThreshold?: number;
/** Custom data directory path (absolute). Default: ~/.openclaw/context-offload */
dataDir?: string;
/** Default context window size when not found in model config. Default: 200000 */
defaultContextWindow?: number;
/** Max tool pairs to process per L1 batch. Default: 20 */
maxPairsPerBatch?: number;
/** Trigger L2 when offload.jsonl has >= this many node_id=null entries. Default: 4 */
l2NullThreshold?: number;
/** Trigger L2 if it hasn't run for this many seconds. Default: 300 (5 minutes) */
l2TimeoutSeconds?: number;
/**
* If L2 leaves entries in `node_id="wait"` (e.g. parse/mapping failure),
* those entries will be retried after waiting for at least this many seconds.
* Default: 120
*/
l2WaitRetrySeconds?: number;
/**
* If true (default), time-based L2 only runs when at least one `node_id=null` entry has
* `timestamp` strictly after `lastL2TriggerTime` (i.e. new offload rows since last L2).
* Does not affect condition A (null count threshold). Set false for legacy timeout retry of stale nulls.
*/
l2TimeTriggerRequiresNewOffload?: boolean;
/** Mild offload: replace non-current-task tool results when context >= this ratio. Default: 0.5 */
mildOffloadRatio?: number;
/** Mild offload scan range: scan the last N% of messages (0.7 = last 70%). Default: 0.7 */
mildOffloadScanRatio?: number;
/** Mild offload phase-1: replace top N% highest-score (most replaceable) entries first. Default: 0.4 */
mildScoreTopRatio?: number;
/** Mild offload: only trigger when current task messages occupy >= this ratio of total tokens. Default: 0.8 */
mildCurrentTaskRatio?: number;
/** Aggressive compress: delete tail messages when context >= this ratio. Default: 0.85 */
aggressiveCompressRatio?: number;
/**
* Aggressive compress: target fraction of **message** tokens to remove from the **oldest**
* messages each round (0.4 ≈ oldest 40% of total per-message token sum). Default: 0.4
*/
aggressiveDeleteRatio?: number;
/** Emergency trigger: when tokens >= contextWindow * emergencyCompressRatio, fire emergency. Default: 0.95 */
emergencyCompressRatio?: number;
/** Emergency target: delete until tokens <= contextWindow * emergencyTargetRatio. Default: 0.6 */
emergencyTargetRatio?: number;
/** Max ratio of total tokens that injected MMDs may occupy. Default: 0.2 */
mmdMaxTokenRatio?: number;
/**
* L3 token counting: `tiktoken` uses js-tiktoken (exact BPE for chosen encoding);
* `heuristic` uses 中文/1.7 + 其余/4. Default: tiktoken.
*/
l3TokenCountMode?: "tiktoken" | "heuristic";
/**
* tiktoken encoding when `l3TokenCountMode` is `tiktoken`.
* Typical: `o200k_base` (GPT-4o/o-series), `cl100k_base` (GPT-4/3.5). Default: o200k_base.
*/
l3TiktokenEncoding?:
| "gpt2"
| "r50k_base"
| "p50k_base"
| "p50k_edit"
| "cl100k_base"
| "o200k_base";
/**
* Default ratio of context window assumed to be system overhead (system prompt +
* tool schemas). Used when no cached overhead is available from llm_input hook.
* Default: 0.12 (12%).
*/
defaultSystemOverheadRatio?: number;
}
// ============================
// Logger interface
// ============================
/** Logger interface used by offload plugin components */
export interface PluginLogger {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
}
// ============================
// Plugin defaults
// ============================
/** Defaults for all configurable values (sourced from runtime .js) */
export const PLUGIN_DEFAULTS = {
temperature: 0.2,
forceTriggerThreshold: 4,
defaultContextWindow: 200_000,
maxPairsPerBatch: 20,
l2NullThreshold: 4,
l2TimeoutSeconds: 300,
/** If L2 leaves entries in node_id="wait", retry after this many seconds */
l2WaitRetrySeconds: 120,
/** When true, time-based L2 only fires if some node_id=null row is newer than last L2 */
l2TimeTriggerRequiresNewOffload: true,
mildOffloadRatio: 0.5,
mildOffloadScanRatio: 0.7,
mildScoreTopRatio: 0.4,
mildCurrentTaskRatio: 0.8,
aggressiveCompressRatio: 0.85,
aggressiveDeleteRatio: 0.4,
/** Emergency trigger: when tokens >= contextWindow * 0.95, fire emergency */
emergencyCompressRatio: 0.95,
/** Emergency target: delete until tokens <= contextWindow * 0.6 */
emergencyTargetRatio: 0.6,
mmdMaxTokenRatio: 0.2,
l3TokenCountMode: "tiktoken" as const,
l3TiktokenEncoding: "o200k_base" as const,
defaultSystemOverheadRatio: 0.12,
} as const;
+80
View File
@@ -0,0 +1,80 @@
/**
* User ID resolver for backend reporting.
*
* The backend `/offload/v1/store` endpoint keys state by `X-User-Id`.
* If the plugin config does not provide one, we fall back to the host's
* primary non-loopback IPv4 address so each machine still maps to a
* stable identifier. Falls back further to `"unknown-host"` on failure.
*
* The resolved value is cached on first read; IP lookup is cheap but
* callers invoke this per request so caching keeps the hot path clean.
*/
import * as os from "node:os";
let _cachedUserId: string | null = null;
let _cachedSource: "config" | "ip" | "fallback" | null = null;
/**
* Find the first non-loopback, non-internal IPv4 address on the host.
* Returns null when the host has no external-facing interface.
*/
function detectLocalIPv4(): string | null {
try {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
const addrs = interfaces[name];
if (!addrs) continue;
for (const addr of addrs) {
// node >= 18 exposes `family` as "IPv4" / "IPv6"; older versions use 4 / 6.
const isV4 = addr.family === "IPv4" || (addr.family as unknown as number) === 4;
if (isV4 && !addr.internal && typeof addr.address === "string") {
return addr.address;
}
}
}
} catch {
/* ignore — detection best-effort */
}
return null;
}
/**
* Resolve the effective user ID. Priority:
* 1. `configuredUserId` from plugin config (trimmed, non-empty)
* 2. Primary non-loopback IPv4 address of the host
* 3. Literal `"unknown-host"` fallback
*
* Result and source are cached — subsequent calls are O(1).
*/
export function resolveUserId(configuredUserId?: string | null): string {
if (_cachedUserId) return _cachedUserId;
const trimmed = typeof configuredUserId === "string" ? configuredUserId.trim() : "";
if (trimmed) {
_cachedUserId = trimmed;
_cachedSource = "config";
return _cachedUserId;
}
const ip = detectLocalIPv4();
if (ip) {
_cachedUserId = ip;
_cachedSource = "ip";
return _cachedUserId;
}
_cachedUserId = "unknown-host";
_cachedSource = "fallback";
return _cachedUserId;
}
/** Returns how the currently-cached user id was resolved (or null if unresolved). */
export function getUserIdSource(): "config" | "ip" | "fallback" | null {
return _cachedSource;
}
/** Testing hook: wipe the cache so the next resolve() re-evaluates. */
export function _resetUserIdCacheForTests(): void {
_cachedUserId = null;
_cachedSource = null;
}