@octocodeai/freellama-mcp-server 0.6.2 → 0.9.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.
Files changed (45) hide show
  1. package/CONFIGURATION.md +11 -3
  2. package/DEVELOPING.md +6 -6
  3. package/README.md +224 -402
  4. package/dist/index.js +11714 -4251
  5. package/dist/research/bash-agent.js +1495 -71
  6. package/dist/research/coder-agent.js +4668 -0
  7. package/dist/research/github-agent.js +2923 -0
  8. package/dist/research/octocode-agent.js +1407 -116
  9. package/docs/ARCHITECTURE.md +101 -45
  10. package/docs/BUILD_AND_NOTES.md +31 -0
  11. package/docs/CLI.md +76 -27
  12. package/docs/COMPARISON.md +40 -0
  13. package/docs/CPU_GPU_ROUTING.md +127 -25
  14. package/docs/DEFERRED_JOBS.md +79 -0
  15. package/docs/FEATURES.md +303 -0
  16. package/docs/LOCAL_MODELS.md +190 -0
  17. package/docs/MODEL_FORMS_AND_RESULTS.md +55 -0
  18. package/docs/MODEL_INTROSPECTION.md +128 -0
  19. package/docs/MODEL_SELECTION.md +74 -22
  20. package/docs/MONITORING.md +115 -33
  21. package/docs/PRODUCTION.md +76 -13
  22. package/docs/README.md +11 -6
  23. package/docs/SCOPES_AND_WARMING.md +55 -7
  24. package/docs/TESTING.md +24 -2
  25. package/docs/TUNING.md +86 -0
  26. package/docs/cookbooks/README.md +22 -0
  27. package/docs/cookbooks/batch-text.md +19 -0
  28. package/docs/cookbooks/classify.md +25 -0
  29. package/docs/cookbooks/coding.md +17 -0
  30. package/docs/cookbooks/duplicates.md +17 -0
  31. package/docs/cookbooks/find-code.md +21 -0
  32. package/docs/cookbooks/image-recognition.md +15 -0
  33. package/docs/cookbooks/image-search.md +13 -0
  34. package/docs/cookbooks/jobs.md +60 -0
  35. package/docs/cookbooks/memory-and-caching.md +31 -0
  36. package/docs/cookbooks/ocr.md +17 -0
  37. package/docs/cookbooks/research-agent.md +27 -0
  38. package/docs/cookbooks/speech-to-text.md +19 -0
  39. package/docs/cookbooks/sweep.md +66 -0
  40. package/docs/cookbooks/text-to-speech.md +15 -0
  41. package/docs/octocode/GITHUB.md +30 -0
  42. package/docs/octocode/LOCAL.md +29 -0
  43. package/native/index.d.ts +1 -11
  44. package/native/index.js +18 -34
  45. package/package.json +13 -15
