@octocodeai/freellama-mcp-server 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +11 -3
- package/DEVELOPING.md +6 -6
- package/README.md +267 -403
- package/dist/index.js +7186 -1601
- package/dist/research/bash-agent.js +1504 -71
- package/dist/research/coder-agent.js +4677 -0
- package/dist/research/github-agent.js +2932 -0
- package/dist/research/octocode-agent.js +1416 -116
- package/docs/ARCHITECTURE.md +101 -45
- package/docs/BUILD_AND_NOTES.md +31 -0
- package/docs/CLI.md +76 -27
- package/docs/COMPARISON.md +40 -0
- package/docs/CPU_GPU_ROUTING.md +127 -25
- package/docs/DEFERRED_JOBS.md +79 -0
- package/docs/FEATURES.md +303 -0
- package/docs/LOCAL_MODELS.md +190 -0
- package/docs/MODEL_FORMS_AND_RESULTS.md +55 -0
- package/docs/MODEL_INTROSPECTION.md +128 -0
- package/docs/MODEL_SELECTION.md +75 -23
- package/docs/MONITORING.md +115 -33
- package/docs/PRODUCTION.md +74 -11
- package/docs/README.md +11 -6
- package/docs/SCOPES_AND_WARMING.md +55 -7
- package/docs/TESTING.md +24 -2
- package/docs/TUNING.md +86 -0
- package/docs/cookbooks/README.md +22 -0
- package/docs/cookbooks/batch-text.md +19 -0
- package/docs/cookbooks/classify.md +25 -0
- package/docs/cookbooks/coding.md +17 -0
- package/docs/cookbooks/duplicates.md +17 -0
- package/docs/cookbooks/find-code.md +21 -0
- package/docs/cookbooks/image-recognition.md +15 -0
- package/docs/cookbooks/image-search.md +13 -0
- package/docs/cookbooks/jobs.md +60 -0
- package/docs/cookbooks/memory-and-caching.md +31 -0
- package/docs/cookbooks/ocr.md +17 -0
- package/docs/cookbooks/research-agent.md +27 -0
- package/docs/cookbooks/speech-to-text.md +19 -0
- package/docs/cookbooks/sweep.md +66 -0
- package/docs/cookbooks/text-to-speech.md +15 -0
- package/docs/octocode/GITHUB.md +30 -0
- package/docs/octocode/LOCAL.md +29 -0
- package/native/index.d.ts +0 -11
- package/native/index.js +18 -34
- package/package.json +6 -9
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
// src/research/bash-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
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
382
|
-
this.
|
|
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:
|
|
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
|
};
|
|
@@ -444,10 +475,10 @@ function compact(content, policy) {
|
|
|
444
475
|
}
|
|
445
476
|
var textContent = (message) => typeof message?.content === "string" ? message.content : null;
|
|
446
477
|
var contentLength = (message) => cpLength(textContent(message) ?? "");
|
|
447
|
-
function widestIndex(
|
|
478
|
+
function widestIndex(indexes2, messages) {
|
|
448
479
|
let best;
|
|
449
480
|
let bestLength = -1;
|
|
450
|
-
for (const index of
|
|
481
|
+
for (const index of indexes2) {
|
|
451
482
|
const length = contentLength(messages[index]);
|
|
452
483
|
if (length > bestLength) {
|
|
453
484
|
best = index;
|
|
@@ -542,11 +573,16 @@ var AgentContextManager = class {
|
|
|
542
573
|
constructor(runtime, options = {}) {
|
|
543
574
|
this.runtime = runtime;
|
|
544
575
|
this.model = options.model ?? "";
|
|
545
|
-
|
|
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, {
|
|
549
|
-
|
|
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
|
|
961
|
-
const payload = await
|
|
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
|
|
973
|
-
|
|
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)
|
|
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
|
|
1580
|
+
import path4 from "node:path";
|
|
1158
1581
|
var SymlinkLoopError = class extends ValidationError {
|
|
1159
1582
|
name = "SymlinkLoopError";
|
|
1160
1583
|
};
|
|
@@ -1241,7 +1664,53 @@ function resolvePath(target, cwd = process.cwd()) {
|
|
|
1241
1664
|
return current;
|
|
1242
1665
|
};
|
|
1243
1666
|
const text = cleanPath(target);
|
|
1244
|
-
return
|
|
1667
|
+
return path4.posix.normalize(walk(text.startsWith("/") ? "" : cwd, text) || "/");
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// src/research/grounding.ts
|
|
1671
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1672
|
+
import path5 from "node:path";
|
|
1673
|
+
var PATH_TOKEN = /(?<![\w@/.-])(?:\.\/)?[\w@-][\w@.-]*(?:\/[\w@.-]+)+\.[A-Za-z][A-Za-z0-9]{0,5}(?=$|[\s:`'"),.;\]])/g;
|
|
1674
|
+
function clean(token) {
|
|
1675
|
+
return token.replace(/^\.\//, "").replace(/[.,;]+$/, "");
|
|
1676
|
+
}
|
|
1677
|
+
function pathsIn(text) {
|
|
1678
|
+
return [...new Set([...text.matchAll(PATH_TOKEN)].map((match) => clean(match[0])))];
|
|
1679
|
+
}
|
|
1680
|
+
function missingPaths(answer, workspace) {
|
|
1681
|
+
return pathsIn(answer).filter((candidate) => !existsSync3(path5.join(workspace, candidate)));
|
|
1682
|
+
}
|
|
1683
|
+
function evidencePaths(observations, workspace, limit = 10) {
|
|
1684
|
+
const found = [];
|
|
1685
|
+
for (const text of observations) {
|
|
1686
|
+
for (const candidate of pathsIn(text)) {
|
|
1687
|
+
if (!found.includes(candidate) && existsSync3(path5.join(workspace, candidate))) found.push(candidate);
|
|
1688
|
+
if (found.length >= limit) return found;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
return found;
|
|
1692
|
+
}
|
|
1693
|
+
function unseenPaths(answer, observations) {
|
|
1694
|
+
return pathsIn(answer).filter((candidate) => !observations.some((text) => text.includes(candidate)));
|
|
1695
|
+
}
|
|
1696
|
+
function observedPaths(observations, limit = 10) {
|
|
1697
|
+
return [...new Set(observations.flatMap((text) => pathsIn(text)))].slice(0, limit);
|
|
1698
|
+
}
|
|
1699
|
+
function groundingRepairNotice(missing, evidence, observed = false) {
|
|
1700
|
+
return `These paths in your answer ${observed ? "did not appear in any tool result" : "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 ${observed ? "your tool results showed" : "that exist"}; if the evidence does not establish the answer, say so.`;
|
|
1701
|
+
}
|
|
1702
|
+
function groundingNote(missing, observed = false) {
|
|
1703
|
+
return `
|
|
1704
|
+
[freellama: cited path(s) ${observed ? "not seen in any tool result" : "not found in the workspace"}: ${missing.join(", ")}; treat this answer as unverified]`;
|
|
1705
|
+
}
|
|
1706
|
+
function toolErrorHint(message) {
|
|
1707
|
+
if (/rg: unrecognized flag --(?:exclude-dir|include)/.test(message)) {
|
|
1708
|
+
return "\nHint: rg excludes directories with --glob '!node_modules' and filters types with -t ts; or use grep -rn PATTERN . --exclude-dir=node_modules.";
|
|
1709
|
+
}
|
|
1710
|
+
return "";
|
|
1711
|
+
}
|
|
1712
|
+
function sourceRepairNotice(evidence) {
|
|
1713
|
+
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(", ")}.`;
|
|
1245
1714
|
}
|
|
1246
1715
|
|
|
1247
1716
|
// src/research/agent-loop.ts
|
|
@@ -1251,7 +1720,7 @@ function requireEnv(env, name) {
|
|
|
1251
1720
|
if (value === void 0) throw new Error(`${name} is not set`);
|
|
1252
1721
|
return value;
|
|
1253
1722
|
}
|
|
1254
|
-
var readText = (file) => normalizeNewlines(decodeUtf8(
|
|
1723
|
+
var readText = (file) => normalizeNewlines(decodeUtf8(readFileSync5(file), file));
|
|
1255
1724
|
var pathOf = (value) => value === "" ? "." : value;
|
|
1256
1725
|
var numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : typeof value === "boolean" ? Number(value) : fallback;
|
|
1257
1726
|
var echo = (action) => canonicalJson(action, { sortKeys: false, ensureAscii: false });
|
|
@@ -1281,9 +1750,15 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1281
1750
|
const { model, workspace, prompt, resultPath } = inputs;
|
|
1282
1751
|
const print = hooks.print ?? ((line) => process.stdout.write(`${line}
|
|
1283
1752
|
`));
|
|
1284
|
-
const
|
|
1753
|
+
const progress = hooks.progress ?? progressEmitter(env);
|
|
1285
1754
|
const endpoint = (env.FREELLAMA_OLLAMA_ENDPOINT ?? "http://127.0.0.1:11434").replace(/\/+$/, "");
|
|
1286
1755
|
const managedEndpoint = (env.FREELLAMA_AGENT_MANAGED_ENDPOINT ?? "").replace(/\/+$/, "");
|
|
1756
|
+
let phaseAt = { maxTurns: 0 };
|
|
1757
|
+
const sendJson = hooks.requestJson ?? (managedEndpoint ? (url, payload, timeoutSeconds) => requestJsonAsJob(url, payload, timeoutSeconds, {
|
|
1758
|
+
report: (message) => progress?.({ event: "job_phase", ...phaseAt, message }),
|
|
1759
|
+
onJob: (id, open) => progress?.({ event: open ? "job_open" : "job_closed", id }),
|
|
1760
|
+
request: hooks.httpRequest
|
|
1761
|
+
}) : requestJson);
|
|
1287
1762
|
const executionPreference = env.FREELLAMA_AGENT_EXECUTION_PREFERENCE ?? "auto";
|
|
1288
1763
|
const minPlacementEvidence = env.FREELLAMA_AGENT_MIN_PLACEMENT_EVIDENCE ?? "configured";
|
|
1289
1764
|
let runtime;
|
|
@@ -1298,7 +1773,7 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1298
1773
|
}
|
|
1299
1774
|
let messages = [
|
|
1300
1775
|
{ role: "system", content: spec.systemPrompt(workspace) },
|
|
1301
|
-
{ role: "user", content: prompt }
|
|
1776
|
+
{ role: "user", content: withBriefing(prompt, spec, workspace) }
|
|
1302
1777
|
];
|
|
1303
1778
|
const calls = [];
|
|
1304
1779
|
const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: null, cache_write_tokens: null };
|
|
@@ -1308,12 +1783,20 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1308
1783
|
let answer = "";
|
|
1309
1784
|
let failure = null;
|
|
1310
1785
|
let transportError = null;
|
|
1786
|
+
const readCatalog = (url) => readCatalogJson(url, hooks.httpRequest);
|
|
1787
|
+
const platformRatio = managedEndpoint ? await (hooks.resolvePlatformTokenRatio ?? resolvePlatformTokenRatio)(managedEndpoint, model, { readJson: readCatalog }) : null;
|
|
1311
1788
|
const calibrationDir = trimSpace(env.FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR ?? "");
|
|
1312
|
-
const calibrationModelIdentity = calibrationDir ? await (hooks.resolveModelIdentity ?? resolveModelIdentity)(
|
|
1789
|
+
const calibrationModelIdentity = calibrationDir ? await (hooks.resolveModelIdentity ?? resolveModelIdentity)(
|
|
1790
|
+
managedEndpoint || endpoint,
|
|
1791
|
+
model,
|
|
1792
|
+
messages[0].content,
|
|
1793
|
+
{ readJson: readCatalog }
|
|
1794
|
+
) : { verified: false, scope: "persistence_disabled", identity: "" };
|
|
1313
1795
|
const contextManager = new AgentContextManager(runtime, {
|
|
1314
1796
|
model,
|
|
1315
1797
|
calibrationIdentity: calibrationModelIdentity.identity,
|
|
1316
|
-
calibrationDir: calibrationDir && calibrationModelIdentity.verified ? calibrationDir : null
|
|
1798
|
+
calibrationDir: calibrationDir && calibrationModelIdentity.verified ? calibrationDir : null,
|
|
1799
|
+
platformRatio
|
|
1317
1800
|
});
|
|
1318
1801
|
try {
|
|
1319
1802
|
messages = contextManager.fit(messages);
|
|
@@ -1346,12 +1829,19 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1346
1829
|
executionPreference,
|
|
1347
1830
|
minPlacementEvidence
|
|
1348
1831
|
);
|
|
1832
|
+
const recorded = executionReceipts.length;
|
|
1349
1833
|
response = unwrapChatResponse(await sendJson(url, payload, runtime.request_timeout_seconds), executionReceipts);
|
|
1834
|
+
if (managedEndpoint && executionReceipts.length === recorded) {
|
|
1835
|
+
executionReceipts.push({ model: null, execution: null, reason: "managed_execution_receipt_missing" });
|
|
1836
|
+
}
|
|
1350
1837
|
break;
|
|
1351
1838
|
} catch (error) {
|
|
1352
1839
|
if (!isTransportError(error) || !retryableChatError(error)) throw error;
|
|
1353
1840
|
lastError = error;
|
|
1354
|
-
if (attempt + 1 < runtime.retry_attempts)
|
|
1841
|
+
if (attempt + 1 < runtime.retry_attempts) {
|
|
1842
|
+
progress?.({ event: "retry", attempt: attempt + 2, attempts: runtime.retry_attempts });
|
|
1843
|
+
await sleep(runtime.retry_backoff_seconds * 1e3);
|
|
1844
|
+
}
|
|
1355
1845
|
}
|
|
1356
1846
|
}
|
|
1357
1847
|
if (response === null) throw lastError;
|
|
@@ -1386,10 +1876,29 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1386
1876
|
const observations = hooks.observationStore ? hooks.observationStore(pageSize) : new ObservationStore(pageSize);
|
|
1387
1877
|
let session;
|
|
1388
1878
|
try {
|
|
1389
|
-
|
|
1879
|
+
try {
|
|
1880
|
+
session = spec.open ? await spec.open(workspace) : void 0;
|
|
1881
|
+
} catch (error) {
|
|
1882
|
+
if (!isValidationError(error)) throw error;
|
|
1883
|
+
answer = `research adapter could not start: ${error.message}`;
|
|
1884
|
+
writeFailureResult(resultPath, answer);
|
|
1885
|
+
print(answer);
|
|
1886
|
+
return 1;
|
|
1887
|
+
}
|
|
1888
|
+
const root = session?.workspace ?? workspace;
|
|
1390
1889
|
let parseFailures = 0;
|
|
1391
1890
|
let exhausted = true;
|
|
1891
|
+
let repeats = 0;
|
|
1892
|
+
let groundingRepaired = false;
|
|
1893
|
+
let sourceRepaired = false;
|
|
1894
|
+
const seenOutputs = [];
|
|
1895
|
+
const observed = spec.grounding === "observed";
|
|
1896
|
+
const ungrounded = (text) => observed ? unseenPaths(text, seenOutputs) : missingPaths(text, root);
|
|
1897
|
+
const evidenceFor = () => observed ? observedPaths(seenOutputs) : evidencePaths(seenOutputs, root);
|
|
1392
1898
|
for (let turn = 0; turn < runtime.max_turns; turn += 1) {
|
|
1899
|
+
const at = { turn: turn + 1, maxTurns: runtime.max_turns };
|
|
1900
|
+
phaseAt = at;
|
|
1901
|
+
progress?.({ event: "model", ...at });
|
|
1393
1902
|
let response;
|
|
1394
1903
|
try {
|
|
1395
1904
|
response = await callModel();
|
|
@@ -1413,6 +1922,7 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1413
1922
|
exhausted = false;
|
|
1414
1923
|
break;
|
|
1415
1924
|
}
|
|
1925
|
+
progress?.({ event: "repair", ...at });
|
|
1416
1926
|
messages.push({ role: "assistant", content: cpSlice(raw, 0, runtime.parse_repair_echo_chars) });
|
|
1417
1927
|
messages.push({ role: "user", content: PARSE_REPAIR_NOTICE });
|
|
1418
1928
|
if (!refit()) {
|
|
@@ -1425,6 +1935,7 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1425
1935
|
if (action.action === "page") {
|
|
1426
1936
|
const wantStep = Math.trunc(numberOr(action.step, 0));
|
|
1427
1937
|
const wantPage = Math.trunc(numberOr(action.page, 1));
|
|
1938
|
+
progress?.({ event: "action", ...at, action });
|
|
1428
1939
|
messages.push({ role: "assistant", content: echo(action) });
|
|
1429
1940
|
messages.push({ role: "user", content: observations.message(wantStep, wantPage) });
|
|
1430
1941
|
if (!refit()) {
|
|
@@ -1434,45 +1945,83 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1434
1945
|
continue;
|
|
1435
1946
|
}
|
|
1436
1947
|
if (action.action === "finish") {
|
|
1437
|
-
|
|
1948
|
+
const proposed = trimSpace(String(action.answer ?? ""));
|
|
1949
|
+
const missing = ungrounded(proposed);
|
|
1950
|
+
if (missing.length > 0 && !groundingRepaired) {
|
|
1951
|
+
groundingRepaired = true;
|
|
1952
|
+
progress?.({ event: "repair", ...at });
|
|
1953
|
+
messages.push({ role: "assistant", content: echo(action) });
|
|
1954
|
+
messages.push({ role: "user", content: groundingRepairNotice(missing, evidenceFor(), observed) });
|
|
1955
|
+
if (!refit()) {
|
|
1956
|
+
answer = proposed + groundingNote(missing, observed);
|
|
1957
|
+
exhausted = false;
|
|
1958
|
+
break;
|
|
1959
|
+
}
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
const evidence = spec.requireSource !== false && missing.length === 0 && !sourceRepaired && pathsIn(proposed).length === 0 ? evidenceFor() : [];
|
|
1963
|
+
if (evidence.length > 0) {
|
|
1964
|
+
sourceRepaired = true;
|
|
1965
|
+
progress?.({ event: "repair", ...at });
|
|
1966
|
+
messages.push({ role: "assistant", content: echo(action) });
|
|
1967
|
+
messages.push({ role: "user", content: sourceRepairNotice(evidence) });
|
|
1968
|
+
if (!refit()) {
|
|
1969
|
+
answer = proposed;
|
|
1970
|
+
exhausted = false;
|
|
1971
|
+
break;
|
|
1972
|
+
}
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
progress?.({ event: "finish", ...at });
|
|
1976
|
+
answer = missing.length > 0 ? proposed + groundingNote(missing, observed) : proposed;
|
|
1438
1977
|
exhausted = false;
|
|
1439
1978
|
break;
|
|
1440
1979
|
}
|
|
1441
1980
|
const started = performance.now();
|
|
1442
|
-
const call = session.toolCall(action,
|
|
1981
|
+
const call = session.toolCall(action, root, runtime);
|
|
1443
1982
|
const signature = callSignature(call.signature);
|
|
1444
1983
|
let observation;
|
|
1445
1984
|
let status;
|
|
1985
|
+
let exitCode;
|
|
1446
1986
|
if (seenCalls.has(signature)) {
|
|
1987
|
+
progress?.({ event: "action", ...at, action, repeat: true });
|
|
1447
1988
|
observation = REPEAT_NOTICE;
|
|
1448
1989
|
status = "repeat";
|
|
1990
|
+
repeats += 1;
|
|
1449
1991
|
} else {
|
|
1992
|
+
progress?.({ event: "action", ...at, action });
|
|
1450
1993
|
try {
|
|
1451
1994
|
observation = hooks.runTool ? await hooks.runTool(call.record.arguments) : await call.run();
|
|
1452
1995
|
status = "ok";
|
|
1453
1996
|
} catch (error) {
|
|
1454
1997
|
if (!isToolError(error)) throw error;
|
|
1455
|
-
|
|
1456
|
-
|
|
1998
|
+
status = error instanceof ToolExitError ? error.status : "error";
|
|
1999
|
+
exitCode = error instanceof ToolExitError ? error.exitCode : void 0;
|
|
2000
|
+
observation = `${status === "empty" ? "tool search" : "tool error"}: ${error.message}${toolErrorHint(error.message)}`;
|
|
1457
2001
|
}
|
|
1458
2002
|
seenCalls.set(signature, calls.length + 1);
|
|
1459
2003
|
}
|
|
1460
2004
|
calls.push({
|
|
1461
2005
|
...call.record,
|
|
1462
2006
|
status,
|
|
2007
|
+
...exitCode === void 0 ? {} : { exit_code: exitCode },
|
|
1463
2008
|
duration_ms: Math.round((performance.now() - started) * 1e3) / 1e3
|
|
1464
2009
|
});
|
|
1465
2010
|
messages.push({ role: "assistant", content: echo(action) });
|
|
1466
2011
|
const remaining = runtime.max_turns - calls.length;
|
|
1467
2012
|
const step = calls.length;
|
|
1468
2013
|
observations.put(step, observation, call.ledger(status));
|
|
2014
|
+
seenOutputs.push(observation);
|
|
1469
2015
|
messages.push({ role: "user", content: observations.message(step) + spec.remaining(remaining) });
|
|
1470
2016
|
if (!refit()) {
|
|
1471
2017
|
exhausted = false;
|
|
1472
2018
|
break;
|
|
1473
2019
|
}
|
|
2020
|
+
if (repeats >= 2) break;
|
|
1474
2021
|
}
|
|
1475
2022
|
if (exhausted) {
|
|
2023
|
+
progress?.({ event: "finalize", maxTurns: runtime.max_turns });
|
|
2024
|
+
phaseAt = { maxTurns: runtime.max_turns };
|
|
1476
2025
|
messages.push({ role: "user", content: spec.exhausted });
|
|
1477
2026
|
if (refit()) {
|
|
1478
2027
|
try {
|
|
@@ -1480,6 +2029,8 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1480
2029
|
const finalAction = spec.parseAction(content(response));
|
|
1481
2030
|
if (finalAction.action !== "finish") throw new ValidationError("forced final response was not finish");
|
|
1482
2031
|
answer = trimSpace(String(finalAction.answer ?? ""));
|
|
2032
|
+
const missing = ungrounded(answer);
|
|
2033
|
+
if (missing.length > 0) answer += groundingNote(missing, observed);
|
|
1483
2034
|
} catch (error) {
|
|
1484
2035
|
if (!isTransportError(error) && !isValidationError(error)) throw error;
|
|
1485
2036
|
let diagnostic;
|
|
@@ -1489,7 +2040,9 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1489
2040
|
}
|
|
1490
2041
|
}
|
|
1491
2042
|
if (!answer) answer = failure ?? "agent stopped without a final answer";
|
|
2043
|
+
const report = spec.report ? await spec.report(session) : {};
|
|
1492
2044
|
const result = {
|
|
2045
|
+
...report,
|
|
1493
2046
|
final_answer: answer,
|
|
1494
2047
|
tool_calls: calls,
|
|
1495
2048
|
usage,
|
|
@@ -1525,14 +2078,540 @@ async function runLoop(spec, hooks, env, inputs) {
|
|
|
1525
2078
|
observations.close();
|
|
1526
2079
|
}
|
|
1527
2080
|
}
|
|
2081
|
+
function withBriefing(prompt, spec, workspace) {
|
|
2082
|
+
try {
|
|
2083
|
+
const briefing = spec.briefing?.(workspace);
|
|
2084
|
+
return briefing ? `${prompt}
|
|
1528
2085
|
|
|
1529
|
-
|
|
1530
|
-
|
|
2086
|
+
${briefing}` : prompt;
|
|
2087
|
+
} catch {
|
|
2088
|
+
return prompt;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// ../rust-core/src/model_catalog.json
|
|
2093
|
+
var model_catalog_default = {
|
|
2094
|
+
snapshot: "2026-11-01",
|
|
2095
|
+
measured_on: "Apple M2 Pro, 32 GB, Ollama 0.40.13, with other work running",
|
|
2096
|
+
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.",
|
|
2097
|
+
jobs: [
|
|
2098
|
+
{ job: "classify", title: "Classify, triage, yes/no", call: 'run_task {task:"decision"}', cookbook: "cookbooks/classify", picks: [
|
|
2099
|
+
{ 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" },
|
|
2100
|
+
{ 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" }
|
|
2101
|
+
] },
|
|
2102
|
+
{ job: "find-code", title: "Find the code for an issue", call: 'run_task {task:"embedding", nearest:{query}}', cookbook: "cookbooks/find-code", picks: [
|
|
2103
|
+
{ 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" },
|
|
2104
|
+
{ 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" }
|
|
2105
|
+
] },
|
|
2106
|
+
{ job: "duplicates", title: "Find duplicates", call: 'run_task {task:"embedding", nearest:{}}', cookbook: "cookbooks/duplicates", picks: [
|
|
2107
|
+
{ tag: "nomic-embed-text:v1.5", role: "default", file_gb: 0.3, memory_gb: 1.4, quality: "measured", evidence: "8/12 real issue pairs" }
|
|
2108
|
+
] },
|
|
2109
|
+
{ job: "ocr", title: "Read text from an image", call: 'run_task {task:"vision", files:[path]}', cookbook: "cookbooks/ocr", picks: [
|
|
2110
|
+
{ 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" },
|
|
2111
|
+
{ 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" }
|
|
2112
|
+
] },
|
|
2113
|
+
{ job: "image-recognition", title: "Understand what an image shows", call: 'run_task {task:"vision", files:[path], prompt}', cookbook: "cookbooks/image-recognition", picks: [
|
|
2114
|
+
{ 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" },
|
|
2115
|
+
{ 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" },
|
|
2116
|
+
{ 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" }
|
|
2117
|
+
] },
|
|
2118
|
+
{ job: "image-search", title: "Find an image by describing it", call: 'run_task {task:"embedding", files:[images], nearest:{query}}', cookbook: "cookbooks/image-search", picks: [
|
|
2119
|
+
{ 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" }
|
|
2120
|
+
] },
|
|
2121
|
+
{ job: "speech-to-text", title: "Recording to text", call: 'audio {action:"transcribe", file}', cookbook: "cookbooks/speech-to-text", picks: [
|
|
2122
|
+
{ 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" },
|
|
2123
|
+
{ 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" }
|
|
2124
|
+
] },
|
|
2125
|
+
{ job: "text-to-speech", title: "Text to speech", call: 'audio {action:"speak", text}', cookbook: "cookbooks/text-to-speech", picks: [
|
|
2126
|
+
{ 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" },
|
|
2127
|
+
{ 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" },
|
|
2128
|
+
{ 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" }
|
|
2129
|
+
] },
|
|
2130
|
+
{ job: "coding", title: "Small coding tasks", call: 'run_task {task:"coding", files:[path]}', cookbook: "cookbooks/coding", picks: [
|
|
2131
|
+
{ 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" },
|
|
2132
|
+
{ 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" },
|
|
2133
|
+
{ 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" },
|
|
2134
|
+
{ 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" }
|
|
2135
|
+
] },
|
|
2136
|
+
{ job: "research-agent", title: "Research a repository", call: 'delegate_research {adapter:"octocode" or "github"}', cookbook: "cookbooks/research-agent", picks: [
|
|
2137
|
+
{ 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" },
|
|
2138
|
+
{ 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" },
|
|
2139
|
+
{ 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" }
|
|
2140
|
+
] }
|
|
2141
|
+
]
|
|
2142
|
+
};
|
|
2143
|
+
|
|
2144
|
+
// src/local-prompts.ts
|
|
2145
|
+
var catalog = model_catalog_default;
|
|
2146
|
+
function picksOf(job) {
|
|
2147
|
+
return catalog.jobs.find((entry) => entry.job === job)?.picks.map((pick) => pick.tag) ?? [];
|
|
2148
|
+
}
|
|
2149
|
+
var cookbook = (name) => `docs/cookbooks/${name}.md`;
|
|
2150
|
+
var SOURCES = {
|
|
2151
|
+
miniCpmOcr: ["https://huggingface.co/openbmb/MiniCPM-OCR-1.0", "https://huggingface.co/openbmb/MiniCPM-OCR-1.0-gguf"],
|
|
2152
|
+
glmOcr: ["https://ollama.com/library/glm-ocr", "https://huggingface.co/zai-org/GLM-OCR"],
|
|
2153
|
+
lfm3Vl: ["https://huggingface.co/LiquidAI/LFM3-VL-2B", "https://huggingface.co/LiquidAI/LFM3-VL-2B-GGUF", "https://ollama.com/library/lfm3-vl"],
|
|
2154
|
+
lfm3Omni: ["https://huggingface.co/LiquidAI/LFM3-Omni-2B", "https://ollama.com/library/lfm3-omni"],
|
|
2155
|
+
embeddingGemma: ["https://huggingface.co/google/embeddinggemma-2", "https://ai.google.dev/gemma/docs/embeddinggemma/inference-embeddinggemma-with-sentence-transformers"],
|
|
2156
|
+
nomic: ["https://huggingface.co/nomic-ai/nomic-embed-text-v1.5"],
|
|
2157
|
+
strandsDecider: ["https://ollama.com/library/strands-decider", "https://raw.githubusercontent.com/strands-labs/strands-decider/main/README.md"],
|
|
2158
|
+
nimble: ["https://ollama.com/library/nimble-v3:latest"],
|
|
2159
|
+
bonsai9b: ["https://huggingface.co/prism-ml/Ternary-Bonsai-2-9B-gguf", "https://ollama.com/library/bonsai-2:9b"],
|
|
2160
|
+
bonsai27b: ["https://huggingface.co/prism-ml/Ternary-Bonsai-2-27B-gguf", "https://ollama.com/library/bonsai-2"],
|
|
2161
|
+
lfm3: ["https://huggingface.co/LiquidAI/LFM3-1.7B", "https://ollama.com/library/lfm3"],
|
|
2162
|
+
nemotronBolt: ["https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Bolt-E4B-4GB"],
|
|
2163
|
+
lfm3Audio: ["https://huggingface.co/LiquidAI/LFM3-Audio-2B", "https://ollama.com/library/lfm3-audio"],
|
|
2164
|
+
qwen38: ["https://huggingface.co/Qwen/Qwen3.8-27B", "https://ollama.com/library/qwen3.8"],
|
|
2165
|
+
qwen35: ["https://huggingface.co/Qwen/Qwen3.5-4B", "https://ollama.com/library/qwen3.5"],
|
|
2166
|
+
phonon: ["https://ollama.com/library/phonon:en"]
|
|
2167
|
+
};
|
|
2168
|
+
var EMBEDDING_PREFIXES = {
|
|
2169
|
+
embeddinggemma: {
|
|
2170
|
+
query: "task: search result | query: ",
|
|
2171
|
+
document: "title: none | text: ",
|
|
2172
|
+
codeQuery: "task: code retrieval | query: ",
|
|
2173
|
+
similarity: "task: sentence similarity | query: ",
|
|
2174
|
+
clustering: "task: clustering | query: ",
|
|
2175
|
+
classification: "task: classification | query: "
|
|
2176
|
+
},
|
|
2177
|
+
// A prefix is required; the card names clustering: for removing semantic duplicates.
|
|
2178
|
+
"nomic-embed-text": { query: "search_query: ", document: "search_document: ", clustering: "clustering: ", classification: "classification: " }
|
|
2179
|
+
};
|
|
2180
|
+
var GEMMA = EMBEDDING_PREFIXES.embeddinggemma;
|
|
2181
|
+
var NOMIC = EMBEDDING_PREFIXES["nomic-embed-text"];
|
|
2182
|
+
var OCR_PROMPTS = { text: "Text Recognition:", table: "Table Recognition:", formula: "Formula Recognition:" };
|
|
2183
|
+
var OCR_TRAINED = ["minicpm-ocr", "glm-ocr"];
|
|
2184
|
+
var OCR_PLAIN = {
|
|
2185
|
+
text: "Transcribe all text in the image exactly, line by line. Output only the text.",
|
|
2186
|
+
table: "Transcribe the table in the image as a Markdown table. Output only the table."
|
|
2187
|
+
};
|
|
2188
|
+
var ocrPrompt = (kind, model) => {
|
|
2189
|
+
const name = (model ?? "").toLowerCase().replace(/^(?:hf\.co|huggingface\.co)\/[^/]+\//, "");
|
|
2190
|
+
return model === void 0 || OCR_TRAINED.some((family) => name.startsWith(family)) ? OCR_PROMPTS[kind] : OCR_PLAIN[kind];
|
|
2191
|
+
};
|
|
2192
|
+
var EDIT_FORMAT = `Return each change to an existing file as the file's path (copied from its "--- file:" line), then:
|
|
2193
|
+
<<<<<<< SEARCH
|
|
2194
|
+
exact current lines (5 to 30, unique in the file, indentation exact)
|
|
2195
|
+
=======
|
|
2196
|
+
replacement lines
|
|
2197
|
+
>>>>>>> REPLACE
|
|
2198
|
+
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.`;
|
|
2199
|
+
var NO_TOOLS = "You have no tools and cannot open files: everything you need is in this message. Answer in plain text.";
|
|
2200
|
+
var WORKFLOWS = {
|
|
2201
|
+
"read-text": {
|
|
2202
|
+
job: "ocr",
|
|
2203
|
+
title: "Read all text in an image, exactly",
|
|
2204
|
+
input: "files: the images",
|
|
2205
|
+
prompt: (_request, model) => ocrPrompt("text", model)
|
|
2206
|
+
},
|
|
2207
|
+
"read-table": {
|
|
2208
|
+
job: "ocr",
|
|
2209
|
+
title: "Read a table in an image as Markdown",
|
|
2210
|
+
input: "files: the images",
|
|
2211
|
+
prompt: (_request, model) => ocrPrompt("table", model)
|
|
2212
|
+
},
|
|
2213
|
+
"read-fields": {
|
|
2214
|
+
job: "ocr",
|
|
2215
|
+
title: "Pull named fields from a receipt, invoice or form image as JSON",
|
|
2216
|
+
input: "files: the images; prompt: the field names",
|
|
2217
|
+
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.`
|
|
2218
|
+
},
|
|
2219
|
+
"answer-image": {
|
|
2220
|
+
job: "image-recognition",
|
|
2221
|
+
title: "Answer a question about an image",
|
|
2222
|
+
input: "files: the images; prompt: the question",
|
|
2223
|
+
system: `Answer the question about the image.
|
|
2224
|
+
1. Find the part of the image the question is about.
|
|
2225
|
+
2. Read any text there exactly as printed.
|
|
2226
|
+
3. Answer with just the value or label, or one or two sentences.
|
|
2227
|
+
If the image does not show it, answer "not visible". Never guess at what is cut off.`
|
|
2228
|
+
},
|
|
2229
|
+
"describe-screen": {
|
|
2230
|
+
job: "image-recognition",
|
|
2231
|
+
title: "Describe a screenshot for a developer: text, errors, state",
|
|
2232
|
+
input: "files: the screenshot; prompt: what to look for (optional)",
|
|
2233
|
+
system: `You describe a software screenshot for a developer, in short bullets, in this order:
|
|
2234
|
+
- the app or page shown;
|
|
2235
|
+
- headings and main text, quoted exactly;
|
|
2236
|
+
- any error, warning or empty state, quoted exactly;
|
|
2237
|
+
- the state of the controls that matter (selected, disabled, filled in);
|
|
2238
|
+
- anything that looks broken or misaligned.
|
|
2239
|
+
Describe only what is visible.`
|
|
2240
|
+
},
|
|
2241
|
+
"change-code": {
|
|
2242
|
+
job: "coding",
|
|
2243
|
+
title: "Make one small code change",
|
|
2244
|
+
input: "files: the file to change; prompt: the change",
|
|
2245
|
+
system: `${NO_TOOLS}
|
|
2246
|
+
You make one small code change.
|
|
2247
|
+
1. Find where the change belongs in the code shown.
|
|
2248
|
+
2. Follow the file's existing names, style and error handling.
|
|
2249
|
+
3. Change as few lines as the task needs; touch nothing else.
|
|
2250
|
+
${EDIT_FORMAT}
|
|
2251
|
+
Explain nothing unless asked. If the task is a question, answer it in a few lines.`
|
|
2252
|
+
},
|
|
2253
|
+
"fix-bug": {
|
|
2254
|
+
prefer: "capable",
|
|
2255
|
+
job: "coding",
|
|
2256
|
+
title: "Fix a bug with the smallest change",
|
|
2257
|
+
input: "files: the suspect code; prompt: the error message or symptom",
|
|
2258
|
+
system: `${NO_TOOLS}
|
|
2259
|
+
You fix one bug with the smallest change.
|
|
2260
|
+
1. Read the error or symptom, then the code shown.
|
|
2261
|
+
2. Name the cause in one sentence, quoting the line that causes it.
|
|
2262
|
+
3. Change only what the fix needs.
|
|
2263
|
+
Answer with the cause line, then the edits.
|
|
2264
|
+
${EDIT_FORMAT}
|
|
2265
|
+
If the code shown cannot cause the symptom, say so and name the file or function to look at instead.`
|
|
2266
|
+
},
|
|
2267
|
+
implement: {
|
|
2268
|
+
prefer: "capable",
|
|
2269
|
+
job: "coding",
|
|
2270
|
+
title: "Add a small feature to existing code",
|
|
2271
|
+
input: "files: the code; prompt: the feature",
|
|
2272
|
+
system: `${NO_TOOLS}
|
|
2273
|
+
You add one small feature to the code shown.
|
|
2274
|
+
1. Find the function or place it belongs; reuse helpers the file already has.
|
|
2275
|
+
2. Follow the file's names, style and error handling.
|
|
2276
|
+
3. Handle the empty and error cases the feature can meet.
|
|
2277
|
+
${EDIT_FORMAT}
|
|
2278
|
+
End with one line: what to test.`
|
|
2279
|
+
},
|
|
2280
|
+
"write-tests": {
|
|
2281
|
+
prefer: "capable",
|
|
2282
|
+
job: "coding",
|
|
2283
|
+
title: "Write unit tests for the code shown",
|
|
2284
|
+
input: "files: the code (and an existing test, for style); prompt: what to cover (optional)",
|
|
2285
|
+
system: `${NO_TOOLS}
|
|
2286
|
+
You write unit tests for the code shown; you never change that code.
|
|
2287
|
+
1. List the behaviours: the normal case, edge cases (empty, zero, missing, boundary) and one failure case.
|
|
2288
|
+
2. Use the test framework and import style the project already uses; copy them from the files shown.
|
|
2289
|
+
3. Write one small test per behaviour, named after it, with literal expected values.
|
|
2290
|
+
Answer with the test file's path on one line, then the whole file in one fenced block.`
|
|
2291
|
+
},
|
|
2292
|
+
"review-code": {
|
|
2293
|
+
prefer: "capable",
|
|
2294
|
+
job: "coding",
|
|
2295
|
+
title: "Find real defects in code (not style)",
|
|
2296
|
+
input: "files: the code; prompt: what to focus on (optional)",
|
|
2297
|
+
system: `${NO_TOOLS}
|
|
2298
|
+
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.
|
|
2299
|
+
For each defect write one line: path:line - the problem - the fix.
|
|
2300
|
+
Report at most 5, the surest first. If you find none, answer "no defects found". Never invent a line you did not see.`
|
|
2301
|
+
},
|
|
2302
|
+
"explain-code": {
|
|
2303
|
+
job: "coding",
|
|
2304
|
+
title: "Explain code to someone about to change it",
|
|
2305
|
+
input: "files: the code; prompt: what to explain (optional)",
|
|
2306
|
+
system: `${NO_TOOLS}
|
|
2307
|
+
Explain the code shown to a developer who is about to change it, in at most 6 bullets:
|
|
2308
|
+
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.
|
|
2309
|
+
Name functions and variables exactly.`
|
|
2310
|
+
},
|
|
2311
|
+
"summarize-log": {
|
|
2312
|
+
think: false,
|
|
2313
|
+
prefer: "capable",
|
|
2314
|
+
job: "summarize",
|
|
2315
|
+
title: "Summarize a log: errors grouped, counted, first failure",
|
|
2316
|
+
input: "files: the log; prompt: what you are investigating (optional)",
|
|
2317
|
+
system: `${NO_TOOLS}
|
|
2318
|
+
You read a log for a developer. Report:
|
|
2319
|
+
1. Errors and warnings, grouped by message, each with its count and first and last timestamp.
|
|
2320
|
+
2. The first error in time order, quoted exactly.
|
|
2321
|
+
3. The likely cause, only if the log shows it.
|
|
2322
|
+
Quote messages exactly, skip routine lines, at most 10 bullets.`
|
|
2323
|
+
},
|
|
2324
|
+
"summarize-text": {
|
|
2325
|
+
think: false,
|
|
2326
|
+
job: "summarize",
|
|
2327
|
+
title: "Summarize text for someone who will act on it",
|
|
2328
|
+
input: "files: the text; prompt: what you need from it (optional)",
|
|
2329
|
+
system: `${NO_TOOLS}
|
|
2330
|
+
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.
|
|
2331
|
+
Quote numbers, names, versions and paths exactly. Write "not stated" when the text does not say; never guess. No introduction or closing.`
|
|
2332
|
+
},
|
|
2333
|
+
"summarize-diff": {
|
|
2334
|
+
think: false,
|
|
2335
|
+
prefer: "capable",
|
|
2336
|
+
job: "summarize",
|
|
2337
|
+
title: "Summarize a diff for a reviewer: changes and risks",
|
|
2338
|
+
input: "files: the diff or patch",
|
|
2339
|
+
system: `${NO_TOOLS}
|
|
2340
|
+
You summarize a code diff for a reviewer, in at most 8 bullets:
|
|
2341
|
+
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.
|
|
2342
|
+
Name files, functions and settings exactly.`
|
|
2343
|
+
},
|
|
2344
|
+
"extract-json": {
|
|
2345
|
+
think: false,
|
|
2346
|
+
job: "summarize",
|
|
2347
|
+
title: "Extract named fields from text into JSON",
|
|
2348
|
+
input: "files: the text; prompt: the field names (or a JSON schema)",
|
|
2349
|
+
system: `${NO_TOOLS}
|
|
2350
|
+
Extract the fields the request names from the text into one JSON object.
|
|
2351
|
+
Use the values exactly as written in the text; use null for a field the text does not give; never invent a value.
|
|
2352
|
+
Output only the JSON object.`,
|
|
2353
|
+
format: "json"
|
|
2354
|
+
}
|
|
2355
|
+
};
|
|
2356
|
+
var WORKFLOW_NAMES = Object.keys(WORKFLOWS);
|
|
2357
|
+
var BONSAI_9B = {
|
|
2358
|
+
think: false,
|
|
2359
|
+
options: { temperature: 0.7, top_p: 0.8, top_k: 20, min_p: 0, presence_penalty: 1.5, repeat_penalty: 1 },
|
|
2360
|
+
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.",
|
|
2361
|
+
sources: SOURCES.bonsai9b
|
|
2362
|
+
};
|
|
2363
|
+
var qwenNonThinking = (sources) => ({
|
|
2364
|
+
think: false,
|
|
2365
|
+
options: { temperature: 0.7, top_p: 0.8, top_k: 20, min_p: 0, presence_penalty: 1.5, repeat_penalty: 1 },
|
|
2366
|
+
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.",
|
|
2367
|
+
sources
|
|
2368
|
+
});
|
|
2369
|
+
var CHAT_MODELS = {
|
|
2370
|
+
lfm3: {
|
|
2371
|
+
options: { temperature: 0.1, top_k: 50, repeat_penalty: 1.1 },
|
|
2372
|
+
notes: "Thinks by default; its card calls thinking off inconsistent, so it stays on. Not for agentic coding or multi-step reasoning.",
|
|
2373
|
+
sources: SOURCES.lfm3
|
|
2374
|
+
},
|
|
2375
|
+
"nvidia-nemotron-3.5-bolt": {
|
|
2376
|
+
options: { temperature: 1, top_p: 0.95 },
|
|
2377
|
+
// `ollama show` of the hf.co Q4_K_M tag, 2026-11-08: no thinking capability, and the template
|
|
2378
|
+
// defaults enable_thinking to false.
|
|
2379
|
+
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.",
|
|
2380
|
+
sources: SOURCES.nemotronBolt
|
|
2381
|
+
},
|
|
2382
|
+
"bonsai-2": BONSAI_9B,
|
|
2383
|
+
// The card documents the same instruct (non-thinking) preset as the 9B; Ollama lists think
|
|
2384
|
+
// values false, low, medium and xhigh for this tag.
|
|
2385
|
+
"bonsai-2:27b": {
|
|
2386
|
+
...BONSAI_9B,
|
|
2387
|
+
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.",
|
|
2388
|
+
sources: SOURCES.bonsai27b
|
|
2389
|
+
},
|
|
2390
|
+
"qwen3.8": qwenNonThinking(SOURCES.qwen38),
|
|
2391
|
+
"qwen3.5": qwenNonThinking(SOURCES.qwen35)
|
|
2392
|
+
};
|
|
2393
|
+
var CHAT_SOURCES = [...SOURCES.lfm3, ...SOURCES.nemotronBolt, ...SOURCES.bonsai9b, ...SOURCES.bonsai27b, ...SOURCES.qwen38, ...SOURCES.qwen35];
|
|
2394
|
+
var LOCAL_JOBS = {
|
|
2395
|
+
classify: {
|
|
2396
|
+
job: "classify",
|
|
2397
|
+
task: "decision",
|
|
2398
|
+
catalogJob: "classify",
|
|
2399
|
+
defaultPicks: picksOf("classify"),
|
|
2400
|
+
models: {
|
|
2401
|
+
"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 },
|
|
2402
|
+
"nimble-v3": { notes: "Documents num_ctx 32768 (routing sizes it here); CC BY-NC 4.0, noncommercial.", sources: SOURCES.nimble }
|
|
2403
|
+
},
|
|
2404
|
+
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.",
|
|
2405
|
+
sources: [...SOURCES.strandsDecider, ...SOURCES.nimble, cookbook("classify")]
|
|
2406
|
+
},
|
|
2407
|
+
"find-code": {
|
|
2408
|
+
job: "find-code",
|
|
2409
|
+
task: "embedding",
|
|
2410
|
+
catalogJob: "find-code",
|
|
2411
|
+
defaultPicks: picksOf("find-code"),
|
|
2412
|
+
needs: "nearest.query: the issue, or what the code does",
|
|
2413
|
+
prefixes: { embeddinggemma: { query: GEMMA.codeQuery, document: GEMMA.document }, "nomic-embed-text": { query: NOMIC.query, document: NOMIC.document } },
|
|
2414
|
+
// Measured 2026-11-08 on the find-code set (10 questions, 1,039 chunks): code retrieval with
|
|
2415
|
+
// title: none 8 first / 9 in top 3; search result 8/8; code retrieval with the file as title 7/9.
|
|
2416
|
+
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.",
|
|
2417
|
+
sources: [...SOURCES.embeddingGemma, ...SOURCES.nomic, cookbook("find-code")]
|
|
2418
|
+
},
|
|
2419
|
+
duplicates: {
|
|
2420
|
+
job: "duplicates",
|
|
2421
|
+
task: "embedding",
|
|
2422
|
+
catalogJob: "duplicates",
|
|
2423
|
+
defaultPicks: picksOf("duplicates"),
|
|
2424
|
+
// Symmetric: every item, the query included, takes the same prefix.
|
|
2425
|
+
prefixes: {
|
|
2426
|
+
// The measured 8/12 used search_document:; the card's clustering: is documented but unmeasured here.
|
|
2427
|
+
"nomic-embed-text": { query: NOMIC.document, document: NOMIC.document },
|
|
2428
|
+
embeddinggemma: { query: GEMMA.similarity, document: GEMMA.similarity }
|
|
2429
|
+
},
|
|
2430
|
+
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.",
|
|
2431
|
+
sources: [...SOURCES.nomic, ...SOURCES.embeddingGemma, cookbook("duplicates")]
|
|
2432
|
+
},
|
|
2433
|
+
ocr: {
|
|
2434
|
+
job: "ocr",
|
|
2435
|
+
task: "vision",
|
|
2436
|
+
catalogJob: "ocr",
|
|
2437
|
+
defaultPicks: picksOf("ocr"),
|
|
2438
|
+
prompt: ({ model }) => ocrPrompt("text", model),
|
|
2439
|
+
// Both publishers' Ollama Modelfiles set temperature 0; the hf.co MiniCPM tag ships no parameters.
|
|
2440
|
+
models: {
|
|
2441
|
+
"minicpm-ocr": {
|
|
2442
|
+
options: { temperature: 0 },
|
|
2443
|
+
notes: "Chinese and English; tables come back as HTML. Its Modelfile sets num_ctx 8192.",
|
|
2444
|
+
sources: [...SOURCES.miniCpmOcr, "https://raw.githubusercontent.com/OpenBMB/MiniCPM-OCR/main/docs/ollama_deploy.md"]
|
|
2445
|
+
},
|
|
2446
|
+
"glm-ocr": {
|
|
2447
|
+
options: { temperature: 0 },
|
|
2448
|
+
notes: "Also documents Figure Recognition:.",
|
|
2449
|
+
sources: [...SOURCES.glmOcr, "https://github.com/zai-org/GLM-OCR/blob/main/examples/ollama-deploy/README.md"]
|
|
2450
|
+
}
|
|
2451
|
+
},
|
|
2452
|
+
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.",
|
|
2453
|
+
sources: [...SOURCES.miniCpmOcr, ...SOURCES.glmOcr, cookbook("ocr")]
|
|
2454
|
+
},
|
|
2455
|
+
"image-recognition": {
|
|
2456
|
+
job: "image-recognition",
|
|
2457
|
+
task: "vision",
|
|
2458
|
+
catalogJob: "image-recognition",
|
|
2459
|
+
defaultPicks: picksOf("image-recognition"),
|
|
2460
|
+
needs: "prompt: your question about the image",
|
|
2461
|
+
systemPrompt: WORKFLOWS["answer-image"].system,
|
|
2462
|
+
models: {
|
|
2463
|
+
"lfm3-vl": {
|
|
2464
|
+
options: { temperature: 0.1, top_p: 1, top_k: 50, min_p: 0, repeat_penalty: 1.1 },
|
|
2465
|
+
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.",
|
|
2466
|
+
sources: SOURCES.lfm3Vl
|
|
2467
|
+
},
|
|
2468
|
+
"lfm3-omni": {
|
|
2469
|
+
systemPrompt: "Answer questions about the provided image. Base every answer only on what can be seen.",
|
|
2470
|
+
options: { temperature: 0 },
|
|
2471
|
+
notes: "Non-reasoning: never send think:true. Up to 2,048 tokens per image. The card gives 32K context; the Ollama tag advertises 128K.",
|
|
2472
|
+
sources: SOURCES.lfm3Omni
|
|
2473
|
+
},
|
|
2474
|
+
"bonsai-2": BONSAI_9B,
|
|
2475
|
+
"qwen3.8": qwenNonThinking(SOURCES.qwen38),
|
|
2476
|
+
"qwen3.5": qwenNonThinking(SOURCES.qwen35)
|
|
2477
|
+
},
|
|
2478
|
+
notes: 'Ask a specific question, not "describe this". Tested on drawn images only.',
|
|
2479
|
+
sources: [...SOURCES.lfm3Vl, ...SOURCES.lfm3Omni, ...SOURCES.bonsai9b, cookbook("image-recognition")]
|
|
2480
|
+
},
|
|
2481
|
+
"image-search": {
|
|
2482
|
+
job: "image-search",
|
|
2483
|
+
task: "embedding",
|
|
2484
|
+
catalogJob: "image-search",
|
|
2485
|
+
defaultPicks: picksOf("image-search"),
|
|
2486
|
+
needs: "nearest.query: a description of the image",
|
|
2487
|
+
prefixes: { embeddinggemma: { query: GEMMA.query, document: GEMMA.document } },
|
|
2488
|
+
notes: "Images are embedded bare: the prefixes apply to text only. Open the top match.",
|
|
2489
|
+
sources: [...SOURCES.embeddingGemma, cookbook("image-search")]
|
|
2490
|
+
},
|
|
2491
|
+
coding: {
|
|
2492
|
+
job: "coding",
|
|
2493
|
+
task: "coding",
|
|
2494
|
+
catalogJob: "coding",
|
|
2495
|
+
defaultPicks: picksOf("coding"),
|
|
2496
|
+
needs: "prompt: the change to make",
|
|
2497
|
+
systemPrompt: WORKFLOWS["change-code"].system,
|
|
2498
|
+
models: CHAT_MODELS,
|
|
2499
|
+
notes: "The answer is SEARCH/REPLACE blocks: apply one only where its SEARCH text matches exactly once, then run the tests.",
|
|
2500
|
+
sources: [...CHAT_SOURCES, cookbook("coding")]
|
|
2501
|
+
},
|
|
2502
|
+
summarize: {
|
|
2503
|
+
job: "summarize",
|
|
2504
|
+
task: "completion",
|
|
2505
|
+
catalogJob: "coding",
|
|
2506
|
+
defaultPicks: picksOf("coding"),
|
|
2507
|
+
prompt: () => "Summarize the text below.",
|
|
2508
|
+
systemPrompt: WORKFLOWS["summarize-text"].system,
|
|
2509
|
+
models: CHAT_MODELS,
|
|
2510
|
+
notes: "The coding picks: the same chat models apply (docs/cookbooks/batch-text.md). Check every number and name you reuse.",
|
|
2511
|
+
sources: [...CHAT_SOURCES, cookbook("batch-text")]
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
var TOOL_JOBS = {
|
|
2515
|
+
"speech-to-text": {
|
|
2516
|
+
tool: "audio",
|
|
2517
|
+
defaultPicks: picksOf("speech-to-text"),
|
|
2518
|
+
// The audio tool calls Ollama's transcription endpoint, which builds the model's prompt; these
|
|
2519
|
+
// are lfm3-audio's documented prompts for a direct chat call.
|
|
2520
|
+
systemPrompts: {
|
|
2521
|
+
transcribe: (language) => language ? `Perform ASR in ${language}.` : "Perform ASR.",
|
|
2522
|
+
translate: (source, target) => `Translate the following speech from ${source} to ${target} in text form.`
|
|
2523
|
+
},
|
|
2524
|
+
notes: "WAV input only. Numbers and names are the usual misses; translation works best to or from English.",
|
|
2525
|
+
sources: [...SOURCES.lfm3Audio, cookbook("speech-to-text")]
|
|
2526
|
+
},
|
|
2527
|
+
"text-to-speech": {
|
|
2528
|
+
tool: "audio",
|
|
2529
|
+
defaultPicks: picksOf("text-to-speech"),
|
|
2530
|
+
notes: "Send plain text: Markdown marks may be read aloud. One voice (default); 24 kHz mono WAV.",
|
|
2531
|
+
sources: [...SOURCES.phonon, cookbook("text-to-speech")]
|
|
2532
|
+
},
|
|
2533
|
+
"research-agent": {
|
|
2534
|
+
tool: "delegate_research",
|
|
2535
|
+
defaultPicks: picksOf("research-agent"),
|
|
2536
|
+
notes: "The adapters' system prompts are below. Discard an escalate verdict and verify the citations.",
|
|
2537
|
+
sources: [cookbook("research-agent")]
|
|
2538
|
+
},
|
|
2539
|
+
sweep: {
|
|
2540
|
+
tool: "sweep",
|
|
2541
|
+
defaultPicks: picksOf("classify"),
|
|
2542
|
+
/** The decision state of one chunk, and the explain step's user message for a confirmed flag. */
|
|
2543
|
+
state: (label, text) => `Source: ${label}
|
|
2544
|
+
|
|
2545
|
+
${text}`,
|
|
2546
|
+
explain: (prompt, label, text) => `${prompt}
|
|
2547
|
+
|
|
2548
|
+
Source: ${label}
|
|
2549
|
+
|
|
2550
|
+
${text}`,
|
|
2551
|
+
// FreeLlama's own setting for the short explain note, not a model's documented value.
|
|
2552
|
+
explainControls: { think: false, temperature: 0 },
|
|
2553
|
+
notes: 'One yes/no question per concern, phrased about one chunk ("Does this code ..."); a noul answer flags at p >= 0.5.',
|
|
2554
|
+
sources: [cookbook("sweep")]
|
|
2555
|
+
}
|
|
2556
|
+
};
|
|
2557
|
+
var BASH_SYSTEM_PROMPT = `You answer questions about the code in this read-only workspace (the current directory) by looking, not guessing. Reply with exactly one JSON object per turn and nothing else.
|
|
2558
|
+
|
|
2559
|
+
Actions, in order of preference:
|
|
2560
|
+
{"action":"search","pattern":"EMBEDDINGS_PER_JOB"} finds text in files and prints path:line:text. Literal and case-sensitive; add "regex":true for a|b or \\s, "ignoreCase":true, "glob":"*.rs", or "path":"packages/mcp" to narrow it.
|
|
2561
|
+
{"action":"read","path":"packages/mcp/src/tools/run-task.ts","start":310,"end":340} shows numbered lines of one file.
|
|
2562
|
+
{"action":"list","path":"packages"} shows files and folders two levels deep.
|
|
2563
|
+
{"action":"shell","command":"rg -n -w build_decision_request -g '*.rs'"} runs one read-only pipeline when the actions above cannot. Use rg (rg -n PATTERN PATH; -w whole word, -l file names only, -t rust or -t ts, -g '!tests/') or grep -rn PATTERN PATH --exclude-dir=node_modules, plus find, head, sed -n '10,40p' FILE, wc and git log/show/grep/blame. No network, redirection, $(...), variables or loops.
|
|
2564
|
+
{"action":"page","step":2,"page":2} shows the next page of a long earlier result.
|
|
2565
|
+
{"action":"finish","answer":"32. Source: packages/mcp/src/tools/run-task.ts:322"} ends the task.
|
|
2566
|
+
|
|
2567
|
+
How to research:
|
|
2568
|
+
1. Search for the exact name in the question (a function, constant, type, flag or error text). A path in the question may be partial; search finds the real one.
|
|
2569
|
+
2. Read the lines around the best hit to confirm it.
|
|
2570
|
+
3. Finish once the answer is confirmed, usually after 2 to 4 actions. Never repeat an action: its result is already above.
|
|
2571
|
+
Keep every result small: search before reading, read 30 to 80 lines around a hit rather than a whole file, use rg -l or rg -c to see where matches are before printing them, and end long shell output with | head -n 40 or | tail -n 40.
|
|
2572
|
+
For "which files call X", search "X(" and list every file except the one that defines X. For "is there an X", a search with no matches over the whole workspace means no.
|
|
2573
|
+
|
|
2574
|
+
Answer format: the direct answer first (the value, name, list or yes/no the question asks for), then "Source: path:line" for each file it comes from, copied from the tool output. Answer "not found" only after a search of the whole workspace returned no matches.`;
|
|
2575
|
+
var CODER_SYSTEM_PROMPT = [
|
|
2576
|
+
"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.",
|
|
2577
|
+
"",
|
|
2578
|
+
"Actions, in order of preference:",
|
|
2579
|
+
'{"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.',
|
|
2580
|
+
'{"action":"read","path":"src/config.ts","start":40,"end":70} shows numbered lines of one file.',
|
|
2581
|
+
'{"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.',
|
|
2582
|
+
'{"action":"write","path":"src/limits.ts","content":"export const MAX = 10;\\n"} creates a file, or replaces a whole small file.',
|
|
2583
|
+
'{"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.',
|
|
2584
|
+
'{"action":"list","path":"src"} shows files and folders two levels deep.',
|
|
2585
|
+
'{"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.',
|
|
2586
|
+
'{"action":"page","step":2,"page":2} shows the next page of a long earlier result.',
|
|
2587
|
+
'{"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.',
|
|
2588
|
+
"",
|
|
2589
|
+
"How to work:",
|
|
2590
|
+
"1. Search for the names in the task, then read the lines you will change.",
|
|
2591
|
+
"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.",
|
|
2592
|
+
"3. Run an allowed command to test the change. If it fails, read the error, fix the code and run it again.",
|
|
2593
|
+
"4. Finish with what changed, why, and the last test result. With no allowed command, say the change is untested.",
|
|
2594
|
+
"Keep results small: read line ranges, not whole files, and end long shell output with | head -n 40."
|
|
2595
|
+
].join("\n");
|
|
2596
|
+
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.";
|
|
2597
|
+
var AGENT_GUIDE = `Use FreeLlama to run bounded work on local models and keep it out of your context.
|
|
2598
|
+
|
|
2599
|
+
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.
|
|
2600
|
+
|
|
2601
|
+
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.
|
|
2602
|
+
|
|
2603
|
+
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".
|
|
2604
|
+
|
|
2605
|
+
Coding: the answer is SEARCH/REPLACE blocks. ${APPLY_EDITS}
|
|
2606
|
+
|
|
2607
|
+
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]. Answers over 8,000 characters come back as a summary with check: lines and an output_file holding the whole answer; output:"inline" returns the whole text, outputPath:"<path>" writes (or replaces) that file inside your project.
|
|
2608
|
+
|
|
2609
|
+
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.`;
|
|
1531
2610
|
|
|
1532
2611
|
// src/research/sandbox.ts
|
|
1533
|
-
import { accessSync, constants as fsConstants, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, realpathSync, rmSync as rmSync2, symlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2612
|
+
import { accessSync, constants as fsConstants, existsSync as existsSync4, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, realpathSync, rmSync as rmSync2, symlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1534
2613
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
1535
|
-
import
|
|
2614
|
+
import path6 from "node:path";
|
|
1536
2615
|
|
|
1537
2616
|
// src/research/proc.ts
|
|
1538
2617
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -1830,6 +2909,9 @@ var POSIX_WORDCHARS = "\xDF\xE0\xE1\xE2\xE3\xE4\xE5\xE6\xE7\xE8\xE9\xEA\xEB\xEC\
|
|
|
1830
2909
|
function within2(char, set) {
|
|
1831
2910
|
return set.includes(char);
|
|
1832
2911
|
}
|
|
2912
|
+
function shlexTokens(text, options) {
|
|
2913
|
+
return [...shlexIter(text, options)];
|
|
2914
|
+
}
|
|
1833
2915
|
function* shlexIter(text, options) {
|
|
1834
2916
|
const posix = options.posix;
|
|
1835
2917
|
const punctuation = options.punctuationChars ?? "";
|
|
@@ -2116,10 +3198,43 @@ function assertCommandConfined(root, commandText) {
|
|
|
2116
3198
|
}
|
|
2117
3199
|
var OPERATOR_CHARS = new Set(";&|()<>");
|
|
2118
3200
|
var SEPARATORS = /* @__PURE__ */ new Set(["|", "||", "&&", ";"]);
|
|
3201
|
+
var LITERAL_OPEN = "\uE000";
|
|
3202
|
+
var LITERAL_CLOSE = "\uE001";
|
|
3203
|
+
function protectLiteralParens(commandText) {
|
|
3204
|
+
let out = "";
|
|
3205
|
+
let quote;
|
|
3206
|
+
for (let index = 0; index < commandText.length; index++) {
|
|
3207
|
+
const char = commandText[index];
|
|
3208
|
+
if (quote === void 0 && char === "\\" && (commandText[index + 1] === "(" || commandText[index + 1] === ")")) {
|
|
3209
|
+
out += commandText[index + 1] === "(" ? LITERAL_OPEN : LITERAL_CLOSE;
|
|
3210
|
+
index += 1;
|
|
3211
|
+
continue;
|
|
3212
|
+
}
|
|
3213
|
+
if (quote === void 0 && char === "\\") {
|
|
3214
|
+
out += char + (commandText[index + 1] ?? "");
|
|
3215
|
+
index += 1;
|
|
3216
|
+
continue;
|
|
3217
|
+
}
|
|
3218
|
+
if (quote === void 0 && (char === "'" || char === '"')) quote = char;
|
|
3219
|
+
else if (quote === char) quote = void 0;
|
|
3220
|
+
else if (quote !== void 0 && (char === "(" || char === ")")) {
|
|
3221
|
+
out += char === "(" ? LITERAL_OPEN : LITERAL_CLOSE;
|
|
3222
|
+
continue;
|
|
3223
|
+
}
|
|
3224
|
+
out += char;
|
|
3225
|
+
}
|
|
3226
|
+
return out;
|
|
3227
|
+
}
|
|
3228
|
+
var restoreParens = (token) => token.replaceAll(LITERAL_OPEN, "(").replaceAll(LITERAL_CLOSE, ")");
|
|
2119
3229
|
function segments(commandText) {
|
|
2120
|
-
const tokens = shlexIter(commandText, { posix: true, punctuationChars: ";&|()<>", whitespaceSplit: true, commenters: "" });
|
|
3230
|
+
const tokens = [...shlexIter(protectLiteralParens(commandText), { posix: true, punctuationChars: ";&|()<>", whitespaceSplit: true, commenters: "" })];
|
|
2121
3231
|
const result = [[]];
|
|
2122
|
-
for (const
|
|
3232
|
+
for (const raw of tokens) {
|
|
3233
|
+
if (raw.includes(LITERAL_OPEN) || raw.includes(LITERAL_CLOSE)) {
|
|
3234
|
+
result[result.length - 1].push(restoreParens(raw));
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
const token = raw;
|
|
2123
3238
|
if (SEPARATORS.has(token)) {
|
|
2124
3239
|
result.push([]);
|
|
2125
3240
|
} else if (token && [...token].every((char) => OPERATOR_CHARS.has(char))) {
|
|
@@ -2175,7 +3290,7 @@ function checkTool(words) {
|
|
|
2175
3290
|
}
|
|
2176
3291
|
const target = xargsCommand(args);
|
|
2177
3292
|
if (!["echo", "printf", "true", "false"].includes(target[0])) {
|
|
2178
|
-
throw new ValidationError(
|
|
3293
|
+
throw new ValidationError(`command blocked: xargs can only feed echo/printf here (its file arguments cannot be confined). Search file contents directly instead: grep -rln --include='*.ts' "PATTERN" . or rg -l "PATTERN"`);
|
|
2179
3294
|
}
|
|
2180
3295
|
checkTool(target);
|
|
2181
3296
|
}
|
|
@@ -2239,15 +3354,16 @@ function validateCommand(root, commandTextInput) {
|
|
|
2239
3354
|
}
|
|
2240
3355
|
|
|
2241
3356
|
// src/research/sandbox.ts
|
|
2242
|
-
function macosProfile(workspace, scratch) {
|
|
3357
|
+
function macosProfile(workspace, scratch, readPaths = []) {
|
|
2243
3358
|
const quote = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
3359
|
+
const extra = readPaths.map((dir) => ` (subpath ${quote(dir)})`).join("");
|
|
2244
3360
|
return `(version 1)
|
|
2245
3361
|
(allow default)
|
|
2246
3362
|
(deny network*)
|
|
2247
3363
|
(deny file-write*)
|
|
2248
3364
|
(allow file-write* (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (subpath ${quote(scratch)}))
|
|
2249
3365
|
(deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/root"))
|
|
2250
|
-
(allow file-read-data (subpath ${quote(workspace)}) (subpath ${quote(scratch)}))
|
|
3366
|
+
(allow file-read-data (subpath ${quote(workspace)}) (subpath ${quote(scratch)})${extra})
|
|
2251
3367
|
`;
|
|
2252
3368
|
}
|
|
2253
3369
|
function which(name, envPath = process.env.PATH) {
|
|
@@ -2275,18 +3391,21 @@ var ReadOnlyShell = class {
|
|
|
2275
3391
|
env = {};
|
|
2276
3392
|
bash = "/bin/bash";
|
|
2277
3393
|
scratch = null;
|
|
2278
|
-
|
|
3394
|
+
/** Directories outside the workspace the shell may also read, such as a worktree's git directory. */
|
|
3395
|
+
readPaths;
|
|
3396
|
+
constructor(workspace, options = {}) {
|
|
2279
3397
|
this.workspace = workspace;
|
|
3398
|
+
this.readPaths = options.readPaths ?? [];
|
|
2280
3399
|
}
|
|
2281
3400
|
enter() {
|
|
2282
3401
|
this.workspace = resolvePath(this.workspace);
|
|
2283
|
-
const scratch = mkdtempSync2(
|
|
3402
|
+
const scratch = mkdtempSync2(path6.join(path6.resolve(tmpdir2()), "freellama-shell-"));
|
|
2284
3403
|
this.scratch = scratch;
|
|
2285
|
-
const binDir =
|
|
3404
|
+
const binDir = path6.join(scratch, "bin");
|
|
2286
3405
|
mkdirSync3(binDir);
|
|
2287
3406
|
for (const tool of READ_ONLY_TOOLS) {
|
|
2288
3407
|
const located = which(tool);
|
|
2289
|
-
if (located) symlinkSync(realpathSync(located),
|
|
3408
|
+
if (located) symlinkSync(realpathSync(located), path6.join(binDir, tool));
|
|
2290
3409
|
}
|
|
2291
3410
|
this.bash = which("bash") ?? "/bin/bash";
|
|
2292
3411
|
const env = {};
|
|
@@ -2320,8 +3439,8 @@ var ReadOnlyShell = class {
|
|
|
2320
3439
|
if ((process.env.FREELLAMA_AGENT_OS_SANDBOX ?? "auto") === "off") return [];
|
|
2321
3440
|
const sandboxExec = which("sandbox-exec");
|
|
2322
3441
|
if (sandboxExec) {
|
|
2323
|
-
const profile =
|
|
2324
|
-
writeFileSync2(profile, macosProfile(this.workspace, scratch), "utf8");
|
|
3442
|
+
const profile = path6.join(scratch, "profile.sb");
|
|
3443
|
+
writeFileSync2(profile, macosProfile(this.workspace, scratch, this.readPaths), "utf8");
|
|
2325
3444
|
const prefix = [sandboxExec, "-f", profile];
|
|
2326
3445
|
if (probe2([...prefix, this.bash, "-c", "true"], this.env)) {
|
|
2327
3446
|
this.kind = "sandbox-exec+restricted-bash";
|
|
@@ -2338,6 +3457,7 @@ var ReadOnlyShell = class {
|
|
|
2338
3457
|
"--ro-bind",
|
|
2339
3458
|
this.workspace,
|
|
2340
3459
|
this.workspace,
|
|
3460
|
+
...this.readPaths.flatMap((dir) => ["--ro-bind", dir, dir]),
|
|
2341
3461
|
"--ro-bind",
|
|
2342
3462
|
binDir,
|
|
2343
3463
|
binDir,
|
|
@@ -2355,18 +3475,324 @@ var ReadOnlyShell = class {
|
|
|
2355
3475
|
}
|
|
2356
3476
|
return [];
|
|
2357
3477
|
}
|
|
3478
|
+
/**
|
|
3479
|
+
* Run one read-only tool with an argument vector, no shell: the structured `search` action uses
|
|
3480
|
+
* it so a pattern such as `${x}` or `(` is data, never shell syntax. Same OS sandbox, PATH and
|
|
3481
|
+
* scrubbed environment as `run`; exit 1 of a search means no matches.
|
|
3482
|
+
*/
|
|
3483
|
+
/** Whether `tool` was found on the caller's PATH when the shell was entered. */
|
|
3484
|
+
hasTool(tool) {
|
|
3485
|
+
return this.scratch !== null && existsSync4(path6.join(this.scratch, "bin", tool));
|
|
3486
|
+
}
|
|
3487
|
+
async runTool(argv, timeoutSeconds) {
|
|
3488
|
+
const [tool, ...args] = argv;
|
|
3489
|
+
if (!tool || !READ_ONLY_TOOLS.includes(tool) || this.scratch === null) {
|
|
3490
|
+
throw new ValidationError(`tool ${tool ?? "(none)"} is not available to structured actions`);
|
|
3491
|
+
}
|
|
3492
|
+
const result = await runProcess(
|
|
3493
|
+
[...this.prefix, path6.join(this.scratch, "bin", tool), ...args],
|
|
3494
|
+
{ cwd: this.workspace, env: this.env, timeoutSeconds }
|
|
3495
|
+
);
|
|
3496
|
+
if (result.returncode === 1 && !trimSpace(result.stderr)) return "(no matches)";
|
|
3497
|
+
if (result.returncode !== 0) throw new ToolExitError(result.returncode, result.stdout + result.stderr);
|
|
3498
|
+
return trimSpace(result.stdout) ? result.stdout : "(no matches)";
|
|
3499
|
+
}
|
|
2358
3500
|
async run(commandText, timeoutSeconds) {
|
|
2359
3501
|
const command = validateCommand(this.workspace, commandText);
|
|
3502
|
+
const tokens = shlexTokens(command, { posix: true, punctuationChars: ";&|()<>", whitespaceSplit: true, commenters: "" });
|
|
3503
|
+
if (tokens.some((token) => token === ";" || token === "&&" || token === "||")) {
|
|
3504
|
+
throw new ValidationError("command blocked: send one pipeline per turn so each execution outcome can be recorded");
|
|
3505
|
+
}
|
|
2360
3506
|
const result = await runProcess(
|
|
2361
|
-
[...this.prefix, this.bash, "--restricted", "--noprofile", "--norc", "-c", command],
|
|
3507
|
+
[...this.prefix, this.bash, "--restricted", "--noprofile", "--norc", "-o", "pipefail", "-c", command],
|
|
2362
3508
|
{ cwd: this.workspace, env: this.env, timeoutSeconds }
|
|
2363
3509
|
);
|
|
2364
3510
|
let output = result.stdout + result.stderr;
|
|
3511
|
+
if (result.returncode !== 0) {
|
|
3512
|
+
const parts = segments(command);
|
|
3513
|
+
const words = parts.length === 1 ? parts[0] : [];
|
|
3514
|
+
const isSearch = ["rg", "grep", "egrep", "fgrep"].includes(words[0] ?? "") || words[0] === "git" && words[1] === "grep";
|
|
3515
|
+
const isDiff = words[0] === "diff" || words[0] === "git" && words[1] === "diff";
|
|
3516
|
+
if (result.returncode === 1 && isSearch && !trimSpace(result.stderr)) {
|
|
3517
|
+
throw new ToolExitError(result.returncode, output, "empty");
|
|
3518
|
+
}
|
|
3519
|
+
if (!(result.returncode === 1 && isDiff && !trimSpace(result.stderr))) {
|
|
3520
|
+
throw new ToolExitError(result.returncode, output);
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
2365
3523
|
if (!trimSpace(output)) output = `(no output, exit code ${result.returncode})`;
|
|
2366
3524
|
return output;
|
|
2367
3525
|
}
|
|
2368
3526
|
};
|
|
2369
3527
|
|
|
3528
|
+
// src/research/workspace-overview.ts
|
|
3529
|
+
import { readdirSync as readdirSync3 } from "node:fs";
|
|
3530
|
+
import path7 from "node:path";
|
|
3531
|
+
var SKIP = /* @__PURE__ */ new Set(["node_modules", "target", ".git", "dist", "build", ".venv", "venv", "vendor", "__pycache__", ".octocode", "site-packages", "coverage"]);
|
|
3532
|
+
var MAX_ENTRIES = 2e4;
|
|
3533
|
+
var MAX_DEPTH = 8;
|
|
3534
|
+
var SOURCE_DIRS = /* @__PURE__ */ new Set(["src", "lib", "app", "source", "pkg", "cmd", "internal", "crates"]);
|
|
3535
|
+
function workspaceOverview(root) {
|
|
3536
|
+
let top;
|
|
3537
|
+
try {
|
|
3538
|
+
top = readdirSync3(root, { withFileTypes: true });
|
|
3539
|
+
} catch {
|
|
3540
|
+
return "";
|
|
3541
|
+
}
|
|
3542
|
+
const extensions = /* @__PURE__ */ new Map();
|
|
3543
|
+
const sourceRoots = /* @__PURE__ */ new Map();
|
|
3544
|
+
let files = 0;
|
|
3545
|
+
let seen = 0;
|
|
3546
|
+
const walk = (dir, depth, sourceRoot) => {
|
|
3547
|
+
if (depth > MAX_DEPTH || seen > MAX_ENTRIES) return;
|
|
3548
|
+
let entries;
|
|
3549
|
+
try {
|
|
3550
|
+
entries = readdirSync3(dir, { withFileTypes: true });
|
|
3551
|
+
} catch {
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
for (const entry of entries) {
|
|
3555
|
+
if (++seen > MAX_ENTRIES) return;
|
|
3556
|
+
if (entry.isDirectory()) {
|
|
3557
|
+
if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
3558
|
+
const child = path7.join(dir, entry.name);
|
|
3559
|
+
walk(child, depth + 1, sourceRoot ?? (SOURCE_DIRS.has(entry.name) ? path7.relative(root, child) : void 0));
|
|
3560
|
+
} else if (entry.isFile()) {
|
|
3561
|
+
files += 1;
|
|
3562
|
+
const extension = path7.extname(entry.name).toLowerCase() || "(none)";
|
|
3563
|
+
extensions.set(extension, (extensions.get(extension) ?? 0) + 1);
|
|
3564
|
+
if (sourceRoot) sourceRoots.set(sourceRoot, (sourceRoots.get(sourceRoot) ?? 0) + 1);
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
};
|
|
3568
|
+
walk(root, 0);
|
|
3569
|
+
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);
|
|
3570
|
+
const types = [...extensions.entries()].sort((left, right) => right[1] - left[1]).slice(0, 8).map(([extension, count]) => `${extension} ${count}`);
|
|
3571
|
+
const sources = [...sourceRoots.entries()].sort((left, right) => right[1] - left[1]).slice(0, 8).map(([dir, count]) => `${dir}/ (${count})`);
|
|
3572
|
+
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.";
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
// src/research/structured-actions.ts
|
|
3576
|
+
import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync2, statSync as statSync2 } from "node:fs";
|
|
3577
|
+
import path8 from "node:path";
|
|
3578
|
+
var SKIPPED_DIRS = ["node_modules", "target", "dist", "build", ".git", ".venv", "venv", "vendor", "__pycache__", ".octocode", "site-packages", "coverage"];
|
|
3579
|
+
var READ_DEFAULT_LINES = 120;
|
|
3580
|
+
var READ_MAX_LINES = 400;
|
|
3581
|
+
var LIST_MAX_ENTRIES = 300;
|
|
3582
|
+
var SEARCH_MAX_PER_FILE = 20;
|
|
3583
|
+
function confined(workspace, rel) {
|
|
3584
|
+
const raw = (rel ?? ".").trim() || ".";
|
|
3585
|
+
if (path8.isAbsolute(raw) && !raw.startsWith(workspace)) throw new ValidationError(`path is outside the workspace: ${raw}`);
|
|
3586
|
+
const absolute = path8.resolve(workspace, raw);
|
|
3587
|
+
const real = existsSync5(absolute) ? realpathSync2(absolute) : absolute;
|
|
3588
|
+
if (escapesRoot(real, workspace)) throw new ValidationError(`path is outside the workspace: ${raw}`);
|
|
3589
|
+
return path8.relative(workspace, real) || ".";
|
|
3590
|
+
}
|
|
3591
|
+
function validateSearch(value) {
|
|
3592
|
+
if (typeof value.pattern !== "string" || !value.pattern.trim()) throw new ValidationError("search action requires a non-empty string pattern");
|
|
3593
|
+
if (value.pattern.length > 300) throw new ValidationError("search pattern is longer than 300 characters");
|
|
3594
|
+
for (const key of ["path", "glob"]) {
|
|
3595
|
+
if (value[key] !== void 0 && typeof value[key] !== "string") throw new ValidationError(`search ${key} must be a string`);
|
|
3596
|
+
}
|
|
3597
|
+
for (const key of ["regex", "ignoreCase"]) {
|
|
3598
|
+
if (value[key] !== void 0 && typeof value[key] !== "boolean") throw new ValidationError(`search ${key} must be true or false`);
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
function validateRead(value) {
|
|
3602
|
+
if (typeof value.path !== "string" || !value.path.trim()) throw new ValidationError("read action requires a file path");
|
|
3603
|
+
for (const key of ["start", "end"]) {
|
|
3604
|
+
const n = value[key];
|
|
3605
|
+
if (n !== void 0 && (typeof n !== "number" || !Number.isInteger(n) || n < 1)) throw new ValidationError(`read ${key} must be a positive integer line number`);
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
function searchArgv(workspace, action, ripgrep) {
|
|
3609
|
+
const target = confined(workspace, action.path);
|
|
3610
|
+
if (ripgrep) {
|
|
3611
|
+
return [
|
|
3612
|
+
"rg",
|
|
3613
|
+
"-n",
|
|
3614
|
+
"--no-heading",
|
|
3615
|
+
"--color",
|
|
3616
|
+
"never",
|
|
3617
|
+
"--max-columns",
|
|
3618
|
+
"300",
|
|
3619
|
+
"--max-count",
|
|
3620
|
+
String(SEARCH_MAX_PER_FILE),
|
|
3621
|
+
...action.regex ? [] : ["-F"],
|
|
3622
|
+
...action.ignoreCase ? ["-i"] : [],
|
|
3623
|
+
...action.glob ? ["-g", action.glob] : [],
|
|
3624
|
+
...SKIPPED_DIRS.flatMap((dir) => ["-g", `!${dir}/`]),
|
|
3625
|
+
"-e",
|
|
3626
|
+
action.pattern,
|
|
3627
|
+
"--",
|
|
3628
|
+
target
|
|
3629
|
+
];
|
|
3630
|
+
}
|
|
3631
|
+
return [
|
|
3632
|
+
"grep",
|
|
3633
|
+
"-rnI",
|
|
3634
|
+
...action.regex ? ["-E"] : ["-F"],
|
|
3635
|
+
...action.ignoreCase ? ["-i"] : [],
|
|
3636
|
+
...action.glob ? [`--include=${action.glob}`] : [],
|
|
3637
|
+
...SKIPPED_DIRS.map((dir) => `--exclude-dir=${dir}`),
|
|
3638
|
+
"-e",
|
|
3639
|
+
action.pattern,
|
|
3640
|
+
"--",
|
|
3641
|
+
target
|
|
3642
|
+
];
|
|
3643
|
+
}
|
|
3644
|
+
function readLines(workspace, action) {
|
|
3645
|
+
const resolved = resolveNamedPath(workspace, action.path, "file");
|
|
3646
|
+
if (resolved.path === void 0) throw new ValidationError(`no such file: ${resolved.note}; search or list to find the right path`);
|
|
3647
|
+
const rel = resolved.path;
|
|
3648
|
+
const absolute = path8.join(workspace, rel);
|
|
3649
|
+
if (!statSync2(absolute).isFile()) throw new ValidationError(`${rel} is a directory; use list`);
|
|
3650
|
+
const bytes = readFileSync6(absolute);
|
|
3651
|
+
if (bytes.includes(0)) throw new ValidationError(`${rel} is binary`);
|
|
3652
|
+
const text = bytes.toString("utf8");
|
|
3653
|
+
const lines = (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n");
|
|
3654
|
+
const start = Math.min(action.start ?? 1, Math.max(lines.length, 1));
|
|
3655
|
+
const end = Math.min(action.end ?? start + READ_DEFAULT_LINES - 1, start + READ_MAX_LINES - 1, lines.length);
|
|
3656
|
+
const width = String(end).length;
|
|
3657
|
+
const body = lines.slice(start - 1, end).map((line, index) => `${String(start + index).padStart(width)} ${line}`).join("\n");
|
|
3658
|
+
const more = end < lines.length ? `
|
|
3659
|
+
(${rel} has ${lines.length} lines; read start=${end + 1} for more)` : "";
|
|
3660
|
+
return `${resolved.note ? `${resolved.note}
|
|
3661
|
+
` : ""}${rel}:${start}-${end}
|
|
3662
|
+
${body}${more}`;
|
|
3663
|
+
}
|
|
3664
|
+
function listEntries(workspace, rel) {
|
|
3665
|
+
const resolved = resolveNamedPath(workspace, rel, "dir");
|
|
3666
|
+
if (resolved.path === void 0) throw new ValidationError(`not a directory: ${resolved.note}`);
|
|
3667
|
+
const base = resolved.path;
|
|
3668
|
+
const root = path8.join(workspace, base);
|
|
3669
|
+
if (!statSync2(root).isDirectory()) throw new ValidationError(`not a directory: ${base}; read it instead`);
|
|
3670
|
+
const out = [];
|
|
3671
|
+
const walk = (dir, depth) => {
|
|
3672
|
+
let entries;
|
|
3673
|
+
try {
|
|
3674
|
+
entries = readdirSync4(dir, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
3675
|
+
} catch {
|
|
3676
|
+
return;
|
|
3677
|
+
}
|
|
3678
|
+
for (const entry of entries) {
|
|
3679
|
+
if (out.length >= LIST_MAX_ENTRIES) return;
|
|
3680
|
+
if (entry.name.startsWith(".") || SKIPPED_DIRS.includes(entry.name)) continue;
|
|
3681
|
+
const shown = path8.relative(workspace, path8.join(dir, entry.name));
|
|
3682
|
+
out.push(entry.isDirectory() ? `${shown}/` : shown);
|
|
3683
|
+
if (entry.isDirectory() && depth < 1) walk(path8.join(dir, entry.name), depth + 1);
|
|
3684
|
+
}
|
|
3685
|
+
};
|
|
3686
|
+
walk(root, 0);
|
|
3687
|
+
const listing = out.length === 0 ? `(${base} is empty)` : out.join("\n") + (out.length >= LIST_MAX_ENTRIES ? "\n(listing truncated; list a subdirectory)" : "");
|
|
3688
|
+
return resolved.note ? `${resolved.note}
|
|
3689
|
+
${listing}` : listing;
|
|
3690
|
+
}
|
|
3691
|
+
var INDEX_MAX_ENTRIES = 5e4;
|
|
3692
|
+
var indexes = /* @__PURE__ */ new Map();
|
|
3693
|
+
function workspaceIndex(workspace) {
|
|
3694
|
+
const cached = indexes.get(workspace);
|
|
3695
|
+
if (cached) return cached;
|
|
3696
|
+
const files = [];
|
|
3697
|
+
const dirs = [];
|
|
3698
|
+
const walk = (dir) => {
|
|
3699
|
+
let entries;
|
|
3700
|
+
try {
|
|
3701
|
+
entries = readdirSync4(dir, { withFileTypes: true });
|
|
3702
|
+
} catch {
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
for (const entry of entries) {
|
|
3706
|
+
if (files.length + dirs.length >= INDEX_MAX_ENTRIES) return;
|
|
3707
|
+
if (entry.name.startsWith(".") || SKIPPED_DIRS.includes(entry.name)) continue;
|
|
3708
|
+
const absolute = path8.join(dir, entry.name);
|
|
3709
|
+
const rel = path8.relative(workspace, absolute);
|
|
3710
|
+
if (entry.isDirectory()) {
|
|
3711
|
+
dirs.push(rel);
|
|
3712
|
+
walk(absolute);
|
|
3713
|
+
} else if (entry.isFile()) {
|
|
3714
|
+
files.push(rel);
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
};
|
|
3718
|
+
walk(workspace);
|
|
3719
|
+
const index = { files, dirs };
|
|
3720
|
+
indexes.set(workspace, index);
|
|
3721
|
+
return index;
|
|
3722
|
+
}
|
|
3723
|
+
function pathCandidates(workspace, rel, kind = "any") {
|
|
3724
|
+
const wanted = rel.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3725
|
+
if (!wanted || wanted === ".") return [];
|
|
3726
|
+
const { files, dirs } = workspaceIndex(workspace);
|
|
3727
|
+
const pool = kind === "file" ? files : kind === "dir" ? dirs : [...files, ...dirs];
|
|
3728
|
+
const suffix = `/${wanted}`;
|
|
3729
|
+
return pool.filter((candidate) => candidate === wanted || candidate.endsWith(suffix)).sort((a, b) => a.length - b.length);
|
|
3730
|
+
}
|
|
3731
|
+
function resolveNamedPath(workspace, rel, kind) {
|
|
3732
|
+
const named = confined(workspace, rel);
|
|
3733
|
+
if (named === "." || existsSync5(path8.join(workspace, named))) return { path: named };
|
|
3734
|
+
const candidates = pathCandidates(workspace, named, kind);
|
|
3735
|
+
if (candidates.length === 1) return { path: candidates[0], note: `(${named} does not exist; using ${candidates[0]})` };
|
|
3736
|
+
if (candidates.length > 1) {
|
|
3737
|
+
return { path: void 0, note: `${named} does not exist; matching paths: ${candidates.slice(0, 8).join(", ")}${candidates.length > 8 ? ", \u2026" : ""}` };
|
|
3738
|
+
}
|
|
3739
|
+
return { path: void 0, note: `${named} does not exist and no path ends with it` };
|
|
3740
|
+
}
|
|
3741
|
+
function looksLikeRegex(pattern) {
|
|
3742
|
+
return /\||\.\*|\\[bswdBSWD]|\[[^\]]+\]|^\^|\$$|\(\?|\.\+/.test(pattern);
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
// src/research/structured-call.ts
|
|
3746
|
+
async function structuredSearch(shell, action, timeoutSeconds) {
|
|
3747
|
+
const notes = [];
|
|
3748
|
+
const resolved = resolveNamedPath(shell.workspace, action.path, "any");
|
|
3749
|
+
let target = resolved.path;
|
|
3750
|
+
if (target === void 0) {
|
|
3751
|
+
notes.push(`(${resolved.note}; searched the whole workspace)`);
|
|
3752
|
+
target = ".";
|
|
3753
|
+
} else if (resolved.note) {
|
|
3754
|
+
notes.push(resolved.note);
|
|
3755
|
+
}
|
|
3756
|
+
const ripgrep = shell.hasTool("rg");
|
|
3757
|
+
const run = (regex) => shell.runTool(searchArgv(shell.workspace, { ...action, path: target, regex }, ripgrep), timeoutSeconds);
|
|
3758
|
+
let output = await run(action.regex);
|
|
3759
|
+
if (output === "(no matches)" && action.regex === void 0 && looksLikeRegex(action.pattern)) {
|
|
3760
|
+
try {
|
|
3761
|
+
const asRegex = await run(true);
|
|
3762
|
+
if (asRegex !== "(no matches)") {
|
|
3763
|
+
notes.push("(no literal match; searched as a regular expression)");
|
|
3764
|
+
output = asRegex;
|
|
3765
|
+
}
|
|
3766
|
+
} catch {
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
return notes.length > 0 ? `${notes.join("\n")}
|
|
3770
|
+
${output}` : output;
|
|
3771
|
+
}
|
|
3772
|
+
function structuredCall(shell, action, timeoutSeconds) {
|
|
3773
|
+
const name = String(action.action);
|
|
3774
|
+
const args = Object.fromEntries(Object.entries(action).filter(([key]) => key !== "action"));
|
|
3775
|
+
const run = async () => {
|
|
3776
|
+
if (name === "read") return readLines(shell.workspace, args);
|
|
3777
|
+
if (name === "list") return listEntries(shell.workspace, args.path);
|
|
3778
|
+
return structuredSearch(shell, args, timeoutSeconds);
|
|
3779
|
+
};
|
|
3780
|
+
return {
|
|
3781
|
+
signature: { [name]: args },
|
|
3782
|
+
run,
|
|
3783
|
+
record: { name, raw_name: name, arguments: args },
|
|
3784
|
+
ledger: (status) => ({ action: name, status, source: JSON.stringify(args) })
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
// src/research/entry.ts
|
|
3789
|
+
import path9 from "node:path";
|
|
3790
|
+
import { pathToFileURL } from "node:url";
|
|
3791
|
+
function isEntry(moduleUrl, name, script = process.argv[1]) {
|
|
3792
|
+
if (!script || moduleUrl !== pathToFileURL(script).href) return false;
|
|
3793
|
+
return path9.basename(script).replace(/\.[cm]?[jt]s$/, "") === name;
|
|
3794
|
+
}
|
|
3795
|
+
|
|
2370
3796
|
// src/research/bash-agent.ts
|
|
2371
3797
|
function parseAction(content) {
|
|
2372
3798
|
const value = parseJsonAction(content);
|
|
@@ -2381,8 +3807,11 @@ function parseAction(content) {
|
|
|
2381
3807
|
if (action === "finish" && typeof value.answer !== "string") {
|
|
2382
3808
|
throw new ValidationError("finish action requires a string answer");
|
|
2383
3809
|
}
|
|
2384
|
-
if (action
|
|
2385
|
-
|
|
3810
|
+
if (action === "search") validateSearch(value);
|
|
3811
|
+
if (action === "read") validateRead(value);
|
|
3812
|
+
if (action === "list" && value.path !== void 0 && typeof value.path !== "string") throw new ValidationError("list path must be a string");
|
|
3813
|
+
if (!["shell", "search", "read", "list", "page", "finish"].includes(String(action))) {
|
|
3814
|
+
throw new ValidationError(`unsupported action for bash adapter: ${String(action)} (use search, read, list, shell, page or finish)`);
|
|
2386
3815
|
}
|
|
2387
3816
|
return value;
|
|
2388
3817
|
}
|
|
@@ -2398,12 +3827,16 @@ async function runShell(root, commandText, timeoutSeconds, shell) {
|
|
|
2398
3827
|
var BASH_ADAPTER = {
|
|
2399
3828
|
defaultToolTimeoutSeconds: 30,
|
|
2400
3829
|
systemPrompt: () => BASH_SYSTEM_PROMPT,
|
|
3830
|
+
briefing: workspaceOverview,
|
|
2401
3831
|
parseAction,
|
|
2402
3832
|
open: (workspace) => {
|
|
2403
3833
|
const shell = new ReadOnlyShell(workspace).enter();
|
|
2404
3834
|
return {
|
|
2405
3835
|
shell,
|
|
2406
3836
|
toolCall: (action, _workspace, runtime) => {
|
|
3837
|
+
if (action.action === "search" || action.action === "read" || action.action === "list") {
|
|
3838
|
+
return structuredCall(shell, action, runtime.tool_timeout_seconds);
|
|
3839
|
+
}
|
|
2407
3840
|
const commandText = String(action.command ?? "");
|
|
2408
3841
|
return {
|
|
2409
3842
|
signature: { command: commandText },
|
|
@@ -2424,7 +3857,7 @@ Commands remaining: ${count}. Finish now if the task is answerable; do not repea
|
|
|
2424
3857
|
sandbox: session?.shell?.kind ?? "restricted-bash"
|
|
2425
3858
|
})
|
|
2426
3859
|
};
|
|
2427
|
-
if (
|
|
3860
|
+
if (isEntry(import.meta.url, "bash-agent")) {
|
|
2428
3861
|
process.exitCode = await runAdapter(BASH_ADAPTER);
|
|
2429
3862
|
}
|
|
2430
3863
|
export {
|