s-app-scoder-agent 0.8.21 → 0.9.2
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/README.md +31 -5
- package/dist/agent.js +662 -65
- package/dist/agents-guide.js +201 -0
- package/dist/agents.js +76 -3
- package/dist/compaction.js +12 -4
- package/dist/config.js +16 -2
- package/dist/context.js +15 -3
- package/dist/diagnostics.js +188 -0
- package/dist/index.js +277 -6
- package/dist/mcp-import.js +204 -0
- package/dist/mcp.js +553 -54
- package/dist/memory.js +140 -0
- package/dist/permissions.js +18 -3
- package/dist/prompt.js +204 -84
- package/dist/repo-map.js +177 -0
- package/dist/sandbox.js +6 -5
- package/dist/session.js +29 -8
- package/dist/skills.js +211 -62
- package/dist/snapshot.js +5 -0
- package/dist/steering.js +4 -0
- package/dist/tools.js +374 -57
- package/dist/tui/app.js +79 -28
- package/dist/tui/ask-user-modal.js +25 -0
- package/dist/tui/chat-views.js +15 -8
- package/dist/tui/command-registry.js +284 -9
- package/dist/tui/commands.js +224 -37
- package/dist/tui/markdown.js +170 -12
- package/dist/tui/sidebar.js +36 -13
- package/dist/tui/state.js +101 -2
- package/dist/tui/theme.js +4 -0
- package/dist/tui/transcript.js +145 -0
- package/dist/watch.js +8 -0
- package/dist/webfetch.js +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 (3.2): AGENTS.md hierarchy — project/global conventions injected into
|
|
3
|
+
* the system prompt instead of being rediscovered every run.
|
|
4
|
+
*
|
|
5
|
+
* Sources (in order of increasing specificity):
|
|
6
|
+
* 1. ~/.scoder/AGENTS.md — user-global conventions
|
|
7
|
+
* 2. <workdir>/AGENTS.md — repo root conventions
|
|
8
|
+
* 3. <dir>/AGENTS.md — nearest file walking up from each focus
|
|
9
|
+
* file mentioned in the task (e.g. src/tui/app.tsx → src/tui/, src/, root)
|
|
10
|
+
*
|
|
11
|
+
* Budgets: global + root ≤ 1200 chars each, nested ≤ 800 chars each,
|
|
12
|
+
* total block ≤ 4000 chars. Content is wrapped in a clearly-delimited block
|
|
13
|
+
* and marked "(project conventions — apply unless the task says otherwise)".
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
|
|
18
|
+
const GLOBAL_BUDGET = 1200;
|
|
19
|
+
const ROOT_BUDGET = 1200;
|
|
20
|
+
const NESTED_BUDGET = 800;
|
|
21
|
+
const TOTAL_BUDGET = 4000;
|
|
22
|
+
function readBudget(file, budget) {
|
|
23
|
+
try {
|
|
24
|
+
const st = statSync(file);
|
|
25
|
+
if (!st.isFile())
|
|
26
|
+
return null;
|
|
27
|
+
const raw = readFileSync(file, "utf8").trim();
|
|
28
|
+
if (!raw)
|
|
29
|
+
return null;
|
|
30
|
+
return raw.length <= budget ? raw : `${raw.slice(0, budget)}\n… [truncated — ${raw.length - budget} more chars]`;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const ROOT_NAMES = ["AGENTS.md", "AGENTS.local.md"];
|
|
37
|
+
function rootGuide(workdir) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const globalFile = join(homedir(), ".scoder", "AGENTS.md");
|
|
40
|
+
const global = readBudget(globalFile, GLOBAL_BUDGET);
|
|
41
|
+
if (global)
|
|
42
|
+
out.push({ source: globalFile, content: global });
|
|
43
|
+
for (const name of ROOT_NAMES) {
|
|
44
|
+
const f = join(workdir, name);
|
|
45
|
+
const content = readBudget(f, ROOT_BUDGET);
|
|
46
|
+
if (content) {
|
|
47
|
+
out.push({ source: f, content });
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** Nearest AGENTS.md at or above `focusFile`, stopping at workdir root. */
|
|
54
|
+
function nearestGuide(workdir, focusFile) {
|
|
55
|
+
let dir = dirname(resolve(workdir, focusFile));
|
|
56
|
+
const rootAbs = resolve(workdir);
|
|
57
|
+
for (;;) {
|
|
58
|
+
const root = normalize(rootAbs);
|
|
59
|
+
const current = normalize(dir);
|
|
60
|
+
if (current !== root && !current.startsWith(`${root}${sep}`))
|
|
61
|
+
return null;
|
|
62
|
+
for (const name of ROOT_NAMES) {
|
|
63
|
+
const f = join(dir, name);
|
|
64
|
+
const content = readBudget(f, NESTED_BUDGET);
|
|
65
|
+
if (content)
|
|
66
|
+
return { source: f, content };
|
|
67
|
+
}
|
|
68
|
+
if (normalize(dir) === normalize(rootAbs))
|
|
69
|
+
return null;
|
|
70
|
+
const parent = dirname(dir);
|
|
71
|
+
if (parent === dir)
|
|
72
|
+
return null;
|
|
73
|
+
dir = parent;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Extract path-like tokens from a task description (relative file mentions). */
|
|
77
|
+
export function extractFocusFiles(task, workdir) {
|
|
78
|
+
const FILE_EXTS = new Set([
|
|
79
|
+
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "java", "rb", "php", "cs",
|
|
80
|
+
"json", "yaml", "yml", "toml", "md", "sql", "sh", "css", "scss", "vue", "svelte", "html",
|
|
81
|
+
]);
|
|
82
|
+
const out = new Set();
|
|
83
|
+
const rx = /(?:\.\.?\/|[\w-]+\/)*[\w-]+\.[A-Za-z][\w]{0,9}/g;
|
|
84
|
+
for (const m of task.matchAll(rx)) {
|
|
85
|
+
const p = m[0];
|
|
86
|
+
const ext = p.slice(p.lastIndexOf(".") + 1).toLowerCase();
|
|
87
|
+
if (!FILE_EXTS.has(ext))
|
|
88
|
+
continue;
|
|
89
|
+
if (/^https?:/i.test(p))
|
|
90
|
+
continue;
|
|
91
|
+
const abs = resolve(workdir, p);
|
|
92
|
+
const rel = relative(workdir, abs);
|
|
93
|
+
if (rel.startsWith("..") || /^[a-zA-Z]:/.test(rel))
|
|
94
|
+
continue;
|
|
95
|
+
if (existsSync(abs))
|
|
96
|
+
out.add(rel.split(sep).join("/"));
|
|
97
|
+
if (out.size >= 5)
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
return [...out];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Collect the AGENTS.md chain for a task. `focusFiles` are relative paths
|
|
104
|
+
* (typically extracted via extractFocusFiles). Order: global → root → nearest
|
|
105
|
+
* nested (deduped by source). Total size capped at TOTAL_BUDGET.
|
|
106
|
+
*/
|
|
107
|
+
export function collectAgentGuides(workdir, focusFiles = []) {
|
|
108
|
+
const guides = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const push = (g) => {
|
|
111
|
+
if (seen.has(g.source))
|
|
112
|
+
return false;
|
|
113
|
+
seen.add(g.source);
|
|
114
|
+
guides.push(g);
|
|
115
|
+
return true;
|
|
116
|
+
};
|
|
117
|
+
for (const g of rootGuide(workdir))
|
|
118
|
+
push(g);
|
|
119
|
+
for (const f of focusFiles) {
|
|
120
|
+
const g = nearestGuide(workdir, f);
|
|
121
|
+
if (g)
|
|
122
|
+
push(g);
|
|
123
|
+
}
|
|
124
|
+
// Total budget cap — drop the oldest (least specific) first.
|
|
125
|
+
let total = guides.reduce((n, g) => n + g.content.length, 0);
|
|
126
|
+
while (guides.length > 1 && total > TOTAL_BUDGET) {
|
|
127
|
+
const dropped = guides.shift();
|
|
128
|
+
if (!dropped)
|
|
129
|
+
break;
|
|
130
|
+
total -= dropped.content.length;
|
|
131
|
+
}
|
|
132
|
+
return guides;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Template written by /init — AGENTS.md starter. Detects project identity and
|
|
136
|
+
* build/test commands from common manifest files; falls back to placeholders.
|
|
137
|
+
*/
|
|
138
|
+
export function agentsMdTemplate(workdir) {
|
|
139
|
+
let name = basename(workdir);
|
|
140
|
+
let build = "npm run build";
|
|
141
|
+
let test = "npm test";
|
|
142
|
+
try {
|
|
143
|
+
const pkg = JSON.parse(readFileSync(join(workdir, "package.json"), "utf8"));
|
|
144
|
+
if (pkg.name)
|
|
145
|
+
name = pkg.name;
|
|
146
|
+
if (pkg.scripts?.build)
|
|
147
|
+
build = "npm run build";
|
|
148
|
+
if (pkg.scripts?.test)
|
|
149
|
+
test = "npm test";
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// not a node project — keep generic placeholders
|
|
153
|
+
if (existsSync(join(workdir, "go.mod"))) {
|
|
154
|
+
build = "go build ./...";
|
|
155
|
+
test = "go test ./...";
|
|
156
|
+
}
|
|
157
|
+
else if (existsSync(join(workdir, "Cargo.toml"))) {
|
|
158
|
+
build = "cargo build";
|
|
159
|
+
test = "cargo test";
|
|
160
|
+
}
|
|
161
|
+
else if (existsSync(join(workdir, "pyproject.toml"))) {
|
|
162
|
+
build = "python -m compileall .";
|
|
163
|
+
test = "pytest";
|
|
164
|
+
}
|
|
165
|
+
else if (existsSync(join(workdir, "Makefile"))) {
|
|
166
|
+
build = "make";
|
|
167
|
+
test = "make test";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return `# ${name} — AGENTS.md
|
|
171
|
+
|
|
172
|
+
Conventions for AI coding agents working in this repository. Keep this file
|
|
173
|
+
accurate: update it when the build, tests, or architecture change.
|
|
174
|
+
|
|
175
|
+
## Project overview
|
|
176
|
+
<!-- One paragraph: what this project is, its main stack, and its layout. -->
|
|
177
|
+
|
|
178
|
+
## Commands
|
|
179
|
+
- Build: \`${build}\`
|
|
180
|
+
- Test: \`${test}\`
|
|
181
|
+
- Lint / typecheck: <!-- e.g. npm run lint && npm run typecheck -->
|
|
182
|
+
|
|
183
|
+
## Architecture map
|
|
184
|
+
<!-- Where the important things live: entry points, modules, data flow. -->
|
|
185
|
+
|
|
186
|
+
## Conventions
|
|
187
|
+
<!-- Code style, error-handling patterns, naming, test style, commit style. -->
|
|
188
|
+
|
|
189
|
+
## Guardrails
|
|
190
|
+
<!-- What agents must never do here (generated files, migrations, secrets…). -->
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
/** Format the AGENTS.md block for the system prompt ("" when nothing found). */
|
|
194
|
+
export function buildAgentGuideBlock(workdir, task) {
|
|
195
|
+
const guides = collectAgentGuides(workdir, extractFocusFiles(task, workdir));
|
|
196
|
+
if (guides.length === 0)
|
|
197
|
+
return "";
|
|
198
|
+
const parts = guides.map((g) => `--- ${relative(workdir, g.source).split(sep).join("/") || g.source} ---\n${g.content}`);
|
|
199
|
+
return (`\n\nAGENTS.md (project conventions — apply unless the task explicitly says otherwise):\n` +
|
|
200
|
+
parts.join("\n\n"));
|
|
201
|
+
}
|
package/dist/agents.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agents (I-06) — .scoder/agents/<name>.md definitions + built-ins.
|
|
2
|
+
* Agents (I-06 + v2 profiles) — .scoder/agents/<name>.md definitions + built-ins.
|
|
3
3
|
*
|
|
4
4
|
* Frontmatter semantics from opencode /v2/docs/agents (concept only —
|
|
5
5
|
* prompt bodies are scoder's own). An agent def is: description, mode
|
|
6
|
-
* (primary|subagent|all), model override, steps, prompt body
|
|
6
|
+
* (primary|subagent|all), model override, steps, prompt body — plus v2:
|
|
7
|
+
* tool whitelist, permission mode, temperature, reasoning, maxTokens,
|
|
8
|
+
* skills pre-staging, color.
|
|
7
9
|
*/
|
|
8
10
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
9
11
|
import { homedir } from "node:os";
|
|
10
12
|
import { join } from "node:path";
|
|
11
13
|
import { parseFrontmatter } from "./skills.js";
|
|
14
|
+
/** Read-only tools an agent may use — mutating specs are filtered out. */
|
|
15
|
+
export const READ_ONLY_TOOL_SPECS = [
|
|
16
|
+
"read_file",
|
|
17
|
+
"list_files",
|
|
18
|
+
"grep",
|
|
19
|
+
"glob",
|
|
20
|
+
"repo_map",
|
|
21
|
+
"web_fetch",
|
|
22
|
+
"load_skill",
|
|
23
|
+
"write_todos",
|
|
24
|
+
];
|
|
25
|
+
/** Specs a readonly primary/subagent never sees (defense in depth vs permission alone). */
|
|
26
|
+
export const MUTATING_TOOL_SPECS = ["write_file", "edit_file", "apply_patch", "run_command"];
|
|
12
27
|
/** Built-in agents — lowest priority; .md files override by name. */
|
|
13
28
|
export const BUILTIN_AGENTS = [
|
|
14
29
|
{
|
|
@@ -22,14 +37,41 @@ export const BUILTIN_AGENTS = [
|
|
|
22
37
|
description: "Read-only planner — investigates and proposes an implementation plan, never edits code.",
|
|
23
38
|
mode: "primary",
|
|
24
39
|
prompt: "You are a planning agent. Investigate the codebase (read files, search, list) and produce a concrete, step-by-step implementation plan. You MUST NOT modify any files or run mutating commands — report the plan and stop. Structure: goal, findings (files/lines), plan steps ordered, risks, and the exact first step to take.",
|
|
40
|
+
permission: "readonly",
|
|
41
|
+
tools: READ_ONLY_TOOL_SPECS,
|
|
42
|
+
reasoning: "on",
|
|
25
43
|
},
|
|
26
44
|
{
|
|
27
45
|
name: "explore",
|
|
28
46
|
description: "Read-only subagent — searches and summarizes the codebase, returns findings.",
|
|
29
47
|
mode: "subagent",
|
|
30
48
|
prompt: "You are an exploration subagent. Answer the given question about this codebase using read-only tools (list_files, read_file, run_command with read-only commands). Be terse and factual; cite file paths. Do not modify anything.",
|
|
49
|
+
permission: "readonly",
|
|
50
|
+
tools: READ_ONLY_TOOL_SPECS,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "review",
|
|
54
|
+
description: "Code reviewer — reviews diffs for correctness, security and style. Never edits.",
|
|
55
|
+
mode: "all",
|
|
56
|
+
prompt: "You are a code review agent. Review the requested diff or files for correctness bugs, security issues, and violations of the project's conventions. Use read-only tools only. Report findings as a ranked list: [blocking] / [important] / [nit] with file:line citations and a concrete fix for each. End with an overall verdict: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION. Do NOT modify any files.",
|
|
57
|
+
permission: "readonly",
|
|
58
|
+
tools: READ_ONLY_TOOL_SPECS,
|
|
31
59
|
},
|
|
32
60
|
];
|
|
61
|
+
const parsePermissions = (v) => v === "auto" || v === "ask" || v === "readonly" ? v : undefined;
|
|
62
|
+
const parseReasoning = (v) => v === "off" || v === "on" ? v : undefined;
|
|
63
|
+
const parseList = (v) => {
|
|
64
|
+
if (!v)
|
|
65
|
+
return undefined;
|
|
66
|
+
const out = (Array.isArray(v) ? v : v.split(",")).map((s) => String(s).trim()).filter(Boolean);
|
|
67
|
+
return out.length > 0 ? out : undefined;
|
|
68
|
+
};
|
|
69
|
+
const parseNumber = (v) => {
|
|
70
|
+
if (v === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
const n = Number(v);
|
|
73
|
+
return Number.isFinite(n) ? n : undefined;
|
|
74
|
+
};
|
|
33
75
|
/** Scan .scoder/agents/*.md (project) + ~/.scoder/agents/*.md (global). */
|
|
34
76
|
export function discoverAgents(workdir) {
|
|
35
77
|
const out = new Map();
|
|
@@ -51,10 +93,18 @@ export function discoverAgents(workdir) {
|
|
|
51
93
|
description: meta.description ?? "",
|
|
52
94
|
mode: mode,
|
|
53
95
|
model: meta.model || undefined,
|
|
54
|
-
steps:
|
|
96
|
+
steps: parseNumber(meta.steps),
|
|
55
97
|
prompt: body,
|
|
56
98
|
hidden: meta.hidden === "true",
|
|
57
99
|
disabled: meta.disabled === "true",
|
|
100
|
+
tools: parseList(meta.tools),
|
|
101
|
+
permission: parsePermissions(meta.permission),
|
|
102
|
+
temperature: parseNumber(meta.temperature),
|
|
103
|
+
reasoning: parseReasoning(meta.reasoning),
|
|
104
|
+
// frontmatter keys are lowercased by parseFrontmatter
|
|
105
|
+
maxTokens: parseNumber(meta.maxtokens),
|
|
106
|
+
skills: parseList(meta.skills),
|
|
107
|
+
color: meta.color || undefined,
|
|
58
108
|
});
|
|
59
109
|
}
|
|
60
110
|
catch {
|
|
@@ -68,3 +118,26 @@ export function discoverAgents(workdir) {
|
|
|
68
118
|
export function findAgent(workdir, name) {
|
|
69
119
|
return discoverAgents(workdir).find((a) => a.name === name) ?? null;
|
|
70
120
|
}
|
|
121
|
+
/** Primary agents user-selectable via --agent / /agent (mode primary|all). */
|
|
122
|
+
export function selectableAgents(workdir) {
|
|
123
|
+
return discoverAgents(workdir).filter((a) => a.mode !== "subagent" && !a.hidden);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* v2: filter a tool list for an agent def.
|
|
127
|
+
* - def.tools set → whitelist (unknown names dropped, reported by caller);
|
|
128
|
+
* - def.permission "readonly" → drop mutating specs (defense in depth);
|
|
129
|
+
* - otherwise the list is unchanged.
|
|
130
|
+
*/
|
|
131
|
+
export function filterToolNamesForDef(names, def) {
|
|
132
|
+
if (!def)
|
|
133
|
+
return names;
|
|
134
|
+
if (def.tools && def.tools.length > 0) {
|
|
135
|
+
const allow = new Set(def.tools.map((t) => t.toLowerCase()));
|
|
136
|
+
return names.filter((n) => allow.has(n.toLowerCase()));
|
|
137
|
+
}
|
|
138
|
+
if (def.permission === "readonly") {
|
|
139
|
+
const blocked = new Set(MUTATING_TOOL_SPECS);
|
|
140
|
+
return names.filter((n) => !blocked.has(n.toLowerCase()));
|
|
141
|
+
}
|
|
142
|
+
return names;
|
|
143
|
+
}
|
package/dist/compaction.js
CHANGED
|
@@ -79,11 +79,16 @@ export function extractSummaryInput(task, messages) {
|
|
|
79
79
|
lines.push(`Files modified: ${[...files].join(", ")}`);
|
|
80
80
|
return lines.join("\n");
|
|
81
81
|
}
|
|
82
|
-
/** Message sent to the summarizer LLM. */
|
|
82
|
+
/** Message sent to the summarizer LLM (v2: structured template, not prose). */
|
|
83
83
|
export const COMPACTION_INSTRUCTION = "Summarize the conversation above for a coding agent that will CONTINUE this task. " +
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
84
|
+
"Use a STRUCTURED template with exactly these sections (skip a section only if empty):\n" +
|
|
85
|
+
"GOAL: one line — the original task.\n" +
|
|
86
|
+
"STATE: what is done and what is not (build/test status).\n" +
|
|
87
|
+
"CHANGED: files created/modified and why.\n" +
|
|
88
|
+
"DECISIONS: decisions made and their rationale (keep every decision).\n" +
|
|
89
|
+
"FAILURES: errors hit and what did NOT work (so they are not repeated).\n" +
|
|
90
|
+
"NEXT: the exact next steps, in order.\n" +
|
|
91
|
+
"Be factual and terse — max 400 words. Preserve file paths, commands and error codes verbatim.";
|
|
87
92
|
/** Build the post-compaction message list. */
|
|
88
93
|
export function applyCompaction(messages, summary, cfg) {
|
|
89
94
|
const cut = findCutIndex(messages, cfg);
|
|
@@ -104,6 +109,9 @@ export function applyCompaction(messages, summary, cfg) {
|
|
|
104
109
|
arguments: tc.arguments.length > 120 ? tc.arguments.slice(0, 120) + "…" : tc.arguments,
|
|
105
110
|
})),
|
|
106
111
|
});
|
|
112
|
+
for (const tc of m.tool_calls) {
|
|
113
|
+
skeletons.push({ role: "tool", tool_call_id: tc.id, content: "[compacted — tool result omitted]" });
|
|
114
|
+
}
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
117
|
const summaryMsg = {
|
package/dist/config.js
CHANGED
|
@@ -53,6 +53,9 @@ function mergeRc(global, project) {
|
|
|
53
53
|
if (global.mcpServers || project.mcpServers) {
|
|
54
54
|
merged.mcpServers = { ...global.mcpServers, ...project.mcpServers };
|
|
55
55
|
}
|
|
56
|
+
if (global.routing || project.routing) {
|
|
57
|
+
merged.routing = { ...global.routing, ...project.routing };
|
|
58
|
+
}
|
|
56
59
|
if (global.modelPricing || project.modelPricing) {
|
|
57
60
|
merged.modelPricing = { ...global.modelPricing, ...project.modelPricing };
|
|
58
61
|
}
|
|
@@ -66,6 +69,7 @@ function mergeRc(global, project) {
|
|
|
66
69
|
mode: project.permissions?.mode ?? global.permissions?.mode,
|
|
67
70
|
allowCommands: concat(global.permissions?.allowCommands, project.permissions?.allowCommands),
|
|
68
71
|
denyCommands: concat(global.permissions?.denyCommands, project.permissions?.denyCommands),
|
|
72
|
+
rules: concat(global.permissions?.rules, project.permissions?.rules),
|
|
69
73
|
};
|
|
70
74
|
}
|
|
71
75
|
return merged;
|
|
@@ -176,7 +180,7 @@ export function loadConfig(overrides = {}) {
|
|
|
176
180
|
process.env.OPENAI_API_KEY ??
|
|
177
181
|
rc.apiKey ??
|
|
178
182
|
"",
|
|
179
|
-
model: overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (
|
|
183
|
+
model: modelFromSlash || (overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (isAnthropic ? ANTHROPIC_DEFAULT_MODEL : DEFAULT_MODEL)),
|
|
180
184
|
providerDef,
|
|
181
185
|
contextWindow: providerDef?.contextWindow,
|
|
182
186
|
workdir,
|
|
@@ -208,6 +212,12 @@ export function loadConfig(overrides = {}) {
|
|
|
208
212
|
sandbox: rc.sandbox,
|
|
209
213
|
limits: parseLimits(rc.limits),
|
|
210
214
|
snapshots: rc.snapshots,
|
|
215
|
+
agent: overrides.agent ?? process.env.SCODER_AGENT ?? rc.agent,
|
|
216
|
+
routing: rc.routing,
|
|
217
|
+
repoMap: rc.repoMap,
|
|
218
|
+
promptVersion: rc.promptVersion,
|
|
219
|
+
verifyCmds: rc.verifyCmds,
|
|
220
|
+
selfReview: rc.selfReview,
|
|
211
221
|
modelPricing: parsePricing(rc.modelPricing),
|
|
212
222
|
};
|
|
213
223
|
// Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
|
|
@@ -266,7 +276,11 @@ export function loadSavedApprovals(workdir) {
|
|
|
266
276
|
if (!existsSync(p))
|
|
267
277
|
return [];
|
|
268
278
|
try {
|
|
269
|
-
|
|
279
|
+
const raw = JSON.parse(readFileSync(p, "utf8"));
|
|
280
|
+
const rules = raw && typeof raw === "object" && !Array.isArray(raw) && "rules" in raw
|
|
281
|
+
? raw.rules
|
|
282
|
+
: raw;
|
|
283
|
+
return parsePermissionRules(rules, p) ?? [];
|
|
270
284
|
}
|
|
271
285
|
catch {
|
|
272
286
|
return [];
|
package/dist/context.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { encode } from "gpt-tokenizer";
|
|
2
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
|
|
3
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
4
4
|
const MAX_MESSAGES = 120; // keep history under control for long runs
|
|
5
5
|
/**
|
|
6
6
|
* Default history token budget when compaction is disabled (I-10:
|
|
@@ -137,7 +137,19 @@ export function expandMentions(task, workdir) {
|
|
|
137
137
|
if (idx >= 0)
|
|
138
138
|
out = (out.slice(0, idx) + " " + out.slice(idx + token.length)).replace(/ {2,}/g, " ");
|
|
139
139
|
const abs = resolve(workdir, path);
|
|
140
|
-
|
|
140
|
+
const root = resolve(workdir);
|
|
141
|
+
const lexical = relative(root, abs);
|
|
142
|
+
let contained = !lexical.startsWith("..") && !isAbsolute(lexical);
|
|
143
|
+
try {
|
|
144
|
+
const realRoot = realpathSync(root);
|
|
145
|
+
const realAbs = realpathSync(abs);
|
|
146
|
+
const realRel = relative(realRoot, realAbs);
|
|
147
|
+
contained = contained && !realRel.startsWith("..") && !isAbsolute(realRel);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Missing paths are retained as a diagnostic; existing paths are checked above.
|
|
151
|
+
}
|
|
152
|
+
if (!contained) {
|
|
141
153
|
blocks.push(`@${path}: path escapes working directory — skipped.`);
|
|
142
154
|
continue;
|
|
143
155
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 (3.5): Diagnostics — turn raw build/test/lint logs into structured
|
|
3
|
+
* {file, line, severity, code, message} records, plus an error classifier
|
|
4
|
+
* that maps a failure to a recovery strategy for the agent.
|
|
5
|
+
*
|
|
6
|
+
* Supported: tsc (both formats), eslint stylish/compact, pytest (tracebacks +
|
|
7
|
+
* FAILED lines), go test, cargo, make. Unknown formats fall back to a generic
|
|
8
|
+
* "error-ish line with a file(:line)? prefix" pattern. Output is deduplicated
|
|
9
|
+
* and capped (30 diagnostics) so the agent gets a fixable list, not a log.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
const MAX_DIAGNOSTICS = 30;
|
|
14
|
+
function add(out, seen, d) {
|
|
15
|
+
const key = `${d.file}:${d.line}:${d.code}:${d.message.slice(0, 80)}`;
|
|
16
|
+
if (seen.has(key))
|
|
17
|
+
return;
|
|
18
|
+
seen.add(key);
|
|
19
|
+
if (out.length < MAX_DIAGNOSTICS)
|
|
20
|
+
out.push(d);
|
|
21
|
+
}
|
|
22
|
+
/** Parse a command output into structured diagnostics (best-effort). */
|
|
23
|
+
export function parseDiagnostics(output) {
|
|
24
|
+
const out = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
let currentFile = null;
|
|
27
|
+
let pendingCargo = null;
|
|
28
|
+
for (const rawLine of output.split("\n")) {
|
|
29
|
+
const line = rawLine.trimEnd();
|
|
30
|
+
let m;
|
|
31
|
+
// ── cargo (real output): error line, then " --> file:line" on the NEXT line.
|
|
32
|
+
if (pendingCargo) {
|
|
33
|
+
m = /^\s*-->\s+([^\s:]+):(\d+)(?::\d+)?$/.exec(line);
|
|
34
|
+
if (m) {
|
|
35
|
+
add(out, seen, { file: m[1], line: Number(m[2]), severity: "error", code: pendingCargo.code, message: pendingCargo.message, source: "cargo" });
|
|
36
|
+
pendingCargo = null;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
pendingCargo = null; // the error had no location line — drop it
|
|
40
|
+
}
|
|
41
|
+
// ── tsc (modern): src/foo.ts(12,5): error TS2322: message
|
|
42
|
+
m = /^([^\s()][^()]*)\((\d+),(\d+)\):\s*(error|warning)\s+(TS\d+):\s*(.+)$/.exec(line);
|
|
43
|
+
if (m) {
|
|
44
|
+
add(out, seen, { file: m[1], line: Number(m[2]), severity: m[4] === "warning" ? "warning" : "error", code: m[5], message: m[6].trim(), source: "tsc" });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// ── tsc (new style): src/foo.ts:12:5 - error TS2322: message
|
|
48
|
+
m = /^([^\s:][^:]*):(\d+):(\d+)\s+-\s+(error|warning)\s+(TS\d+):\s*(.+)$/.exec(line);
|
|
49
|
+
if (m) {
|
|
50
|
+
add(out, seen, { file: m[1], line: Number(m[2]), severity: m[4] === "warning" ? "warning" : "error", code: m[5], message: m[6].trim(), source: "tsc" });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// ── cargo: error[E0308]: msg --> src/main.rs:10:5
|
|
54
|
+
m = /^error(?:\[(E\d+)\])?:\s*(.+)\s+-->\s+([^\s:]+):(\d+)(?::\d+)?$/.exec(line);
|
|
55
|
+
if (m) {
|
|
56
|
+
add(out, seen, { file: m[3], line: Number(m[4]), severity: "error", code: m[1] ?? null, message: m[2].trim(), source: "cargo" });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// ── cargo (2-line): "error[E0308]: msg" standing alone → expect --> next line.
|
|
60
|
+
m = /^error(?:\[(E\d+)\])?:\s*(.+)$/.exec(line);
|
|
61
|
+
if (m) {
|
|
62
|
+
pendingCargo = { code: m[1] ?? null, message: m[2].trim() };
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// ── go vet/test: file.go:123: message (also covers "# package" headers)
|
|
66
|
+
m = /^([^\s:][^:()]*(?:\.go|\.py|\.rs|\.ts|\.tsx|\.js|\.mjs)):(\d+):\s+(.+)$/.exec(line);
|
|
67
|
+
if (m && /error|fail|cannot|undefined|invalid|unexpected|panic/i.test(m[3])) {
|
|
68
|
+
add(out, seen, { file: m[1], line: Number(m[2]), severity: "error", code: null, message: m[3].trim(), source: "generic" });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// ── eslint stylish: "✖ N problems" ignored; file header then indented rows
|
|
72
|
+
m = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}([\w-]+)$/.exec(line);
|
|
73
|
+
if (m && currentFile) {
|
|
74
|
+
add(out, seen, { file: currentFile, line: Number(m[1]), severity: m[3], code: m[5], message: m[4].trim(), source: "eslint" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// eslint file header: /abs/path/file.ts (a bare path on its own line)
|
|
78
|
+
if (/^([\w./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|vue|svelte))\s*$/.test(line.trim())) {
|
|
79
|
+
currentFile = line.trim();
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// ── pytest: FAILED test_x.py::test_name - assert ...
|
|
83
|
+
m = /^FAILED\s+([^\s:]+)(?:::[^\s]+)?(?:\s+-\s+(.+))?$/.exec(line);
|
|
84
|
+
if (m) {
|
|
85
|
+
add(out, seen, { file: m[1], line: null, severity: "error", code: "FAILED", message: (m[2] ?? "test failed").trim(), source: "pytest" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// pytest traceback frame: E AssertionError: ...
|
|
89
|
+
m = /^E\s+(.+)$/.exec(line);
|
|
90
|
+
if (m) {
|
|
91
|
+
add(out, seen, { file: "(pytest)", line: null, severity: "error", code: null, message: m[1].trim().slice(0, 200), source: "pytest" });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// ── go test: --- FAIL: TestName
|
|
95
|
+
m = /^--- FAIL:\s+([\w/]+)/.exec(line);
|
|
96
|
+
if (m) {
|
|
97
|
+
add(out, seen, { file: "(go test)", line: null, severity: "error", code: "FAIL", message: `test failed: ${m[1]}`, source: "go" });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// ── make: make: *** [target] Error 2
|
|
101
|
+
m = /^make(?:\[\d+\])?: \*\*\* \[([^\]]+)\] Error (\d+)/.exec(line);
|
|
102
|
+
if (m) {
|
|
103
|
+
add(out, seen, { file: "Makefile", line: null, severity: "error", code: `make:${m[2]}`, message: `target failed: ${m[1]}`, source: "make" });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** Compact one-line-per-diagnostic rendering (prompt + UI friendly). */
|
|
110
|
+
export function formatDiagnostics(diags) {
|
|
111
|
+
if (diags.length === 0)
|
|
112
|
+
return "";
|
|
113
|
+
const lines = diags.map((d) => `- ${d.file}${d.line != null ? `:${d.line}` : ""} [${d.severity}${d.code ? ` ${d.code}` : ""}] ${d.message}`);
|
|
114
|
+
return `Diagnostics (${diags.length}):\n${lines.join("\n")}`;
|
|
115
|
+
}
|
|
116
|
+
/** Classify an error into a recovery strategy for the agent. */
|
|
117
|
+
export function classifyError(text) {
|
|
118
|
+
const t = text.toLowerCase();
|
|
119
|
+
const has = (...pats) => pats.some((p) => t.includes(p));
|
|
120
|
+
if (has("permission denied", "eacces", "denied by policy", "access is denied", "unauthorized", "403")) {
|
|
121
|
+
return { kind: "permission", strategy: "do NOT retry the same command — use a safer alternative or report the limitation to the user." };
|
|
122
|
+
}
|
|
123
|
+
if (has("enotfound", "econnrefused", "etimedout", "network", "fetch failed", "eai_again", "econnreset", "401", "rate limit", "429")) {
|
|
124
|
+
return { kind: "network", strategy: "check connectivity and credentials first (web_fetch/env), wait or pick another source; do not loop blindly." };
|
|
125
|
+
}
|
|
126
|
+
if (has("syntaxerror", "unexpected token", "unexpected end of", "parse error", "unterminated")) {
|
|
127
|
+
return { kind: "syntax", strategy: "read the file around the indicated line and fix the syntax error directly." };
|
|
128
|
+
}
|
|
129
|
+
if (/ts\d{3,5}|type .{0,40}is not assignable|type mismatch|expected type|no compatible/.test(t)) {
|
|
130
|
+
return { kind: "type", strategy: "read the failing type declaration and the usage site — fix the type, don't cast blindly." };
|
|
131
|
+
}
|
|
132
|
+
if (has("assertionerror", "assert failed", "expected", "to equal", "failed assert", "--- fail")) {
|
|
133
|
+
return { kind: "test", strategy: "read the failing test, understand what it expects, then fix the implementation (or the test only if it is provably wrong)." };
|
|
134
|
+
}
|
|
135
|
+
if (has("typeerror", "referenceerror", "rangeerror", "panic", "nil pointer", "null pointer", "segmentation", "enoent")) {
|
|
136
|
+
return { kind: "runtime", strategy: "read the stack trace, find the failing frame, and fix the root cause." };
|
|
137
|
+
}
|
|
138
|
+
return { kind: "unknown", strategy: "read the full error carefully; try a different approach or ask the user." };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Detect verify commands for the workdir from manifests. rc override via
|
|
142
|
+
* `verifyCmds` wins (empty string disables one).
|
|
143
|
+
*/
|
|
144
|
+
export function detectVerifyCmds(workdir) {
|
|
145
|
+
const out = {};
|
|
146
|
+
try {
|
|
147
|
+
const pkgPath = join(workdir, "package.json");
|
|
148
|
+
if (existsSync(pkgPath)) {
|
|
149
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
150
|
+
const s = pkg.scripts ?? {};
|
|
151
|
+
if (s.build)
|
|
152
|
+
out.build = "npm run build";
|
|
153
|
+
if (s.test)
|
|
154
|
+
out.test = "npm test";
|
|
155
|
+
if (s.lint)
|
|
156
|
+
out.lint = "npm run lint";
|
|
157
|
+
if (s.typecheck)
|
|
158
|
+
out.typecheck = "npm run typecheck";
|
|
159
|
+
else if (s["type-check"])
|
|
160
|
+
out.typecheck = "npm run type-check";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch { /* malformed package.json — fall through */ }
|
|
164
|
+
try {
|
|
165
|
+
if (existsSync(join(workdir, "pyproject.toml")))
|
|
166
|
+
out.test = out.test ?? "pytest";
|
|
167
|
+
if (existsSync(join(workdir, "Cargo.toml"))) {
|
|
168
|
+
out.build = out.build ?? "cargo build";
|
|
169
|
+
out.test = out.test ?? "cargo test";
|
|
170
|
+
}
|
|
171
|
+
if (existsSync(join(workdir, "go.mod"))) {
|
|
172
|
+
out.build = out.build ?? "go build ./...";
|
|
173
|
+
out.test = out.test ?? "go test ./...";
|
|
174
|
+
}
|
|
175
|
+
const makefile = join(workdir, "Makefile");
|
|
176
|
+
if (existsSync(makefile)) {
|
|
177
|
+
const mk = readFileSync(makefile, "utf8");
|
|
178
|
+
if (/^build:/m.test(mk))
|
|
179
|
+
out.build = out.build ?? "make";
|
|
180
|
+
if (/^test:/m.test(mk))
|
|
181
|
+
out.test = out.test ?? "make test";
|
|
182
|
+
if (/^lint:/m.test(mk))
|
|
183
|
+
out.lint = out.lint ?? "make lint";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch { /* best-effort */ }
|
|
187
|
+
return out;
|
|
188
|
+
}
|