karajan-code 1.58.1 → 1.59.0
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 +3 -0
- 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/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/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/orchestrator.js +67 -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 +198 -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/reviewer-role.js +6 -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/display.js +44 -4
- package/src/utils/git.js +5 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Injection middleware for the KJ proxy.
|
|
3
|
+
*
|
|
4
|
+
* Intercepts API requests and injects additional content into the system
|
|
5
|
+
* prompt on-the-fly, based on registered injection fragments.
|
|
6
|
+
*
|
|
7
|
+
* Supports Anthropic, OpenAI, and Gemini provider formats.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registry that holds keyed prompt-injection fragments.
|
|
12
|
+
* Fragments are concatenated (newline-separated) when retrieved.
|
|
13
|
+
*/
|
|
14
|
+
export class PromptInjectionRegistry {
|
|
15
|
+
/** @type {Map<string, string>} */
|
|
16
|
+
#entries = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register a prompt fragment under the given key.
|
|
20
|
+
* Overwrites any previous value for that key.
|
|
21
|
+
* @param {string} key
|
|
22
|
+
* @param {string} content
|
|
23
|
+
*/
|
|
24
|
+
register(key, content) {
|
|
25
|
+
this.#entries.set(key, content);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Remove a previously registered fragment.
|
|
30
|
+
* @param {string} key
|
|
31
|
+
*/
|
|
32
|
+
unregister(key) {
|
|
33
|
+
this.#entries.delete(key);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Return all registered fragments concatenated with newlines.
|
|
38
|
+
* Returns empty string when registry is empty.
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
getAll() {
|
|
42
|
+
if (this.#entries.size === 0) return "";
|
|
43
|
+
return [...this.#entries.values()].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove all registered fragments. */
|
|
47
|
+
clear() {
|
|
48
|
+
this.#entries.clear();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Inject content into the system prompt for a given provider.
|
|
54
|
+
* Mutates the parsed body object in-place.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} parsed - Parsed JSON request body
|
|
57
|
+
* @param {string} content - Content to inject
|
|
58
|
+
* @param {string} provider - "anthropic" | "openai" | "gemini"
|
|
59
|
+
*/
|
|
60
|
+
function injectSystemPrompt(parsed, content, provider) {
|
|
61
|
+
switch (provider) {
|
|
62
|
+
case "anthropic":
|
|
63
|
+
injectAnthropic(parsed, content);
|
|
64
|
+
break;
|
|
65
|
+
case "openai":
|
|
66
|
+
injectOpenAI(parsed, content);
|
|
67
|
+
break;
|
|
68
|
+
case "gemini":
|
|
69
|
+
injectGemini(parsed, content);
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
// Unknown provider — attempt OpenAI-style as a sensible default
|
|
73
|
+
injectOpenAI(parsed, content);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Anthropic: body.system is either a string or an array of content blocks.
|
|
80
|
+
*/
|
|
81
|
+
function injectAnthropic(parsed, content) {
|
|
82
|
+
if (typeof parsed.system === "string") {
|
|
83
|
+
parsed.system = parsed.system + "\n" + content;
|
|
84
|
+
} else if (Array.isArray(parsed.system)) {
|
|
85
|
+
parsed.system.push({ type: "text", text: content });
|
|
86
|
+
} else {
|
|
87
|
+
// No system prompt yet — create one
|
|
88
|
+
parsed.system = content;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* OpenAI: messages[0] with role "system". Append to its content.
|
|
94
|
+
*/
|
|
95
|
+
function injectOpenAI(parsed, content) {
|
|
96
|
+
if (!Array.isArray(parsed.messages) || parsed.messages.length === 0) {
|
|
97
|
+
parsed.messages = [{ role: "system", content }];
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const first = parsed.messages[0];
|
|
102
|
+
if (first.role === "system") {
|
|
103
|
+
first.content = (first.content || "") + "\n" + content;
|
|
104
|
+
} else {
|
|
105
|
+
// No system message — prepend one
|
|
106
|
+
parsed.messages.unshift({ role: "system", content });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Gemini: body.systemInstruction.parts — append a new text part.
|
|
112
|
+
*/
|
|
113
|
+
function injectGemini(parsed, content) {
|
|
114
|
+
if (!parsed.systemInstruction) {
|
|
115
|
+
parsed.systemInstruction = { parts: [{ text: content }] };
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!Array.isArray(parsed.systemInstruction.parts)) {
|
|
120
|
+
parsed.systemInstruction.parts = [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
parsed.systemInstruction.parts.push({ text: content });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a proxy middleware that injects prompt fragments from the registry
|
|
128
|
+
* into every API request's system prompt.
|
|
129
|
+
*
|
|
130
|
+
* @param {PromptInjectionRegistry} registry
|
|
131
|
+
* @returns {(ctx: object, next: () => Promise<void>) => Promise<void>}
|
|
132
|
+
*/
|
|
133
|
+
export function createPromptInjector(registry) {
|
|
134
|
+
return async function promptInjectorMiddleware(ctx, next) {
|
|
135
|
+
const content = registry.getAll();
|
|
136
|
+
|
|
137
|
+
// Nothing to inject — pass through unchanged
|
|
138
|
+
if (!content) {
|
|
139
|
+
return next();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Only intercept POST requests with a body
|
|
143
|
+
if (ctx.req.method !== "POST" || !ctx.body) {
|
|
144
|
+
return next();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(ctx.body);
|
|
150
|
+
} catch {
|
|
151
|
+
// Not valid JSON — pass through
|
|
152
|
+
return next();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
injectSystemPrompt(parsed, content, ctx.provider);
|
|
156
|
+
|
|
157
|
+
const modified = JSON.stringify(parsed);
|
|
158
|
+
ctx.modifiedBody = modified;
|
|
159
|
+
|
|
160
|
+
// Recalculate Content-Length for the modified body
|
|
161
|
+
ctx.req.headers["content-length"] = String(Buffer.byteLength(modified));
|
|
162
|
+
|
|
163
|
+
return next();
|
|
164
|
+
};
|
|
165
|
+
}
|