-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
3406 lines (3166 loc) · 135 KB
/
server.js
File metadata and controls
3406 lines (3166 loc) · 135 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const express = require("express");
const { query } = require("@anthropic-ai/claude-agent-sdk");
const { randomUUID, createHash } = require("crypto");
const fs = require("fs");
const fsp = require("fs/promises");
const os = require("os");
const path = require("path");
const app = express();
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
app.use(express.json({ limit: "50mb" }));
const PORT = process.env.PORT || 4001;
const VERBOSE =
process.argv.includes("--verbose") || process.env.VERBOSE === "1";
const DEFAULT_MODEL = process.env.CLAUDE_MODEL || "sonnet";
const LOG_REQUESTS = process.env.LOG_REQUESTS === "1";
const THINKING_BUDGETS = { off: 0, low: 1024, medium: 8000, high: 16000, max: 32000 };
const THINKING = (process.env.CLAUDE_THINKING || "high").toLowerCase();
const MAX_THINKING_TOKENS = THINKING_BUDGETS[THINKING] ?? THINKING_BUDGETS.high;
const SESSION_MAX = parseInt(process.env.CLAUDE_SESSION_MAX || "20", 10);
const SESSION_TTL_MS = parseInt(process.env.CLAUDE_SESSION_TTL_MS || (30 * 60 * 1000).toString(), 10);
const SESSIONS_ENABLED = process.env.CLAUDE_SESSIONS !== "0";
const REQUEST_TIMEOUT_MS = parseInt(process.env.CLAUDE_REQUEST_TIMEOUT_MS || "600000", 10);
const IDLE_TIMEOUT_MS = parseInt(process.env.CLAUDE_IDLE_TIMEOUT_MS || "120000", 10);
const FALLBACK_ENABLED = process.env.CLAUDE_FALLBACK !== "0";
const INCLUDE_GLOBAL_NOTES = process.env.INCLUDE_GLOBAL_NOTES !== "0";
const ENABLE_WEB_SEARCH = process.env.ENABLE_WEB_SEARCH === "1";
// Built-in Claude Agent SDK tools we expose to the model. File-search tools
// (Read/Glob/Grep) are on by default so the model can answer questions about
// the user's filesystem without being limited to whatever the IDE exposes.
// Bash and Write/Edit are opt-in because they're more powerful.
// ENABLE_FS_TOOLS=0 drops Read/Glob/Grep
// ENABLE_BASH_TOOL=1 adds Bash (shell exec)
// ENABLE_WRITE_TOOLS=1 adds Write + Edit (mutating filesystem ops)
// EXTRA_SDK_TOOLS=a,b appends arbitrary tool names (advanced)
const ENABLE_FS_TOOLS = process.env.ENABLE_FS_TOOLS !== "0";
const ENABLE_BASH_TOOL = process.env.ENABLE_BASH_TOOL === "1";
const ENABLE_WRITE_TOOLS = process.env.ENABLE_WRITE_TOOLS === "1";
const EXTRA_SDK_TOOLS = (process.env.EXTRA_SDK_TOOLS || "")
.split(",").map((s) => s.trim()).filter(Boolean);
const SDK_TOOLS = [
...(ENABLE_FS_TOOLS ? ["Read", "Glob", "Grep"] : []),
...(ENABLE_BASH_TOOL ? ["Bash"] : []),
...(ENABLE_WRITE_TOOLS ? ["Write", "Edit"] : []),
...(ENABLE_WEB_SEARCH ? ["WebSearch"] : []),
...EXTRA_SDK_TOOLS,
];
// Subset actually registered with the SDK as native tools. Read/Glob/Grep are
// stripped because the proxy executes them locally via the <tool_call> text
// protocol (see runInternalTool + dispatchRun's internal-tool loop). Anything
// else (WebSearch, Bash, Write, Edit, EXTRA_SDK_TOOLS entries) stays native.
const SDK_NATIVE_TOOLS = SDK_TOOLS.filter((t) => !["Read", "Glob", "Grep"].includes(t));
// `additionalDirectories` widens file access beyond the proxy's cwd. By default
// we enumerate every existing drive root on Windows (C:\, D:\, …) or `/` on
// Unix so file search isn't artificially capped at the IDE workspace. Override
// with FS_ADDITIONAL_DIRS=C:\,D:\Projects (comma-separated) — set to an empty
// string to disable (only cwd will be reachable).
function defaultAdditionalDirs() {
if (process.platform !== "win32") return ["/"];
const drives = [];
for (let c = 65; c <= 90; c++) {
const root = String.fromCharCode(c) + ":\\";
try { if (fs.existsSync(root)) drives.push(root); } catch (e) {}
}
return drives;
}
const ADDITIONAL_DIRS = process.env.FS_ADDITIONAL_DIRS !== undefined
? process.env.FS_ADDITIONAL_DIRS.split(",").map((s) => s.trim()).filter(Boolean)
: defaultAdditionalDirs();
// Internal tools — proxy-side executors for Read/Glob/Grep. The model emits
// these via the same <tool_call> text protocol used for IDE tools, but the
// proxy intercepts those names, runs the implementation locally, and feeds the
// result back into the SDK session as a synthetic tool_result. This is what
// actually delivers FS access to drives outside the IDE workspace — registering
// them on the SDK side did nothing because the system prompt forces the model
// to use the text protocol, which black-holes at the IDE (no matching name).
const INTERNAL_TOOL_NAMES = new Set();
const INTERNAL_TOOL_DEFINITIONS = [];
if (ENABLE_FS_TOOLS) {
INTERNAL_TOOL_DEFINITIONS.push(
{
name: "Read",
description: "Read a file from the local filesystem. Returns text contents with 1-indexed line numbers (cat -n style). Absolute paths only. Honors the proxy's filesystem access scope.",
parameters: {
type: "object",
properties: {
file_path: { type: "string", description: "Absolute path to the file (e.g. 'D:/Projects/foo.js' or 'D:\\\\Projects\\\\foo.js')" },
offset: { type: "integer", description: "0-indexed line to start at (optional)" },
limit: { type: "integer", description: "Max number of lines to return (optional)" },
},
required: ["file_path"],
},
},
{
name: "Glob",
description: "Find files by glob pattern. Supports **, *, ?, [chars]. Absolute patterns only. Returns matching paths sorted by mtime (newest first).",
parameters: {
type: "object",
properties: {
pattern: { type: "string", description: "Absolute glob pattern, e.g. 'D:/Projects/**/*.js'. May also pass a relative pattern with an absolute 'path'." },
path: { type: "string", description: "Optional absolute base directory; pattern is resolved relative to this." },
},
required: ["pattern"],
},
},
{
name: "LS",
description: "List a directory's immediate contents (subdirectories and files). Use this — NOT the IDE's list_dir — for paths outside the IDE workspace.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Absolute directory path (e.g. 'D:/Projects/perception/dbd')" },
},
required: ["path"],
},
},
{
name: "Grep",
description: "Regex search across file contents. Walks the given directory (or single file). Honors the proxy's filesystem access scope.",
parameters: {
type: "object",
properties: {
pattern: { type: "string", description: "JavaScript regular expression" },
path: { type: "string", description: "Absolute file or directory to search" },
glob: { type: "string", description: "Optional file-name glob filter (e.g. '*.js')" },
output_mode: { type: "string", enum: ["content", "files_with_matches", "count"], description: "Default: files_with_matches" },
"-i": { type: "boolean", description: "Case-insensitive" },
"-n": { type: "boolean", description: "Show line numbers (content mode, default true)" },
"-A": { type: "integer", description: "Lines after match (content mode)" },
"-B": { type: "integer", description: "Lines before match (content mode)" },
"-C": { type: "integer", description: "Lines before AND after (content mode)" },
head_limit: { type: "integer", description: "Max results (default 200)" },
},
required: ["pattern", "path"],
},
},
);
for (const t of INTERNAL_TOOL_DEFINITIONS) INTERNAL_TOOL_NAMES.add(t.name);
}
const MAX_INTERNAL_READ_BYTES = parseInt(process.env.INTERNAL_READ_MAX_BYTES || (2 * 1024 * 1024).toString(), 10);
const MAX_INTERNAL_GLOB_RESULTS = parseInt(process.env.INTERNAL_GLOB_MAX || "1000", 10);
const MAX_INTERNAL_GREP_RESULTS = parseInt(process.env.INTERNAL_GREP_MAX || "200", 10);
const MAX_INTERNAL_RESULT_CHARS = parseInt(process.env.INTERNAL_RESULT_MAX_CHARS || "100000", 10);
const MAX_INTERNAL_WALK_DEPTH = parseInt(process.env.INTERNAL_WALK_DEPTH || "12", 10);
// Local-tool loop cap. Real RE/analysis work routinely needs 15-25 targeted
// reads + greps before the model has enough context to produce code. Override
// with MAX_INTERNAL_TURNS in .env if you're hitting this — e.g. =40 for very
// deep dives, =5 to fail-fast during prompt experiments.
const MAX_INTERNAL_TURNS = parseInt(process.env.MAX_INTERNAL_TURNS || "20", 10);
const INTERNAL_TOOL_TIMEOUT_MS = parseInt(process.env.INTERNAL_TOOL_TIMEOUT_MS || "60000", 10);
const SKIP_DIR_NAMES = new Set([
"node_modules", ".git", ".hg", ".svn", "$RECYCLE.BIN",
"System Volume Information", "AppData", "$Recycle.Bin",
]);
function globToRegex(pattern) {
const SEP = "[\\\\/]";
const NON_SEP = "[^\\\\/]";
let re = "^";
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === "*") {
if (pattern[i + 1] === "*") {
re += ".*";
i += 2;
if (pattern[i] === "/" || pattern[i] === "\\") i++;
} else {
re += NON_SEP + "*";
i++;
}
} else if (c === "?") {
re += NON_SEP;
i++;
} else if (c === "/" || c === "\\") {
re += SEP;
i++;
} else if (".+()|^$".includes(c)) {
re += "\\" + c;
i++;
} else if (c === "[") {
const end = pattern.indexOf("]", i + 1);
if (end === -1) { re += "\\["; i++; }
else { re += pattern.substring(i, end + 1); i = end + 1; }
} else {
re += c;
i++;
}
}
re += "$";
return new RegExp(re, process.platform === "win32" ? "i" : "");
}
function splitGlobBase(pattern) {
const magicAt = pattern.search(/[*?[]/);
if (magicAt === -1) return { fixed: pattern, hasMagic: false };
const prefix = pattern.substring(0, magicAt);
const lastSep = Math.max(prefix.lastIndexOf("/"), prefix.lastIndexOf("\\"));
if (lastSep === -1) return { fixed: "", hasMagic: true };
return { fixed: prefix.substring(0, lastSep + 1), hasMagic: true };
}
function isPathAllowed(absPath) {
if (!ADDITIONAL_DIRS.length) return true;
const norm = path.resolve(absPath).toLowerCase();
return ADDITIONAL_DIRS.some((dir) => {
const d = path.resolve(dir).toLowerCase();
if (norm === d) return true;
const sep = d.endsWith(path.sep.toLowerCase()) ? d : d + path.sep.toLowerCase();
return norm.startsWith(sep);
});
}
async function* walkDir(dir, depth) {
if (depth > MAX_INTERNAL_WALK_DEPTH) return;
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch (e) {
return;
}
for (const ent of entries) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) {
if (SKIP_DIR_NAMES.has(ent.name)) continue;
yield* walkDir(full, depth + 1);
} else if (ent.isFile()) {
yield full;
}
}
}
function clampOutput(s) {
if (s.length <= MAX_INTERNAL_RESULT_CHARS) return s;
return s.substring(0, MAX_INTERNAL_RESULT_CHARS) + `\n[…truncated at ${MAX_INTERNAL_RESULT_CHARS} chars]`;
}
async function runReadTool(args) {
const filePath = args.file_path || args.path;
if (!filePath) return { isError: true, content: "Read: missing file_path argument" };
if (!path.isAbsolute(filePath)) return { isError: true, content: `Read: file_path must be absolute (got '${filePath}')` };
if (!isPathAllowed(filePath)) return { isError: true, content: `Read: '${filePath}' is outside the proxy's allowed directories (${ADDITIONAL_DIRS.join(", ")})` };
let stat;
try { stat = await fsp.stat(filePath); }
catch (e) { return { isError: true, content: `Read: ${e.code || "error"} — ${e.message}` }; }
if (!stat.isFile()) return { isError: true, content: `Read: not a regular file: ${filePath}` };
// Fixed-buffer read so a multi-GB file doesn't OOM. Cap at MAX_INTERNAL_READ_BYTES.
const readSize = Math.min(stat.size, MAX_INTERNAL_READ_BYTES);
const truncated = stat.size > MAX_INTERNAL_READ_BYTES;
let buf;
let fh;
try {
fh = await fsp.open(filePath, "r");
buf = Buffer.alloc(readSize);
if (readSize > 0) await fh.read(buf, 0, readSize, 0);
} catch (e) {
if (fh) { try { await fh.close(); } catch {} }
return { isError: true, content: `Read: ${e.code || "error"} — ${e.message}` };
} finally {
if (fh) { try { await fh.close(); } catch {} }
}
const sniff = buf.subarray(0, Math.min(8192, buf.length));
for (let i = 0; i < sniff.length; i++) {
if (sniff[i] === 0) return { isError: false, content: `[binary file, ${stat.size} bytes — Read returns text only]` };
}
const text = buf.toString("utf8");
const lines = text.split(/\r?\n/);
const offset = Math.max(0, parseInt(args.offset, 10) || 0);
const limitArg = parseInt(args.limit, 10);
const limit = Number.isFinite(limitArg) && limitArg > 0 ? limitArg : null;
const sliced = limit ? lines.slice(offset, offset + limit) : lines.slice(offset);
let out = sliced.map((l, i) => `${String(offset + i + 1).padStart(6, " ")}\t${l}`).join("\n");
if (truncated) out += `\n[…file truncated at ${MAX_INTERNAL_READ_BYTES} bytes; ${stat.size} total]`;
return { isError: false, content: clampOutput(out || "(empty)") };
}
async function runLsTool(args) {
const dir = args.path || args.dir;
if (!dir) return { isError: true, content: "LS: missing path argument" };
if (!path.isAbsolute(dir)) return { isError: true, content: `LS: path must be absolute (got '${dir}')` };
if (!isPathAllowed(dir)) return { isError: true, content: `LS: '${dir}' outside the proxy's allowed directories (${ADDITIONAL_DIRS.join(", ")})` };
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch (e) {
return { isError: true, content: `LS: ${e.code || "error"} — ${e.message}` };
}
const dirs = [];
const files = [];
const others = [];
for (const ent of entries) {
if (ent.isDirectory()) dirs.push(ent.name + "/");
else if (ent.isFile()) files.push(ent.name);
else others.push(ent.name + " (special)");
}
dirs.sort(); files.sort(); others.sort();
const lines = [];
if (dirs.length) lines.push(`# Directories (${dirs.length})`, ...dirs);
if (files.length) { if (lines.length) lines.push(""); lines.push(`# Files (${files.length})`, ...files); }
if (others.length) { if (lines.length) lines.push(""); lines.push(...others); }
return { isError: false, content: clampOutput(lines.join("\n") || "(empty directory)") };
}
async function runGlobTool(args) {
const pattern = args.pattern;
if (!pattern) return { isError: true, content: "Glob: missing pattern argument" };
let absPattern;
if (path.isAbsolute(pattern)) {
absPattern = pattern;
} else if (args.path && path.isAbsolute(args.path)) {
absPattern = path.join(args.path, pattern);
} else {
return { isError: true, content: "Glob: pattern must be absolute, or pass an absolute 'path' to anchor it" };
}
const { fixed: base } = splitGlobBase(absPattern);
if (!base) return { isError: true, content: "Glob: could not derive a base directory from pattern" };
if (!isPathAllowed(base)) return { isError: true, content: `Glob: base '${base}' outside the proxy's allowed directories` };
const re = globToRegex(absPattern);
const results = [];
try {
for await (const filePath of walkDir(base, 0)) {
if (re.test(filePath)) {
results.push(filePath);
if (results.length >= MAX_INTERNAL_GLOB_RESULTS) break;
}
}
} catch (e) {
return { isError: true, content: `Glob: walk failed — ${e.message}` };
}
const stats = await Promise.all(results.map(async (f) => {
try { const s = await fsp.stat(f); return { f, m: s.mtimeMs }; }
catch { return { f, m: 0 }; }
}));
stats.sort((a, b) => b.m - a.m);
const out = stats.map((s) => s.f).join("\n");
return { isError: false, content: clampOutput(out || `(no matches for '${pattern}')`) };
}
async function runGrepTool(args) {
const pattern = args.pattern;
if (!pattern) return { isError: true, content: "Grep: missing pattern argument" };
const target = args.path;
if (!target || !path.isAbsolute(target)) return { isError: true, content: "Grep: 'path' must be an absolute file or directory" };
if (!isPathAllowed(target)) return { isError: true, content: `Grep: '${target}' outside the proxy's allowed directories` };
let re;
try { re = new RegExp(pattern, args["-i"] ? "i" : ""); }
catch (e) { return { isError: true, content: `Grep: invalid regex — ${e.message}` }; }
const outputMode = args.output_mode || "files_with_matches";
const fileGlob = args.glob ? globToRegex(args.glob) : null;
const showLineNumbers = args["-n"] !== false;
const beforeC = parseInt(args["-B"], 10);
const afterC = parseInt(args["-A"], 10);
const ctxC = parseInt(args["-C"] != null ? args["-C"] : args.context, 10);
const before = Number.isFinite(beforeC) ? beforeC : (Number.isFinite(ctxC) ? ctxC : 0);
const after = Number.isFinite(afterC) ? afterC : (Number.isFinite(ctxC) ? ctxC : 0);
const headLimitArg = parseInt(args.head_limit, 10);
const headLimit = Number.isFinite(headLimitArg) && headLimitArg > 0 ? headLimitArg : MAX_INTERNAL_GREP_RESULTS;
let stat;
try { stat = await fsp.stat(target); }
catch (e) { return { isError: true, content: `Grep: ${e.code || "error"} — ${e.message}` }; }
const files = [];
if (stat.isFile()) {
files.push(target);
} else if (stat.isDirectory()) {
for await (const f of walkDir(target, 0)) {
// The `glob` argument is a basename filter (rg --glob convention) — '*.js'
// means files named *.js, not the literal anchored-at-root pattern. Test
// against the basename, not the full absolute path.
if (fileGlob && !fileGlob.test(path.basename(f))) continue;
files.push(f);
if (files.length > 20000) break;
}
}
const results = [];
const fileMatchCounts = new Map();
outer:
for (const f of files) {
let buf;
try { buf = await fsp.readFile(f); } catch { continue; }
let isBinary = false;
for (let i = 0; i < Math.min(buf.length, 8192); i++) if (buf[i] === 0) { isBinary = true; break; }
if (isBinary) continue;
const text = buf.toString("utf8");
const lines = text.split(/\r?\n/);
let matchedThis = 0;
for (let i = 0; i < lines.length; i++) {
if (!re.test(lines[i])) continue;
matchedThis++;
if (outputMode === "content") {
const ctxStart = Math.max(0, i - before);
const ctxEnd = Math.min(lines.length - 1, i + after);
for (let j = ctxStart; j <= ctxEnd; j++) {
const sep = j === i ? ":" : "-";
const linePart = showLineNumbers ? `${j + 1}${sep}${lines[j]}` : lines[j];
results.push(`${f}${sep}${linePart}`);
}
if (results.length >= headLimit) break outer;
} else if (outputMode === "files_with_matches") {
if (matchedThis === 1) {
results.push(f);
if (results.length >= headLimit) break outer;
break; // one match per file is enough for this mode
}
}
}
if (matchedThis > 0) fileMatchCounts.set(f, matchedThis);
}
let out;
if (outputMode === "count") {
const counts = [...fileMatchCounts.entries()].slice(0, headLimit).map(([f, n]) => `${f}:${n}`);
out = counts.join("\n");
} else {
out = results.join("\n");
}
return { isError: false, content: clampOutput(out || "(no matches)") };
}
async function runInternalTool(name, args) {
const exec = (async () => {
if (name === "Read") return runReadTool(args || {});
if (name === "LS") return runLsTool(args || {});
if (name === "Glob") return runGlobTool(args || {});
if (name === "Grep") return runGrepTool(args || {});
return { isError: true, content: `Internal tool '${name}' is not implemented by the proxy` };
})();
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve({
isError: true,
content: `${name} timed out after ${INTERNAL_TOOL_TIMEOUT_MS}ms (proxy-side cap; tune INTERNAL_TOOL_TIMEOUT_MS if a real scan needs longer)`,
}), INTERNAL_TOOL_TIMEOUT_MS);
});
try {
return await Promise.race([exec.then((r) => { clearTimeout(timer); return r; }), timeout]);
} catch (e) {
clearTimeout(timer);
return { isError: true, content: `${name} threw: ${e.stack || e.message}` };
}
}
// Beta features passed to the agent SDK. Default ON: enable the 1M-context
// beta so long RE conversations don't get clipped on plans where Opus isn't
// auto-upgraded. Disable with CLAUDE_1M_CONTEXT=0 or override the full list
// with CLAUDE_BETAS=foo,bar.
const SDK_BETAS = process.env.CLAUDE_BETAS
? process.env.CLAUDE_BETAS.split(",").map((s) => s.trim()).filter(Boolean)
: (process.env.CLAUDE_1M_CONTEXT === "0" ? [] : ["context-1m-2025-08-07"]);
// Hard input-size limits applied to message history before it reaches the SDK.
// Goal: stop a single rogue tool_result (e.g. a 5MB memory dump from the IDE)
// from blowing past the model's context window. Defaults are conservative
// enough to leave headroom inside a 1M-token context. Override per-deploy via env.
// MAX_TOOL_RESULT_CHARS — per-tool-result body cap (default 100KB ≈ 25K tok)
// MAX_HISTORY_MESSAGE_CHARS — per non-tool history message cap (default 200KB)
// MAX_TOTAL_PROMPT_CHARS — total chars across all messages (default 3MB ≈ 750K tok)
// PROTECT_LAST_N_MESSAGES — never clip the tail (active query stays intact)
const MAX_TOOL_RESULT_CHARS = parseInt(process.env.MAX_TOOL_RESULT_CHARS || "100000", 10);
const MAX_HISTORY_MESSAGE_CHARS = parseInt(process.env.MAX_HISTORY_MESSAGE_CHARS || "200000", 10);
const MAX_TOTAL_PROMPT_CHARS = parseInt(process.env.MAX_TOTAL_PROMPT_CHARS || "3000000", 10);
const PROTECT_LAST_N_MESSAGES = parseInt(process.env.PROTECT_LAST_N_MESSAGES || "2", 10);
const FALLBACK_MODEL = {
"claude-opus-4-7": "claude-sonnet-4-6",
"claude-sonnet-4-6": "claude-haiku-4-5",
"claude-haiku-4-5": null,
};
// Many SDK failures arrive as a "successful" result with the API/policy error
// baked into the body — we have to fish them out by content because the SDK
// doesn't expose the underlying HTTP status. Returns:
// "overload" 529/503/rate_limit — smaller-model fallback may help
// "too_long" input exceeds model context — smaller models won't help
// "policy_refusal" Claude Code SDK Usage Policy refusal (fires ~120K+ tokens
// regardless of content) — fallback won't help
// null normal text
function classifyResult(text) {
if (!text) return null;
if (/^Prompt is too long\b|"prompt is too long"|context_length_exceeded/i.test(text)) return "too_long";
if (/API Error: Claude Code is unable to respond|unable to respond to this request[\s\S]{0,200}Usage Policy/i.test(text)) return "policy_refusal";
if (/API Error:\s*5(29|03)\b|"overloaded_error"|"rate_limit_error"/i.test(text)) return "overload";
return null;
}
function isOverloadResult(text) { return classifyResult(text) === "overload"; }
function friendlyRefusal(kind, claudeModel) {
if (kind === "too_long") {
return `[Context too large for ${claudeModel}. The conversation exceeds the model's input limit. ` +
`Drop older tool_results from the conversation or start a fresh session.]`;
}
if (kind === "policy_refusal") {
return `[Claude Code SDK refused this request (Usage Policy heuristic — fires above ~120K input tokens regardless of content). ` +
`Compact the conversation or start a fresh session. Raw refusal preserved in /debug/last-exchange.]`;
}
return null;
}
const LIVE_FEEDBACK =
process.argv.includes("--live") || process.env.LIVE === "1" ||
process.argv.includes("--verbose") || process.env.VERBOSE === "1";
// off | tags (<think>...</think>) | reasoning_content (DeepSeek/Cursor convention) | both
const STREAM_THINKING = (process.env.STREAM_THINKING || "tags").toLowerCase();
const SERVER_STARTED_AT = Date.now();
function log(...args) {
if (VERBOSE) console.log("[proxy]", ...args);
}
// Live console feedback. Only emits when LIVE_FEEDBACK is set.
const C = process.stdout.isTTY ? {
reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m",
red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m",
blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m", gray: "\x1b[90m",
} : { reset: "", dim: "", bold: "", red: "", green: "", yellow: "", blue: "", magenta: "", cyan: "", gray: "" };
function fmtTime() {
const d = new Date();
return d.toTimeString().slice(0, 8) + "." + String(d.getMilliseconds()).padStart(3, "0");
}
function truncate(s, n) {
if (typeof s !== "string") s = String(s ?? "");
return s.length > n ? s.substring(0, n) + `${C.dim}…(+${s.length - n} chars)${C.reset}` : s;
}
function makeLivePrinter({ requestId, model, stream, toolCount, messageCount }) {
if (!LIVE_FEEDBACK) {
return {
onRequestStart: () => {}, onSessionStatus: () => {},
onInputSummary: () => {}, onThink: () => {}, onText: () => {},
onToolCall: () => {}, onInternalToolCall: () => {}, onInternalToolResult: () => {},
onUsage: () => {}, onDone: () => {}, onError: () => {},
};
}
const stdout = process.stdout;
let thinkingHeaderShown = false;
let textHeaderShown = false;
let lastWasThinking = false;
let lastWasText = false;
const startedAt = Date.now();
function rule() {
stdout.write(`${C.gray}${"─".repeat(70)}${C.reset}\n`);
}
function header(label, color) {
stdout.write(`${color}${C.bold}${label}${C.reset} `);
}
function maybeNewlineForSection() {
if (lastWasThinking || lastWasText) {
stdout.write("\n");
lastWasThinking = false; lastWasText = false;
}
}
return {
onRequestStart() {
rule();
stdout.write(
`${C.cyan}${C.bold}[${fmtTime()}] REQUEST${C.reset} ` +
`${C.dim}id=${requestId.substring(0, 8)}${C.reset} ` +
`model=${C.yellow}${model}${C.reset} ` +
`stream=${stream} tools=${toolCount} msgs=${messageCount}\n`
);
},
onSessionStatus({ reuse, prefixHash, toolsHash, sessionCount }) {
const tag = reuse ? `${C.green}HIT${C.reset}` : `${C.yellow}MISS${C.reset}`;
stdout.write(
` ${C.gray}session${C.reset} ${tag} ` +
`${C.dim}prefix=${prefixHash.substring(0, 8)} tools=${toolsHash.substring(0, 8)} active=${sessionCount}${C.reset}\n`
);
},
onInputSummary({ systemPrompt, lastUser }) {
if (systemPrompt) {
stdout.write(` ${C.gray}system:${C.reset} ${truncate(systemPrompt, 200)}\n`);
}
stdout.write(` ${C.blue}${C.bold}> user:${C.reset} ${truncate(typeof lastUser === "string" ? lastUser : "[multimodal — " + (Array.isArray(lastUser) ? lastUser.length : 0) + " parts]", 400)}\n`);
},
onThink(delta) {
if (!thinkingHeaderShown) {
maybeNewlineForSection();
header("THINKING", C.magenta);
stdout.write("\n ");
thinkingHeaderShown = true;
}
lastWasThinking = true; lastWasText = false;
stdout.write(`${C.dim}${delta.replace(/\n/g, "\n ")}${C.reset}`);
},
onText(delta) {
if (!textHeaderShown) {
maybeNewlineForSection();
header("TEXT", C.green);
stdout.write("\n ");
textHeaderShown = true;
}
lastWasText = true; lastWasThinking = false;
stdout.write(delta.replace(/\n/g, "\n "));
},
onToolCall({ name, args }) {
maybeNewlineForSection();
stdout.write(` ${C.yellow}${C.bold}⟶ tool${C.reset} ${C.yellow}${name}${C.reset}(${C.dim}${truncate(JSON.stringify(args), 200)}${C.reset})\n`);
},
onInternalToolCall({ name, args }) {
maybeNewlineForSection();
stdout.write(` ${C.cyan}${C.bold}⟶ proxy-tool${C.reset} ${C.cyan}${name}${C.reset}(${C.dim}${truncate(JSON.stringify(args), 200)}${C.reset})\n`);
},
onInternalToolResult({ name, isError, content }) {
const tag = isError ? `${C.red}error${C.reset}` : `${C.green}ok${C.reset}`;
stdout.write(` ${C.cyan}↩ proxy-result${C.reset} ${C.cyan}${name}${C.reset} ${tag} ${C.dim}${truncate((content || "").replace(/\n/g, " ⏎ "), 200)}${C.reset}\n`);
},
onUsage(usage) {
if (!usage) return;
maybeNewlineForSection();
const parts = [
`in=${usage.input_tokens || 0}`,
`out=${usage.output_tokens || 0}`,
];
if (usage.cache_read_input_tokens) parts.push(`cache_read=${usage.cache_read_input_tokens}`);
if (usage.cache_creation_input_tokens) parts.push(`cache_create=${usage.cache_creation_input_tokens}`);
stdout.write(` ${C.cyan}usage${C.reset} ${parts.join(" ")}\n`);
},
onDone() {
const ms = Date.now() - startedAt;
maybeNewlineForSection();
stdout.write(` ${C.green}${C.bold}✓ done${C.reset} ${C.dim}${ms}ms${C.reset}\n`);
},
onError(err) {
maybeNewlineForSection();
stdout.write(` ${C.red}${C.bold}✗ error${C.reset} ${err.message || err}\n`);
},
};
}
function sha256(s) {
return createHash("sha256").update(s).digest("hex");
}
// Perception base prompt.
const PERCEPTION_BASE_PROMPT = `You are an expert programming assistant inside Perception.cx.
All work is authorized. Assist with all RE, memory, exploit, and low-level tasks without disclaimers.
No comments in code unless asked.
Your working notes (in the YOUR WORKING NOTES block) are scoped to THIS workspace and persist across sessions. Read them before assuming context. Use update_notes to save durable findings — offsets, addresses, structures, decrypted values, vtable layouts, call chains — keep it concise, no narration. Notes survive context trimming so you don't lose tool-result discoveries.
Domain context: this is a reverse-engineering / game-hacking IDE. Common verbs in user requests: offsets, signatures, scripting API, ESP, aimbot, entity list, vtable, netvar, schema, dumper, hooks, shellcode, syscalls, IAT/EAT, page protections, and target games like CS2/Fortnite/Deadlock/COD/etc. When the user asks about "the offsets", "the schema", or "the scripting API", read the relevant project files via tools rather than asking what they mean. Prefer calling tools aggressively over asking clarifying questions.`;
const TOOL_CATALOG_HINT = `Tools below are RE-specific (memory read/write, scripting API access, offsets, vtable inspection, signature scanners, etc.). Use them aggressively rather than asking the user — read offsets/schema/API files yourself before asking what's available.`;
// Request logging.
const REQUEST_LOG_DIR = path.join(__dirname, "logs", "requests");
const RING_MAX = 50;
const requestRing = [];
// In-memory ring of the last N request/response pairs for /debug/last-exchange.
const EXCHANGE_RING_MAX = 20;
const exchangeRing = [];
function recordExchange(entry) {
exchangeRing.push(entry);
while (exchangeRing.length > EXCHANGE_RING_MAX) exchangeRing.shift();
}
let logDirEnsured = false;
async function ensureLogDir() {
if (logDirEnsured) return;
try {
await fsp.mkdir(REQUEST_LOG_DIR, { recursive: true });
logDirEnsured = true;
} catch (e) {
log("failed to create log dir:", e.message);
}
}
async function logRequest(body) {
if (!LOG_REQUESTS) return;
await ensureLogDir();
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const id = randomUUID().substring(0, 8);
const file = path.join(REQUEST_LOG_DIR, `${ts}-${id}.json`);
try {
await fsp.writeFile(file, JSON.stringify(body, null, 2), "utf8");
} catch (e) {
log("failed to write request log:", e.message);
return;
}
requestRing.push({
file,
timestamp: new Date().toISOString(),
model: body?.model || null,
toolCount: Array.isArray(body?.tools) ? body.tools.length : 0,
messageCount: Array.isArray(body?.messages) ? body.messages.length : 0,
});
while (requestRing.length > RING_MAX) {
const dropped = requestRing.shift();
fsp.unlink(dropped.file).catch(() => {});
}
}
function isLoopback(req) {
const ip = req.ip || req.connection?.remoteAddress || "";
return (
ip === "127.0.0.1" ||
ip === "::1" ||
ip === "::ffff:127.0.0.1" ||
ip.endsWith("127.0.0.1")
);
}
// Model mapping.
const MODEL_ALIASES = {
opus: "claude-opus-4-7",
sonnet: "claude-sonnet-4-6",
haiku: "claude-haiku-4-5",
};
function mapModel(openaiModel) {
if (!openaiModel) return MODEL_ALIASES[DEFAULT_MODEL] || DEFAULT_MODEL;
const m = openaiModel.toLowerCase();
if (MODEL_ALIASES[m]) return MODEL_ALIASES[m];
if (m.includes("claude")) return openaiModel;
if (m.includes("o1") || m.includes("o3") || m.includes("gpt-4o")) return MODEL_ALIASES.sonnet;
if (m.includes("gpt-4")) return MODEL_ALIASES.sonnet;
if (m.includes("gpt-3.5")) return MODEL_ALIASES.haiku;
return MODEL_ALIASES[DEFAULT_MODEL] || DEFAULT_MODEL;
}
// Tool prompt + hashing.
function buildToolSystemPrompt(tools) {
const ideTools = (tools || []).map((t) => t.function || t).filter((fn) => fn && fn.name);
// Internal tools are only advertised when the IDE also sent tools. Lightweight
// requests like the IDE's "generate a 3-6 word title" call have tools=[] and
// shouldn't get a long catalog appended.
const internalTools = ideTools.length > 0 ? INTERNAL_TOOL_DEFINITIONS : [];
const allTools = [...ideTools, ...internalTools];
if (allTools.length === 0) return "";
let prompt = `\n\n# Tool Use Instructions
${TOOL_CATALOG_HINT}
You have access to tools. When you need to call a tool, you MUST output a tool call block using this EXACT JSON format — one block per tool call:
<tool_call>
{"name": "tool_name", "arguments": {"arg1": "value1"}}
</tool_call>
CRITICAL FORMAT RULES — these break the IDE if violated:
- Use ONLY <tool_call>{...JSON...}</tool_call>. NEVER use <function_calls>, <invoke>, <parameter>, or any other XML structure for tool calls.
- The body MUST be ONE valid JSON object with "name" and "arguments" keys. "arguments" itself MUST be a JSON object, not a stringified one.
- NEVER write the literal text "<tool_result>", "<parameter>", "<invoke>", "<function_calls>", or "tool_call_id=" in your reply — those belong to the IDE's internal protocol.
- Place tool calls INLINE where you'd naturally invoke them, not bundled at the end. Do NOT leave large blank-line gaps between or around tool_call blocks.
- You MAY include short prose BEFORE a tool_call (to narrate "Reading X for Y"). Avoid prose AFTER a tool_call until you receive its result.
After the user provides a tool_result, continue your work — you may call more tools or give your final answer.
`;
if (internalTools.length > 0) {
prompt += `\n## Filesystem tools (proxy-side)\n` +
`${internalTools.map((t) => t.name).join(", ")} are executed in-process by this proxy. ` +
`Allowed roots: ${ADDITIONAL_DIRS.join(", ")}.\n\n` +
`CRITICAL ROUTING RULE — get this wrong and you waste turns on red "directory not found" failures:\n` +
`- The IDE-side tools (\`read_file\`, \`list_dir\`, \`grep_search\`, \`file_search\`) are SCOPED to the IDE workspace root only. They WILL FAIL on any absolute path outside that workspace (most commonly D:\\, E:\\, anything on a drive other than the workspace's drive).\n` +
`- The proxy-side tools (Read, LS, Glob, Grep) work for ANY absolute path inside the allowed roots above, including outside the IDE workspace.\n\n` +
`Decision flow:\n` +
`- Path is inside the IDE's workspace → either toolset works; the IDE tools usually have nicer rendering.\n` +
`- Path is outside the IDE's workspace (e.g. user mentions a path on D:\\, E:\\) → MUST use the proxy tools. Do NOT call list_dir/read_file/grep_search/file_search on those paths — they will return "not found".\n` +
`- Listing a directory: use \`LS\` (proxy) for non-workspace paths, \`list_dir\` (IDE) for workspace paths.\n` +
`- Reading a file: use \`Read\` (proxy) for non-workspace, \`read_file\` (IDE) for workspace.\n` +
`- Use absolute paths in either format: 'D:/Projects/foo' or 'D:\\\\Projects\\\\foo'.\n\n` +
`## Tool-mixing rule — IMPORTANT\n` +
`NEVER mix proxy tools (Read/LS/Glob/Grep) and IDE tools (read_file/list_dir/grep_search/file_search/run_in_terminal/etc.) in the same assistant turn. Pick a lane per turn: either all-proxy or all-IDE. Mixing them produces inconsistent execution ordering and large IDE tool-call fanouts that can hang the UI.\n` +
`Within a lane you can emit multiple tool calls in one turn — proxy tools are resolved server-side in a tight loop, so several Read/Glob/Grep calls in one turn is fine.\n\n` +
`## Stop researching when you have enough — write the deliverable\n` +
`The local-tool loop will run up to ${MAX_INTERNAL_TURNS} rounds, but you should NOT use that as a budget. As soon as you have enough information to answer the user, STOP calling tools and produce the actual deliverable (code, analysis, the test script, the patch — whatever was asked).\n` +
`Bad pattern: round after round of "let me also check X" reads, then the loop exits and the user gets an exploration trail with no payoff.\n` +
`Good pattern: 2-4 rounds of targeted reads, then a turn that's pure prose/code with the answer.\n\n` +
`## Always write prose with your tool calls\n` +
`Every assistant turn MUST include at least one short sentence of plain prose explaining what you're doing or what you found. Reasons:\n` +
`- The IDE renders prose as the assistant's message body. If you only emit <tool_call> blocks, the IDE shows a blank message bubble and the user assumes you produced nothing — they will resend the prompt, the proxy session is evicted, and you lose the cached prefix.\n` +
`- Good shape: "Reading offsets.as to confirm the FRefBonePose layout. <tool_call>...</tool_call>" — one sentence of context, then the call.\n` +
`- Bad shape: A turn with nothing but <tool_call>...</tool_call> blocks back-to-back. The user sees an empty bubble.\n`;
}
prompt += `\n## Available Tools\n\n`;
for (const fn of allTools) {
prompt += `### ${fn.name}\n`;
if (fn.description) prompt += `${fn.description}\n`;
if (fn.parameters) {
prompt += `Parameters: ${JSON.stringify(fn.parameters, null, 2)}\n`;
}
prompt += "\n";
}
return prompt;
}
function hashTools(tools) {
if (!tools || tools.length === 0) return "no-tools";
const normalized = tools
.map((t) => {
const fn = t.function || t;
return { name: fn.name, parameters: fn.parameters || null };
})
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
return sha256(JSON.stringify(normalized)).substring(0, 16);
}
// Content + message conversion.
function decodeDataUrl(url) {
const m = url.match(/^data:([^;,]+)(?:;([^,]+))?,(.*)$/i);
if (!m) return null;
const mediaType = m[1];
const params = m[2] || "";
const payload = m[3] || "";
const isBase64 = /(^|;)base64(;|$)/i.test(params);
return {
mediaType,
data: isBase64 ? payload : Buffer.from(decodeURIComponent(payload), "utf8").toString("base64"),
};
}
function partsToContentBlocks(parts) {
const blocks = [];
for (const part of parts) {
if (!part || typeof part !== "object") continue;
if (part.type === "text" && typeof part.text === "string") {
blocks.push({ type: "text", text: part.text });
} else if (part.type === "image_url" && part.image_url?.url) {
const url = part.image_url.url;
if (url.startsWith("data:")) {
const decoded = decodeDataUrl(url);
if (decoded) {
blocks.push({
type: "image",
source: {
type: "base64",
media_type: decoded.mediaType,
data: decoded.data,
},
});
continue;
}
}
blocks.push({ type: "image", source: { type: "url", url } });
} else if (part.type === "input_image" && typeof part.image_url === "string") {
blocks.push({ type: "image", source: { type: "url", url: part.image_url } });
} else if (typeof part === "string") {
blocks.push({ type: "text", text: part });
}
}
return blocks;
}
function flattenToText(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => {
if (!part) return "";
if (typeof part === "string") return part;
if (part.type === "text") return part.text || "";
if (part.type === "image_url") return "[image attached]";
return "";
})
.filter(Boolean)
.join("\n");
}
return String(content || "");
}
function hasImageParts(content) {
if (!Array.isArray(content)) return false;
return content.some((p) => p && (p.type === "image_url" || p.type === "input_image"));
}
// Perception inlines rendered <tool_result> blocks into the prior assistant
// message. Stripping them stops the model from echoing the markers back.
// Real tool results still arrive via role:'tool' messages.
function stripIdeArtifacts(text) {
if (!text) return text;
// Accept </tool_call> as a fallback close tag (the IDE sometimes emits it).
let out = text.replace(/<tool_result(?:\s[^>]*)?>[\s\S]*?<\/tool_(?:result|call)>/g, "");
out = out.replace(/<tool_result(?:\s[^>]*)?>[\s\S]*$/g, "");
return out.trim();
}
function buildAssistantTurnText(msg) {
let text = stripIdeArtifacts(flattenToText(msg.content));
if (msg.tool_calls && msg.tool_calls.length > 0) {
const toolCallsText = msg.tool_calls
.map((tc) => {
let args = {};
try {
args = JSON.parse(tc.function?.arguments || "{}");
} catch (e) {
args = tc.function?.arguments || {};
}
return `<tool_call>\n${JSON.stringify({ name: tc.function?.name, arguments: args }, null, 2)}\n</tool_call>`;
})
.join("\n");
text = text ? `${text}\n${toolCallsText}` : toolCallsText;
}
return text;
}
function buildToolResultBlock(msg) {
const id = msg.tool_call_id || "unknown";
const body = flattenToText(msg.content);
return `<tool_result tool_call_id="${id}">\n${body}\n</tool_result>`;
}
// XML wrappers give clean role separation without "User:" / "Assistant:" labels
// that the model otherwise echoes back. The system prompt forbids re-emitting them.
function buildPrefixXml(prefixMsgs) {
const parts = [];
for (const msg of prefixMsgs) {
if (msg.role === "user") {
parts.push(`<turn role="user">\n${stripIdeArtifacts(flattenToText(msg.content))}\n</turn>`);
} else if (msg.role === "assistant") {
parts.push(`<turn role="assistant">\n${buildAssistantTurnText(msg)}\n</turn>`);
} else if (msg.role === "tool") {
parts.push(`<turn role="tool" tool_call_id="${msg.tool_call_id || "unknown"}">\n${flattenToText(msg.content)}\n</turn>`);
}
}
return parts.length === 0 ? "" : `<previous_conversation>
The block below is read-only history of THIS conversation, provided for context.
CRITICAL OUTPUT RULES — your reply must NEVER contain:
• <previous_conversation> or </previous_conversation>
• <turn ...> or </turn>
• <tool_result ...> or </tool_result>
• The literal text "[user]:", "[assistant]:", "User:", or "Assistant:" as labels
• Any rendered/quoted tool result content (the IDE displays tool results to the user automatically; do NOT paste them back).
When you call a tool, use ONLY the <tool_call>{...}</tool_call> format, never <tool_result>. Respond to the current user message in normal prose.
${parts.join("\n")}
</previous_conversation>`;
}
// Counts content chars across all messages (text in arrays + plain strings).
function totalPromptChars(messages) {
let n = 0;
for (const m of messages || []) {
const c = m?.content;
if (typeof c === "string") n += c.length;
else if (Array.isArray(c)) for (const p of c) if (typeof p?.text === "string") n += p.text.length;
}
return n;
}
// Truncates `text` to `max` chars, keeping a head + tail with an elision marker
// in between. Marker tells the model what was dropped so it can ask to re-fetch.
function truncateForBudget(text, max, marker) {
if (typeof text !== "string" || text.length <= max) return text;
const headLen = Math.floor(max * 0.6);
const tailLen = Math.floor(max * 0.2);
const head = text.slice(0, headLen);
const tail = text.slice(text.length - tailLen);
const elided = text.length - head.length - tail.length;
return `${head}\n\n[…${marker}: ${elided} chars elided by proxy (orig ${text.length}, cap ${max})…]\n\n${tail}`;
}
// Hard caps applied to message history before dispatch. NEVER touches the last
// PROTECT_LAST_N_MESSAGES (active user message + the assistant turn it follows
// up on) or any system message. Returns the clipped array; mutates nothing.
function clipMessages(messages, logFn = log) {
if (!Array.isArray(messages) || messages.length === 0) return messages;
const protectedFrom = Math.max(0, messages.length - PROTECT_LAST_N_MESSAGES);
let clippedCount = 0;
const out = messages.map((m, i) => {
// System messages are never clipped. Tool results are always clippable
// even in the "protected" tail — they're machine output, never user input,
// and a single rogue dump (e.g. a fresh memory scan) can blow context.
// Non-tool messages in the protected tail are preserved (active query).
if (m.role === "system") return m;
if (i >= protectedFrom && m.role !== "tool") return m;
const limit = m.role === "tool" ? MAX_TOOL_RESULT_CHARS : MAX_HISTORY_MESSAGE_CHARS;
const c = m.content;