Files
OmniRoute/open-sse/services/compression/engines/session-dedup/index.ts
ikelvingo 40b1d94cbd fix(compression): restore auto-combo scoring/routing features dropped by stale rebase
PR #8438 (session-dedup OOM-guard rewrite) was built against a stale local
copy of scoring.ts/autoStrategy.ts predating #8008 and #7301: merging it
as-is silently deleted the cacheAffinity scoring factor + normalizeScoringWeights
(#8008), the cached getCachedProviderConnections read path, and the
#COMBO-REF candidate-pool guard (#7301), breaking the TypeScript build.
Restore all three while keeping the PR's own legitimate additions
(computePoolMaxima/PoolMaxima pool-maxima memoization).

Also fix a real perf regression the PR introduced in session-dedup's
single-message intra-dedup path: dedupeWithinMessage dynamically built and
ran a global RegExp per candidate suffix block to count/replace occurrences,
which is pathologically slow on large single-message inputs (the exact
"huge tool result" shape the #7849 O(n^2) guard targets). Replace it with
indexOf-based counting/replacement (same semantics, no regex compile/match
overhead).

Update the #7849 regression suite's assertions to match the new
MAX_SUFFIX_STARTS/MAX_TOTAL_BLOCK_BYTES bound (which truncates suffix-block
enumeration silently/best-effort instead of failing the whole request open
with the old "suffix work budget exceeded" warning) instead of asserting on
the removed shared-budget mechanism.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-07-25 12:54:27 -03:00

486 lines
18 KiB
TypeScript

/**
* session-dedup compression engine (R11 / N2 / TO1)
*
* Content-addressed cross-turn deduplication, inspired by the TokenMizer
* session-graph + line-dedup blueprint (arXiv 2606.06337) and sqz prior-art.
*
* Algorithm (two-pass, suffix-block content-addressed):
* Pass 1 — scan all non-system messages. For each message, enumerate suffix
* line blocks (lines[start..end-of-message]) that meet minBlockChars
* and minBlockLines. Hash each block. Record the first message that
* owns each hash.
* Pass 2 — for each non-system message (index i), find blocks whose hash was
* first seen in a STRICTLY EARLIER message (index j < i). Replace the
* LONGEST such block's text with `[dedup:ref sha=<8hex>]`.
* First occurrence is always kept intact.
*
* Greedy, longest-first replacement: sort duplicate blocks by length descending;
* replace the longest block first so shorter overlapping candidates are skipped.
*
* Conservative guards:
* - Never touch `role: "system"`.
* - Never touch multipart content parts other than `type: "text"`.
* - Only dedup blocks ≥ minBlockChars (default 80 chars) AND ≥ MIN_BLOCK_LINES lines.
* - First occurrence is ALWAYS kept intact; only later identical occurrences are replaced.
*
* Reconstruction:
* Replace every `[dedup:ref sha=XXXXXXXX]` marker with the original block text
* from the reverse map attached as `__sessionDedupMap__` on the body object.
*/
import crypto from "node:crypto";
import { createCompressionStats } from "../../stats.ts";
import { runFuzzyPass } from "./fuzzy.ts";
import type {
CompressionEngine,
CompressionEngineApplyOptions,
EngineConfigField,
EngineValidationResult,
} from "../types.ts";
import type { CompressionResult } from "../../types.ts";
// ─── constants ────────────────────────────────────────────────────────────────
const ENGINE_ID = "session-dedup";
/** Minimum block character count to be a dedup candidate. */
const DEFAULT_MIN_BLOCK_CHARS = 80;
/** Minimum number of lines a block must span to be a dedup candidate. */
const MIN_BLOCK_LINES = 3;
/**
* O(n²) guard for {@link findSuffixBlocks} (OOM incident): a single message with
* thousands of lines otherwise generates one full-length suffix string PER line,
* all retained at once. A real agent conversation embedding a large
* line-numbered file view (e.g. a tool result pasting a multi-thousand-line
* file back into the chat) drove ~1.7GB of live suffix strings and OOM-killed
* the 2GB heap (heap snapshot confirmed 6801 `{ block }` objects). These bound
* both the number of suffix starts scanned
* and the total bytes of retained blocks, so memory is O(budget) instead of O(n²).
* Dedup is best-effort — skipping the tail only forgoes some compression, never
* changes output correctness.
*/
const MAX_SUFFIX_STARTS = 2000;
const MAX_TOTAL_BLOCK_BYTES = 8 * 1024 * 1024;
// ─── hash helper (SHA-256 prefix, collision-resistant) ───────────────────────
function hashBlock(text: string): string {
// 24 hex / 96 bits — collision-resistant (a 32-bit djb2 could collide and make a
// dedup marker reference the WRONG block). Pass 2 additionally verifies block
// equality before substituting, so a collision can never cause corruption.
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 24);
}
// ─── suffix-block extraction ──────────────────────────────────────────────────
/**
* For each starting line position, emit the suffix block `lines[start..end]`
* (i.e. from `start` to the end of the line array). This ensures that any
* multiline sub-content that appears verbatim in multiple messages is discoverable
* regardless of what text precedes it in each message.
*
* Only emits blocks that meet minBlockChars AND have at least MIN_BLOCK_LINES lines.
* Uses a seen-set to deduplicate identical suffix blocks.
*/
function findSuffixBlocks(
lines: string[],
minBlockChars: number
): Array<{ block: string; startLine: number }> {
const n = lines.length;
const seen = new Set<string>();
const results: Array<{ block: string; startLine: number }> = [];
// O(n²) guard (#OOM): cap the number of suffix starts and the total retained
// block bytes so a huge message can't materialize thousands of full-length
// suffix strings at once. See MAX_SUFFIX_STARTS / MAX_TOTAL_BLOCK_BYTES.
const maxStarts = Math.min(n, MAX_SUFFIX_STARTS);
let totalBlockBytes = 0;
for (let start = 0; start < maxStarts; start++) {
const block = lines.slice(start).join("\n");
const blockLines = n - start;
if (blockLines >= MIN_BLOCK_LINES && block.length >= minBlockChars && !seen.has(block)) {
seen.add(block);
results.push({ block, startLine: start });
totalBlockBytes += block.length;
if (totalBlockBytes >= MAX_TOTAL_BLOCK_BYTES) break;
}
}
return results;
}
// ─── two-pass dedup on message texts ─────────────────────────────────────────
/**
* Replace every occurrence of `needle` in `haystack` after the first with
* `marker`, keeping the first occurrence intact. Returns the new string and
* how many replacements were made.
*
* Uses plain `indexOf`/slice instead of a dynamically-built global RegExp:
* escaping an up-to-multi-hundred-KB literal block into a RegExp and running
* it (once per candidate block, once for counting + once for replacing) is
* pathologically slow on large single-message inputs — the exact
* multi-thousand-line "huge tool result" shape the O(n²) guard above targets
* (#OOM incident) — so a single call could still take tens of seconds even
* though findSuffixBlocks itself is bounded. indexOf-based scanning is O(n)
* per pass and has no compile step.
*/
function replaceAllButFirst(
haystack: string,
needle: string,
marker: string
): { result: string; occurrences: number } {
if (needle.length === 0) return { result: haystack, occurrences: 0 };
const firstIdx = haystack.indexOf(needle);
if (firstIdx === -1) return { result: haystack, occurrences: 0 };
let occurrences = 1;
let searchFrom = firstIdx + needle.length;
let result = haystack.slice(0, searchFrom);
let cursor = searchFrom;
for (;;) {
const idx = haystack.indexOf(needle, cursor);
if (idx === -1) break;
occurrences++;
result += haystack.slice(cursor, idx) + marker;
cursor = idx + needle.length;
}
result += haystack.slice(cursor);
return { result, occurrences };
}
/**
* Deduplicates repeated lines within a single message (intra-message dedup).
* Replaces repeated suffix blocks with markers.
*/
function dedupeWithinMessage(
text: string,
minBlockChars: number
): { deduped: string; changed: boolean } {
const lines = text.split("\n");
const blocks = findSuffixBlocks(lines, minBlockChars);
if (blocks.length < 2) return { deduped: text, changed: false };
// findSuffixBlocks already de-duplicates by exact block content (its `seen`
// set), so every entry here is already frequency-1 by construction. Sort by
// length descending so the longest candidate blocks are tried first.
const sortedBlocks = [...blocks].sort((a, b) => b.block.length - a.block.length);
let result = text;
let changed = false;
for (const { block } of sortedBlocks) {
const sha = hashBlock(block);
const marker = `[dedup:ref sha=${sha}]`;
// Only dedup blocks that appear 2+ times in the text; keep the first
// occurrence intact and replace the rest.
const { result: replaced, occurrences } = replaceAllButFirst(result, block, marker);
if (occurrences < 2) continue;
result = replaced;
changed = true;
}
return { deduped: result, changed };
}
/**
* Runs two-pass dedup over an ordered list of (msgIdx, text) pairs.
* Returns the replaced texts for duplicate messages, a reverse map, and a count.
*/
function dedupMessageTexts(
msgTexts: Array<{ msgIdx: number; text: string }>,
minBlockChars: number
): {
deduped: Map<number, string>;
dedupCount: number;
} {
const deduped = new Map<number, string>();
let dedupCount = 0;
// Single-message case: apply intra-message dedup.
if (msgTexts.length === 1) {
const { text, msgIdx } = msgTexts[0];
const { deduped: dedupedText, changed } = dedupeWithinMessage(text, minBlockChars);
if (changed) {
deduped.set(msgIdx, dedupedText);
dedupCount++;
}
return { deduped, dedupCount };
}
// Multi-message case: apply cross-turn dedup.
// Pass 1: for each message, extract suffix blocks and record first ownership.
// `firstSeen`: sha → { ownerMsgIdx, block }
const firstSeen = new Map<string, { ownerMsgIdx: number; block: string }>();
for (const { msgIdx, text } of msgTexts) {
const lines = text.split("\n");
const blocks = findSuffixBlocks(lines, minBlockChars);
for (const { block } of blocks) {
const sha = hashBlock(block);
if (!firstSeen.has(sha)) {
firstSeen.set(sha, { ownerMsgIdx: msgIdx, block });
}
}
}
// Pass 2: for each message, find blocks that were FIRST seen in an earlier message.
for (const { msgIdx, text } of msgTexts) {
const lines = text.split("\n");
const blocks = findSuffixBlocks(lines, minBlockChars);
// Collect blocks that are duplicates (owned by an earlier message).
const dupBlocks: Array<{ block: string; sha: string }> = [];
for (const { block } of blocks) {
const sha = hashBlock(block);
const owner = firstSeen.get(sha);
// owner.block === block guards against a (now astronomically unlikely) hash
// collision substituting a marker that would reference the wrong block.
if (owner && owner.ownerMsgIdx < msgIdx && owner.block === block) {
dupBlocks.push({ block, sha });
}
}
if (dupBlocks.length === 0) continue;
// Sort longest-first to prefer replacing the longest matching block.
dupBlocks.sort((a, b) => b.block.length - a.block.length);
let result = text;
let changed = false;
const replaced = new Set<string>(); // avoid double-replacing overlapping blocks
for (const { block, sha } of dupBlocks) {
// Skip if this block is a suffix of a block already replaced (overlap guard).
if ([...replaced].some((r) => r.includes(block))) continue;
const idx = result.indexOf(block);
if (idx !== -1) {
const marker = `[dedup:ref sha=${sha}]`;
result = result.slice(0, idx) + marker + result.slice(idx + block.length);
changed = true;
replaced.add(block);
// Only replace once per block per message pass.
break;
}
}
if (changed) {
deduped.set(msgIdx, result);
dedupCount++;
}
}
return { deduped, dedupCount };
}
// ─── message array processing ─────────────────────────────────────────────────
type MessageLike = {
role?: string;
content?: string | Array<Record<string, unknown>>;
[key: string]: unknown;
};
/**
* Process messages: collect text content, run two-pass dedup, apply results.
*/
function processMessages(
messages: MessageLike[],
minBlockChars: number
): { messages: MessageLike[]; dedupCount: number } {
// Collect (msgIdx, text) for non-system string-content messages.
// For multipart, index each text part separately.
const msgTexts: Array<{ msgIdx: number; text: string }> = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "system") continue;
if (typeof msg.content === "string") {
msgTexts.push({ msgIdx: i, text: msg.content });
} else if (Array.isArray(msg.content)) {
for (let p = 0; p < msg.content.length; p++) {
const part = msg.content[p];
if (part["type"] === "text" && typeof part["text"] === "string") {
// Composite key: i * 100000 + p + 1 (safe for reasonable message counts)
msgTexts.push({ msgIdx: i * 100000 + p + 1, text: part["text"] as string });
}
}
}
}
if (msgTexts.length === 0) {
return { messages, dedupCount: 0 };
}
const { deduped, dedupCount } = dedupMessageTexts(msgTexts, minBlockChars);
if (dedupCount === 0) {
return { messages, dedupCount: 0 };
}
const result = messages.map((msg, i) => {
if (msg.role === "system") return { ...msg };
if (typeof msg.content === "string") {
const replacement = deduped.get(i);
return replacement !== undefined ? { ...msg, content: replacement } : { ...msg };
}
if (Array.isArray(msg.content)) {
let changed = false;
const newContent = msg.content.map((part, p) => {
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
const key = i * 100000 + p + 1;
const replacement = deduped.get(key);
if (replacement !== undefined) {
changed = true;
return { ...part, text: replacement };
}
return part;
});
return changed ? { ...msg, content: newContent } : { ...msg };
}
return { ...msg };
});
return { messages: result, dedupCount };
}
// ─── schema & validation ──────────────────────────────────────────────────────
const SESSION_DEDUP_SCHEMA: EngineConfigField[] = [
{
key: "enabled",
type: "boolean",
label: "Enabled",
defaultValue: true,
},
{
key: "minBlockChars",
type: "number",
label: "Minimum block characters",
description: "Minimum character count for a suffix block to be a dedup candidate.",
defaultValue: DEFAULT_MIN_BLOCK_CHARS,
min: 1,
max: 100000,
},
{
key: "fuzzy",
type: "boolean",
label: "Fuzzy near-duplicate dedup",
description:
"Opt-in: replace whole messages ~85%+ similar to an earlier one with a recoverable CCR marker.",
defaultValue: false,
},
];
function validateSessionDedupConfig(config: Record<string, unknown>): EngineValidationResult {
const errors: string[] = [];
if (config["enabled"] !== undefined && typeof config["enabled"] !== "boolean") {
errors.push("enabled must be a boolean");
}
if (config["minBlockChars"] !== undefined) {
const v = config["minBlockChars"];
if (typeof v !== "number" || !Number.isFinite(v) || v < 1) {
errors.push("minBlockChars must be a positive number");
}
}
if (config["fuzzy"] !== undefined) {
const f = config["fuzzy"];
if (typeof f === "object" && f !== null) {
const fe = (f as Record<string, unknown>)["enabled"];
if (fe !== undefined && typeof fe !== "boolean") errors.push("fuzzy.enabled must be a boolean");
} else if (typeof f !== "boolean") {
errors.push("fuzzy must be an object { enabled } or a boolean");
}
}
return { valid: errors.length === 0, errors };
}
// ─── engine export ────────────────────────────────────────────────────────────
export const sessionDedupEngine: CompressionEngine = {
id: ENGINE_ID,
name: "Session Dedup",
description:
"Content-addressed cross-turn deduplication: replaces repeated multi-line blocks " +
"with short reference markers (R11/N2/TO1, TokenMizer blueprint).",
icon: "content_copy",
targets: ["messages"],
stackable: true,
// stackPriority 3 = runs BEFORE lite (5), caveman (20), aggressive (30), ultra (40).
// Dedup first so downstream engines operate on already-deduplicated content.
stackPriority: 3,
metadata: {
id: ENGINE_ID,
name: "Session Dedup",
description:
"Content-addressed cross-turn deduplication: replaces repeated multi-line blocks with short reference markers.",
inputScope: "messages",
targetLatencyMs: 1,
supportsPreview: true,
stable: true,
},
apply(body: Record<string, unknown>, options?: CompressionEngineApplyOptions): CompressionResult {
const stepConfig = options?.stepConfig ?? {};
if (stepConfig["enabled"] === false) {
return { body, compressed: false, stats: null };
}
const minBlockChars =
typeof stepConfig["minBlockChars"] === "number"
? (stepConfig["minBlockChars"] as number)
: DEFAULT_MIN_BLOCK_CHARS;
const messages = body["messages"];
if (!Array.isArray(messages) || messages.length === 0) {
return { body, compressed: false, stats: null };
}
const start = performance.now();
const { messages: exactMessages, dedupCount } = processMessages(
messages as MessageLike[],
minBlockChars
);
const { messages: finalMessages, fuzzyCount } = runFuzzyPass(
exactMessages,
stepConfig,
minBlockChars,
options?.principalId
);
if (dedupCount + fuzzyCount === 0) {
return { body, compressed: false, stats: null };
}
const newBody: Record<string, unknown> = { ...body, messages: finalMessages };
const durationMs = Math.round(performance.now() - start);
const techniques = ["session-dedup"];
if (fuzzyCount > 0) techniques.push("fuzzy-dedup");
const rules: string[] = [];
if (dedupCount > 0) rules.push(`deduplicated-${dedupCount}-blocks`);
if (fuzzyCount > 0) rules.push(`fuzzy-${fuzzyCount}-blocks`);
const stats = createCompressionStats(body, newBody, "stacked", techniques, rules, durationMs);
return { body: newBody, compressed: true, stats };
},
compress(body: Record<string, unknown>, config?: Record<string, unknown>): CompressionResult {
return this.apply(body, { stepConfig: config ?? {} });
},
getConfigSchema(): EngineConfigField[] {
return SESSION_DEDUP_SCHEMA;
},
validateConfig(config: Record<string, unknown>): EngineValidationResult {
return validateSessionDedupConfig(config);
},
};