@octocodeai/freellama-mcp-server 0.7.0 → 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 +6943 -1602
  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 +74 -11
  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 +0 -11
  44. package/native/index.js +18 -34
  45. package/package.json +6 -9
@@ -1,13 +1,10 @@
1
- // src/research/octocode-agent.ts
2
- import { pathToFileURL } from "node:url";
3
-
4
1
  // src/research/adapter.ts
5
2
  function isPositiveInt(value) {
6
3
  return (typeof value === "boolean" || typeof value === "number" && Number.isInteger(value)) && Number(value) >= 1;
7
4
  }
8
5
 
9
6
  // src/research/agent-loop.ts
10
- import { readFileSync as readFileSync4 } from "node:fs";
7
+ import { readFileSync as readFileSync5 } from "node:fs";
11
8
  import { performance } from "node:perf_hooks";
12
9
  import { setTimeout as sleep } from "node:timers/promises";
13
10
 
@@ -57,8 +54,21 @@ function isSystemError(error) {
57
54
  var ToolTimeoutError = class extends Error {
58
55
  name = "ToolTimeoutError";
59
56
  };
57
+ var ToolExitError = class extends Error {
58
+ constructor(exitCode, output, status = "error") {
59
+ super(`${status === "empty" ? "nothing found" : "command failed"} (exit code ${exitCode})` + (output ? `
60
+ ${output}` : ""));
61
+ this.exitCode = exitCode;
62
+ this.output = output;
63
+ this.status = status;
64
+ }
65
+ exitCode;
66
+ output;
67
+ status;
68
+ name = "ToolExitError";
69
+ };
60
70
  function isToolError(error) {
61
- return error instanceof ValidationError || error instanceof ToolTimeoutError || error instanceof SyntaxError || isSystemError(error);
71
+ return error instanceof ValidationError || error instanceof ToolTimeoutError || error instanceof ToolExitError || error instanceof SyntaxError || isSystemError(error);
62
72
  }
63
73
 
64
74
  // src/research/actions.ts
@@ -362,27 +372,44 @@ var estimateText = (value) => canonicalJson(value, { sortKeys: false, ensureAsci
362
372
  function estimateTokens(text, charsPerToken = CHARS_PER_TOKEN) {
363
373
  return Math.floor(cpLength(text) / charsPerToken) + 1;
364
374
  }
365
- function messagesTokens(messages, policy = new ContextPolicy()) {
375
+ var UNMEASURED_BYTES_PER_TOKEN_MILLI = 3e3;
376
+ function platformBytesPerTokenMilli(learnedMilli) {
377
+ if (learnedMilli === null || learnedMilli === void 0) return UNMEASURED_BYTES_PER_TOKEN_MILLI;
378
+ return Math.min(Math.max(Math.floor(learnedMilli / 10) * 9, 1e3), 8e3);
379
+ }
380
+ function platformTextTokens(text, learnedMilli) {
381
+ return Math.ceil(Buffer.byteLength(text, "utf8") * 1e3 / platformBytesPerTokenMilli(learnedMilli));
382
+ }
383
+ function messagesTokens(messages, policy = new ContextPolicy(), learnedMilli = null) {
366
384
  let total = 0;
367
385
  for (const message of messages) {
368
- const { content = "", images = [], ...metadata } = message;
369
- total += estimateTokens(typeof content === "string" ? content : estimateText(content), policy.chars_per_token);
386
+ const { images = [], ...text } = message;
370
387
  if (Array.isArray(images)) total += images.length * policy.image_token_estimate;
388
+ if (learnedMilli !== null) {
389
+ total += platformTextTokens(JSON.stringify(text), learnedMilli) + 1;
390
+ continue;
391
+ }
392
+ const { content = "", ...metadata } = text;
393
+ total += estimateTokens(typeof content === "string" ? content : estimateText(content), policy.chars_per_token);
371
394
  total += estimateTokens(estimateText(metadata), policy.chars_per_token);
372
395
  }
373
396
  return total;
374
397
  }
375
398
  var ContextBudgeter = class {
376
399
  policy;
400
+ platformRatio;
377
401
  scale;
378
402
  calibration_samples;
379
403
  constructor(policy = new ContextPolicy(), options = {}) {
380
404
  this.policy = policy;
381
- this.scale = Math.max(options.initialScale ?? 1, 1);
382
- this.calibration_samples = Math.max(Math.trunc(options.calibrationSamples ?? 0), 0);
405
+ this.platformRatio = options.platformRatio ?? null;
406
+ this.scale = this.platformRatio ? 1 : Math.max(options.initialScale ?? 1, 1);
407
+ this.calibration_samples = this.platformRatio ? 0 : Math.max(Math.trunc(options.calibrationSamples ?? 0), 0);
383
408
  }
384
- estimate = (messages) => Math.ceil(messagesTokens(messages, this.policy) * this.scale);
409
+ estimate = (messages) => this.platformRatio ? messagesTokens(messages, this.policy, this.platformRatio.bytes_per_token_milli) : Math.ceil(messagesTokens(messages, this.policy) * this.scale);
410
+ /** Calibrate the fallback estimate; returns whether the multiplier rose (and should persist). */
385
411
  observe(messages, promptEvalCount) {
412
+ if (this.platformRatio) return false;
386
413
  if (typeof promptEvalCount !== "number" || !Number.isFinite(promptEvalCount)) return false;
387
414
  const actual = Math.trunc(promptEvalCount);
388
415
  if (actual <= 0) return false;
@@ -393,10 +420,14 @@ var ContextBudgeter = class {
393
420
  return this.scale > previous;
394
421
  }
395
422
  metadata() {
423
+ const ratio = this.platformRatio;
396
424
  return {
397
- token_counting: this.calibration_samples ? "model_calibrated_estimate" : "configured_estimate",
425
+ token_counting: ratio ? "platform_learned_ratio" : "local_chars_per_token",
398
426
  estimate_scale: Math.round(this.scale * 1e6) / 1e6,
399
- calibration_samples: this.calibration_samples
427
+ calibration_samples: this.calibration_samples,
428
+ bytes_per_token_milli: ratio?.bytes_per_token_milli ?? null,
429
+ bytes_per_token_milli_with_margin: ratio ? platformBytesPerTokenMilli(ratio.bytes_per_token_milli) : null,
430
+ bytes_per_token_samples: ratio?.bytes_per_token_samples ?? null
400
431
  };
401
432
  }
402
433
  };
@@ -542,11 +573,16 @@ var AgentContextManager = class {
542
573
  constructor(runtime, options = {}) {
543
574
  this.runtime = runtime;
544
575
  this.model = options.model ?? "";
545
- this.calibrationDir = options.calibrationDir ?? null;
576
+ const platformRatio = options.platformRatio ?? null;
577
+ this.calibrationDir = platformRatio ? null : options.calibrationDir ?? null;
546
578
  this.calibrationIdentity = options.calibrationIdentity ?? "";
547
579
  const loaded = this.loadCalibration();
548
- this.budgeter = new ContextBudgeter(runtime.context, { initialScale: loaded?.scale ?? 1, calibrationSamples: loaded?.samples ?? 0 });
549
- this.calibrationSource = loaded ? "persistent_model_cache" : "current_process";
580
+ this.budgeter = new ContextBudgeter(runtime.context, {
581
+ initialScale: loaded?.scale ?? 1,
582
+ calibrationSamples: loaded?.samples ?? 0,
583
+ platformRatio
584
+ });
585
+ this.calibrationSource = platformRatio ? "not_applied" : loaded ? "persistent_model_cache" : "current_process";
550
586
  }
551
587
  modelKey() {
552
588
  return createHash("sha256").update(this.calibrationContractJson(), "utf8").digest("hex").slice(0, 24);
@@ -840,8 +876,27 @@ ${body}${footer}`;
840
876
  }
841
877
  };
842
878
 
879
+ // src/research/progress-events.ts
880
+ var PROGRESS_ENV = "FREELLAMA_AGENT_PROGRESS";
881
+ var PROGRESS_PREFIX = "@freellama-progress ";
882
+ function progressEmitter(env, write = (line) => {
883
+ process.stderr.write(line);
884
+ }) {
885
+ if (env[PROGRESS_ENV] !== "stderr") return void 0;
886
+ return (event) => {
887
+ try {
888
+ write(`${PROGRESS_PREFIX}${JSON.stringify(event)}
889
+ `);
890
+ } catch {
891
+ }
892
+ };
893
+ }
894
+
843
895
  // src/research/transport.ts
844
896
  import { createHash as createHash2 } from "node:crypto";
897
+ import { readFileSync as readFileSync4 } from "node:fs";
898
+ import { homedir } from "node:os";
899
+ import path3 from "node:path";
845
900
 
846
901
  // src/research/transport-http.ts
847
902
  import { readFileSync as readFileSync3 } from "node:fs";
@@ -903,13 +958,21 @@ function httpRequest(url, options) {
903
958
  if (options.body) headers["content-length"] = String(options.body.length);
904
959
  let sent = false;
905
960
  let settled = false;
961
+ const { signal } = options;
962
+ const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("request aborted"));
906
963
  const fail = (error) => {
907
964
  if (settled) return;
908
965
  settled = true;
966
+ signal?.removeEventListener("abort", onAbort);
909
967
  request.destroy();
910
968
  reject(error);
911
969
  };
970
+ if (signal?.aborted) {
971
+ reject(signal.reason instanceof Error ? signal.reason : new Error("request aborted"));
972
+ return;
973
+ }
912
974
  const request = client.request(parsed, { method: options.method, headers });
975
+ signal?.addEventListener("abort", onAbort, { once: true });
913
976
  request.setTimeout(Math.max(options.timeoutSeconds * 1e3, 1), () => {
914
977
  fail(sent ? new RequestTimeoutError() : new URLError(new RequestTimeoutError()));
915
978
  });
@@ -926,6 +989,7 @@ function httpRequest(url, options) {
926
989
  const finish = () => {
927
990
  if (settled) return;
928
991
  settled = true;
992
+ signal?.removeEventListener("abort", onAbort);
929
993
  const body = Buffer.concat(chunks);
930
994
  request.destroy();
931
995
  if (ok) resolve(body);
@@ -957,11 +1021,25 @@ async function requestJson(url, payload, timeoutSeconds) {
957
1021
  var MANAGED_SUFFIX = "/_freellama/v1/tasks";
958
1022
  var IDENTITY_LIMIT = 1024 * 1024;
959
1023
  var trimSlashes = (text) => text.replace(/\/+$/, "");
960
- async function readIdentityJson(url) {
961
- const payload = await httpRequest(url, { method: "GET", headers: requestHeaders(), timeoutSeconds: 5, limit: IDENTITY_LIMIT + 1 });
1024
+ async function readCatalogJson(url, request = httpRequest) {
1025
+ const payload = await request(url, { method: "GET", headers: requestHeaders(), timeoutSeconds: 5, limit: IDENTITY_LIMIT + 1 });
962
1026
  if (payload.length > IDENTITY_LIMIT) throw new ValidationError("model identity catalog exceeds size limit");
963
1027
  return parseJsonBody(payload, "model identity catalog");
964
1028
  }
1029
+ function catalogEntry(payload, model) {
1030
+ const entries = isRecord(payload) ? payload.models ?? [] : [];
1031
+ if (!Array.isArray(entries)) return null;
1032
+ const names = /* @__PURE__ */ new Set([model]);
1033
+ const lastSegment = model.slice(model.lastIndexOf("/") + 1);
1034
+ if (!lastSegment.includes(":")) names.add(model + ":latest");
1035
+ for (const item of entries) {
1036
+ if (!isRecord(item)) continue;
1037
+ const name = Object.hasOwn(item, "name") ? item.name : item.model;
1038
+ if (typeof name === "object" && name !== null) return "ambiguous";
1039
+ if (typeof name === "string" && names.has(name)) return item;
1040
+ }
1041
+ return null;
1042
+ }
965
1043
  var DIGEST = /^(?:sha256:)?[0-9a-fA-F]{64}$/;
966
1044
  var sha256 = (data) => createHash2("sha256").update(data, "utf8").digest("hex");
967
1045
  async function resolveModelIdentity(endpointInput, model, systemPrompt, options = {}) {
@@ -969,22 +1047,8 @@ async function resolveModelIdentity(endpointInput, model, systemPrompt, options
969
1047
  const url = endpoint.endsWith(MANAGED_SUFFIX) ? endpoint.slice(0, -MANAGED_SUFFIX.length) + "/_freellama/v1/models" : endpoint + "/api/tags";
970
1048
  const unknown = { identity: "", verified: false, scope: "current_process_only" };
971
1049
  try {
972
- const payload = await (options.readJson ?? readIdentityJson)(url);
973
- const entries = isRecord(payload) ? payload.models ?? [] : [];
974
- if (!Array.isArray(entries)) return unknown;
975
- const names = /* @__PURE__ */ new Set([model]);
976
- const lastSegment = model.slice(model.lastIndexOf("/") + 1);
977
- if (!lastSegment.includes(":")) names.add(model + ":latest");
978
- let entry = null;
979
- for (const item of entries) {
980
- if (!isRecord(item)) continue;
981
- const name = Object.hasOwn(item, "name") ? item.name : item.model;
982
- if (typeof name === "object" && name !== null) return unknown;
983
- if (typeof name === "string" && names.has(name)) {
984
- entry = item;
985
- break;
986
- }
987
- }
1050
+ const entry = catalogEntry(await (options.readJson ?? readCatalogJson)(url), model);
1051
+ if (entry === "ambiguous") return unknown;
988
1052
  const digestValue = entry?.digest;
989
1053
  if (typeof digestValue !== "string" || !DIGEST.test(digestValue)) return unknown;
990
1054
  const digest = (digestValue.startsWith("sha256:") ? digestValue.slice("sha256:".length) : digestValue).toLowerCase();
@@ -1002,11 +1066,36 @@ async function resolveModelIdentity(endpointInput, model, systemPrompt, options
1002
1066
  throw error;
1003
1067
  }
1004
1068
  }
1069
+ async function resolvePlatformTokenRatio(serveEndpoint, model, options = {}) {
1070
+ try {
1071
+ const url = `${trimSlashes(serveEndpoint)}/_freellama/v1/models`;
1072
+ const entry = catalogEntry(await (options.readJson ?? readCatalogJson)(url), model);
1073
+ if (entry === null || entry === "ambiguous") return null;
1074
+ const milli = entry.bytes_per_token_milli;
1075
+ if (typeof milli !== "number" || !Number.isSafeInteger(milli) || milli <= 0) return null;
1076
+ const samples = entry.bytes_per_token_samples;
1077
+ return {
1078
+ bytes_per_token_milli: milli,
1079
+ bytes_per_token_samples: typeof samples === "number" && Number.isSafeInteger(samples) && samples > 0 ? samples : 1
1080
+ };
1081
+ } catch {
1082
+ return null;
1083
+ }
1084
+ }
1005
1085
  function isTransportError(error) {
1006
1086
  return error instanceof HTTPError || error instanceof URLError || error instanceof RequestTimeoutError;
1007
1087
  }
1008
1088
  function retryableChatError(error) {
1009
- if (error instanceof HTTPError) return error.code === 503;
1089
+ if (error instanceof HTTPError) {
1090
+ if (error.code !== 503 || error.body.length > RECEIPT_BODY_LIMIT) return false;
1091
+ try {
1092
+ const body = parseJsonBody(error.body, "retry receipt");
1093
+ if (!isRecord(body) || body.retry_safe === false || body.execution_outcome !== void 0 && body.execution_outcome !== "not_started") return false;
1094
+ 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));
1095
+ } catch {
1096
+ return false;
1097
+ }
1098
+ }
1010
1099
  return error instanceof URLError && error.reason.code === "ECONNREFUSED";
1011
1100
  }
1012
1101
  var RECEIPT_FIELDS = /* @__PURE__ */ new Set([
@@ -1022,7 +1111,12 @@ var RECEIPT_FIELDS = /* @__PURE__ */ new Set([
1022
1111
  "route",
1023
1112
  "decision",
1024
1113
  "quality",
1025
- "feedback"
1114
+ "feedback",
1115
+ "execution_outcome",
1116
+ "retry_safe",
1117
+ "result_recoverable",
1118
+ "job_id",
1119
+ "last_observed_status"
1026
1120
  ]);
1027
1121
  var PAYLOAD_FIELDS = /* @__PURE__ */ new Set([
1028
1122
  "prompt",
@@ -1105,6 +1199,22 @@ var PromptCacheUsage = class {
1105
1199
  };
1106
1200
  }
1107
1201
  };
1202
+ var resolvedShift;
1203
+ function contextShift(env = process.env, reset = false) {
1204
+ if (resolvedShift !== void 0 && !reset) return resolvedShift;
1205
+ const read = (value2) => value2 === "ollama_default" || value2 === "off" ? value2 : void 0;
1206
+ let value = read(env.FREELLAMA_CONTEXT_SHIFT?.trim());
1207
+ if (!value) {
1208
+ const file = env.FREELLAMA_RUNTIME_CONFIG || path3.join(env.FREELLAMA_HOME || path3.join(homedir(), ".octocode", "freellama"), "runtime.toml");
1209
+ try {
1210
+ const topLevel = readFileSync4(file, "utf8").split(/^\s*\[/m)[0] ?? "";
1211
+ value = read(/^\s*context_shift\s*=\s*"([^"]*)"/m.exec(topLevel)?.[1]);
1212
+ } catch {
1213
+ }
1214
+ }
1215
+ resolvedShift = value ?? "off";
1216
+ return resolvedShift;
1217
+ }
1108
1218
  function chatRequest(endpointInput, model, messages, options, think, keepAlive, executionPreference = "auto", minPlacementEvidence = "configured") {
1109
1219
  const endpoint = trimSlashes(endpointInput);
1110
1220
  if (endpoint.endsWith(MANAGED_SUFFIX)) {
@@ -1130,7 +1240,7 @@ function chatRequest(endpointInput, model, messages, options, think, keepAlive,
1130
1240
  messages,
1131
1241
  stream: false,
1132
1242
  truncate: false,
1133
- shift: false,
1243
+ ...contextShift() === "ollama_default" ? {} : { shift: false },
1134
1244
  format: "json",
1135
1245
  think,
1136
1246
  keep_alive: keepAlive,
@@ -1152,9 +1262,322 @@ function unwrapChatResponse(payload, executionReceipts) {
1152
1262
  return response;
1153
1263
  }
1154
1264
 
1265
+ // src/research/transport-jobs.ts
1266
+ import { STATUS_CODES } from "node:http";
1267
+
1268
+ // src/job-progress.ts
1269
+ var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "expired"]);
1270
+ var SUBMIT_FALLBACK_CODES = /* @__PURE__ */ new Set(["task_job_input_too_large", "task_jobs_full", "task_jobs_input_budget"]);
1271
+ var RETENTION_FAILURE_CODES = /* @__PURE__ */ new Set(["task_job_result_too_large", "task_job_error_too_large"]);
1272
+ var FIRST_POLL_MS = 250;
1273
+ var HEARTBEAT_MS = 1e4;
1274
+ var MAX_POLL_MS = 2e3;
1275
+ var MAX_POLL_FAILURES = 5;
1276
+ var CANCEL_ATTEMPTS = 3;
1277
+ var CANCEL_BACKOFF_MS = 250;
1278
+ var CANCEL_REPORT_MS = 5e3;
1279
+ var object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
1280
+ var number = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
1281
+ var gigabytes = (bytes) => `${(bytes / 1e9).toFixed(1)} GB`;
1282
+ var seconds = (ms) => ms < 1e4 ? `${(ms / 1e3).toFixed(1)} s` : `${Math.round(ms / 1e3)} s`;
1283
+ function modelNames(value) {
1284
+ if (!Array.isArray(value)) return [];
1285
+ return value.flatMap((item) => {
1286
+ if (typeof item === "string") return [item];
1287
+ const named = object(item)?.model ?? object(item)?.name;
1288
+ return typeof named === "string" ? [named] : [];
1289
+ });
1290
+ }
1291
+ function evictedSuffix(detail) {
1292
+ const evicted = modelNames(detail?.evicted ?? detail?.evicted_idle_models);
1293
+ return evicted.length > 0 ? `, evicted ${evicted.join(", ")}` : "";
1294
+ }
1295
+ function holdersSuffix(detail) {
1296
+ const holders = Array.isArray(detail?.memory_holders) ? detail.memory_holders : [];
1297
+ const named = holders.map((holder) => holder).filter((holder) => typeof holder.name === "string" && typeof holder.resident_bytes === "number").slice(0, 3).map((holder) => {
1298
+ const count = typeof holder.processes === "number" && holder.processes > 1 ? ` \xD7${holder.processes}` : "";
1299
+ return `${holder.name}${count} ${gigabytes(holder.resident_bytes)}`;
1300
+ });
1301
+ return named.length > 0 ? `; largest memory users: ${named.join(", ")}` : "";
1302
+ }
1303
+ function jobProgressMessage(job) {
1304
+ const status = typeof job.status === "string" ? job.status : "queued";
1305
+ const reason = typeof job.reason === "string" ? job.reason : "";
1306
+ const detail = object(job.detail);
1307
+ const model = typeof job.selected_model === "string" && job.selected_model ? job.selected_model : void 0;
1308
+ switch (status) {
1309
+ case "queued":
1310
+ return "queued";
1311
+ case "waiting_for_admission":
1312
+ return reason === "resources_reserved_waiting_for_backend_capacity" ? "memory reserved; waiting for backend capacity" : "waiting for backend capacity";
1313
+ case "waiting_for_resources": {
1314
+ const need = number(detail?.required_available_bytes);
1315
+ const available = number(detail?.effective_available_bytes);
1316
+ if (need === void 0 && available === void 0) {
1317
+ return (reason === "checking_host_resources" ? "checking host memory" : "waiting for memory") + evictedSuffix(detail);
1318
+ }
1319
+ const sizes = [need !== void 0 ? `need ${gigabytes(need)}` : void 0, available !== void 0 ? `${gigabytes(available)} available` : void 0].filter(Boolean).join(", ");
1320
+ const pressure = Array.isArray(detail?.pressure_reasons) ? detail.pressure_reasons.map((item) => typeof item === "string" ? item : JSON.stringify(item)) : [];
1321
+ return `waiting for memory: ${sizes}${pressure.length > 0 ? ` (host: ${pressure.join(", ")})` : ""}${holdersSuffix(detail)}${evictedSuffix(detail)}`;
1322
+ }
1323
+ case "waiting_for_runner":
1324
+ return "waiting for runner";
1325
+ case "loading": {
1326
+ const expected = number(detail?.expected_load_ms);
1327
+ return `loading ${model ?? "model"}${expected !== void 0 ? ` (~${seconds(expected)})` : ""}${evictedSuffix(detail)}`;
1328
+ }
1329
+ case "running":
1330
+ return model ? `running ${model}` : "running";
1331
+ default:
1332
+ return status.replace(/_/g, " ");
1333
+ }
1334
+ }
1335
+ function errorBody(error) {
1336
+ try {
1337
+ return object(JSON.parse(error instanceof Error ? error.message : String(error)));
1338
+ } catch {
1339
+ return void 0;
1340
+ }
1341
+ }
1342
+ function describeNapiError(error) {
1343
+ const message = error instanceof Error ? error.message : String(error);
1344
+ const missing = /does not exist or has expired|\bHTTP 404\b|^\s*(?:404\b|Not Found\s*$)/i.test(message);
1345
+ return { body: errorBody(error), ...missing ? { status: 404 } : {} };
1346
+ }
1347
+ function unconfirmedSubmission(error, body) {
1348
+ const message = error instanceof Error ? error.message : String(error);
1349
+ if (body && (typeof body.code === "string" || "execution_outcome" in body || "retry_safe" in body)) {
1350
+ return error instanceof Error ? error : new Error(message);
1351
+ }
1352
+ return new Error(JSON.stringify({
1353
+ ...body,
1354
+ error: typeof body?.error === "string" ? body.error : message,
1355
+ execution_outcome: "unknown",
1356
+ retry_safe: false
1357
+ }));
1358
+ }
1359
+ function submissionRefused({ body }) {
1360
+ const code = body?.code;
1361
+ return typeof code === "string" && SUBMIT_FALLBACK_CODES.has(code);
1362
+ }
1363
+ var defaultWait = (ms, signal) => new Promise((resolve) => {
1364
+ if (signal?.aborted) return resolve();
1365
+ const done = () => {
1366
+ clearTimeout(timer);
1367
+ signal?.removeEventListener("abort", done);
1368
+ resolve();
1369
+ };
1370
+ const timer = setTimeout(done, ms);
1371
+ signal?.addEventListener("abort", done, { once: true });
1372
+ });
1373
+ async function runWithJobProgress(calls, report, options = {}) {
1374
+ const { signal, wait = defaultWait, describe = describeNapiError, onJob } = options;
1375
+ 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.");
1376
+ if (signal?.aborted) throw options.aborted?.() ?? new Error("run_task was cancelled by the client before submission; no task was submitted.");
1377
+ let raw;
1378
+ try {
1379
+ raw = await calls.submit();
1380
+ } catch (error) {
1381
+ if (signal?.aborted) throw aborted();
1382
+ const described = describe(error);
1383
+ if (submissionRefused(described)) return calls.sync(described.body);
1384
+ throw options.unconfirmed?.(error, described.body) ?? unconfirmedSubmission(error, described.body);
1385
+ }
1386
+ let submitted;
1387
+ try {
1388
+ submitted = object(JSON.parse(raw));
1389
+ } catch {
1390
+ return raw;
1391
+ }
1392
+ let job = object(submitted?.job);
1393
+ const id = typeof job?.id === "string" ? job.id : void 0;
1394
+ if (submitted?.deferred !== true || !job || !id) return raw;
1395
+ onJob?.(id, true);
1396
+ let open = true;
1397
+ const settled = () => {
1398
+ if (open) onJob?.(id, false);
1399
+ open = false;
1400
+ };
1401
+ let cancellation;
1402
+ const requestCancellation = async () => {
1403
+ for (let attempt = 1; ; attempt++) {
1404
+ try {
1405
+ const reply = object(JSON.parse(await calls.cancel(id)));
1406
+ const status = object(reply?.job)?.status ?? reply?.status;
1407
+ if (!TERMINAL.has(String(status))) return "requested";
1408
+ settled();
1409
+ return "acknowledged";
1410
+ } catch (error) {
1411
+ if (describe(error).status === 404) {
1412
+ settled();
1413
+ return "unknown";
1414
+ }
1415
+ if (attempt >= CANCEL_ATTEMPTS) return "unknown";
1416
+ await wait(CANCEL_BACKOFF_MS * 2 ** (attempt - 1));
1417
+ }
1418
+ }
1419
+ };
1420
+ const cancel = () => {
1421
+ cancellation ??= requestCancellation();
1422
+ };
1423
+ const cancellationState = async () => {
1424
+ if (!cancellation) return void 0;
1425
+ let timer;
1426
+ const capped = new Promise((resolve) => {
1427
+ timer = setTimeout(() => resolve("unknown"), CANCEL_REPORT_MS);
1428
+ });
1429
+ try {
1430
+ return await Promise.race([cancellation, capped]);
1431
+ } finally {
1432
+ clearTimeout(timer);
1433
+ }
1434
+ };
1435
+ signal?.addEventListener("abort", cancel, { once: true });
1436
+ try {
1437
+ let lastMessage = "";
1438
+ let lastPhase = "";
1439
+ let lastReportAt = Date.now();
1440
+ let phaseStartedAt = lastReportAt;
1441
+ let delay = FIRST_POLL_MS;
1442
+ let failures = 0;
1443
+ for (; ; ) {
1444
+ if (signal?.aborted) {
1445
+ cancel();
1446
+ throw aborted(id, await cancellationState());
1447
+ }
1448
+ const now = Date.now();
1449
+ const phase = `${String(job.status)}|${String(job.reason)}`;
1450
+ if (phase !== lastPhase) {
1451
+ lastPhase = phase;
1452
+ phaseStartedAt = now;
1453
+ delay = FIRST_POLL_MS;
1454
+ }
1455
+ const message = jobProgressMessage(job);
1456
+ if (message !== lastMessage) {
1457
+ report(message);
1458
+ lastMessage = message;
1459
+ lastReportAt = now;
1460
+ } else if (now - lastReportAt >= HEARTBEAT_MS && !TERMINAL.has(String(job.status))) {
1461
+ report(`${message} (${seconds(now - phaseStartedAt)})`);
1462
+ lastReportAt = now;
1463
+ }
1464
+ if (TERMINAL.has(String(job.status))) break;
1465
+ await wait(delay, signal);
1466
+ if (signal?.aborted) {
1467
+ cancel();
1468
+ throw aborted(id, await cancellationState());
1469
+ }
1470
+ delay = Math.min(MAX_POLL_MS, Math.round(delay * 1.5));
1471
+ try {
1472
+ const polled = object(object(JSON.parse(await calls.get(id)))?.job);
1473
+ if (polled) job = polled;
1474
+ failures = 0;
1475
+ } catch (error) {
1476
+ if (describe(error).status === 404) {
1477
+ settled();
1478
+ const failure2 = {
1479
+ code: "task_job_unavailable",
1480
+ 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.`,
1481
+ job_id: id,
1482
+ last_observed_status: job.status,
1483
+ execution_outcome: "unknown",
1484
+ result_recoverable: false,
1485
+ retry_safe: false
1486
+ };
1487
+ throw options.failure?.(failure2, 404) ?? new Error(JSON.stringify(failure2));
1488
+ }
1489
+ failures += 1;
1490
+ if (failures >= MAX_POLL_FAILURES) {
1491
+ cancel();
1492
+ throw error;
1493
+ }
1494
+ }
1495
+ }
1496
+ settled();
1497
+ if (job.status === "completed") return JSON.stringify(job.result ?? {});
1498
+ const failure = { ...object(job.error) ?? { error: `deferred job ${id} ended ${String(job.status)}` } };
1499
+ if (typeof failure.code === "string" && RETENTION_FAILURE_CODES.has(failure.code)) {
1500
+ const lost = failure.code === "task_job_result_too_large" ? "result" : "error";
1501
+ 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.`;
1502
+ failure.job_id = id;
1503
+ failure.result_recoverable = false;
1504
+ failure.retry_safe = false;
1505
+ }
1506
+ const status = typeof failure.status === "number" ? failure.status : void 0;
1507
+ delete failure.id;
1508
+ delete failure.ok;
1509
+ if (status !== void 0) delete failure.status;
1510
+ throw options.failure?.(failure, status) ?? new Error(JSON.stringify(failure));
1511
+ } finally {
1512
+ signal?.removeEventListener("abort", cancel);
1513
+ }
1514
+ }
1515
+
1516
+ // src/research/transport-jobs.ts
1517
+ var TASKS_SUFFIX = "/_freellama/v1/tasks";
1518
+ function describeHttpError(error) {
1519
+ if (!(error instanceof HTTPError)) return {};
1520
+ try {
1521
+ const body = parseJsonBody(error.body.subarray(0, RECEIPT_BODY_LIMIT), "error body");
1522
+ return { status: error.code, ...isRecord(body) ? { body } : {} };
1523
+ } catch {
1524
+ return { status: error.code };
1525
+ }
1526
+ }
1527
+ function syncError(body, status) {
1528
+ const code = status ?? 500;
1529
+ return new HTTPError(code, STATUS_CODES[code] ?? "", Buffer.from(JSON.stringify(body), "utf8"));
1530
+ }
1531
+ async function requestJsonAsJob(url, payload, timeoutSeconds, options) {
1532
+ const request = options.request ?? httpRequest;
1533
+ const headers = requestHeaders();
1534
+ const call = async (method, target, body, signal) => {
1535
+ const sent = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body), "utf8");
1536
+ const response = await request(target, { method, headers, body: sent, timeoutSeconds, signal });
1537
+ return decodeUtf8(response, "managed task response");
1538
+ };
1539
+ const sync = () => call("POST", url, payload);
1540
+ if (!url.endsWith(TASKS_SUFFIX)) return parseJsonBody(Buffer.from(await sync(), "utf8"), "chat response");
1541
+ const jobs = `${url.slice(0, -TASKS_SUFFIX.length)}/_freellama/v1/jobs`;
1542
+ const deadline = new AbortController();
1543
+ const timer = setTimeout(() => deadline.abort(), Math.max(timeoutSeconds * 1e3, 1));
1544
+ try {
1545
+ const raw = await runWithJobProgress({
1546
+ submit: () => call("POST", url, { ...payload, defer: true }),
1547
+ get: (id) => call("GET", `${jobs}/${encodeURIComponent(id)}`, void 0, deadline.signal),
1548
+ cancel: (id) => call("POST", `${jobs}/${encodeURIComponent(id)}/cancel`, null),
1549
+ sync: async () => {
1550
+ throw syncError({
1551
+ code: "cancellable_task_unavailable",
1552
+ error: "Serve refused job submission before execution; cancellable research requires available job capacity and a supported request size.",
1553
+ execution_outcome: "not_started",
1554
+ retry_safe: true
1555
+ }, 409);
1556
+ }
1557
+ }, options.report, {
1558
+ signal: deadline.signal,
1559
+ wait: options.wait,
1560
+ describe: describeHttpError,
1561
+ failure: syncError,
1562
+ aborted: () => new RequestTimeoutError(),
1563
+ // Keep transport types, but do not let an arbitrary 503 erase an uncertain submission.
1564
+ unconfirmed: (error, body) => error instanceof HTTPError && !retryableChatError(error) ? syncError({
1565
+ ...body,
1566
+ error: typeof body?.error === "string" ? body.error : error.message,
1567
+ execution_outcome: body?.execution_outcome ?? "unknown",
1568
+ retry_safe: false
1569
+ }, error.code) : error,
1570
+ onJob: options.onJob
1571
+ });
1572
+ return parseJsonBody(Buffer.from(raw, "utf8"), "chat response");
1573
+ } finally {
1574
+ clearTimeout(timer);
1575
+ }
1576
+ }
1577
+
1155
1578
  // src/research/workspace-path.ts
1156
1579
  import { lstatSync, readlinkSync, statSync } from "node:fs";
1157
- import path3 from "node:path";
1580
+ import path4 from "node:path";
1158
1581
  var SymlinkLoopError = class extends ValidationError {
1159
1582
  name = "SymlinkLoopError";
1160
1583
  };
@@ -1209,7 +1632,47 @@ function resolvePath(target, cwd = process.cwd()) {
1209
1632
  return current;
1210
1633
  };
1211
1634
  const text = cleanPath(target);
1212
- return path3.posix.normalize(walk(text.startsWith("/") ? "" : cwd, text) || "/");
1635
+ return path4.posix.normalize(walk(text.startsWith("/") ? "" : cwd, text) || "/");
1636
+ }
1637
+
1638
+ // src/research/grounding.ts
1639
+ import { existsSync as existsSync3 } from "node:fs";
1640
+ import path5 from "node:path";
1641
+ var PATH_TOKEN = /(?<![\w@/.-])(?:\.\/)?[\w@-][\w@.-]*(?:\/[\w@.-]+)+\.[A-Za-z][A-Za-z0-9]{0,5}(?=$|[\s:`'"),.;\]])/g;
1642
+ function clean(token) {
1643
+ return token.replace(/^\.\//, "").replace(/[.,;]+$/, "");
1644
+ }
1645
+ function pathsIn(text) {
1646
+ return [...new Set([...text.matchAll(PATH_TOKEN)].map((match) => clean(match[0])))];
1647
+ }
1648
+ function missingPaths(answer, workspace) {
1649
+ return pathsIn(answer).filter((candidate) => !existsSync3(path5.join(workspace, candidate)));
1650
+ }
1651
+ function evidencePaths(observations, workspace, limit = 10) {
1652
+ const found = [];
1653
+ for (const text of observations) {
1654
+ for (const candidate of pathsIn(text)) {
1655
+ if (!found.includes(candidate) && existsSync3(path5.join(workspace, candidate))) found.push(candidate);
1656
+ if (found.length >= limit) return found;
1657
+ }
1658
+ }
1659
+ return found;
1660
+ }
1661
+ function groundingRepairNotice(missing, evidence) {
1662
+ 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.";
1663
+ }
1664
+ function groundingNote(missing) {
1665
+ return `
1666
+ [freellama: cited path(s) not found in the workspace: ${missing.join(", ")}; treat this answer as unverified]`;
1667
+ }
1668
+ function toolErrorHint(message) {
1669
+ if (/rg: unrecognized flag --(?:exclude-dir|include)/.test(message)) {
1670
+ return "\nHint: rg excludes directories with --glob '!node_modules' and filters types with -t ts; or use grep -rn PATTERN . --exclude-dir=node_modules.";
1671
+ }
1672
+ return "";
1673
+ }
1674
+ function sourceRepairNotice(evidence) {
1675
+ 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(", ")}.`;
1213
1676
  }
1214
1677
 
1215
1678
  // src/research/agent-loop.ts
@@ -1219,7 +1682,7 @@ function requireEnv(env, name) {
1219
1682
  if (value === void 0) throw new Error(`${name} is not set`);
1220
1683
  return value;
1221
1684
  }
1222
- var readText = (file) => normalizeNewlines(decodeUtf8(readFileSync4(file), file));
1685
+ var readText = (file) => normalizeNewlines(decodeUtf8(readFileSync5(file), file));
1223
1686
  var pathOf = (value) => value === "" ? "." : value;
1224
1687
  var numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : typeof value === "boolean" ? Number(value) : fallback;
1225
1688
  var echo = (action) => canonicalJson(action, { sortKeys: false, ensureAscii: false });
@@ -1249,9 +1712,15 @@ async function runLoop(spec, hooks, env, inputs) {
1249
1712
  const { model, workspace, prompt, resultPath } = inputs;
1250
1713
  const print = hooks.print ?? ((line) => process.stdout.write(`${line}
1251
1714
  `));
1252
- const sendJson = hooks.requestJson ?? requestJson;
1715
+ const progress = hooks.progress ?? progressEmitter(env);
1253
1716
  const endpoint = (env.FREELLAMA_OLLAMA_ENDPOINT ?? "http://127.0.0.1:11434").replace(/\/+$/, "");
1254
1717
  const managedEndpoint = (env.FREELLAMA_AGENT_MANAGED_ENDPOINT ?? "").replace(/\/+$/, "");
1718
+ let phaseAt = { maxTurns: 0 };
1719
+ const sendJson = hooks.requestJson ?? (managedEndpoint ? (url, payload, timeoutSeconds) => requestJsonAsJob(url, payload, timeoutSeconds, {
1720
+ report: (message) => progress?.({ event: "job_phase", ...phaseAt, message }),
1721
+ onJob: (id, open) => progress?.({ event: open ? "job_open" : "job_closed", id }),
1722
+ request: hooks.httpRequest
1723
+ }) : requestJson);
1255
1724
  const executionPreference = env.FREELLAMA_AGENT_EXECUTION_PREFERENCE ?? "auto";
1256
1725
  const minPlacementEvidence = env.FREELLAMA_AGENT_MIN_PLACEMENT_EVIDENCE ?? "configured";
1257
1726
  let runtime;
@@ -1266,7 +1735,7 @@ async function runLoop(spec, hooks, env, inputs) {
1266
1735
  }
1267
1736
  let messages = [
1268
1737
  { role: "system", content: spec.systemPrompt(workspace) },
1269
- { role: "user", content: prompt }
1738
+ { role: "user", content: withBriefing(prompt, spec, workspace) }
1270
1739
  ];
1271
1740
  const calls = [];
1272
1741
  const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: null, cache_write_tokens: null };
@@ -1276,12 +1745,20 @@ async function runLoop(spec, hooks, env, inputs) {
1276
1745
  let answer = "";
1277
1746
  let failure = null;
1278
1747
  let transportError = null;
1748
+ const readCatalog = (url) => readCatalogJson(url, hooks.httpRequest);
1749
+ const platformRatio = managedEndpoint ? await (hooks.resolvePlatformTokenRatio ?? resolvePlatformTokenRatio)(managedEndpoint, model, { readJson: readCatalog }) : null;
1279
1750
  const calibrationDir = trimSpace(env.FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR ?? "");
1280
- const calibrationModelIdentity = calibrationDir ? await (hooks.resolveModelIdentity ?? resolveModelIdentity)(managedEndpoint || endpoint, model, messages[0].content) : { verified: false, scope: "persistence_disabled", identity: "" };
1751
+ const calibrationModelIdentity = calibrationDir ? await (hooks.resolveModelIdentity ?? resolveModelIdentity)(
1752
+ managedEndpoint || endpoint,
1753
+ model,
1754
+ messages[0].content,
1755
+ { readJson: readCatalog }
1756
+ ) : { verified: false, scope: "persistence_disabled", identity: "" };
1281
1757
  const contextManager = new AgentContextManager(runtime, {
1282
1758
  model,
1283
1759
  calibrationIdentity: calibrationModelIdentity.identity,
1284
- calibrationDir: calibrationDir && calibrationModelIdentity.verified ? calibrationDir : null
1760
+ calibrationDir: calibrationDir && calibrationModelIdentity.verified ? calibrationDir : null,
1761
+ platformRatio
1285
1762
  });
1286
1763
  try {
1287
1764
  messages = contextManager.fit(messages);
@@ -1314,12 +1791,19 @@ async function runLoop(spec, hooks, env, inputs) {
1314
1791
  executionPreference,
1315
1792
  minPlacementEvidence
1316
1793
  );
1794
+ const recorded = executionReceipts.length;
1317
1795
  response = unwrapChatResponse(await sendJson(url, payload, runtime.request_timeout_seconds), executionReceipts);
1796
+ if (managedEndpoint && executionReceipts.length === recorded) {
1797
+ executionReceipts.push({ model: null, execution: null, reason: "managed_execution_receipt_missing" });
1798
+ }
1318
1799
  break;
1319
1800
  } catch (error) {
1320
1801
  if (!isTransportError(error) || !retryableChatError(error)) throw error;
1321
1802
  lastError = error;
1322
- if (attempt + 1 < runtime.retry_attempts) await sleep(runtime.retry_backoff_seconds * 1e3);
1803
+ if (attempt + 1 < runtime.retry_attempts) {
1804
+ progress?.({ event: "retry", attempt: attempt + 2, attempts: runtime.retry_attempts });
1805
+ await sleep(runtime.retry_backoff_seconds * 1e3);
1806
+ }
1323
1807
  }
1324
1808
  }
1325
1809
  if (response === null) throw lastError;
@@ -1354,10 +1838,26 @@ async function runLoop(spec, hooks, env, inputs) {
1354
1838
  const observations = hooks.observationStore ? hooks.observationStore(pageSize) : new ObservationStore(pageSize);
1355
1839
  let session;
1356
1840
  try {
1357
- session = spec.open ? await spec.open(workspace) : void 0;
1841
+ try {
1842
+ session = spec.open ? await spec.open(workspace) : void 0;
1843
+ } catch (error) {
1844
+ if (!isValidationError(error)) throw error;
1845
+ answer = `research adapter could not start: ${error.message}`;
1846
+ writeFailureResult(resultPath, answer);
1847
+ print(answer);
1848
+ return 1;
1849
+ }
1850
+ const root = session?.workspace ?? workspace;
1358
1851
  let parseFailures = 0;
1359
1852
  let exhausted = true;
1853
+ let repeats = 0;
1854
+ let groundingRepaired = false;
1855
+ let sourceRepaired = false;
1856
+ const seenOutputs = [];
1360
1857
  for (let turn = 0; turn < runtime.max_turns; turn += 1) {
1858
+ const at = { turn: turn + 1, maxTurns: runtime.max_turns };
1859
+ phaseAt = at;
1860
+ progress?.({ event: "model", ...at });
1361
1861
  let response;
1362
1862
  try {
1363
1863
  response = await callModel();
@@ -1381,6 +1881,7 @@ async function runLoop(spec, hooks, env, inputs) {
1381
1881
  exhausted = false;
1382
1882
  break;
1383
1883
  }
1884
+ progress?.({ event: "repair", ...at });
1384
1885
  messages.push({ role: "assistant", content: cpSlice(raw, 0, runtime.parse_repair_echo_chars) });
1385
1886
  messages.push({ role: "user", content: PARSE_REPAIR_NOTICE });
1386
1887
  if (!refit()) {
@@ -1393,6 +1894,7 @@ async function runLoop(spec, hooks, env, inputs) {
1393
1894
  if (action.action === "page") {
1394
1895
  const wantStep = Math.trunc(numberOr(action.step, 0));
1395
1896
  const wantPage = Math.trunc(numberOr(action.page, 1));
1897
+ progress?.({ event: "action", ...at, action });
1396
1898
  messages.push({ role: "assistant", content: echo(action) });
1397
1899
  messages.push({ role: "user", content: observations.message(wantStep, wantPage) });
1398
1900
  if (!refit()) {
@@ -1402,45 +1904,83 @@ async function runLoop(spec, hooks, env, inputs) {
1402
1904
  continue;
1403
1905
  }
1404
1906
  if (action.action === "finish") {
1405
- answer = trimSpace(String(action.answer ?? ""));
1907
+ const proposed = trimSpace(String(action.answer ?? ""));
1908
+ const missing = missingPaths(proposed, root);
1909
+ if (missing.length > 0 && !groundingRepaired) {
1910
+ groundingRepaired = true;
1911
+ progress?.({ event: "repair", ...at });
1912
+ messages.push({ role: "assistant", content: echo(action) });
1913
+ messages.push({ role: "user", content: groundingRepairNotice(missing, evidencePaths(seenOutputs, root)) });
1914
+ if (!refit()) {
1915
+ answer = proposed + groundingNote(missing);
1916
+ exhausted = false;
1917
+ break;
1918
+ }
1919
+ continue;
1920
+ }
1921
+ const evidence = spec.requireSource !== false && missing.length === 0 && !sourceRepaired && pathsIn(proposed).length === 0 ? evidencePaths(seenOutputs, root) : [];
1922
+ if (evidence.length > 0) {
1923
+ sourceRepaired = true;
1924
+ progress?.({ event: "repair", ...at });
1925
+ messages.push({ role: "assistant", content: echo(action) });
1926
+ messages.push({ role: "user", content: sourceRepairNotice(evidence) });
1927
+ if (!refit()) {
1928
+ answer = proposed;
1929
+ exhausted = false;
1930
+ break;
1931
+ }
1932
+ continue;
1933
+ }
1934
+ progress?.({ event: "finish", ...at });
1935
+ answer = missing.length > 0 ? proposed + groundingNote(missing) : proposed;
1406
1936
  exhausted = false;
1407
1937
  break;
1408
1938
  }
1409
1939
  const started = performance.now();
1410
- const call = session.toolCall(action, workspace, runtime);
1940
+ const call = session.toolCall(action, root, runtime);
1411
1941
  const signature = callSignature(call.signature);
1412
1942
  let observation;
1413
1943
  let status;
1944
+ let exitCode;
1414
1945
  if (seenCalls.has(signature)) {
1946
+ progress?.({ event: "action", ...at, action, repeat: true });
1415
1947
  observation = REPEAT_NOTICE;
1416
1948
  status = "repeat";
1949
+ repeats += 1;
1417
1950
  } else {
1951
+ progress?.({ event: "action", ...at, action });
1418
1952
  try {
1419
1953
  observation = hooks.runTool ? await hooks.runTool(call.record.arguments) : await call.run();
1420
1954
  status = "ok";
1421
1955
  } catch (error) {
1422
1956
  if (!isToolError(error)) throw error;
1423
- observation = `tool error: ${error.message}`;
1424
- status = "error";
1957
+ status = error instanceof ToolExitError ? error.status : "error";
1958
+ exitCode = error instanceof ToolExitError ? error.exitCode : void 0;
1959
+ observation = `${status === "empty" ? "tool search" : "tool error"}: ${error.message}${toolErrorHint(error.message)}`;
1425
1960
  }
1426
1961
  seenCalls.set(signature, calls.length + 1);
1427
1962
  }
1428
1963
  calls.push({
1429
1964
  ...call.record,
1430
1965
  status,
1966
+ ...exitCode === void 0 ? {} : { exit_code: exitCode },
1431
1967
  duration_ms: Math.round((performance.now() - started) * 1e3) / 1e3
1432
1968
  });
1433
1969
  messages.push({ role: "assistant", content: echo(action) });
1434
1970
  const remaining = runtime.max_turns - calls.length;
1435
1971
  const step = calls.length;
1436
1972
  observations.put(step, observation, call.ledger(status));
1973
+ seenOutputs.push(observation);
1437
1974
  messages.push({ role: "user", content: observations.message(step) + spec.remaining(remaining) });
1438
1975
  if (!refit()) {
1439
1976
  exhausted = false;
1440
1977
  break;
1441
1978
  }
1979
+ if (repeats >= 2) break;
1442
1980
  }
1443
1981
  if (exhausted) {
1982
+ progress?.({ event: "finalize", maxTurns: runtime.max_turns });
1983
+ phaseAt = { maxTurns: runtime.max_turns };
1444
1984
  messages.push({ role: "user", content: spec.exhausted });
1445
1985
  if (refit()) {
1446
1986
  try {
@@ -1448,6 +1988,8 @@ async function runLoop(spec, hooks, env, inputs) {
1448
1988
  const finalAction = spec.parseAction(content(response));
1449
1989
  if (finalAction.action !== "finish") throw new ValidationError("forced final response was not finish");
1450
1990
  answer = trimSpace(String(finalAction.answer ?? ""));
1991
+ const missing = missingPaths(answer, root);
1992
+ if (missing.length > 0) answer += groundingNote(missing);
1451
1993
  } catch (error) {
1452
1994
  if (!isTransportError(error) && !isValidationError(error)) throw error;
1453
1995
  let diagnostic;
@@ -1457,7 +1999,9 @@ async function runLoop(spec, hooks, env, inputs) {
1457
1999
  }
1458
2000
  }
1459
2001
  if (!answer) answer = failure ?? "agent stopped without a final answer";
2002
+ const report = spec.report ? await spec.report(session) : {};
1460
2003
  const result = {
2004
+ ...report,
1461
2005
  final_answer: answer,
1462
2006
  tool_calls: calls,
1463
2007
  usage,
@@ -1493,6 +2037,16 @@ async function runLoop(spec, hooks, env, inputs) {
1493
2037
  observations.close();
1494
2038
  }
1495
2039
  }
2040
+ function withBriefing(prompt, spec, workspace) {
2041
+ try {
2042
+ const briefing = spec.briefing?.(workspace);
2043
+ return briefing ? `${prompt}
2044
+
2045
+ ${briefing}` : prompt;
2046
+ } catch {
2047
+ return prompt;
2048
+ }
2049
+ }
1496
2050
 
1497
2051
  // src/research/proc.ts
1498
2052
  import { spawn, spawnSync } from "node:child_process";
@@ -1542,98 +2096,835 @@ function runProcess(argv, options) {
1542
2096
  });
1543
2097
  }
1544
2098
 
1545
- // src/research/prompts.ts
2099
+ // ../rust-core/src/model_catalog.json
2100
+ var model_catalog_default = {
2101
+ snapshot: "2026-11-01",
2102
+ measured_on: "Apple M2 Pro, 32 GB, Ollama 0.40.13, with other work running",
2103
+ 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.",
2104
+ jobs: [
2105
+ { job: "classify", title: "Classify, triage, yes/no", call: 'run_task {task:"decision"}', cookbook: "cookbooks/classify", picks: [
2106
+ { 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" },
2107
+ { 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" }
2108
+ ] },
2109
+ { job: "find-code", title: "Find the code for an issue", call: 'run_task {task:"embedding", nearest:{query}}', cookbook: "cookbooks/find-code", picks: [
2110
+ { 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" },
2111
+ { 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" }
2112
+ ] },
2113
+ { job: "duplicates", title: "Find duplicates", call: 'run_task {task:"embedding", nearest:{}}', cookbook: "cookbooks/duplicates", picks: [
2114
+ { tag: "nomic-embed-text:v1.5", role: "default", file_gb: 0.3, memory_gb: 1.4, quality: "measured", evidence: "8/12 real issue pairs" }
2115
+ ] },
2116
+ { job: "ocr", title: "Read text from an image", call: 'run_task {task:"vision", files:[path]}', cookbook: "cookbooks/ocr", picks: [
2117
+ { 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" },
2118
+ { 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" }
2119
+ ] },
2120
+ { job: "image-recognition", title: "Understand what an image shows", call: 'run_task {task:"vision", files:[path], prompt}', cookbook: "cookbooks/image-recognition", picks: [
2121
+ { 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" },
2122
+ { 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" },
2123
+ { 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" }
2124
+ ] },
2125
+ { job: "image-search", title: "Find an image by describing it", call: 'run_task {task:"embedding", files:[images], nearest:{query}}', cookbook: "cookbooks/image-search", picks: [
2126
+ { 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" }
2127
+ ] },
2128
+ { job: "speech-to-text", title: "Recording to text", call: 'audio {action:"transcribe", file}', cookbook: "cookbooks/speech-to-text", picks: [
2129
+ { 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" },
2130
+ { 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" }
2131
+ ] },
2132
+ { job: "text-to-speech", title: "Text to speech", call: 'audio {action:"speak", text}', cookbook: "cookbooks/text-to-speech", picks: [
2133
+ { 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" },
2134
+ { 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" },
2135
+ { 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" }
2136
+ ] },
2137
+ { job: "coding", title: "Small coding tasks", call: 'run_task {task:"coding", files:[path]}', cookbook: "cookbooks/coding", picks: [
2138
+ { 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" },
2139
+ { 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" },
2140
+ { 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" },
2141
+ { 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" }
2142
+ ] },
2143
+ { job: "research-agent", title: "Research a repository", call: 'delegate_research {adapter:"octocode" or "github"}', cookbook: "cookbooks/research-agent", picks: [
2144
+ { 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" },
2145
+ { 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" },
2146
+ { 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" }
2147
+ ] }
2148
+ ]
2149
+ };
2150
+
2151
+ // src/research/octocode-instructions.ts
2152
+ var OCTOCODE_LOCAL_INSTRUCTIONS = `Octocode is a read-only code research CLI: it finds code in a directory so you read a few exact lines, not whole files.
2153
+ Call: octocode <tool> '{"queries":[{...}]}' (1-5 queries per call; paths relative to the working directory)
2154
+ Result: lines of findings, then "#" notes. Exit 0 found, 1 nothing found, 2 bad input (the message names the field), 3 path not found.
2155
+ More: copy a printed {"cursor":"..."} as the whole input. No cursor means the result is complete.
2156
+
2157
+ structureSearch: see the layout or list files, when you do not know where to look.
2158
+ {"operation":"tree","path":".","maxDepth":2}
2159
+ {"operation":"files","path":".","include":["**/*.rs"],"view":"paths"}
2160
+ localSearch: find a word, name or error text, when you do not know the file.
2161
+ {"path":".","matchString":["memory_holders"],"view":"files"} which files
2162
+ {"path":".","matchString":["memory_holders","holders"]} matching lines with line numbers; several words mean any of them
2163
+ localFetch: read a file an earlier result named (src/a.rs below is a placeholder); ask for the least that answers.
2164
+ {"path":"src/a.rs","view":"symbols"} outline with line numbers
2165
+ {"path":"src/a.rs","ranges":[{"startLine":40,"endLine":90}]}
2166
+ {"path":"src/a.rs","matchString":["fn run"],"contextLines":{"before":2,"after":15}}
2167
+ astSearch: find code by shape, not by text. Use it to find where something is declared.
2168
+ {"operation":"symbols","path":".","symbolFilter":{"terms":["run"],"mode":"exact"}} declarations named run
2169
+ {"operation":"match","path":".","rule":{"pattern":"eval($X)"}} every call of this shape
2170
+ lspSearch: who defines, uses or calls a symbol; exact where text search only guesses. Take path and line from an earlier result, never guess them.
2171
+ {"path":"src/a.rs","symbolName":"run","line":35} its definition
2172
+ {"operation":"references","path":"src/a.rs","symbolName":"run","line":35,"view":"files"} where it is used
2173
+ {"operation":"callers","path":"src/a.rs","symbolName":"run","line":35} also callees, hover, documentSymbols
2174
+
2175
+ Work smart:
2176
+ 1. Narrow before reading: list files or matching lines first, then read only those ranges.
2177
+ 2. Try several words at once: put synonyms in one matchString, or several queries in one call.
2178
+ 3. Build and dependency folders are skipped already; add "testCode":"exclude" to skip tests.
2179
+ 4. Nothing found proves little: widen the words or the path before saying something does not exist. If lspSearch is empty, use localSearch.
2180
+ 5. Answer with file:line evidence, and stop as soon as the question is answered.
2181
+ `;
2182
+ var OCTOCODE_GITHUB_INSTRUCTIONS = `Octocode's GitHub tools research public and private repositories without cloning them: find a repo, its code, its history, and its packages.
2183
+ Call: octocode <tool> '{"queries":[{...}]}' (1-5 queries per call)
2184
+ Result: JSON with results[].data. Exit 0 found, 1 nothing found, 2 bad input (the message names the field), 3 not found, 4 not logged in (octocode auth login), 7 rate limited (wait).
2185
+ More: follow a printed next or {"cursor":"..."}; no next and no hasMore means the result is complete.
2186
+
2187
+ ghSearchRepo: find a repository when you do not know its owner or name.
2188
+ {"operation":"search","keywords":["local llm router"],"filters":{"language":"Rust"}}
2189
+ {"operation":"get","owner":"ollama","repo":"ollama"} one repo: description, stars, default branch
2190
+ artifactSearch: find a package's repository and versions.
2191
+ {"operation":"resolve","ecosystem":"npm","packageName":"zod"} also pypi, maven
2192
+ ghStructure: see a repo's layout; operation "refs" lists branches, "languages" its languages.
2193
+ {"operation":"tree","owner":"ollama","repo":"ollama","path":"server","maxDepth":2}
2194
+ ghSearchCode: find a word or name inside a repo when you do not know the file.
2195
+ {"owner":"ollama","repo":"ollama","keywords":["SystemOneRequest"]} returns paths, lines and a commitSha
2196
+ ghGetFileContent: read a file you found; ask for the least that answers.
2197
+ {"owner":"ollama","repo":"ollama","path":"api/types.go","view":"symbols"} outline
2198
+ {"owner":"ollama","repo":"ollama","path":"api/types.go","ranges":[{"startLine":600,"endLine":640}]}
2199
+ {"owner":"ollama","repo":"ollama","path":"api/types.go","matchString":["type SystemOneRequest"]}
2200
+ ghSearchHistory: find why something changed: pull requests, issues, commits.
2201
+ {"operation":"pullRequest","owner":"ollama","repo":"ollama","keywords":["systemone"]}
2202
+ {"operation":"commit","owner":"ollama","repo":"ollama","keywords":["decision"]} or operation "issue" with "filters":{"state":"open"}
2203
+ ghGetHistoryItem: read one pull request, or issue, you found.
2204
+ {"operation":"pullRequest","owner":"ollama","repo":"ollama","number":18606,"sections":["body","files"]}
2205
+
2206
+ Work smart:
2207
+ 1. Narrow: repo, then search, then read only the ranges you need. Never read whole files first.
2208
+ 2. Reuse what a result returns: its path, commitSha (as ref) and the next step it suggests.
2209
+ 3. Search words are exact code words, not sentences: one to three identifiers beat a question.
2210
+ 4. Nothing found proves little: try another word or check the repo name before saying it does not exist.
2211
+ 5. Answer with owner/repo, path:line or PR number as evidence, and stop as soon as the question is answered.
2212
+ `;
2213
+
2214
+ // src/local-prompts.ts
2215
+ var catalog = model_catalog_default;
2216
+ function picksOf(job) {
2217
+ return catalog.jobs.find((entry) => entry.job === job)?.picks.map((pick) => pick.tag) ?? [];
2218
+ }
2219
+ var cookbook = (name) => `docs/cookbooks/${name}.md`;
2220
+ var SOURCES = {
2221
+ miniCpmOcr: ["https://huggingface.co/openbmb/MiniCPM-OCR-1.0", "https://huggingface.co/openbmb/MiniCPM-OCR-1.0-gguf"],
2222
+ glmOcr: ["https://ollama.com/library/glm-ocr", "https://huggingface.co/zai-org/GLM-OCR"],
2223
+ lfm3Vl: ["https://huggingface.co/LiquidAI/LFM3-VL-2B", "https://huggingface.co/LiquidAI/LFM3-VL-2B-GGUF", "https://ollama.com/library/lfm3-vl"],
2224
+ lfm3Omni: ["https://huggingface.co/LiquidAI/LFM3-Omni-2B", "https://ollama.com/library/lfm3-omni"],
2225
+ embeddingGemma: ["https://huggingface.co/google/embeddinggemma-2", "https://ai.google.dev/gemma/docs/embeddinggemma/inference-embeddinggemma-with-sentence-transformers"],
2226
+ nomic: ["https://huggingface.co/nomic-ai/nomic-embed-text-v1.5"],
2227
+ strandsDecider: ["https://ollama.com/library/strands-decider", "https://raw.githubusercontent.com/strands-labs/strands-decider/main/README.md"],
2228
+ nimble: ["https://ollama.com/library/nimble-v3:latest"],
2229
+ bonsai9b: ["https://huggingface.co/prism-ml/Ternary-Bonsai-2-9B-gguf", "https://ollama.com/library/bonsai-2:9b"],
2230
+ bonsai27b: ["https://huggingface.co/prism-ml/Ternary-Bonsai-2-27B-gguf", "https://ollama.com/library/bonsai-2"],
2231
+ lfm3: ["https://huggingface.co/LiquidAI/LFM3-1.7B", "https://ollama.com/library/lfm3"],
2232
+ nemotronBolt: ["https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Bolt-E4B-4GB"],
2233
+ lfm3Audio: ["https://huggingface.co/LiquidAI/LFM3-Audio-2B", "https://ollama.com/library/lfm3-audio"],
2234
+ qwen38: ["https://huggingface.co/Qwen/Qwen3.8-27B", "https://ollama.com/library/qwen3.8"],
2235
+ qwen35: ["https://huggingface.co/Qwen/Qwen3.5-4B", "https://ollama.com/library/qwen3.5"],
2236
+ phonon: ["https://ollama.com/library/phonon:en"]
2237
+ };
2238
+ var EMBEDDING_PREFIXES = {
2239
+ embeddinggemma: {
2240
+ query: "task: search result | query: ",
2241
+ document: "title: none | text: ",
2242
+ codeQuery: "task: code retrieval | query: ",
2243
+ similarity: "task: sentence similarity | query: ",
2244
+ clustering: "task: clustering | query: ",
2245
+ classification: "task: classification | query: "
2246
+ },
2247
+ // A prefix is required; the card names clustering: for removing semantic duplicates.
2248
+ "nomic-embed-text": { query: "search_query: ", document: "search_document: ", clustering: "clustering: ", classification: "classification: " }
2249
+ };
2250
+ var GEMMA = EMBEDDING_PREFIXES.embeddinggemma;
2251
+ var NOMIC = EMBEDDING_PREFIXES["nomic-embed-text"];
2252
+ var OCR_PROMPTS = { text: "Text Recognition:", table: "Table Recognition:", formula: "Formula Recognition:" };
2253
+ var OCR_TRAINED = ["minicpm-ocr", "glm-ocr"];
2254
+ var OCR_PLAIN = {
2255
+ text: "Transcribe all text in the image exactly, line by line. Output only the text.",
2256
+ table: "Transcribe the table in the image as a Markdown table. Output only the table."
2257
+ };
2258
+ var ocrPrompt = (kind, model) => {
2259
+ const name = (model ?? "").toLowerCase().replace(/^(?:hf\.co|huggingface\.co)\/[^/]+\//, "");
2260
+ return model === void 0 || OCR_TRAINED.some((family) => name.startsWith(family)) ? OCR_PROMPTS[kind] : OCR_PLAIN[kind];
2261
+ };
2262
+ var EDIT_FORMAT = `Return each change to an existing file as the file's path (copied from its "--- file:" line), then:
2263
+ <<<<<<< SEARCH
2264
+ exact current lines (5 to 30, unique in the file, indentation exact)
2265
+ =======
2266
+ replacement lines
2267
+ >>>>>>> REPLACE
2268
+ 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.`;
2269
+ var NO_TOOLS = "You have no tools and cannot open files: everything you need is in this message. Answer in plain text.";
2270
+ var WORKFLOWS = {
2271
+ "read-text": {
2272
+ job: "ocr",
2273
+ title: "Read all text in an image, exactly",
2274
+ input: "files: the images",
2275
+ prompt: (_request, model) => ocrPrompt("text", model)
2276
+ },
2277
+ "read-table": {
2278
+ job: "ocr",
2279
+ title: "Read a table in an image as Markdown",
2280
+ input: "files: the images",
2281
+ prompt: (_request, model) => ocrPrompt("table", model)
2282
+ },
2283
+ "read-fields": {
2284
+ job: "ocr",
2285
+ title: "Pull named fields from a receipt, invoice or form image as JSON",
2286
+ input: "files: the images; prompt: the field names",
2287
+ 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.`
2288
+ },
2289
+ "answer-image": {
2290
+ job: "image-recognition",
2291
+ title: "Answer a question about an image",
2292
+ input: "files: the images; prompt: the question",
2293
+ system: `Answer the question about the image.
2294
+ 1. Find the part of the image the question is about.
2295
+ 2. Read any text there exactly as printed.
2296
+ 3. Answer with just the value or label, or one or two sentences.
2297
+ If the image does not show it, answer "not visible". Never guess at what is cut off.`
2298
+ },
2299
+ "describe-screen": {
2300
+ job: "image-recognition",
2301
+ title: "Describe a screenshot for a developer: text, errors, state",
2302
+ input: "files: the screenshot; prompt: what to look for (optional)",
2303
+ system: `You describe a software screenshot for a developer, in short bullets, in this order:
2304
+ - the app or page shown;
2305
+ - headings and main text, quoted exactly;
2306
+ - any error, warning or empty state, quoted exactly;
2307
+ - the state of the controls that matter (selected, disabled, filled in);
2308
+ - anything that looks broken or misaligned.
2309
+ Describe only what is visible.`
2310
+ },
2311
+ "change-code": {
2312
+ job: "coding",
2313
+ title: "Make one small code change",
2314
+ input: "files: the file to change; prompt: the change",
2315
+ system: `${NO_TOOLS}
2316
+ You make one small code change.
2317
+ 1. Find where the change belongs in the code shown.
2318
+ 2. Follow the file's existing names, style and error handling.
2319
+ 3. Change as few lines as the task needs; touch nothing else.
2320
+ ${EDIT_FORMAT}
2321
+ Explain nothing unless asked. If the task is a question, answer it in a few lines.`
2322
+ },
2323
+ "fix-bug": {
2324
+ prefer: "capable",
2325
+ job: "coding",
2326
+ title: "Fix a bug with the smallest change",
2327
+ input: "files: the suspect code; prompt: the error message or symptom",
2328
+ system: `${NO_TOOLS}
2329
+ You fix one bug with the smallest change.
2330
+ 1. Read the error or symptom, then the code shown.
2331
+ 2. Name the cause in one sentence, quoting the line that causes it.
2332
+ 3. Change only what the fix needs.
2333
+ Answer with the cause line, then the edits.
2334
+ ${EDIT_FORMAT}
2335
+ If the code shown cannot cause the symptom, say so and name the file or function to look at instead.`
2336
+ },
2337
+ implement: {
2338
+ prefer: "capable",
2339
+ job: "coding",
2340
+ title: "Add a small feature to existing code",
2341
+ input: "files: the code; prompt: the feature",
2342
+ system: `${NO_TOOLS}
2343
+ You add one small feature to the code shown.
2344
+ 1. Find the function or place it belongs; reuse helpers the file already has.
2345
+ 2. Follow the file's names, style and error handling.
2346
+ 3. Handle the empty and error cases the feature can meet.
2347
+ ${EDIT_FORMAT}
2348
+ End with one line: what to test.`
2349
+ },
2350
+ "write-tests": {
2351
+ prefer: "capable",
2352
+ job: "coding",
2353
+ title: "Write unit tests for the code shown",
2354
+ input: "files: the code (and an existing test, for style); prompt: what to cover (optional)",
2355
+ system: `${NO_TOOLS}
2356
+ You write unit tests for the code shown; you never change that code.
2357
+ 1. List the behaviours: the normal case, edge cases (empty, zero, missing, boundary) and one failure case.
2358
+ 2. Use the test framework and import style the project already uses; copy them from the files shown.
2359
+ 3. Write one small test per behaviour, named after it, with literal expected values.
2360
+ Answer with the test file's path on one line, then the whole file in one fenced block.`
2361
+ },
2362
+ "review-code": {
2363
+ prefer: "capable",
2364
+ job: "coding",
2365
+ title: "Find real defects in code (not style)",
2366
+ input: "files: the code; prompt: what to focus on (optional)",
2367
+ system: `${NO_TOOLS}
2368
+ 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.
2369
+ For each defect write one line: path:line - the problem - the fix.
2370
+ Report at most 5, the surest first. If you find none, answer "no defects found". Never invent a line you did not see.`
2371
+ },
2372
+ "explain-code": {
2373
+ job: "coding",
2374
+ title: "Explain code to someone about to change it",
2375
+ input: "files: the code; prompt: what to explain (optional)",
2376
+ system: `${NO_TOOLS}
2377
+ Explain the code shown to a developer who is about to change it, in at most 6 bullets:
2378
+ 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.
2379
+ Name functions and variables exactly.`
2380
+ },
2381
+ "summarize-log": {
2382
+ think: false,
2383
+ prefer: "capable",
2384
+ job: "summarize",
2385
+ title: "Summarize a log: errors grouped, counted, first failure",
2386
+ input: "files: the log; prompt: what you are investigating (optional)",
2387
+ system: `${NO_TOOLS}
2388
+ You read a log for a developer. Report:
2389
+ 1. Errors and warnings, grouped by message, each with its count and first and last timestamp.
2390
+ 2. The first error in time order, quoted exactly.
2391
+ 3. The likely cause, only if the log shows it.
2392
+ Quote messages exactly, skip routine lines, at most 10 bullets.`
2393
+ },
2394
+ "summarize-text": {
2395
+ think: false,
2396
+ job: "summarize",
2397
+ title: "Summarize text for someone who will act on it",
2398
+ input: "files: the text; prompt: what you need from it (optional)",
2399
+ system: `${NO_TOOLS}
2400
+ 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.
2401
+ Quote numbers, names, versions and paths exactly. Write "not stated" when the text does not say; never guess. No introduction or closing.`
2402
+ },
2403
+ "summarize-diff": {
2404
+ think: false,
2405
+ prefer: "capable",
2406
+ job: "summarize",
2407
+ title: "Summarize a diff for a reviewer: changes and risks",
2408
+ input: "files: the diff or patch",
2409
+ system: `${NO_TOOLS}
2410
+ You summarize a code diff for a reviewer, in at most 8 bullets:
2411
+ 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.
2412
+ Name files, functions and settings exactly.`
2413
+ },
2414
+ "extract-json": {
2415
+ think: false,
2416
+ job: "summarize",
2417
+ title: "Extract named fields from text into JSON",
2418
+ input: "files: the text; prompt: the field names (or a JSON schema)",
2419
+ system: `${NO_TOOLS}
2420
+ Extract the fields the request names from the text into one JSON object.
2421
+ Use the values exactly as written in the text; use null for a field the text does not give; never invent a value.
2422
+ Output only the JSON object.`,
2423
+ format: "json"
2424
+ }
2425
+ };
2426
+ var WORKFLOW_NAMES = Object.keys(WORKFLOWS);
2427
+ var BONSAI_9B = {
2428
+ think: false,
2429
+ options: { temperature: 0.7, top_p: 0.8, top_k: 20, min_p: 0, presence_penalty: 1.5, repeat_penalty: 1 },
2430
+ 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.",
2431
+ sources: SOURCES.bonsai9b
2432
+ };
2433
+ var qwenNonThinking = (sources) => ({
2434
+ think: false,
2435
+ options: { temperature: 0.7, top_p: 0.8, top_k: 20, min_p: 0, presence_penalty: 1.5, repeat_penalty: 1 },
2436
+ 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.",
2437
+ sources
2438
+ });
2439
+ var CHAT_MODELS = {
2440
+ lfm3: {
2441
+ options: { temperature: 0.1, top_k: 50, repeat_penalty: 1.1 },
2442
+ notes: "Thinks by default; its card calls thinking off inconsistent, so it stays on. Not for agentic coding or multi-step reasoning.",
2443
+ sources: SOURCES.lfm3
2444
+ },
2445
+ "nvidia-nemotron-3.5-bolt": {
2446
+ options: { temperature: 1, top_p: 0.95 },
2447
+ // `ollama show` of the hf.co Q4_K_M tag, 2026-11-08: no thinking capability, and the template
2448
+ // defaults enable_thinking to false.
2449
+ 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.",
2450
+ sources: SOURCES.nemotronBolt
2451
+ },
2452
+ "bonsai-2": BONSAI_9B,
2453
+ // The card documents the same instruct (non-thinking) preset as the 9B; Ollama lists think
2454
+ // values false, low, medium and xhigh for this tag.
2455
+ "bonsai-2:27b": {
2456
+ ...BONSAI_9B,
2457
+ 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.",
2458
+ sources: SOURCES.bonsai27b
2459
+ },
2460
+ "qwen3.8": qwenNonThinking(SOURCES.qwen38),
2461
+ "qwen3.5": qwenNonThinking(SOURCES.qwen35)
2462
+ };
2463
+ var CHAT_SOURCES = [...SOURCES.lfm3, ...SOURCES.nemotronBolt, ...SOURCES.bonsai9b, ...SOURCES.bonsai27b, ...SOURCES.qwen38, ...SOURCES.qwen35];
2464
+ var LOCAL_JOBS = {
2465
+ classify: {
2466
+ job: "classify",
2467
+ task: "decision",
2468
+ catalogJob: "classify",
2469
+ defaultPicks: picksOf("classify"),
2470
+ models: {
2471
+ "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 },
2472
+ "nimble-v3": { notes: "Documents num_ctx 32768 (routing sizes it here); CC BY-NC 4.0, noncommercial.", sources: SOURCES.nimble }
2473
+ },
2474
+ 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.",
2475
+ sources: [...SOURCES.strandsDecider, ...SOURCES.nimble, cookbook("classify")]
2476
+ },
2477
+ "find-code": {
2478
+ job: "find-code",
2479
+ task: "embedding",
2480
+ catalogJob: "find-code",
2481
+ defaultPicks: picksOf("find-code"),
2482
+ needs: "nearest.query: the issue, or what the code does",
2483
+ prefixes: { embeddinggemma: { query: GEMMA.codeQuery, document: GEMMA.document }, "nomic-embed-text": { query: NOMIC.query, document: NOMIC.document } },
2484
+ // Measured 2026-11-08 on the find-code set (10 questions, 1,039 chunks): code retrieval with
2485
+ // title: none 8 first / 9 in top 3; search result 8/8; code retrieval with the file as title 7/9.
2486
+ 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.",
2487
+ sources: [...SOURCES.embeddingGemma, ...SOURCES.nomic, cookbook("find-code")]
2488
+ },
2489
+ duplicates: {
2490
+ job: "duplicates",
2491
+ task: "embedding",
2492
+ catalogJob: "duplicates",
2493
+ defaultPicks: picksOf("duplicates"),
2494
+ // Symmetric: every item, the query included, takes the same prefix.
2495
+ prefixes: {
2496
+ // The measured 8/12 used search_document:; the card's clustering: is documented but unmeasured here.
2497
+ "nomic-embed-text": { query: NOMIC.document, document: NOMIC.document },
2498
+ embeddinggemma: { query: GEMMA.similarity, document: GEMMA.similarity }
2499
+ },
2500
+ 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.",
2501
+ sources: [...SOURCES.nomic, ...SOURCES.embeddingGemma, cookbook("duplicates")]
2502
+ },
2503
+ ocr: {
2504
+ job: "ocr",
2505
+ task: "vision",
2506
+ catalogJob: "ocr",
2507
+ defaultPicks: picksOf("ocr"),
2508
+ prompt: ({ model }) => ocrPrompt("text", model),
2509
+ // Both publishers' Ollama Modelfiles set temperature 0; the hf.co MiniCPM tag ships no parameters.
2510
+ models: {
2511
+ "minicpm-ocr": {
2512
+ options: { temperature: 0 },
2513
+ notes: "Chinese and English; tables come back as HTML. Its Modelfile sets num_ctx 8192.",
2514
+ sources: [...SOURCES.miniCpmOcr, "https://raw.githubusercontent.com/OpenBMB/MiniCPM-OCR/main/docs/ollama_deploy.md"]
2515
+ },
2516
+ "glm-ocr": {
2517
+ options: { temperature: 0 },
2518
+ notes: "Also documents Figure Recognition:.",
2519
+ sources: [...SOURCES.glmOcr, "https://github.com/zai-org/GLM-OCR/blob/main/examples/ollama-deploy/README.md"]
2520
+ }
2521
+ },
2522
+ 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.",
2523
+ sources: [...SOURCES.miniCpmOcr, ...SOURCES.glmOcr, cookbook("ocr")]
2524
+ },
2525
+ "image-recognition": {
2526
+ job: "image-recognition",
2527
+ task: "vision",
2528
+ catalogJob: "image-recognition",
2529
+ defaultPicks: picksOf("image-recognition"),
2530
+ needs: "prompt: your question about the image",
2531
+ systemPrompt: WORKFLOWS["answer-image"].system,
2532
+ models: {
2533
+ "lfm3-vl": {
2534
+ options: { temperature: 0.1, top_p: 1, top_k: 50, min_p: 0, repeat_penalty: 1.1 },
2535
+ 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.",
2536
+ sources: SOURCES.lfm3Vl
2537
+ },
2538
+ "lfm3-omni": {
2539
+ systemPrompt: "Answer questions about the provided image. Base every answer only on what can be seen.",
2540
+ options: { temperature: 0 },
2541
+ notes: "Non-reasoning: never send think:true. Up to 2,048 tokens per image. The card gives 32K context; the Ollama tag advertises 128K.",
2542
+ sources: SOURCES.lfm3Omni
2543
+ },
2544
+ "bonsai-2": BONSAI_9B,
2545
+ "qwen3.8": qwenNonThinking(SOURCES.qwen38),
2546
+ "qwen3.5": qwenNonThinking(SOURCES.qwen35)
2547
+ },
2548
+ notes: 'Ask a specific question, not "describe this". Tested on drawn images only.',
2549
+ sources: [...SOURCES.lfm3Vl, ...SOURCES.lfm3Omni, ...SOURCES.bonsai9b, cookbook("image-recognition")]
2550
+ },
2551
+ "image-search": {
2552
+ job: "image-search",
2553
+ task: "embedding",
2554
+ catalogJob: "image-search",
2555
+ defaultPicks: picksOf("image-search"),
2556
+ needs: "nearest.query: a description of the image",
2557
+ prefixes: { embeddinggemma: { query: GEMMA.query, document: GEMMA.document } },
2558
+ notes: "Images are embedded bare: the prefixes apply to text only. Open the top match.",
2559
+ sources: [...SOURCES.embeddingGemma, cookbook("image-search")]
2560
+ },
2561
+ coding: {
2562
+ job: "coding",
2563
+ task: "coding",
2564
+ catalogJob: "coding",
2565
+ defaultPicks: picksOf("coding"),
2566
+ needs: "prompt: the change to make",
2567
+ systemPrompt: WORKFLOWS["change-code"].system,
2568
+ models: CHAT_MODELS,
2569
+ notes: "The answer is SEARCH/REPLACE blocks: apply one only where its SEARCH text matches exactly once, then run the tests.",
2570
+ sources: [...CHAT_SOURCES, cookbook("coding")]
2571
+ },
2572
+ summarize: {
2573
+ job: "summarize",
2574
+ task: "completion",
2575
+ catalogJob: "coding",
2576
+ defaultPicks: picksOf("coding"),
2577
+ prompt: () => "Summarize the text below.",
2578
+ systemPrompt: WORKFLOWS["summarize-text"].system,
2579
+ models: CHAT_MODELS,
2580
+ notes: "The coding picks: the same chat models apply (docs/cookbooks/batch-text.md). Check every number and name you reuse.",
2581
+ sources: [...CHAT_SOURCES, cookbook("batch-text")]
2582
+ }
2583
+ };
2584
+ var TOOL_JOBS = {
2585
+ "speech-to-text": {
2586
+ tool: "audio",
2587
+ defaultPicks: picksOf("speech-to-text"),
2588
+ // The audio tool calls Ollama's transcription endpoint, which builds the model's prompt; these
2589
+ // are lfm3-audio's documented prompts for a direct chat call.
2590
+ systemPrompts: {
2591
+ transcribe: (language) => language ? `Perform ASR in ${language}.` : "Perform ASR.",
2592
+ translate: (source, target) => `Translate the following speech from ${source} to ${target} in text form.`
2593
+ },
2594
+ notes: "WAV input only. Numbers and names are the usual misses; translation works best to or from English.",
2595
+ sources: [...SOURCES.lfm3Audio, cookbook("speech-to-text")]
2596
+ },
2597
+ "text-to-speech": {
2598
+ tool: "audio",
2599
+ defaultPicks: picksOf("text-to-speech"),
2600
+ notes: "Send plain text: Markdown marks may be read aloud. One voice (default); 24 kHz mono WAV.",
2601
+ sources: [...SOURCES.phonon, cookbook("text-to-speech")]
2602
+ },
2603
+ "research-agent": {
2604
+ tool: "delegate_research",
2605
+ defaultPicks: picksOf("research-agent"),
2606
+ notes: "The adapters' system prompts are below. Discard an escalate verdict and verify the citations.",
2607
+ sources: [cookbook("research-agent")]
2608
+ },
2609
+ sweep: {
2610
+ tool: "sweep",
2611
+ defaultPicks: picksOf("classify"),
2612
+ /** The decision state of one chunk, and the explain step's user message for a confirmed flag. */
2613
+ state: (label, text) => `Source: ${label}
2614
+
2615
+ ${text}`,
2616
+ explain: (prompt, label, text) => `${prompt}
2617
+
2618
+ Source: ${label}
2619
+
2620
+ ${text}`,
2621
+ // FreeLlama's own setting for the short explain note, not a model's documented value.
2622
+ explainControls: { think: false, temperature: 0 },
2623
+ notes: 'One yes/no question per concern, phrased about one chunk ("Does this code ..."); a noul answer flags at p >= 0.5.',
2624
+ sources: [cookbook("sweep")]
2625
+ }
2626
+ };
2627
+ var ACTION_PROTOCOL = (example) => `You do not run a shell: ignore the "Call: octocode ..." form, the exit codes and the login hint above. Put "cursor" inside a query to read more of a tool's result; use the page action only for stored output of your own earlier step.
2628
+ Return exactly one JSON object per turn, and nothing else:
2629
+ Run a tool, in place of the shell command above: {"action":"octocode","tool":"${example}","queries":[{...}]}
2630
+ Read another page of an earlier step's stored output: {"action":"page","step":2,"page":2}
2631
+ Finish: {"action":"finish","answer":"concise answer with its evidence"}
2632
+ You see page 1 of each result and the total; a shortened observation is recoverable with page, so never re-run a tool for it.
2633
+ Most questions need 2 to 6 calls. An invalid action is rejected and costs a turn. Never edit or write anything.`;
1546
2634
  function octocodeSystemPrompt(workspace) {
1547
- return "You are a local code-research agent in a read-only workspace at " + workspace + '. Read or search files only through the `octocode` local tools below. Return exactly one JSON object per turn.\n\nTools (call as {"action":"octocode","tool":"<name>","queries":{...}}):\n\nlocalViewStructure - browse a directory tree, no content loaded; cheapest first orientation step.\n path (string, required, absolute), maxDepth (int), recursive (bool), filesOnly (bool),\n directoriesOnly (bool), pattern (glob/substring filter).\n\nlocalFindFiles - find files/dirs by name, glob, regex, or type; returns paths only, not content.\n path (string, required, absolute), names (array of globs), pathPattern (glob over full path),\n regex (basename regex), entryType ("f"|"d"), excludeDir (array, e.g. ["node_modules",".git"]).\n\nlocalSearchCode - search file contents for text/regex; returns file+line matches. Your main tool.\n path (string, required, absolute), keywords (string; literal or regex search term),\n mode ("paginated"|"discovery"|"detailed"), include/exclude (glob arrays),\n caseInsensitive (bool), maxFiles (int).\n Do NOT pass mode "structural" with keywords \u2014 structural (AST) search takes `pattern`/`rule`\n instead and hard-errors on keywords, costing you a turn for nothing.\n\nlocalGetFileContent - read one file or a line range/matched slice of it.\n path (string, required, absolute), fullContent (bool; small files only), startLine +\n endLine (ints, both required together), matchString (anchor text/regex), minify\n ("none"|"standard"|"symbols" \u2014 "symbols" gives a cheap outline first).\n\nlspGetSemantics - LSP semantic queries: definitions, references, callers/callees, symbol outline.\n uri (string, required, absolute path), type ("definition"|"references"|"callers"|\n "callees"|"documentSymbols"|"hover"), symbolName (exact name), lineHint (int from a prior\n search result \u2014 never guess it).\n An empty result is ambiguous: indexing or unavailable analysis can produce no symbols.\n Retry once or use localSearchCode; an empty LSP result alone does not prove absence.\n\nRead another page of an earlier step: {"action":"page","step":2,"page":2}\nFull tool output is stored in pages; you see page 1 and the total. Context fitting can shorten\nan observation in the conversation. Request its stored page to recover the evidence without\nre-running the tool. Claim "not found" only after checking the relevant search scope and pages.\n\nFinish with: {"action":"finish","answer":"concise final answer with repository-relative evidence"}\n\nThese are the complete action schemas. Use only `octocode`, `page`, or `finish`; every shown field\nis required with the shown type, and `tool` must be one of the five names above. An invalid shape\nis rejected and costs one bounded repair turn.\n\nSCOPE YOUR SEARCHES. Pass excludeDir/exclude on every search in a real workspace \u2014\n["node_modules","target",".venv","dist","build",".git","vendor","__pycache__",".octocode"] \u2014 or\nvendored and generated files will bury the answer. For implementation questions, prefer src/,\npackages/*/src/ and lib/ before fixtures, mocks, or examples. If the question targets tests or examples,\ninspect those files. Name the file that supplies the answer. Absence of a match is weak evidence: widen the pattern before concluding\nsomething does not exist.\nFor a production default, find its declaration before relying on test occurrences. Declarations look like\n`default=`, `unwrap_or(`, `const `, `static `, or a settings schema initializer.\nFor a test-specific default or fixture, inspect its test declaration.\n\nAll paths you pass must resolve inside the workspace; relative paths are resolved against the\nworkspace root automatically. Orient cheap (localViewStructure) before reading in\nfull. Be decisive: most tasks need 2-6 tool calls. Never edit or write files \u2014 you only have\nread-only tools. Call finish as soon as the requested facts are established.';
2635
+ return `You are a local code-research agent in a read-only workspace at ${workspace}. Paths are relative to it.
2636
+
2637
+ ${OCTOCODE_LOCAL_INSTRUCTIONS}
2638
+ ${ACTION_PROTOCOL("localSearch")}`;
2639
+ }
2640
+ var GH_ACTION = `You may also run the GitHub CLI, read-only, as {"action":"gh","args":[...]} (arguments as an array; no shell):
2641
+ {"action":"gh","args":["search","code","SystemOneRequest","--repo","ollama/ollama"]} finds code; add "--filename","types.go" or "--language","go" to narrow it.
2642
+ {"action":"gh","args":["api","repos/ollama/ollama/contents/api/types.go","--jq",".content"]} reads a file (base64), or use ghGetFileContent for decoded text.
2643
+ {"action":"gh","args":["repo","view","colinhacks/zod","--json","name,description,primaryLanguage"]}, ["pr","view","18606","--repo","ollama/ollama"], ["issue","list","--repo","owner/repo","--search","keyword"], ["release","list","--repo","owner/repo"].
2644
+ Only search, repo view/list, pr view/list/diff/checks, issue view/list, release view/list and api (GET) run. Finish with the direct answer first, then "Source: owner/repo/path" or the PR or issue URL.`;
2645
+ var CODER_SYSTEM_PROMPT = [
2646
+ "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.",
2647
+ "",
2648
+ "Actions, in order of preference:",
2649
+ '{"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.',
2650
+ '{"action":"read","path":"src/config.ts","start":40,"end":70} shows numbered lines of one file.',
2651
+ '{"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.',
2652
+ '{"action":"write","path":"src/limits.ts","content":"export const MAX = 10;\\n"} creates a file, or replaces a whole small file.',
2653
+ '{"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.',
2654
+ '{"action":"list","path":"src"} shows files and folders two levels deep.',
2655
+ '{"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.',
2656
+ '{"action":"page","step":2,"page":2} shows the next page of a long earlier result.',
2657
+ '{"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.',
2658
+ "",
2659
+ "How to work:",
2660
+ "1. Search for the names in the task, then read the lines you will change.",
2661
+ "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.",
2662
+ "3. Run an allowed command to test the change. If it fails, read the error, fix the code and run it again.",
2663
+ "4. Finish with what changed, why, and the last test result. With no allowed command, say the change is untested.",
2664
+ "Keep results small: read line ranges, not whole files, and end long shell output with | head -n 40."
2665
+ ].join("\n");
2666
+ function githubSystemPrompt() {
2667
+ return `You are a code-research agent that answers questions about GitHub repositories and packages, read-only.
2668
+
2669
+ ${OCTOCODE_GITHUB_INSTRUCTIONS}
2670
+ ${ACTION_PROTOCOL("ghSearchCode")}
2671
+ ${GH_ACTION}`;
2672
+ }
2673
+ 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.";
2674
+ var AGENT_GUIDE = `Use FreeLlama to run bounded work on local models and keep it out of your context.
2675
+
2676
+ 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.
2677
+
2678
+ 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.
2679
+
2680
+ 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".
2681
+
2682
+ Coding: the answer is SEARCH/REPLACE blocks. ${APPLY_EDITS}
2683
+
2684
+ 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].
2685
+
2686
+ 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.`;
2687
+
2688
+ // src/research/workspace-overview.ts
2689
+ import { readdirSync as readdirSync2 } from "node:fs";
2690
+ import path6 from "node:path";
2691
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", "target", ".git", "dist", "build", ".venv", "venv", "vendor", "__pycache__", ".octocode", "site-packages", "coverage"]);
2692
+ var MAX_ENTRIES = 2e4;
2693
+ var MAX_DEPTH = 8;
2694
+ var SOURCE_DIRS = /* @__PURE__ */ new Set(["src", "lib", "app", "source", "pkg", "cmd", "internal", "crates"]);
2695
+ function workspaceOverview(root) {
2696
+ let top;
2697
+ try {
2698
+ top = readdirSync2(root, { withFileTypes: true });
2699
+ } catch {
2700
+ return "";
2701
+ }
2702
+ const extensions = /* @__PURE__ */ new Map();
2703
+ const sourceRoots = /* @__PURE__ */ new Map();
2704
+ let files = 0;
2705
+ let seen = 0;
2706
+ const walk = (dir, depth, sourceRoot) => {
2707
+ if (depth > MAX_DEPTH || seen > MAX_ENTRIES) return;
2708
+ let entries;
2709
+ try {
2710
+ entries = readdirSync2(dir, { withFileTypes: true });
2711
+ } catch {
2712
+ return;
2713
+ }
2714
+ for (const entry of entries) {
2715
+ if (++seen > MAX_ENTRIES) return;
2716
+ if (entry.isDirectory()) {
2717
+ if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue;
2718
+ const child = path6.join(dir, entry.name);
2719
+ walk(child, depth + 1, sourceRoot ?? (SOURCE_DIRS.has(entry.name) ? path6.relative(root, child) : void 0));
2720
+ } else if (entry.isFile()) {
2721
+ files += 1;
2722
+ const extension = path6.extname(entry.name).toLowerCase() || "(none)";
2723
+ extensions.set(extension, (extensions.get(extension) ?? 0) + 1);
2724
+ if (sourceRoot) sourceRoots.set(sourceRoot, (sourceRoots.get(sourceRoot) ?? 0) + 1);
2725
+ }
2726
+ }
2727
+ };
2728
+ walk(root, 0);
2729
+ 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);
2730
+ const types = [...extensions.entries()].sort((left, right) => right[1] - left[1]).slice(0, 8).map(([extension, count]) => `${extension} ${count}`);
2731
+ const sources = [...sourceRoots.entries()].sort((left, right) => right[1] - left[1]).slice(0, 8).map(([dir, count]) => `${dir}/ (${count})`);
2732
+ 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.";
2733
+ }
2734
+
2735
+ // src/research/gh-action.ts
2736
+ var READ_ONLY = {
2737
+ search: /* @__PURE__ */ new Set(["code", "repos", "issues", "prs", "commits"]),
2738
+ repo: /* @__PURE__ */ new Set(["view", "list"]),
2739
+ pr: /* @__PURE__ */ new Set(["view", "list", "diff", "checks"]),
2740
+ issue: /* @__PURE__ */ new Set(["view", "list"]),
2741
+ release: /* @__PURE__ */ new Set(["view", "list"]),
2742
+ api: /* @__PURE__ */ new Set()
2743
+ };
2744
+ var FORBIDDEN = /^(?:-X|--method|-f|-F|--field|--raw-field|--input|-H|--header|-w|--web|--browser|-e|--edit|--hostname|--paginate-all)(?:=|$)/;
2745
+ var MAX_ARGS = 40;
2746
+ function validateGhArgs(args) {
2747
+ if (!Array.isArray(args) || args.length === 0 || args.length > MAX_ARGS || !args.every((arg) => typeof arg === "string" && !arg.includes("\n"))) {
2748
+ throw new ValidationError(`gh action requires "args": an array of 1-${MAX_ARGS} strings, e.g. ["search","code","SystemOneRequest","--repo","ollama/ollama"]`);
2749
+ }
2750
+ const words = args;
2751
+ const [group, sub] = words;
2752
+ const allowed = READ_ONLY[group];
2753
+ if (!allowed) throw new ValidationError(`gh ${group} is not allowed; read-only commands: search, repo view/list, pr view/list/diff/checks, issue view/list, release view/list, api (GET)`);
2754
+ if (allowed.size > 0 && !allowed.has(sub ?? "")) {
2755
+ throw new ValidationError(`gh ${group} ${sub ?? ""} is not read-only; allowed: ${[...allowed].map((name) => `gh ${group} ${name}`).join(", ")}`);
2756
+ }
2757
+ const bad = words.find((word) => FORBIDDEN.test(word));
2758
+ if (bad) throw new ValidationError(`gh flag ${bad} is not allowed: the gh action only reads`);
2759
+ if (group === "api" && (!sub || sub.startsWith("-") || /graphql/i.test(sub))) {
2760
+ throw new ValidationError("gh api needs a REST path such as repos/ollama/ollama/contents/api/types.go (GET only; no graphql)");
2761
+ }
2762
+ return words;
2763
+ }
2764
+ async function runGh(args, cwd, timeoutSeconds) {
2765
+ const result = await runProcess(["gh", ...validateGhArgs(args)], {
2766
+ cwd,
2767
+ // gh reads its login from the operator's config and keychain; nothing prompts.
2768
+ env: { ...process.env, GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", NO_COLOR: "1", GH_PAGER: "cat" },
2769
+ timeoutSeconds
2770
+ });
2771
+ const output = trimSpace(result.stdout);
2772
+ if (result.returncode !== 0) throw new ToolExitError(result.returncode, `${output}
2773
+ ${trimSpace(result.stderr)}`.trim());
2774
+ return output || "(no results)";
2775
+ }
2776
+
2777
+ // src/research/entry.ts
2778
+ import path7 from "node:path";
2779
+ import { pathToFileURL } from "node:url";
2780
+ function isEntry(moduleUrl, name, script = process.argv[1]) {
2781
+ if (!script || moduleUrl !== pathToFileURL(script).href) return false;
2782
+ return path7.basename(script).replace(/\.[cm]?[jt]s$/, "") === name;
1548
2783
  }
1549
2784
 
1550
2785
  // src/research/octocode-agent.ts
1551
2786
  var OCTOCODE_TOOLS = /* @__PURE__ */ new Set([
1552
- "localViewStructure",
1553
- "localFindFiles",
1554
- "localSearchCode",
1555
- "localGetFileContent",
1556
- "lspGetSemantics"
2787
+ "structureSearch",
2788
+ "localSearch",
2789
+ "localFetch",
2790
+ "astSearch",
2791
+ "lspSearch"
1557
2792
  ]);
1558
- var PATH_KEYS = ["path", "uri"];
1559
- var OCTOCODE_PACKAGE = process.env.FREELLAMA_AGENT_OCTOCODE_PACKAGE ?? "octocode@19.1.0";
1560
- var SORTED_TOOLS = [...OCTOCODE_TOOLS].sort().join(", ");
2793
+ var GITHUB_TOOLS = /* @__PURE__ */ new Set([
2794
+ "ghSearchRepo",
2795
+ "artifactSearch",
2796
+ "ghStructure",
2797
+ "ghSearchCode",
2798
+ "ghGetFileContent",
2799
+ "ghSearchHistory",
2800
+ "ghGetHistoryItem"
2801
+ ]);
2802
+ var MAX_QUERIES = 5;
2803
+ var OCTOCODE_BIN = process.env.FREELLAMA_AGENT_OCTOCODE_BIN ?? "octocode";
1561
2804
  function safeResolve(root, value) {
1562
2805
  const candidate = resolvePath(joinPath(root, value));
1563
2806
  if (escapesRoot(candidate, root)) throw new ValidationError(`path escapes benchmark workspace: ${value}`);
1564
2807
  if (candidate.split("/").includes(".git")) throw new ValidationError("git metadata is outside the task surface");
1565
2808
  return candidate;
1566
2809
  }
1567
- function parseAction(content) {
1568
- const value = parseJsonAction(content);
1569
- const action = value.action;
1570
- if (action === "octocode") {
1571
- const tool = value.tool;
1572
- if (typeof tool !== "string" || !OCTOCODE_TOOLS.has(tool)) {
1573
- throw new ValidationError(`octocode action requires one supported tool: ${SORTED_TOOLS}`);
2810
+ function queryList(queries) {
2811
+ if (isRecord(queries)) return [queries];
2812
+ return Array.isArray(queries) ? queries.filter(isRecord) : [];
2813
+ }
2814
+ function normalizeAction(value, tools) {
2815
+ const named = typeof value.action === "string" && tools.has(value.action) ? value.action : void 0;
2816
+ if (named === void 0 && value.action !== "octocode") return value;
2817
+ const { action: _action, tool: named2, queries, ...fields } = value;
2818
+ const tool = named ?? named2;
2819
+ if (tool === void 0) return value;
2820
+ const query = queries ?? (Object.keys(fields).length > 0 ? fields : void 0);
2821
+ return { action: "octocode", tool, ...query === void 0 ? {} : { queries: query } };
2822
+ }
2823
+ function actionParser(tools, gh = false) {
2824
+ const names = [...tools].sort().join(", ");
2825
+ return (content) => {
2826
+ const value = normalizeAction(parseJsonAction(content), tools);
2827
+ const action = value.action;
2828
+ if (gh && action === "gh") {
2829
+ validateGhArgs(value.args);
2830
+ return value;
1574
2831
  }
1575
- if (!isRecord(value.queries)) throw new ValidationError("octocode action requires a queries object");
1576
- }
1577
- if (action === "page" && (!isPositiveInt(value.step) || !isPositiveInt(value.page))) {
1578
- throw new ValidationError("page action requires positive integer step and page");
1579
- }
1580
- if (action === "finish" && typeof value.answer !== "string") {
1581
- throw new ValidationError("finish action requires a string answer");
1582
- }
1583
- if (action !== "octocode" && action !== "page" && action !== "finish") {
1584
- throw new ValidationError(`unsupported action for octocode adapter: ${String(action)}`);
2832
+ if (action === "octocode") {
2833
+ const tool = value.tool;
2834
+ if (typeof tool !== "string" || !tools.has(tool)) {
2835
+ throw new ValidationError(`octocode action requires one supported tool: ${names}`);
2836
+ }
2837
+ const queries = queryList(value.queries);
2838
+ if (queries.length === 0 || queries.length > MAX_QUERIES) {
2839
+ throw new ValidationError(`octocode action requires queries: 1 to ${MAX_QUERIES} query objects`);
2840
+ }
2841
+ }
2842
+ if (action === "page" && (!isPositiveInt(value.step) || !isPositiveInt(value.page))) {
2843
+ throw new ValidationError("page action requires positive integer step and page");
2844
+ }
2845
+ if (action === "finish" && typeof value.answer !== "string") {
2846
+ throw new ValidationError("finish action requires a string answer");
2847
+ }
2848
+ if (action !== "octocode" && action !== "page" && action !== "finish") {
2849
+ throw new ValidationError(`unsupported action for octocode adapter: ${String(action)}${gh ? " (use octocode, gh, page or finish)" : ""}`);
2850
+ }
2851
+ return value;
2852
+ };
2853
+ }
2854
+ var parseAction = actionParser(OCTOCODE_TOOLS);
2855
+ async function runCli(root, tool, queries, timeoutSeconds) {
2856
+ const result = await runProcess([OCTOCODE_BIN, tool, JSON.stringify({ queries })], { cwd: root, timeoutSeconds });
2857
+ const parts = [trimSpace(result.stdout), trimSpace(result.stderr)].filter(Boolean);
2858
+ const output = parts.join("\n") || "(empty result)";
2859
+ if (result.returncode !== 0) {
2860
+ throw new ToolExitError(result.returncode, output, result.returncode === 1 ? "empty" : "error");
1585
2861
  }
1586
- return value;
2862
+ return output;
1587
2863
  }
1588
2864
  async function runOctocode(root, toolName, queries, timeoutSeconds) {
1589
2865
  if (!OCTOCODE_TOOLS.has(toolName)) throw new ValidationError(`unsupported octocode tool: ${toolName}`);
1590
- const resolved = { ...queries };
1591
- for (const key of PATH_KEYS) {
1592
- const value = resolved[key];
1593
- if (typeof value === "string") resolved[key] = safeResolve(root, value);
1594
- }
1595
- const command = ["npx", "--yes", OCTOCODE_PACKAGE, "tools", toolName, "--queries", JSON.stringify(resolved), "--compact"];
1596
- const result = await runProcess(command, { cwd: root, timeoutSeconds });
1597
- let output = result.stdout + (result.returncode !== 0 ? `
1598
- STDERR:
1599
- ${result.stderr}` : "");
1600
- if (!trimSpace(output)) output = "(empty result)";
1601
- return output;
2866
+ const list = queryList(queries);
2867
+ for (const query of list) {
2868
+ if (typeof query.path === "string") safeResolve(root, query.path);
2869
+ }
2870
+ return runCli(root, toolName, list, timeoutSeconds);
1602
2871
  }
1603
- var OCTOCODE_ADAPTER = {
1604
- defaultToolTimeoutSeconds: 45,
1605
- systemPrompt: (workspace) => octocodeSystemPrompt(workspace),
1606
- parseAction,
1607
- open: () => ({
1608
- toolCall: (action, workspace, runtime) => {
1609
- const toolName = String(action.tool ?? "");
1610
- const queries = isRecord(action.queries) ? action.queries : {};
1611
- return {
1612
- signature: { tool: toolName, queries },
1613
- run: () => runOctocode(workspace, toolName, queries, runtime.tool_timeout_seconds),
1614
- record: {
1615
- name: toolName ? `mcp.octocode.${toolName}` : "mcp.octocode.unknown",
1616
- raw_name: action.action ?? "",
1617
- arguments: { tool: toolName, queries }
1618
- },
1619
- ledger: (status) => ({ action: toolName, status, source: canonicalJson(queries, { sortKeys: false, ensureAscii: false }) })
1620
- };
1621
- }
1622
- }),
1623
- remaining: (count) => `
2872
+ async function runGithub(root, toolName, queries, timeoutSeconds) {
2873
+ if (!GITHUB_TOOLS.has(toolName)) throw new ValidationError(`unsupported GitHub tool: ${toolName}`);
2874
+ return runCli(root, toolName, queryList(queries), timeoutSeconds);
2875
+ }
2876
+ function adapterFor(options) {
2877
+ return {
2878
+ defaultToolTimeoutSeconds: 45,
2879
+ systemPrompt: options.prompt,
2880
+ ...options.briefing ? { briefing: options.briefing } : {},
2881
+ parseAction: actionParser(options.tools, options.gh),
2882
+ open: () => ({
2883
+ toolCall: (action, workspace, runtime) => {
2884
+ if (options.gh && action.action === "gh") {
2885
+ const args = validateGhArgs(action.args);
2886
+ return {
2887
+ signature: { gh: args },
2888
+ run: () => runGh(args, workspace, runtime.tool_timeout_seconds),
2889
+ record: { name: "gh", raw_name: "gh", arguments: { args } },
2890
+ ledger: (status) => ({ action: "gh", status, source: args.join(" ") })
2891
+ };
2892
+ }
2893
+ const toolName = String(action.tool ?? "");
2894
+ const queries = queryList(action.queries);
2895
+ return {
2896
+ signature: { tool: toolName, queries },
2897
+ run: () => options.run(workspace, toolName, queries, runtime.tool_timeout_seconds),
2898
+ record: {
2899
+ name: toolName ? `mcp.octocode.${toolName}` : "mcp.octocode.unknown",
2900
+ raw_name: action.action ?? "",
2901
+ arguments: { tool: toolName, queries }
2902
+ },
2903
+ ledger: (status) => ({ action: toolName, status, source: canonicalJson(queries, { sortKeys: false, ensureAscii: false }) })
2904
+ };
2905
+ }
2906
+ }),
2907
+ remaining: (count) => `
1624
2908
 
1625
2909
  Tool calls remaining: ${count}. Finish now if the task is answerable; do not repeat prior calls.`,
1626
- exhausted: "Tool budget is exhausted. Return exactly a finish JSON action now using the evidence collected. Do not request another tool.",
1627
- adapterMetadata: () => ({ adapter: "octocode_cli_agent_v1" })
1628
- };
1629
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
2910
+ exhausted: "Tool budget is exhausted. Return exactly a finish JSON action now using the evidence collected. Do not request another tool.",
2911
+ adapterMetadata: () => ({ adapter: options.metadata })
2912
+ };
2913
+ }
2914
+ var OCTOCODE_ADAPTER = adapterFor({ tools: OCTOCODE_TOOLS, prompt: octocodeSystemPrompt, run: runOctocode, metadata: "octocode_cli_agent_v2", briefing: workspaceOverview });
2915
+ var GITHUB_ADAPTER = adapterFor({ tools: GITHUB_TOOLS, prompt: githubSystemPrompt, run: runGithub, metadata: "octocode_github_agent_v2", gh: true });
2916
+ if (isEntry(import.meta.url, "octocode-agent")) {
1630
2917
  process.exitCode = await runAdapter(OCTOCODE_ADAPTER);
1631
2918
  }
1632
2919
  export {
2920
+ GITHUB_ADAPTER,
2921
+ GITHUB_TOOLS,
1633
2922
  OCTOCODE_ADAPTER,
1634
- OCTOCODE_PACKAGE,
2923
+ OCTOCODE_BIN,
1635
2924
  OCTOCODE_TOOLS,
1636
2925
  parseAction,
2926
+ queryList,
2927
+ runGithub,
1637
2928
  runOctocode,
1638
2929
  safeResolve
1639
2930
  };