Files
2026-07-13 13:39:12 +08:00

436 lines
16 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;
// ─── 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 }> = [];
for (let start = 0; start < n; 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 });
}
}
return results;
}
// ─── two-pass dedup on message texts ─────────────────────────────────────────
/**
* 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 };
// Find the most common block (likely candidate for intra-message dedup).
const blockFreq = new Map<string, number>();
for (const { block } of blocks) {
blockFreq.set(block, (blockFreq.get(block) || 0) + 1);
}
// Sort by frequency descending, then by length descending (prefer replacing more common, longer blocks first).
const sortedBlocks = [...blocks].sort((a, b) => {
const freqDiff = (blockFreq.get(b.block) || 0) - (blockFreq.get(a.block) || 0);
return freqDiff !== 0 ? freqDiff : b.block.length - a.block.length;
});
let result = text;
let changed = false;
for (const { block } of sortedBlocks) {
// Only dedup blocks that appear 2+ times in the text.
const occurrences = (result.match(new RegExp(block.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
if (occurrences < 2) continue;
const sha = hashBlock(block);
const marker = `[dedup:ref sha=${sha}]`;
// Replace ALL occurrences except the first (keep the original once).
let count = 0;
result = result.replace(new RegExp(block.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), () => {
count++;
return count === 1 ? block : marker;
});
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);
},
};