s-app-scoder-agent 0.8.20 → 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 +50 -6
- package/dist/agent.js +754 -79
- 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 +37 -11
- 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 +297 -9
- package/dist/tui/commands.js +224 -36
- 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,13 +1,24 @@
|
|
|
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
|
-
*
|
|
7
|
-
* char heuristic 100k chars ≈ 25k
|
|
8
|
-
* tool defs + reply buffer chiếm phần
|
|
6
|
+
* Default history token budget when compaction is disabled (I-10:
|
|
7
|
+
* token-accurate via gpt-tokenizer, thay char heuristic 100k chars ≈ 25k
|
|
8
|
+
* tokens). Conservative: system prompt + tool defs + reply buffer chiếm phần
|
|
9
|
+
* còn lại của contextWindow.
|
|
9
10
|
*/
|
|
10
|
-
const MAX_HISTORY_TOKENS = 90_000;
|
|
11
|
+
export const MAX_HISTORY_TOKENS = 90_000;
|
|
12
|
+
/**
|
|
13
|
+
* Fraction of the model context window where mechanical trim starts when
|
|
14
|
+
* compaction is enabled. Must stay ABOVE the compaction trigger (default
|
|
15
|
+
* 0.8) so semantic compaction gets a chance to run first.
|
|
16
|
+
*/
|
|
17
|
+
export const HISTORY_TRIM_FRACTION = 0.95;
|
|
18
|
+
/** Mechanical trim budget for a given context window (floor 2k). */
|
|
19
|
+
export function historyTokenBudget(contextWindow) {
|
|
20
|
+
return Math.max(2_000, Math.floor(contextWindow * HISTORY_TRIM_FRACTION));
|
|
21
|
+
}
|
|
11
22
|
/** Tool results older than this many recent tool messages get pruned. */
|
|
12
23
|
const KEEP_RECENT_TOOL_RESULTS = 20;
|
|
13
24
|
/** Token count for one message (tool_calls arguments included). Never throws. */
|
|
@@ -25,16 +36,19 @@ function messageTokens(m) {
|
|
|
25
36
|
}
|
|
26
37
|
const historyTokens = (msgs) => msgs.reduce((n, m) => n + messageTokens(m), 0);
|
|
27
38
|
/**
|
|
28
|
-
* Trim message history to stay within token budget. Layered strategy
|
|
39
|
+
* Trim message history to stay within a token budget. Layered strategy
|
|
29
40
|
* (context engineering — inspired by compaction patterns, from scratch):
|
|
30
41
|
* 1. Message-count cap: keep system + newest MAX_MESSAGES-1.
|
|
31
42
|
* 2. Token budget: drop the OLDEST WHOLE TURNS (from a user message up to
|
|
32
|
-
* the next user message) until under
|
|
43
|
+
* the next user message) until under `maxTokens` — dropping whole
|
|
33
44
|
* turns never orphans an assistant tool_calls / tool result pair.
|
|
34
45
|
* 3. Old tool results (outside the recent window) are pruned in place to a
|
|
35
46
|
* short placeholder — their content dominated the budget anyway.
|
|
47
|
+
*
|
|
48
|
+
* `maxTokens` defaults to the legacy 90k cap; callers with compaction enabled
|
|
49
|
+
* pass `historyTokenBudget(contextWindow)` so trim runs *after* compaction.
|
|
36
50
|
*/
|
|
37
|
-
export function trimHistory(messages) {
|
|
51
|
+
export function trimHistory(messages, maxTokens = MAX_HISTORY_TOKENS) {
|
|
38
52
|
let out = messages;
|
|
39
53
|
// 1) Message-count cap
|
|
40
54
|
if (out.length > MAX_MESSAGES) {
|
|
@@ -43,7 +57,7 @@ export function trimHistory(messages) {
|
|
|
43
57
|
out = system ? [system, ...rest.slice(-(MAX_MESSAGES - 1))] : rest.slice(-(MAX_MESSAGES - 1));
|
|
44
58
|
}
|
|
45
59
|
// 2) Token budget — drop oldest whole turns, never the system message
|
|
46
|
-
while (out.length > 2 && historyTokens(out) >
|
|
60
|
+
while (out.length > 2 && historyTokens(out) > maxTokens) {
|
|
47
61
|
const firstIdx = out[0].role === "system" ? 1 : 0;
|
|
48
62
|
if (out[firstIdx]?.role !== "user")
|
|
49
63
|
break; // no clean turn boundary — stop
|
|
@@ -123,7 +137,19 @@ export function expandMentions(task, workdir) {
|
|
|
123
137
|
if (idx >= 0)
|
|
124
138
|
out = (out.slice(0, idx) + " " + out.slice(idx + token.length)).replace(/ {2,}/g, " ");
|
|
125
139
|
const abs = resolve(workdir, path);
|
|
126
|
-
|
|
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) {
|
|
127
153
|
blocks.push(`@${path}: path escapes working directory — skipped.`);
|
|
128
154
|
continue;
|
|
129
155
|
}
|