karajan-code 1.59.0 → 2.0.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/README.md +2 -0
- package/package.json +1 -1
- package/scripts/install.js +18 -15
- package/src/agents/aider-agent.js +0 -7
- package/src/agents/claude-agent.js +34 -6
- package/src/agents/codex-agent.js +2 -6
- package/src/agents/gemini-agent.js +0 -4
- package/src/agents/opencode-agent.js +0 -4
- package/src/{becaria → ci}/dispatch.js +13 -13
- package/src/{becaria → ci}/pr-diff.js +1 -1
- package/src/cli.js +3 -2
- package/src/commands/agents.js +0 -1
- package/src/commands/doctor.js +22 -40
- package/src/commands/init.js +8 -8
- package/src/commands/report.js +0 -13
- package/src/commands/review.js +17 -17
- package/src/commands/run.js +1 -2
- package/src/commands/status.js +1 -7
- package/src/config.js +10 -52
- package/src/domains/domain-loader.js +85 -4
- package/src/git/automation.js +9 -9
- package/src/guards/output-guard.js +0 -17
- package/src/mcp/handlers/direct-handlers.js +1 -1
- package/src/mcp/handlers/run-handler.js +1 -2
- package/src/mcp/sovereignty-guard.js +2 -2
- package/src/mcp/tools.js +3 -2
- package/src/orchestrator/brain-coordinator.js +165 -0
- package/src/orchestrator/ci-integration.js +139 -0
- package/src/orchestrator/config-init.js +435 -0
- package/src/orchestrator/direct-actions.js +186 -0
- package/src/orchestrator/feedback-enrichment.js +140 -0
- package/src/orchestrator/feedback-queue.js +152 -0
- package/src/orchestrator/flow-control.js +159 -0
- package/src/orchestrator/post-loop-stages.js +20 -15
- package/src/orchestrator/preflight-checks.js +7 -3
- package/src/orchestrator/role-output-compressor.js +190 -0
- package/src/orchestrator/session-journal.js +344 -0
- package/src/orchestrator/solomon-escalation.js +38 -5
- package/src/orchestrator/solomon-rules.js +32 -33
- package/src/orchestrator/stages/reviewer-stage.js +16 -5
- package/src/orchestrator/stages/sonar-stage.js +24 -1
- package/src/orchestrator/verification-gate.js +146 -0
- package/src/orchestrator.js +435 -661
- package/src/planning-game/client.js +2 -2
- package/src/planning-game/pipeline-adapter.js +1 -1
- package/src/prompts/coder.js +17 -3
- package/src/prompts/reviewer.js +2 -2
- package/src/prompts/rtk-snippet.js +3 -5
- package/src/review/diff-generator.js +66 -17
- package/src/review/snapshot-diff.js +122 -0
- package/src/review/tdd-policy.js +4 -2
- package/src/roles/agent-role.js +135 -0
- package/src/roles/architect-role.js +61 -108
- package/src/roles/audit-role.js +25 -71
- package/src/roles/coder-role.js +32 -57
- package/src/roles/discover-role.js +30 -71
- package/src/roles/hu-reviewer-role.js +51 -99
- package/src/roles/impeccable-role.js +34 -91
- package/src/roles/index.js +2 -0
- package/src/roles/karajan-brain-role.js +134 -0
- package/src/roles/planner-role.js +40 -75
- package/src/roles/refactorer-role.js +22 -64
- package/src/roles/researcher-role.js +52 -118
- package/src/roles/reviewer-role.js +69 -126
- package/src/roles/security-role.js +38 -102
- package/src/roles/solomon-role.js +71 -15
- package/src/roles/tester-role.js +121 -95
- package/src/roles/triage-role.js +50 -111
- package/src/session-store.js +1 -1
- package/src/skills/skill-detector.js +11 -0
- package/src/sonar/config-resolver.js +20 -1
- package/src/sonar/credentials.js +17 -0
- package/src/sonar/project-key.js +1 -1
- package/src/sonar/scanner.js +1 -1
- package/src/utils/banner.js +66 -0
- package/src/utils/display.js +35 -17
- package/src/utils/injection-guard.js +23 -8
- package/src/utils/logger.js +6 -1
- package/src/utils/process.js +3 -4
- package/src/utils/role-assigner.js +131 -0
- package/src/utils/update-check.js +5 -1
- package/src/utils/welcome.js +3 -8
- package/src/webperf/devtools-detect.js +70 -23
- package/templates/roles/coder.md +7 -0
- package/templates/roles/karajan-brain.md +95 -0
- package/templates/roles/solomon.md +67 -106
- package/templates/workflows/automerge.yml +4 -4
- package/templates/workflows/houston-override.yml +4 -4
- package/templates/workflows/{becaria-gateway.yml → kj-ci-gateway.yml} +12 -12
- package/src/proxy/adapters/anthropic.js +0 -131
- package/src/proxy/adapters/gemini.js +0 -106
- package/src/proxy/adapters/index.js +0 -51
- package/src/proxy/adapters/openai.js +0 -71
- package/src/proxy/compression/ai-compressor.js +0 -209
- package/src/proxy/compression/dedup-cache.js +0 -100
- package/src/proxy/compression/deterministic/bash-build.js +0 -60
- package/src/proxy/compression/deterministic/bash-git.js +0 -101
- package/src/proxy/compression/deterministic/bash-infra.js +0 -64
- package/src/proxy/compression/deterministic/bash-misc.js +0 -53
- package/src/proxy/compression/deterministic/bash-pkg.js +0 -49
- package/src/proxy/compression/deterministic/bash-test.js +0 -61
- package/src/proxy/compression/deterministic/glob.js +0 -26
- package/src/proxy/compression/deterministic/grep.js +0 -53
- package/src/proxy/compression/deterministic/index.js +0 -58
- package/src/proxy/compression/deterministic/read.js +0 -22
- package/src/proxy/compression/deterministic/utils.js +0 -33
- package/src/proxy/compression/pipeline.js +0 -270
- package/src/proxy/middleware/prompt-injector.js +0 -165
- package/src/proxy/middleware/response-interceptor.js +0 -223
- package/src/proxy/progress-monitor.js +0 -98
- package/src/proxy/proxy-lifecycle.js +0 -198
- package/src/proxy/proxy-server.js +0 -239
- package/src/proxy/security.js +0 -45
- /package/src/{becaria → ci}/index.js +0 -0
- /package/src/{becaria → ci}/repo.js +0 -0
|
@@ -1,270 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,165 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from "node:events";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Create a response interceptor middleware that extracts tool calls, usage data,
|
|
5
|
-
* and text content from AI provider responses without buffering.
|
|
6
|
-
*
|
|
7
|
-
* For SSE (text/event-stream) responses, it parses each "data: " line inline.
|
|
8
|
-
* For non-streaming JSON responses, it parses the completed body.
|
|
9
|
-
*
|
|
10
|
-
* Events emitted on the provided EventEmitter:
|
|
11
|
-
* - tool_call {name, id}
|
|
12
|
-
* - tool_call_complete {id}
|
|
13
|
-
* - text_delta {text}
|
|
14
|
-
* - usage {input_tokens, output_tokens}
|
|
15
|
-
* - message_complete {}
|
|
16
|
-
*
|
|
17
|
-
* @param {object} [config]
|
|
18
|
-
* @param {EventEmitter} [config.emitter] - EventEmitter to emit events on (created if not provided)
|
|
19
|
-
* @returns {{ middleware: (ctx: object, next: () => Promise<void>) => Promise<void>, emitter: EventEmitter }}
|
|
20
|
-
*/
|
|
21
|
-
export function createResponseInterceptor(config = {}) {
|
|
22
|
-
const emitter = config.emitter || new EventEmitter();
|
|
23
|
-
|
|
24
|
-
// Track accumulated tool input per tool id
|
|
25
|
-
const toolInputBuffers = new Map();
|
|
26
|
-
|
|
27
|
-
function handleSSELine(line) {
|
|
28
|
-
if (!line.startsWith("data: ")) return;
|
|
29
|
-
const payload = line.slice(6).trim();
|
|
30
|
-
if (payload === "[DONE]") return;
|
|
31
|
-
|
|
32
|
-
let event;
|
|
33
|
-
try {
|
|
34
|
-
event = JSON.parse(payload);
|
|
35
|
-
} catch {
|
|
36
|
-
return; // Malformed JSON, skip
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
processAnthropicEvent(event);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function processAnthropicEvent(event) {
|
|
43
|
-
const type = event.type;
|
|
44
|
-
|
|
45
|
-
if (type === "content_block_start") {
|
|
46
|
-
const block = event.content_block;
|
|
47
|
-
if (block && block.type === "tool_use") {
|
|
48
|
-
toolInputBuffers.set(block.id, "");
|
|
49
|
-
emitter.emit("tool_call", { name: block.name, id: block.id });
|
|
50
|
-
}
|
|
51
|
-
} else if (type === "content_block_delta") {
|
|
52
|
-
const delta = event.delta;
|
|
53
|
-
if (delta && delta.type === "input_json_delta") {
|
|
54
|
-
const id = findActiveToolId(event);
|
|
55
|
-
if (id && toolInputBuffers.has(id)) {
|
|
56
|
-
toolInputBuffers.set(id, toolInputBuffers.get(id) + (delta.partial_json || ""));
|
|
57
|
-
}
|
|
58
|
-
} else if (delta && delta.type === "text_delta") {
|
|
59
|
-
emitter.emit("text_delta", { text: delta.text || "" });
|
|
60
|
-
}
|
|
61
|
-
} else if (type === "content_block_stop") {
|
|
62
|
-
const id = findActiveToolId(event);
|
|
63
|
-
if (id && toolInputBuffers.has(id)) {
|
|
64
|
-
toolInputBuffers.delete(id);
|
|
65
|
-
emitter.emit("tool_call_complete", { id });
|
|
66
|
-
}
|
|
67
|
-
} else if (type === "message_delta") {
|
|
68
|
-
if (event.usage) {
|
|
69
|
-
emitter.emit("usage", {
|
|
70
|
-
input_tokens: event.usage.input_tokens,
|
|
71
|
-
output_tokens: event.usage.output_tokens,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
} else if (type === "message_start") {
|
|
75
|
-
if (event.message && event.message.usage) {
|
|
76
|
-
emitter.emit("usage", {
|
|
77
|
-
input_tokens: event.message.usage.input_tokens,
|
|
78
|
-
output_tokens: event.message.usage.output_tokens,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
} else if (type === "message_stop") {
|
|
82
|
-
emitter.emit("message_complete", {});
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Resolve the tool ID from the event. Anthropic events use `index` to refer
|
|
88
|
-
* to the content block, but we track by id. For events with an index, we map
|
|
89
|
-
* it to the id stored at creation time. To keep things simple and avoid
|
|
90
|
-
* maintaining an index->id map, we accept an explicit `id` field if present
|
|
91
|
-
* or fall back to the first (and often only) active tool.
|
|
92
|
-
*/
|
|
93
|
-
function findActiveToolId(event) {
|
|
94
|
-
if (event.id) return event.id;
|
|
95
|
-
// Fall back to the first active tool (works for single-tool scenarios)
|
|
96
|
-
const keys = [...toolInputBuffers.keys()];
|
|
97
|
-
return keys.length > 0 ? keys[keys.length - 1] : null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Process a non-streaming JSON response body.
|
|
102
|
-
*/
|
|
103
|
-
function processJsonBody(bodyStr) {
|
|
104
|
-
let body;
|
|
105
|
-
try {
|
|
106
|
-
body = JSON.parse(bodyStr);
|
|
107
|
-
} catch {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Extract tool_use blocks from content array
|
|
112
|
-
if (Array.isArray(body.content)) {
|
|
113
|
-
for (const block of body.content) {
|
|
114
|
-
if (block.type === "tool_use") {
|
|
115
|
-
emitter.emit("tool_call", { name: block.name, id: block.id });
|
|
116
|
-
emitter.emit("tool_call_complete", { id: block.id });
|
|
117
|
-
} else if (block.type === "text") {
|
|
118
|
-
emitter.emit("text_delta", { text: block.text || "" });
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Extract usage
|
|
124
|
-
if (body.usage) {
|
|
125
|
-
emitter.emit("usage", {
|
|
126
|
-
input_tokens: body.usage.input_tokens,
|
|
127
|
-
output_tokens: body.usage.output_tokens,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
emitter.emit("message_complete", {});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Middleware function compatible with the proxy server's Koa-style middleware.
|
|
136
|
-
* Wraps ctx.res.write and ctx.res.end to tap into the response stream.
|
|
137
|
-
*/
|
|
138
|
-
async function middleware(ctx, next) {
|
|
139
|
-
const origWrite = ctx.res.write.bind(ctx.res);
|
|
140
|
-
const origEnd = ctx.res.end.bind(ctx.res);
|
|
141
|
-
const origWriteHead = ctx.res.writeHead.bind(ctx.res);
|
|
142
|
-
|
|
143
|
-
let isSSE = false;
|
|
144
|
-
let isJSON = false;
|
|
145
|
-
let lineBuf = ""; // Buffer for partial SSE lines across chunks
|
|
146
|
-
const jsonChunks = []; // Accumulate non-streaming JSON body
|
|
147
|
-
|
|
148
|
-
function processSSEChunk(text) {
|
|
149
|
-
lineBuf += text;
|
|
150
|
-
const lines = lineBuf.split("\n");
|
|
151
|
-
// Keep the last element as it may be incomplete
|
|
152
|
-
lineBuf = lines.pop() || "";
|
|
153
|
-
for (const line of lines) {
|
|
154
|
-
const trimmed = line.trim();
|
|
155
|
-
if (trimmed) {
|
|
156
|
-
handleSSELine(trimmed);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Intercept writeHead to detect content-type
|
|
162
|
-
ctx.res.writeHead = function interceptWriteHead(statusCode, ...args) {
|
|
163
|
-
// headers can be in args[0] (object) or args[1] with reasonPhrase in args[0]
|
|
164
|
-
const headers = typeof args[0] === "object" ? args[0] : args[1];
|
|
165
|
-
const ct = findHeader(headers, "content-type") || "";
|
|
166
|
-
isSSE = ct.includes("text/event-stream");
|
|
167
|
-
isJSON = ct.includes("application/json");
|
|
168
|
-
return origWriteHead(statusCode, ...args);
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
// Intercept write to parse chunks inline
|
|
172
|
-
ctx.res.write = function interceptWrite(chunk, encoding, callback) {
|
|
173
|
-
if (isSSE) {
|
|
174
|
-
processSSEChunk(typeof chunk === "string" ? chunk : chunk.toString());
|
|
175
|
-
} else if (isJSON) {
|
|
176
|
-
jsonChunks.push(typeof chunk === "string" ? chunk : chunk.toString());
|
|
177
|
-
}
|
|
178
|
-
return origWrite(chunk, encoding, callback);
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
// Intercept end to handle final chunk and JSON body
|
|
182
|
-
ctx.res.end = function interceptEnd(chunk, encoding, callback) {
|
|
183
|
-
if (chunk) {
|
|
184
|
-
if (isSSE) {
|
|
185
|
-
processSSEChunk(typeof chunk === "string" ? chunk : chunk.toString());
|
|
186
|
-
} else if (isJSON) {
|
|
187
|
-
jsonChunks.push(typeof chunk === "string" ? chunk : chunk.toString());
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// Flush remaining SSE buffer
|
|
191
|
-
if (isSSE && lineBuf.trim()) {
|
|
192
|
-
handleSSELine(lineBuf.trim());
|
|
193
|
-
lineBuf = "";
|
|
194
|
-
}
|
|
195
|
-
// Process complete JSON body
|
|
196
|
-
if (isJSON && jsonChunks.length > 0) {
|
|
197
|
-
processJsonBody(jsonChunks.join(""));
|
|
198
|
-
}
|
|
199
|
-
return origEnd(chunk, encoding, callback);
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
await next();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function findHeader(headers, name) {
|
|
206
|
-
if (!headers) return undefined;
|
|
207
|
-
// Headers might be an object or an array of [key, value] pairs
|
|
208
|
-
if (Array.isArray(headers)) {
|
|
209
|
-
for (let i = 0; i < headers.length; i += 2) {
|
|
210
|
-
if (typeof headers[i] === "string" && headers[i].toLowerCase() === name) {
|
|
211
|
-
return headers[i + 1];
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return undefined;
|
|
215
|
-
}
|
|
216
|
-
for (const key of Object.keys(headers)) {
|
|
217
|
-
if (key.toLowerCase() === name) return headers[key];
|
|
218
|
-
}
|
|
219
|
-
return undefined;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return { middleware, emitter };
|
|
223
|
-
}
|