@magland/gentl 0.0.0-stage → 0.1.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/dist/hub.js ADDED
@@ -0,0 +1,147 @@
1
+ // The hub client: fetch tasks from a gentl hub, upload runs to the hub their task came from, and remember the
2
+ // name and passcode to use for each hub (in ~/.config/gentl/hubs.json, readable only by you).
3
+ import { execFileSync } from "node:child_process";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import * as readline from "node:readline";
8
+ /** A task page URL (https://<hub>/tasks/<id>), or null if `s` is not one. */
9
+ export function parseHubTaskUrl(s) {
10
+ const m = s.match(/^(https?:\/\/[^/]+)\/tasks\/(\d+)\/?$/);
11
+ return m ? { hub: m[1], id: Number(m[2]) } : null;
12
+ }
13
+ export async function fetchHubTask(url) {
14
+ const p = parseHubTaskUrl(url);
15
+ if (!p)
16
+ throw new Error(`not a hub task URL: ${url} (expected https://<hub>/tasks/<id>)`);
17
+ const r = await fetch(`${p.hub}/api/tasks/${p.id}`);
18
+ if (!r.ok)
19
+ throw new Error(`could not fetch task ${p.id} from ${p.hub}: HTTP ${r.status} ${(await r.text()).slice(0, 200)}`);
20
+ const { task } = (await r.json());
21
+ return { hub: p.hub, id: task.id, title: task.title, text: task.text.trim(), url: `${p.hub}/tasks/${task.id}` };
22
+ }
23
+ // ---------- credentials ----------
24
+ function configPath() {
25
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
26
+ return path.join(base, "gentl", "hubs.json");
27
+ }
28
+ function readConfig() {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(configPath(), "utf8"));
31
+ }
32
+ catch {
33
+ return { hubs: {} };
34
+ }
35
+ }
36
+ function saveCredentials(hub, creds) {
37
+ const file = configPath();
38
+ const config = readConfig();
39
+ config.hubs[hub] = creds;
40
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
41
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
42
+ fs.chmodSync(file, 0o600);
43
+ }
44
+ function ask(question, hidden = false) {
45
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
46
+ let muted = false;
47
+ if (hidden)
48
+ rl._writeToOutput = (s) => { if (!muted)
49
+ process.stdout.write(s); };
50
+ return new Promise((resolve, reject) => {
51
+ let answered = false;
52
+ rl.on("close", () => { if (!answered)
53
+ reject(new Error("cancelled")); });
54
+ rl.question(question, (answer) => {
55
+ answered = true;
56
+ rl.close();
57
+ if (hidden)
58
+ process.stdout.write("\n");
59
+ resolve(answer.trim());
60
+ });
61
+ muted = true;
62
+ });
63
+ }
64
+ function gitUserName() {
65
+ try {
66
+ return execFileSync("git", ["config", "user.name"], { encoding: "utf8" }).trim();
67
+ }
68
+ catch {
69
+ return "";
70
+ }
71
+ }
72
+ /** Name and passcode for a hub: from GENTL_HUB_USER and GENTL_HUB_PASSCODE, the saved config, or by asking. */
73
+ export async function hubCredentials(hub) {
74
+ const saved = readConfig().hubs[hub];
75
+ const user = process.env.GENTL_HUB_USER || saved?.user;
76
+ const passcode = process.env.GENTL_HUB_PASSCODE || saved?.passcode;
77
+ if (user && passcode)
78
+ return { user, passcode };
79
+ if (!process.stdin.isTTY)
80
+ throw new Error(`no credentials for ${hub}: set GENTL_HUB_USER and GENTL_HUB_PASSCODE, or run gentl upload in a terminal once`);
81
+ console.log(`Uploading to ${hub} needs your name (shown with your runs) and the hub's passcode.`);
82
+ const def = user || gitUserName();
83
+ const name = (await ask(`Your name${def ? ` [${def}]` : ""}: `)) || def;
84
+ if (!name)
85
+ throw new Error("a name is required");
86
+ const code = passcode || (await ask("Passcode: ", true));
87
+ if (!code)
88
+ throw new Error("a passcode is required");
89
+ const creds = { user: name, passcode: code };
90
+ const remember = (await ask(`Remember them in ${configPath()}? [y/n] `)).toLowerCase();
91
+ if (remember === "y" || remember === "yes")
92
+ saveCredentials(hub, creds);
93
+ return creds;
94
+ }
95
+ // ---------- upload ----------
96
+ function readJsonl(file) {
97
+ return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
98
+ }
99
+ /** The hub a run's task came from, or null for a local task. */
100
+ export function runHub(runDir) {
101
+ const s = JSON.parse(fs.readFileSync(path.join(runDir, "summary.json"), "utf8"));
102
+ return s.task_source?.kind === "hub" ? s.task_source.hub : null;
103
+ }
104
+ /** The upload payload: summary, evaluation, final state, and each turn as the agent saw it (not full results). */
105
+ export function buildUpload(runDir, user) {
106
+ const read = (f) => (fs.existsSync(path.join(runDir, f)) ? fs.readFileSync(path.join(runDir, f), "utf8") : null);
107
+ const summary = JSON.parse(read("summary.json"));
108
+ const evaluation = read("evaluation.json");
109
+ const turns = readJsonl(path.join(runDir, "events.jsonl")).filter((e) => e.type === "turn").map((e) => ({
110
+ turn: e.turn,
111
+ notes_tokens: e.notes_tokens,
112
+ result_tokens: e.result_tokens,
113
+ notes: e.notes ?? null,
114
+ notes_ops: e.notes_ops ?? null,
115
+ action: e.parsed?.action ?? null,
116
+ result_shown: e.result_shown ?? null,
117
+ result_full_chars: typeof e.result_full === "string" ? e.result_full.length : null,
118
+ parse_error: e.parse_error ?? null,
119
+ raw_response: e.parse_error ? e.response : null,
120
+ notes_reminder: !!e.notes_reminder,
121
+ }));
122
+ return { user, summary, evaluation: evaluation ? JSON.parse(evaluation) : null, final_state: read("final-state.md"), turns };
123
+ }
124
+ /** Upload a run to the hub its task came from. Returns the run's page URL. */
125
+ export async function uploadRun(runDir, creds) {
126
+ const hub = runHub(runDir);
127
+ if (!hub)
128
+ throw new Error(`${runDir}: its task did not come from a hub, so there is nowhere to upload it`);
129
+ const r = await fetch(`${hub}/api/runs`, {
130
+ method: "POST",
131
+ headers: { "content-type": "application/json", authorization: `Bearer ${creds.passcode}` },
132
+ body: JSON.stringify(buildUpload(runDir, creds.user)),
133
+ });
134
+ const text = await r.text();
135
+ if (r.status !== 201)
136
+ throw new Error(`upload to ${hub} failed: HTTP ${r.status} ${text.slice(0, 300)}`);
137
+ return `${hub}/runs/${JSON.parse(text).id}`;
138
+ }
139
+ /** Run directories under `dir`: the directory itself if it is a run, else its subdirectories that are runs (a batch). */
140
+ export function runDirsIn(dir) {
141
+ if (fs.existsSync(path.join(dir, "summary.json")))
142
+ return [dir];
143
+ return fs.readdirSync(dir, { withFileTypes: true })
144
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "summary.json")))
145
+ .map((e) => path.join(dir, e.name))
146
+ .sort();
147
+ }
@@ -0,0 +1,116 @@
1
+ // The local copy: clones of your repo's origin URL are served from a temporary bare mirror of your checkout,
2
+ // including uncommitted changes as an extra commit, so you can edit docs and rerun without pushing.
3
+ // The sandbox's git config gets url.<mirror>.insteadOf <origin URL variants>, so the agent runs the git clone the
4
+ // task names and gets your working copy.
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import { execLocal } from "./sandbox.js";
9
+ /** Info recorded in summary.json. */
10
+ export function localCopyInfo(lc) {
11
+ return { repo: lc.repo, origin: lc.origin, urls: lc.urls, branch: lc.branch, head: lc.head,
12
+ uncommitted_commit: lc.uncommitted_commit, uncommitted_files: lc.uncommitted_files };
13
+ }
14
+ function git(args, opts = {}) {
15
+ return execLocal("git", args, { cwd: opts.cwd, env: opts.env ? { ...process.env, ...opts.env } : undefined });
16
+ }
17
+ /** URL forms a task might use for the same repo: https, http, scp-style ssh and ssh://, each with and without .git. */
18
+ export function urlVariants(origin) {
19
+ const m = origin.match(/^(?:https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)|git@([^:]+):(.+?)|ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?))(?:\.git)?\/?$/);
20
+ if (!m)
21
+ return [origin];
22
+ const host = m[1] ?? m[3] ?? m[5];
23
+ const repoPath = m[2] ?? m[4] ?? m[6];
24
+ const forms = [`https://${host}/${repoPath}`, `http://${host}/${repoPath}`, `git@${host}:${repoPath}`, `ssh://git@${host}/${repoPath}`];
25
+ return [...new Set([origin, ...forms.flatMap((f) => [f, `${f}.git`])])];
26
+ }
27
+ /** URLs in the task text that a variant would also rewrite by prefix (e.g. owner/foo-extra for owner/foo). */
28
+ export function prefixCollisions(task, urls) {
29
+ const found = new Set();
30
+ for (const u of urls) {
31
+ const re = new RegExp(u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "([A-Za-z0-9_-][^\\s)\"'`]*)", "g");
32
+ for (const m of task.matchAll(re))
33
+ found.add(m[0]);
34
+ }
35
+ return [...found];
36
+ }
37
+ /** The repo to mirror: the git checkout containing `dir`, if it has an origin. Returns a reason when there is none. */
38
+ export function findRepo(dir) {
39
+ const top = git(["rev-parse", "--show-toplevel"], { cwd: dir });
40
+ if (!top.ok)
41
+ return { reason: `${dir} is not in a git repo` };
42
+ const repo = top.out.trim();
43
+ const origin = git(["remote", "get-url", "origin"], { cwd: repo });
44
+ if (!origin.ok)
45
+ return { reason: `${repo} has no origin remote` };
46
+ return { repo, origin: origin.out.trim() };
47
+ }
48
+ /** Make the mirror: a bare clone of the checkout, with uncommitted changes (tracked and untracked, not ignored)
49
+ * committed on top of HEAD. Your own repo, index and working tree are not modified. */
50
+ export function prepareLocalCopy(repo, origin) {
51
+ const mirrorDir = fs.mkdtempSync(path.join(os.tmpdir(), "gentl-mirror-"));
52
+ const name = (origin.replace(/\/$/, "").split(/[/:]/).pop() ?? "repo").replace(/\.git$/, "").replace(/[^A-Za-z0-9_.-]/g, "-") || "repo";
53
+ const mirrorName = `${name}.git`;
54
+ const mirror = path.join(mirrorDir, mirrorName);
55
+ const clone = git(["clone", "--quiet", "--bare", repo, mirror]);
56
+ if (!clone.ok)
57
+ throw new Error(`could not mirror ${repo}: ${clone.out.trim()}`);
58
+ const branchRef = git(["symbolic-ref", "-q", "HEAD"], { cwd: repo });
59
+ const branch = branchRef.ok ? branchRef.out.trim().replace(/^refs\/heads\//, "") : null;
60
+ const head = git(["rev-parse", "HEAD"], { cwd: repo }).out.trim();
61
+ // Honor the checkout's own excludes (.git/info/exclude), which git would not read with GIT_DIR set to the mirror.
62
+ const exclude = git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { cwd: repo }).out.trim();
63
+ if (exclude && fs.existsSync(exclude)) {
64
+ fs.mkdirSync(path.join(mirror, "info"), { recursive: true });
65
+ fs.copyFileSync(exclude, path.join(mirror, "info", "exclude"));
66
+ }
67
+ // Build a tree of the working copy with a temporary index, writing objects into the mirror only.
68
+ const index = path.join(mirrorDir, "index");
69
+ const env = { GIT_DIR: mirror, GIT_WORK_TREE: repo, GIT_INDEX_FILE: index };
70
+ const steps = [["read-tree", head], ["add", "-A"]];
71
+ for (const args of steps) {
72
+ const r = git(args, { cwd: repo, env });
73
+ if (!r.ok)
74
+ throw new Error(`could not record uncommitted changes (git ${args.join(" ")}): ${r.out.trim()}`);
75
+ }
76
+ const tree = git(["write-tree"], { cwd: repo, env }).out.trim();
77
+ fs.rmSync(index, { force: true });
78
+ const headTree = git(["--git-dir", mirror, "rev-parse", `${head}^{tree}`]).out.trim();
79
+ let uncommitted = null;
80
+ let files = 0;
81
+ if (tree && tree !== headTree) {
82
+ files = git(["--git-dir", mirror, "diff-tree", "-r", "--name-only", headTree, tree]).out.split("\n").filter(Boolean).length;
83
+ // An ordinary-looking commit under your identity, so the agent has no reason to dig into where its clone came from.
84
+ const name = git(["config", "user.name"], { cwd: repo }).out.trim() || "gentl";
85
+ const email = git(["config", "user.email"], { cwd: repo }).out.trim() || "gentl@localhost";
86
+ const ident = { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email };
87
+ const c = git(["--git-dir", mirror, "commit-tree", tree, "-p", head, "-m", "Work in progress"], { env: ident });
88
+ if (!c.ok)
89
+ throw new Error(`could not commit uncommitted changes: ${c.out.trim()}`);
90
+ uncommitted = c.out.trim();
91
+ const ref = branch ? ["update-ref", `refs/heads/${branch}`, uncommitted] : ["update-ref", "--no-deref", "HEAD", uncommitted];
92
+ git(["--git-dir", mirror, ...ref]);
93
+ }
94
+ return { repo, origin, urls: urlVariants(origin), branch, head, uncommitted_commit: uncommitted,
95
+ uncommitted_files: files, mirrorDir, mirrorName };
96
+ }
97
+ export function describeLocalCopy(lc) {
98
+ const at = `${lc.branch ?? "detached HEAD"} @ ${lc.head.slice(0, 7)}`;
99
+ const extra = lc.uncommitted_commit ? ` + uncommitted changes in ${lc.uncommitted_files} file(s)` : "";
100
+ return `clones of ${lc.origin} are served from your checkout ${lc.repo} (${at}${extra})`;
101
+ }
102
+ /** Point the sandbox's git at the mirror. `mirrorPath` is where the mirror is seen inside the sandbox. */
103
+ export function configureLocalCopy(sb, lc, mirrorPath) {
104
+ const url = `file://${mirrorPath}`;
105
+ for (const u of lc.urls) {
106
+ const r = sb.exec(["git", "config", "--global", "--add", `url.${url}.insteadOf`, u]);
107
+ if (!r.ok)
108
+ return `could not configure git in the sandbox: ${r.out.trim()}`;
109
+ }
110
+ // The mirror belongs to another user inside a container; without this git refuses to clone it.
111
+ sb.exec(["git", "config", "--global", "--add", "safe.directory", mirrorPath]);
112
+ return null;
113
+ }
114
+ export function removeLocalCopy(lc) {
115
+ fs.rmSync(lc.mirrorDir, { recursive: true, force: true });
116
+ }
package/dist/model.js ADDED
@@ -0,0 +1,112 @@
1
+ // Model backends. "openrouter": an OpenAI-compatible chat completions endpoint (OpenRouter by default).
2
+ // "claude-code": the user's Claude Code login, through `claude -p` (see claude.ts).
3
+ import { ClaudeCodeClient } from "./claude.js";
4
+ /** `cwd` is where a claude-code call runs; ideally a directory that matches the agent's working directory. */
5
+ export function createModelClient(cfg, schema, log, cwd) {
6
+ return cfg.backend === "claude-code" ? new ClaudeCodeClient(cfg.model, schema, log, cwd) : new OpenAIClient(cfg, schema, log);
7
+ }
8
+ export class Interrupted extends Error {
9
+ constructor() {
10
+ super("interrupted");
11
+ }
12
+ }
13
+ export const RETRY_DELAYS_S = [2, 5, 15];
14
+ /** Wait before a retry; Ctrl-C ends the wait. */
15
+ export async function retryWait(attempt, why, signal, log) {
16
+ const s = RETRY_DELAYS_S[attempt];
17
+ log(`${why}; retrying in ${s} s`);
18
+ await new Promise((resolve, reject) => {
19
+ const t = setTimeout(resolve, s * 1000);
20
+ signal.addEventListener("abort", () => {
21
+ clearTimeout(t);
22
+ reject(new Interrupted());
23
+ }, { once: true });
24
+ });
25
+ }
26
+ class OpenAIClient {
27
+ cfg;
28
+ schema;
29
+ log;
30
+ structured = true;
31
+ openrouter;
32
+ /** `schema` is the JSON schema requested for responses (structured output). */
33
+ constructor(cfg, schema, log) {
34
+ this.cfg = cfg;
35
+ this.schema = schema;
36
+ this.log = log;
37
+ this.openrouter = cfg.baseUrl.includes("openrouter.ai");
38
+ }
39
+ async call(system, user, signal) {
40
+ for (let attempt = 0;; attempt++) {
41
+ let r;
42
+ try {
43
+ r = await fetch(`${this.cfg.baseUrl.replace(/\/$/, "")}/chat/completions`, {
44
+ method: "POST",
45
+ headers: { Authorization: `Bearer ${this.cfg.apiKey}`, "Content-Type": "application/json" },
46
+ body: JSON.stringify(this.body(system, user)),
47
+ signal,
48
+ });
49
+ }
50
+ catch (e) {
51
+ if (signal.aborted)
52
+ throw new Interrupted();
53
+ if (attempt >= RETRY_DELAYS_S.length)
54
+ throw new Error(`model call failed: ${e.message}`);
55
+ await retryWait(attempt, `network error: ${e.message}`, signal, this.log);
56
+ continue;
57
+ }
58
+ const raw = await r.text().catch(() => "");
59
+ if (r.ok) {
60
+ let d;
61
+ try {
62
+ d = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ d = { error: { message: `response is not JSON: ${raw.slice(0, 200)}` } };
66
+ }
67
+ if (!d.error && d.choices?.[0]?.message) {
68
+ return { text: d.choices[0].message.content ?? "", usage: d.usage ?? {} };
69
+ }
70
+ if (this.structured) {
71
+ this.fallBack(`error in response: ${JSON.stringify(d.error ?? d).slice(0, 200)}`);
72
+ attempt--;
73
+ continue;
74
+ }
75
+ if (attempt >= RETRY_DELAYS_S.length)
76
+ throw new Error(`model call failed: ${JSON.stringify(d.error ?? d).slice(0, 500)}`);
77
+ await retryWait(attempt, `error in response: ${JSON.stringify(d.error ?? d).slice(0, 200)}`, signal, this.log);
78
+ continue;
79
+ }
80
+ if (this.structured && [400, 404, 422].includes(r.status)) {
81
+ this.fallBack(`HTTP ${r.status}: ${raw.slice(0, 200)}`);
82
+ attempt--;
83
+ continue;
84
+ }
85
+ const retryable = r.status === 429 || r.status >= 500;
86
+ if (!retryable || attempt >= RETRY_DELAYS_S.length)
87
+ throw new Error(`model call failed: HTTP ${r.status} ${raw.slice(0, 500)}`);
88
+ await retryWait(attempt, `HTTP ${r.status}`, signal, this.log);
89
+ }
90
+ }
91
+ fallBack(why) {
92
+ this.structured = false;
93
+ this.log(`structured output failed (${why}); using plain text from now on`);
94
+ }
95
+ body(system, user) {
96
+ const body = {
97
+ model: this.cfg.model,
98
+ max_tokens: 8000,
99
+ messages: [{ role: "system", content: system }, { role: "user", content: user }],
100
+ };
101
+ if (this.structured) {
102
+ body.response_format = { type: "json_schema", json_schema: { name: this.schema.name, strict: true, schema: this.schema.schema } };
103
+ }
104
+ if (this.openrouter) {
105
+ body.usage = { include: true };
106
+ // Only route to providers that honor response_format.
107
+ if (this.structured)
108
+ body.provider = { require_parameters: true };
109
+ }
110
+ return body;
111
+ }
112
+ }
@@ -0,0 +1,188 @@
1
+ // What the agent sees and how its responses are parsed.
2
+ export const ACTION_TYPES = ["run", "write_file", "edit_file", "ask", "finish"];
3
+ export const ASK_RESULT = "No one is available to answer. Your question is recorded as a gap in the docs. Decide from the docs and code as best you can.";
4
+ export const DEFAULT_MAX_CHARS = 2000;
5
+ export const MAX_MAX_CHARS = 20000;
6
+ export const CMD_TIMEOUT_S = 300;
7
+ export function systemPrompt(workdir, platform) {
8
+ return `You are a newcomer to some software, carrying out a task on a ${platform} machine with internet access. Your working directory is ${workdir}; it starts empty. Tools include git, curl and w3m (\`w3m -dump URL\` renders a web page as plain text).
9
+
10
+ Treat the software as unfamiliar. Go by its docs and code, not by what you remember about it, since the version here may differ.
11
+
12
+ This is a test of how readable the software and its docs are. We count the tokens you read (every result you see, plus your notes each turn) as a proxy for the effort a human newcomer would need, and we collect what was missing or unclear. So read in a targeted way (grep, sed -n, head, --help) rather than dumping whole files or long pages. When the docs and code don't answer something you need, don't keep searching or guess silently: use the ask action, then make a reasonable decision and move on. A gap in the docs is useful feedback, not a failure on your part.
13
+
14
+ You have no memory between turns. Each turn you see only the task, your notes, and your last action with its result. The result is shown once and is gone on the next turn; your notes are all you carry forward. They are your working memory, like what a person can hold in their head at once. They start empty.
15
+
16
+ So every turn has two steps. First, update your notes: if the result (or the task, on the first turn) tells you something you will need later, such as a fact, a path, a command that worked, what is done or what is next, record it and set "notes_keep": false. Only if nothing is worth keeping, set "notes_keep": true. Second, choose the action. Keep the notes small, with only what you still need; they are counted every turn.
17
+
18
+ Each turn, respond with one JSON object and nothing else:
19
+ {"notes_keep": false, "notes": "...", "notes_edit": {"old": "...", "new": "..."}, "notes_append": "...", "action": {...}}
20
+ "notes_keep" and "action" are required. With "notes_keep": false, give at least one of "notes" (replaces your notes), "notes_edit" (replaces text that occurs exactly once in them) and "notes_append" (adds a line at the end); they are applied in that order. "notes_keep": true leaves your notes as they are.
21
+
22
+ Actions:
23
+ - {"type": "run", "cmd": "..."}: run with bash in your working directory. Each command starts a fresh shell (cd and exported variables do not persist) with no input. stdout and stderr are combined; output longer than ${DEFAULT_MAX_CHARS} characters is cut in the middle (add "max_chars": N, at most ${MAX_MAX_CHARS}, to see more). Timeout ${CMD_TIMEOUT_S} s.
24
+ - {"type": "write_file", "path": "...", "content": "..."}: create or overwrite a file. Relative paths are under your working directory.
25
+ - {"type": "edit_file", "path": "...", "old": "...", "new": "..."}: replace text that occurs exactly once in the file.
26
+ - {"type": "ask", "question": "..."}: ask the maintainers something the docs and code don't tell you. No one will answer during this test; the question is recorded as feedback on the docs.
27
+ - {"type": "finish", "success": true|false, "summary": "...", "friction": "..."}: end the task. Verify your work before finishing. If success is true, the summary must cite the evidence: what you ran and what it showed. "friction" (optional) is what in the docs was missing, wrong or unclear, and where you had to guess.
28
+
29
+ A person watching may decline an action; the result then says so, with their reason if they gave one.`;
30
+ }
31
+ /** JSON schema for the response, sent to endpoints that support structured output. Each action is its own
32
+ * variant with its fields required, since optional fields make grammar compilation slow (it timed out on Anthropic). */
33
+ const obj = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
34
+ const str = { type: "string" };
35
+ const actionType = (t) => ({ type: "string", enum: [t] });
36
+ export const RESPONSE_SCHEMA = obj({
37
+ notes_keep: { type: "boolean" },
38
+ notes: str,
39
+ notes_edit: obj({ old: str, new: str }, ["old", "new"]),
40
+ notes_append: str,
41
+ action: {
42
+ anyOf: [
43
+ obj({ type: actionType("run"), cmd: str, max_chars: { type: "integer" } }, ["type", "cmd"]),
44
+ obj({ type: actionType("write_file"), path: str, content: str }, ["type", "path", "content"]),
45
+ obj({ type: actionType("edit_file"), path: str, old: str, new: str }, ["type", "path", "old", "new"]),
46
+ obj({ type: actionType("ask"), question: str }, ["type", "question"]),
47
+ obj({ type: actionType("finish"), success: { type: "boolean" }, summary: str, friction: str }, ["type", "success", "summary"]),
48
+ ],
49
+ },
50
+ }, ["notes_keep", "action"]);
51
+ function isObject(v) {
52
+ return typeof v === "object" && v !== null && !Array.isArray(v);
53
+ }
54
+ /** Index of the brace closing the object that starts at `start`, or -1. */
55
+ function matchBrace(s, start) {
56
+ let depth = 0;
57
+ let inString = false;
58
+ for (let j = start; j < s.length; j++) {
59
+ const c = s[j];
60
+ if (inString) {
61
+ if (c === "\\")
62
+ j++;
63
+ else if (c === '"')
64
+ inString = false;
65
+ }
66
+ else if (c === '"')
67
+ inString = true;
68
+ else if (c === "{")
69
+ depth++;
70
+ else if (c === "}" && --depth === 0)
71
+ return j;
72
+ }
73
+ return -1;
74
+ }
75
+ /** The first JSON object in the text that satisfies `accept`; anything around it is ignored. */
76
+ export function parseFirstJson(text, accept) {
77
+ for (let i = text.indexOf("{"); i !== -1; i = text.indexOf("{", i + 1)) {
78
+ const end = matchBrace(text, i);
79
+ if (end === -1)
80
+ continue;
81
+ let v;
82
+ try {
83
+ v = JSON.parse(text.slice(i, end + 1));
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ if (isObject(v) && accept(v))
89
+ return v;
90
+ }
91
+ throw new Error("no matching JSON object was found");
92
+ }
93
+ /** The first JSON object in the text that contains an "action" object. */
94
+ export function parseResponse(text) {
95
+ try {
96
+ return parseFirstJson(text, (v) => isObject(v.action));
97
+ }
98
+ catch {
99
+ throw new Error('no JSON object with an "action" object was found');
100
+ }
101
+ }
102
+ export function countOccurrences(text, sub) {
103
+ return sub === "" ? 0 : text.split(sub).length - 1;
104
+ }
105
+ /** Whether the response makes a notes decision: an operation, or "notes_keep": true. */
106
+ export function hasNotesDecision(r) {
107
+ return r.notes !== undefined || r.notes_edit !== undefined || r.notes_append !== undefined || r.notes_keep === true;
108
+ }
109
+ /** The reminder sent, within the same turn, when a response has no notes decision. */
110
+ export const NOTES_REMINDER = 'Your response did not update your notes or set "notes_keep": true. The result above will be gone on the next turn. Respond again with your notes update ("notes", "notes_edit" or "notes_append"), or "notes_keep": true if nothing is worth keeping, together with your action.';
111
+ /** Apply the notes operations in order (rewrite, edit, append). Problems are returned as feedback for the agent. */
112
+ export function applyNotes(notes, r) {
113
+ const ops = [];
114
+ const feedback = [];
115
+ if (r.notes !== undefined) {
116
+ if (typeof r.notes === "string") {
117
+ notes = r.notes;
118
+ ops.push("rewritten");
119
+ }
120
+ else
121
+ feedback.push('"notes" must be a string; it was ignored.');
122
+ }
123
+ if (r.notes_edit !== undefined) {
124
+ const e = r.notes_edit;
125
+ if (!isObject(e) || typeof e.old !== "string" || typeof e.new !== "string") {
126
+ feedback.push('"notes_edit" must be {"old": string, "new": string}; it was ignored.');
127
+ }
128
+ else {
129
+ const n = countOccurrences(notes, e.old);
130
+ if (n !== 1)
131
+ feedback.push(`notes_edit failed: the old text occurs ${n} times in your notes; it must occur exactly once.`);
132
+ else {
133
+ const replacement = e.new;
134
+ notes = notes.replace(e.old, () => replacement);
135
+ ops.push("edited");
136
+ }
137
+ }
138
+ }
139
+ if (r.notes_append !== undefined) {
140
+ if (typeof r.notes_append === "string") {
141
+ notes = notes ? `${notes}\n${r.notes_append}` : r.notes_append;
142
+ ops.push("appended");
143
+ }
144
+ else
145
+ feedback.push('"notes_append" must be a string; it was ignored.');
146
+ }
147
+ return { notes, ops, feedback };
148
+ }
149
+ /** Why the action is malformed, or null if it is fine. */
150
+ export function validateAction(a) {
151
+ const str = (k) => typeof a[k] === "string";
152
+ switch (a.type) {
153
+ case "run":
154
+ if (!str("cmd"))
155
+ return 'run needs "cmd" (string)';
156
+ if (a.max_chars !== undefined && typeof a.max_chars !== "number")
157
+ return '"max_chars" must be a number';
158
+ return null;
159
+ case "write_file":
160
+ return str("path") && str("content") ? null : 'write_file needs "path" and "content" (strings)';
161
+ case "edit_file":
162
+ return str("path") && str("old") && str("new") ? null : 'edit_file needs "path", "old" and "new" (strings)';
163
+ case "ask":
164
+ return str("question") ? null : 'ask needs "question" (string)';
165
+ case "finish":
166
+ if (typeof a.success !== "boolean" || !str("summary"))
167
+ return 'finish needs "success" (boolean) and "summary" (string)';
168
+ if (a.friction !== undefined && !str("friction"))
169
+ return '"friction" must be a string';
170
+ return null;
171
+ default:
172
+ return `unknown action type ${JSON.stringify(a.type)}; use ${ACTION_TYPES.join(", ")}`;
173
+ }
174
+ }
175
+ /** The action as echoed back to the agent on the next turn. Files are shown by path only. */
176
+ export function describeAction(a) {
177
+ switch (a.type) {
178
+ case "run":
179
+ return a.max_chars !== undefined && a.max_chars !== DEFAULT_MAX_CHARS ? `run (max_chars ${a.max_chars}): ${a.cmd}` : `run: ${a.cmd}`;
180
+ case "write_file":
181
+ case "edit_file":
182
+ return `${a.type}: ${a.path}`;
183
+ case "ask":
184
+ return `ask: ${a.question}`;
185
+ default:
186
+ return JSON.stringify(a);
187
+ }
188
+ }