feat: release v0.3.6

This commit is contained in:
chrishuan
2026-05-27 21:45:25 +08:00
parent 1bdcf28c5e
commit 438869bec8
48 changed files with 2484 additions and 459 deletions
+11 -11
View File
@@ -135,13 +135,13 @@ export class BackendClient {
/** 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}]`);
this.logger.debug?.(`[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}]`);
this.logger.debug?.(`[context-offload] L1 <<< ${entryCount} entries [${scores}]`);
traceOffloadModelIo({
sessionKey: this.sessionKeyFn(),
stage: "L1.backend",
@@ -161,13 +161,13 @@ export class BackendClient {
/** L1.5 Task Judgment — synchronous await, uses unified timeout */
async l15Judge(req: L15Request): Promise<L15Response> {
this.logger.info(
this.logger.debug?.(
`[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(
this.logger.debug?.(
`[context-offload] L1.5 <<< completed=${resp.taskCompleted}, continuation=${resp.isContinuation}, continuationFile=${resp.continuationMmdFile ?? "null"}, newLabel=${resp.newTaskLabel ?? "null"}, longTask=${resp.isLongTask}`,
);
traceOffloadModelIo({
@@ -189,7 +189,7 @@ export class BackendClient {
/** 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(
this.logger.debug?.(
`[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();
@@ -197,7 +197,7 @@ export class BackendClient {
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(
this.logger.debug?.(
`[context-offload] L2 <<< action=${resp.fileAction}, mmdContent=${resp.mmdContent ? `${resp.mmdContent.length} chars` : "null"}, replaceBlocks=${resp.replaceBlocks?.length ?? 0}, nodeMapping=${mappingCount} [${mappingStr}]`,
);
traceOffloadModelIo({
@@ -218,13 +218,13 @@ export class BackendClient {
/** L4 Skill Generation — synchronous await, uses unified timeout */
async l4Generate(req: L4Request): Promise<L4Response> {
this.logger.info(
this.logger.debug?.(
`[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(
this.logger.debug?.(
`[context-offload] L4 <<< skill="${resp.skillName}", content=${resp.skillContent?.length ?? 0} chars`,
);
traceOffloadModelIo({
@@ -255,7 +255,7 @@ export class BackendClient {
try {
const resp = await this.post<StoreStateResponse>("/offload/v1/store", payload, timeoutMs);
const durationMs = Date.now() - startMs;
this.logger.info(
this.logger.debug?.(
`[context-offload] store <<< insertedId=${resp.insertedId ?? "?"} (${durationMs}ms)`,
);
return resp;
@@ -273,7 +273,7 @@ export class BackendClient {
const startMs = Date.now();
const bodyStr = JSON.stringify(body);
this.logger.info(`[context-offload] HTTP >>> POST ${url} (${bodyStr.length} bytes, timeout=${timeoutMs}ms)`);
this.logger.debug?.(`[context-offload] HTTP >>> POST ${url} (${bodyStr.length} bytes, timeout=${timeoutMs}ms)`);
const reqHeaders: Record<string, string> = {
"Content-Type": "application/json",
@@ -330,7 +330,7 @@ export class BackendClient {
try {
const parsed = JSON.parse(data) as T;
this.logger.info(
this.logger.debug?.(
`[context-offload] HTTP <<< ${path}: ${res.statusCode} (${durationMs}ms, ${data.length} bytes)`,
);
resolve(parsed);
+89
View File
@@ -0,0 +1,89 @@
/**
* Benchmark: fastEstimateTokens vs tiktoken cl100k_base
*/
import { fastEstimateTokens } from "../src/offload/fast-token-estimate.ts";
import { getEncoding } from "js-tiktoken";
import { readFileSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const enc = getEncoding("cl100k_base");
function tiktokenCount(text: string): number {
return enc.encode(text).length;
}
// Load corpus
const corpusDir = join(__dirname, "../token_count/corpus");
const testTexts: { name: string; text: string }[] = [];
if (existsSync(corpusDir)) {
const files = ["en_pride.txt", "en_arxiv.txt", "cn_hlm.txt", "cn_sgy.txt", "fr_french.txt",
"ru_russian.txt", "ja_japanese.txt", "ko_korean.txt", "ar_arabic.txt",
"de_german.txt", "es_spanish.txt", "pt_portuguese.txt"];
for (const f of files) {
const fp = join(corpusDir, f);
if (existsSync(fp)) {
testTexts.push({ name: f.replace(".txt", ""), text: readFileSync(fp, "utf-8").slice(0, 100_000) });
}
}
}
// Add typical agent scenarios
testTexts.push({ name: "json_messages", text: JSON.stringify([
{ role: "user", content: "Hello world" },
{ role: "assistant", content: "Hi! How can I help?" },
{ role: "user", content: "Run ls -la" },
{ role: "toolResult", toolCallId: "call_123", content: "total 48\ndrwxr-xr-x 5 user user 4096 May 18 10:00 .\n-rw-r--r-- 1 user user 1234 May 18 09:30 package.json\n" },
]).repeat(50) });
testTexts.push({ name: "mixed_code_zh", text: "// 这是一个测试函数\nfunction hello(name: string): string {\n return `你好 ${name}!`;\n}\n".repeat(1000) });
console.log("\n══════════════════════════════════════════════════════════════════");
console.log(" fastEstimateTokens vs tiktoken cl100k_base");
console.log("══════════════════════════════════════════════════════════════════\n");
const header = [
"文本".padEnd(18), "chars".padStart(8), "tiktoken".padStart(9),
"estimate".padStart(9), "error".padStart(7), "tk_ms".padStart(7), "est_ms".padStart(7), "speedup".padStart(8),
];
console.log(header.join(" │ "));
console.log("─".repeat(85));
let totalTk = 0, totalEst = 0, totalTkMs = 0, totalEstMs = 0;
for (const { name, text } of testTexts) {
const t0 = performance.now();
const tk = tiktokenCount(text);
const tkMs = performance.now() - t0;
const t1 = performance.now();
const est = fastEstimateTokens(text);
const estMs = performance.now() - t1;
const err = ((est - tk) / tk * 100).toFixed(1);
const speedup = (tkMs / Math.max(estMs, 0.01)).toFixed(0);
const mark = Math.abs(est - tk) / tk <= 0.10 ? "✅" : "❌";
totalTk += tk; totalEst += est; totalTkMs += tkMs; totalEstMs += estMs;
console.log([
name.padEnd(18), text.length.toLocaleString().padStart(8),
tk.toLocaleString().padStart(9), est.toLocaleString().padStart(9),
`${err}%`.padStart(7), tkMs.toFixed(1).padStart(7), estMs.toFixed(1).padStart(7),
`${speedup}x`.padStart(8),
].join(" │ ") + ` ${mark}`);
}
console.log("─".repeat(85));
const totalErr = ((totalEst - totalTk) / totalTk * 100).toFixed(1);
console.log([
"TOTAL".padEnd(18), "".padStart(8),
totalTk.toLocaleString().padStart(9), totalEst.toLocaleString().padStart(9),
`${totalErr}%`.padStart(7), totalTkMs.toFixed(0).padStart(7), totalEstMs.toFixed(0).padStart(7),
`${(totalTkMs / totalEstMs).toFixed(0)}x`.padStart(8),
].join(" │ "));
console.log(`\n 精度: 平均误差 ${totalErr}%`);
console.log(` 速度: tiktoken ${totalTkMs.toFixed(0)}ms vs estimate ${totalEstMs.toFixed(0)}ms (${(totalTkMs / totalEstMs).toFixed(0)}x faster)`);
console.log();
+3 -1
View File
@@ -70,12 +70,14 @@ export interface ContextSnapshot {
}
// Internal metadata keys that should NOT be counted as tokens.
// These are plugin-internal markers that the LLM never sees.
// These are plugin-internal markers or framework-internal fields that the LLM never sees.
// Note: "details" is stripped by OpenClaw's normalizeMessagesForLlmBoundary before sending to LLM.
const INTERNAL_KEYS = new Set([
"_offloaded",
"_mmdContextMessage",
"_mmdInjection",
"_contextOffloadProcessed",
"details",
]);
/** JSON replacer that strips internal metadata keys from serialization. */
+307
View File
@@ -0,0 +1,307 @@
/**
* Fast token estimator — TypeScript port of token_count/fast_token_estimate.py
* Targets cl100k_base encoding (GPT-4, Claude, DeepSeek, GLM, MiniMax).
*
* Precision: ~2-7% error for most languages (tested vs tiktoken cl100k_base).
* Speed: ~5ms per 100K chars (vs tiktoken ~3-10s).
*
* Algorithm: single-pass character classification with per-category coefficients.
* No BPE encoding, no regex splitting — pure arithmetic on codepoints.
*/
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
// ─── CJK Lookup Table ──────────────────────────────────────────────────────
// Each byte = token cost × 255 for one CJK character (U+4E00..U+9FFF).
// Pre-computed from tiktoken cl100k_base actual encoding.
const CJK_START = 0x4E00;
const CJK_END = 0x9FFF;
let _cjkTable: Uint8Array | null = null;
function loadCjkTable(): Uint8Array | null {
if (_cjkTable) return _cjkTable;
try {
// Try multiple paths for the CJK table
const paths = [
join(dirname(fileURLToPath(import.meta.url)), "../../token_count/cjk_token_table.bin"),
join(dirname(fileURLToPath(import.meta.url)), "../../../token_count/cjk_token_table.bin"),
];
for (const p of paths) {
try {
const buf = readFileSync(p);
if (buf.length === CJK_END - CJK_START + 1) {
_cjkTable = new Uint8Array(buf.buffer, buf.byteOffset, buf.length);
return _cjkTable;
}
} catch { /* try next */ }
}
} catch { /* ignore */ }
return null;
}
// ─── Character Classification ──────────────────────────────────────────────
function isLatinLetter(cp: number): boolean {
return (
(cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A) ||
(cp >= 0x00C0 && cp <= 0x00FF && cp !== 0x00D7 && cp !== 0x00F7) ||
(cp >= 0x0100 && cp <= 0x024F)
);
}
function isCjkHan(cp: number): boolean {
return (
(cp >= 0x4E00 && cp <= 0x9FFF) ||
(cp >= 0x3400 && cp <= 0x4DBF) ||
(cp >= 0xF900 && cp <= 0xFAFF)
);
}
function isKana(cp: number): boolean {
return (cp >= 0x3040 && cp <= 0x309F) || (cp >= 0x30A0 && cp <= 0x30FF);
}
function isHangul(cp: number): boolean {
return (cp >= 0xAC00 && cp <= 0xD7AF) || (cp >= 0x1100 && cp <= 0x11FF) || (cp >= 0x3130 && cp <= 0x318F);
}
function isCyrillic(cp: number): boolean {
return (cp >= 0x0400 && cp <= 0x04FF) || (cp >= 0x0500 && cp <= 0x052F);
}
function isArabic(cp: number): boolean {
return (
(cp >= 0x0600 && cp <= 0x06FF) || (cp >= 0x0750 && cp <= 0x077F) ||
(cp >= 0x08A0 && cp <= 0x08FF) || (cp >= 0xFB50 && cp <= 0xFDFF) ||
(cp >= 0xFE70 && cp <= 0xFEFF)
);
}
function isGreek(cp: number): boolean {
return (cp >= 0x0370 && cp <= 0x03FF) || (cp >= 0x1F00 && cp <= 0x1FFF);
}
// ─── Main Estimator ────────────────────────────────────────────────────────
/**
* Estimate token count for a string without doing BPE encoding.
* Targets cl100k_base (GPT-4/Claude/DeepSeek/GLM/MiniMax).
* Error typically <5% for code/English, <10% for CJK/mixed.
*/
export function fastEstimateTokens(text: string): number {
if (!text) return 0;
const n = text.length;
const cjkTable = loadCjkTable();
let tokens = 0.0;
let i = 0;
// Pre-scan: detect if text is non-English Latin (French, Spanish, etc.)
let accentCount = 0;
let latinCount = 0;
const sampleEnd = Math.min(n, 50000);
for (let s = 0; s < sampleEnd; s++) {
const cp = text.charCodeAt(s);
if (cp >= 0x80 && cp <= 0x024F &&
((cp >= 0x00C0 && cp <= 0x00FF && cp !== 0x00D7 && cp !== 0x00F7) || (cp >= 0x0100 && cp <= 0x024F))) {
accentCount++;
}
if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) {
latinCount++;
}
}
const isNonEnglishLatin = latinCount > 100 && accentCount > latinCount * 0.005;
while (i < n) {
const cp = text.charCodeAt(i);
// ── Latin word ──
if (isLatinLetter(cp)) {
let j = i + 1;
while (j < n) {
const c = text.charCodeAt(j);
if (isLatinLetter(c)) { j++; }
else if (c === 0x27 && j + 1 < n && isLatinLetter(text.charCodeAt(j + 1))) { j += 2; }
else { break; }
}
const wl = j - i;
// Check if this word contains accented characters
let hasAccent = false;
if (isNonEnglishLatin) {
for (let k = i; k < j; k++) {
if (text.charCodeAt(k) >= 0x80) { hasAccent = true; break; }
}
if (!hasAccent) {
// Check nearby window
const lo = Math.max(0, i - 100);
const hi = Math.min(n, j + 100);
for (let k = lo; k < hi; k++) {
const cc = text.charCodeAt(k);
if (cc >= 0x00C0 && cc <= 0x024F && cc !== 0x00D7 && cc !== 0x00F7) {
hasAccent = true; break;
}
}
}
}
if (hasAccent) {
// Non-English Latin words are longer in tokens
if (wl <= 3) tokens += 1.0;
else if (wl <= 5) tokens += 1.35;
else if (wl <= 7) tokens += 1.85;
else if (wl <= 9) tokens += 2.5;
else if (wl <= 12) tokens += 3.2;
else tokens += 3.2 + (wl - 12) * 0.32;
} else {
// English word
if (wl <= 4) tokens += 1.0;
else if (wl <= 8) tokens += 1.1;
else if (wl <= 13) tokens += 1.5;
else tokens += 1.5 + (wl - 13) * 0.3;
}
i = j;
continue;
}
// ── CJK Han characters ──
if (isCjkHan(cp)) {
let j = i + 1;
let segTokens = 0.0;
if (cjkTable && cp >= CJK_START && cp <= CJK_END) {
segTokens += cjkTable[cp - CJK_START]; // table values are direct token counts (1-3)
} else {
segTokens += 1.3;
}
while (j < n && isCjkHan(text.charCodeAt(j))) {
const cp2 = text.charCodeAt(j);
if (cjkTable && cp2 >= CJK_START && cp2 <= CJK_END) {
segTokens += cjkTable[cp2 - CJK_START];
} else {
segTokens += 1.3;
}
j++;
}
const run = j - i;
// BPE merges adjacent CJK characters. Longer segments get more merges.
if (run >= 4) segTokens *= 0.94;
else if (run >= 2) segTokens *= 0.97;
tokens += segTokens;
i = j;
continue;
}
// ── Japanese Kana ──
if (isKana(cp)) {
let j = i + 1;
while (j < n && isKana(text.charCodeAt(j))) j++;
const run = j - i;
if (run === 1) tokens += 1.0;
else if (run === 2) tokens += 1.6;
else if (run === 3) tokens += 2.65;
else if (run === 4) tokens += 3.7;
else if (run <= 6) tokens += run * 0.93;
else tokens += run * 0.95;
i = j;
continue;
}
// ── Korean Hangul ──
if (isHangul(cp)) {
tokens += 1.4;
i++;
continue;
}
// ── Cyrillic (Russian etc.) ──
if (isCyrillic(cp)) {
let j = i + 1;
while (j < n && isCyrillic(text.charCodeAt(j))) j++;
tokens += (j - i) * 0.55;
i = j;
continue;
}
// ── Arabic ──
if (isArabic(cp)) {
let j = i + 1;
while (j < n && isArabic(text.charCodeAt(j))) j++;
tokens += (j - i) * 0.82;
i = j;
continue;
}
// ── Greek ──
if (isGreek(cp)) {
let j = i + 1;
while (j < n && isGreek(text.charCodeAt(j))) j++;
tokens += (j - i) * 0.85;
i = j;
continue;
}
// ── Digits (with commas, dots) ──
if (cp >= 0x30 && cp <= 0x39) {
let j = i + 1;
let digits = 1;
let commas = 0;
let dots = 0;
while (j < n) {
const c = text.charCodeAt(j);
if (c >= 0x30 && c <= 0x39) { digits++; j++; }
else if (c === 0x2C && j + 1 < n && text.charCodeAt(j + 1) >= 0x30 && text.charCodeAt(j + 1) <= 0x39) {
commas++; j += 2; digits++;
}
else if (c === 0x2E && j + 1 < n && text.charCodeAt(j + 1) >= 0x30 && text.charCodeAt(j + 1) <= 0x39) {
dots++; j += 2; digits++;
}
else { break; }
}
if (digits <= 3 && commas === 0 && dots === 0) tokens += 1.0;
else if (commas > 0) tokens += commas * 2 + 1.0;
else if (dots > 0) tokens += Math.max(2.0, digits / 3.0 + dots * 1.5);
else tokens += Math.max(1.0, digits / 2.5);
i = j;
continue;
}
// ── Whitespace (space, tab) ──
if (cp === 0x20 || cp === 0x09) { i++; continue; }
// ── Newline ──
if (cp === 0x0A || cp === 0x0D) { tokens += 1.0; i++; continue; }
// ── Fullwidth punctuation ──
if ((cp >= 0x3000 && cp <= 0x303F) || (cp >= 0xFF00 && cp <= 0xFFEF) ||
cp === 0x2018 || cp === 0x2019 || cp === 0x201C || cp === 0x201D ||
cp === 0x2014 || cp === 0x2026 || cp === 0x2013) {
tokens += 1.0; i++; continue;
}
// ── ASCII punctuation ──
if (cp >= 0x21 && cp <= 0x7E) { tokens += 0.6; i++; continue; }
// ── Other Unicode (emoji etc.) ──
if (cp > 0x7F) { tokens += 2.5; i++; continue; }
i++;
}
return Math.max(1, Math.round(tokens));
}
/**
* Estimate tokens for an array of messages (same as buildTiktokenContextSnapshot
* but using fast estimation instead of tiktoken).
*/
export function fastEstimateMessages(messages: any[], jsonReplacer?: (key: string, value: unknown) => unknown): number {
let total = 0;
for (const msg of messages) {
const str = JSON.stringify(msg, jsonReplacer as any);
total += fastEstimateTokens(str);
}
// JSON array overhead
total += Math.ceil(messages.length * 0.5);
return total;
}
+27 -17
View File
@@ -112,7 +112,7 @@ export function createAfterToolCallHandler(
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"}`);
logger.debug?.(`[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
@@ -168,7 +168,7 @@ export function createAfterToolCallHandler(
// 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})`);
logger.debug?.(`[context-offload] after_tool_call: SKIP approval-pending tool ${event.toolName} (${toolCallId})`);
stateManager.processedToolCallIds.add(toolCallId);
return;
}
@@ -183,7 +183,7 @@ export function createAfterToolCallHandler(
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`);
logger.debug?.(`[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) {
@@ -200,9 +200,9 @@ export function createAfterToolCallHandler(
const l15Settled = stateManager.l15Settled;
const activeMmdFile = stateManager.getActiveMmdFile();
if (!l15Settled) {
logger.info(`[context-offload] after_tool_call MMD: SKIP (L1.5 not settled yet)`);
logger.debug?.(`[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)`);
logger.debug?.(`[context-offload] after_tool_call MMD: SKIP (no active MMD file)`);
} else {
const mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
if (mmdContent) {
@@ -231,19 +231,19 @@ export function createAfterToolCallHandler(
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`);
logger.debug?.(`[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`);
logger.debug?.(`[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}`);
logger.debug?.(`[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`);
logger.debug?.(`[context-offload] after_tool_call MMD: file=${activeMmdFile} content is null`);
}
}
} catch (err) {
@@ -264,7 +264,7 @@ export function createAfterToolCallHandler(
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`);
logger.debug?.(`[context-offload] after_tool_call L3 check completed: ${_compDuration}ms`);
// QUICK-SKIP: no snapshots, skip trace
if (_compResult) {
@@ -419,7 +419,7 @@ async function checkAndCompressAfterToolCall(
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}`);
logger.debug?.(`[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;
}
@@ -435,7 +435,7 @@ async function checkAndCompressAfterToolCall(
const utilisation = snap.totalTokens / contextWindow;
const aboveMild = snap.totalTokens >= mildThreshold;
const aboveAggressive = snap.totalTokens >= aggressiveThreshold;
logger.info(
logger.debug?.(
`[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"}`,
@@ -456,14 +456,19 @@ async function checkAndCompressAfterToolCall(
let _aggDeletedCount = 0;
// Aggressive
if (workingTokens >= aggressiveThreshold) {
logger.info(`[context-offload] L3(after_tool_call) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}`);
logger.debug?.(`[context-offload] L3(after_tool_call) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}`);
const _atcAggStart = Date.now();
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}`);
const _atcAggDuration = Date.now() - _atcAggStart;
logger.debug?.(`[context-offload] L3(after_tool_call) AGGRESSIVE done: rounds=${result.rounds ?? "?"}, deleted=${result.allDeletedToolCallIds.length}, remaining≈${workingTokens}, stalledByUserMsg=${result.stalledByUserMsg ?? false}, duration=${_atcAggDuration}ms`);
if (_atcAggDuration > 10_000) {
logger.warn(`[context-offload] L3(after_tool_call) AGGRESSIVE SLOW: ${_atcAggDuration}ms (rounds=${result.rounds ?? "?"}, deleted=${result.allDeletedToolCallIds.length}, remaining≈${workingTokens})`);
}
dumpMessagesSnapshot("atc-after-aggressive", messages, logger);
if (result.allDeletedToolCallIds.length > 0) {
const statusUpdates = new Map<string, string | boolean>();
@@ -496,10 +501,10 @@ async function checkAndCompressAfterToolCall(
// 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}`);
logger.debug?.(`[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}]` : ""}`);
logger.debug?.(`[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) {
@@ -527,8 +532,13 @@ async function checkAndCompressAfterToolCall(
let _emergencyDeletedCount = 0;
if ((workingTokens >= emergencyThreshold || forceEmergency) && messages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
_emergencyTriggered = true;
const _atcEmStart = Date.now();
const emergencyResult = emergencyCompress(messages, emergencyTarget, countTokens, sysPrompt, null, logger);
const _atcEmDuration = Date.now() - _atcEmStart;
_emergencyDeletedCount = emergencyResult.deletedCount;
if (_atcEmDuration > 10_000) {
logger.warn(`[context-offload] L3(after_tool_call) EMERGENCY SLOW: ${_atcEmDuration}ms (deleted=${emergencyResult.deletedCount}, remaining≈${emergencyResult.remainingTokens})`);
}
if (emergencyResult.deletedToolCallIds.length > 0) {
const statusUpdates = new Map<string, string | boolean>();
for (const id of emergencyResult.deletedToolCallIds) {
@@ -580,5 +590,5 @@ function _extractText(msg: any): string {
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}`);
logger.debug?.(`[context-offload] POST-MMD-${action} (after_tool_call): ${messages.length} msgs, mmd=${mmdCount}, offloaded=${offloadedCount}`);
}
+6 -6
View File
@@ -69,16 +69,16 @@ export async function handleTaskTransition(
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)"})`);
logger.debug?.(`[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}`);
logger.debug?.(`[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)"})`);
logger.debug?.(`[context-offload] L1.5: Reactivating MMD: ${contFile} (current=${currentMmd ?? "(none)"})`);
if (currentMmd && currentMmd !== contFile) {
await cleanupIfEmptyShell(currentMmd);
}
@@ -95,7 +95,7 @@ export async function handleTaskTransition(
};
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"}`);
logger.debug?.(`[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) {
@@ -110,11 +110,11 @@ export async function handleTaskTransition(
stateManager.setActiveMmd(null, null);
}
} else {
logger.info("[context-offload] L1.5: No MMD needed (casual/short), clearing active MMD");
logger.debug?.("[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)"}`);
logger.debug?.(`[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);
+11 -1
View File
@@ -51,7 +51,7 @@ export function createBeforePromptBuildHandler(
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 ?? "?"}`);
logger.debug?.(`[context-offload] before_prompt_build CALLED, msgs=${event?.messages?.length ?? "?"}`);
try {
const messages = event.messages;
if (!messages || !Array.isArray(messages) || messages.length === 0) return;
@@ -158,11 +158,16 @@ export function createBeforePromptBuildHandler(
const countTokens = createL3TokenCounter(pluginConfig, logger);
const aggressiveDeleteRatio = (pluginConfig as any)?.aggressiveDeleteRatio ?? PLUGIN_DEFAULTS.aggressiveDeleteRatio;
const currentTaskNodeIds = await getCurrentTaskNodeIds(stateManager);
const _bpbAggStart = Date.now();
const result = await aggressiveCompressUntilBelowThreshold(
messages, offloadMap, currentTaskNodeIds, aggressiveDeleteRatio,
stateManager, logger, aggressiveThreshold, countTokens, null, null,
);
workingTokens = result.remainingTokens;
const _bpbAggDuration = Date.now() - _bpbAggStart;
if (_bpbAggDuration > 10_000) {
logger.warn(`[context-offload] L3(before_prompt_build) AGGRESSIVE SLOW: ${_bpbAggDuration}ms (rounds=${result.rounds}, deleted=${result.deletedCount}, remaining≈${workingTokens})`);
}
dumpMessagesSnapshot("bpb-after-aggressive", messages, logger);
if (result.allDeletedToolCallIds.length > 0) {
const statusUpdates = new Map<string, string | boolean>();
@@ -221,8 +226,13 @@ export function createBeforePromptBuildHandler(
if (forceEmergency) stateManager._forceEmergencyNext = false;
if ((workingTokens >= emergencyThreshold || forceEmergency) && messages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
const countTokensBpb = createL3TokenCounter(pluginConfig, logger);
const _bpbEmStart = Date.now();
const emergencyResult = emergencyCompress(messages, emergencyTarget, countTokensBpb, null, null, logger);
workingTokens = emergencyResult.remainingTokens;
const _bpbEmDuration = Date.now() - _bpbEmStart;
if (_bpbEmDuration > 10_000) {
logger.warn(`[context-offload] L3(before_prompt_build) EMERGENCY SLOW: ${_bpbEmDuration}ms (deleted=${emergencyResult.deletedCount}, remaining≈${workingTokens})`);
}
if (emergencyResult.deletedToolCallIds.length > 0) {
const emergencyStatusUpdates = new Map<string, string | boolean>();
for (const id of emergencyResult.deletedToolCallIds) {
+339 -117
View File
@@ -8,7 +8,7 @@ import { readOffloadEntries, readMmd, listMmds, markOffloadStatus } from "../sto
import { traceOffloadDecision } from "../opik-tracer.js";
import { createL3TokenCounter } from "../l3-token-counter.js";
import { injectMmdIntoMessages, findHistoryMmdInsertionPoint, findActiveMmdInsertionPoint } from "../mmd-injector.js";
import { buildTiktokenContextSnapshot, tiktokenCount, jsonReplacer } from "../context-token-tracker.js";
import { buildTiktokenContextSnapshot, tiktokenCount, jsonReplacer, invalidateTokenCache } from "../context-token-tracker.js";
import {
normalizeToolCallIdForLookup,
getOffloadEntry,
@@ -114,7 +114,11 @@ export const MILD_CASCADE_MIN_COUNT = 10;
export const MILD_CASCADE_INITIAL_SCORE = 7;
export const MILD_CASCADE_FLOOR_SCORE = 1;
export const AGGRESSIVE_MIN_MESSAGES_TO_KEEP = 2;
export const EMERGENCY_MIN_MESSAGES_TO_KEEP = 4;
export const EMERGENCY_MIN_MESSAGES_TO_KEEP = 2;
// Maximum content length (chars) to keep when truncating an oversized message in-place.
// ~2K chars ≈ ~500 tokens — enough to preserve tool_call_id and a snippet of context.
const EMERGENCY_TRUNCATE_MAX_CHARS = 2000;
// ─── Message dump helper ─────────────────────────────────────────────────────
@@ -169,7 +173,7 @@ export function createLlmInputL3Handler(
const _sk = stateManager.getLastSessionKey();
if (typeof _sk === "string" && /memory-.*-session-\d+/.test(_sk)) return;
logger.info(`[context-offload] llm_input_l3 CALLED, historyMsgs=${event?.historyMessages?.length ?? "?"}, prompt=${typeof event?.prompt === "string" ? event.prompt.slice(0, 50) : "?"}`);
logger.debug?.(`[context-offload] llm_input_l3 CALLED, historyMsgs=${event?.historyMessages?.length ?? "?"}, prompt=${typeof event?.prompt === "string" ? event.prompt.slice(0, 50) : "?"}`);
let _aggDeleted = 0;
let _mildReplaced = 0;
let _emergencyTriggered = false;
@@ -213,7 +217,7 @@ export function createLlmInputL3Handler(
const aggressiveThreshold = Math.floor(contextWindow * aggressiveRatio);
const utilisation = snap.totalTokens / contextWindow;
logger.info(
logger.debug?.(
`[context-offload] L3(llm_input) token snapshot: total=${snap.totalTokens} ` +
`(system=${snap.systemTokens}, messages=${snap.messagesTokens}, user=${snap.userPromptTokens}) ` +
`msgCount=${historyMessages.length} utilisation=${(utilisation * 100).toFixed(1)}% ` +
@@ -222,7 +226,7 @@ export function createLlmInputL3Handler(
if (historyMessages.length === 0) return;
if (snap.totalTokens < mildThreshold) {
logger.info(`[context-offload] L3(llm_input): ${snap.totalTokens} < mild@${mildThreshold} → no compression needed`);
logger.debug?.(`[context-offload] L3(llm_input): ${snap.totalTokens} < mild@${mildThreshold} → no compression needed`);
return;
}
@@ -237,14 +241,19 @@ export function createLlmInputL3Handler(
// Aggressive
if (workingTokens >= aggressiveThreshold) {
logger.info(`[context-offload] L3(llm_input) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}, starting deletion`);
logger.debug?.(`[context-offload] L3(llm_input) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}, starting deletion`);
const _llmAggStart = Date.now();
const result = await aggressiveCompressUntilBelowThreshold(
historyMessages, offloadMap, currentTaskNodeIds, aggressiveDeleteRatio,
stateManager, logger, aggressiveThreshold, countTokens, sysPrompt, promptText,
);
workingTokens = result.remainingTokens;
_aggDeleted = result.deletedCount ?? result.allDeletedToolCallIds.length;
logger.info(`[context-offload] L3(llm_input) AGGRESSIVE done: rounds=${result.rounds}, deleted=${result.deletedCount}, remaining≈${workingTokens}, deletedIds=${result.allDeletedToolCallIds.length}, stalledByUserMsg=${result.stalledByUserMsg ?? false}`);
const _llmAggDuration = Date.now() - _llmAggStart;
logger.debug?.(`[context-offload] L3(llm_input) AGGRESSIVE done: rounds=${result.rounds}, deleted=${result.deletedCount}, remaining≈${workingTokens}, deletedIds=${result.allDeletedToolCallIds.length}, stalledByUserMsg=${result.stalledByUserMsg ?? false}, duration=${_llmAggDuration}ms`);
if (_llmAggDuration > 10_000) {
logger.warn(`[context-offload] L3(llm_input) AGGRESSIVE SLOW: ${_llmAggDuration}ms (rounds=${result.rounds}, deleted=${result.deletedCount}, remaining≈${workingTokens})`);
}
dumpMessagesSnapshot("after-aggressive", historyMessages, logger);
if (result.allDeletedToolCallIds.length > 0) {
const statusUpdates = new Map<string, string | boolean>();
@@ -263,7 +272,7 @@ export function createLlmInputL3Handler(
const histInsertIdx = findHistoryMmdInsertionPoint(historyMessages);
historyMessages.splice(histInsertIdx, 0, ...mmdInjection.injectedMessages);
workingTokens += mmdInjection.totalMmdTokens;
logger.info(`[context-offload] L3(llm_input) AGGRESSIVE: injected ${mmdInjection.injectedMessages.length} history MMD msgs at [${histInsertIdx}] (${mmdInjection.totalMmdTokens} tokens, files=${mmdInjection.mmdFiles.join(",")})`);
logger.debug?.(`[context-offload] L3(llm_input) AGGRESSIVE: injected ${mmdInjection.injectedMessages.length} history MMD msgs at [${histInsertIdx}] (${mmdInjection.totalMmdTokens} tokens, files=${mmdInjection.mmdFiles.join(",")})`);
dumpMessagesSnapshot("after-aggressive-mmd-injection", historyMessages, logger);
}
}
@@ -276,10 +285,10 @@ export function createLlmInputL3Handler(
// Mild
if (workingTokens >= mildThreshold) {
logger.info(`[context-offload] L3(llm_input) MILD: tokens≈${workingTokens} >= ${mildThreshold}, starting cascade`);
logger.debug?.(`[context-offload] L3(llm_input) MILD: tokens≈${workingTokens} >= ${mildThreshold}, starting cascade`);
const cascadeResult = compressByScoreCascade(historyMessages, offloadMap, currentTaskNodeIds, mildScanRatio, logger);
_mildReplaced = cascadeResult.replacedCount;
logger.info(`[context-offload] L3(llm_input) MILD done: replaced=${cascadeResult.replacedCount}, finalThreshold=${cascadeResult.finalThreshold}, ids=[${cascadeResult.replacedToolCallIds.slice(0,5).join(",")}${cascadeResult.replacedToolCallIds.length > 5 ? "..." : ""}]`);
logger.debug?.(`[context-offload] L3(llm_input) MILD done: replaced=${cascadeResult.replacedCount}, finalThreshold=${cascadeResult.finalThreshold}, ids=[${cascadeResult.replacedToolCallIds.slice(0,5).join(",")}${cascadeResult.replacedToolCallIds.length > 5 ? "..." : ""}]`);
if (cascadeResult.replacedCount > 0) {
for (const id of cascadeResult.replacedToolCallIds) {
stateManager.confirmedOffloadIds.add(id);
@@ -305,7 +314,7 @@ export function createLlmInputL3Handler(
if ((workingTokens >= emergencyThreshold || forceEmergency) && historyMessages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
_emergencyTriggered = true;
logger.warn(`[context-offload] L3(llm_input) EMERGENCY: tokens≈${workingTokens} >= ${emergencyThreshold} (force=${forceEmergency}), target=${emergencyTarget}`);
logger.warn(`[context-offload] L3(llm_input) EMERGENCY: tokens≈${workingTokens} >= ${emergencyThreshold} (force=${forceEmergency}), target=${emergencyTarget}`);
const emergencyResult = emergencyCompress(historyMessages, emergencyTarget, countTokens, sysPrompt, promptText, logger);
_emergencyDeleted = emergencyResult.deletedCount;
logger.warn(`[context-offload] L3(llm_input) EMERGENCY done: deleted=${emergencyResult.deletedCount}, remaining≈${emergencyResult.remainingTokens}, deletedIds=${emergencyResult.deletedToolCallIds.length}`);
@@ -327,7 +336,7 @@ export function createLlmInputL3Handler(
const finalSnap = buildTiktokenContextSnapshot("llm_input_l3_final", historyMessages, sysPrompt, promptText);
const totalSaved = snap.totalTokens - finalSnap.totalTokens;
if (totalSaved > 0) {
logger.info(`[context-offload] L3(llm_input) SUMMARY: ${snap.totalTokens}${finalSnap.totalTokens} (saved≈${totalSaved} tokens), msgs=${historyMessages.length}`);
logger.debug?.(`[context-offload] L3(llm_input) SUMMARY: ${snap.totalTokens}${finalSnap.totalTokens} (saved≈${totalSaved} tokens), msgs=${historyMessages.length}`);
}
traceOffloadDecision({
@@ -437,7 +446,7 @@ export function compressByScoreCascade(
candidates.push({ msgIndex: i, toolCallId, offloadEntry, score: offloadEntry.score ?? 5 });
}
if (candidates.length === 0) {
logger.info(`[context-offload] L3-MILD: 0 candidates in scan range (0..${scanEnd}/${totalMessages}), offloadMap=${offloadMap.size} entries`);
logger.debug?.(`[context-offload] L3-MILD: 0 candidates in scan range (0..${scanEnd}/${totalMessages}), offloadMap=${offloadMap.size} entries`);
return { replacedCount: 0, lastOffloadedId: null, finalThreshold: initialScore, replacedToolCallIds: [], replacedDetails: [] };
}
candidates.sort((a: any, b: any) => b.score - a.score);
@@ -449,7 +458,7 @@ export function compressByScoreCascade(
scoreDist.set(s, (scoreDist.get(s) ?? 0) + 1);
}
const scoreDistStr = [...scoreDist.entries()].sort((a, b) => b[0] - a[0]).map(([s, n]) => `score=${s}:${n}`).join(", ");
logger.info(`[context-offload] L3-MILD: ${candidates.length} candidates (scan 0..${scanEnd}/${totalMessages}), distribution=[${scoreDistStr}], offloadMap=${offloadMap.size}`);
logger.debug?.(`[context-offload] L3-MILD: ${candidates.length} candidates (scan 0..${scanEnd}/${totalMessages}), distribution=[${scoreDistStr}], offloadMap=${offloadMap.size}`);
const toolCallIdToResultIdx = new Map<string, number>();
const toolCallIdToAssistantIdx = new Map<string, number>();
@@ -512,14 +521,14 @@ export function compressByScoreCascade(
}
} else {
const replInfo = replaceWithSummary(msg, c.offloadEntry);
logger.info(
logger.debug?.(
`[context-offload] L3-MILD replace: [${c.msgIndex}] ${c.toolCallId} score=${c.score}, ` +
`original=${replInfo.originalLength}→summary=${replInfo.summaryLength} (delta=${replInfo.summaryLength - replInfo.originalLength}), ` +
`tool=${(c.offloadEntry.tool_call ?? "").slice(0, 80)}, ` +
`summary="${(c.offloadEntry.summary ?? "").slice(0, 100)}"`,
);
if (replInfo.summaryLength > replInfo.originalLength) {
logger.info(`[context-offload] L3-MILD: SKIPPING replacement for ${c.toolCallId} — summary larger than original (${replInfo.originalLength}${replInfo.summaryLength}, delta=+${replInfo.summaryLength - replInfo.originalLength}), reverting`);
logger.debug?.(`[context-offload] L3-MILD: SKIPPING replacement for ${c.toolCallId} — summary larger than original (${replInfo.originalLength}${replInfo.summaryLength}, delta=+${replInfo.summaryLength - replInfo.originalLength}), reverting`);
// Revert: the message was already mutated by replaceWithSummary,
// but we mark it as _offloaded anyway to avoid re-processing.
// The net effect is minimal since the size barely increased.
@@ -611,36 +620,33 @@ function capDeleteCountForUserMessage(messages: any[], deleteCount: number): num
// ─── Aggressive Compression ──────────────────────────────────────────────────
/**
* Compute how many messages to delete from the head of the array.
* Compute how many messages to delete from the head to bring total tokens
* below threshold. One-shot: accumulate per-message token costs from the
* head until enough tokens have been removed.
*
* Strategy: accumulate tokens from the oldest messages until reaching
* `totalMsgTokens * deleteRatio`. This preferentially deletes the oldest
* (typically already-offloaded / compressed) messages.
*
* IMPORTANT: When many messages are already offloaded (small summaries),
* the head region may contain very few tokens. To prevent "delete 0" stalls,
* we guarantee a minimum delete count proportional to the message count
* when above threshold — this ensures progress even when token distribution
* is heavily tail-weighted.
* @param messages - messages array (MMD already extracted)
* @param remainingTokens - current total tokens (may include sys/prompt overhead)
* @param aggressiveThreshold - target total tokens to reach
* @param countTokens - tiktoken counter
* @param maxDeletable - max messages allowed to delete (preserves MIN_KEEP)
*/
function computeAggressiveDeleteCount(messages: any[], deleteRatio: number, countTokens: (t: string) => number, maxDeletable: number): number {
function computeAggressiveDeleteCount(messages: any[], remainingTokens: number, aggressiveThreshold: number, countTokens: (t: string) => number, maxDeletable: number): number {
if (messages.length === 0 || maxDeletable <= 0) return 0;
if (remainingTokens <= aggressiveThreshold) return 0; // already below target
// Need to remove (remainingTokens - aggressiveThreshold) tokens from messages
const tokensToDelete = remainingTokens - aggressiveThreshold;
const perMsg = messages.map((m: any) => countTokens(JSON.stringify(m)));
const totalMsgTokens = perMsg.reduce((a: number, b: number) => a + b, 0);
if (totalMsgTokens <= 0) return Math.min(maxDeletable, Math.ceil(messages.length * deleteRatio));
const targetTokens = totalMsgTokens * deleteRatio;
let acc = 0;
let deleteCount = 0;
for (let i = 0; i < messages.length && deleteCount < maxDeletable; i++) {
acc += perMsg[i];
deleteCount = i + 1;
if (acc >= targetTokens) break;
if (acc >= tokensToDelete) break;
}
// Minimum progress guarantee: when we couldn't reach targetTokens
// (head messages are tiny offloaded summaries), ensure at least
// deleteRatio of MESSAGE COUNT is deleted to make forward progress.
if (acc < targetTokens && deleteCount > 0) {
const minByCount = Math.max(1, Math.ceil(messages.length * deleteRatio * 0.5));
// Minimum progress guarantee: if head messages are tiny (offloaded summaries)
// and we couldn't reach tokensToDelete, ensure at least 20% of messages are deleted.
if (acc < tokensToDelete && deleteCount > 0) {
const minByCount = Math.max(1, Math.ceil(messages.length * 0.2));
deleteCount = Math.max(deleteCount, Math.min(minByCount, maxDeletable));
}
return deleteCount;
@@ -653,64 +659,11 @@ function adjustDeleteCountForToolPairing(messages: any[], initialDeleteCount: nu
return count;
}
async function aggressiveCompress(
messages: any[],
offloadMap: Map<string, OffloadEntry>,
deleteRatio: number,
stateManager: OffloadStateManager,
logger: PluginLogger,
countTokens: (t: string) => number,
): Promise<{ deletedCount: number; deletedToolCallIds: string[]; deletedTokens: number }> {
const mmdMsgs: { msg: any }[] = [];
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]._mmdContextMessage || messages[i]._mmdInjection) {
mmdMsgs.unshift({ msg: messages.splice(i, 1)[0] });
}
}
const totalMessages = messages.length;
const maxDeletable = Math.max(0, totalMessages - AGGRESSIVE_MIN_MESSAGES_TO_KEEP);
let deleteCount = computeAggressiveDeleteCount(messages, deleteRatio, countTokens, maxDeletable);
deleteCount = adjustDeleteCountForToolPairing(messages, deleteCount);
const preCapCount = deleteCount;
deleteCount = capDeleteCountForUserMessage(messages, deleteCount);
if (deleteCount < preCapCount) {
logger.info(`[context-offload] L3-AGGRESSIVE capDeleteCountForUserMessage: ${preCapCount}${deleteCount} (lastUserIdx=${findLastUserMessageIndex(messages)})`);
}
// Calculate token cost of messages to delete BEFORE splicing (for incremental subtraction)
const deletedTokens = tiktokenCount(JSON.stringify(messages.slice(0, deleteCount), jsonReplacer));
const toDelete = messages.splice(0, deleteCount);
const deletedToolCallIds: string[] = [];
// Collect tool call IDs and log aggregated summary (was per-message, now single line)
for (const msg of toDelete) {
const toolCallId = extractToolCallId(msg) ?? extractToolUseIdFromAssistant(msg);
if ((isToolResultMessage(msg) || isToolUseInAssistant(msg)) && toolCallId && deletedToolCallIds.length < 50) {
deletedToolCallIds.push(toolCallId);
}
}
logger.info(
`[context-offload] L3-AGGRESSIVE deleted ${toDelete.length} msgs, toolCallIds=[${deletedToolCallIds.slice(0, 5).join(",")}${deletedToolCallIds.length > 5 ? `...+${deletedToolCallIds.length - 5}` : ""}]`,
);
// Restore MMD context messages (including _mmdInjection)
for (const { msg } of mmdMsgs) {
if (msg._mmdContextMessage === "history" || msg._mmdInjection) {
const restoreIdx = findHistoryMmdInsertionPoint(messages);
messages.splice(restoreIdx, 0, msg);
} else {
// Active MMD: use the same insertion logic as mmd-injector to avoid
// breaking tool_call/tool_result pairing or user→assistant alternation.
const insertIdx = findActiveMmdInsertionPoint(messages);
messages.splice(insertIdx, 0, msg);
}
}
return { deletedCount: toDelete.length, deletedToolCallIds, deletedTokens };
}
/**
* One-shot aggressive compression. Computes the exact cut point to bring
* tokens below threshold in a single pass, then splices once.
* No multi-round while loop — O(N) tiktoken + O(1) splice.
*/
export async function aggressiveCompressUntilBelowThreshold(
messages: any[],
offloadMap: Map<string, OffloadEntry>,
@@ -723,31 +676,78 @@ export async function aggressiveCompressUntilBelowThreshold(
sysPrompt: string | null,
promptText: string | null,
): Promise<{ deletedCount: number; rounds: number; remainingTokens: number; allDeletedToolCallIds: string[]; stalledByUserMsg?: boolean }> {
let deletedTotal = 0;
let rounds = 0;
const allDeletedToolCallIds: string[] = [];
let remainingTokens = buildTiktokenContextSnapshot("l3_aggressive_est", messages, sysPrompt, promptText).totalTokens;
let stalledByUserMsg = false;
logger.info(`[context-offload] L3-aggressive entry: msgs=${messages.length}, remainingTokens=${remainingTokens}, threshold=${aggressiveThreshold}, minKeep=${AGGRESSIVE_MIN_MESSAGES_TO_KEEP}, willLoop=${remainingTokens >= aggressiveThreshold && messages.length > AGGRESSIVE_MIN_MESSAGES_TO_KEEP}`);
logger.debug?.(`[context-offload] L3-aggressive entry: msgs=${messages.length}, remainingTokens=${remainingTokens}, threshold=${aggressiveThreshold}, minKeep=${AGGRESSIVE_MIN_MESSAGES_TO_KEEP}`);
while (remainingTokens >= aggressiveThreshold && messages.length > AGGRESSIVE_MIN_MESSAGES_TO_KEEP) {
rounds++;
const oneRound = await aggressiveCompress(messages, offloadMap, deleteRatio, stateManager, logger, countTokens);
if (oneRound.deletedCount <= 0) {
// Aggressive stalled — likely because capDeleteCountForUserMessage blocked deletion.
// Signal the caller so it can escalate to emergency compression.
stalledByUserMsg = true;
logger.warn(`[context-offload] L3-aggressive STALLED at round ${rounds}: deleted=0 (user msg at head?), remaining≈${remainingTokens}, msgs=${messages.length}`);
break;
}
deletedTotal += oneRound.deletedCount;
allDeletedToolCallIds.push(...oneRound.deletedToolCallIds);
// Incremental subtraction instead of full tiktoken re-encode
remainingTokens -= oneRound.deletedTokens;
logger.info(`[context-offload] L3-aggressive round ${rounds}: deleted=${oneRound.deletedCount}, remaining≈${remainingTokens}, msgsLeft=${messages.length}`);
if (remainingTokens < aggressiveThreshold || messages.length <= AGGRESSIVE_MIN_MESSAGES_TO_KEEP) {
return { deletedCount: 0, rounds: 0, remainingTokens, allDeletedToolCallIds, stalledByUserMsg };
}
return { deletedCount: deletedTotal, rounds, remainingTokens, allDeletedToolCallIds, stalledByUserMsg };
// ── Extract MMD messages before computing delete count ──
const mmdMsgs: { msg: any }[] = [];
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]._mmdContextMessage || messages[i]._mmdInjection) {
mmdMsgs.unshift({ msg: messages.splice(i, 1)[0] });
}
}
// ── One-shot: compute exactly how many to delete to reach threshold ──
const maxDeletable = Math.max(0, messages.length - AGGRESSIVE_MIN_MESSAGES_TO_KEEP);
let deleteCount = computeAggressiveDeleteCount(messages, remainingTokens, aggressiveThreshold, countTokens, maxDeletable);
deleteCount = adjustDeleteCountForToolPairing(messages, deleteCount);
const preCapCount = deleteCount;
deleteCount = capDeleteCountForUserMessage(messages, deleteCount);
if (deleteCount < preCapCount) {
logger.debug?.(`[context-offload] L3-AGGRESSIVE capDeleteCountForUserMessage: ${preCapCount}${deleteCount} (lastUserIdx=${findLastUserMessageIndex(messages)})`);
}
if (deleteCount <= 0) {
stalledByUserMsg = true;
logger.warn(`[context-offload] L3-aggressive STALLED: deleteCount=0 (user msg at head?), remaining≈${remainingTokens}, msgs=${messages.length}`);
// Restore MMD messages
for (const { msg } of mmdMsgs) {
if (msg._mmdContextMessage === "history" || msg._mmdInjection) {
messages.splice(findHistoryMmdInsertionPoint(messages), 0, msg);
} else {
messages.splice(findActiveMmdInsertionPoint(messages), 0, msg);
}
}
return { deletedCount: 0, rounds: 1, remainingTokens, allDeletedToolCallIds, stalledByUserMsg };
}
// ── Calculate deleted token cost and splice ──
const deletedTokens = tiktokenCount(JSON.stringify(messages.slice(0, deleteCount), jsonReplacer));
const toDelete = messages.splice(0, deleteCount);
// Collect tool call IDs
for (const msg of toDelete) {
const toolCallId = extractToolCallId(msg) ?? extractToolUseIdFromAssistant(msg);
if ((isToolResultMessage(msg) || isToolUseInAssistant(msg)) && toolCallId && allDeletedToolCallIds.length < 200) {
allDeletedToolCallIds.push(toolCallId);
}
}
remainingTokens -= deletedTokens;
logger.debug?.(
`[context-offload] L3-AGGRESSIVE one-shot: deleted=${toDelete.length} msgs, remaining≈${remainingTokens}, msgsLeft=${messages.length}, ` +
`toolCallIds=[${allDeletedToolCallIds.slice(0, 5).join(",")}${allDeletedToolCallIds.length > 5 ? `...+${allDeletedToolCallIds.length - 5}` : ""}]`,
);
// ── Restore MMD context messages ──
for (const { msg } of mmdMsgs) {
if (msg._mmdContextMessage === "history" || msg._mmdInjection) {
const restoreIdx = findHistoryMmdInsertionPoint(messages);
messages.splice(restoreIdx, 0, msg);
} else {
const insertIdx = findActiveMmdInsertionPoint(messages);
messages.splice(insertIdx, 0, msg);
}
}
return { deletedCount: toDelete.length, rounds: 1, remainingTokens, allDeletedToolCallIds, stalledByUserMsg };
}
// ─── Emergency Compression ───────────────────────────────────────────────────
@@ -791,7 +791,13 @@ export function emergencyCompress(
const tailDeleted = _emergencyTailDelete(messages, targetTokens, currentTokens, deletedToolCallIds, logger);
deletedCount += tailDeleted.count;
currentTokens -= tailDeleted.tokens;
if (tailDeleted.count <= 0) break; // truly nothing left to delete
if (tailDeleted.count <= 0) {
// Both head-delete and tail-delete are stuck.
// Last-resort: truncate the LARGEST message content in-place.
const truncResult = _emergencyTruncateOversized(messages, targetTokens, currentTokens, deletedToolCallIds, logger);
currentTokens -= truncResult.tokensSaved;
if (truncResult.tokensSaved <= 0) break; // truly nothing left to do
}
continue;
}
// Calculate deleted tokens before splicing (incremental subtraction)
@@ -938,7 +944,7 @@ function _emergencyTailDelete(
}
totalDeleted += best.indices.length;
totalTokensDeleted += best.tokens;
logger.info(
logger.debug?.(
`[context-offload] EMERGENCY tail-delete: removed ${best.indices.length} msgs (group tokens=${best.tokens}, ids=[${best.toolCallIds.slice(0, 3).join(",")}${best.toolCallIds.length > 3 ? "..." : ""}]), remaining≈${currentTokens - totalTokensDeleted}`,
);
}
@@ -946,6 +952,222 @@ function _emergencyTailDelete(
return { count: totalDeleted, tokens: totalTokensDeleted };
}
/**
* Emergency truncate: when both head-delete and tail-delete are blocked
* (e.g. only MIN_KEEP messages remain but one is 142K tokens), truncate
* the LARGEST message content in-place to break the deadlock.
*
* Strategy:
* 1. Find the largest non-user message by token count.
* 2. If it's a tool result, replace content with a truncated stub.
* 3. If truncation fails or message is protected, try deleting it entirely
* (ignoring MIN_KEEP for this single critical operation).
*
* This ensures emergency ALWAYS makes progress regardless of MIN_KEEP constraints.
*/
function _emergencyTruncateOversized(
messages: any[],
targetTokens: number,
currentTokens: number,
deletedToolCallIds: string[],
logger: PluginLogger,
): { tokensSaved: number } {
const lastUserIdx = findLastUserMessageIndex(messages);
let bestIdx = -1;
let bestTokens = 0;
for (let i = 0; i < messages.length; i++) {
if (i === lastUserIdx) continue; // protect last user message
const msg = messages[i];
if (msg._mmdContextMessage || msg._mmdInjection) continue;
const tokens = tiktokenCount(JSON.stringify(msg, jsonReplacer));
if (tokens > bestTokens) {
bestTokens = tokens;
bestIdx = i;
}
}
if (bestIdx < 0 || bestTokens <= 0) return { tokensSaved: 0 };
// Skip if the largest message is already small enough — truncation would
// make it LARGER (stub text overhead > original content). ~600 tokens is
// the approximate size of the stub + preview.
if (bestTokens < 600) return { tokensSaved: 0 };
const msg = messages[bestIdx];
const role = msg.role ?? msg.message?.role ?? msg.type;
const isAssistantTU = isAssistantMessageWithToolUse(msg);
const toolCallId = extractToolCallId(msg) ?? extractToolUseIdFromAssistant(msg);
// Try truncation first: replace content with a short stub
try {
if (isAssistantTU) {
// Assistant with tool_use: preserve tool_use block structure (id, name, type)
// but replace input/arguments with a compact stub to maintain tool pairing.
_truncateAssistantToolUseContent(msg, bestTokens, logger);
} else {
// toolResult / plain assistant / other: safe to replace entire content
const stubText =
`[Tool output truncated for context management. Original ~${bestTokens} tokens, role=${role}${toolCallId ? `, id=${toolCallId}` : ""}]`;
_setMessageContent(msg, stubText);
// Also strip any other large fields that may exist on the message
// (OpenClaw tool results can have output/result/data fields outside content)
_stripLargeFields(msg);
}
// Invalidate WeakMap token cache so buildTiktokenContextSnapshot sees the new size
invalidateTokenCache(msg);
// Also clean up any legacy per-message cache markers
if (msg._cachedTokens !== undefined) delete msg._cachedTokens;
if (msg._tokenCount !== undefined) delete msg._tokenCount;
const afterTokens = tiktokenCount(JSON.stringify(msg, jsonReplacer));
const saved = bestTokens - afterTokens;
if (toolCallId) deletedToolCallIds.push(toolCallId);
logger.warn(
`[context-offload] EMERGENCY truncate-in-place: idx=${bestIdx}, role=${role}, isToolUse=${isAssistantTU}, ` +
`${bestTokens}${afterTokens} tokens (saved=${saved}), id=${toolCallId ?? "N/A"}`,
);
return { tokensSaved: saved };
} catch (truncErr) {
// Truncation failed — force-delete the message regardless of MIN_KEEP.
// If it's an assistant with tool_use, also remove its paired toolResult
// messages to avoid orphaned tool results (Anthropic 400 error).
logger.warn(`[context-offload] EMERGENCY truncate failed (${truncErr}), force-deleting msg idx=${bestIdx}`);
let totalSaved = bestTokens;
const tuIds = isAssistantTU ? new Set(extractAllToolUseIds(msg)) : null;
messages.splice(bestIdx, 1);
if (toolCallId) deletedToolCallIds.push(toolCallId);
// Clean up orphaned toolResult messages for the deleted tool_use IDs
if (tuIds && tuIds.size > 0) {
for (let i = messages.length - 1; i >= 0; i--) {
if (!isToolResultMessage(messages[i])) continue;
const tid = extractToolCallId(messages[i]);
if (tid && tuIds.has(tid)) {
totalSaved += tiktokenCount(JSON.stringify(messages[i], jsonReplacer));
messages.splice(i, 1);
deletedToolCallIds.push(tid);
tuIds.delete(tid);
if (tuIds.size === 0) break;
}
}
}
return { tokensSaved: totalSaved };
}
}
/**
* Truncate an assistant message with tool_use blocks while preserving
* tool_use structure (type, id, name) to maintain tool pairing.
* Replaces text blocks with a stub and tool_use input with a compact marker.
*/
function _truncateAssistantToolUseContent(msg: any, originalTokens: number, logger: PluginLogger): void {
const content = msg.content ?? msg.message?.content;
if (!Array.isArray(content)) {
// Not array content — fall back to simple text replacement
_setMessageContent(msg, `[Assistant tool_use message truncated for context management. Original ~${originalTokens} tokens. Tool call arguments removed.]`);
return;
}
// Insert a truncation notice as the first text block
content.unshift({
type: "text",
text: `[Assistant message truncated for context management. Original ~${originalTokens} tokens. Tool call arguments below replaced with stubs.]`,
});
for (let i = 1; i < content.length; i++) {
const block = content[i] as any;
if (block.type === "tool_use" || block.type === "toolCall") {
// Preserve id/name/type, replace input with compact stub
if (block.input !== undefined) {
block.input = { _truncated: true, _original_tokens: originalTokens };
}
if (block.arguments !== undefined) {
block.arguments = { _truncated: true, _original_tokens: originalTokens };
}
} else if (block.type === "text") {
// Truncate text blocks
block.text = typeof block.text === "string"
? block.text.slice(0, 200) + (block.text.length > 200 ? "…[truncated]" : "")
: "[truncated]";
}
}
}
/** Extract a preview of message content (first N chars) */
function _extractContentPreview(msg: any, maxChars: number): string {
const content = msg.content ?? msg.message?.content;
if (typeof content === "string") {
return content.slice(0, maxChars);
}
if (Array.isArray(content)) {
let result = "";
for (const block of content) {
const text = typeof block === "string" ? block : (block.text ?? "");
result += text;
if (result.length >= maxChars) break;
}
return result.slice(0, maxChars);
}
return "";
}
/** Set message content (handles both direct and transcript wrapper format) */
function _setMessageContent(msg: any, text: string): void {
if (msg.type === "message" && msg.message) {
if (Array.isArray(msg.message.content)) {
msg.message.content = [{ type: "text", text }];
} else {
msg.message.content = text;
}
} else {
if (Array.isArray(msg.content)) {
msg.content = [{ type: "text", text }];
} else {
msg.content = text;
}
}
}
/**
* Strip large non-essential fields from a message after content truncation.
* OpenClaw tool result messages may store the raw output in fields like
* `output`, `result`, `data`, `rawContent`, `response`, etc. that are
* outside of `content` but still get serialized and counted as tokens.
*
* Preserves structural fields (role, type, id, toolCallId, name, tool_call_id).
*/
function _stripLargeFields(msg: any): void {
const PRESERVE_KEYS = new Set([
"role", "type", "name", "id", "toolCallId", "tool_call_id",
"content", "message", "status",
// internal plugin markers
"_offloaded", "_mmdContextMessage", "_mmdInjection", "_contextOffloadProcessed",
"_cachedTokens", "_tokenCount",
]);
const LARGE_THRESHOLD = 500; // chars — delete any field value > 500 chars serialized
const stripObj = (obj: any) => {
if (!obj || typeof obj !== "object") return;
for (const key of Object.keys(obj)) {
if (PRESERVE_KEYS.has(key)) continue;
const val = obj[key];
if (val === null || val === undefined) continue;
const serialized = typeof val === "string" ? val : JSON.stringify(val);
if (serialized && serialized.length > LARGE_THRESHOLD) {
delete obj[key];
}
}
};
stripObj(msg);
// Also strip inside the transcript wrapper
if (msg.type === "message" && msg.message && typeof msg.message === "object") {
stripObj(msg.message);
}
}
// ─── History MMD Injection ───────────────────────────────────────────────────
export function removeExistingMmdInjections(messages: any[]): number {
@@ -1011,7 +1233,7 @@ export async function buildHistoryMmdInjection(
const metaText = buildHistoryMmdMetaText(filename, mmdContent);
const metaTokens = countTokens(metaText);
if (totalMmdTokens + metaTokens <= mmdTokenBudget) {
logger.info(`[context-offload] History MMD ${filename}: full=${fullTokens} tokens exceeds budget, injecting meta-only (${metaTokens} tokens)`);
logger.debug?.(`[context-offload] History MMD ${filename}: full=${fullTokens} tokens exceeds budget, injecting meta-only (${metaTokens} tokens)`);
injectedMessages.push({ role: "user", content: [{ type: "text", text: metaText }], _mmdInjection: true });
totalMmdTokens += metaTokens;
mmdFiles.push(`${filename}(meta)`);
@@ -1019,7 +1241,7 @@ export async function buildHistoryMmdInjection(
}
// Even meta exceeds budget — skip entirely
logger.info(`[context-offload] History MMD ${filename}: skipped (full=${fullTokens}, meta=${metaTokens}, remaining budget=${mmdTokenBudget - totalMmdTokens})`);
logger.debug?.(`[context-offload] History MMD ${filename}: skipped (full=${fullTokens}, meta=${metaTokens}, remaining budget=${mmdTokenBudget - totalMmdTokens})`);
}
// Reverse back so oldest appears first in messages (chronological order for LLM)
+492 -165
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -41,7 +41,7 @@ export async function injectMmdIntoMessages(
// 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(
logger.debug?.(
`[context-offload] mmd-injector inject: SKIPPED — L1.5 not settled yet (waitForL15=true), msgs=${messages.length}`,
);
return { mmdTokens: stateManager.lastMmdInjectedTokens };
@@ -49,7 +49,7 @@ export async function injectMmdIntoMessages(
const injReady = stateManager.isMmdInjectionReady();
const actFile = stateManager.getActiveMmdFile();
logger.info(
logger.debug?.(
`[context-offload] mmd-injector inject: injectionReady=${injReady}, activeMmdFile=${actFile ?? "null"}, msgs=${messages.length}`,
);
if (!injReady) {
@@ -67,7 +67,7 @@ export async function injectMmdIntoMessages(
const countTokens = createL3TokenCounter(pluginConfig, logger);
const activeMmdText = await buildActiveMmdText(stateManager, logger);
logger.info(
logger.debug?.(
`[context-offload] mmd-injector inject: activeMmdText=${activeMmdText ? `${activeMmdText.length} chars` : "null"}, contextWindow=${contextWindow}`,
);
removeMmdMessages(messages);
@@ -88,7 +88,7 @@ export async function injectMmdIntoMessages(
stateManager.lastMmdInjectedTokens = totalMmdTokens;
const activeMmd = stateManager.getActiveMmdFile();
logger.info(
logger.debug?.(
`[context-offload] mmd-injector: injected active MMD into messages (${totalMmdTokens} tokens, file=${activeMmd})`,
);
@@ -96,7 +96,7 @@ export async function injectMmdIntoMessages(
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}`);
logger.debug?.(`[context-offload] POST-ACTIVE-MMD-INJECT: ${messages.length} msgs, mmd=${mmdCount}, offloaded=${offloadedCount}`);
}
traceOffloadDecision({
@@ -133,7 +133,7 @@ export async function maybeUpdateMmdInMessages(
): Promise<boolean> {
const injectionReady = stateManager.isMmdInjectionReady();
const activeMmdFile = stateManager.getActiveMmdFile();
logger.info(
logger.debug?.(
`[context-offload] mmd-injector maybeUpdate: injectionReady=${injectionReady}, activeMmdFile=${activeMmdFile ?? "null"}, msgs=${messages.length}`,
);
if (!injectionReady) return false;
@@ -142,11 +142,11 @@ export async function maybeUpdateMmdInMessages(
let mmdContent: string | null;
try {
mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
logger.info(
logger.debug?.(
`[context-offload] mmd-injector maybeUpdate: readMmd result=${mmdContent ? `${mmdContent.length} chars` : "null"}`,
);
} catch (e) {
logger.info(`[context-offload] mmd-injector maybeUpdate: readMmd error=${e}`);
logger.debug?.(`[context-offload] mmd-injector maybeUpdate: readMmd error=${e}`);
return false;
}
if (!mmdContent) return false;
@@ -155,7 +155,7 @@ export async function maybeUpdateMmdInMessages(
const lastFp = stateManager.getInjectedMmdVersion(activeMmdFile);
if (newFp === lastFp) return false;
logger.info(
logger.debug?.(
`[context-offload] mmd-injector: MMD updated (${activeMmdFile}), refreshing in-loop`,
);
await injectMmdIntoMessages(
+1 -1
View File
@@ -116,7 +116,7 @@ export function initOffloadOpikTracer(
} catch (err) {
tracerEnabled = false;
client = null;
logger.warn(`[context-offload] Opik tracer init failed: ${String(err)}`);
logger.debug?.(`[context-offload] Opik tracer init failed: ${String(err)}`);
}
}
+1 -1
View File
@@ -262,7 +262,7 @@ export async function backfillNodeIds(
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}`);
logger.debug?.(`[context-offload] L2 backfill: mapped=${mappedCount}, fallback=${fallbackCount} (to ${effectiveFallback ?? "N/A"}), skipped=${skippedCount}, total=${waitIds.size}`);
}
function getMostFrequent(arr: string[]): string | null {
+7 -7
View File
@@ -73,12 +73,12 @@ export async function reclaimOffloadData(
};
if (config.retentionDays < 3) {
logger.info(`${TAG} Skipped: retentionDays=${config.retentionDays} (min effective: 3)`);
logger.debug?.(`${TAG} Skipped: retentionDays=${config.retentionDays} (min effective: 3)`);
return stats;
}
if (!existsSync(dataRoot)) {
logger.info(`${TAG} Skipped: dataRoot does not exist: ${dataRoot}`);
logger.debug?.(`${TAG} Skipped: dataRoot does not exist: ${dataRoot}`);
return stats;
}
@@ -180,7 +180,7 @@ async function deleteExpiredJsonlInDir(
if (s.mtimeMs < cutoffMs) {
await unlink(filePath);
deleted++;
logger.info(`${TAG} Step 1: deleted expired JSONL: ${filePath} (mtime=${new Date(s.mtimeMs).toISOString()})`);
logger.debug?.(`${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)}`);
@@ -258,7 +258,7 @@ async function reclaimOrphanRefs(
if (s.mtimeMs < cutoffMs) {
await unlink(filePath);
deleted++;
logger.info(`${TAG} Step 2: deleted orphan ref: ${filePath}`);
logger.debug?.(`${TAG} Step 2: deleted orphan ref: ${filePath}`);
}
} catch {
/* skip individual file errors */
@@ -362,7 +362,7 @@ async function reclaimExpiredMmds(
await unlink(filePath);
deleted++;
remaining--;
logger.info(`${TAG} Step 3: deleted expired MMD: ${filePath}`);
logger.debug?.(`${TAG} Step 3: deleted expired MMD: ${filePath}`);
} catch {
/* skip */
}
@@ -422,7 +422,7 @@ async function rotateDebugLogs(
await truncate(file.path, 0);
totalSize -= file.size;
truncated++;
logger.info(`${TAG} Step 4: truncated log: ${file.path} (was ${file.size} bytes)`);
logger.debug?.(`${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)}`);
}
@@ -465,7 +465,7 @@ async function pruneRegistries(
const removedCount = originalCount - Object.keys(registry).length;
pruned += removedCount;
await atomicWriteJson(registryPath, registry);
logger.info(`${TAG} Step 5: pruned ${removedCount} expired entries from ${registryPath}`);
logger.debug?.(`${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)}`);
+12
View File
@@ -88,6 +88,17 @@ export class OffloadStateManager {
lastKnownMessageCount = 0;
/** Consecutive QUICK-SKIP count; reset to 0 on each precise calculation */
consecutiveQuickSkips = 0;
/** Boundary info from last aggressive deletion — enables O(1) head-delete on replay.
* originalIndex: position of the first kept message in the original input array.
* fingerprint: hash of that message for verification.
* keptMsgCount: number of messages kept after aggressive.
* remainingTokens: total tokens (incl sys) after aggressive compression. */
_lastAggressiveBoundary: {
originalIndex: number;
fingerprint: number;
keptMsgCount: number;
remainingTokens: number;
} | null = null;
/** 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 */
@@ -277,6 +288,7 @@ export class OffloadStateManager {
this.lastKnownMessageCount = 0;
this.consecutiveQuickSkips = 0;
this._forceEmergencyNext = false;
this._lastAggressiveBoundary = null;
// Keep cachedSystemPrompt/Tokens across switchSession within the same agent
if (prevAgent !== parsed.agentName) {
this.cachedSystemPrompt = null;
+1 -1
View File
@@ -333,7 +333,7 @@ export function reportL3Trigger(
backendClient
.storeState(report as unknown as StoreStatePayload)
.then(() => {
logger.info(
logger.debug?.(
`[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}`,
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeText } from "./storage.js";
describe("sanitizeText", () => {
it("preserves plain ASCII", () => {
expect(sanitizeText("hello world")).toBe("hello world");
});
it("preserves emoji and other non-BMP code points", () => {
// 🎉 = U+1F389, 𠮷 = U+20BB7 (CJK Extension B), 𝐀 = U+1D400 (math bold A).
// Each is a surrogate pair in UTF-16. Without the `u` flag, the
// [\uD800-\uDFFF] range in UNSAFE_CHAR_RE would strip each half
// independently and silently destroy these characters.
expect(sanitizeText("emoji \u{1F389} here")).toBe("emoji \u{1F389} here");
expect(sanitizeText("CJK ext-B \u{20BB7} here")).toBe(
"CJK ext-B \u{20BB7} here",
);
expect(sanitizeText("math bold \u{1D400} here")).toBe(
"math bold \u{1D400} here",
);
});
it("strips lone (malformed) surrogates", () => {
expect(sanitizeText("lone \uD800 surrogate")).toBe("lone surrogate");
expect(sanitizeText("lone \uDC00 surrogate")).toBe("lone surrogate");
});
it("strips C0 and C1 control characters", () => {
expect(sanitizeText("ctrlhere")).toBe("ctrlhere");
expect(sanitizeText("c1…here")).toBe("c1here");
});
it("strips zero-width characters and BOM", () => {
expect(sanitizeText("ab")).toBe("ab");
expect(sanitizeText("ab")).toBe("ab");
});
it("returns non-string input unchanged", () => {
// Matches the existing typeof guard in sanitizeText.
expect(sanitizeText(42 as unknown as string)).toBe(42);
});
});
+1 -1
View File
@@ -247,6 +247,6 @@ export const PLUGIN_DEFAULTS = {
emergencyTargetRatio: 0.6,
mmdMaxTokenRatio: 0.2,
l3TokenCountMode: "tiktoken" as const,
l3TiktokenEncoding: "o200k_base" as const,
l3TiktokenEncoding: "cl100k_base" as const,
defaultSystemOverheadRatio: 0.12,
} as const;