@@ -0,0 +1,4668 @@
1
+ // src/research/coder-agent.ts
2
+ import { createHash as createHash3 } from "node:crypto";
3
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync9, realpathSync as realpathSync5, writeFileSync as writeFileSync5 } from "node:fs";
4
+ import path12 from "node:path";
5
+
6
+ // src/research/canonical-json.ts
7
+ function canonicalJson(value, options = {}) {
8
+ const { sortKeys = true, ensureAscii = true, separators: [item, colon] = [", ", ": "], floatKeys } = options;
9
+ const quote = (text) => {
10
+ const json = JSON.stringify(text);
11
+ return ensureAscii ? json.replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`) : json;
12
+ };
13
+ const render = (node, float) => {
14
+ if (node === null) return "null";
15
+ if (typeof node === "string") return quote(node);
16
+ if (typeof node === "boolean") return String(node);
17
+ if (typeof node === "number") return numberText(node, float);
18
+ if (Array.isArray(node)) return `[${node.map((entry) => render(entry, false)).join(item)}]`;
19
+ if (typeof node === "object") {
20
+ const record = node;
21
+ const keys = Object.keys(record).filter((key) => record[key] !== void 0);
22
+ if (sortKeys) keys.sort();
23
+ return `{${keys.map((key) => `${quote(key)}${colon}${render(record[key], floatKeys?.has(key) ?? false)}`).join(item)}}`;
24
+ }
25
+ throw new TypeError(`cannot serialize a ${typeof node} as JSON`);
26
+ };
27
+ return render(value, false);
28
+ }
29
+ function numberText(value, float) {
30
+ if (!Number.isFinite(value)) throw new RangeError(`cannot serialize ${value} as JSON`);
31
+ if (Number.isInteger(value) && !float) return Math.abs(value) < 1e21 ? String(value) : BigInt(value).toString();
32
+ const [mantissa, power] = value.toExponential().split("e");
33
+ const exponent = Number(power);
34
+ if (value !== 0 && (exponent < -4 || exponent >= 16)) {
35
+ return `${mantissa}e${exponent < 0 ? "-" : "+"}${String(Math.abs(exponent)).padStart(2, "0")}`;
36
+ }
37
+ const text = String(value);
38
+ return Number.isInteger(value) ? `${text}.0` : text;
39
+ }
40
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
41
+
42
+ // src/research/errors.ts
43
+ var ValidationError = class extends Error {
44
+ name = "ValidationError";
45
+ };
46
+ function isSystemError(error) {
47
+ return error instanceof Error && typeof error.errno === "number";
48
+ }
49
+ var ToolTimeoutError = class extends Error {
50
+ name = "ToolTimeoutError";
51
+ };
52
+ var ToolExitError = class extends Error {
53
+ constructor(exitCode, output, status = "error") {
54
+ super(`${status === "empty" ? "nothing found" : "command failed"} (exit code ${exitCode})` + (output ? `
55
+ ${output}` : ""));
56
+ this.exitCode = exitCode;
57
+ this.output = output;
58
+ this.status = status;
59
+ }
60
+ exitCode;
61
+ output;
62
+ status;
63
+ name = "ToolExitError";
64
+ };
65
+ function isToolError(error) {
66
+ return error instanceof ValidationError || error instanceof ToolTimeoutError || error instanceof ToolExitError || error instanceof SyntaxError || isSystemError(error);
67
+ }
68
+
69
+ // src/research/actions.ts
70
+ function objectAt(text, start) {
71
+ let depth = 0;
72
+ let inString = false;
73
+ for (let index = start; index < text.length; index += 1) {
74
+ const char = text[index];
75
+ if (inString) {
76
+ if (char === "\\") index += 1;
77
+ else if (char === '"') inString = false;
78
+ } else if (char === '"') {
79
+ inString = true;
80
+ } else if (char === "{" || char === "[") {
81
+ depth += 1;
82
+ } else if (char === "}" || char === "]") {
83
+ depth -= 1;
84
+ if (depth === 0) return JSON.parse(text.slice(start, index + 1));
85
+ }
86
+ }
87
+ throw new SyntaxError("unterminated JSON object");
88
+ }
89
+ function parseJsonAction(content) {
90
+ let text = content.trim();
91
+ if (text.startsWith("```")) text = text.split(/\r\n|\r|\n/).slice(1, -1).join("\n").trim();
92
+ let value;
93
+ try {
94
+ value = JSON.parse(text);
95
+ } catch (error) {
96
+ if (!(error instanceof SyntaxError)) throw error;
97
+ const start = text.indexOf("{");
98
+ if (start < 0) throw new ValidationError("model did not return a JSON action");
99
+ try {
100
+ value = objectAt(text, start);
101
+ } catch (inner) {
102
+ if (!(inner instanceof SyntaxError)) throw inner;
103
+ throw new ValidationError("model did not return a valid JSON action");
104
+ }
105
+ }
106
+ if (!isRecord(value) || typeof value.action !== "string") throw new ValidationError("model action must be a JSON object with an action");
107
+ return value;
108
+ }
109
+ function callSignature(payload) {
110
+ return canonicalJson(payload, { ensureAscii: false });
111
+ }
112
+ var REPEAT_NOTICE = "(identical call already made this run \u2014 its result is above and was not re-executed. Use a different call, or finish now with the evidence already collected.)";
113
+ var PARSE_REPAIR_NOTICE = "Your last reply was not a valid JSON action, so it was discarded. Reply with EXACTLY one JSON object and nothing else \u2014 no prose before or after, no markdown fence. Use the action shapes given in your instructions. If you already have enough evidence, send the finish action now.";
114
+
115
+ // src/research/adapter.ts
116
+ function isPositiveInt(value) {
117
+ return (typeof value === "boolean" || typeof value === "number" && Number.isInteger(value)) && Number(value) >= 1;
118
+ }
119
+
120
+ // src/research/agent-loop.ts
121
+ import { readFileSync as readFileSync5 } from "node:fs";
122
+ import { performance as performance2 } from "node:perf_hooks";
123
+ import { setTimeout as sleep } from "node:timers/promises";
124
+
125
+ // src/research/context-manager.ts
126
+ import { createHash } from "node:crypto";
127
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
128
+ import path from "node:path";
129
+
130
+ // src/research/context-policy.ts
131
+ var CHARS_PER_TOKEN = 4;
132
+ var SAFETY_MARGIN_TOKENS = 256;
133
+ var IMAGE_TOKEN_ESTIMATE = 1024;
134
+ var DEFAULT_KEEP_RECENT = 2;
135
+ var COMPACT_PREVIEW_CHARS = 180;
136
+ var COMPACT_RETAIN_RATIO = 0.8;
137
+ var CLIP_HEAD_RATIO = 2 / 3;
138
+ var OBSERVATION_PAGE_CHARS = 3e3;
139
+ var COMPACTION_TARGET_RATIO = 0.9;
140
+ var MAX_PARSE_REPAIRS = 2;
141
+ var INTEGER = /^[+-]?[0-9]+$/;
142
+ var DECIMAL = /^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
143
+ function envInt(env, name, defaultValue, minimum = 0) {
144
+ const raw = env[name];
145
+ if (raw === void 0 || raw === "") return defaultValue;
146
+ if (!INTEGER.test(raw.trim())) throw new ValidationError(`${name} must be an integer, got ${JSON.stringify(raw)}`);
147
+ const value = Number(raw.trim());
148
+ if (value < minimum) throw new ValidationError(`${name} must be >= ${minimum}, got ${value}`);
149
+ return value;
150
+ }
151
+ function envFloat(env, name, defaultValue, minimum = 0) {
152
+ const raw = env[name];
153
+ if (raw === void 0 || raw === "") return defaultValue;
154
+ const value = Number(raw.trim());
155
+ if (!DECIMAL.test(raw.trim())) throw new ValidationError(`${name} must be a number, got ${JSON.stringify(raw)}`);
156
+ if (!Number.isFinite(value) || value < minimum) {
157
+ throw new ValidationError(`${name} must be finite and >= ${minimum}, got ${JSON.stringify(raw)}`);
158
+ }
159
+ return value;
160
+ }
161
+ function envBool(env, name, defaultValue) {
162
+ const raw = env[name];
163
+ if (raw === void 0 || raw === "") return defaultValue;
164
+ const normalized = raw.trim().toLowerCase();
165
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
166
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
167
+ throw new ValidationError(`${name} must be true/false, got ${JSON.stringify(raw)}`);
168
+ }
169
+ function contextBudget(numCtx, numPredict, safetyMarginTokens = SAFETY_MARGIN_TOKENS) {
170
+ return Math.max(numCtx - numPredict - safetyMarginTokens, 0);
171
+ }
172
+ var ContextPolicy = class _ContextPolicy {
173
+ fields;
174
+ /**
175
+ * Fields configured from the environment. They are floats, so canonical JSON (calibration keys)
176
+ * renders them as floats: `FREELLAMA_AGENT_CHARS_PER_TOKEN=8` gives `8.0`, the default gives `4`.
177
+ */
178
+ floatKeys;
179
+ constructor(fields = {}, floatKeys = []) {
180
+ this.fields = {
181
+ chars_per_token: fields.chars_per_token ?? CHARS_PER_TOKEN,
182
+ safety_margin_tokens: fields.safety_margin_tokens ?? SAFETY_MARGIN_TOKENS,
183
+ image_token_estimate: fields.image_token_estimate ?? IMAGE_TOKEN_ESTIMATE,
184
+ keep_recent: fields.keep_recent ?? DEFAULT_KEEP_RECENT,
185
+ compact_preview_chars: fields.compact_preview_chars ?? COMPACT_PREVIEW_CHARS,
186
+ compact_retain_ratio: fields.compact_retain_ratio ?? COMPACT_RETAIN_RATIO,
187
+ clip_head_ratio: fields.clip_head_ratio ?? CLIP_HEAD_RATIO,
188
+ observation_page_chars: fields.observation_page_chars ?? OBSERVATION_PAGE_CHARS,
189
+ pinned_overflow: fields.pinned_overflow ?? "error"
190
+ };
191
+ this.floatKeys = new Set(floatKeys);
192
+ const f = this.fields;
193
+ if (!Number.isFinite(f.chars_per_token) || f.chars_per_token <= 0) throw new ValidationError("chars_per_token must be finite and > 0");
194
+ if (f.safety_margin_tokens < 0 || f.image_token_estimate < 0 || f.keep_recent < 0) {
195
+ throw new ValidationError("context token estimates and keep_recent must be >= 0");
196
+ }
197
+ if (f.compact_preview_chars <= 0 || f.observation_page_chars <= 0) throw new ValidationError("preview and page sizes must be > 0");
198
+ if (!(f.compact_retain_ratio > 0 && f.compact_retain_ratio < 1)) throw new ValidationError("compact_retain_ratio must be between 0 and 1");
199
+ if (!(f.clip_head_ratio > 0 && f.clip_head_ratio < 1)) throw new ValidationError("clip_head_ratio must be between 0 and 1");
200
+ if (f.pinned_overflow !== "error" && f.pinned_overflow !== "clip") throw new ValidationError("pinned_overflow must be 'error' or 'clip'");
201
+ }
202
+ get chars_per_token() {
203
+ return this.fields.chars_per_token;
204
+ }
205
+ get safety_margin_tokens() {
206
+ return this.fields.safety_margin_tokens;
207
+ }
208
+ get image_token_estimate() {
209
+ return this.fields.image_token_estimate;
210
+ }
211
+ get keep_recent() {
212
+ return this.fields.keep_recent;
213
+ }
214
+ get compact_preview_chars() {
215
+ return this.fields.compact_preview_chars;
216
+ }
217
+ get compact_retain_ratio() {
218
+ return this.fields.compact_retain_ratio;
219
+ }
220
+ get clip_head_ratio() {
221
+ return this.fields.clip_head_ratio;
222
+ }
223
+ get observation_page_chars() {
224
+ return this.fields.observation_page_chars;
225
+ }
226
+ get pinned_overflow() {
227
+ return this.fields.pinned_overflow;
228
+ }
229
+ static fromEnv(env = process.env) {
230
+ const floats = [
231
+ ["chars_per_token", "FREELLAMA_AGENT_CHARS_PER_TOKEN"],
232
+ ["compact_retain_ratio", "FREELLAMA_AGENT_COMPACT_RETAIN_RATIO"],
233
+ ["clip_head_ratio", "FREELLAMA_AGENT_CLIP_HEAD_RATIO"]
234
+ ];
235
+ return new _ContextPolicy({
236
+ chars_per_token: envFloat(env, "FREELLAMA_AGENT_CHARS_PER_TOKEN", CHARS_PER_TOKEN, 1e-6),
237
+ safety_margin_tokens: envInt(env, "FREELLAMA_AGENT_SAFETY_MARGIN_TOKENS", SAFETY_MARGIN_TOKENS),
238
+ image_token_estimate: envInt(env, "FREELLAMA_AGENT_IMAGE_TOKEN_ESTIMATE", IMAGE_TOKEN_ESTIMATE),
239
+ keep_recent: envInt(env, "FREELLAMA_AGENT_KEEP_RECENT", DEFAULT_KEEP_RECENT),
240
+ compact_preview_chars: envInt(env, "FREELLAMA_AGENT_COMPACT_PREVIEW_CHARS", COMPACT_PREVIEW_CHARS, 1),
241
+ compact_retain_ratio: envFloat(env, "FREELLAMA_AGENT_COMPACT_RETAIN_RATIO", COMPACT_RETAIN_RATIO, 1e-6),
242
+ clip_head_ratio: envFloat(env, "FREELLAMA_AGENT_CLIP_HEAD_RATIO", CLIP_HEAD_RATIO, 1e-6),
243
+ observation_page_chars: envInt(env, "FREELLAMA_AGENT_OBSERVATION_PAGE_CHARS", OBSERVATION_PAGE_CHARS, 1),
244
+ pinned_overflow: (env.FREELLAMA_AGENT_PINNED_OVERFLOW ?? "error").trim().toLowerCase()
245
+ }, floats.filter(([, name]) => env[name] !== void 0 && env[name] !== "").map(([key]) => key));
246
+ }
247
+ metadata() {
248
+ return { ...this.fields };
249
+ }
250
+ };
251
+ var AgentRuntimeConfig = class _AgentRuntimeConfig {
252
+ constructor(fields) {
253
+ this.fields = fields;
254
+ }
255
+ fields;
256
+ get max_turns() {
257
+ return this.fields.max_turns;
258
+ }
259
+ get num_ctx() {
260
+ return this.fields.num_ctx;
261
+ }
262
+ get num_predict() {
263
+ return this.fields.num_predict;
264
+ }
265
+ get temperature() {
266
+ return this.fields.temperature;
267
+ }
268
+ get seed() {
269
+ return this.fields.seed;
270
+ }
271
+ get request_timeout_seconds() {
272
+ return this.fields.request_timeout_seconds;
273
+ }
274
+ get retry_attempts() {
275
+ return this.fields.retry_attempts;
276
+ }
277
+ get retry_backoff_seconds() {
278
+ return this.fields.retry_backoff_seconds;
279
+ }
280
+ get tool_timeout_seconds() {
281
+ return this.fields.tool_timeout_seconds;
282
+ }
283
+ get keep_alive() {
284
+ return this.fields.keep_alive;
285
+ }
286
+ get think() {
287
+ return this.fields.think;
288
+ }
289
+ get max_parse_repairs() {
290
+ return this.fields.max_parse_repairs;
291
+ }
292
+ get parse_repair_echo_chars() {
293
+ return this.fields.parse_repair_echo_chars;
294
+ }
295
+ get context() {
296
+ return this.fields.context;
297
+ }
298
+ static fromEnv(options) {
299
+ const env = options.env ?? process.env;
300
+ const context = ContextPolicy.fromEnv(env);
301
+ const config = new _AgentRuntimeConfig({
302
+ max_turns: envInt(env, "FREELLAMA_AGENT_MAX_TURNS", 10, 1),
303
+ num_ctx: envInt(env, "FREELLAMA_AGENT_NUM_CTX", 8192, 1),
304
+ num_predict: envInt(env, "FREELLAMA_AGENT_NUM_PREDICT", 512, 1),
305
+ temperature: envFloat(env, "FREELLAMA_AGENT_TEMPERATURE", 0),
306
+ seed: envInt(env, "FREELLAMA_AGENT_SEED", 42),
307
+ request_timeout_seconds: envFloat(env, "FREELLAMA_AGENT_REQUEST_TIMEOUT_SECONDS", 600, 1e-3),
308
+ retry_attempts: envInt(env, "FREELLAMA_AGENT_RETRY_ATTEMPTS", 2, 1),
309
+ retry_backoff_seconds: envFloat(env, "FREELLAMA_AGENT_RETRY_BACKOFF_SECONDS", 5),
310
+ tool_timeout_seconds: envFloat(env, "FREELLAMA_AGENT_TOOL_TIMEOUT_SECONDS", options.defaultToolTimeoutSeconds, 1e-3),
311
+ keep_alive: (env.FREELLAMA_AGENT_KEEP_ALIVE ?? "5m").trim(),
312
+ think: envBool(env, "FREELLAMA_AGENT_THINK", false),
313
+ max_parse_repairs: envInt(env, "FREELLAMA_AGENT_MAX_PARSE_REPAIRS", MAX_PARSE_REPAIRS, 0),
314
+ parse_repair_echo_chars: envInt(env, "FREELLAMA_AGENT_PARSE_REPAIR_ECHO_CHARS", 500, 1),
315
+ context
316
+ });
317
+ if (contextBudget(config.num_ctx, config.num_predict, context.safety_margin_tokens) <= 0) {
318
+ throw new ValidationError("FREELLAMA_AGENT_NUM_CTX must exceed FREELLAMA_AGENT_NUM_PREDICT plus FREELLAMA_AGENT_SAFETY_MARGIN_TOKENS");
319
+ }
320
+ if (!config.keep_alive) throw new ValidationError("FREELLAMA_AGENT_KEEP_ALIVE must not be empty");
321
+ return config;
322
+ }
323
+ metadata() {
324
+ const { context: _context, ...rest } = this.fields;
325
+ return { ...rest };
326
+ }
327
+ };
328
+
329
+ // src/research/text.ts
330
+ var SPACE_CLASS = "\\p{White_Space}\\x1c-\\x1f";
331
+ var SPACE = new RegExp(`^[${SPACE_CLASS}]$`, "u");
332
+ var EDGE_SPACE = new RegExp(`^[${SPACE_CLASS}]+|[${SPACE_CLASS}]+$`, "gu");
333
+ var isSpace = (char) => char !== void 0 && SPACE.test(char);
334
+ var trimSpace = (text) => text.replace(EDGE_SPACE, "");
335
+ var ASTRAL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
336
+ function cpLength(text) {
337
+ if (!ASTRAL.test(text)) return text.length;
338
+ let count = 0;
339
+ for (const _ of text) count += 1;
340
+ return count;
341
+ }
342
+ function cpAdvance(text, start, count) {
343
+ let index = start;
344
+ for (let step = 0; step < count && index < text.length; step += 1) {
345
+ const code = text.charCodeAt(index);
346
+ const pair = code >= 55296 && code <= 56319 && index + 1 < text.length && text.charCodeAt(index + 1) >= 56320 && text.charCodeAt(index + 1) <= 57343;
347
+ index += pair ? 2 : 1;
348
+ }
349
+ return index;
350
+ }
351
+ function cpSlice(text, start, end) {
352
+ if (!ASTRAL.test(text)) return text.slice(start, end);
353
+ const from = cpAdvance(text, 0, start);
354
+ if (end === void 0) return text.slice(from);
355
+ if (end <= start) return "";
356
+ return text.slice(from, cpAdvance(text, from, end - start));
357
+ }
358
+ function cpTail(text, count) {
359
+ const length = cpLength(text);
360
+ return count >= length ? text : cpSlice(text, length - count);
361
+ }
362
+ var UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
363
+ function decodeUtf8(bytes, source) {
364
+ try {
365
+ return UTF8.decode(bytes);
366
+ } catch {
367
+ throw new ValidationError(`${source} is not valid UTF-8`);
368
+ }
369
+ }
370
+ function normalizeNewlines(text) {
371
+ return text.replace(/\r\n?/g, "\n");
372
+ }
373
+
374
+ // src/research/fit.ts
375
+ var COMPACT_MARKER = "[earlier observation compacted to save context";
376
+ var estimateText = (value) => canonicalJson(value, { sortKeys: false, ensureAscii: false });
377
+ function estimateTokens(text, charsPerToken = CHARS_PER_TOKEN) {
378
+ return Math.floor(cpLength(text) / charsPerToken) + 1;
379
+ }
380
+ var UNMEASURED_BYTES_PER_TOKEN_MILLI = 3e3;
381
+ function platformBytesPerTokenMilli(learnedMilli) {
382
+ if (learnedMilli === null || learnedMilli === void 0) return UNMEASURED_BYTES_PER_TOKEN_MILLI;
383
+ return Math.min(Math.max(Math.floor(learnedMilli / 10) * 9, 1e3), 8e3);
384
+ }
385
+ function platformTextTokens(text, learnedMilli) {
386
+ return Math.ceil(Buffer.byteLength(text, "utf8") * 1e3 / platformBytesPerTokenMilli(learnedMilli));
387
+ }
388
+ function messagesTokens(messages, policy = new ContextPolicy(), learnedMilli = null) {
389
+ let total = 0;
390
+ for (const message of messages) {
391
+ const { images = [], ...text } = message;
392
+ if (Array.isArray(images)) total += images.length * policy.image_token_estimate;
393
+ if (learnedMilli !== null) {
394
+ total += platformTextTokens(JSON.stringify(text), learnedMilli) + 1;
395
+ continue;
396
+ }
397
+ const { content = "", ...metadata } = text;
398
+ total += estimateTokens(typeof content === "string" ? content : estimateText(content), policy.chars_per_token);
399
+ total += estimateTokens(estimateText(metadata), policy.chars_per_token);
400
+ }
401
+ return total;
402
+ }
403
+ var ContextBudgeter = class {
404
+ policy;
405
+ platformRatio;
406
+ scale;
407
+ calibration_samples;
408
+ constructor(policy = new ContextPolicy(), options = {}) {
409
+ this.policy = policy;
410
+ this.platformRatio = options.platformRatio ?? null;
411
+ this.scale = this.platformRatio ? 1 : Math.max(options.initialScale ?? 1, 1);
412
+ this.calibration_samples = this.platformRatio ? 0 : Math.max(Math.trunc(options.calibrationSamples ?? 0), 0);
413
+ }
414
+ estimate = (messages) => this.platformRatio ? messagesTokens(messages, this.policy, this.platformRatio.bytes_per_token_milli) : Math.ceil(messagesTokens(messages, this.policy) * this.scale);
415
+ /** Calibrate the fallback estimate; returns whether the multiplier rose (and should persist). */
416
+ observe(messages, promptEvalCount) {
417
+ if (this.platformRatio) return false;
418
+ if (typeof promptEvalCount !== "number" || !Number.isFinite(promptEvalCount)) return false;
419
+ const actual = Math.trunc(promptEvalCount);
420
+ if (actual <= 0) return false;
421
+ const baseline = Math.max(messagesTokens(messages, this.policy), 1);
422
+ const previous = this.scale;
423
+ this.scale = Math.max(this.scale, actual / baseline);
424
+ this.calibration_samples += 1;
425
+ return this.scale > previous;
426
+ }
427
+ metadata() {
428
+ const ratio = this.platformRatio;
429
+ return {
430
+ token_counting: ratio ? "platform_learned_ratio" : "local_chars_per_token",
431
+ estimate_scale: Math.round(this.scale * 1e6) / 1e6,
432
+ calibration_samples: this.calibration_samples,
433
+ bytes_per_token_milli: ratio?.bytes_per_token_milli ?? null,
434
+ bytes_per_token_milli_with_margin: ratio ? platformBytesPerTokenMilli(ratio.bytes_per_token_milli) : null,
435
+ bytes_per_token_samples: ratio?.bytes_per_token_samples ?? null
436
+ };
437
+ }
438
+ };
439
+ function clip(text, limit, headRatio = CLIP_HEAD_RATIO) {
440
+ if (!(headRatio > 0 && headRatio < 1)) throw new ValidationError("head_ratio must be between 0 and 1");
441
+ const length = cpLength(text);
442
+ if (limit <= 0 || length <= limit) return text;
443
+ const marker = (dropped2) => `
444
+ \u2026 [${dropped2} characters elided] \u2026
445
+ `;
446
+ const usable = Math.max(limit - cpLength(marker(length)), 0);
447
+ if (usable === 0) return cpSlice(text, 0, limit);
448
+ const headChars = Math.trunc(usable * headRatio);
449
+ const tailChars = usable - headChars;
450
+ const dropped = length - headChars - tailChars;
451
+ const tail = tailChars ? cpTail(text, tailChars) : "";
452
+ return cpSlice(text, 0, headChars) + marker(dropped) + tail;
453
+ }
454
+ var STEP_HEADER = /^Observation \(step ([0-9]+)\):\n/;
455
+ var LOOP_REMINDER = /\n\n(?:Commands|Tool calls) remaining: [0-9]+\. Finish now if the task is answerable; do not repeat prior (?:commands|calls)\.(?=\n?$)/g;
456
+ var PAGE_FOOTER = /\n\n\[page ([0-9]+) of ([0-9]+) — [0-9]+ characters total, nothing discarded\. /;
457
+ var decimal = (digits) => BigInt(digits).toString();
458
+ function compact(content, policy) {
459
+ if (content.startsWith(COMPACT_MARKER)) return content;
460
+ const stepMatch = STEP_HEADER.exec(content);
461
+ let recovery = "";
462
+ let body = content;
463
+ if (stepMatch) {
464
+ const step = decimal(stepMatch[1]);
465
+ body = content.slice(stepMatch[0].length);
466
+ const newline = body.indexOf("\n");
467
+ const ledgerLine = newline < 0 ? body : body.slice(0, newline);
468
+ const ledger = ledgerLine.startsWith("[evidence ledger: ") && ledgerLine.endsWith("]") ? ledgerLine : "";
469
+ if (ledger && newline >= 0) body = body.slice(newline + 1);
470
+ body = body.replace(LOOP_REMINDER, "");
471
+ const footer = PAGE_FOOTER.exec(body);
472
+ const page = footer ? decimal(footer[1]) : "1";
473
+ const total = footer ? decimal(footer[2]) : "1";
474
+ if (footer) body = body.slice(0, footer.index);
475
+ recovery = `; step ${step}, page ${page} of ${total}; retrieve stored output: {"action":"page","step":${step},"page":${page}}`;
476
+ if (ledger) recovery += `; ${ledger}`;
477
+ }
478
+ const preview = clip(body, policy.compact_preview_chars, policy.clip_head_ratio);
479
+ return `${COMPACT_MARKER}${recovery}] ${preview}`;
480
+ }
481
+ var textContent = (message) => typeof message?.content === "string" ? message.content : null;
482
+ var contentLength = (message) => cpLength(textContent(message) ?? "");
483
+ function widestIndex(indexes2, messages) {
484
+ let best;
485
+ let bestLength = -1;
486
+ for (const index of indexes2) {
487
+ const length = contentLength(messages[index]);
488
+ if (length > bestLength) {
489
+ best = index;
490
+ bestLength = length;
491
+ }
492
+ }
493
+ return best;
494
+ }
495
+ function textCandidates(messages, from, to, minimum) {
496
+ const result = [];
497
+ for (let index = from; index < to; index += 1) {
498
+ if (textContent(messages[index]) !== null && contentLength(messages[index]) > minimum) result.push(index);
499
+ }
500
+ return result;
501
+ }
502
+ function fitToContext(messages, options) {
503
+ const policy = options.policy ?? new ContextPolicy();
504
+ const keepRecent = options.keepRecent ?? policy.keep_recent;
505
+ const budget = contextBudget(options.numCtx, options.numPredict, policy.safety_margin_tokens);
506
+ const count = options.tokenCounter ?? ((value) => messagesTokens(value, policy));
507
+ if (count(messages) <= budget) return [messages, false];
508
+ const pinned = Math.min(2, messages.length);
509
+ const trimmed = messages.map((message) => ({ ...message }));
510
+ const observations = [];
511
+ for (let index = pinned; index < trimmed.length; index += 1) {
512
+ const role = trimmed[index].role;
513
+ if ((role === "user" || role === "tool") && textContent(trimmed[index]) !== null) observations.push(index);
514
+ }
515
+ const protectedIndexes = new Set(keepRecent ? observations.slice(-keepRecent) : []);
516
+ let compacted = false;
517
+ const target = Math.floor(budget * COMPACTION_TARGET_RATIO);
518
+ for (const index of observations) {
519
+ if (count(trimmed) <= target) break;
520
+ if (protectedIndexes.has(index)) continue;
521
+ const content = textContent(trimmed[index]);
522
+ const replacement = compact(content, policy);
523
+ if (cpLength(replacement) < cpLength(content)) {
524
+ trimmed[index].content = replacement;
525
+ compacted = true;
526
+ }
527
+ }
528
+ const shrinkWidest = (candidates) => {
529
+ const widest = widestIndex(candidates, trimmed);
530
+ if (widest === void 0) return false;
531
+ const content = textContent(trimmed[widest]) ?? "";
532
+ const keep = Math.max(Math.trunc(cpLength(content) * policy.compact_retain_ratio), policy.compact_preview_chars);
533
+ if (keep >= cpLength(content)) return false;
534
+ trimmed[widest].content = clip(content, keep, policy.clip_head_ratio);
535
+ compacted = true;
536
+ return true;
537
+ };
538
+ for (let guard = 0; count(trimmed) > budget && guard < 200; guard += 1) {
539
+ if (!shrinkWidest(observations)) break;
540
+ }
541
+ for (const message of trimmed.slice(pinned, Math.max(trimmed.length - 2, pinned))) {
542
+ const thinking = message.thinking;
543
+ if (count(trimmed) <= budget) break;
544
+ if (typeof thinking === "string" && cpLength(thinking) > policy.compact_preview_chars) {
545
+ message.thinking = compact(thinking, policy);
546
+ compacted = true;
547
+ }
548
+ }
549
+ for (let guard = 0; count(trimmed) > budget && guard < 200; guard += 1) {
550
+ const candidates = textCandidates(trimmed, pinned, trimmed.length, policy.compact_preview_chars);
551
+ if (candidates.length === 0 || !shrinkWidest(candidates)) break;
552
+ }
553
+ for (let guard = 0; policy.pinned_overflow === "clip" && count(trimmed) > budget && guard < 200; guard += 1) {
554
+ const candidates = textCandidates(trimmed, 0, pinned, policy.compact_preview_chars);
555
+ if (candidates.length === 0 || !shrinkWidest(candidates)) break;
556
+ }
557
+ if (count(trimmed) > budget) {
558
+ throw new ValidationError(
559
+ `pinned system/question content and minimum history exceed context budget: ${count(trimmed)} > ${budget} estimated tokens. Increase FREELLAMA_AGENT_NUM_CTX, reduce the task/instructions, or explicitly set FREELLAMA_AGENT_PINNED_OVERFLOW=clip.`
560
+ );
561
+ }
562
+ return [trimmed, compacted];
563
+ }
564
+
565
+ // src/research/context-manager.ts
566
+ function timeNs() {
567
+ const subMillis = process.hrtime.bigint() % 1000000n;
568
+ return (BigInt(Date.now()) * 1000000n + subMillis).toString();
569
+ }
570
+ var AgentContextManager = class {
571
+ runtime;
572
+ model;
573
+ calibrationDir;
574
+ calibrationIdentity;
575
+ budgeter;
576
+ calibrationSource;
577
+ compactions = 0;
578
+ constructor(runtime, options = {}) {
579
+ this.runtime = runtime;
580
+ this.model = options.model ?? "";
581
+ const platformRatio = options.platformRatio ?? null;
582
+ this.calibrationDir = platformRatio ? null : options.calibrationDir ?? null;
583
+ this.calibrationIdentity = options.calibrationIdentity ?? "";
584
+ const loaded = this.loadCalibration();
585
+ this.budgeter = new ContextBudgeter(runtime.context, {
586
+ initialScale: loaded?.scale ?? 1,
587
+ calibrationSamples: loaded?.samples ?? 0,
588
+ platformRatio
589
+ });
590
+ this.calibrationSource = platformRatio ? "not_applied" : loaded ? "persistent_model_cache" : "current_process";
591
+ }
592
+ modelKey() {
593
+ return createHash("sha256").update(this.calibrationContractJson(), "utf8").digest("hex").slice(0, 24);
594
+ }
595
+ calibrationContract() {
596
+ return {
597
+ schema_version: 2,
598
+ model: this.model,
599
+ identity: this.calibrationIdentity,
600
+ estimator_version: 1,
601
+ chars_per_token: this.runtime.context.chars_per_token,
602
+ image_token_estimate: this.runtime.context.image_token_estimate
603
+ };
604
+ }
605
+ /** The canonical JSON the model key hashes. */
606
+ calibrationContractJson() {
607
+ return canonicalJson(this.calibrationContract(), { floatKeys: this.runtime.context.floatKeys });
608
+ }
609
+ /** One persisted calibration record: the contract plus the float `scale` and integer `samples`. */
610
+ calibrationRecordJson(scale, samples) {
611
+ const floatKeys = /* @__PURE__ */ new Set([...this.runtime.context.floatKeys, "scale"]);
612
+ return `${canonicalJson({ ...this.calibrationContract(), scale, samples }, { floatKeys })}
613
+ `;
614
+ }
615
+ /** Record files `<key>-*.json`; the key is hexadecimal, so only the literal affixes matter. */
616
+ records(directory, key) {
617
+ const prefix = `${key}-`;
618
+ return readdirSync(directory).filter((name) => name.startsWith(prefix) && name.endsWith(".json") && name.length >= prefix.length + 5);
619
+ }
620
+ loadCalibration() {
621
+ if (!this.model || this.calibrationDir === null || !existsSync(this.calibrationDir)) return null;
622
+ let bestScale = 1;
623
+ let samples = 0;
624
+ const contract = this.calibrationContract();
625
+ for (const name of this.records(this.calibrationDir, this.modelKey())) {
626
+ let record;
627
+ try {
628
+ record = JSON.parse(readFileSync(path.join(this.calibrationDir, name), "utf8"));
629
+ } catch {
630
+ continue;
631
+ }
632
+ if (!isRecord(record)) continue;
633
+ if (Object.entries(contract).some(([key, value]) => record[key] !== value)) continue;
634
+ const scale = record.scale ?? 1;
635
+ const recordSamples = record.samples ?? 0;
636
+ if (typeof scale !== "number" || typeof recordSamples !== "number") continue;
637
+ if (!Number.isFinite(scale) || scale < 1 || !(Math.trunc(recordSamples) > 0)) continue;
638
+ bestScale = Math.max(bestScale, scale);
639
+ samples = Math.max(samples, Math.trunc(recordSamples));
640
+ }
641
+ return samples ? { scale: bestScale, samples } : null;
642
+ }
643
+ persistCalibration() {
644
+ if (!this.model || this.calibrationDir === null) return;
645
+ mkdirSync(this.calibrationDir, { recursive: true });
646
+ const key = this.modelKey();
647
+ const stem = `${key}-${timeNs()}-${process.pid}`;
648
+ const temporary = path.join(this.calibrationDir, `${stem}.tmp`);
649
+ writeFileSync(temporary, this.calibrationRecordJson(this.budgeter.scale, this.budgeter.calibration_samples), "utf8");
650
+ renameSync(temporary, path.join(this.calibrationDir, `${stem}.json`));
651
+ const records = this.records(this.calibrationDir, key).sort();
652
+ for (const stale of records.slice(0, Math.max(records.length - 32, 0))) {
653
+ try {
654
+ unlinkSync(path.join(this.calibrationDir, stale));
655
+ } catch (error) {
656
+ if (error.code !== "ENOENT") throw error;
657
+ }
658
+ }
659
+ }
660
+ fit(messages) {
661
+ const [fitted, compacted] = fitToContext(messages, {
662
+ numCtx: this.runtime.num_ctx,
663
+ numPredict: this.runtime.num_predict,
664
+ policy: this.runtime.context,
665
+ tokenCounter: this.budgeter.estimate
666
+ });
667
+ this.compactions += compacted ? 1 : 0;
668
+ return fitted;
669
+ }
670
+ observe(messages, promptEvalCount) {
671
+ if (this.budgeter.observe(messages, promptEvalCount)) this.persistCalibration();
672
+ }
673
+ metadata() {
674
+ return {
675
+ ...this.runtime.context.metadata(),
676
+ ...this.budgeter.metadata(),
677
+ calibration_source: this.calibrationSource,
678
+ calibration_scope: this.calibrationIdentity ? "provided_identity_and_estimator" : "model_tag_and_estimator",
679
+ compaction_target_ratio: COMPACTION_TARGET_RATIO,
680
+ estimated_input_budget: contextBudget(this.runtime.num_ctx, this.runtime.num_predict, this.runtime.context.safety_margin_tokens),
681
+ compactions: this.compactions
682
+ };
683
+ }
684
+ };
685
+ function writeFailureResult(resultPath, answer) {
686
+ mkdirSync(path.dirname(resultPath), { recursive: true });
687
+ writeFileSync(resultPath, `${JSON.stringify({ final_answer: answer, tool_calls: [], usage: {} }, null, 2)}
688
+ `, "utf8");
689
+ }
690
+
691
+ // src/research/observations.ts
692
+ import { randomBytes } from "node:crypto";
693
+ import { closeSync, existsSync as existsSync2, mkdirSync as mkdirSync2, mkdtempSync, openSync, readFileSync as readFileSync2, readSync, renameSync as renameSync2, rmSync, unlinkSync as unlinkSync2, writeSync } from "node:fs";
694
+ import { tmpdir } from "node:os";
695
+ import path2 from "node:path";
696
+ function pageFooter(step, page, total, totalChars) {
697
+ if (total <= 1) return "";
698
+ const head = `
699
+
700
+ [page ${page} of ${total} \u2014 ${totalChars} characters total, nothing discarded. `;
701
+ if (page >= total) return `${head}End of stored output.]`;
702
+ return `${head}For the next page send exactly: {"action":"page","step":${step},"page":${page + 1}} \u2014 that re-reads stored output and does NOT re-run the command.]`;
703
+ }
704
+ function encodeStrict(text) {
705
+ if (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(text)) {
706
+ throw new ValidationError("observation contains a lone surrogate and cannot be encoded as UTF-8");
707
+ }
708
+ return Buffer.from(text, "utf8");
709
+ }
710
+ function writeAll(fd, data) {
711
+ let offset = 0;
712
+ while (offset < data.length) offset += writeSync(fd, data, offset, data.length - offset);
713
+ }
714
+ function readAt(file, position, length) {
715
+ const fd = openSync(file, "r");
716
+ try {
717
+ const buffer = Buffer.alloc(length);
718
+ let read = 0;
719
+ while (read < length) {
720
+ const count = readSync(fd, buffer, read, length - read, position + read);
721
+ if (count === 0) break;
722
+ read += count;
723
+ }
724
+ return buffer.subarray(0, read);
725
+ } finally {
726
+ closeSync(fd);
727
+ }
728
+ }
729
+ var utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
730
+ var ObservationStore = class {
731
+ directory;
732
+ pageSize;
733
+ byStep = /* @__PURE__ */ new Map();
734
+ constructor(pageSize = OBSERVATION_PAGE_CHARS) {
735
+ if (pageSize <= 0) throw new ValidationError("observation page size must be > 0");
736
+ this.directory = mkdtempSync(path2.join(path2.resolve(tmpdir()), "freellama-observations-"));
737
+ this.pageSize = pageSize;
738
+ }
739
+ close() {
740
+ this.byStep.clear();
741
+ rmSync(this.directory, { recursive: true, force: true });
742
+ }
743
+ put(step, text, metadata = {}) {
744
+ if (step < 1) throw new ValidationError("observation step must be positive");
745
+ const file = path2.join(this.directory, `${step}.utf8`);
746
+ const indexPath = path2.join(this.directory, `${step}.index`);
747
+ let start = 0;
748
+ let offset = 0;
749
+ let pages = 0;
750
+ let characters = 0;
751
+ const output = openSync(file, "w");
752
+ const index = openSync(indexPath, "w");
753
+ try {
754
+ while (start < text.length) {
755
+ let end = cpAdvance(text, start, this.pageSize);
756
+ if (end < text.length) {
757
+ const newline = text.lastIndexOf("\n", end - 1);
758
+ if (newline >= start) end = newline + 1;
759
+ }
760
+ const slice = text.slice(start, end);
761
+ characters += cpLength(slice);
762
+ const encoded = encodeStrict(slice);
763
+ writeAll(output, encoded);
764
+ const nextOffset = offset + encoded.length;
765
+ const record = Buffer.alloc(16);
766
+ record.writeBigUInt64BE(BigInt(offset), 0);
767
+ record.writeBigUInt64BE(BigInt(nextOffset), 8);
768
+ writeAll(index, record);
769
+ offset = nextOffset;
770
+ pages += 1;
771
+ start = end;
772
+ }
773
+ if (!pages) {
774
+ writeAll(index, Buffer.alloc(16));
775
+ pages = 1;
776
+ }
777
+ } finally {
778
+ closeSync(output);
779
+ closeSync(index);
780
+ }
781
+ const bounded = {};
782
+ for (const [key, value] of Object.entries(metadata)) {
783
+ if (key === "action" || key === "status" || key === "source") bounded[key] = clip(String(value), 240);
784
+ }
785
+ this.byStep.set(step, { path: file, indexPath, characters, pages, metadata: bounded });
786
+ }
787
+ /** Explicit full materialization for consumers that need it; adapters use view/writeResult. */
788
+ get(step) {
789
+ const record = this.byStep.get(step);
790
+ return record ? utf8.decode(readFileSync2(record.path)) : null;
791
+ }
792
+ pageCount(step) {
793
+ const record = this.byStep.get(step);
794
+ if (!record) throw new Error(`no stored observation for step ${step}`);
795
+ return record.pages;
796
+ }
797
+ /** Return `[body, footer]` for one page of a stored observation. */
798
+ view(step, page = 1) {
799
+ const record = this.byStep.get(step);
800
+ if (!record) {
801
+ const known = [...this.byStep.keys()].sort((a, b) => a - b).join(", ") || "none yet";
802
+ return [`no stored output for step ${step} (known steps: ${known})`, ""];
803
+ }
804
+ const chosen = Math.max(1, Math.min(page, record.pages));
805
+ const entry = readAt(record.indexPath, (chosen - 1) * 16, 16);
806
+ const start = Number(entry.readBigUInt64BE(0));
807
+ const end = Number(entry.readBigUInt64BE(8));
808
+ const chunk = utf8.decode(readAt(record.path, start, end - start));
809
+ return [chunk, pageFooter(step, chosen, record.pages, record.characters)];
810
+ }
811
+ message(step, page = 1) {
812
+ const [body, footer] = this.view(step, page);
813
+ const record = this.byStep.get(step);
814
+ if (!record) return `Observation (step ${step}):
815
+ ${body}`;
816
+ const chosen = Math.max(1, Math.min(page, record.pages));
817
+ const ledger = { ...record.metadata, step, page: chosen, total_pages: record.pages, trust: "untrusted_tool_output" };
818
+ return `Observation (step ${step}):
819
+ [evidence ledger: ${JSON.stringify(ledger)}]
820
+ ${body}${footer}`;
821
+ }
822
+ /** Decode a stored UTF-8 file in 64 KiB reads, never splitting a code point across chunks. */
823
+ streamStored(file, consume) {
824
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
825
+ const fd = openSync(file, "r");
826
+ try {
827
+ const buffer = Buffer.alloc(65536);
828
+ for (; ; ) {
829
+ const count = readSync(fd, buffer, 0, buffer.length, null);
830
+ if (count === 0) break;
831
+ const chunk = decoder.decode(buffer.subarray(0, count), { stream: true });
832
+ if (chunk) consume(chunk);
833
+ }
834
+ const rest = decoder.decode();
835
+ if (rest) consume(rest);
836
+ } finally {
837
+ closeSync(fd);
838
+ }
839
+ }
840
+ /** Atomically write the public JSON result, restoring full call results from disk. */
841
+ writeResult(resultPath, result) {
842
+ const directory = path2.dirname(resultPath);
843
+ mkdirSync2(directory, { recursive: true });
844
+ const temporary = path2.join(directory, `.${path2.basename(resultPath)}.${randomBytes(6).toString("hex")}.tmp`);
845
+ let fd = null;
846
+ try {
847
+ fd = openSync(temporary, "wx", 384);
848
+ const out = fd;
849
+ const emit = (text) => writeAll(out, Buffer.from(text, "utf8"));
850
+ emit("{");
851
+ Object.entries(result).forEach(([key, value], fieldIndex) => {
852
+ if (fieldIndex) emit(",");
853
+ emit(`${JSON.stringify(key)}:`);
854
+ if (key !== "tool_calls") {
855
+ emit(JSON.stringify(value) ?? "null");
856
+ return;
857
+ }
858
+ emit("[");
859
+ value.forEach((call, callIndex) => {
860
+ const step = callIndex + 1;
861
+ if (step > 1) emit(",");
862
+ const metadata = {};
863
+ for (const [name, item] of Object.entries(call)) if (name !== "result") metadata[name] = item;
864
+ const encoded = JSON.stringify(metadata);
865
+ emit(encoded.slice(0, -1) + (Object.keys(metadata).length ? "," : "") + '"result":"');
866
+ const record = this.byStep.get(step);
867
+ if (!record) throw new Error(`no stored observation for step ${step}`);
868
+ this.streamStored(record.path, (chunk) => emit(JSON.stringify(chunk).slice(1, -1)));
869
+ emit('"}');
870
+ });
871
+ emit("]");
872
+ });
873
+ emit("}\n");
874
+ closeSync(fd);
875
+ fd = null;
876
+ renameSync2(temporary, resultPath);
877
+ } finally {
878
+ if (fd !== null) closeSync(fd);
879
+ if (existsSync2(temporary)) unlinkSync2(temporary);
880
+ }
881
+ }
882
+ };
883
+
884
+ // src/research/progress-events.ts
885
+ var PROGRESS_ENV = "FREELLAMA_AGENT_PROGRESS";
886
+ var PROGRESS_PREFIX = "@freellama-progress ";
887
+ function progressEmitter(env, write = (line2) => {
888
+ process.stderr.write(line2);
889
+ }) {
890
+ if (env[PROGRESS_ENV] !== "stderr") return void 0;
891
+ return (event) => {
892
+ try {
893
+ write(`${PROGRESS_PREFIX}${JSON.stringify(event)}
894
+ `);
895
+ } catch {
896
+ }
897
+ };
898
+ }
899
+
900
+ // src/research/transport.ts
901
+ import { createHash as createHash2 } from "node:crypto";
902
+ import { readFileSync as readFileSync4 } from "node:fs";
903
+ import { homedir } from "node:os";
904
+ import path3 from "node:path";
905
+
906
+ // src/research/transport-http.ts
907
+ import { readFileSync as readFileSync3 } from "node:fs";
908
+ import http from "node:http";
909
+ import https from "node:https";
910
+ var RECEIPT_BODY_LIMIT = 64 * 1024;
911
+ var HTTPError = class extends Error {
912
+ constructor(code, reason, body = Buffer.alloc(0)) {
913
+ super(`HTTP Error ${code}: ${reason}`);
914
+ this.code = code;
915
+ this.reason = reason;
916
+ this.body = body;
917
+ }
918
+ code;
919
+ reason;
920
+ body;
921
+ name = "HTTPError";
922
+ };
923
+ var URLError = class extends Error {
924
+ constructor(reason) {
925
+ super(`request not sent: ${reason.message}`);
926
+ this.reason = reason;
927
+ }
928
+ reason;
929
+ name = "URLError";
930
+ };
931
+ var RequestTimeoutError = class extends Error {
932
+ name = "RequestTimeoutError";
933
+ constructor(message = "timed out") {
934
+ super(message);
935
+ }
936
+ };
937
+ function requestHeaders(env = process.env) {
938
+ const headers = { "content-type": "application/json" };
939
+ const tokenFile = trimSpace(env.FREELLAMA_AUTH_TOKEN_FILE ?? "");
940
+ if (!tokenFile) return headers;
941
+ const token = trimSpace(decodeUtf8(readFileSync3(tokenFile), "FREELLAMA_AUTH_TOKEN_FILE"));
942
+ if (cpLength(token) < 32 || [...token].some((character) => isSpace(character))) {
943
+ throw new ValidationError("FREELLAMA_AUTH_TOKEN_FILE must contain one token of at least 32 bytes");
944
+ }
945
+ headers.authorization = `Bearer ${token}`;
946
+ return headers;
947
+ }
948
+ function httpRequest(url, options) {
949
+ return new Promise((resolve, reject) => {
950
+ let parsed;
951
+ try {
952
+ parsed = new URL(url);
953
+ } catch {
954
+ reject(new ValidationError(`unknown url type: ${JSON.stringify(url)}`));
955
+ return;
956
+ }
957
+ const client = parsed.protocol === "https:" ? https : parsed.protocol === "http:" ? http : null;
958
+ if (client === null) {
959
+ reject(new URLError(new Error(`unknown url type: ${parsed.protocol.replace(/:$/, "")}`)));
960
+ return;
961
+ }
962
+ const headers = { ...options.headers, connection: "close" };
963
+ if (options.body) headers["content-length"] = String(options.body.length);
964
+ let sent = false;
965
+ let settled = false;
966
+ const { signal } = options;
967
+ const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("request aborted"));
968
+ const fail = (error) => {
969
+ if (settled) return;
970
+ settled = true;
971
+ signal?.removeEventListener("abort", onAbort);
972
+ request.destroy();
973
+ reject(error);
974
+ };
975
+ if (signal?.aborted) {
976
+ reject(signal.reason instanceof Error ? signal.reason : new Error("request aborted"));
977
+ return;
978
+ }
979
+ const request = client.request(parsed, { method: options.method, headers });
980
+ signal?.addEventListener("abort", onAbort, { once: true });
981
+ request.setTimeout(Math.max(options.timeoutSeconds * 1e3, 1), () => {
982
+ fail(sent ? new RequestTimeoutError() : new URLError(new RequestTimeoutError()));
983
+ });
984
+ request.on("finish", () => {
985
+ sent = true;
986
+ });
987
+ request.on("error", (error) => fail(sent ? error : new URLError(error)));
988
+ request.on("response", (response) => {
989
+ const chunks = [];
990
+ let size = 0;
991
+ const status = response.statusCode ?? 0;
992
+ const ok = status >= 200 && status < 300;
993
+ const limit = ok ? options.limit : RECEIPT_BODY_LIMIT + 1;
994
+ const finish = () => {
995
+ if (settled) return;
996
+ settled = true;
997
+ signal?.removeEventListener("abort", onAbort);
998
+ const body = Buffer.concat(chunks);
999
+ request.destroy();
1000
+ if (ok) resolve(body);
1001
+ else reject(new HTTPError(status, response.statusMessage ?? "", body));
1002
+ };
1003
+ response.on("data", (chunk) => {
1004
+ if (limit !== void 0 && size >= limit) return;
1005
+ const piece = limit !== void 0 ? chunk.subarray(0, limit - size) : chunk;
1006
+ chunks.push(piece);
1007
+ size += piece.length;
1008
+ if (limit !== void 0 && size >= limit) finish();
1009
+ });
1010
+ response.on("error", (error) => fail(error));
1011
+ response.on("end", finish);
1012
+ });
1013
+ request.end(options.body);
1014
+ });
1015
+ }
1016
+ function parseJsonBody(body, source) {
1017
+ return JSON.parse(decodeUtf8(body, source));
1018
+ }
1019
+ async function requestJson(url, payload, timeoutSeconds) {
1020
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
1021
+ const response = await httpRequest(url, { method: "POST", headers: requestHeaders(), body, timeoutSeconds });
1022
+ return parseJsonBody(response, "chat response");
1023
+ }
1024
+
1025
+ // src/research/transport.ts
1026
+ var MANAGED_SUFFIX = "/_freellama/v1/tasks";
1027
+ var IDENTITY_LIMIT = 1024 * 1024;
1028
+ var trimSlashes = (text) => text.replace(/\/+$/, "");
1029
+ async function readCatalogJson(url, request = httpRequest) {
1030
+ const payload = await request(url, { method: "GET", headers: requestHeaders(), timeoutSeconds: 5, limit: IDENTITY_LIMIT + 1 });
1031
+ if (payload.length > IDENTITY_LIMIT) throw new ValidationError("model identity catalog exceeds size limit");
1032
+ return parseJsonBody(payload, "model identity catalog");
1033
+ }
1034
+ function catalogEntry(payload, model) {
1035
+ const entries = isRecord(payload) ? payload.models ?? [] : [];
1036
+ if (!Array.isArray(entries)) return null;
1037
+ const names = /* @__PURE__ */ new Set([model]);
1038
+ const lastSegment = model.slice(model.lastIndexOf("/") + 1);
1039
+ if (!lastSegment.includes(":")) names.add(model + ":latest");
1040
+ for (const item of entries) {
1041
+ if (!isRecord(item)) continue;
1042
+ const name = Object.hasOwn(item, "name") ? item.name : item.model;
1043
+ if (typeof name === "object" && name !== null) return "ambiguous";
1044
+ if (typeof name === "string" && names.has(name)) return item;
1045
+ }
1046
+ return null;
1047
+ }
1048
+ var DIGEST = /^(?:sha256:)?[0-9a-fA-F]{64}$/;
1049
+ var sha256 = (data) => createHash2("sha256").update(data, "utf8").digest("hex");
1050
+ async function resolveModelIdentity(endpointInput, model, systemPrompt, options = {}) {
1051
+ const endpoint = trimSlashes(endpointInput);
1052
+ const url = endpoint.endsWith(MANAGED_SUFFIX) ? endpoint.slice(0, -MANAGED_SUFFIX.length) + "/_freellama/v1/models" : endpoint + "/api/tags";
1053
+ const unknown = { identity: "", verified: false, scope: "current_process_only" };
1054
+ try {
1055
+ const entry = catalogEntry(await (options.readJson ?? readCatalogJson)(url), model);
1056
+ if (entry === "ambiguous") return unknown;
1057
+ const digestValue = entry?.digest;
1058
+ if (typeof digestValue !== "string" || !DIGEST.test(digestValue)) return unknown;
1059
+ const digest = (digestValue.startsWith("sha256:") ? digestValue.slice("sha256:".length) : digestValue).toLowerCase();
1060
+ const contract = { endpoint, model, digest, system_hash: sha256(systemPrompt) };
1061
+ return {
1062
+ identity: sha256(canonicalJson(contract)),
1063
+ verified: true,
1064
+ scope: "manifest_digest_and_system_prompt",
1065
+ digest
1066
+ };
1067
+ } catch (error) {
1068
+ if (isTransportError(error) || isSystemError(error) || error instanceof ValidationError || error instanceof SyntaxError || error instanceof TypeError) {
1069
+ return unknown;
1070
+ }
1071
+ throw error;
1072
+ }
1073
+ }
1074
+ async function resolvePlatformTokenRatio(serveEndpoint, model, options = {}) {
1075
+ try {
1076
+ const url = `${trimSlashes(serveEndpoint)}/_freellama/v1/models`;
1077
+ const entry = catalogEntry(await (options.readJson ?? readCatalogJson)(url), model);
1078
+ if (entry === null || entry === "ambiguous") return null;
1079
+ const milli = entry.bytes_per_token_milli;
1080
+ if (typeof milli !== "number" || !Number.isSafeInteger(milli) || milli <= 0) return null;
1081
+ const samples = entry.bytes_per_token_samples;
1082
+ return {
1083
+ bytes_per_token_milli: milli,
1084
+ bytes_per_token_samples: typeof samples === "number" && Number.isSafeInteger(samples) && samples > 0 ? samples : 1
1085
+ };
1086
+ } catch {
1087
+ return null;
1088
+ }
1089
+ }
1090
+ function isTransportError(error) {
1091
+ return error instanceof HTTPError || error instanceof URLError || error instanceof RequestTimeoutError;
1092
+ }
1093
+ function retryableChatError(error) {
1094
+ if (error instanceof HTTPError) {
1095
+ if (error.code !== 503 || error.body.length > RECEIPT_BODY_LIMIT) return false;
1096
+ try {
1097
+ const body = parseJsonBody(error.body, "retry receipt");
1098
+ if (!isRecord(body) || body.retry_safe === false || body.execution_outcome !== void 0 && body.execution_outcome !== "not_started") return false;
1099
+ return body.retry_safe === true && body.execution_outcome === "not_started" || ["admission_timeout", "resource_admission_unavailable", "upstream_circuit_open", "model_lifecycle_busy"].includes(String(body.code));
1100
+ } catch {
1101
+ return false;
1102
+ }
1103
+ }
1104
+ return error instanceof URLError && error.reason.code === "ECONNREFUSED";
1105
+ }
1106
+ var RECEIPT_FIELDS = /* @__PURE__ */ new Set([
1107
+ "error",
1108
+ "code",
1109
+ "reason",
1110
+ "retry_after_seconds",
1111
+ "resource_admission",
1112
+ "lifecycle",
1113
+ "placement",
1114
+ "execution",
1115
+ "admission",
1116
+ "route",
1117
+ "decision",
1118
+ "quality",
1119
+ "feedback",
1120
+ "execution_outcome",
1121
+ "retry_safe",
1122
+ "result_recoverable",
1123
+ "job_id",
1124
+ "last_observed_status"
1125
+ ]);
1126
+ var PAYLOAD_FIELDS = /* @__PURE__ */ new Set([
1127
+ "prompt",
1128
+ "system",
1129
+ "system_prompt",
1130
+ "messages",
1131
+ "content",
1132
+ "input",
1133
+ "images",
1134
+ "tools",
1135
+ "request",
1136
+ "request_options",
1137
+ "headers",
1138
+ "authorization",
1139
+ "token",
1140
+ "api_key",
1141
+ "auth_token"
1142
+ ]);
1143
+ function receiptValue(value) {
1144
+ if (isRecord(value)) {
1145
+ const result = {};
1146
+ for (const [key, item] of Object.entries(value)) {
1147
+ if (!PAYLOAD_FIELDS.has(key.toLowerCase())) result[key] = receiptValue(item);
1148
+ }
1149
+ return result;
1150
+ }
1151
+ if (Array.isArray(value)) return value.map(receiptValue);
1152
+ return value;
1153
+ }
1154
+ function errorDiagnostic(error) {
1155
+ if (error instanceof Error) return `${error.name}: ${error.message}`;
1156
+ return String(error);
1157
+ }
1158
+ function chatErrorDetails(error) {
1159
+ let diagnostic = errorDiagnostic(error);
1160
+ if (!(error instanceof HTTPError)) return [diagnostic, null];
1161
+ const details = { status: error.code };
1162
+ try {
1163
+ const body = error.body.subarray(0, RECEIPT_BODY_LIMIT + 1);
1164
+ if (body.length > RECEIPT_BODY_LIMIT) return [diagnostic, details];
1165
+ const payload = parseJsonBody(body, "refusal body");
1166
+ if (!isRecord(payload) || typeof payload.error !== "string") return [diagnostic, details];
1167
+ const receipt = {};
1168
+ for (const [key, value] of Object.entries(payload)) {
1169
+ if (RECEIPT_FIELDS.has(key)) receipt[key] = receiptValue(value);
1170
+ }
1171
+ details.receipt = receipt;
1172
+ diagnostic = `HTTP ${error.code}: ${receipt.error}`;
1173
+ } catch (failure) {
1174
+ if (!(failure instanceof SyntaxError || failure instanceof ValidationError || failure instanceof RangeError)) throw failure;
1175
+ }
1176
+ return [diagnostic, details];
1177
+ }
1178
+ var PromptCacheUsage = class {
1179
+ calls = 0;
1180
+ reportedCalls = 0;
1181
+ reportedTokens = 0;
1182
+ reportedPromptTokens = 0;
1183
+ observe(response) {
1184
+ this.calls += 1;
1185
+ const cached = response.prompt_eval_cached_count;
1186
+ const prompt = response.prompt_eval_count;
1187
+ if (Number.isInteger(cached) && Number.isInteger(prompt) && cached >= 0 && cached <= prompt) {
1188
+ this.reportedCalls += 1;
1189
+ this.reportedTokens += cached;
1190
+ this.reportedPromptTokens += prompt;
1191
+ }
1192
+ }
1193
+ get tokens() {
1194
+ return this.calls > 0 && this.calls === this.reportedCalls ? this.reportedTokens : null;
1195
+ }
1196
+ metadata() {
1197
+ const tokens = this.tokens;
1198
+ return {
1199
+ status: tokens !== null ? "reported" : this.reportedCalls ? "partially_reported" : "not_reported",
1200
+ calls: this.calls,
1201
+ reported_calls: this.reportedCalls,
1202
+ reported_tokens: this.reportedCalls ? this.reportedTokens : null,
1203
+ hit_ratio: tokens !== null && this.reportedPromptTokens ? this.reportedTokens / this.reportedPromptTokens : null
1204
+ };
1205
+ }
1206
+ };
1207
+ var resolvedShift;
1208
+ function contextShift(env = process.env, reset = false) {
1209
+ if (resolvedShift !== void 0 && !reset) return resolvedShift;
1210
+ const read = (value2) => value2 === "ollama_default" || value2 === "off" ? value2 : void 0;
1211
+ let value = read(env.FREELLAMA_CONTEXT_SHIFT?.trim());
1212
+ if (!value) {
1213
+ const file = env.FREELLAMA_RUNTIME_CONFIG || path3.join(env.FREELLAMA_HOME || path3.join(homedir(), ".octocode", "freellama"), "runtime.toml");
1214
+ try {
1215
+ const topLevel = readFileSync4(file, "utf8").split(/^\s*\[/m)[0] ?? "";
1216
+ value = read(/^\s*context_shift\s*=\s*"([^"]*)"/m.exec(topLevel)?.[1]);
1217
+ } catch {
1218
+ }
1219
+ }
1220
+ resolvedShift = value ?? "off";
1221
+ return resolvedShift;
1222
+ }
1223
+ function chatRequest(endpointInput, model, messages, options, think, keepAlive, executionPreference = "auto", minPlacementEvidence = "configured") {
1224
+ const endpoint = trimSlashes(endpointInput);
1225
+ if (endpoint.endsWith(MANAGED_SUFFIX)) {
1226
+ const managedOptions = {};
1227
+ for (const [key, value] of Object.entries(options)) {
1228
+ if (key !== "num_ctx") managedOptions[key] = value;
1229
+ }
1230
+ if (!Object.hasOwn(options, "num_ctx")) throw new ValidationError("managed chat request requires num_ctx");
1231
+ return [endpoint, {
1232
+ task: "coding",
1233
+ objective: "fastest",
1234
+ model,
1235
+ context_tokens: options.num_ctx,
1236
+ execution_preference: executionPreference,
1237
+ min_placement_evidence: minPlacementEvidence,
1238
+ messages,
1239
+ keep_alive: keepAlive,
1240
+ request_options: { format: "json", think, options: managedOptions }
1241
+ }];
1242
+ }
1243
+ return [`${endpoint}/api/chat`, {
1244
+ model,
1245
+ messages,
1246
+ stream: false,
1247
+ truncate: false,
1248
+ ...contextShift() === "ollama_default" ? {} : { shift: false },
1249
+ format: "json",
1250
+ think,
1251
+ keep_alive: keepAlive,
1252
+ options
1253
+ }];
1254
+ }
1255
+ function unwrapChatResponse(payload, executionReceipts) {
1256
+ if (!isRecord(payload)) throw new TypeError("chat response must be a JSON object");
1257
+ const { response } = payload;
1258
+ if (!isRecord(response)) return payload;
1259
+ const route = payload.route ?? {};
1260
+ if (!isRecord(route)) throw new TypeError("managed chat response route must be a JSON object");
1261
+ executionReceipts.push({
1262
+ model: route.selected_model ?? null,
1263
+ execution: payload.execution ?? null,
1264
+ admission: payload.admission ?? null,
1265
+ feedback: payload.feedback ?? null
1266
+ });
1267
+ return response;
1268
+ }
1269
+
1270
+ // src/research/transport-jobs.ts
1271
+ import { STATUS_CODES } from "node:http";
1272
+
1273
+ // src/job-progress.ts
1274
+ var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "expired"]);
1275
+ var SUBMIT_FALLBACK_CODES = /* @__PURE__ */ new Set(["task_job_input_too_large", "task_jobs_full", "task_jobs_input_budget"]);
1276
+ var RETENTION_FAILURE_CODES = /* @__PURE__ */ new Set(["task_job_result_too_large", "task_job_error_too_large"]);
1277
+ var FIRST_POLL_MS = 250;
1278
+ var HEARTBEAT_MS = 1e4;
1279
+ var MAX_POLL_MS = 2e3;
1280
+ var MAX_POLL_FAILURES = 5;
1281
+ var CANCEL_ATTEMPTS = 3;
1282
+ var CANCEL_BACKOFF_MS = 250;
1283
+ var CANCEL_REPORT_MS = 5e3;
1284
+ var object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
1285
+ var number = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
1286
+ var gigabytes = (bytes) => `${(bytes / 1e9).toFixed(1)} GB`;
1287
+ var seconds = (ms) => ms < 1e4 ? `${(ms / 1e3).toFixed(1)} s` : `${Math.round(ms / 1e3)} s`;
1288
+ function modelNames(value) {
1289
+ if (!Array.isArray(value)) return [];
1290
+ return value.flatMap((item) => {
1291
+ if (typeof item === "string") return [item];
1292
+ const named = object(item)?.model ?? object(item)?.name;
1293
+ return typeof named === "string" ? [named] : [];
1294
+ });
1295
+ }
1296
+ function evictedSuffix(detail) {
1297
+ const evicted = modelNames(detail?.evicted ?? detail?.evicted_idle_models);
1298
+ return evicted.length > 0 ? `, evicted ${evicted.join(", ")}` : "";
1299
+ }
1300
+ function holdersSuffix(detail) {
1301
+ const holders = Array.isArray(detail?.memory_holders) ? detail.memory_holders : [];
1302
+ const named = holders.map((holder) => holder).filter((holder) => typeof holder.name === "string" && typeof holder.resident_bytes === "number").slice(0, 3).map((holder) => {
1303
+ const count = typeof holder.processes === "number" && holder.processes > 1 ? ` \xD7${holder.processes}` : "";
1304
+ return `${holder.name}${count} ${gigabytes(holder.resident_bytes)}`;
1305
+ });
1306
+ return named.length > 0 ? `; largest memory users: ${named.join(", ")}` : "";
1307
+ }
1308
+ function jobProgressMessage(job) {
1309
+ const status = typeof job.status === "string" ? job.status : "queued";
1310
+ const reason = typeof job.reason === "string" ? job.reason : "";
1311
+ const detail = object(job.detail);
1312
+ const model = typeof job.selected_model === "string" && job.selected_model ? job.selected_model : void 0;
1313
+ switch (status) {
1314
+ case "queued":
1315
+ return "queued";
1316
+ case "waiting_for_admission":
1317
+ return reason === "resources_reserved_waiting_for_backend_capacity" ? "memory reserved; waiting for backend capacity" : "waiting for backend capacity";
1318
+ case "waiting_for_resources": {
1319
+ const need = number(detail?.required_available_bytes);
1320
+ const available = number(detail?.effective_available_bytes);
1321
+ if (need === void 0 && available === void 0) {
1322
+ return (reason === "checking_host_resources" ? "checking host memory" : "waiting for memory") + evictedSuffix(detail);
1323
+ }
1324
+ const sizes = [need !== void 0 ? `need ${gigabytes(need)}` : void 0, available !== void 0 ? `${gigabytes(available)} available` : void 0].filter(Boolean).join(", ");
1325
+ const pressure = Array.isArray(detail?.pressure_reasons) ? detail.pressure_reasons.map((item) => typeof item === "string" ? item : JSON.stringify(item)) : [];
1326
+ return `waiting for memory: ${sizes}${pressure.length > 0 ? ` (host: ${pressure.join(", ")})` : ""}${holdersSuffix(detail)}${evictedSuffix(detail)}`;
1327
+ }
1328
+ case "waiting_for_runner":
1329
+ return "waiting for runner";
1330
+ case "loading": {
1331
+ const expected = number(detail?.expected_load_ms);
1332
+ return `loading ${model ?? "model"}${expected !== void 0 ? ` (~${seconds(expected)})` : ""}${evictedSuffix(detail)}`;
1333
+ }
1334
+ case "running":
1335
+ return model ? `running ${model}` : "running";
1336
+ default:
1337
+ return status.replace(/_/g, " ");
1338
+ }
1339
+ }
1340
+ function errorBody(error) {
1341
+ try {
1342
+ return object(JSON.parse(error instanceof Error ? error.message : String(error)));
1343
+ } catch {
1344
+ return void 0;
1345
+ }
1346
+ }
1347
+ function describeNapiError(error) {
1348
+ const message = error instanceof Error ? error.message : String(error);
1349
+ const missing = /does not exist or has expired|\bHTTP 404\b|^\s*(?:404\b|Not Found\s*$)/i.test(message);
1350
+ return { body: errorBody(error), ...missing ? { status: 404 } : {} };
1351
+ }
1352
+ function unconfirmedSubmission(error, body) {
1353
+ const message = error instanceof Error ? error.message : String(error);
1354
+ if (body && (typeof body.code === "string" || "execution_outcome" in body || "retry_safe" in body)) {
1355
+ return error instanceof Error ? error : new Error(message);
1356
+ }
1357
+ return new Error(JSON.stringify({
1358
+ ...body,
1359
+ error: typeof body?.error === "string" ? body.error : message,
1360
+ execution_outcome: "unknown",
1361
+ retry_safe: false
1362
+ }));
1363
+ }
1364
+ function submissionRefused({ body }) {
1365
+ const code = body?.code;
1366
+ return typeof code === "string" && SUBMIT_FALLBACK_CODES.has(code);
1367
+ }
1368
+ var defaultWait = (ms, signal) => new Promise((resolve) => {
1369
+ if (signal?.aborted) return resolve();
1370
+ const done = () => {
1371
+ clearTimeout(timer);
1372
+ signal?.removeEventListener("abort", done);
1373
+ resolve();
1374
+ };
1375
+ const timer = setTimeout(done, ms);
1376
+ signal?.addEventListener("abort", done, { once: true });
1377
+ });
1378
+ async function runWithJobProgress(calls, report, options = {}) {
1379
+ const { signal, wait = defaultWait, describe = describeNapiError, onJob } = options;
1380
+ const aborted = (id2, cancellation2) => options.aborted?.(id2, cancellation2) ?? new Error(id2 ? cancellation2 === "unknown" ? `run_task was cancelled by the client; cancellation acknowledgement is unknown for deferred job ${id2}, which may still be running. Upstream stop is not confirmed.` : `run_task was cancelled by the client; cancellation was requested for deferred job ${id2}${cancellation2 === "acknowledged" ? " and acknowledged by serve" : ""}. Upstream stop is not confirmed.` : "run_task was cancelled by the client before a job handle was received; execution outcome is unknown. The task was not replayed.");
1381
+ if (signal?.aborted) throw options.aborted?.() ?? new Error("run_task was cancelled by the client before submission; no task was submitted.");
1382
+ let raw;
1383
+ try {
1384
+ raw = await calls.submit();
1385
+ } catch (error) {
1386
+ if (signal?.aborted) throw aborted();
1387
+ const described = describe(error);
1388
+ if (submissionRefused(described)) return calls.sync(described.body);
1389
+ throw options.unconfirmed?.(error, described.body) ?? unconfirmedSubmission(error, described.body);
1390
+ }
1391
+ let submitted;
1392
+ try {
1393
+ submitted = object(JSON.parse(raw));
1394
+ } catch {
1395
+ return raw;
1396
+ }
1397
+ let job = object(submitted?.job);
1398
+ const id = typeof job?.id === "string" ? job.id : void 0;
1399
+ if (submitted?.deferred !== true || !job || !id) return raw;
1400
+ onJob?.(id, true);
1401
+ let open = true;
1402
+ const settled = () => {
1403
+ if (open) onJob?.(id, false);
1404
+ open = false;
1405
+ };
1406
+ let cancellation;
1407
+ const requestCancellation = async () => {
1408
+ for (let attempt = 1; ; attempt++) {
1409
+ try {
1410
+ const reply = object(JSON.parse(await calls.cancel(id)));
1411
+ const status = object(reply?.job)?.status ?? reply?.status;
1412
+ if (!TERMINAL.has(String(status))) return "requested";
1413
+ settled();
1414
+ return "acknowledged";
1415
+ } catch (error) {
1416
+ if (describe(error).status === 404) {
1417
+ settled();
1418
+ return "unknown";
1419
+ }
1420
+ if (attempt >= CANCEL_ATTEMPTS) return "unknown";
1421
+ await wait(CANCEL_BACKOFF_MS * 2 ** (attempt - 1));
1422
+ }
1423
+ }
1424
+ };
1425
+ const cancel = () => {
1426
+ cancellation ??= requestCancellation();
1427
+ };
1428
+ const cancellationState = async () => {
1429
+ if (!cancellation) return void 0;
1430
+ let timer;
1431
+ const capped = new Promise((resolve) => {
1432
+ timer = setTimeout(() => resolve("unknown"), CANCEL_REPORT_MS);
1433
+ });
1434
+ try {
1435
+ return await Promise.race([cancellation, capped]);
1436
+ } finally {
1437
+ clearTimeout(timer);
1438
+ }
1439
+ };
1440
+ signal?.addEventListener("abort", cancel, { once: true });
1441
+ try {
1442
+ let lastMessage = "";
1443
+ let lastPhase = "";
1444
+ let lastReportAt = Date.now();
1445
+ let phaseStartedAt = lastReportAt;
1446
+ let delay = FIRST_POLL_MS;
1447
+ let failures = 0;
1448
+ for (; ; ) {
1449
+ if (signal?.aborted) {
1450
+ cancel();
1451
+ throw aborted(id, await cancellationState());
1452
+ }
1453
+ const now = Date.now();
1454
+ const phase = `${String(job.status)}|${String(job.reason)}`;
1455
+ if (phase !== lastPhase) {
1456
+ lastPhase = phase;
1457
+ phaseStartedAt = now;
1458
+ delay = FIRST_POLL_MS;
1459
+ }
1460
+ const message = jobProgressMessage(job);
1461
+ if (message !== lastMessage) {
1462
+ report(message);
1463
+ lastMessage = message;
1464
+ lastReportAt = now;
1465
+ } else if (now - lastReportAt >= HEARTBEAT_MS && !TERMINAL.has(String(job.status))) {
1466
+ report(`${message} (${seconds(now - phaseStartedAt)})`);
1467
+ lastReportAt = now;
1468
+ }
1469
+ if (TERMINAL.has(String(job.status))) break;
1470
+ await wait(delay, signal);
1471
+ if (signal?.aborted) {
1472
+ cancel();
1473
+ throw aborted(id, await cancellationState());
1474
+ }
1475
+ delay = Math.min(MAX_POLL_MS, Math.round(delay * 1.5));
1476
+ try {
1477
+ const polled = object(object(JSON.parse(await calls.get(id)))?.job);
1478
+ if (polled) job = polled;
1479
+ failures = 0;
1480
+ } catch (error) {
1481
+ if (describe(error).status === 404) {
1482
+ settled();
1483
+ const failure2 = {
1484
+ code: "task_job_unavailable",
1485
+ error: `Deferred job ${id} is no longer available; its execution outcome is unknown. The task was not run again. Its result cannot be recovered from the missing job record; verify the original execution before submitting new work.`,
1486
+ job_id: id,
1487
+ last_observed_status: job.status,
1488
+ execution_outcome: "unknown",
1489
+ result_recoverable: false,
1490
+ retry_safe: false
1491
+ };
1492
+ throw options.failure?.(failure2, 404) ?? new Error(JSON.stringify(failure2));
1493
+ }
1494
+ failures += 1;
1495
+ if (failures >= MAX_POLL_FAILURES) {
1496
+ cancel();
1497
+ throw error;
1498
+ }
1499
+ }
1500
+ }
1501
+ settled();
1502
+ if (job.status === "completed") return JSON.stringify(job.result ?? {});
1503
+ const failure = { ...object(job.error) ?? { error: `deferred job ${id} ended ${String(job.status)}` } };
1504
+ if (typeof failure.code === "string" && RETENTION_FAILURE_CODES.has(failure.code)) {
1505
+ const lost = failure.code === "task_job_result_too_large" ? "result" : "error";
1506
+ failure.error = `${String(failure.error)} Deferred job ${id} was not run again. Its original ${lost} was not retained and cannot be recovered from this job; resubmission would execute new work.`;
1507
+ failure.job_id = id;
1508
+ failure.result_recoverable = false;
1509
+ failure.retry_safe = false;
1510
+ }
1511
+ const status = typeof failure.status === "number" ? failure.status : void 0;
1512
+ delete failure.id;
1513
+ delete failure.ok;
1514
+ if (status !== void 0) delete failure.status;
1515
+ throw options.failure?.(failure, status) ?? new Error(JSON.stringify(failure));
1516
+ } finally {
1517
+ signal?.removeEventListener("abort", cancel);
1518
+ }
1519
+ }
1520
+
1521
+ // src/research/transport-jobs.ts
1522
+ var TASKS_SUFFIX = "/_freellama/v1/tasks";
1523
+ function describeHttpError(error) {
1524
+ if (!(error instanceof HTTPError)) return {};
1525
+ try {
1526
+ const body = parseJsonBody(error.body.subarray(0, RECEIPT_BODY_LIMIT), "error body");
1527
+ return { status: error.code, ...isRecord(body) ? { body } : {} };
1528
+ } catch {
1529
+ return { status: error.code };
1530
+ }
1531
+ }
1532
+ function syncError(body, status) {
1533
+ const code = status ?? 500;
1534
+ return new HTTPError(code, STATUS_CODES[code] ?? "", Buffer.from(JSON.stringify(body), "utf8"));
1535
+ }
1536
+ async function requestJsonAsJob(url, payload, timeoutSeconds, options) {
1537
+ const request = options.request ?? httpRequest;
1538
+ const headers = requestHeaders();
1539
+ const call = async (method, target, body, signal) => {
1540
+ const sent = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body), "utf8");
1541
+ const response = await request(target, { method, headers, body: sent, timeoutSeconds, signal });
1542
+ return decodeUtf8(response, "managed task response");
1543
+ };
1544
+ const sync = () => call("POST", url, payload);
1545
+ if (!url.endsWith(TASKS_SUFFIX)) return parseJsonBody(Buffer.from(await sync(), "utf8"), "chat response");
1546
+ const jobs = `${url.slice(0, -TASKS_SUFFIX.length)}/_freellama/v1/jobs`;
1547
+ const deadline = new AbortController();
1548
+ const timer = setTimeout(() => deadline.abort(), Math.max(timeoutSeconds * 1e3, 1));
1549
+ try {
1550
+ const raw = await runWithJobProgress({
1551
+ submit: () => call("POST", url, { ...payload, defer: true }),
1552
+ get: (id) => call("GET", `${jobs}/${encodeURIComponent(id)}`, void 0, deadline.signal),
1553
+ cancel: (id) => call("POST", `${jobs}/${encodeURIComponent(id)}/cancel`, null),
1554
+ sync: async () => {
1555
+ throw syncError({
1556
+ code: "cancellable_task_unavailable",
1557
+ error: "Serve refused job submission before execution; cancellable research requires available job capacity and a supported request size.",
1558
+ execution_outcome: "not_started",
1559
+ retry_safe: true
1560
+ }, 409);
1561
+ }
1562
+ }, options.report, {
1563
+ signal: deadline.signal,
1564
+ wait: options.wait,
1565
+ describe: describeHttpError,
1566
+ failure: syncError,
1567
+ aborted: () => new RequestTimeoutError(),
1568
+ // Keep transport types, but do not let an arbitrary 503 erase an uncertain submission.
1569
+ unconfirmed: (error, body) => error instanceof HTTPError && !retryableChatError(error) ? syncError({
1570
+ ...body,
1571
+ error: typeof body?.error === "string" ? body.error : error.message,
1572
+ execution_outcome: body?.execution_outcome ?? "unknown",
1573
+ retry_safe: false
1574
+ }, error.code) : error,
1575
+ onJob: options.onJob
1576
+ });
1577
+ return parseJsonBody(Buffer.from(raw, "utf8"), "chat response");
1578
+ } finally {
1579
+ clearTimeout(timer);
1580
+ }
1581
+ }
1582
+
1583
+ // src/research/workspace-path.ts
1584
+ import { lstatSync, readlinkSync, statSync } from "node:fs";
1585
+ import path4 from "node:path";
1586
+ var SymlinkLoopError = class extends ValidationError {
1587
+ name = "SymlinkLoopError";
1588
+ };
1589
+ var hasNul = (text) => text.includes("\0");
1590
+ function cleanPath(text) {
1591
+ const parts = text.split("/").filter((part) => part !== "" && part !== ".");
1592
+ if (text.startsWith("/")) return `/${parts.join("/")}`;
1593
+ return parts.join("/") || ".";
1594
+ }
1595
+ function joinPath(base, child) {
1596
+ return cleanPath(child.startsWith("/") ? child : `${base}/${child}`);
1597
+ }
1598
+ function escapesRoot(resolved, root) {
1599
+ const a = cleanPath(resolved);
1600
+ const b = cleanPath(root);
1601
+ return a !== b && !a.startsWith(b === "/" ? "/" : `${b}/`);
1602
+ }
1603
+ var IGNORED = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR", "EBADF", "ELOOP"]);
1604
+ function probe(read, fallback) {
1605
+ try {
1606
+ return read();
1607
+ } catch (error) {
1608
+ if (IGNORED.has(error.code ?? "")) return fallback;
1609
+ throw error;
1610
+ }
1611
+ }
1612
+ function exists(target) {
1613
+ return !hasNul(target) && probe(() => (statSync(target), true), false);
1614
+ }
1615
+ function isSymlink(target) {
1616
+ return !hasNul(target) && probe(() => lstatSync(target).isSymbolicLink(), false);
1617
+ }
1618
+ function lexists(target) {
1619
+ if (hasNul(target)) return false;
1620
+ try {
1621
+ lstatSync(target);
1622
+ return true;
1623
+ } catch {
1624
+ return false;
1625
+ }
1626
+ }
1627
+ function isDir(target) {
1628
+ if (hasNul(target)) return false;
1629
+ try {
1630
+ return statSync(target).isDirectory();
1631
+ } catch {
1632
+ return false;
1633
+ }
1634
+ }
1635
+ function readlink(target) {
1636
+ if (hasNul(target)) throw new ValidationError("embedded null byte");
1637
+ try {
1638
+ return readlinkSync(target);
1639
+ } catch {
1640
+ return null;
1641
+ }
1642
+ }
1643
+ function resolvePath(target, cwd = process.cwd()) {
1644
+ const seen = /* @__PURE__ */ new Map();
1645
+ const walk = (start, rest) => {
1646
+ let current = rest.startsWith("/") ? "" : start;
1647
+ for (const name of rest.split("/")) {
1648
+ if (!name || name === ".") continue;
1649
+ if (name === "..") {
1650
+ current = current.slice(0, Math.max(current.lastIndexOf("/"), 0));
1651
+ continue;
1652
+ }
1653
+ const next = `${current}/${name}`;
1654
+ if (seen.has(next)) {
1655
+ const cached = seen.get(next);
1656
+ if (cached === null || cached === void 0) throw new SymlinkLoopError(`symlink loop at ${JSON.stringify(next)}`);
1657
+ current = cached;
1658
+ continue;
1659
+ }
1660
+ const link = readlink(next);
1661
+ if (link === null) {
1662
+ current = next;
1663
+ } else {
1664
+ seen.set(next, null);
1665
+ current = walk(current, link);
1666
+ seen.set(next, current);
1667
+ }
1668
+ }
1669
+ return current;
1670
+ };
1671
+ const text = cleanPath(target);
1672
+ return path4.posix.normalize(walk(text.startsWith("/") ? "" : cwd, text) || "/");
1673
+ }
1674
+
1675
+ // src/research/grounding.ts
1676
+ import { existsSync as existsSync3 } from "node:fs";
1677
+ import path5 from "node:path";
1678
+ var PATH_TOKEN = /(?<![\w@/.-])(?:\.\/)?[\w@-][\w@.-]*(?:\/[\w@.-]+)+\.[A-Za-z][A-Za-z0-9]{0,5}(?=$|[\s:`'"),.;\]])/g;
1679
+ function clean(token) {
1680
+ return token.replace(/^\.\//, "").replace(/[.,;]+$/, "");
1681
+ }
1682
+ function pathsIn(text) {
1683
+ return [...new Set([...text.matchAll(PATH_TOKEN)].map((match) => clean(match[0])))];
1684
+ }
1685
+ function missingPaths(answer, workspace) {
1686
+ return pathsIn(answer).filter((candidate) => !existsSync3(path5.join(workspace, candidate)));
1687
+ }
1688
+ function evidencePaths(observations, workspace, limit = 10) {
1689
+ const found = [];
1690
+ for (const text of observations) {
1691
+ for (const candidate of pathsIn(text)) {
1692
+ if (!found.includes(candidate) && existsSync3(path5.join(workspace, candidate))) found.push(candidate);
1693
+ if (found.length >= limit) return found;
1694
+ }
1695
+ }
1696
+ return found;
1697
+ }
1698
+ function groundingRepairNotice(missing, evidence) {
1699
+ return `These paths in your answer do not exist in the workspace: ${missing.join(", ")}. ` + (evidence.length > 0 ? `Paths your own commands returned: ${evidence.join(", ")}. ` : "") + "Return a finish action again that cites only paths that exist; if the evidence does not establish the answer, say so.";
1700
+ }
1701
+ function groundingNote(missing) {
1702
+ return `
1703
+ [freellama: cited path(s) not found in the workspace: ${missing.join(", ")}; treat this answer as unverified]`;
1704
+ }
1705
+ function toolErrorHint(message) {
1706
+ if (/rg: unrecognized flag --(?:exclude-dir|include)/.test(message)) {
1707
+ return "\nHint: rg excludes directories with --glob '!node_modules' and filters types with -t ts; or use grep -rn PATTERN . --exclude-dir=node_modules.";
1708
+ }
1709
+ return "";
1710
+ }
1711
+ function sourceRepairNotice(evidence) {
1712
+ return `Your answer names no source file. Keep the answer and end it with "Source: path:line" copied from your results, for example one of: ${evidence.slice(0, 6).join(", ")}.`;
1713
+ }
1714
+
1715
+ // src/research/agent-loop.ts
1716
+ var isValidationError = (error) => error instanceof ValidationError;
1717
+ function requireEnv(env, name) {
1718
+ const value = env[name];
1719
+ if (value === void 0) throw new Error(`${name} is not set`);
1720
+ return value;
1721
+ }
1722
+ var readText = (file) => normalizeNewlines(decodeUtf8(readFileSync5(file), file));
1723
+ var pathOf = (value) => value === "" ? "." : value;
1724
+ var numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : typeof value === "boolean" ? Number(value) : fallback;
1725
+ var echo = (action) => canonicalJson(action, { sortKeys: false, ensureAscii: false });
1726
+ async function runAdapter(spec, hooks = {}) {
1727
+ const env = hooks.env ?? process.env;
1728
+ let resultPath = null;
1729
+ try {
1730
+ const model = env.FREELLAMA_TARGET_MODEL || requireEnv(env, "FREELLAMA_BENCH_MODEL");
1731
+ const workspace = resolvePath(pathOf(requireEnv(env, "FREELLAMA_BENCH_WORKSPACE")));
1732
+ const prompt = readText(pathOf(requireEnv(env, "FREELLAMA_BENCH_PROMPT")));
1733
+ resultPath = pathOf(requireEnv(env, "FREELLAMA_AGENT_RESULT"));
1734
+ return await runLoop(spec, hooks, env, { model, workspace, prompt, resultPath });
1735
+ } catch (error) {
1736
+ const answer = `unexpected adapter error: ${errorDiagnostic(error)}`;
1737
+ process.stderr.write(`${error instanceof Error ? error.stack ?? answer : answer}
1738
+ `);
1739
+ if (resultPath !== null) {
1740
+ try {
1741
+ writeFailureResult(resultPath, answer);
1742
+ } catch {
1743
+ }
1744
+ }
1745
+ return 1;
1746
+ }
1747
+ }
1748
+ async function runLoop(spec, hooks, env, inputs) {
1749
+ const { model, workspace, prompt, resultPath } = inputs;
1750
+ const print = hooks.print ?? ((line2) => process.stdout.write(`${line2}
1751
+ `));
1752
+ const progress = hooks.progress ?? progressEmitter(env);
1753
+ const endpoint = (env.FREELLAMA_OLLAMA_ENDPOINT ?? "http://127.0.0.1:11434").replace(/\/+$/, "");
1754
+ const managedEndpoint = (env.FREELLAMA_AGENT_MANAGED_ENDPOINT ?? "").replace(/\/+$/, "");
1755
+ let phaseAt = { maxTurns: 0 };
1756
+ const sendJson = hooks.requestJson ?? (managedEndpoint ? (url, payload, timeoutSeconds) => requestJsonAsJob(url, payload, timeoutSeconds, {
1757
+ report: (message) => progress?.({ event: "job_phase", ...phaseAt, message }),
1758
+ onJob: (id, open) => progress?.({ event: open ? "job_open" : "job_closed", id }),
1759
+ request: hooks.httpRequest
1760
+ }) : requestJson);
1761
+ const executionPreference = env.FREELLAMA_AGENT_EXECUTION_PREFERENCE ?? "auto";
1762
+ const minPlacementEvidence = env.FREELLAMA_AGENT_MIN_PLACEMENT_EVIDENCE ?? "configured";
1763
+ let runtime;
1764
+ try {
1765
+ runtime = AgentRuntimeConfig.fromEnv({ defaultToolTimeoutSeconds: spec.defaultToolTimeoutSeconds, env });
1766
+ } catch (error) {
1767
+ if (!isValidationError(error)) throw error;
1768
+ const answer2 = `invalid research adapter configuration: ${error.message}`;
1769
+ writeFailureResult(resultPath, answer2);
1770
+ print(answer2);
1771
+ return 1;
1772
+ }
1773
+ let messages = [
1774
+ { role: "system", content: spec.systemPrompt(workspace) },
1775
+ { role: "user", content: withBriefing(prompt, spec, workspace) }
1776
+ ];
1777
+ const calls = [];
1778
+ const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: null, cache_write_tokens: null };
1779
+ const cacheUsage = new PromptCacheUsage();
1780
+ const metrics = { load_ms: 0, prompt_eval_ms: 0, eval_ms: 0 };
1781
+ const executionReceipts = [];
1782
+ let answer = "";
1783
+ let failure = null;
1784
+ let transportError = null;
1785
+ const readCatalog = (url) => readCatalogJson(url, hooks.httpRequest);
1786
+ const platformRatio = managedEndpoint ? await (hooks.resolvePlatformTokenRatio ?? resolvePlatformTokenRatio)(managedEndpoint, model, { readJson: readCatalog }) : null;
1787
+ const calibrationDir = trimSpace(env.FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR ?? "");
1788
+ const calibrationModelIdentity = calibrationDir ? await (hooks.resolveModelIdentity ?? resolveModelIdentity)(
1789
+ managedEndpoint || endpoint,
1790
+ model,
1791
+ messages[0].content,
1792
+ { readJson: readCatalog }
1793
+ ) : { verified: false, scope: "persistence_disabled", identity: "" };
1794
+ const contextManager = new AgentContextManager(runtime, {
1795
+ model,
1796
+ calibrationIdentity: calibrationModelIdentity.identity,
1797
+ calibrationDir: calibrationDir && calibrationModelIdentity.verified ? calibrationDir : null,
1798
+ platformRatio
1799
+ });
1800
+ try {
1801
+ messages = contextManager.fit(messages);
1802
+ } catch (error) {
1803
+ if (!isValidationError(error)) throw error;
1804
+ answer = `research task does not fit the configured context safely: ${error.message}`;
1805
+ writeFailureResult(resultPath, answer);
1806
+ print(answer);
1807
+ return 1;
1808
+ }
1809
+ const chatOptions = {
1810
+ temperature: runtime.temperature,
1811
+ seed: runtime.seed,
1812
+ num_ctx: runtime.num_ctx,
1813
+ num_predict: runtime.num_predict
1814
+ };
1815
+ const callModel = async () => {
1816
+ let lastError = null;
1817
+ let response = null;
1818
+ for (let attempt = 0; attempt < runtime.retry_attempts; attempt += 1) {
1819
+ try {
1820
+ const transportEndpoint = managedEndpoint ? `${managedEndpoint}/_freellama/v1/tasks` : endpoint;
1821
+ const [url, payload] = chatRequest(
1822
+ transportEndpoint,
1823
+ model,
1824
+ messages,
1825
+ chatOptions,
1826
+ runtime.think,
1827
+ runtime.keep_alive,
1828
+ executionPreference,
1829
+ minPlacementEvidence
1830
+ );
1831
+ const recorded = executionReceipts.length;
1832
+ response = unwrapChatResponse(await sendJson(url, payload, runtime.request_timeout_seconds), executionReceipts);
1833
+ if (managedEndpoint && executionReceipts.length === recorded) {
1834
+ executionReceipts.push({ model: null, execution: null, reason: "managed_execution_receipt_missing" });
1835
+ }
1836
+ break;
1837
+ } catch (error) {
1838
+ if (!isTransportError(error) || !retryableChatError(error)) throw error;
1839
+ lastError = error;
1840
+ if (attempt + 1 < runtime.retry_attempts) {
1841
+ progress?.({ event: "retry", attempt: attempt + 2, attempts: runtime.retry_attempts });
1842
+ await sleep(runtime.retry_backoff_seconds * 1e3);
1843
+ }
1844
+ }
1845
+ }
1846
+ if (response === null) throw lastError;
1847
+ usage.input_tokens = usage.input_tokens + Math.trunc(numberOr(response.prompt_eval_count, 0));
1848
+ cacheUsage.observe(response);
1849
+ usage.cache_read_tokens = cacheUsage.tokens;
1850
+ usage.output_tokens = usage.output_tokens + Math.trunc(numberOr(response.eval_count, 0));
1851
+ metrics.load_ms += numberOr(response.load_duration, 0) / 1e6;
1852
+ metrics.prompt_eval_ms += numberOr(response.prompt_eval_duration, 0) / 1e6;
1853
+ metrics.eval_ms += numberOr(response.eval_duration, 0) / 1e6;
1854
+ contextManager.observe(messages, response.prompt_eval_count);
1855
+ return response;
1856
+ };
1857
+ const refit = () => {
1858
+ try {
1859
+ messages = contextManager.fit(messages);
1860
+ return true;
1861
+ } catch (error) {
1862
+ if (!isValidationError(error)) throw error;
1863
+ failure = `research context cannot be fitted safely: ${error.message}`;
1864
+ return false;
1865
+ }
1866
+ };
1867
+ const content = (response) => {
1868
+ const message = response.message ?? {};
1869
+ if (!isRecord(message)) throw new TypeError("chat response message must be a JSON object");
1870
+ const text = message.content ?? "";
1871
+ return typeof text === "string" ? text : JSON.stringify(text);
1872
+ };
1873
+ const seenCalls = /* @__PURE__ */ new Map();
1874
+ const pageSize = runtime.context.observation_page_chars;
1875
+ const observations = hooks.observationStore ? hooks.observationStore(pageSize) : new ObservationStore(pageSize);
1876
+ let session;
1877
+ try {
1878
+ try {
1879
+ session = spec.open ? await spec.open(workspace) : void 0;
1880
+ } catch (error) {
1881
+ if (!isValidationError(error)) throw error;
1882
+ answer = `research adapter could not start: ${error.message}`;
1883
+ writeFailureResult(resultPath, answer);
1884
+ print(answer);
1885
+ return 1;
1886
+ }
1887
+ const root = session?.workspace ?? workspace;
1888
+ let parseFailures = 0;
1889
+ let exhausted = true;
1890
+ let repeats = 0;
1891
+ let groundingRepaired = false;
1892
+ let sourceRepaired = false;
1893
+ const seenOutputs = [];
1894
+ for (let turn = 0; turn < runtime.max_turns; turn += 1) {
1895
+ const at = { turn: turn + 1, maxTurns: runtime.max_turns };
1896
+ phaseAt = at;
1897
+ progress?.({ event: "model", ...at });
1898
+ let response;
1899
+ try {
1900
+ response = await callModel();
1901
+ } catch (error) {
1902
+ if (!isTransportError(error)) throw error;
1903
+ let diagnostic;
1904
+ [diagnostic, transportError] = chatErrorDetails(error);
1905
+ failure = `agent response failed: ${diagnostic}`;
1906
+ exhausted = false;
1907
+ break;
1908
+ }
1909
+ const raw = content(response);
1910
+ let action;
1911
+ try {
1912
+ action = spec.parseAction(raw);
1913
+ } catch (error) {
1914
+ if (!isValidationError(error)) throw error;
1915
+ parseFailures += 1;
1916
+ if (parseFailures > runtime.max_parse_repairs) {
1917
+ failure = `agent gave ${parseFailures} unparseable replies: ${errorDiagnostic(error)}`;
1918
+ exhausted = false;
1919
+ break;
1920
+ }
1921
+ progress?.({ event: "repair", ...at });
1922
+ messages.push({ role: "assistant", content: cpSlice(raw, 0, runtime.parse_repair_echo_chars) });
1923
+ messages.push({ role: "user", content: PARSE_REPAIR_NOTICE });
1924
+ if (!refit()) {
1925
+ exhausted = false;
1926
+ break;
1927
+ }
1928
+ continue;
1929
+ }
1930
+ parseFailures = 0;
1931
+ if (action.action === "page") {
1932
+ const wantStep = Math.trunc(numberOr(action.step, 0));
1933
+ const wantPage = Math.trunc(numberOr(action.page, 1));
1934
+ progress?.({ event: "action", ...at, action });
1935
+ messages.push({ role: "assistant", content: echo(action) });
1936
+ messages.push({ role: "user", content: observations.message(wantStep, wantPage) });
1937
+ if (!refit()) {
1938
+ exhausted = false;
1939
+ break;
1940
+ }
1941
+ continue;
1942
+ }
1943
+ if (action.action === "finish") {
1944
+ const proposed = trimSpace(String(action.answer ?? ""));
1945
+ const missing = missingPaths(proposed, root);
1946
+ if (missing.length > 0 && !groundingRepaired) {
1947
+ groundingRepaired = true;
1948
+ progress?.({ event: "repair", ...at });
1949
+ messages.push({ role: "assistant", content: echo(action) });
1950
+ messages.push({ role: "user", content: groundingRepairNotice(missing, evidencePaths(seenOutputs, root)) });
1951
+ if (!refit()) {
1952
+ answer = proposed + groundingNote(missing);
1953
+ exhausted = false;
1954
+ break;
1955
+ }
1956
+ continue;
1957
+ }
1958
+ const evidence = spec.requireSource !== false && missing.length === 0 && !sourceRepaired && pathsIn(proposed).length === 0 ? evidencePaths(seenOutputs, root) : [];
1959
+ if (evidence.length > 0) {
1960
+ sourceRepaired = true;
1961
+ progress?.({ event: "repair", ...at });
1962
+ messages.push({ role: "assistant", content: echo(action) });
1963
+ messages.push({ role: "user", content: sourceRepairNotice(evidence) });
1964
+ if (!refit()) {
1965
+ answer = proposed;
1966
+ exhausted = false;
1967
+ break;
1968
+ }
1969
+ continue;
1970
+ }
1971
+ progress?.({ event: "finish", ...at });
1972
+ answer = missing.length > 0 ? proposed + groundingNote(missing) : proposed;
1973
+ exhausted = false;
1974
+ break;
1975
+ }
1976
+ const started = performance2.now();
1977
+ const call = session.toolCall(action, root, runtime);
1978
+ const signature = callSignature(call.signature);
1979
+ let observation;
1980
+ let status;
1981
+ let exitCode;
1982
+ if (seenCalls.has(signature)) {
1983
+ progress?.({ event: "action", ...at, action, repeat: true });
1984
+ observation = REPEAT_NOTICE;
1985
+ status = "repeat";
1986
+ repeats += 1;
1987
+ } else {
1988
+ progress?.({ event: "action", ...at, action });
1989
+ try {
1990
+ observation = hooks.runTool ? await hooks.runTool(call.record.arguments) : await call.run();
1991
+ status = "ok";
1992
+ } catch (error) {
1993
+ if (!isToolError(error)) throw error;
1994
+ status = error instanceof ToolExitError ? error.status : "error";
1995
+ exitCode = error instanceof ToolExitError ? error.exitCode : void 0;
1996
+ observation = `${status === "empty" ? "tool search" : "tool error"}: ${error.message}${toolErrorHint(error.message)}`;
1997
+ }
1998
+ seenCalls.set(signature, calls.length + 1);
1999
+ }
2000
+ calls.push({
2001
+ ...call.record,
2002
+ status,
2003
+ ...exitCode === void 0 ? {} : { exit_code: exitCode },
2004
+ duration_ms: Math.round((performance2.now() - started) * 1e3) / 1e3
2005
+ });
2006
+ messages.push({ role: "assistant", content: echo(action) });
2007
+ const remaining = runtime.max_turns - calls.length;
2008
+ const step = calls.length;
2009
+ observations.put(step, observation, call.ledger(status));
2010
+ seenOutputs.push(observation);
2011
+ messages.push({ role: "user", content: observations.message(step) + spec.remaining(remaining) });
2012
+ if (!refit()) {
2013
+ exhausted = false;
2014
+ break;
2015
+ }
2016
+ if (repeats >= 2) break;
2017
+ }
2018
+ if (exhausted) {
2019
+ progress?.({ event: "finalize", maxTurns: runtime.max_turns });
2020
+ phaseAt = { maxTurns: runtime.max_turns };
2021
+ messages.push({ role: "user", content: spec.exhausted });
2022
+ if (refit()) {
2023
+ try {
2024
+ const response = await callModel();
2025
+ const finalAction = spec.parseAction(content(response));
2026
+ if (finalAction.action !== "finish") throw new ValidationError("forced final response was not finish");
2027
+ answer = trimSpace(String(finalAction.answer ?? ""));
2028
+ const missing = missingPaths(answer, root);
2029
+ if (missing.length > 0) answer += groundingNote(missing);
2030
+ } catch (error) {
2031
+ if (!isTransportError(error) && !isValidationError(error)) throw error;
2032
+ let diagnostic;
2033
+ [diagnostic, transportError] = chatErrorDetails(error);
2034
+ failure = `agent exceeded ${runtime.max_turns} turns and finalization failed: ${diagnostic}`;
2035
+ }
2036
+ }
2037
+ }
2038
+ if (!answer) answer = failure ?? "agent stopped without a final answer";
2039
+ const report = spec.report ? await spec.report(session) : {};
2040
+ const result = {
2041
+ ...report,
2042
+ final_answer: answer,
2043
+ tool_calls: calls,
2044
+ usage,
2045
+ provider_metrics: {
2046
+ load_ms: metrics.load_ms,
2047
+ prompt_eval_ms: metrics.prompt_eval_ms,
2048
+ eval_ms: metrics.eval_ms
2049
+ },
2050
+ model_metadata: {
2051
+ ...spec.adapterMetadata(session),
2052
+ temperature: chatOptions.temperature,
2053
+ seed: chatOptions.seed,
2054
+ num_ctx: chatOptions.num_ctx,
2055
+ num_predict: chatOptions.num_predict,
2056
+ max_turns: runtime.max_turns,
2057
+ runtime_config: runtime.metadata(),
2058
+ context_management: contextManager.metadata(),
2059
+ context_compactions: contextManager.compactions,
2060
+ transport: managedEndpoint ? "managed" : "direct_ollama",
2061
+ execution_preference: executionPreference,
2062
+ min_placement_evidence: minPlacementEvidence,
2063
+ execution_receipts: executionReceipts,
2064
+ ...transportError ? { transport_error: transportError } : {},
2065
+ cache_token_metrics: cacheUsage.metadata(),
2066
+ calibration_model_identity: calibrationModelIdentity
2067
+ }
2068
+ };
2069
+ observations.writeResult(resultPath, result);
2070
+ print(answer);
2071
+ return failure ? 1 : 0;
2072
+ } finally {
2073
+ session?.close?.();
2074
+ observations.close();
2075
+ }
2076
+ }
2077
+ function withBriefing(prompt, spec, workspace) {
2078
+ try {
2079
+ const briefing2 = spec.briefing?.(workspace);
2080
+ return briefing2 ? `${prompt}
2081
+
2082
+ ${briefing2}` : prompt;
2083
+ } catch {
2084
+ return prompt;
2085
+ }
2086
+ }
2087
+
2088
+ // src/research/coder-actions.ts
2089
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
2090
+ import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync7, realpathSync as realpathSync3, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
2091
+ import path8 from "node:path";
2092
+
2093
+ // src/research/sandbox.ts
2094
+ import { accessSync, constants as fsConstants, existsSync as existsSync4, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, realpathSync, rmSync as rmSync2, symlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
2095
+ import { tmpdir as tmpdir2 } from "node:os";
2096
+ import path6 from "node:path";
2097
+
2098
+ // src/research/proc.ts
2099
+ import { spawn, spawnSync } from "node:child_process";
2100
+ import { constants } from "node:os";
2101
+ function decodeText(bytes, source = "command output") {
2102
+ return normalizeNewlines(decodeUtf8(bytes, source));
2103
+ }
2104
+ function runProcess(argv, options) {
2105
+ const timeoutMs = Math.max(options.timeoutSeconds * 1e3, 0);
2106
+ return new Promise((resolve, reject) => {
2107
+ const child = spawn(argv[0], argv.slice(1), {
2108
+ cwd: options.cwd,
2109
+ env: options.env,
2110
+ stdio: ["ignore", "pipe", "pipe"]
2111
+ });
2112
+ const stdout = [];
2113
+ const stderr = [];
2114
+ let settled = false;
2115
+ const finish = (action) => {
2116
+ if (settled) return;
2117
+ settled = true;
2118
+ clearTimeout(timer);
2119
+ action();
2120
+ };
2121
+ const timer = setTimeout(() => {
2122
+ child.kill("SIGKILL");
2123
+ const fail = () => finish(() => reject(new ToolTimeoutError(
2124
+ `command ${JSON.stringify(argv.at(-1))} timed out after ${options.timeoutSeconds} seconds`
2125
+ )));
2126
+ if (child.exitCode !== null || child.signalCode !== null) fail();
2127
+ else child.once("exit", fail);
2128
+ }, timeoutMs);
2129
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
2130
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
2131
+ child.once("error", (error) => finish(() => reject(error)));
2132
+ child.once("close", (code, signal) => finish(() => {
2133
+ try {
2134
+ resolve({
2135
+ stdout: decodeText(Buffer.concat(stdout)),
2136
+ stderr: decodeText(Buffer.concat(stderr)),
2137
+ returncode: code ?? -(signal ? constants.signals[signal] ?? 1 : 1)
2138
+ });
2139
+ } catch (error) {
2140
+ reject(error);
2141
+ }
2142
+ }));
2143
+ });
2144
+ }
2145
+ function probe2(argv, env) {
2146
+ const result = spawnSync(argv[0], argv.slice(1), { env, stdio: ["ignore", "pipe", "pipe"], timeout: 1e4, killSignal: "SIGKILL" });
2147
+ return !result.error && result.status === 0;
2148
+ }
2149
+
2150
+ // src/research/glob.ts
2151
+ import { readdirSync as readdirSync2 } from "node:fs";
2152
+ var GlobPatternError = class extends ValidationError {
2153
+ name = "GlobPatternError";
2154
+ };
2155
+ var hasGlobMagic = (text) => /[*?[]/.test(text);
2156
+ var isHidden = (name) => name.startsWith(".");
2157
+ function globPaths(pattern) {
2158
+ const split = pattern.lastIndexOf("/") + 1;
2159
+ const basename = pattern.slice(split);
2160
+ let dirname = pattern.slice(0, split);
2161
+ if (/[^/]/.test(dirname)) dirname = dirname.replace(/\/+$/, "");
2162
+ if (!hasGlobMagic(pattern)) return (basename ? lexists(pattern) : isDir(dirname)) ? [pattern] : [];
2163
+ if (!dirname) return matchNames("", basename);
2164
+ const directories = dirname !== pattern && hasGlobMagic(dirname) ? globDirectories(dirname) : [dirname];
2165
+ const results = [];
2166
+ for (const directory of directories) {
2167
+ const names = hasGlobMagic(basename) ? matchNames(directory, basename) : (basename ? lexists(join(directory, basename)) : isDir(directory)) ? [basename] : [];
2168
+ for (const name of names) results.push(join(directory, name));
2169
+ }
2170
+ return results;
2171
+ }
2172
+ function globDirectories(pattern) {
2173
+ return globPaths(pattern).filter((match) => isDir(match));
2174
+ }
2175
+ function join(directory, name) {
2176
+ if (name.startsWith("/")) return name;
2177
+ return !directory || directory.endsWith("/") ? directory + name : `${directory}/${name}`;
2178
+ }
2179
+ function matchNames(directory, pattern) {
2180
+ const where = directory || ".";
2181
+ if (where.includes("\0")) throw new ValidationError("embedded null byte");
2182
+ let names;
2183
+ try {
2184
+ names = readdirSync2(where);
2185
+ } catch {
2186
+ names = [];
2187
+ }
2188
+ if (!isHidden(pattern)) names = names.filter((name) => !isHidden(name));
2189
+ const matches = compileGlob(pattern);
2190
+ return names.filter(matches);
2191
+ }
2192
+ function compileGlob(pattern) {
2193
+ const chars = Array.from(pattern);
2194
+ const items = [];
2195
+ for (let index = 0; index < chars.length; ) {
2196
+ const char = chars[index++];
2197
+ if (char === "*") {
2198
+ if (items.at(-1)?.kind !== "star") items.push({ kind: "star" });
2199
+ } else if (char === "?") {
2200
+ items.push({ kind: "any" });
2201
+ } else if (char === "[") {
2202
+ const parsed = parseClass(chars, index, pattern);
2203
+ if (parsed) {
2204
+ items.push(parsed.item);
2205
+ index = parsed.end;
2206
+ } else {
2207
+ items.push({ kind: "char", char: char.codePointAt(0) });
2208
+ }
2209
+ } else {
2210
+ items.push({ kind: "char", char: char.codePointAt(0) });
2211
+ }
2212
+ }
2213
+ return (name) => matchItems(items, Array.from(name, (char) => char.codePointAt(0)));
2214
+ }
2215
+ function parseClass(chars, start, pattern) {
2216
+ let close = start;
2217
+ if (chars[close] === "!") close += 1;
2218
+ if (chars[close] === "]") close += 1;
2219
+ while (close < chars.length && chars[close] !== "]") close += 1;
2220
+ if (close >= chars.length) return null;
2221
+ const body = chars.slice(start, close);
2222
+ const negate = body[0] === "!";
2223
+ const first = negate ? 1 : 0;
2224
+ const tokens = body.map((char) => ({ char, operator: char === "-" }));
2225
+ if (body.join("").includes("--")) {
2226
+ for (const token of tokens) token.operator = false;
2227
+ for (let hyphen = body.indexOf("-", first + 1); hyphen >= 0; hyphen = body.indexOf("-", hyphen + 3)) {
2228
+ tokens[hyphen].operator = true;
2229
+ }
2230
+ }
2231
+ const ranges = [];
2232
+ const point = (char) => char.codePointAt(0);
2233
+ for (let index = first; index < tokens.length; ) {
2234
+ const low = tokens[index++].char;
2235
+ if (!tokens[index]?.operator || index + 1 >= tokens.length) {
2236
+ ranges.push([point(low), point(low)]);
2237
+ continue;
2238
+ }
2239
+ const high = tokens[index + 1].char;
2240
+ index += 2;
2241
+ if (point(high) < point(low)) {
2242
+ throw new GlobPatternError(`bad character range ${low}-${high} in glob pattern ${JSON.stringify(pattern)}`);
2243
+ }
2244
+ ranges.push([point(low), point(high)]);
2245
+ }
2246
+ return { item: { kind: "class", negate, ranges }, end: close + 1 };
2247
+ }
2248
+ function matchOne(item, char) {
2249
+ if (item.kind === "any") return true;
2250
+ if (item.kind === "char") return item.char === char;
2251
+ if (item.kind === "class") return item.ranges.some(([low, high]) => char >= low && char <= high) !== item.negate;
2252
+ return false;
2253
+ }
2254
+ function matchItems(items, name) {
2255
+ let item = 0;
2256
+ let char = 0;
2257
+ let starItem = -1;
2258
+ let starChar = 0;
2259
+ while (char < name.length) {
2260
+ if (item < items.length && items[item].kind !== "star" && matchOne(items[item], name[char])) {
2261
+ item += 1;
2262
+ char += 1;
2263
+ } else if (item < items.length && items[item].kind === "star") {
2264
+ starItem = item;
2265
+ starChar = char;
2266
+ item += 1;
2267
+ } else if (starItem >= 0) {
2268
+ item = starItem + 1;
2269
+ starChar += 1;
2270
+ char = starChar;
2271
+ } else {
2272
+ return false;
2273
+ }
2274
+ }
2275
+ while (item < items.length && items[item].kind === "star") item += 1;
2276
+ return item === items.length;
2277
+ }
2278
+
2279
+ // src/research/sandbox-sed.ts
2280
+ var DIGIT = /^[\p{Nd}\u{b2}-\u{b3}\u{b9}\u{1369}-\u{1371}\u{19da}\u{2070}\u{2074}-\u{2079}\u{2080}-\u{2089}\u{2460}-\u{2468}\u{2474}-\u{247c}\u{2488}-\u{2490}\u{24ea}\u{24f5}-\u{24fd}\u{24ff}\u{2776}-\u{277e}\u{2780}-\u{2788}\u{278a}-\u{2792}\u{10a40}-\u{10a43}\u{10e60}-\u{10e68}\u{11052}-\u{1105a}\u{1f100}-\u{1f10a}]$/u;
2281
+ var isDigit = (char) => char !== void 0 && DIGIT.test(char);
2282
+ var within = (char, set) => set.includes(char ?? "");
2283
+ function checkSed(args) {
2284
+ for (const program of sedPrograms(args)) checkSedProgram(program);
2285
+ }
2286
+ function checkSedProgram(text) {
2287
+ const program = Array.from(text);
2288
+ const length = program.length;
2289
+ let index = 0;
2290
+ const delimited = (start) => {
2291
+ const delimiter = program[start];
2292
+ let cursor = start + 1;
2293
+ while (cursor < length) {
2294
+ if (program[cursor] === "\\") cursor += 2;
2295
+ else if (program[cursor] === delimiter) return cursor + 1;
2296
+ else cursor += 1;
2297
+ }
2298
+ throw new ValidationError("command blocked: unterminated sed expression");
2299
+ };
2300
+ while (index < length) {
2301
+ while (index < length && (isSpace(program[index]) || within(program[index], ";{}"))) index += 1;
2302
+ if (index === length) return;
2303
+ for (let address = 0; address < 2; address += 1) {
2304
+ if (index < length && isDigit(program[index])) {
2305
+ while (index < length && isDigit(program[index])) index += 1;
2306
+ } else if (index < length && program[index] === "$") {
2307
+ index += 1;
2308
+ } else if (index < length && program[index] === "/") {
2309
+ index = delimited(index);
2310
+ } else if (index + 1 < length && program[index] === "\\") {
2311
+ index = delimited(index + 1);
2312
+ } else {
2313
+ break;
2314
+ }
2315
+ while (index < length && isSpace(program[index])) index += 1;
2316
+ if (address === 0 && index < length && program[index] === ",") {
2317
+ index += 1;
2318
+ continue;
2319
+ }
2320
+ break;
2321
+ }
2322
+ while (index < length && (isSpace(program[index]) || program[index] === "!")) index += 1;
2323
+ if (index === length) throw new ValidationError("command blocked: sed command is missing");
2324
+ const command = program[index];
2325
+ index += 1;
2326
+ if (command === "s" && index < length) {
2327
+ index = delimited(index);
2328
+ index = delimited(index - 1);
2329
+ const flagsStart = index;
2330
+ while (index < length && !within(program[index], ";}\n")) index += 1;
2331
+ const flags = Array.from(trimSpace(program.slice(flagsStart, index).join("")));
2332
+ if (flags.some((flag) => !within(flag, "gIp0123456789"))) {
2333
+ throw new ValidationError("command blocked: sed substitution flags can read, write or execute");
2334
+ }
2335
+ } else if (!within(command, "pPdDqQnNhHgGx={}")) {
2336
+ throw new ValidationError("command blocked: sed command can read, write or execute files");
2337
+ }
2338
+ if (!within(command, "{}") && index < length && !(isSpace(program[index]) || within(program[index], ";{}"))) {
2339
+ throw new ValidationError("command blocked: unsupported sed command argument");
2340
+ }
2341
+ }
2342
+ }
2343
+ function sedPrograms(args) {
2344
+ const programs = [];
2345
+ let index = 0;
2346
+ while (index < args.length) {
2347
+ const arg = args[index];
2348
+ if (arg === "--") {
2349
+ programs.push(...args.slice(index + 1));
2350
+ break;
2351
+ }
2352
+ if (arg === "--file" || arg.startsWith("--file=")) {
2353
+ throw new ValidationError("command blocked: sed script files can read, write or execute");
2354
+ }
2355
+ if (arg === "--expression") {
2356
+ index += 1;
2357
+ if (index >= args.length) throw new ValidationError("command blocked: sed expression is missing");
2358
+ programs.push(args[index]);
2359
+ } else if (arg.startsWith("--expression=")) {
2360
+ programs.push(arg.slice(arg.indexOf("=") + 1));
2361
+ } else if (arg.startsWith("-") && !arg.startsWith("--")) {
2362
+ const short = Array.from(arg).slice(1);
2363
+ for (let offset = 0; offset < short.length; offset += 1) {
2364
+ const option = short[offset];
2365
+ if (option === "f") throw new ValidationError("command blocked: sed script files can read, write or execute");
2366
+ if (option === "e") {
2367
+ let program = short.slice(offset + 1).join("");
2368
+ if (!program) {
2369
+ index += 1;
2370
+ if (index >= args.length) throw new ValidationError("command blocked: sed expression is missing");
2371
+ program = args[index];
2372
+ }
2373
+ programs.push(program);
2374
+ break;
2375
+ }
2376
+ if (!within(option, "nEr")) throw new ValidationError("command blocked: unsupported sed option");
2377
+ }
2378
+ } else if (!arg.startsWith("--")) {
2379
+ if (programs.length === 0) programs.push(arg);
2380
+ } else {
2381
+ throw new ValidationError("command blocked: unsupported sed option");
2382
+ }
2383
+ index += 1;
2384
+ }
2385
+ return programs;
2386
+ }
2387
+
2388
+ // src/research/shlex.ts
2389
+ var ASCII_WORDCHARS = "abcdfeghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
2390
+ var POSIX_WORDCHARS = "\xDF\xE0\xE1\xE2\xE3\xE4\xE5\xE6\xE7\xE8\xE9\xEA\xEB\xEC\xED\xEE\xEF\xF0\xF1\xF2\xF3\xF4\xF5\xF6\xF8\xF9\xFA\xFB\xFC\xFD\xFE\xFF\xC0\xC1\xC2\xC3\xC4\xC5\xC6\xC7\xC8\xC9\xCA\xCB\xCC\xCD\xCE\xCF\xD0\xD1\xD2\xD3\xD4\xD5\xD6\xD8\xD9\xDA\xDB\xDC\xDD\xDE";
2391
+ function within2(char, set) {
2392
+ return set.includes(char);
2393
+ }
2394
+ function shlexTokens(text, options) {
2395
+ return [...shlexIter(text, options)];
2396
+ }
2397
+ function* shlexIter(text, options) {
2398
+ const posix = options.posix;
2399
+ const punctuation = options.punctuationChars ?? "";
2400
+ const whitespaceSplit = options.whitespaceSplit ?? false;
2401
+ const commenters = options.commenters ?? "#";
2402
+ let wordchars = ASCII_WORDCHARS + (posix ? POSIX_WORDCHARS : "");
2403
+ if (punctuation) {
2404
+ wordchars += "~-./*?=";
2405
+ wordchars = [...wordchars].filter((char) => !punctuation.includes(char)).join("");
2406
+ }
2407
+ const whitespace = " \r\n";
2408
+ const quotes = `'"`;
2409
+ const escape = "\\";
2410
+ const escapedquotes = '"';
2411
+ const eof = posix ? null : "";
2412
+ const chars = Array.from(text);
2413
+ let cursor = 0;
2414
+ const pushbackChars = [];
2415
+ const pushback = [];
2416
+ let state = " ";
2417
+ let token = "";
2418
+ const readChar = () => {
2419
+ if (punctuation && pushbackChars.length > 0) return pushbackChars.pop();
2420
+ if (cursor < chars.length) return chars[cursor++];
2421
+ return "";
2422
+ };
2423
+ const skipLine = () => {
2424
+ while (cursor < chars.length) {
2425
+ const char = chars[cursor++];
2426
+ if (char === "\n") break;
2427
+ }
2428
+ };
2429
+ const readToken = () => {
2430
+ let quoted = false;
2431
+ let escapedstate = " ";
2432
+ for (; ; ) {
2433
+ const nextchar = readChar();
2434
+ if (state === null) {
2435
+ token = "";
2436
+ break;
2437
+ } else if (state === " ") {
2438
+ if (!nextchar) {
2439
+ state = null;
2440
+ break;
2441
+ } else if (within2(nextchar, whitespace)) {
2442
+ if (token || posix && quoted) break;
2443
+ continue;
2444
+ } else if (within2(nextchar, commenters)) {
2445
+ skipLine();
2446
+ } else if (posix && within2(nextchar, escape)) {
2447
+ escapedstate = "a";
2448
+ state = nextchar;
2449
+ } else if (within2(nextchar, wordchars)) {
2450
+ token = nextchar;
2451
+ state = "a";
2452
+ } else if (within2(nextchar, punctuation)) {
2453
+ token = nextchar;
2454
+ state = "c";
2455
+ } else if (within2(nextchar, quotes)) {
2456
+ if (!posix) token = nextchar;
2457
+ state = nextchar;
2458
+ } else if (whitespaceSplit) {
2459
+ token = nextchar;
2460
+ state = "a";
2461
+ } else {
2462
+ token = nextchar;
2463
+ if (token || posix && quoted) break;
2464
+ continue;
2465
+ }
2466
+ } else if (within2(state, quotes)) {
2467
+ quoted = true;
2468
+ if (!nextchar) throw new ValidationError("No closing quotation");
2469
+ if (nextchar === state) {
2470
+ if (!posix) {
2471
+ token += nextchar;
2472
+ state = " ";
2473
+ break;
2474
+ }
2475
+ state = "a";
2476
+ } else if (posix && within2(nextchar, escape) && within2(state, escapedquotes)) {
2477
+ escapedstate = state;
2478
+ state = nextchar;
2479
+ } else {
2480
+ token += nextchar;
2481
+ }
2482
+ } else if (within2(state, escape)) {
2483
+ if (!nextchar) throw new ValidationError("No escaped character");
2484
+ if (within2(escapedstate, quotes) && nextchar !== state && nextchar !== escapedstate) {
2485
+ token += state;
2486
+ }
2487
+ token += nextchar;
2488
+ state = escapedstate;
2489
+ } else if (state === "a" || state === "c") {
2490
+ if (!nextchar) {
2491
+ state = null;
2492
+ break;
2493
+ } else if (within2(nextchar, whitespace)) {
2494
+ state = " ";
2495
+ if (token || posix && quoted) break;
2496
+ continue;
2497
+ } else if (within2(nextchar, commenters)) {
2498
+ skipLine();
2499
+ if (posix) {
2500
+ state = " ";
2501
+ if (token || posix && quoted) break;
2502
+ continue;
2503
+ }
2504
+ } else if (state === "c") {
2505
+ if (within2(nextchar, punctuation)) {
2506
+ token += nextchar;
2507
+ } else {
2508
+ if (!within2(nextchar, whitespace)) pushbackChars.push(nextchar);
2509
+ state = " ";
2510
+ break;
2511
+ }
2512
+ } else if (posix && within2(nextchar, quotes)) {
2513
+ state = nextchar;
2514
+ } else if (posix && within2(nextchar, escape)) {
2515
+ escapedstate = "a";
2516
+ state = nextchar;
2517
+ } else if (within2(nextchar, wordchars) || within2(nextchar, quotes) || whitespaceSplit && !within2(nextchar, punctuation)) {
2518
+ token += nextchar;
2519
+ } else {
2520
+ if (punctuation) pushbackChars.push(nextchar);
2521
+ else pushback.unshift(nextchar);
2522
+ state = " ";
2523
+ if (token || posix && quoted) break;
2524
+ continue;
2525
+ }
2526
+ }
2527
+ }
2528
+ const result = token;
2529
+ token = "";
2530
+ if (posix && !quoted && result === "") return null;
2531
+ return result;
2532
+ };
2533
+ for (; ; ) {
2534
+ const next = pushback.length > 0 ? pushback.shift() : readToken();
2535
+ if (next === eof) return;
2536
+ yield next;
2537
+ }
2538
+ }
2539
+
2540
+ // src/research/sandbox-validate.ts
2541
+ var READ_ONLY_TOOLS = [
2542
+ "ls",
2543
+ "find",
2544
+ "grep",
2545
+ "egrep",
2546
+ "fgrep",
2547
+ "rg",
2548
+ "cat",
2549
+ "head",
2550
+ "tail",
2551
+ "wc",
2552
+ "sort",
2553
+ "uniq",
2554
+ "cut",
2555
+ "tr",
2556
+ "nl",
2557
+ "file",
2558
+ "stat",
2559
+ "du",
2560
+ "tree",
2561
+ "basename",
2562
+ "dirname",
2563
+ "realpath",
2564
+ "readlink",
2565
+ "sed",
2566
+ "jq",
2567
+ "diff",
2568
+ "cmp",
2569
+ "column",
2570
+ "xargs",
2571
+ "git",
2572
+ "tac",
2573
+ "fold",
2574
+ "paste",
2575
+ "comm",
2576
+ "od",
2577
+ "strings",
2578
+ "seq",
2579
+ "expr"
2580
+ ];
2581
+ var SAFE_BUILTINS = /* @__PURE__ */ new Set(["echo", "printf", "true", "false", "test", "[", "pwd"]);
2582
+ var READ_ONLY_GIT = /* @__PURE__ */ new Set([
2583
+ "log",
2584
+ "show",
2585
+ "grep",
2586
+ "ls-files",
2587
+ "ls-tree",
2588
+ "diff",
2589
+ "blame",
2590
+ "status",
2591
+ "rev-parse",
2592
+ "cat-file",
2593
+ "shortlog",
2594
+ "describe",
2595
+ "rev-list",
2596
+ "name-rev",
2597
+ "annotate"
2598
+ ]);
2599
+ var READ_ONLY_TOOL_SET = new Set(READ_ONLY_TOOLS);
2600
+ var FORBIDDEN_FLAGS = {
2601
+ find: ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"],
2602
+ sort: ["-o", "--output", "--compress-program", "--files0-from"],
2603
+ wc: ["--files0-from"],
2604
+ grep: ["-f", "--file"],
2605
+ egrep: ["-f", "--file"],
2606
+ fgrep: ["-f", "--file"],
2607
+ rg: ["--pre", "--pre-glob", "--search-zip", "-z", "-f", "--file"],
2608
+ tree: ["-o"],
2609
+ file: ["-C", "--compile", "-f", "--files-from"],
2610
+ jq: ["-f", "--from-file", "-L", "--library-path"],
2611
+ git: ["-C", "-c", "--config-env", "--exec-path", "--output", "-o", "--ext-diff", "--textconv", "--open-files-in-pager", "-O"],
2612
+ diff: ["--to-file"]
2613
+ };
2614
+ var SED_IN_PLACE = /^(?:-[a-zA-Z]*i|--in-place)/;
2615
+ var S = SPACE_CLASS;
2616
+ var W = "\\p{L}\\p{N}_";
2617
+ var HIDDEN_EXPANSION = /\$\(|`|<\(|>\(|\$'|\$\{|\$[A-Za-z_0-9@*#?!$-]/;
2618
+ var ALLOWED_STDERR_REDIRECT = new RegExp(`(?<![${W}>&])2>[${S}]*/dev/null|(?<![${W}>&])2>&1`, "gu");
2619
+ var HOME_ESCAPE = new RegExp(`(?:^|[${S}="'])(?:~|\\$HOME(?![${W}])|\\$\\{HOME\\})`, "u");
2620
+ var ABS_PATH = new RegExp(`(?:^|[${S}="'(:,])(/[^${S}"';|&)]*)`, "gu");
2621
+ var DOTDOT_PATH = new RegExp(`(?:^|[${S}="'/])\\.\\.(?:/|[${S}"']|$)`, "u");
2622
+ var FS_ABS_PREFIX = /^\/(?:etc|usr|home|Users|var|tmp|private|opt|root|System|Library|bin|sbin|dev|proc|sys|Applications|Volumes|mnt|media|srv|run|nix|snap)(?:\/|$)/;
2623
+ function outsideSingleQuotes(command) {
2624
+ const out = [];
2625
+ let quoted = false;
2626
+ let escaped = false;
2627
+ for (const char of command) {
2628
+ if (quoted) {
2629
+ quoted = char !== "'";
2630
+ out.push(" ");
2631
+ continue;
2632
+ }
2633
+ if (escaped) {
2634
+ escaped = false;
2635
+ out.push(" ");
2636
+ continue;
2637
+ }
2638
+ if (char === "\\") {
2639
+ escaped = true;
2640
+ out.push(" ");
2641
+ continue;
2642
+ }
2643
+ if (char === "'") {
2644
+ quoted = true;
2645
+ out.push(out.length > 0 && out[out.length - 1] === "$" ? "'" : " ");
2646
+ continue;
2647
+ }
2648
+ out.push(char);
2649
+ }
2650
+ return out.join("");
2651
+ }
2652
+ function assertCommandConfined(root, commandText) {
2653
+ if (HOME_ESCAPE.test(commandText)) {
2654
+ throw new ValidationError("command blocked: home-directory path is outside the workspace");
2655
+ }
2656
+ if (DOTDOT_PATH.test(commandText)) {
2657
+ throw new ValidationError("command blocked: '..' walks outside the workspace");
2658
+ }
2659
+ const resolvedRoot = resolvePath(root);
2660
+ for (const match of commandText.matchAll(ABS_PATH)) {
2661
+ const raw = match[1];
2662
+ let candidates;
2663
+ if (hasGlobMagic(raw)) {
2664
+ const matches = globPaths(raw).map(cleanPath);
2665
+ if (matches.length === 0 && !raw.startsWith(resolvedRoot)) {
2666
+ throw new ValidationError(`command blocked: path pattern escapes workspace: ${raw}`);
2667
+ }
2668
+ candidates = matches;
2669
+ } else {
2670
+ const candidate = cleanPath(raw);
2671
+ if (FS_ABS_PREFIX.exec(raw) === null && !exists(candidate)) continue;
2672
+ candidates = [candidate];
2673
+ }
2674
+ for (const candidate of candidates) {
2675
+ if (escapesRoot(resolvePath(candidate), resolvedRoot)) {
2676
+ throw new ValidationError(`command blocked: path escapes workspace: ${raw}`);
2677
+ }
2678
+ }
2679
+ }
2680
+ }
2681
+ var OPERATOR_CHARS = new Set(";&|()<>");
2682
+ var SEPARATORS = /* @__PURE__ */ new Set(["|", "||", "&&", ";"]);
2683
+ var LITERAL_OPEN = "\uE000";
2684
+ var LITERAL_CLOSE = "\uE001";
2685
+ function protectLiteralParens(commandText) {
2686
+ let out = "";
2687
+ let quote;
2688
+ for (let index = 0; index < commandText.length; index++) {
2689
+ const char = commandText[index];
2690
+ if (quote === void 0 && char === "\\" && (commandText[index + 1] === "(" || commandText[index + 1] === ")")) {
2691
+ out += commandText[index + 1] === "(" ? LITERAL_OPEN : LITERAL_CLOSE;
2692
+ index += 1;
2693
+ continue;
2694
+ }
2695
+ if (quote === void 0 && char === "\\") {
2696
+ out += char + (commandText[index + 1] ?? "");
2697
+ index += 1;
2698
+ continue;
2699
+ }
2700
+ if (quote === void 0 && (char === "'" || char === '"')) quote = char;
2701
+ else if (quote === char) quote = void 0;
2702
+ else if (quote !== void 0 && (char === "(" || char === ")")) {
2703
+ out += char === "(" ? LITERAL_OPEN : LITERAL_CLOSE;
2704
+ continue;
2705
+ }
2706
+ out += char;
2707
+ }
2708
+ return out;
2709
+ }
2710
+ var restoreParens = (token) => token.replaceAll(LITERAL_OPEN, "(").replaceAll(LITERAL_CLOSE, ")");
2711
+ function segments(commandText) {
2712
+ const tokens = [...shlexIter(protectLiteralParens(commandText), { posix: true, punctuationChars: ";&|()<>", whitespaceSplit: true, commenters: "" })];
2713
+ const result = [[]];
2714
+ for (const raw of tokens) {
2715
+ if (raw.includes(LITERAL_OPEN) || raw.includes(LITERAL_CLOSE)) {
2716
+ result[result.length - 1].push(restoreParens(raw));
2717
+ continue;
2718
+ }
2719
+ const token = raw;
2720
+ if (SEPARATORS.has(token)) {
2721
+ result.push([]);
2722
+ } else if (token && [...token].every((char) => OPERATOR_CHARS.has(char))) {
2723
+ if (token === "<") {
2724
+ result[result.length - 1].push(token);
2725
+ continue;
2726
+ }
2727
+ throw new ValidationError(
2728
+ `command blocked: shell operator '${token}' is not allowed (read-only research: no output redirection, background jobs or subshells)`
2729
+ );
2730
+ } else {
2731
+ result[result.length - 1].push(token);
2732
+ }
2733
+ }
2734
+ return result.filter((segment) => segment.length > 0);
2735
+ }
2736
+ var inShortCluster = (arg, letters) => arg.startsWith("-") && !arg.startsWith("--") && arg.slice(1).includes(letters);
2737
+ function checkTool(words) {
2738
+ if (words.length === 0) return;
2739
+ const name = words[0];
2740
+ const args = words.slice(1);
2741
+ if (name.includes("/")) throw new ValidationError(`command blocked: run tools by name, not path: ${name}`);
2742
+ if (SAFE_BUILTINS.has(name)) return;
2743
+ if (!READ_ONLY_TOOL_SET.has(name)) {
2744
+ throw new ValidationError(
2745
+ `command blocked: '${name}' is not a read-only research tool (allowed: ${READ_ONLY_TOOLS.join(", ")})`
2746
+ );
2747
+ }
2748
+ for (const flag of FORBIDDEN_FLAGS[name] ?? []) {
2749
+ if (args.some((arg) => arg === flag || arg.startsWith(`${flag}=`) || flag.length === 2 && flag.startsWith("-") && inShortCluster(arg, flag[1]))) {
2750
+ throw new ValidationError(`command blocked: \`${name} ${flag}\` loads external arguments, writes or executes`);
2751
+ }
2752
+ }
2753
+ if (name === "sed") {
2754
+ for (const arg of args) {
2755
+ if (SED_IN_PLACE.test(arg)) throw new ValidationError("command blocked: sed -i edits files");
2756
+ }
2757
+ checkSed(args);
2758
+ }
2759
+ if (name === "git") {
2760
+ const allowed = [...READ_ONLY_GIT].sort().join(", ");
2761
+ const subcommand = args.find((arg) => !arg.startsWith("-"));
2762
+ if (subcommand === void 0) {
2763
+ throw new ValidationError(`command blocked: \`git\` needs a read-only subcommand (allowed: ${allowed})`);
2764
+ }
2765
+ if (!READ_ONLY_GIT.has(subcommand)) {
2766
+ throw new ValidationError(`command blocked: \`git ${subcommand}\` is not read-only (allowed: ${allowed})`);
2767
+ }
2768
+ }
2769
+ if (name === "xargs") {
2770
+ if (args.some((arg) => arg === "--arg-file" || arg.startsWith("--arg-file=") || arg.startsWith("-a") && !arg.startsWith("--"))) {
2771
+ throw new ValidationError("command blocked: xargs argument files cannot be confined");
2772
+ }
2773
+ const target = xargsCommand(args);
2774
+ if (!["echo", "printf", "true", "false"].includes(target[0])) {
2775
+ throw new ValidationError(`command blocked: xargs can only feed echo/printf here (its file arguments cannot be confined). Search file contents directly instead: grep -rln --include='*.ts' "PATTERN" . or rg -l "PATTERN"`);
2776
+ }
2777
+ checkTool(target);
2778
+ }
2779
+ }
2780
+ var XARGS_WITH_VALUE = /* @__PURE__ */ new Set(["-n", "-I", "-d", "-P", "-L", "-s", "-E", "-a", "--arg-file", "--delimiter", "--max-args", "--max-procs"]);
2781
+ function xargsCommand(args) {
2782
+ let index = 0;
2783
+ while (index < args.length && args[index].startsWith("-")) index += XARGS_WITH_VALUE.has(args[index]) ? 2 : 1;
2784
+ const command = args.slice(index);
2785
+ return command.length > 0 ? command : ["echo"];
2786
+ }
2787
+ var FOLLOW_FLAGS = { rg: "L", grep: "R", egrep: "R", fgrep: "R", find: "L", tree: "l", du: "L" };
2788
+ var SHELL_CONSTRUCTS = /* @__PURE__ */ new Set(["for", "while", "until", "if", "case", "function", "{", "eval", "exec", "source", "."]);
2789
+ function validateCommand(root, commandTextInput) {
2790
+ if (!trimSpace(commandTextInput)) throw new ValidationError("empty shell command");
2791
+ if (trimSpace(commandTextInput).includes("\n")) throw new ValidationError("command blocked: send one command line per turn");
2792
+ const commandText = trimSpace(commandTextInput.replace(ALLOWED_STDERR_REDIRECT, " "));
2793
+ if (HIDDEN_EXPANSION.test(outsideSingleQuotes(commandText))) {
2794
+ throw new ValidationError(
2795
+ "command blocked: command substitution, process substitution and $-expansion are not allowed; write paths and patterns literally"
2796
+ );
2797
+ }
2798
+ for (const token of shlexIter(commandText, { posix: false, whitespaceSplit: true, commenters: "" })) {
2799
+ if (token.startsWith("'") || token.startsWith('"') || token.startsWith("--exclude-dir=")) continue;
2800
+ if (token.includes("{") || token.includes("}")) {
2801
+ throw new ValidationError("command blocked: brace-expanded paths cannot be confined");
2802
+ }
2803
+ }
2804
+ const resolvedRoot = () => resolvePath(root);
2805
+ for (const segment of segments(commandText)) {
2806
+ for (let word of segment.slice(1)) {
2807
+ if (word.startsWith("--exclude-dir=")) continue;
2808
+ if (word === "<") continue;
2809
+ if (word.startsWith("-")) {
2810
+ if (!word.includes("=")) continue;
2811
+ word = word.slice(word.indexOf("=") + 1);
2812
+ }
2813
+ const candidate = joinPath(root, word);
2814
+ const candidates = hasGlobMagic(word) ? globPaths(candidate).map(cleanPath) : [candidate];
2815
+ for (const item of candidates) {
2816
+ if ((exists(item) || isSymlink(item)) && escapesRoot(resolvePath(item), resolvedRoot())) {
2817
+ throw new ValidationError("command blocked: relative path escapes workspace");
2818
+ }
2819
+ }
2820
+ }
2821
+ const tool = segment[0];
2822
+ const followFlags = Object.hasOwn(FOLLOW_FLAGS, tool) ? FOLLOW_FLAGS[tool] : void 0;
2823
+ if (followFlags && segment.slice(1).some((arg) => arg === "--follow" || arg === "--dereference" || arg === "--dereference-recursive" || inShortCluster(arg, followFlags))) {
2824
+ throw new ValidationError("command blocked: recursive symlink following cannot be confined");
2825
+ }
2826
+ if (tool.includes("=") && !tool.startsWith("=")) {
2827
+ throw new ValidationError("command blocked: variable assignment is not allowed");
2828
+ }
2829
+ if (SHELL_CONSTRUCTS.has(tool)) {
2830
+ throw new ValidationError(`command blocked: shell construct '${tool}' is not allowed; use one pipeline`);
2831
+ }
2832
+ checkTool(segment.filter((word) => word !== "<"));
2833
+ }
2834
+ assertCommandConfined(root, commandText);
2835
+ return commandText;
2836
+ }
2837
+
2838
+ // src/research/sandbox.ts
2839
+ function macosProfile(workspace, scratch, readPaths = []) {
2840
+ const quote = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
2841
+ const extra = readPaths.map((dir) => ` (subpath ${quote(dir)})`).join("");
2842
+ return `(version 1)
2843
+ (allow default)
2844
+ (deny network*)
2845
+ (deny file-write*)
2846
+ (allow file-write* (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (subpath ${quote(scratch)}))
2847
+ (deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/root"))
2848
+ (allow file-read-data (subpath ${quote(workspace)}) (subpath ${quote(scratch)})${extra})
2849
+ `;
2850
+ }
2851
+ function which(name, envPath = process.env.PATH) {
2852
+ const searchPath = envPath ?? "/bin:/usr/bin";
2853
+ if (!searchPath) return null;
2854
+ const seen = /* @__PURE__ */ new Set();
2855
+ for (const directory of searchPath.split(":")) {
2856
+ if (seen.has(directory)) continue;
2857
+ seen.add(directory);
2858
+ const candidate = directory ? directory.endsWith("/") ? directory + name : `${directory}/${name}` : name;
2859
+ try {
2860
+ accessSync(candidate, fsConstants.X_OK);
2861
+ } catch {
2862
+ continue;
2863
+ }
2864
+ if (!isDir(candidate)) return candidate;
2865
+ }
2866
+ return null;
2867
+ }
2868
+ var KEPT_ENV = /* @__PURE__ */ new Set(["LANG", "LC_ALL", "LC_CTYPE", "TZ"]);
2869
+ var ReadOnlyShell = class {
2870
+ workspace;
2871
+ kind = "restricted-bash";
2872
+ prefix = [];
2873
+ env = {};
2874
+ bash = "/bin/bash";
2875
+ scratch = null;
2876
+ /** Directories outside the workspace the shell may also read, such as a worktree's git directory. */
2877
+ readPaths;
2878
+ constructor(workspace, options = {}) {
2879
+ this.workspace = workspace;
2880
+ this.readPaths = options.readPaths ?? [];
2881
+ }
2882
+ enter() {
2883
+ this.workspace = resolvePath(this.workspace);
2884
+ const scratch = mkdtempSync2(path6.join(path6.resolve(tmpdir2()), "freellama-shell-"));
2885
+ this.scratch = scratch;
2886
+ const binDir = path6.join(scratch, "bin");
2887
+ mkdirSync3(binDir);
2888
+ for (const tool of READ_ONLY_TOOLS) {
2889
+ const located = which(tool);
2890
+ if (located) symlinkSync(realpathSync(located), path6.join(binDir, tool));
2891
+ }
2892
+ this.bash = which("bash") ?? "/bin/bash";
2893
+ const env = {};
2894
+ for (const [key, value] of Object.entries(process.env)) {
2895
+ if (KEPT_ENV.has(key) && value !== void 0) env[key] = value;
2896
+ }
2897
+ Object.assign(env, {
2898
+ PATH: binDir,
2899
+ HOME: this.workspace,
2900
+ TMPDIR: scratch,
2901
+ TERM: "dumb",
2902
+ PAGER: "cat",
2903
+ GIT_PAGER: "cat",
2904
+ GIT_CONFIG_NOSYSTEM: "1",
2905
+ GIT_CONFIG_GLOBAL: "/dev/null",
2906
+ GIT_OPTIONAL_LOCKS: "0",
2907
+ // `git status` otherwise rewrites the index
2908
+ GIT_TERMINAL_PROMPT: "0"
2909
+ });
2910
+ this.env = env;
2911
+ this.prefix = this.osSandbox(scratch, binDir);
2912
+ return this;
2913
+ }
2914
+ close() {
2915
+ if (this.scratch !== null) {
2916
+ rmSync2(this.scratch, { recursive: true, force: true });
2917
+ this.scratch = null;
2918
+ }
2919
+ }
2920
+ osSandbox(scratch, binDir) {
2921
+ if ((process.env.FREELLAMA_AGENT_OS_SANDBOX ?? "auto") === "off") return [];
2922
+ const sandboxExec = which("sandbox-exec");
2923
+ if (sandboxExec) {
2924
+ const profile = path6.join(scratch, "profile.sb");
2925
+ writeFileSync2(profile, macosProfile(this.workspace, scratch, this.readPaths), "utf8");
2926
+ const prefix = [sandboxExec, "-f", profile];
2927
+ if (probe2([...prefix, this.bash, "-c", "true"], this.env)) {
2928
+ this.kind = "sandbox-exec+restricted-bash";
2929
+ return prefix;
2930
+ }
2931
+ }
2932
+ const bwrap = which("bwrap");
2933
+ if (bwrap) {
2934
+ const prefix = [bwrap, "--unshare-all", "--die-with-parent", "--new-session", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"];
2935
+ for (const systemDir of ["/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/etc/alternatives", "/etc/ld.so.cache", "/etc/localtime", "/nix/store"]) {
2936
+ prefix.push("--ro-bind-try", systemDir, systemDir);
2937
+ }
2938
+ prefix.push(
2939
+ "--ro-bind",
2940
+ this.workspace,
2941
+ this.workspace,
2942
+ ...this.readPaths.flatMap((dir) => ["--ro-bind", dir, dir]),
2943
+ "--ro-bind",
2944
+ binDir,
2945
+ binDir,
2946
+ "--bind",
2947
+ scratch,
2948
+ scratch,
2949
+ "--chdir",
2950
+ this.workspace,
2951
+ "--"
2952
+ );
2953
+ if (probe2([...prefix, this.bash, "-c", "true"], this.env)) {
2954
+ this.kind = "bwrap+restricted-bash";
2955
+ return prefix;
2956
+ }
2957
+ }
2958
+ return [];
2959
+ }
2960
+ /**
2961
+ * Run one read-only tool with an argument vector, no shell: the structured `search` action uses
2962
+ * it so a pattern such as `${x}` or `(` is data, never shell syntax. Same OS sandbox, PATH and
2963
+ * scrubbed environment as `run`; exit 1 of a search means no matches.
2964
+ */
2965
+ /** Whether `tool` was found on the caller's PATH when the shell was entered. */
2966
+ hasTool(tool) {
2967
+ return this.scratch !== null && existsSync4(path6.join(this.scratch, "bin", tool));
2968
+ }
2969
+ async runTool(argv, timeoutSeconds) {
2970
+ const [tool, ...args] = argv;
2971
+ if (!tool || !READ_ONLY_TOOLS.includes(tool) || this.scratch === null) {
2972
+ throw new ValidationError(`tool ${tool ?? "(none)"} is not available to structured actions`);
2973
+ }
2974
+ const result = await runProcess(
2975
+ [...this.prefix, path6.join(this.scratch, "bin", tool), ...args],
2976
+ { cwd: this.workspace, env: this.env, timeoutSeconds }
2977
+ );
2978
+ if (result.returncode === 1 && !trimSpace(result.stderr)) return "(no matches)";
2979
+ if (result.returncode !== 0) throw new ToolExitError(result.returncode, result.stdout + result.stderr);
2980
+ return trimSpace(result.stdout) ? result.stdout : "(no matches)";
2981
+ }
2982
+ async run(commandText, timeoutSeconds) {
2983
+ const command = validateCommand(this.workspace, commandText);
2984
+ const tokens = shlexTokens(command, { posix: true, punctuationChars: ";&|()<>", whitespaceSplit: true, commenters: "" });
2985
+ if (tokens.some((token) => token === ";" || token === "&&" || token === "||")) {
2986
+ throw new ValidationError("command blocked: send one pipeline per turn so each execution outcome can be recorded");
2987
+ }
2988
+ const result = await runProcess(
2989
+ [...this.prefix, this.bash, "--restricted", "--noprofile", "--norc", "-o", "pipefail", "-c", command],
2990
+ { cwd: this.workspace, env: this.env, timeoutSeconds }
2991
+ );
2992
+ let output = result.stdout + result.stderr;
2993
+ if (result.returncode !== 0) {
2994
+ const parts = segments(command);
2995
+ const words = parts.length === 1 ? parts[0] : [];
2996
+ const isSearch = ["rg", "grep", "egrep", "fgrep"].includes(words[0] ?? "") || words[0] === "git" && words[1] === "grep";
2997
+ const isDiff = words[0] === "diff" || words[0] === "git" && words[1] === "diff";
2998
+ if (result.returncode === 1 && isSearch && !trimSpace(result.stderr)) {
2999
+ throw new ToolExitError(result.returncode, output, "empty");
3000
+ }
3001
+ if (!(result.returncode === 1 && isDiff && !trimSpace(result.stderr))) {
3002
+ throw new ToolExitError(result.returncode, output);
3003
+ }
3004
+ }
3005
+ if (!trimSpace(output)) output = `(no output, exit code ${result.returncode})`;
3006
+ return output;
3007
+ }
3008
+ };
3009
+
3010
+ // src/research/structured-actions.ts
3011
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, realpathSync as realpathSync2, statSync as statSync2 } from "node:fs";
3012
+ import path7 from "node:path";
3013
+ var SKIPPED_DIRS = ["node_modules", "target", "dist", "build", ".git", ".venv", "venv", "vendor", "__pycache__", ".octocode", "site-packages", "coverage"];
3014
+ var READ_DEFAULT_LINES = 120;
3015
+ var READ_MAX_LINES = 400;
3016
+ var LIST_MAX_ENTRIES = 300;
3017
+ var SEARCH_MAX_PER_FILE = 20;
3018
+ function confined(workspace, rel) {
3019
+ const raw = (rel ?? ".").trim() || ".";
3020
+ if (path7.isAbsolute(raw) && !raw.startsWith(workspace)) throw new ValidationError(`path is outside the workspace: ${raw}`);
3021
+ const absolute = path7.resolve(workspace, raw);
3022
+ const real = existsSync5(absolute) ? realpathSync2(absolute) : absolute;
3023
+ if (escapesRoot(real, workspace)) throw new ValidationError(`path is outside the workspace: ${raw}`);
3024
+ return path7.relative(workspace, real) || ".";
3025
+ }
3026
+ function validateSearch(value) {
3027
+ if (typeof value.pattern !== "string" || !value.pattern.trim()) throw new ValidationError("search action requires a non-empty string pattern");
3028
+ if (value.pattern.length > 300) throw new ValidationError("search pattern is longer than 300 characters");
3029
+ for (const key of ["path", "glob"]) {
3030
+ if (value[key] !== void 0 && typeof value[key] !== "string") throw new ValidationError(`search ${key} must be a string`);
3031
+ }
3032
+ for (const key of ["regex", "ignoreCase"]) {
3033
+ if (value[key] !== void 0 && typeof value[key] !== "boolean") throw new ValidationError(`search ${key} must be true or false`);
3034
+ }
3035
+ }
3036
+ function validateRead(value) {
3037
+ if (typeof value.path !== "string" || !value.path.trim()) throw new ValidationError("read action requires a file path");
3038
+ for (const key of ["start", "end"]) {
3039
+ const n = value[key];
3040
+ if (n !== void 0 && (typeof n !== "number" || !Number.isInteger(n) || n < 1)) throw new ValidationError(`read ${key} must be a positive integer line number`);
3041
+ }
3042
+ }
3043
+ function searchArgv(workspace, action, ripgrep) {
3044
+ const target = confined(workspace, action.path);
3045
+ if (ripgrep) {
3046
+ return [
3047
+ "rg",
3048
+ "-n",
3049
+ "--no-heading",
3050
+ "--color",
3051
+ "never",
3052
+ "--max-columns",
3053
+ "300",
3054
+ "--max-count",
3055
+ String(SEARCH_MAX_PER_FILE),
3056
+ ...action.regex ? [] : ["-F"],
3057
+ ...action.ignoreCase ? ["-i"] : [],
3058
+ ...action.glob ? ["-g", action.glob] : [],
3059
+ ...SKIPPED_DIRS.flatMap((dir) => ["-g", `!${dir}/`]),
3060
+ "-e",
3061
+ action.pattern,
3062
+ "--",
3063
+ target
3064
+ ];
3065
+ }
3066
+ return [
3067
+ "grep",
3068
+ "-rnI",
3069
+ ...action.regex ? ["-E"] : ["-F"],
3070
+ ...action.ignoreCase ? ["-i"] : [],
3071
+ ...action.glob ? [`--include=${action.glob}`] : [],
3072
+ ...SKIPPED_DIRS.map((dir) => `--exclude-dir=${dir}`),
3073
+ "-e",
3074
+ action.pattern,
3075
+ "--",
3076
+ target
3077
+ ];
3078
+ }
3079
+ function readLines(workspace, action) {
3080
+ const resolved = resolveNamedPath(workspace, action.path, "file");
3081
+ if (resolved.path === void 0) throw new ValidationError(`no such file: ${resolved.note}; search or list to find the right path`);
3082
+ const rel = resolved.path;
3083
+ const absolute = path7.join(workspace, rel);
3084
+ if (!statSync2(absolute).isFile()) throw new ValidationError(`${rel} is a directory; use list`);
3085
+ const bytes = readFileSync6(absolute);
3086
+ if (bytes.includes(0)) throw new ValidationError(`${rel} is binary`);
3087
+ const text = bytes.toString("utf8");
3088
+ const lines = (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n");
3089
+ const start = Math.min(action.start ?? 1, Math.max(lines.length, 1));
3090
+ const end = Math.min(action.end ?? start + READ_DEFAULT_LINES - 1, start + READ_MAX_LINES - 1, lines.length);
3091
+ const width = String(end).length;
3092
+ const body = lines.slice(start - 1, end).map((line2, index) => `${String(start + index).padStart(width)} ${line2}`).join("\n");
3093
+ const more = end < lines.length ? `
3094
+ (${rel} has ${lines.length} lines; read start=${end + 1} for more)` : "";
3095
+ return `${resolved.note ? `${resolved.note}
3096
+ ` : ""}${rel}:${start}-${end}
3097
+ ${body}${more}`;
3098
+ }
3099
+ function listEntries(workspace, rel) {
3100
+ const resolved = resolveNamedPath(workspace, rel, "dir");
3101
+ if (resolved.path === void 0) throw new ValidationError(`not a directory: ${resolved.note}`);
3102
+ const base = resolved.path;
3103
+ const root = path7.join(workspace, base);
3104
+ if (!statSync2(root).isDirectory()) throw new ValidationError(`not a directory: ${base}; read it instead`);
3105
+ const out = [];
3106
+ const walk = (dir, depth) => {
3107
+ let entries;
3108
+ try {
3109
+ entries = readdirSync3(dir, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
3110
+ } catch {
3111
+ return;
3112
+ }
3113
+ for (const entry of entries) {
3114
+ if (out.length >= LIST_MAX_ENTRIES) return;
3115
+ if (entry.name.startsWith(".") || SKIPPED_DIRS.includes(entry.name)) continue;
3116
+ const shown = path7.relative(workspace, path7.join(dir, entry.name));
3117
+ out.push(entry.isDirectory() ? `${shown}/` : shown);
3118
+ if (entry.isDirectory() && depth < 1) walk(path7.join(dir, entry.name), depth + 1);
3119
+ }
3120
+ };
3121
+ walk(root, 0);
3122
+ const listing = out.length === 0 ? `(${base} is empty)` : out.join("\n") + (out.length >= LIST_MAX_ENTRIES ? "\n(listing truncated; list a subdirectory)" : "");
3123
+ return resolved.note ? `${resolved.note}
3124
+ ${listing}` : listing;
3125
+ }
3126
+ var INDEX_MAX_ENTRIES = 5e4;
3127
+ var indexes = /* @__PURE__ */ new Map();
3128
+ function workspaceIndex(workspace) {
3129
+ const cached = indexes.get(workspace);
3130
+ if (cached) return cached;
3131
+ const files = [];
3132
+ const dirs = [];
3133
+ const walk = (dir) => {
3134
+ let entries;
3135
+ try {
3136
+ entries = readdirSync3(dir, { withFileTypes: true });
3137
+ } catch {
3138
+ return;
3139
+ }
3140
+ for (const entry of entries) {
3141
+ if (files.length + dirs.length >= INDEX_MAX_ENTRIES) return;
3142
+ if (entry.name.startsWith(".") || SKIPPED_DIRS.includes(entry.name)) continue;
3143
+ const absolute = path7.join(dir, entry.name);
3144
+ const rel = path7.relative(workspace, absolute);
3145
+ if (entry.isDirectory()) {
3146
+ dirs.push(rel);
3147
+ walk(absolute);
3148
+ } else if (entry.isFile()) {
3149
+ files.push(rel);
3150
+ }
3151
+ }
3152
+ };
3153
+ walk(workspace);
3154
+ const index = { files, dirs };
3155
+ indexes.set(workspace, index);
3156
+ return index;
3157
+ }
3158
+ function forgetWorkspaceIndex(workspace) {
3159
+ indexes.delete(workspace);
3160
+ }
3161
+ function pathCandidates(workspace, rel, kind = "any") {
3162
+ const wanted = rel.replace(/^\.\//, "").replace(/\/+$/, "");
3163
+ if (!wanted || wanted === ".") return [];
3164
+ const { files, dirs } = workspaceIndex(workspace);
3165
+ const pool = kind === "file" ? files : kind === "dir" ? dirs : [...files, ...dirs];
3166
+ const suffix = `/${wanted}`;
3167
+ return pool.filter((candidate) => candidate === wanted || candidate.endsWith(suffix)).sort((a, b) => a.length - b.length);
3168
+ }
3169
+ function resolveNamedPath(workspace, rel, kind) {
3170
+ const named = confined(workspace, rel);
3171
+ if (named === "." || existsSync5(path7.join(workspace, named))) return { path: named };
3172
+ const candidates = pathCandidates(workspace, named, kind);
3173
+ if (candidates.length === 1) return { path: candidates[0], note: `(${named} does not exist; using ${candidates[0]})` };
3174
+ if (candidates.length > 1) {
3175
+ return { path: void 0, note: `${named} does not exist; matching paths: ${candidates.slice(0, 8).join(", ")}${candidates.length > 8 ? ", \u2026" : ""}` };
3176
+ }
3177
+ return { path: void 0, note: `${named} does not exist and no path ends with it` };
3178
+ }
3179
+ function looksLikeRegex(pattern) {
3180
+ return /\||\.\*|\\[bswdBSWD]|\[[^\]]+\]|^\^|\$$|\(\?|\.\+/.test(pattern);
3181
+ }
3182
+
3183
+ // src/research/coder-actions.ts
3184
+ var WRITE_MAX_BYTES = 256 * 1024;
3185
+ var MAX_ALLOWED_COMMANDS = 10;
3186
+ var DEFAULT_RUN_TIMEOUT_SECONDS = 300;
3187
+ var MAX_RUN_TIMEOUT_SECONDS = 1800;
3188
+ var OBSERVATION_LINES = { head: 10, tail: 40, lineChars: 240 };
3189
+ var RESULT_LINES = { head: 20, tail: 80, lineChars: 300 };
3190
+ var CAPTURE_HEAD_BYTES = 256 * 1024;
3191
+ var CAPTURE_TAIL_BYTES = 1024 * 1024;
3192
+ var EDIT_PREVIEW_LINES = 12;
3193
+ function confinedWritePath(root, rel) {
3194
+ if (typeof rel !== "string" || !rel.trim()) throw new ValidationError("a file path is required");
3195
+ const raw = rel.trim();
3196
+ if (raw.includes("\0")) throw new ValidationError("path contains a NUL byte");
3197
+ const lexical = path8.resolve(root, raw);
3198
+ const relative = path8.relative(root, lexical);
3199
+ if (!relative || relative.startsWith("..") || path8.isAbsolute(relative)) {
3200
+ throw new ValidationError(`path is outside the copy: ${raw}`);
3201
+ }
3202
+ if (relative.split(path8.sep).includes(".git")) throw new ValidationError(`${relative} is inside .git, which is not editable`);
3203
+ const physical = resolvePath(lexical);
3204
+ if (escapesRoot(physical, root)) throw new ValidationError(`path leaves the copy through a symlink: ${raw}`);
3205
+ return { rel: relative, absolute: physical };
3206
+ }
3207
+ function occurrences(text, needle) {
3208
+ const found = [];
3209
+ for (let at = text.indexOf(needle); at >= 0; at = text.indexOf(needle, at + needle.length)) found.push(at);
3210
+ return found;
3211
+ }
3212
+ var lineAt = (text, offset) => text.slice(0, offset).split("\n").length;
3213
+ function looseMatchLine(text, search) {
3214
+ const wanted = search.split("\n").map((part) => part.trim());
3215
+ while (wanted.length > 0 && wanted[wanted.length - 1] === "") wanted.pop();
3216
+ if (wanted.length === 0) return void 0;
3217
+ const lines = text.split("\n").map((part) => part.trim());
3218
+ const hits = [];
3219
+ for (let index = 0; index + wanted.length <= lines.length && hits.length < 2; index += 1) {
3220
+ if (wanted.every((part, offset) => lines[index + offset] === part)) hits.push(index + 1);
3221
+ }
3222
+ return hits.length === 1 ? hits[0] : void 0;
3223
+ }
3224
+ function numbered(text, from, to) {
3225
+ const lines = text.split("\n");
3226
+ const end = Math.min(to, from + EDIT_PREVIEW_LINES - 1, lines.length);
3227
+ const width = String(end).length;
3228
+ return lines.slice(from - 1, end).map((part, index) => `${String(from + index).padStart(width)} ${part}`).join("\n") + (end < to ? `
3229
+ (${to - end} more changed lines)` : "");
3230
+ }
3231
+ function applyEdit(root, action) {
3232
+ const resolved = resolveNamedPath(root, action.path, "file");
3233
+ if (resolved.path === void 0) throw new ValidationError(`no such file: ${resolved.note}; use write to create a file`);
3234
+ const { rel, absolute } = confinedWritePath(root, resolved.path);
3235
+ if (!statSync3(absolute).isFile()) throw new ValidationError(`${rel} is not a file`);
3236
+ const bytes = readFileSync7(absolute);
3237
+ if (bytes.includes(0)) throw new ValidationError(`${rel} is binary and cannot be edited`);
3238
+ const text = bytes.toString("utf8");
3239
+ const crlf = text.includes("\r\n") && !action.search.includes("\r");
3240
+ const search = crlf ? action.search.replaceAll("\n", "\r\n") : action.search;
3241
+ const replace = crlf ? action.replace.replaceAll("\n", "\r\n") : action.replace;
3242
+ const found = occurrences(text, search);
3243
+ if (found.length === 0) {
3244
+ const loose = looseMatchLine(text.replaceAll("\r\n", "\n"), action.search);
3245
+ throw new ValidationError(
3246
+ `search text not found in ${rel} (0 matches). ` + (loose ? `It matches line ${loose} if indentation is ignored: read those lines and copy them exactly. ` : "Read the lines again and copy them exactly, indentation included. ") + "Nothing was changed."
3247
+ );
3248
+ }
3249
+ if (found.length > 1) {
3250
+ const lines = found.slice(0, 6).map((offset) => lineAt(text, offset)).join(", ");
3251
+ throw new ValidationError(`search text matches ${found.length} times in ${rel} (lines ${lines}${found.length > 6 ? ", \u2026" : ""}); include more surrounding lines so it matches exactly once. Nothing was changed.`);
3252
+ }
3253
+ const at = found[0];
3254
+ const updated = text.slice(0, at) + replace + text.slice(at + search.length);
3255
+ writeFileSync3(absolute, updated);
3256
+ const first = lineAt(updated, at);
3257
+ const removed = search.split("\n").length;
3258
+ const added = replace === "" ? 0 : replace.split("\n").length;
3259
+ const shown = updated.replaceAll("\r\n", "\n");
3260
+ return `${resolved.note ? `${resolved.note}
3261
+ ` : ""}edited ${rel}: ${removed} line(s) at line ${first} replaced by ${added} line(s)` + (added > 0 ? `
3262
+ ${numbered(shown, first, first + added - 1)}` : "");
3263
+ }
3264
+ function writeWholeFile(root, action) {
3265
+ const { rel, absolute } = confinedWritePath(root, action.path);
3266
+ const size = Buffer.byteLength(action.content, "utf8");
3267
+ if (size > WRITE_MAX_BYTES) throw new ValidationError(`content is ${size} bytes; write accepts at most ${WRITE_MAX_BYTES} (use edit for large files)`);
3268
+ const existed = existsSync6(absolute);
3269
+ if (existed && !statSync3(absolute).isFile()) throw new ValidationError(`${rel} is a directory`);
3270
+ mkdirSync4(path8.dirname(absolute), { recursive: true });
3271
+ writeFileSync3(absolute, action.content);
3272
+ const lines = action.content === "" ? 0 : action.content.split("\n").length - (action.content.endsWith("\n") ? 1 : 0);
3273
+ return `${existed ? "replaced" : "created"} ${rel} (${lines} lines, ${size} bytes)`;
3274
+ }
3275
+ function allowedCommand(requested, allowed) {
3276
+ if (typeof requested !== "string" || !requested.trim()) throw new ValidationError("run requires a command");
3277
+ if (allowed.length === 0) throw new ValidationError("no commands are allowed in this task, so nothing can be run; finish and say the change is untested");
3278
+ const match = allowed.find((command) => command === requested.trim());
3279
+ if (match === void 0) {
3280
+ throw new ValidationError(`command not allowed: ${JSON.stringify(requested)}. Run exactly one of: ${allowed.map((command) => JSON.stringify(command)).join(", ")}`);
3281
+ }
3282
+ return match;
3283
+ }
3284
+ function normalizeAllowedCommands(value) {
3285
+ if (value === void 0) return [];
3286
+ if (!Array.isArray(value) || value.some((command) => typeof command !== "string")) throw new ValidationError("commands must be an array of strings");
3287
+ const commands = [...new Set(value.map((command) => command.trim()))];
3288
+ if (commands.some((command) => !command || /[\n\r\0]/.test(command))) throw new ValidationError("each command must be one non-empty line");
3289
+ if (commands.length > MAX_ALLOWED_COMMANDS) throw new ValidationError(`at most ${MAX_ALLOWED_COMMANDS} commands may be allowed`);
3290
+ return commands;
3291
+ }
3292
+ var ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
3293
+ var lenient = new TextDecoder("utf-8", { fatal: false });
3294
+ function headTail(text, options, gap) {
3295
+ const lines = text.endsWith("\n") ? text.slice(0, -1).split("\n") : text.split("\n");
3296
+ const clip2 = (part) => part.length > options.lineChars ? `${part.slice(0, options.lineChars)}\u2026` : part;
3297
+ const marker = (count) => `\u2026 ${count} lines omitted \u2026`;
3298
+ if (lines.length <= options.head + options.tail) {
3299
+ if (!gap) return lines.map(clip2).join("\n");
3300
+ return [...lines.slice(0, gap.line).map(clip2), marker(gap.lines), ...lines.slice(gap.line).map(clip2)].join("\n");
3301
+ }
3302
+ const cutEnd = lines.length - options.tail;
3303
+ const insideCut = gap !== void 0 && gap.line >= options.head && gap.line <= cutEnd;
3304
+ const omitted = cutEnd - options.head + (insideCut ? gap.lines : 0);
3305
+ const kept = [...lines.slice(0, options.head).map(clip2), marker(omitted), ...lines.slice(cutEnd).map(clip2)];
3306
+ if (gap && !insideCut) {
3307
+ const at = gap.line < options.head ? gap.line : gap.line - cutEnd + options.head + 1;
3308
+ kept.splice(at, 0, marker(gap.lines));
3309
+ }
3310
+ return kept.join("\n");
3311
+ }
3312
+ var OutputCapture = class {
3313
+ head = [];
3314
+ headBytes = 0;
3315
+ tail = [];
3316
+ tailBytes = 0;
3317
+ droppedLines = 0;
3318
+ droppedBytes = 0;
3319
+ push(chunk) {
3320
+ if (this.headBytes < CAPTURE_HEAD_BYTES) {
3321
+ const taken = chunk.subarray(0, CAPTURE_HEAD_BYTES - this.headBytes);
3322
+ this.head.push(taken);
3323
+ this.headBytes += taken.length;
3324
+ chunk = chunk.subarray(taken.length);
3325
+ }
3326
+ if (chunk.length === 0) return;
3327
+ this.tail.push(chunk);
3328
+ this.tailBytes += chunk.length;
3329
+ while (this.tailBytes - this.tail[0].length >= CAPTURE_TAIL_BYTES) {
3330
+ const dropped = this.tail.shift();
3331
+ this.tailBytes -= dropped.length;
3332
+ this.droppedBytes += dropped.length;
3333
+ for (let at = dropped.indexOf(10); at >= 0; at = dropped.indexOf(10, at + 1)) this.droppedLines += 1;
3334
+ }
3335
+ }
3336
+ /** The decoded text, and where bytes were dropped (a line index into it) when they were. */
3337
+ text() {
3338
+ const clean2 = (bytes) => lenient.decode(bytes).replace(ANSI, "").replace(/\r\n?/g, "\n");
3339
+ const head = clean2(Buffer.concat(this.head));
3340
+ const tail = clean2(Buffer.concat(this.tail));
3341
+ if (this.droppedBytes === 0) return { text: head + tail };
3342
+ const headLines = head.split("\n").length;
3343
+ return { text: `${head}
3344
+ ${tail}`, gap: { line: headLines, lines: this.droppedLines } };
3345
+ }
3346
+ };
3347
+ function descendants(pid) {
3348
+ const listing = spawnSync2("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
3349
+ if (listing.status !== 0) return [];
3350
+ const children = /* @__PURE__ */ new Map();
3351
+ for (const row of listing.stdout.split("\n")) {
3352
+ const [child, parent] = row.trim().split(/\s+/).map(Number);
3353
+ if (!child || parent === void 0 || Number.isNaN(parent)) continue;
3354
+ children.set(parent, [...children.get(parent) ?? [], child]);
3355
+ }
3356
+ const out = [];
3357
+ const queue = [pid];
3358
+ while (queue.length > 0) {
3359
+ for (const child of children.get(queue.shift()) ?? []) {
3360
+ out.push(child);
3361
+ queue.push(child);
3362
+ }
3363
+ }
3364
+ return out;
3365
+ }
3366
+ function killTree(pid) {
3367
+ for (const target of [pid, ...descendants(pid)]) {
3368
+ try {
3369
+ process.kill(target, "SIGKILL");
3370
+ } catch {
3371
+ }
3372
+ }
3373
+ }
3374
+ var SECRET_HOME_PATHS = [".ssh", ".aws", ".gnupg", ".config/gh", ".netrc", ".git-credentials", ".docker", ".kube", ".octocode", "Library/Keychains"];
3375
+ function secretPaths(env) {
3376
+ const candidates = [
3377
+ ...env.HOME ? SECRET_HOME_PATHS.map((rel) => path8.join(env.HOME, rel)) : [],
3378
+ ...env.FREELLAMA_HOME ? [env.FREELLAMA_HOME] : []
3379
+ ];
3380
+ const found = /* @__PURE__ */ new Set();
3381
+ for (const candidate of candidates) {
3382
+ try {
3383
+ found.add(realpathSync3(candidate));
3384
+ } catch {
3385
+ }
3386
+ }
3387
+ return [...found];
3388
+ }
3389
+ function macosRunProfile(writable, hidden) {
3390
+ const quote = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
3391
+ const unreadable = hidden.map((target) => ` (${lstatSync2(target).isDirectory() ? "subpath" : "literal"} ${quote(target)})`).join("");
3392
+ return `(version 1)
3393
+ (allow default)
3394
+ (deny network*)
3395
+ (deny file-write*)
3396
+ (allow file-write* (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (regex #"^/dev/fd/")${writable.map((dir) => ` (subpath ${quote(dir)})`).join("")})
3397
+ ${unreadable ? `(deny file-read* file-write*${unreadable})
3398
+ ` : ""}`;
3399
+ }
3400
+ var CommandRunner = class {
3401
+ constructor(cwd, writable, env, options) {
3402
+ this.cwd = cwd;
3403
+ this.writable = writable;
3404
+ this.env = env;
3405
+ this.options = options;
3406
+ }
3407
+ cwd;
3408
+ writable;
3409
+ env;
3410
+ options;
3411
+ kind = "none";
3412
+ prefix = [];
3413
+ enter() {
3414
+ if (this.options.osSandbox === "off") return this;
3415
+ const sandboxExec = which("sandbox-exec");
3416
+ if (sandboxExec) {
3417
+ const profile = path8.join(this.options.privateDir, "run.sb");
3418
+ writeFileSync3(profile, macosRunProfile(this.writable, this.options.hidden ?? []), { mode: 384 });
3419
+ const prefix = [sandboxExec, "-f", profile];
3420
+ if (probeRun([...prefix, "/bin/sh", "-c", "true"], this.env, this.cwd)) {
3421
+ this.kind = "sandbox-exec";
3422
+ this.prefix = prefix;
3423
+ return this;
3424
+ }
3425
+ }
3426
+ const bwrap = which("bwrap");
3427
+ if (bwrap) {
3428
+ const hide = (this.options.hidden ?? []).flatMap((target) => lstatSync2(target).isDirectory() ? ["--tmpfs", target] : ["--ro-bind", "/dev/null", target]);
3429
+ const prefix = [
3430
+ bwrap,
3431
+ "--die-with-parent",
3432
+ "--new-session",
3433
+ "--unshare-all",
3434
+ "--ro-bind",
3435
+ "/",
3436
+ "/",
3437
+ "--dev",
3438
+ "/dev",
3439
+ "--proc",
3440
+ "/proc",
3441
+ "--tmpfs",
3442
+ "/tmp",
3443
+ ...hide,
3444
+ ...this.writable.flatMap((dir) => ["--bind", dir, dir]),
3445
+ "--chdir",
3446
+ this.cwd,
3447
+ "--"
3448
+ ];
3449
+ if (probeRun([...prefix, "/bin/sh", "-c", "true"], this.env, this.cwd)) {
3450
+ this.kind = "bwrap";
3451
+ this.prefix = prefix;
3452
+ }
3453
+ }
3454
+ return this;
3455
+ }
3456
+ async run(command, timeoutSeconds) {
3457
+ if (this.kind === "none" && !this.options.allowUnsandboxed) {
3458
+ throw new ValidationError(
3459
+ "run is unavailable: this host has no OS sandbox (sandbox-exec or bwrap) to confine it, and the operator has not set FREELLAMA_CODER_UNSANDBOXED_RUN=1. Finish and say the change is untested."
3460
+ );
3461
+ }
3462
+ const started = performance.now();
3463
+ const capture = new OutputCapture();
3464
+ const argv = [...this.prefix, "/bin/sh", "-c", command];
3465
+ const outcome = await new Promise((resolve, reject) => {
3466
+ const child = spawn2(argv[0], argv.slice(1), { cwd: this.cwd, env: this.env, stdio: ["ignore", "pipe", "pipe"] });
3467
+ let timedOut = false;
3468
+ let grace;
3469
+ const settle = (code, signal) => {
3470
+ clearTimeout(timer);
3471
+ clearTimeout(grace);
3472
+ resolve({ code, signal, timedOut });
3473
+ };
3474
+ const release = (code, signal) => {
3475
+ grace = setTimeout(() => {
3476
+ child.stdout.destroy();
3477
+ child.stderr.destroy();
3478
+ settle(code, signal);
3479
+ }, 2e3);
3480
+ };
3481
+ const timer = setTimeout(() => {
3482
+ timedOut = true;
3483
+ if (child.pid !== void 0) killTree(child.pid);
3484
+ release(null, "SIGKILL");
3485
+ }, Math.max(timeoutSeconds, 1e-3) * 1e3);
3486
+ child.stdout.on("data", (chunk) => capture.push(chunk));
3487
+ child.stderr.on("data", (chunk) => capture.push(chunk));
3488
+ child.once("error", (error) => {
3489
+ clearTimeout(timer);
3490
+ clearTimeout(grace);
3491
+ reject(error);
3492
+ });
3493
+ child.once("exit", (code, signal) => {
3494
+ if (grace === void 0) release(code, signal);
3495
+ });
3496
+ child.once("close", (code, signal) => settle(code, signal));
3497
+ });
3498
+ const { text, gap } = capture.text();
3499
+ return {
3500
+ command,
3501
+ exitCode: outcome.code,
3502
+ signal: outcome.signal,
3503
+ timedOut: outcome.timedOut,
3504
+ durationMs: Math.round(performance.now() - started),
3505
+ observed: headTail(text, OBSERVATION_LINES, gap),
3506
+ kept: headTail(text, RESULT_LINES, gap)
3507
+ };
3508
+ }
3509
+ };
3510
+ function probeRun(argv, env, cwd) {
3511
+ const result = spawnSync2(argv[0], argv.slice(1), { cwd, env, stdio: ["ignore", "pipe", "pipe"], timeout: 1e4, killSignal: "SIGKILL" });
3512
+ return !result.error && result.status === 0;
3513
+ }
3514
+ function describeRun(outcome) {
3515
+ const seconds2 = (outcome.durationMs / 1e3).toFixed(1);
3516
+ const ended = outcome.timedOut ? `timed out after ${seconds2} s and was killed` : outcome.exitCode !== null ? `exit code ${outcome.exitCode} (${seconds2} s)` : `killed by ${outcome.signal ?? "a signal"} (${seconds2} s)`;
3517
+ return `$ ${outcome.command}
3518
+ ${ended}
3519
+ ${outcome.observed.trim() ? outcome.observed : "(no output)"}`;
3520
+ }
3521
+ function runEnvironment(source, tmp) {
3522
+ const env = {};
3523
+ for (const key of [
3524
+ "PATH",
3525
+ "HOME",
3526
+ "USER",
3527
+ "LOGNAME",
3528
+ "LANG",
3529
+ "LC_ALL",
3530
+ "LC_CTYPE",
3531
+ "TZ",
3532
+ "CARGO_HOME",
3533
+ "RUSTUP_HOME",
3534
+ "COREPACK_HOME",
3535
+ "XDG_CACHE_HOME",
3536
+ "npm_config_cache"
3537
+ ]) {
3538
+ if (source[key] !== void 0) env[key] = source[key];
3539
+ }
3540
+ return {
3541
+ ...env,
3542
+ PATH: env.PATH ?? "/usr/bin:/bin",
3543
+ TMPDIR: tmp,
3544
+ TMP: tmp,
3545
+ TEMP: tmp,
3546
+ CI: "1",
3547
+ NO_COLOR: "1",
3548
+ FORCE_COLOR: "0",
3549
+ TERM: "dumb",
3550
+ // The sandbox has no network: let cargo use its cache instead of failing on a registry update.
3551
+ CARGO_NET_OFFLINE: "true",
3552
+ GIT_TERMINAL_PROMPT: "0"
3553
+ };
3554
+ }
3555
+
3556
+ // src/research/isolated-copy.ts
3557
+ import { spawnSync as spawnSync3 } from "node:child_process";
3558
+ import {
3559
+ cpSync,
3560
+ existsSync as existsSync7,
3561
+ lstatSync as lstatSync3,
3562
+ mkdirSync as mkdirSync5,
3563
+ mkdtempSync as mkdtempSync3,
3564
+ readFileSync as readFileSync8,
3565
+ readdirSync as readdirSync4,
3566
+ realpathSync as realpathSync4,
3567
+ rmSync as rmSync3,
3568
+ symlinkSync as symlinkSync2,
3569
+ writeFileSync as writeFileSync4
3570
+ } from "node:fs";
3571
+ import { tmpdir as tmpdir3 } from "node:os";
3572
+ import path9 from "node:path";
3573
+ var WORKTREE_NAME = "freellama-coder";
3574
+ var COPY_SKIPPED = ["node_modules", "target", "dist", ".git"];
3575
+ var DEFAULT_MAX_COPY_BYTES = 200 * 1024 * 1024;
3576
+ var LINK_SKIPPED_DOT = /* @__PURE__ */ new Set([".bin", ".pnpm"]);
3577
+ var LINK_MAX_DEPTH = 4;
3578
+ var LINK_MAX_DIRS = 32;
3579
+ var GIT_MAX_BUFFER = 512 * 1024 * 1024;
3580
+ var GIT_FLAGS = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false"];
3581
+ function baseGitEnv() {
3582
+ const env = {};
3583
+ for (const [key, value] of Object.entries(process.env)) {
3584
+ if (value !== void 0 && !key.startsWith("GIT_")) env[key] = value;
3585
+ }
3586
+ return { ...env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" };
3587
+ }
3588
+ function git(args, cwd, env = baseGitEnv(), input) {
3589
+ const result = spawnSync3("git", [...GIT_FLAGS, ...args], { cwd, env, input, maxBuffer: GIT_MAX_BUFFER, stdio: ["pipe", "pipe", "pipe"] });
3590
+ if (result.error) throw new ValidationError(`git ${args[0]} could not run: ${result.error.message}`);
3591
+ return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr.toString("utf8").trim() };
3592
+ }
3593
+ function gitOk(args, cwd, env, input) {
3594
+ const result = git(args, cwd, env, input);
3595
+ if (result.status !== 0) throw new ValidationError(`git ${args.join(" ")} failed: ${result.stderr || `exit ${result.status}`}`);
3596
+ return result.stdout;
3597
+ }
3598
+ var line = (bytes) => bytes.toString("utf8").trim();
3599
+ function repositoryTop(dir) {
3600
+ const top = git(["rev-parse", "--show-toplevel"], dir);
3601
+ if (top.status !== 0) return null;
3602
+ if (git(["rev-parse", "--verify", "--quiet", "HEAD"], dir).status !== 0) return null;
3603
+ return realpathSync4(line(top.stdout));
3604
+ }
3605
+ function commonDirOf(top) {
3606
+ const raw = line(gitOk(["rev-parse", "--git-common-dir"], top));
3607
+ return realpathSync4(path9.resolve(top, raw));
3608
+ }
3609
+ function treeBytes(dir, limit) {
3610
+ let total = 0;
3611
+ const walk = (current) => {
3612
+ for (const entry of readdirSync4(current, { withFileTypes: true })) {
3613
+ if (COPY_SKIPPED.includes(entry.name)) continue;
3614
+ const absolute = path9.join(current, entry.name);
3615
+ if (entry.isDirectory()) walk(absolute);
3616
+ else if (entry.isFile()) {
3617
+ total += lstatSync3(absolute).size;
3618
+ if (total > limit) {
3619
+ throw new ValidationError(
3620
+ `the workspace is larger than the ${limit >= 1024 * 1024 ? `${Math.round(limit / 1024 / 1024)} MB` : `${limit}-byte`} copy limit (node_modules, target, dist and .git excluded); point workspacePath at a smaller directory, or make it a git repository so a worktree is used instead`
3621
+ );
3622
+ }
3623
+ }
3624
+ }
3625
+ };
3626
+ walk(dir);
3627
+ return total;
3628
+ }
3629
+ function linkDependencies(sourceTop, copyTop) {
3630
+ const linked = [];
3631
+ const walk = (rel, depth) => {
3632
+ if (depth > LINK_MAX_DEPTH || linked.length >= LINK_MAX_DIRS) return;
3633
+ let entries;
3634
+ try {
3635
+ entries = readdirSync4(path9.join(sourceTop, rel), { withFileTypes: true });
3636
+ } catch {
3637
+ return;
3638
+ }
3639
+ for (const entry of entries) {
3640
+ if (!entry.isDirectory()) continue;
3641
+ const child = rel ? path9.join(rel, entry.name) : entry.name;
3642
+ if (entry.name === "node_modules") {
3643
+ const target = path9.join(copyTop, child);
3644
+ if (existsSync7(target) || !existsSync7(path9.join(copyTop, rel))) continue;
3645
+ mkdirSync5(target);
3646
+ for (const item of readdirSync4(path9.join(sourceTop, child), { withFileTypes: true })) {
3647
+ if (item.name.startsWith(".") && item.isDirectory() && !LINK_SKIPPED_DOT.has(item.name)) continue;
3648
+ symlinkSync2(path9.join(sourceTop, child, item.name), path9.join(target, item.name));
3649
+ }
3650
+ linked.push(child);
3651
+ if (linked.length >= LINK_MAX_DIRS) return;
3652
+ continue;
3653
+ }
3654
+ if (entry.name.startsWith(".") || COPY_SKIPPED.includes(entry.name)) continue;
3655
+ walk(child, depth + 1);
3656
+ }
3657
+ };
3658
+ walk("", 0);
3659
+ return linked;
3660
+ }
3661
+ function newCopyContainer() {
3662
+ return realpathSync4(mkdtempSync3(path9.join(tmpdir3(), "freellama-coder-")));
3663
+ }
3664
+ function createIsolatedCopy(workspace, container, options = {}) {
3665
+ const source = realpathSync4(workspace);
3666
+ mkdirSync5(container, { recursive: true });
3667
+ const box = realpathSync4(container);
3668
+ if (readdirSync4(box).length > 0) throw new ValidationError(`copy directory ${box} is not empty`);
3669
+ const tmp = path9.join(box, "tmp");
3670
+ const privateDir = path9.join(box, "private");
3671
+ mkdirSync5(tmp);
3672
+ mkdirSync5(privateDir, { mode: 448 });
3673
+ try {
3674
+ const top = repositoryTop(source);
3675
+ const copy = top ? worktreeCopy(source, top, box) : plainCopy(source, box, options.maxCopyBytes ?? DEFAULT_MAX_COPY_BYTES);
3676
+ const linked = options.linkDependencies === false ? [] : linkDependencies(top ?? source, copy.top);
3677
+ return { ...copy, container: box, tmp, privateDir, linked };
3678
+ } catch (error) {
3679
+ removeIsolatedCopy(source, box);
3680
+ throw error;
3681
+ }
3682
+ }
3683
+ function worktreeCopy(source, repoTop, box) {
3684
+ const work = path9.join(box, WORKTREE_NAME);
3685
+ gitOk(["worktree", "add", "--detach", "--quiet", work, "HEAD"], repoTop);
3686
+ const top = realpathSync4(work);
3687
+ const gitDir = realpathSync4(line(gitOk(["rev-parse", "--absolute-git-dir"], top)));
3688
+ const gitEnv = { ...baseGitEnv(), GIT_DIR: gitDir, GIT_WORK_TREE: top };
3689
+ const head = line(gitOk(["rev-parse", "HEAD"], top, gitEnv));
3690
+ const changes = gitOk([
3691
+ "diff",
3692
+ "HEAD",
3693
+ "--binary",
3694
+ "--no-color",
3695
+ "--no-ext-diff",
3696
+ "--no-textconv",
3697
+ "--ignore-submodules=all",
3698
+ "--src-prefix=a/",
3699
+ "--dst-prefix=b/"
3700
+ ], repoTop);
3701
+ if (changes.length > 0) {
3702
+ const applied = git(["apply", "--index", "--whitespace=nowarn", "-"], top, gitEnv, changes);
3703
+ if (applied.status !== 0) {
3704
+ throw new ValidationError(`could not reproduce your uncommitted changes in the isolated copy (${applied.stderr}); commit or stash them and retry`);
3705
+ }
3706
+ }
3707
+ const baseTree = line(gitOk(["write-tree"], top, gitEnv));
3708
+ const relative = path9.relative(repoTop, source);
3709
+ if (relative && !existsSync7(path9.join(top, relative))) {
3710
+ throw new ValidationError(`${relative} holds no file tracked at HEAD, so a worktree of the repository has no copy of it; commit its files, or point workspacePath at a tracked directory`);
3711
+ }
3712
+ return {
3713
+ kind: "git_worktree",
3714
+ top,
3715
+ root: relative ? path9.join(top, relative) : top,
3716
+ relative,
3717
+ gitEnv,
3718
+ commonDir: commonDirOf(repoTop),
3719
+ baseTree,
3720
+ head,
3721
+ callerChanges: changes.length > 0
3722
+ };
3723
+ }
3724
+ function plainCopy(source, box, maxCopyBytes) {
3725
+ treeBytes(source, maxCopyBytes);
3726
+ const top = path9.join(box, "copy");
3727
+ cpSync(source, top, {
3728
+ recursive: true,
3729
+ verbatimSymlinks: true,
3730
+ filter: (from) => from === source || !COPY_SKIPPED.includes(path9.basename(from))
3731
+ });
3732
+ const gitDir = path9.join(box, "private", "git");
3733
+ const gitEnv = { ...baseGitEnv(), GIT_DIR: gitDir, GIT_WORK_TREE: top, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
3734
+ gitOk(["-c", "init.templateDir=", "init", "--quiet"], top, gitEnv);
3735
+ mkdirSync5(path9.join(gitDir, "info"), { recursive: true });
3736
+ writeFileSync4(path9.join(gitDir, "info", "exclude"), COPY_SKIPPED.map((name) => `${name}/`).join("\n") + "\n");
3737
+ gitOk(["-c", "core.autocrlf=false", "add", "-A", "."], top, gitEnv);
3738
+ const baseTree = line(gitOk(["write-tree"], top, gitEnv));
3739
+ return { kind: "copy", top, root: top, relative: "", gitEnv, commonDir: null, baseTree, head: null, callerChanges: false };
3740
+ }
3741
+ var STATUS = { M: "modified", A: "added", D: "deleted", T: "type_changed" };
3742
+ function agentChanges(copy) {
3743
+ const scope = copy.relative ? [`--relative=${copy.relative}`] : [];
3744
+ const excluded = copy.linked.filter((rel) => git(["check-ignore", "-q", "--", rel], copy.top, copy.gitEnv).status !== 0).map((rel) => `:(exclude)${rel}`);
3745
+ gitOk(["-c", "core.autocrlf=false", "add", "-A", "--", copy.relative || ".", ...excluded], copy.top, copy.gitEnv);
3746
+ const common = ["diff", "--cached", "--no-renames", "--no-color", "--no-ext-diff", "--no-textconv", ...scope];
3747
+ const patch = gitOk([...common, "--binary", "--src-prefix=a/", "--dst-prefix=b/", copy.baseTree], copy.top, copy.gitEnv);
3748
+ const names = gitOk([...common, "--name-status", "-z", copy.baseTree], copy.top, copy.gitEnv).toString("utf8").split("\0");
3749
+ const files = [];
3750
+ for (let index = 0; index + 1 < names.length; index += 2) {
3751
+ files.push({ path: names[index + 1], status: STATUS[names[index].charAt(0)] ?? "modified" });
3752
+ }
3753
+ return { patch, files };
3754
+ }
3755
+ function removeIsolatedCopy(workspace, container) {
3756
+ const work = path9.join(container, WORKTREE_NAME);
3757
+ let commonDir = null;
3758
+ try {
3759
+ const top = repositoryTop(realpathSync4(workspace));
3760
+ commonDir = top ? commonDirOf(top) : null;
3761
+ if (top && existsSync7(work)) git(["worktree", "remove", "--force", work], top);
3762
+ } catch {
3763
+ }
3764
+ try {
3765
+ rmSync3(container, { recursive: true, force: true });
3766
+ } catch {
3767
+ }
3768
+ if (!commonDir) return;
3769
+ const entries = path9.join(commonDir, "worktrees");
3770
+ let names;
3771
+ try {
3772
+ names = readdirSync4(entries);
3773
+ } catch {
3774
+ return;
3775
+ }
3776
+ const ours = /* @__PURE__ */ new Set([path9.join(work, ".git"), path9.join(container, WORKTREE_NAME, ".git")]);
3777
+ for (const name of names) {
3778
+ try {
3779
+ if (ours.has(readFileSync8(path9.join(entries, name, "gitdir"), "utf8").trim())) {
3780
+ rmSync3(path9.join(entries, name), { recursive: true, force: true });
3781
+ }
3782
+ } catch {
3783
+ }
3784
+ }
3785
+ }
3786
+
3787
+ // ../rust-core/src/model_catalog.json
3788
+ var model_catalog_default = {
3789
+ snapshot: "2026-11-01",
3790
+ measured_on: "Apple M2 Pro, 32 GB, Ollama 0.40.13, with other work running",
3791
+ how_to_read: "quality is measured (run here with known answers), published (the publisher's figure), or untested. memory_gb is what FreeLlama asks to have available, including its reserve; null means not previewed. Discovery never authorizes a pull: ask for one exact tag and its size first.",
3792
+ jobs: [
3793
+ { job: "classify", title: "Classify, triage, yes/no", call: 'run_task {task:"decision"}', cookbook: "cookbooks/classify", picks: [
3794
+ { tag: "strands-decider:2b-nvfp4", role: "default", file_gb: 1.3, memory_gb: 3.8, quality: "measured", evidence: "53/56 in all 3 rounds, 7 ms median; published 182/231 JevBench" },
3795
+ { tag: "nimble-v3:9b-q4_K_M", role: "alternative", file_gb: 5.6, memory_gb: 11.5, quality: "measured", evidence: "51/56 in all 3 rounds, 1.5 s median: no better here despite published 198/231 JevBench" }
3796
+ ] },
3797
+ { job: "find-code", title: "Find the code for an issue", call: 'run_task {task:"embedding", nearest:{query}}', cookbook: "cookbooks/find-code", picks: [
3798
+ { tag: "embeddinggemma-2:740m-q8_0", role: "default", file_gb: 1.3, memory_gb: 1.4, quality: "measured", evidence: "right file first 9/10, top 3 9/10; 899 chunks indexed in 67 s" },
3799
+ { tag: "nomic-embed-text:v1.5", role: "alternative", file_gb: 0.3, memory_gb: 1.4, quality: "measured", evidence: "right file first 6/10, top 3 8/10; faster, 881 chunks in 42 s" }
3800
+ ] },
3801
+ { job: "duplicates", title: "Find duplicates", call: 'run_task {task:"embedding", nearest:{}}', cookbook: "cookbooks/duplicates", picks: [
3802
+ { tag: "nomic-embed-text:v1.5", role: "default", file_gb: 0.3, memory_gb: 1.4, quality: "measured", evidence: "8/12 real issue pairs" }
3803
+ ] },
3804
+ { job: "ocr", title: "Read text from an image", call: 'run_task {task:"vision", files:[path]}', cookbook: "cookbooks/ocr", picks: [
3805
+ { tag: "hf.co/openbmb/MiniCPM-OCR-1.0-gguf:Q4_K_M", role: "default", file_gb: 1.4, memory_gb: 3.8, quality: "measured", evidence: "30/30 in all 3 rounds, 257 ms median, clean rendered text" },
3806
+ { tag: "glm-ocr:latest", role: "alternative", file_gb: 2.2, memory_gb: 5.8, quality: "measured", evidence: "30/30 in all 3 rounds, 1,885 ms median" }
3807
+ ] },
3808
+ { job: "image-recognition", title: "Understand what an image shows", call: 'run_task {task:"vision", files:[path], prompt}', cookbook: "cookbooks/image-recognition", picks: [
3809
+ { tag: "lfm3-vl:2b-q4_K_M", role: "default", file_gb: 1.5, memory_gb: 4.1, quality: "measured", evidence: "20/20 drawn images in all 3 rounds, 299 ms median" },
3810
+ { tag: "lfm3-omni:2b-q4_K_M", role: "alternative", file_gb: 2, memory_gb: 5.5, quality: "measured", evidence: "18/20 in all 3 rounds, 269 ms median; also does audio" },
3811
+ { tag: "bonsai-2:9b", role: "alternative", file_gb: 3.2, memory_gb: 10.3, quality: "measured", evidence: "17/20 in all 3 rounds, 1.4 s median" }
3812
+ ] },
3813
+ { job: "image-search", title: "Find an image by describing it", call: 'run_task {task:"embedding", files:[images], nearest:{query}}', cookbook: "cookbooks/image-search", picks: [
3814
+ { tag: "embeddinggemma-2:740m-q8_0", role: "default", file_gb: 1.3, memory_gb: 1.4, quality: "measured", evidence: "5/5 on five drawn screens" }
3815
+ ] },
3816
+ { job: "speech-to-text", title: "Recording to text", call: 'audio {action:"transcribe", file}', cookbook: "cookbooks/speech-to-text", picks: [
3817
+ { tag: "lfm3-audio:2b-q4_K_M", role: "default", file_gb: 1.7, memory_gb: 5.5, quality: "measured", evidence: "1.7% word error on generated speech, 276 ms median, translation worked" },
3818
+ { tag: "lfm3-omni:2b-q4_K_M", role: "alternative", file_gb: 2, memory_gb: 5.5, quality: "measured", evidence: "1.7% word error, 280 ms median, translation worked, also does images" }
3819
+ ] },
3820
+ { job: "text-to-speech", title: "Text to speech", call: 'audio {action:"speak", text}', cookbook: "cookbooks/text-to-speech", picks: [
3821
+ { tag: "phonon:en", role: "default", file_gb: 0.1, memory_gb: null, quality: "measured", evidence: "0.8-1.7% word error when transcribed back, 102 ms median: about 9x faster" },
3822
+ { tag: "lfm3-audio:2b-q4_K_M", role: "alternative", file_gb: 1.7, memory_gb: 5.5, quality: "measured", evidence: "0% word error, 950 ms median; the judge is an LFM3 model and may favour it" },
3823
+ { tag: "lfm3-omni:2b-q4_K_M", role: "alternative", file_gb: 2, memory_gb: 5.5, quality: "measured", evidence: "0% word error, 950 ms median; same judge caveat" }
3824
+ ] },
3825
+ { job: "coding", title: "Small coding tasks", call: 'run_task {task:"coding", files:[path]}', cookbook: "cookbooks/coding", picks: [
3826
+ { tag: "lfm3:1.7b-q4_K_M", role: "default", file_gb: 1.1, memory_gb: 3.3, quality: "measured", evidence: "21, 20, 20 of 25 small questions, 579 ms median" },
3827
+ { tag: "hf.co/nvidia/NVIDIA-Nemotron-3.5-Bolt-E4B-4GB:Q4_K_M", role: "stronger", file_gb: 6.2, memory_gb: 13.5, quality: "measured", evidence: "23, 22, 23 of 25, 267 ms median" },
3828
+ { tag: "bonsai-2:9b", role: "alternative", file_gb: 3.2, memory_gb: 10.3, quality: "measured", evidence: "19, 19, 17 of 25, 475 ms median" },
3829
+ { tag: "bonsai-2:27b", role: "stronger", file_gb: 8.3, memory_gb: 16.3, quality: "published", evidence: "publisher: SWE-bench Verified 60.8%, LiveCodeBench 90.1%; not measured here, never had 16.3 GB available" }
3830
+ ] },
3831
+ { job: "research-agent", title: "Research a repository", call: 'delegate_research {adapter:"octocode" or "github"}', cookbook: "cookbooks/research-agent", picks: [
3832
+ { tag: "bonsai-2:9b", role: "default", file_gb: 3.2, memory_gb: 10.3, quality: "measured", evidence: "4, 5, 5 of 8 local and 3, 5, 5 of 6 GitHub over 3 rounds, 82 s median per question" },
3833
+ { tag: "hf.co/nvidia/NVIDIA-Nemotron-3.5-Bolt-E4B-4GB:Q4_K_M", role: "stronger", file_gb: 6.2, memory_gb: 13.5, quality: "measured", evidence: "6/8 local and 5/6 GitHub in all 3 rounds, 96 s median per question" },
3834
+ { tag: "lfm3:1.7b-q4_K_M", role: "small", file_gb: 1.1, memory_gb: 3.3, quality: "measured", evidence: "6, 5, 5 of 8 local but 1/6 GitHub, 20 s median per question" }
3835
+ ] }
3836
+ ]
3837
+ };
3838
+
3839
+ // src/local-prompts.ts
3840
+ var catalog = model_catalog_default;
3841
+ function picksOf(job) {
3842
+ return catalog.jobs.find((entry) => entry.job === job)?.picks.map((pick) => pick.tag) ?? [];
3843
+ }
3844
+ var cookbook = (name) => `docs/cookbooks/${name}.md`;
3845
+ var SOURCES = {
3846
+ miniCpmOcr: ["https://huggingface.co/openbmb/MiniCPM-OCR-1.0", "https://huggingface.co/openbmb/MiniCPM-OCR-1.0-gguf"],
3847
+ glmOcr: ["https://ollama.com/library/glm-ocr", "https://huggingface.co/zai-org/GLM-OCR"],
3848
+ lfm3Vl: ["https://huggingface.co/LiquidAI/LFM3-VL-2B", "https://huggingface.co/LiquidAI/LFM3-VL-2B-GGUF", "https://ollama.com/library/lfm3-vl"],
3849
+ lfm3Omni: ["https://huggingface.co/LiquidAI/LFM3-Omni-2B", "https://ollama.com/library/lfm3-omni"],
3850
+ embeddingGemma: ["https://huggingface.co/google/embeddinggemma-2", "https://ai.google.dev/gemma/docs/embeddinggemma/inference-embeddinggemma-with-sentence-transformers"],
3851
+ nomic: ["https://huggingface.co/nomic-ai/nomic-embed-text-v1.5"],
3852
+ strandsDecider: ["https://ollama.com/library/strands-decider", "https://raw.githubusercontent.com/strands-labs/strands-decider/main/README.md"],
3853
+ nimble: ["https://ollama.com/library/nimble-v3:latest"],
3854
+ bonsai9b: ["https://huggingface.co/prism-ml/Ternary-Bonsai-2-9B-gguf", "https://ollama.com/library/bonsai-2:9b"],
3855
+ bonsai27b: ["https://huggingface.co/prism-ml/Ternary-Bonsai-2-27B-gguf", "https://ollama.com/library/bonsai-2"],
3856
+ lfm3: ["https://huggingface.co/LiquidAI/LFM3-1.7B", "https://ollama.com/library/lfm3"],
3857
+ nemotronBolt: ["https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Bolt-E4B-4GB"],
3858
+ lfm3Audio: ["https://huggingface.co/LiquidAI/LFM3-Audio-2B", "https://ollama.com/library/lfm3-audio"],
3859
+ qwen38: ["https://huggingface.co/Qwen/Qwen3.8-27B", "https://ollama.com/library/qwen3.8"],
3860
+ qwen35: ["https://huggingface.co/Qwen/Qwen3.5-4B", "https://ollama.com/library/qwen3.5"],
3861
+ phonon: ["https://ollama.com/library/phonon:en"]
3862
+ };
3863
+ var EMBEDDING_PREFIXES = {
3864
+ embeddinggemma: {
3865
+ query: "task: search result | query: ",
3866
+ document: "title: none | text: ",
3867
+ codeQuery: "task: code retrieval | query: ",
3868
+ similarity: "task: sentence similarity | query: ",
3869
+ clustering: "task: clustering | query: ",
3870
+ classification: "task: classification | query: "
3871
+ },
3872
+ // A prefix is required; the card names clustering: for removing semantic duplicates.
3873
+ "nomic-embed-text": { query: "search_query: ", document: "search_document: ", clustering: "clustering: ", classification: "classification: " }
3874
+ };
3875
+ var GEMMA = EMBEDDING_PREFIXES.embeddinggemma;
3876
+ var NOMIC = EMBEDDING_PREFIXES["nomic-embed-text"];
3877
+ var OCR_PROMPTS = { text: "Text Recognition:", table: "Table Recognition:", formula: "Formula Recognition:" };
3878
+ var OCR_TRAINED = ["minicpm-ocr", "glm-ocr"];
3879
+ var OCR_PLAIN = {
3880
+ text: "Transcribe all text in the image exactly, line by line. Output only the text.",
3881
+ table: "Transcribe the table in the image as a Markdown table. Output only the table."
3882
+ };
3883
+ var ocrPrompt = (kind, model) => {
3884
+ const name = (model ?? "").toLowerCase().replace(/^(?:hf\.co|huggingface\.co)\/[^/]+\//, "");
3885
+ return model === void 0 || OCR_TRAINED.some((family) => name.startsWith(family)) ? OCR_PROMPTS[kind] : OCR_PLAIN[kind];
3886
+ };
3887
+ var EDIT_FORMAT = `Return each change to an existing file as the file's path (copied from its "--- file:" line), then:
3888
+ <<<<<<< SEARCH
3889
+ exact current lines (5 to 30, unique in the file, indentation exact)
3890
+ =======
3891
+ replacement lines
3892
+ >>>>>>> REPLACE
3893
+ Copy SEARCH lines character for character; prefer several small blocks to one large one. For a new file, or one under 30 lines, give its path on one line and the whole file in one fenced block. If the file was not shown, give the code in one fenced block and say which file and function it belongs in.`;
3894
+ var NO_TOOLS = "You have no tools and cannot open files: everything you need is in this message. Answer in plain text.";
3895
+ var WORKFLOWS = {
3896
+ "read-text": {
3897
+ job: "ocr",
3898
+ title: "Read all text in an image, exactly",
3899
+ input: "files: the images",
3900
+ prompt: (_request, model) => ocrPrompt("text", model)
3901
+ },
3902
+ "read-table": {
3903
+ job: "ocr",
3904
+ title: "Read a table in an image as Markdown",
3905
+ input: "files: the images",
3906
+ prompt: (_request, model) => ocrPrompt("table", model)
3907
+ },
3908
+ "read-fields": {
3909
+ job: "ocr",
3910
+ title: "Pull named fields from a receipt, invoice or form image as JSON",
3911
+ input: "files: the images; prompt: the field names",
3912
+ prompt: (request) => `Extract these fields from the image: ${request ?? "all labelled fields"}. Format the output as a JSON object with these keys, values exactly as printed; use "###" for a field the image does not show.`
3913
+ },
3914
+ "answer-image": {
3915
+ job: "image-recognition",
3916
+ title: "Answer a question about an image",
3917
+ input: "files: the images; prompt: the question",
3918
+ system: `Answer the question about the image.
3919
+ 1. Find the part of the image the question is about.
3920
+ 2. Read any text there exactly as printed.
3921
+ 3. Answer with just the value or label, or one or two sentences.
3922
+ If the image does not show it, answer "not visible". Never guess at what is cut off.`
3923
+ },
3924
+ "describe-screen": {
3925
+ job: "image-recognition",
3926
+ title: "Describe a screenshot for a developer: text, errors, state",
3927
+ input: "files: the screenshot; prompt: what to look for (optional)",
3928
+ system: `You describe a software screenshot for a developer, in short bullets, in this order:
3929
+ - the app or page shown;
3930
+ - headings and main text, quoted exactly;
3931
+ - any error, warning or empty state, quoted exactly;
3932
+ - the state of the controls that matter (selected, disabled, filled in);
3933
+ - anything that looks broken or misaligned.
3934
+ Describe only what is visible.`
3935
+ },
3936
+ "change-code": {
3937
+ job: "coding",
3938
+ title: "Make one small code change",
3939
+ input: "files: the file to change; prompt: the change",
3940
+ system: `${NO_TOOLS}
3941
+ You make one small code change.
3942
+ 1. Find where the change belongs in the code shown.
3943
+ 2. Follow the file's existing names, style and error handling.
3944
+ 3. Change as few lines as the task needs; touch nothing else.
3945
+ ${EDIT_FORMAT}
3946
+ Explain nothing unless asked. If the task is a question, answer it in a few lines.`
3947
+ },
3948
+ "fix-bug": {
3949
+ prefer: "capable",
3950
+ job: "coding",
3951
+ title: "Fix a bug with the smallest change",
3952
+ input: "files: the suspect code; prompt: the error message or symptom",
3953
+ system: `${NO_TOOLS}
3954
+ You fix one bug with the smallest change.
3955
+ 1. Read the error or symptom, then the code shown.
3956
+ 2. Name the cause in one sentence, quoting the line that causes it.
3957
+ 3. Change only what the fix needs.
3958
+ Answer with the cause line, then the edits.
3959
+ ${EDIT_FORMAT}
3960
+ If the code shown cannot cause the symptom, say so and name the file or function to look at instead.`
3961
+ },
3962
+ implement: {
3963
+ prefer: "capable",
3964
+ job: "coding",
3965
+ title: "Add a small feature to existing code",
3966
+ input: "files: the code; prompt: the feature",
3967
+ system: `${NO_TOOLS}
3968
+ You add one small feature to the code shown.
3969
+ 1. Find the function or place it belongs; reuse helpers the file already has.
3970
+ 2. Follow the file's names, style and error handling.
3971
+ 3. Handle the empty and error cases the feature can meet.
3972
+ ${EDIT_FORMAT}
3973
+ End with one line: what to test.`
3974
+ },
3975
+ "write-tests": {
3976
+ prefer: "capable",
3977
+ job: "coding",
3978
+ title: "Write unit tests for the code shown",
3979
+ input: "files: the code (and an existing test, for style); prompt: what to cover (optional)",
3980
+ system: `${NO_TOOLS}
3981
+ You write unit tests for the code shown; you never change that code.
3982
+ 1. List the behaviours: the normal case, edge cases (empty, zero, missing, boundary) and one failure case.
3983
+ 2. Use the test framework and import style the project already uses; copy them from the files shown.
3984
+ 3. Write one small test per behaviour, named after it, with literal expected values.
3985
+ Answer with the test file's path on one line, then the whole file in one fenced block.`
3986
+ },
3987
+ "review-code": {
3988
+ prefer: "capable",
3989
+ job: "coding",
3990
+ title: "Find real defects in code (not style)",
3991
+ input: "files: the code; prompt: what to focus on (optional)",
3992
+ system: `${NO_TOOLS}
3993
+ You review the code shown for real defects only: wrong logic, unhandled errors, off-by-one, null or undefined access, leaks, injection, races. Ignore style and naming.
3994
+ For each defect write one line: path:line - the problem - the fix.
3995
+ Report at most 5, the surest first. If you find none, answer "no defects found". Never invent a line you did not see.`
3996
+ },
3997
+ "explain-code": {
3998
+ job: "coding",
3999
+ title: "Explain code to someone about to change it",
4000
+ input: "files: the code; prompt: what to explain (optional)",
4001
+ system: `${NO_TOOLS}
4002
+ Explain the code shown to a developer who is about to change it, in at most 6 bullets:
4003
+ what it does (one sentence); its inputs and outputs; side effects (files, network, shared state); what calls it, if visible; the one thing most likely to break when it changes.
4004
+ Name functions and variables exactly.`
4005
+ },
4006
+ "summarize-log": {
4007
+ think: false,
4008
+ prefer: "capable",
4009
+ job: "summarize",
4010
+ title: "Summarize a log: errors grouped, counted, first failure",
4011
+ input: "files: the log; prompt: what you are investigating (optional)",
4012
+ system: `${NO_TOOLS}
4013
+ You read a log for a developer. Report:
4014
+ 1. Errors and warnings, grouped by message, each with its count and first and last timestamp.
4015
+ 2. The first error in time order, quoted exactly.
4016
+ 3. The likely cause, only if the log shows it.
4017
+ Quote messages exactly, skip routine lines, at most 10 bullets.`
4018
+ },
4019
+ "summarize-text": {
4020
+ think: false,
4021
+ job: "summarize",
4022
+ title: "Summarize text for someone who will act on it",
4023
+ input: "files: the text; prompt: what you need from it (optional)",
4024
+ system: `${NO_TOOLS}
4025
+ Summarize the text for someone who will act on it, in short bullets: the decisions, the numbers, the deadlines and the open questions, plus whatever the request asks for.
4026
+ Quote numbers, names, versions and paths exactly. Write "not stated" when the text does not say; never guess. No introduction or closing.`
4027
+ },
4028
+ "summarize-diff": {
4029
+ think: false,
4030
+ prefer: "capable",
4031
+ job: "summarize",
4032
+ title: "Summarize a diff for a reviewer: changes and risks",
4033
+ input: "files: the diff or patch",
4034
+ system: `${NO_TOOLS}
4035
+ You summarize a code diff for a reviewer, in at most 8 bullets:
4036
+ one line per file on what changed; any change in behaviour; risks such as a removed check, a changed default, a new dependency or a missing test.
4037
+ Name files, functions and settings exactly.`
4038
+ },
4039
+ "extract-json": {
4040
+ think: false,
4041
+ job: "summarize",
4042
+ title: "Extract named fields from text into JSON",
4043
+ input: "files: the text; prompt: the field names (or a JSON schema)",
4044
+ system: `${NO_TOOLS}
4045
+ Extract the fields the request names from the text into one JSON object.
4046
+ Use the values exactly as written in the text; use null for a field the text does not give; never invent a value.
4047
+ Output only the JSON object.`,
4048
+ format: "json"
4049
+ }
4050
+ };
4051
+ var WORKFLOW_NAMES = Object.keys(WORKFLOWS);
4052
+ var BONSAI_9B = {
4053
+ think: false,
4054
+ options: { temperature: 0.7, top_p: 0.8, top_k: 20, min_p: 0, presence_penalty: 1.5, repeat_penalty: 1 },
4055
+ notes: "With think:true use the card's thinking preset (temperature 1.0, top_p 0.95, presence_penalty 1.0) and allow at least 8,192 output tokens; never greedy decoding with thinking on.",
4056
+ sources: SOURCES.bonsai9b
4057
+ };
4058
+ var qwenNonThinking = (sources) => ({
4059
+ think: false,
4060
+ options: { temperature: 0.7, top_p: 0.8, top_k: 20, min_p: 0, presence_penalty: 1.5, repeat_penalty: 1 },
4061
+ notes: "Thinks by default; with think:true use the card's thinking preset (temperature 1.0, top_p 0.95, top_k 20; Qwen3.8 presence_penalty 0, Qwen3.5 1.5, or 0 with temperature 0.6 for precise coding) and a large output budget (Qwen3.5 32,768 tokens). Raise presence_penalty (up to 2) if it repeats.",
4062
+ sources
4063
+ });
4064
+ var CHAT_MODELS = {
4065
+ lfm3: {
4066
+ options: { temperature: 0.1, top_k: 50, repeat_penalty: 1.1 },
4067
+ notes: "Thinks by default; its card calls thinking off inconsistent, so it stays on. Not for agentic coding or multi-step reasoning.",
4068
+ sources: SOURCES.lfm3
4069
+ },
4070
+ "nvidia-nemotron-3.5-bolt": {
4071
+ options: { temperature: 1, top_p: 0.95 },
4072
+ // `ollama show` of the hf.co Q4_K_M tag, 2026-11-08: no thinking capability, and the template
4073
+ // defaults enable_thinking to false.
4074
+ notes: "The card says it reasons by default, but its GGUF template turns thinking off and Ollama reports no thinking capability, so it answers directly; think:true is refused.",
4075
+ sources: SOURCES.nemotronBolt
4076
+ },
4077
+ "bonsai-2": BONSAI_9B,
4078
+ // The card documents the same instruct (non-thinking) preset as the 9B; Ollama lists think
4079
+ // values false, low, medium and xhigh for this tag.
4080
+ "bonsai-2:27b": {
4081
+ ...BONSAI_9B,
4082
+ notes: "With think:true (or low, medium, xhigh) use the card's thinking preset (temperature 1.0, top_p 0.95, top_k 20, min_p 0.05, presence_penalty 0) and allow about 16,384 output tokens.",
4083
+ sources: SOURCES.bonsai27b
4084
+ },
4085
+ "qwen3.8": qwenNonThinking(SOURCES.qwen38),
4086
+ "qwen3.5": qwenNonThinking(SOURCES.qwen35)
4087
+ };
4088
+ var CHAT_SOURCES = [...SOURCES.lfm3, ...SOURCES.nemotronBolt, ...SOURCES.bonsai9b, ...SOURCES.bonsai27b, ...SOURCES.qwen38, ...SOURCES.qwen35];
4089
+ var LOCAL_JOBS = {
4090
+ classify: {
4091
+ job: "classify",
4092
+ task: "decision",
4093
+ catalogJob: "classify",
4094
+ defaultPicks: picksOf("classify"),
4095
+ models: {
4096
+ "strands-decider": { notes: "State and questions together must fit 4,096 tokens; weak on long, multi-step documents. Its README: answers at confidence 0.9 or more are right about 95% of the time; confirm the rest.", sources: SOURCES.strandsDecider },
4097
+ "nimble-v3": { notes: "Documents num_ctx 32768 (routing sizes it here); CC BY-NC 4.0, noncommercial.", sources: SOURCES.nimble }
4098
+ },
4099
+ notes: "A decision model reads state and questions, no prompt. Ask short questions about the state, one concern each, and give a choice question an explicit fallback option such as other.",
4100
+ sources: [...SOURCES.strandsDecider, ...SOURCES.nimble, cookbook("classify")]
4101
+ },
4102
+ "find-code": {
4103
+ job: "find-code",
4104
+ task: "embedding",
4105
+ catalogJob: "find-code",
4106
+ defaultPicks: picksOf("find-code"),
4107
+ needs: "nearest.query: the issue, or what the code does",
4108
+ prefixes: { embeddinggemma: { query: GEMMA.codeQuery, document: GEMMA.document }, "nomic-embed-text": { query: NOMIC.query, document: NOMIC.document } },
4109
+ // Measured 2026-11-08 on the find-code set (10 questions, 1,039 chunks): code retrieval with
4110
+ // title: none 8 first / 9 in top 3; search result 8/8; code retrieval with the file as title 7/9.
4111
+ notes: "task: code retrieval (EmbeddingGemma's code-search prefix) measured best here: right file first 8/10, in the top 3 9/10. Read the top ranges before relying on them.",
4112
+ sources: [...SOURCES.embeddingGemma, ...SOURCES.nomic, cookbook("find-code")]
4113
+ },
4114
+ duplicates: {
4115
+ job: "duplicates",
4116
+ task: "embedding",
4117
+ catalogJob: "duplicates",
4118
+ defaultPicks: picksOf("duplicates"),
4119
+ // Symmetric: every item, the query included, takes the same prefix.
4120
+ prefixes: {
4121
+ // The measured 8/12 used search_document:; the card's clustering: is documented but unmeasured here.
4122
+ "nomic-embed-text": { query: NOMIC.document, document: NOMIC.document },
4123
+ embeddinggemma: { query: GEMMA.similarity, document: GEMMA.similarity }
4124
+ },
4125
+ notes: "Uses the measured search_document: (8/12 real issue pairs); nomic's card also documents clustering: for deduplication, unmeasured here. A high score is a candidate, never a verdict.",
4126
+ sources: [...SOURCES.nomic, ...SOURCES.embeddingGemma, cookbook("duplicates")]
4127
+ },
4128
+ ocr: {
4129
+ job: "ocr",
4130
+ task: "vision",
4131
+ catalogJob: "ocr",
4132
+ defaultPicks: picksOf("ocr"),
4133
+ prompt: ({ model }) => ocrPrompt("text", model),
4134
+ // Both publishers' Ollama Modelfiles set temperature 0; the hf.co MiniCPM tag ships no parameters.
4135
+ models: {
4136
+ "minicpm-ocr": {
4137
+ options: { temperature: 0 },
4138
+ notes: "Chinese and English; tables come back as HTML. Its Modelfile sets num_ctx 8192.",
4139
+ sources: [...SOURCES.miniCpmOcr, "https://raw.githubusercontent.com/OpenBMB/MiniCPM-OCR/main/docs/ollama_deploy.md"]
4140
+ },
4141
+ "glm-ocr": {
4142
+ options: { temperature: 0 },
4143
+ notes: "Also documents Figure Recognition:.",
4144
+ sources: [...SOURCES.glmOcr, "https://github.com/zai-org/GLM-OCR/blob/main/examples/ollama-deploy/README.md"]
4145
+ }
4146
+ },
4147
+ notes: "The OCR picks were trained on Text Recognition:, Table Recognition: and Formula Recognition:; a general vision model gets the same request in plain words. Crop to the text first.",
4148
+ sources: [...SOURCES.miniCpmOcr, ...SOURCES.glmOcr, cookbook("ocr")]
4149
+ },
4150
+ "image-recognition": {
4151
+ job: "image-recognition",
4152
+ task: "vision",
4153
+ catalogJob: "image-recognition",
4154
+ defaultPicks: picksOf("image-recognition"),
4155
+ needs: "prompt: your question about the image",
4156
+ systemPrompt: WORKFLOWS["answer-image"].system,
4157
+ models: {
4158
+ "lfm3-vl": {
4159
+ options: { temperature: 0.1, top_p: 1, top_k: 50, min_p: 0, repeat_penalty: 1.1 },
4160
+ notes: "Thinks before it answers by default (Ollama lists think values true and false; off is unmeasured here); images go in at native resolution, so a large one costs many tokens.",
4161
+ sources: SOURCES.lfm3Vl
4162
+ },
4163
+ "lfm3-omni": {
4164
+ systemPrompt: "Answer questions about the provided image. Base every answer only on what can be seen.",
4165
+ options: { temperature: 0 },
4166
+ notes: "Non-reasoning: never send think:true. Up to 2,048 tokens per image. The card gives 32K context; the Ollama tag advertises 128K.",
4167
+ sources: SOURCES.lfm3Omni
4168
+ },
4169
+ "bonsai-2": BONSAI_9B,
4170
+ "qwen3.8": qwenNonThinking(SOURCES.qwen38),
4171
+ "qwen3.5": qwenNonThinking(SOURCES.qwen35)
4172
+ },
4173
+ notes: 'Ask a specific question, not "describe this". Tested on drawn images only.',
4174
+ sources: [...SOURCES.lfm3Vl, ...SOURCES.lfm3Omni, ...SOURCES.bonsai9b, cookbook("image-recognition")]
4175
+ },
4176
+ "image-search": {
4177
+ job: "image-search",
4178
+ task: "embedding",
4179
+ catalogJob: "image-search",
4180
+ defaultPicks: picksOf("image-search"),
4181
+ needs: "nearest.query: a description of the image",
4182
+ prefixes: { embeddinggemma: { query: GEMMA.query, document: GEMMA.document } },
4183
+ notes: "Images are embedded bare: the prefixes apply to text only. Open the top match.",
4184
+ sources: [...SOURCES.embeddingGemma, cookbook("image-search")]
4185
+ },
4186
+ coding: {
4187
+ job: "coding",
4188
+ task: "coding",
4189
+ catalogJob: "coding",
4190
+ defaultPicks: picksOf("coding"),
4191
+ needs: "prompt: the change to make",
4192
+ systemPrompt: WORKFLOWS["change-code"].system,
4193
+ models: CHAT_MODELS,
4194
+ notes: "The answer is SEARCH/REPLACE blocks: apply one only where its SEARCH text matches exactly once, then run the tests.",
4195
+ sources: [...CHAT_SOURCES, cookbook("coding")]
4196
+ },
4197
+ summarize: {
4198
+ job: "summarize",
4199
+ task: "completion",
4200
+ catalogJob: "coding",
4201
+ defaultPicks: picksOf("coding"),
4202
+ prompt: () => "Summarize the text below.",
4203
+ systemPrompt: WORKFLOWS["summarize-text"].system,
4204
+ models: CHAT_MODELS,
4205
+ notes: "The coding picks: the same chat models apply (docs/cookbooks/batch-text.md). Check every number and name you reuse.",
4206
+ sources: [...CHAT_SOURCES, cookbook("batch-text")]
4207
+ }
4208
+ };
4209
+ var TOOL_JOBS = {
4210
+ "speech-to-text": {
4211
+ tool: "audio",
4212
+ defaultPicks: picksOf("speech-to-text"),
4213
+ // The audio tool calls Ollama's transcription endpoint, which builds the model's prompt; these
4214
+ // are lfm3-audio's documented prompts for a direct chat call.
4215
+ systemPrompts: {
4216
+ transcribe: (language) => language ? `Perform ASR in ${language}.` : "Perform ASR.",
4217
+ translate: (source, target) => `Translate the following speech from ${source} to ${target} in text form.`
4218
+ },
4219
+ notes: "WAV input only. Numbers and names are the usual misses; translation works best to or from English.",
4220
+ sources: [...SOURCES.lfm3Audio, cookbook("speech-to-text")]
4221
+ },
4222
+ "text-to-speech": {
4223
+ tool: "audio",
4224
+ defaultPicks: picksOf("text-to-speech"),
4225
+ notes: "Send plain text: Markdown marks may be read aloud. One voice (default); 24 kHz mono WAV.",
4226
+ sources: [...SOURCES.phonon, cookbook("text-to-speech")]
4227
+ },
4228
+ "research-agent": {
4229
+ tool: "delegate_research",
4230
+ defaultPicks: picksOf("research-agent"),
4231
+ notes: "The adapters' system prompts are below. Discard an escalate verdict and verify the citations.",
4232
+ sources: [cookbook("research-agent")]
4233
+ },
4234
+ sweep: {
4235
+ tool: "sweep",
4236
+ defaultPicks: picksOf("classify"),
4237
+ /** The decision state of one chunk, and the explain step's user message for a confirmed flag. */
4238
+ state: (label, text) => `Source: ${label}
4239
+
4240
+ ${text}`,
4241
+ explain: (prompt, label, text) => `${prompt}
4242
+
4243
+ Source: ${label}
4244
+
4245
+ ${text}`,
4246
+ // FreeLlama's own setting for the short explain note, not a model's documented value.
4247
+ explainControls: { think: false, temperature: 0 },
4248
+ notes: 'One yes/no question per concern, phrased about one chunk ("Does this code ..."); a noul answer flags at p >= 0.5.',
4249
+ sources: [cookbook("sweep")]
4250
+ }
4251
+ };
4252
+ var CODER_SYSTEM_PROMPT = [
4253
+ "You change code in an isolated copy of the user's project (the current directory) and test the change. The user's own files are never touched: they review your diff afterwards. Reply with exactly one JSON object per turn and nothing else.",
4254
+ "",
4255
+ "Actions, in order of preference:",
4256
+ '{"action":"search","pattern":"parsePort"} finds text in files and prints path:line:text. Literal and case-sensitive; add "regex":true, "ignoreCase":true, "glob":"*.ts" or "path":"src" to narrow it.',
4257
+ '{"action":"read","path":"src/config.ts","start":40,"end":70} shows numbered lines of one file.',
4258
+ '{"action":"edit","path":"src/config.ts","search":" const port = 8080;\\n return port;","replace":" const port = Number(env.PORT ?? 8080);\\n return port;"} replaces text that occurs exactly once in the file. Copy "search" from the read output without the line numbers and with its exact indentation: 5 to 30 lines, enough to be unique.',
4259
+ '{"action":"write","path":"src/limits.ts","content":"export const MAX = 10;\\n"} creates a file, or replaces a whole small file.',
4260
+ '{"action":"run","command":"yarn test"} runs one of the allowed commands listed with the task, exactly as written there, and shows its exit code and output.',
4261
+ '{"action":"list","path":"src"} shows files and folders two levels deep.',
4262
+ '{"action":"shell","command":"git diff --stat"} runs one read-only pipeline (rg, grep, find, head, sed -n, wc, git log/diff/show). It cannot change files; edit and write do.',
4263
+ '{"action":"page","step":2,"page":2} shows the next page of a long earlier result.',
4264
+ '{"action":"finish","answer":"Changed src/config.ts to read PORT from the environment, because the port was fixed. Test: yarn test passed."} ends the task.',
4265
+ "",
4266
+ "How to work:",
4267
+ "1. Search for the names in the task, then read the lines you will change.",
4268
+ "2. Edit with a search block copied exactly from the read output. If an edit fails, read those lines again and copy them; never guess.",
4269
+ "3. Run an allowed command to test the change. If it fails, read the error, fix the code and run it again.",
4270
+ "4. Finish with what changed, why, and the last test result. With no allowed command, say the change is untested.",
4271
+ "Keep results small: read line ranges, not whole files, and end long shell output with | head -n 40."
4272
+ ].join("\n");
4273
+ var APPLY_EDITS = "Apply the edits yourself: for each block, check that its SEARCH text occurs exactly once in the named file, indentation included; replace it with the REPLACE text; skip a block that matches zero or several times. A path followed by one fenced block is a whole new file. Then run the tests: the edits are a small model's candidates.";
4274
+ var AGENT_GUIDE = `Use FreeLlama to run bounded work on local models and keep it out of your context.
4275
+
4276
+ Offload when the answer is cheap to check and the work is 10 or more similar items, or an input over about 2,000 tokens you would otherwise read whole. Do it yourself: judgment you must stand behind, edits across files, one short item.
4277
+
4278
+ Name a workflow and run_task does the rest: it picks the first catalog model for the job that is installed and fits now and gives it step-by-step instructions for the task. Workflows: read-text, read-table, read-fields (images); answer-image, describe-screen; change-code, fix-bug, implement, write-tests, review-code, explain-code; summarize-log, summarize-text, summarize-diff, extract-json. For labels, code search and duplicates use job classify, find-code or duplicates. Any field you pass wins; job_profile says what was filled in. Name inputs with files; for one answer per file send tasks:[{id, independent:true, task:{files:[path]}}] with job at the top level. For a whole code tree, log or site use sweep; for a question about a repository, delegate_research.
4279
+
4280
+ Verify: local answers are candidates. Each run_task result ends with check: lines worked out from it (uncertain answers, close matches, facts not in the source, edits that do not match the file); act on those first, and discard a research result whose verification.recommendation is "escalate".
4281
+
4282
+ Coding: the answer is SEARCH/REPLACE blocks. ${APPLY_EDITS}
4283
+
4284
+ Hand a result on: saveAs:"<name>" writes the answer to handoffs/<name>.md and returns handoff_file; a later run_task reads it with files:[handoff_file].
4285
+
4286
+ Approval: pull a model only after the user approves one exact tag and its size (ollama_pull approvedSizeBytes). Unload only a model you warmed or the user named; delete only a tag the user names. A memory refusal names what holds memory: tell the user and never stop their processes.`;
4287
+
4288
+ // src/research/structured-call.ts
4289
+ async function structuredSearch(shell, action, timeoutSeconds) {
4290
+ const notes = [];
4291
+ const resolved = resolveNamedPath(shell.workspace, action.path, "any");
4292
+ let target = resolved.path;
4293
+ if (target === void 0) {
4294
+ notes.push(`(${resolved.note}; searched the whole workspace)`);
4295
+ target = ".";
4296
+ } else if (resolved.note) {
4297
+ notes.push(resolved.note);
4298
+ }
4299
+ const ripgrep = shell.hasTool("rg");
4300
+ const run = (regex) => shell.runTool(searchArgv(shell.workspace, { ...action, path: target, regex }, ripgrep), timeoutSeconds);
4301
+ let output = await run(action.regex);
4302
+ if (output === "(no matches)" && action.regex === void 0 && looksLikeRegex(action.pattern)) {
4303
+ try {
4304
+ const asRegex = await run(true);
4305
+ if (asRegex !== "(no matches)") {
4306
+ notes.push("(no literal match; searched as a regular expression)");
4307
+ output = asRegex;
4308
+ }
4309
+ } catch {
4310
+ }
4311
+ }
4312
+ return notes.length > 0 ? `${notes.join("\n")}
4313
+ ${output}` : output;
4314
+ }
4315
+ function structuredCall(shell, action, timeoutSeconds) {
4316
+ const name = String(action.action);
4317
+ const args = Object.fromEntries(Object.entries(action).filter(([key]) => key !== "action"));
4318
+ const run = async () => {
4319
+ if (name === "read") return readLines(shell.workspace, args);
4320
+ if (name === "list") return listEntries(shell.workspace, args.path);
4321
+ return structuredSearch(shell, args, timeoutSeconds);
4322
+ };
4323
+ return {
4324
+ signature: { [name]: args },
4325
+ run,
4326
+ record: { name, raw_name: name, arguments: args },
4327
+ ledger: (status) => ({ action: name, status, source: JSON.stringify(args) })
4328
+ };
4329
+ }
4330
+
4331
+ // src/research/workspace-overview.ts
4332
+ import { readdirSync as readdirSync5 } from "node:fs";
4333
+ import path10 from "node:path";
4334
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", "target", ".git", "dist", "build", ".venv", "venv", "vendor", "__pycache__", ".octocode", "site-packages", "coverage"]);
4335
+ var MAX_ENTRIES = 2e4;
4336
+ var MAX_DEPTH = 8;
4337
+ var SOURCE_DIRS = /* @__PURE__ */ new Set(["src", "lib", "app", "source", "pkg", "cmd", "internal", "crates"]);
4338
+ function workspaceOverview(root) {
4339
+ let top;
4340
+ try {
4341
+ top = readdirSync5(root, { withFileTypes: true });
4342
+ } catch {
4343
+ return "";
4344
+ }
4345
+ const extensions = /* @__PURE__ */ new Map();
4346
+ const sourceRoots = /* @__PURE__ */ new Map();
4347
+ let files = 0;
4348
+ let seen = 0;
4349
+ const walk = (dir, depth, sourceRoot) => {
4350
+ if (depth > MAX_DEPTH || seen > MAX_ENTRIES) return;
4351
+ let entries;
4352
+ try {
4353
+ entries = readdirSync5(dir, { withFileTypes: true });
4354
+ } catch {
4355
+ return;
4356
+ }
4357
+ for (const entry of entries) {
4358
+ if (++seen > MAX_ENTRIES) return;
4359
+ if (entry.isDirectory()) {
4360
+ if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue;
4361
+ const child = path10.join(dir, entry.name);
4362
+ walk(child, depth + 1, sourceRoot ?? (SOURCE_DIRS.has(entry.name) ? path10.relative(root, child) : void 0));
4363
+ } else if (entry.isFile()) {
4364
+ files += 1;
4365
+ const extension = path10.extname(entry.name).toLowerCase() || "(none)";
4366
+ extensions.set(extension, (extensions.get(extension) ?? 0) + 1);
4367
+ if (sourceRoot) sourceRoots.set(sourceRoot, (sourceRoots.get(sourceRoot) ?? 0) + 1);
4368
+ }
4369
+ }
4370
+ };
4371
+ walk(root, 0);
4372
+ const listing = top.filter((entry) => !SKIP.has(entry.name) && !entry.name.startsWith(".")).sort((left, right) => Number(right.isDirectory()) - Number(left.isDirectory()) || left.name.localeCompare(right.name)).slice(0, 25).map((entry) => entry.isDirectory() ? `${entry.name}/` : entry.name);
4373
+ const types = [...extensions.entries()].sort((left, right) => right[1] - left[1]).slice(0, 8).map(([extension, count]) => `${extension} ${count}`);
4374
+ const sources = [...sourceRoots.entries()].sort((left, right) => right[1] - left[1]).slice(0, 8).map(([dir, count]) => `${dir}/ (${count})`);
4375
+ return `Workspace overview (generated, not a search result): top level: ${listing.join(", ") || "(empty)"}${top.length > 25 ? ", \u2026" : ""}. ${files}${seen > MAX_ENTRIES ? "+" : ""} files; most common types: ${types.join(", ") || "none"}. ` + (sources.length > 0 ? `Source folders: ${sources.join(", ")}. Paths are relative to the workspace root. ` : "") + "Search the file types that exist here, and search contents before concluding something is absent.";
4376
+ }
4377
+
4378
+ // src/research/entry.ts
4379
+ import path11 from "node:path";
4380
+ import { pathToFileURL } from "node:url";
4381
+ function isEntry(moduleUrl, name, script = process.argv[1]) {
4382
+ if (!script || moduleUrl !== pathToFileURL(script).href) return false;
4383
+ return path11.basename(script).replace(/\.[cm]?[jt]s$/, "") === name;
4384
+ }
4385
+
4386
+ // src/research/coder-agent.ts
4387
+ var OPT_IN_ENV = "FREELLAMA_MCP_ALLOW_CODER";
4388
+ var ACTIONS = ["search", "read", "list", "shell", "edit", "write", "run", "page", "finish"];
4389
+ var KEPT_RUN_OUTPUTS = 3;
4390
+ var requireString = (value, key, action, nonEmpty = true) => {
4391
+ const field = value[key];
4392
+ if (typeof field !== "string" || nonEmpty && !field) {
4393
+ throw new ValidationError(`${action} action requires a ${nonEmpty ? "non-empty " : ""}string ${key}`);
4394
+ }
4395
+ };
4396
+ function parseAction(content) {
4397
+ const value = parseJsonAction(content);
4398
+ const action = String(value.action);
4399
+ if (!ACTIONS.includes(action)) throw new ValidationError(`unsupported action for coder adapter: ${action} (use ${ACTIONS.join(", ")})`);
4400
+ if (action === "shell" && (typeof value.command !== "string" || !trimSpace(value.command))) {
4401
+ throw new ValidationError("shell action requires a non-empty string command");
4402
+ }
4403
+ if (action === "run") requireString(value, "command", "run");
4404
+ if (action === "page" && (!isPositiveInt(value.step) || !isPositiveInt(value.page))) {
4405
+ throw new ValidationError("page action requires positive integer step and page");
4406
+ }
4407
+ if (action === "finish") requireString(value, "answer", "finish", false);
4408
+ if (action === "search") validateSearch(value);
4409
+ if (action === "read") validateRead(value);
4410
+ if (action === "list" && value.path !== void 0 && typeof value.path !== "string") throw new ValidationError("list path must be a string");
4411
+ if (action === "edit") {
4412
+ requireString(value, "path", "edit");
4413
+ requireString(value, "search", "edit");
4414
+ requireString(value, "replace", "edit", false);
4415
+ if (value.search === value.replace) throw new ValidationError("edit search and replace are identical; nothing would change");
4416
+ }
4417
+ if (action === "write") {
4418
+ requireString(value, "path", "write");
4419
+ requireString(value, "content", "write", false);
4420
+ }
4421
+ return value;
4422
+ }
4423
+ function positiveNumber(raw, fallback, max) {
4424
+ const value = raw === void 0 || raw === "" ? Number.NaN : Number(raw);
4425
+ return Number.isFinite(value) && value > 0 ? Math.min(value, max) : fallback;
4426
+ }
4427
+ function coderOptionsFromEnv(env) {
4428
+ let commands;
4429
+ try {
4430
+ commands = JSON.parse(env.FREELLAMA_CODER_COMMANDS ?? "[]");
4431
+ } catch {
4432
+ throw new ValidationError("FREELLAMA_CODER_COMMANDS must be a JSON array of command strings");
4433
+ }
4434
+ return {
4435
+ commands: normalizeAllowedCommands(commands),
4436
+ container: env.FREELLAMA_CODER_COPY_DIR || void 0,
4437
+ patchPath: env.FREELLAMA_CODER_PATCH || void 0,
4438
+ runTimeoutSeconds: positiveNumber(env.FREELLAMA_CODER_RUN_TIMEOUT_SECONDS, DEFAULT_RUN_TIMEOUT_SECONDS, MAX_RUN_TIMEOUT_SECONDS),
4439
+ maxCopyBytes: positiveNumber(env.FREELLAMA_CODER_MAX_COPY_BYTES, DEFAULT_MAX_COPY_BYTES, Number.MAX_SAFE_INTEGER),
4440
+ writable: (env.FREELLAMA_CODER_WRITABLE ?? "").split(path12.delimiter).filter(Boolean),
4441
+ allowUnsandboxedRun: env.FREELLAMA_CODER_UNSANDBOXED_RUN === "1",
4442
+ osSandbox: env.FREELLAMA_AGENT_OS_SANDBOX === "off" ? "off" : "auto"
4443
+ };
4444
+ }
4445
+ function writableDirs(dirs, workspace) {
4446
+ return dirs.map((dir) => {
4447
+ if (!path12.isAbsolute(dir)) throw new ValidationError(`FREELLAMA_CODER_WRITABLE entry ${dir} must be absolute`);
4448
+ const real = existsSync8(dir) ? realpathSync5(dir) : path12.resolve(dir);
4449
+ if (!escapesRoot(workspace, real) || !escapesRoot(real, workspace)) {
4450
+ throw new ValidationError(`FREELLAMA_CODER_WRITABLE entry ${dir} overlaps the workspace; the coder never writes the user's files`);
4451
+ }
4452
+ return real;
4453
+ });
4454
+ }
4455
+ var sha1 = (file) => {
4456
+ try {
4457
+ return createHash3("sha1").update(readFileSync9(file)).digest("hex");
4458
+ } catch {
4459
+ return "missing";
4460
+ }
4461
+ };
4462
+ var CoderSession = class {
4463
+ constructor(source, copy, options) {
4464
+ this.source = source;
4465
+ this.copy = copy;
4466
+ this.options = options;
4467
+ this.workspace = copy.root;
4468
+ const extra = writableDirs(options.writable ?? [], source);
4469
+ this.shell = new ReadOnlyShell(copy.root, { readPaths: copy.commonDir ? [copy.commonDir] : [] }).enter();
4470
+ try {
4471
+ this.runner = new CommandRunner(copy.root, [copy.top, copy.tmp, ...extra], runEnvironment(process.env, copy.tmp), {
4472
+ privateDir: copy.privateDir,
4473
+ osSandbox: options.osSandbox ?? "auto",
4474
+ allowUnsandboxed: options.allowUnsandboxedRun === true,
4475
+ // A secret location that holds the workspace or the copy would hide the work itself.
4476
+ hidden: secretPaths(process.env).filter((secret) => escapesRoot(source, secret) && escapesRoot(copy.top, secret))
4477
+ }).enter();
4478
+ } catch (error) {
4479
+ this.shell.close();
4480
+ throw error;
4481
+ }
4482
+ }
4483
+ source;
4484
+ copy;
4485
+ options;
4486
+ workspace;
4487
+ shell;
4488
+ runner;
4489
+ runs = [];
4490
+ /** Bumped by every change to the copy, so a read or run after a change is not a repeat. */
4491
+ revision = 0;
4492
+ appliedEdits = /* @__PURE__ */ new Map();
4493
+ closed = false;
4494
+ changed() {
4495
+ this.revision += 1;
4496
+ forgetWorkspaceIndex(this.workspace);
4497
+ }
4498
+ toolCall(action, root, runtime) {
4499
+ const name = String(action.action);
4500
+ if (name === "search" || name === "read" || name === "list") {
4501
+ const call = structuredCall(this.shell, action, runtime.tool_timeout_seconds);
4502
+ return { ...call, signature: { ...call.signature, revision: this.revision } };
4503
+ }
4504
+ if (name === "edit") {
4505
+ const edit = { path: String(action.path), search: String(action.search), replace: String(action.replace) };
4506
+ const key = JSON.stringify(edit);
4507
+ return {
4508
+ // The file's content is part of the identity: a failed edit may be retried once the file changed.
4509
+ signature: { edit, file: sha1(path12.resolve(root, edit.path)) },
4510
+ run: async () => {
4511
+ const step = this.appliedEdits.get(key);
4512
+ if (step !== void 0) throw new ValidationError(`this exact edit was already applied (edit ${step}); read the file to see its current lines`);
4513
+ const result = applyEdit(root, edit);
4514
+ this.appliedEdits.set(key, this.appliedEdits.size + 1);
4515
+ this.changed();
4516
+ return result;
4517
+ },
4518
+ record: { name: "edit", raw_name: "edit", arguments: edit },
4519
+ ledger: (status) => ({ action: "edit", status, source: edit.path })
4520
+ };
4521
+ }
4522
+ if (name === "write") {
4523
+ const write = { path: String(action.path), content: String(action.content) };
4524
+ const bytes = Buffer.byteLength(write.content, "utf8");
4525
+ return {
4526
+ signature: { write },
4527
+ run: async () => {
4528
+ const result = writeWholeFile(root, write);
4529
+ this.changed();
4530
+ return result;
4531
+ },
4532
+ // The content reaches the caller through the diff; the trail keeps its size.
4533
+ record: { name: "write", raw_name: "write", arguments: { path: write.path, content_bytes: bytes, content_limit_bytes: WRITE_MAX_BYTES } },
4534
+ ledger: (status) => ({ action: "write", status, source: write.path })
4535
+ };
4536
+ }
4537
+ if (name === "run") {
4538
+ const requested = String(action.command);
4539
+ return {
4540
+ signature: { run: requested.trim(), revision: this.revision },
4541
+ run: async () => {
4542
+ const command = allowedCommand(requested, this.options.commands);
4543
+ const outcome = await this.runner.run(command, this.options.runTimeoutSeconds ?? DEFAULT_RUN_TIMEOUT_SECONDS);
4544
+ this.runs.push(outcome);
4545
+ this.changed();
4546
+ return describeRun(outcome);
4547
+ },
4548
+ record: { name: "run", raw_name: "run", arguments: { command: requested } },
4549
+ ledger: (status) => ({ action: "run", status, source: requested })
4550
+ };
4551
+ }
4552
+ const commandText = String(action.command ?? "");
4553
+ return {
4554
+ signature: { command: commandText, revision: this.revision },
4555
+ run: () => this.shell.run(commandText, runtime.tool_timeout_seconds),
4556
+ record: { name: "shell", raw_name: action.action ?? "", arguments: { command: commandText } },
4557
+ ledger: (status) => ({ action: "shell", status, source: commandText })
4558
+ };
4559
+ }
4560
+ /** The result's `coder` field: the agent's diff, the files it changed and its runs. */
4561
+ report() {
4562
+ const runs = this.runs.map((outcome, index) => ({
4563
+ command: outcome.command,
4564
+ exit_code: outcome.exitCode,
4565
+ ...outcome.signal ? { signal: outcome.signal } : {},
4566
+ timed_out: outcome.timedOut,
4567
+ duration_ms: outcome.durationMs,
4568
+ ...index >= this.runs.length - KEPT_RUN_OUTPUTS ? { output: outcome.kept } : {}
4569
+ }));
4570
+ const base = {
4571
+ isolation: this.copy.kind,
4572
+ base: { head: this.copy.head, caller_changes: this.copy.callerChanges },
4573
+ linked_dependencies: this.copy.linked,
4574
+ run_sandbox: this.runner.kind,
4575
+ runs
4576
+ };
4577
+ try {
4578
+ const changes = agentChanges(this.copy);
4579
+ let patchFile = null;
4580
+ if (this.options.patchPath && changes.patch.length > 0) {
4581
+ mkdirSync6(path12.dirname(this.options.patchPath), { recursive: true });
4582
+ writeFileSync5(this.options.patchPath, changes.patch, { mode: 384 });
4583
+ patchFile = this.options.patchPath;
4584
+ }
4585
+ return {
4586
+ coder: { ...base, diff: changes.patch.toString("utf8"), patch_bytes: changes.patch.length, patch_file: patchFile, changed_files: changes.files }
4587
+ };
4588
+ } catch (error) {
4589
+ return { coder: { ...base, diff: "", changed_files: [], error: `could not collect the changes: ${error instanceof Error ? error.message : String(error)}` } };
4590
+ }
4591
+ }
4592
+ close() {
4593
+ if (this.closed) return;
4594
+ this.closed = true;
4595
+ this.shell.close();
4596
+ removeIsolatedCopy(this.source, this.copy.container);
4597
+ }
4598
+ };
4599
+ function briefing(commands) {
4600
+ return (workspace) => {
4601
+ const overview = workspaceOverview(workspace);
4602
+ const allowed = commands.length > 0 ? `Allowed run commands, exactly as written: ${commands.map((command) => JSON.stringify(command)).join(", ")}.` : "No run commands are allowed in this task: you cannot test, so say the change is untested when you finish.";
4603
+ return overview ? `${overview}
4604
+
4605
+ ${allowed}` : allowed;
4606
+ };
4607
+ }
4608
+ function coderAdapter(options) {
4609
+ const commands = normalizeAllowedCommands(options.commands);
4610
+ const settings = { ...options, commands };
4611
+ return {
4612
+ defaultToolTimeoutSeconds: 30,
4613
+ systemPrompt: () => CODER_SYSTEM_PROMPT,
4614
+ briefing: briefing(commands),
4615
+ parseAction,
4616
+ requireSource: false,
4617
+ open: (workspace) => {
4618
+ const container = settings.container ?? newCopyContainer();
4619
+ const copy = createIsolatedCopy(workspace, container, { maxCopyBytes: settings.maxCopyBytes, linkDependencies: settings.linkDependencies });
4620
+ try {
4621
+ return new CoderSession(realpathSync5(workspace), copy, settings);
4622
+ } catch (error) {
4623
+ removeIsolatedCopy(workspace, copy.container);
4624
+ throw error;
4625
+ }
4626
+ },
4627
+ remaining: (count) => `
4628
+
4629
+ Actions remaining: ${count}. Finish once the change is made and tested; do not repeat prior actions.`,
4630
+ exhausted: "Action budget is exhausted. Return exactly a finish JSON action now: what changed, why, and the last test result. Do not request another action.",
4631
+ adapterMetadata: (session) => {
4632
+ const coder = session;
4633
+ return {
4634
+ adapter: "coder_agent_v1",
4635
+ sandbox: coder?.shell.kind ?? "restricted-bash",
4636
+ run_sandbox: coder?.runner.kind ?? "none",
4637
+ isolation: coder?.copy.kind ?? null
4638
+ };
4639
+ },
4640
+ report: (session) => session instanceof CoderSession ? session.report() : {}
4641
+ };
4642
+ }
4643
+ if (isEntry(import.meta.url, "coder-agent")) {
4644
+ const result = process.env.FREELLAMA_AGENT_RESULT;
4645
+ const refuse = (message) => {
4646
+ process.stderr.write(`${message}
4647
+ `);
4648
+ if (result) writeFailureResult(result, message);
4649
+ process.exitCode = 1;
4650
+ };
4651
+ if (process.env[OPT_IN_ENV] !== "1") {
4652
+ refuse(`the coder adapter is off: the operator enables it with ${OPT_IN_ENV}=1 in the MCP server's environment`);
4653
+ } else {
4654
+ let options;
4655
+ try {
4656
+ options = coderOptionsFromEnv(process.env);
4657
+ } catch (error) {
4658
+ refuse(`invalid coder adapter configuration: ${error instanceof Error ? error.message : String(error)}`);
4659
+ }
4660
+ if (options) process.exitCode = await runAdapter(coderAdapter(options));
4661
+ }
4662
+ }
4663
+ export {
4664
+ OPT_IN_ENV,
4665
+ coderAdapter,
4666
+ coderOptionsFromEnv,
4667
+ parseAction
4668
+ };