karajan-code 1.58.2 → 1.59.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/aider-agent.js +7 -0
- package/src/agents/claude-agent.js +36 -2
- package/src/agents/codex-agent.js +4 -0
- package/src/agents/gemini-agent.js +4 -0
- package/src/agents/opencode-agent.js +4 -0
- package/src/bootstrap.js +20 -9
- package/src/cli.js +1 -0
- package/src/commands/doctor.js +19 -0
- package/src/commands/report.js +13 -0
- package/src/commands/status.js +7 -0
- package/src/config.js +35 -0
- package/src/domains/domain-loader.js +85 -4
- package/src/git/automation.js +32 -4
- package/src/guards/policy-guard.js +90 -0
- package/src/mcp/handlers/run-handler.js +20 -3
- package/src/mcp/shared-helpers.js +4 -2
- package/src/mcp/sovereignty-guard.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/orchestrator.js +77 -20
- package/src/prompts/coder.js +5 -4
- package/src/prompts/reviewer.js +5 -4
- package/src/prompts/rtk-snippet.js +12 -0
- package/src/proxy/adapters/anthropic.js +131 -0
- package/src/proxy/adapters/gemini.js +106 -0
- package/src/proxy/adapters/index.js +51 -0
- package/src/proxy/adapters/openai.js +71 -0
- package/src/proxy/compression/ai-compressor.js +209 -0
- package/src/proxy/compression/dedup-cache.js +100 -0
- package/src/proxy/compression/deterministic/bash-build.js +60 -0
- package/src/proxy/compression/deterministic/bash-git.js +101 -0
- package/src/proxy/compression/deterministic/bash-infra.js +64 -0
- package/src/proxy/compression/deterministic/bash-misc.js +53 -0
- package/src/proxy/compression/deterministic/bash-pkg.js +49 -0
- package/src/proxy/compression/deterministic/bash-test.js +61 -0
- package/src/proxy/compression/deterministic/glob.js +26 -0
- package/src/proxy/compression/deterministic/grep.js +53 -0
- package/src/proxy/compression/deterministic/index.js +58 -0
- package/src/proxy/compression/deterministic/read.js +22 -0
- package/src/proxy/compression/deterministic/utils.js +33 -0
- package/src/proxy/compression/pipeline.js +270 -0
- package/src/proxy/middleware/prompt-injector.js +165 -0
- package/src/proxy/middleware/response-interceptor.js +223 -0
- package/src/proxy/progress-monitor.js +98 -0
- package/src/proxy/proxy-lifecycle.js +201 -0
- package/src/proxy/proxy-server.js +239 -0
- package/src/proxy/security.js +45 -0
- package/src/review/diff-generator.js +10 -8
- package/src/roles/coder-role.js +1 -0
- package/src/roles/impeccable-role.js +2 -4
- package/src/roles/researcher-role.js +2 -4
- package/src/roles/reviewer-role.js +10 -8
- package/src/roles/solomon-role.js +2 -4
- package/src/session-cleanup.js +39 -15
- package/src/sonar/cloud-scanner.js +6 -3
- package/src/sonar/scanner.js +12 -7
- package/src/utils/banner.js +66 -0
- package/src/utils/display.js +46 -7
- package/src/utils/git.js +5 -0
- package/src/utils/welcome.js +3 -8
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic compression for package manager output.
|
|
3
|
+
* Handles: npm/yarn/pnpm install, npm list, npm outdated.
|
|
4
|
+
*/
|
|
5
|
+
import { stripAnsi, collapseWhitespace, truncateLines } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
const PKG_PATTERNS = [
|
|
8
|
+
/added \d+ packages?/i,
|
|
9
|
+
/npm warn|npm error/i,
|
|
10
|
+
/packages? in \d/,
|
|
11
|
+
/├──|└──|│/,
|
|
12
|
+
/\d+ vulnerabilities/i,
|
|
13
|
+
/npm outdated/i,
|
|
14
|
+
/Current\s+Wanted\s+Latest/i
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function looksLike(text) {
|
|
18
|
+
const clean = stripAnsi(text);
|
|
19
|
+
return PKG_PATTERNS.some((p) => p.test(clean));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function compact(text) {
|
|
23
|
+
const clean = stripAnsi(text);
|
|
24
|
+
|
|
25
|
+
// npm install summary
|
|
26
|
+
if (/added \d+ packages?/i.test(clean)) {
|
|
27
|
+
const lines = clean.split("\n");
|
|
28
|
+
const kept = [];
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const trimmed = line.trim();
|
|
31
|
+
if (/added \d+ packages?/i.test(trimmed) || /vulnerabilit/i.test(trimmed)) {
|
|
32
|
+
kept.push(trimmed);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return kept.join("\n") || clean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// npm list — truncate tree
|
|
39
|
+
if (/├──|└──/.test(clean)) {
|
|
40
|
+
return truncateLines(clean, 25);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// npm outdated — compact table
|
|
44
|
+
if (/Current\s+Wanted\s+Latest/i.test(clean)) {
|
|
45
|
+
return truncateLines(collapseWhitespace(clean), 20);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return truncateLines(collapseWhitespace(clean), 30);
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic compression for test runner output.
|
|
3
|
+
* Handles: vitest, jest, mocha, playwright, pytest, cargo test, go test.
|
|
4
|
+
*/
|
|
5
|
+
import { stripAnsi, collapseWhitespace, truncateLines } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
const TEST_PATTERNS = [
|
|
8
|
+
/Tests?\s+\d+\s+(passed|failed)/i,
|
|
9
|
+
/✓|✗|✘|PASS|FAIL/,
|
|
10
|
+
/Test Suites?:/,
|
|
11
|
+
/test result:/i,
|
|
12
|
+
/FAILED|PASSED|ERROR/,
|
|
13
|
+
/vitest|jest|mocha|playwright|pytest|cargo test|go test/i,
|
|
14
|
+
/\d+ passing/,
|
|
15
|
+
/\d+ failing/
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function looksLike(text) {
|
|
19
|
+
const clean = stripAnsi(text);
|
|
20
|
+
return TEST_PATTERNS.some((p) => p.test(clean));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function compact(text) {
|
|
24
|
+
const clean = stripAnsi(text);
|
|
25
|
+
|
|
26
|
+
// Extract summary line if present
|
|
27
|
+
const summaryPatterns = [
|
|
28
|
+
/Tests?\s+\d+\s+(passed|failed).*/gi,
|
|
29
|
+
/Test Suites?:.*$/gm,
|
|
30
|
+
/\d+ passing.*$/gm,
|
|
31
|
+
/\d+ failing.*$/gm,
|
|
32
|
+
/test result:.*$/gim,
|
|
33
|
+
/Tests:.*\d+ total$/gm
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const summaries = [];
|
|
37
|
+
for (const p of summaryPatterns) {
|
|
38
|
+
const matches = clean.match(p);
|
|
39
|
+
if (matches) summaries.push(...matches.map((m) => m.trim()));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract failed test details (name + error)
|
|
43
|
+
const failures = [];
|
|
44
|
+
const failBlocks = clean.split(/(?=(?:FAIL|✗|✘|×)\s)/);
|
|
45
|
+
for (const block of failBlocks) {
|
|
46
|
+
if (/^(?:FAIL|✗|✘|×)\s/.test(block)) {
|
|
47
|
+
failures.push(truncateLines(block.trim(), 8));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parts = [];
|
|
52
|
+
if (summaries.length) parts.push(summaries.join("\n"));
|
|
53
|
+
if (failures.length) {
|
|
54
|
+
parts.push(`Failed tests:\n${failures.join("\n---\n")}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (parts.length) return parts.join("\n\n");
|
|
58
|
+
|
|
59
|
+
// Fallback: collapse and truncate
|
|
60
|
+
return truncateLines(collapseWhitespace(clean), 50);
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic compression for glob/find/ls output.
|
|
3
|
+
* Truncates long file listings while keeping count.
|
|
4
|
+
*/
|
|
5
|
+
import { stripAnsi, truncateLines } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
const MAX_FILES_SHOWN = 40;
|
|
8
|
+
|
|
9
|
+
export function looksLike(text) {
|
|
10
|
+
const clean = stripAnsi(text);
|
|
11
|
+
const lines = clean.split("\n").filter((l) => l.trim());
|
|
12
|
+
if (lines.length < 5) return false;
|
|
13
|
+
// Most lines look like file paths
|
|
14
|
+
const pathLike = lines.filter((l) => /^[\w./-]+\.\w+$/.test(l.trim()) || /^\//.test(l.trim()));
|
|
15
|
+
return pathLike.length >= lines.length * 0.6;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function compact(text) {
|
|
19
|
+
const clean = stripAnsi(text);
|
|
20
|
+
const lines = clean.split("\n").filter((l) => l.trim());
|
|
21
|
+
if (lines.length <= MAX_FILES_SHOWN) return clean;
|
|
22
|
+
|
|
23
|
+
const shown = lines.slice(0, MAX_FILES_SHOWN);
|
|
24
|
+
shown.push(`\n... (${lines.length} total files, showing first ${MAX_FILES_SHOWN})`);
|
|
25
|
+
return shown.join("\n");
|
|
26
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic compression for grep/ripgrep output.
|
|
3
|
+
* Collapses repetitive results, keeps first N per file + total count.
|
|
4
|
+
*/
|
|
5
|
+
import { stripAnsi, truncateLines } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
const MAX_MATCHES_PER_FILE = 3;
|
|
8
|
+
const MAX_FILES = 15;
|
|
9
|
+
|
|
10
|
+
export function looksLike(text) {
|
|
11
|
+
const clean = stripAnsi(text);
|
|
12
|
+
// grep-style output: file:line:content or file:content
|
|
13
|
+
const lines = clean.split("\n").filter((l) => l.trim());
|
|
14
|
+
if (lines.length < 2) return false;
|
|
15
|
+
const matchingLines = lines.filter((l) => /^[^\s:]+:\d+:/.test(l) || /^[^\s:]+:/.test(l));
|
|
16
|
+
return matchingLines.length >= lines.length * 0.5;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function compact(text) {
|
|
20
|
+
const clean = stripAnsi(text);
|
|
21
|
+
const lines = clean.split("\n").filter((l) => l.trim());
|
|
22
|
+
|
|
23
|
+
// Group by file
|
|
24
|
+
const byFile = new Map();
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const match = line.match(/^([^\s:]+):/);
|
|
27
|
+
const file = match ? match[1] : "__other__";
|
|
28
|
+
if (!byFile.has(file)) byFile.set(file, []);
|
|
29
|
+
byFile.get(file).push(line);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const totalFiles = byFile.size;
|
|
33
|
+
const totalMatches = lines.length;
|
|
34
|
+
const result = [];
|
|
35
|
+
let fileCount = 0;
|
|
36
|
+
|
|
37
|
+
for (const [file, matches] of byFile) {
|
|
38
|
+
if (fileCount >= MAX_FILES) break;
|
|
39
|
+
fileCount++;
|
|
40
|
+
const shown = matches.slice(0, MAX_MATCHES_PER_FILE);
|
|
41
|
+
result.push(...shown);
|
|
42
|
+
if (matches.length > MAX_MATCHES_PER_FILE) {
|
|
43
|
+
result.push(` ... (${matches.length - MAX_MATCHES_PER_FILE} more matches in ${file})`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (totalFiles > MAX_FILES) {
|
|
48
|
+
result.push(`\n... (${totalFiles - MAX_FILES} more files)`);
|
|
49
|
+
}
|
|
50
|
+
result.push(`\nTotal: ${totalMatches} matches in ${totalFiles} files`);
|
|
51
|
+
|
|
52
|
+
return result.join("\n");
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic compression dispatcher.
|
|
3
|
+
* Tries known patterns in order of specificity; returns original text if nothing matches.
|
|
4
|
+
*/
|
|
5
|
+
import * as bashGit from "./bash-git.js";
|
|
6
|
+
import * as bashTest from "./bash-test.js";
|
|
7
|
+
import * as bashBuild from "./bash-build.js";
|
|
8
|
+
import * as bashInfra from "./bash-infra.js";
|
|
9
|
+
import * as bashPkg from "./bash-pkg.js";
|
|
10
|
+
import * as bashMisc from "./bash-misc.js";
|
|
11
|
+
import * as grepMod from "./grep.js";
|
|
12
|
+
import * as readMod from "./read.js";
|
|
13
|
+
import * as globMod from "./glob.js";
|
|
14
|
+
|
|
15
|
+
/** Compressors keyed by tool name hint. */
|
|
16
|
+
const TOOL_COMPRESSORS = {
|
|
17
|
+
Grep: grepMod,
|
|
18
|
+
Read: readMod,
|
|
19
|
+
Glob: globMod
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Bash compressors tried in order of specificity. */
|
|
23
|
+
const BASH_COMPRESSORS = [
|
|
24
|
+
bashGit,
|
|
25
|
+
bashTest,
|
|
26
|
+
bashBuild,
|
|
27
|
+
bashInfra,
|
|
28
|
+
bashPkg,
|
|
29
|
+
bashMisc
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compress tool output deterministically.
|
|
34
|
+
* @param {string} text - raw tool output
|
|
35
|
+
* @param {string} [toolName] - tool name hint (e.g. "Bash", "Grep", "Read", "Glob")
|
|
36
|
+
* @returns {{ text: string, compressed: boolean }}
|
|
37
|
+
*/
|
|
38
|
+
export function compressDeterministic(text, toolName = "") {
|
|
39
|
+
if (!text || typeof text !== "string") return { text: text ?? "", compressed: false };
|
|
40
|
+
|
|
41
|
+
// Direct tool match
|
|
42
|
+
const directCompressor = TOOL_COMPRESSORS[toolName];
|
|
43
|
+
if (directCompressor && directCompressor.looksLike(text)) {
|
|
44
|
+
return { text: directCompressor.compact(text), compressed: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// For Bash or unknown tools, try all bash compressors in order
|
|
48
|
+
if (!toolName || toolName === "Bash") {
|
|
49
|
+
for (const compressor of BASH_COMPRESSORS) {
|
|
50
|
+
if (compressor.looksLike(text)) {
|
|
51
|
+
return { text: compressor.compact(text), compressed: true };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// No pattern matched
|
|
57
|
+
return { text, compressed: false };
|
|
58
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic compression for file read output.
|
|
3
|
+
* Truncates very long file contents while preserving structure.
|
|
4
|
+
*/
|
|
5
|
+
import { truncateLines, countTokens } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
const MAX_LINES = 200;
|
|
8
|
+
const TOKEN_THRESHOLD = 2000;
|
|
9
|
+
|
|
10
|
+
export function looksLike(text) {
|
|
11
|
+
// Read output is generic text; we use this as a fallback for very long content
|
|
12
|
+
// that looks like source code or structured data.
|
|
13
|
+
if (countTokens(text) < TOKEN_THRESHOLD) return false;
|
|
14
|
+
const lines = text.split("\n");
|
|
15
|
+
if (lines.length < 50) return false;
|
|
16
|
+
// Heuristic: numbered lines (cat -n style) or code-like content
|
|
17
|
+
return /^\s*\d+\t/.test(lines[0]) || /^(import |export |const |function |class |def |package )/.test(lines[0]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function compact(text) {
|
|
21
|
+
return truncateLines(text, MAX_LINES);
|
|
22
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for deterministic compression.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Strip ANSI escape sequences (colors, bold, etc.) */
|
|
6
|
+
export function stripAnsi(text) {
|
|
7
|
+
// eslint-disable-next-line no-control-regex
|
|
8
|
+
return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Collapse consecutive whitespace (spaces/tabs) into a single space per line. */
|
|
12
|
+
export function collapseWhitespace(text) {
|
|
13
|
+
return text
|
|
14
|
+
.split("\n")
|
|
15
|
+
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
|
16
|
+
.join("\n")
|
|
17
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Keep at most `max` lines, appending a truncation note. */
|
|
21
|
+
export function truncateLines(text, max = 100) {
|
|
22
|
+
const lines = text.split("\n");
|
|
23
|
+
if (lines.length <= max) return text;
|
|
24
|
+
const kept = lines.slice(0, max);
|
|
25
|
+
kept.push(`... (${lines.length - max} more lines truncated)`);
|
|
26
|
+
return kept.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Rough token estimate: chars / 4. */
|
|
30
|
+
export function countTokens(text) {
|
|
31
|
+
if (!text) return 0;
|
|
32
|
+
return Math.ceil(text.length / 4);
|
|
33
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compression pipeline orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* 5-step pipeline: extract → dedup → deterministic → ai-compress → rebuild.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { compressDeterministic } from "./deterministic/index.js";
|
|
11
|
+
import { countTokens } from "./deterministic/utils.js";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Hashing
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** SHA-256 hex digest of a string. */
|
|
18
|
+
function sha256(text) {
|
|
19
|
+
return createHash("sha256").update(text).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Adaptive pressure
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Estimate context pressure and return the token threshold above which
|
|
28
|
+
* tool_results should be compressed.
|
|
29
|
+
*
|
|
30
|
+
* @param {Array} messages - conversation messages
|
|
31
|
+
* @param {number} modelMaxTokens - model context window size in tokens
|
|
32
|
+
* @returns {{ ratio: number, threshold: number, level: string }}
|
|
33
|
+
*/
|
|
34
|
+
export function estimatePressure(messages, modelMaxTokens) {
|
|
35
|
+
const totalTokens = countTokens(JSON.stringify(messages));
|
|
36
|
+
const ratio = totalTokens / modelMaxTokens;
|
|
37
|
+
|
|
38
|
+
if (ratio > 0.9) return { ratio, threshold: 200, level: "critical" };
|
|
39
|
+
if (ratio > 0.8) return { ratio, threshold: 200, level: "high" };
|
|
40
|
+
if (ratio >= 0.5) return { ratio, threshold: 500, level: "medium" };
|
|
41
|
+
return { ratio, threshold: 2000, level: "low" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Caches
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** Cache: hash → compressed text (avoids re-compressing same content). */
|
|
49
|
+
const compressionCache = new Map();
|
|
50
|
+
|
|
51
|
+
/** Cache: hash → exact original string (preserves identical strings for KV cache warming). */
|
|
52
|
+
const sessionCache = new Map();
|
|
53
|
+
|
|
54
|
+
/** Reset caches (for testing). */
|
|
55
|
+
export function _resetCaches() {
|
|
56
|
+
compressionCache.clear();
|
|
57
|
+
sessionCache.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Deduplication
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Deduplicate tool results: if the same content (by hash) appeared in an
|
|
66
|
+
* earlier turn, replace with a back-reference.
|
|
67
|
+
*
|
|
68
|
+
* @param {Array<{id: string, toolName: string, text: string, turnIndex: number}>} results
|
|
69
|
+
* @returns {Map<string, string>} id → replacement text (only for deduped entries)
|
|
70
|
+
*/
|
|
71
|
+
function dedup(results) {
|
|
72
|
+
const seen = new Map(); // hash → first turnIndex
|
|
73
|
+
const replacements = new Map();
|
|
74
|
+
|
|
75
|
+
for (const r of results) {
|
|
76
|
+
const hash = sha256(r.text);
|
|
77
|
+
if (seen.has(hash)) {
|
|
78
|
+
const firstTurn = seen.get(hash);
|
|
79
|
+
if (r.turnIndex > firstTurn) {
|
|
80
|
+
replacements.set(r.id, `[See turn ${firstTurn}, unchanged]`);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
seen.set(hash, r.turnIndex);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return replacements;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Persist scheduler
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a debounced persistence scheduler for caches.
|
|
96
|
+
*
|
|
97
|
+
* @param {() => Promise<void>} flushFn - function that writes caches to disk
|
|
98
|
+
* @param {number} intervalMs - debounce interval in ms
|
|
99
|
+
* @returns {{ markDirty: () => void, flush: () => Promise<void>, destroy: () => void }}
|
|
100
|
+
*/
|
|
101
|
+
export function createPersistScheduler(flushFn, intervalMs = 30_000) {
|
|
102
|
+
let timer = null;
|
|
103
|
+
let dirty = false;
|
|
104
|
+
|
|
105
|
+
const flush = async () => {
|
|
106
|
+
if (timer) {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
timer = null;
|
|
109
|
+
}
|
|
110
|
+
dirty = false;
|
|
111
|
+
await flushFn();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const markDirty = () => {
|
|
115
|
+
dirty = true;
|
|
116
|
+
if (timer) clearTimeout(timer);
|
|
117
|
+
timer = setTimeout(() => flush(), intervalMs);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const onSignal = () => {
|
|
121
|
+
if (dirty) {
|
|
122
|
+
flush();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
process.on("SIGTERM", onSignal);
|
|
127
|
+
process.on("SIGINT", onSignal);
|
|
128
|
+
|
|
129
|
+
const destroy = () => {
|
|
130
|
+
if (timer) {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
timer = null;
|
|
133
|
+
}
|
|
134
|
+
process.removeListener("SIGTERM", onSignal);
|
|
135
|
+
process.removeListener("SIGINT", onSignal);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return { markDirty, flush, destroy };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Main pipeline
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compress tool_results in a request body using the 5-step pipeline.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} body - parsed JSON request body
|
|
149
|
+
* @param {object} adapter - provider adapter (extractToolResults, rebuildMessages)
|
|
150
|
+
* @param {object} [config={}] - pipeline configuration
|
|
151
|
+
* @param {number} [config.modelMaxTokens=200000] - model context window
|
|
152
|
+
* @param {boolean} [config.aiEnabled=false] - whether AI compression is enabled
|
|
153
|
+
* @param {Function} [config.compressWithAI] - AI compression function(text, toolName) → string
|
|
154
|
+
* @returns {Promise<{ body: object, stats: object }>}
|
|
155
|
+
*/
|
|
156
|
+
export async function compressRequest(body, adapter, config = {}) {
|
|
157
|
+
const {
|
|
158
|
+
modelMaxTokens = 200_000,
|
|
159
|
+
aiEnabled = false,
|
|
160
|
+
compressWithAI = null,
|
|
161
|
+
} = config;
|
|
162
|
+
|
|
163
|
+
const messages = body.messages || [];
|
|
164
|
+
|
|
165
|
+
const stats = {
|
|
166
|
+
originalTokens: 0,
|
|
167
|
+
compressedTokens: 0,
|
|
168
|
+
cacheHits: 0,
|
|
169
|
+
deterministicHits: 0,
|
|
170
|
+
aiHits: 0,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Step 1: Extract
|
|
174
|
+
const toolResults = adapter.extractToolResults(messages);
|
|
175
|
+
if (!toolResults || toolResults.length === 0) {
|
|
176
|
+
return { body, stats };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Adaptive pressure
|
|
180
|
+
const { threshold } = estimatePressure(messages, modelMaxTokens);
|
|
181
|
+
|
|
182
|
+
// Step 2: Dedup
|
|
183
|
+
const dedupMap = dedup(toolResults);
|
|
184
|
+
|
|
185
|
+
// Build compressed map: id → compressed text
|
|
186
|
+
const compressedMap = new Map();
|
|
187
|
+
|
|
188
|
+
for (const result of toolResults) {
|
|
189
|
+
const originalTokens = countTokens(result.text);
|
|
190
|
+
stats.originalTokens += originalTokens;
|
|
191
|
+
|
|
192
|
+
// Deduped?
|
|
193
|
+
if (dedupMap.has(result.id)) {
|
|
194
|
+
const replacement = dedupMap.get(result.id);
|
|
195
|
+
compressedMap.set(result.id, replacement);
|
|
196
|
+
stats.compressedTokens += countTokens(replacement);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Below threshold? Keep original
|
|
201
|
+
if (originalTokens < threshold) {
|
|
202
|
+
stats.compressedTokens += originalTokens;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const hash = sha256(result.text);
|
|
207
|
+
|
|
208
|
+
// Check compression cache
|
|
209
|
+
if (compressionCache.has(hash)) {
|
|
210
|
+
const cached = compressionCache.get(hash);
|
|
211
|
+
compressedMap.set(result.id, cached);
|
|
212
|
+
stats.compressedTokens += countTokens(cached);
|
|
213
|
+
stats.cacheHits += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check session cache (exact string reuse for KV warming)
|
|
218
|
+
if (sessionCache.has(hash)) {
|
|
219
|
+
stats.compressedTokens += originalTokens;
|
|
220
|
+
stats.cacheHits += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Step 3: Deterministic compression
|
|
225
|
+
let compressed = result.text;
|
|
226
|
+
let wasCompressed = false;
|
|
227
|
+
|
|
228
|
+
const det = compressDeterministic(result.text, result.toolName);
|
|
229
|
+
if (det.compressed) {
|
|
230
|
+
compressed = det.text;
|
|
231
|
+
wasCompressed = true;
|
|
232
|
+
stats.deterministicHits += 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Step 4: AI compression (optional)
|
|
236
|
+
const compressedTokens = countTokens(compressed);
|
|
237
|
+
if (
|
|
238
|
+
aiEnabled &&
|
|
239
|
+
compressWithAI &&
|
|
240
|
+
compressedTokens >= threshold &&
|
|
241
|
+
!wasCompressed
|
|
242
|
+
) {
|
|
243
|
+
try {
|
|
244
|
+
const aiResult = await compressWithAI(compressed, result.toolName);
|
|
245
|
+
if (aiResult && countTokens(aiResult) < compressedTokens) {
|
|
246
|
+
compressed = aiResult;
|
|
247
|
+
stats.aiHits += 1;
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// AI compression failed; keep deterministic result
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (compressed !== result.text) {
|
|
255
|
+
compressedMap.set(result.id, compressed);
|
|
256
|
+
compressionCache.set(hash, compressed);
|
|
257
|
+
stats.compressedTokens += countTokens(compressed);
|
|
258
|
+
} else {
|
|
259
|
+
// Not compressed — store in session cache for KV warming
|
|
260
|
+
sessionCache.set(hash, result.text);
|
|
261
|
+
stats.compressedTokens += originalTokens;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Step 5: Rebuild
|
|
266
|
+
const rebuiltMessages = adapter.rebuildMessages(messages, compressedMap);
|
|
267
|
+
const newBody = { ...body, messages: rebuiltMessages };
|
|
268
|
+
|
|
269
|
+
return { body: newBody, stats };
|
|
270
|
+
}
|