mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
feat: release v0.3.3 — Hermes adapter, context offload, core refactor
This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user