s-app-scoder-agent 0.6.0 → 0.8.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +600 -468
- package/dist/agent.js +841 -197
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +423 -0
- package/dist/index.js +129 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +103 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +32 -2
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +505 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +146 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +99 -0
- package/dist/tui/state.js +192 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
package/dist/agents.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agents (I-06) — .scoder/agents/<name>.md definitions + built-ins.
|
|
3
|
+
*
|
|
4
|
+
* Frontmatter semantics from opencode /v2/docs/agents (concept only —
|
|
5
|
+
* prompt bodies are scoder's own). An agent def is: description, mode
|
|
6
|
+
* (primary|subagent|all), model override, steps, prompt body.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { parseFrontmatter } from "./skills.js";
|
|
12
|
+
/** Built-in agents — lowest priority; .md files override by name. */
|
|
13
|
+
export const BUILTIN_AGENTS = [
|
|
14
|
+
{
|
|
15
|
+
name: "build",
|
|
16
|
+
description: "General coding agent (default) — edits code and runs the build until it passes.",
|
|
17
|
+
mode: "all",
|
|
18
|
+
prompt: "", // empty = use the default system prompt unchanged
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "plan",
|
|
22
|
+
description: "Read-only planner — investigates and proposes an implementation plan, never edits code.",
|
|
23
|
+
mode: "primary",
|
|
24
|
+
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.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "explore",
|
|
28
|
+
description: "Read-only subagent — searches and summarizes the codebase, returns findings.",
|
|
29
|
+
mode: "subagent",
|
|
30
|
+
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.",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
/** Scan .scoder/agents/*.md (project) + ~/.scoder/agents/*.md (global). */
|
|
34
|
+
export function discoverAgents(workdir) {
|
|
35
|
+
const out = new Map();
|
|
36
|
+
for (const b of BUILTIN_AGENTS)
|
|
37
|
+
out.set(b.name, { ...b });
|
|
38
|
+
// Global overrides builtins; project overrides global.
|
|
39
|
+
for (const root of [join(homedir(), ".scoder", "agents"), join(workdir, ".scoder", "agents")]) {
|
|
40
|
+
if (!existsSync(root))
|
|
41
|
+
continue;
|
|
42
|
+
for (const f of readdirSync(root)) {
|
|
43
|
+
if (!f.endsWith(".md"))
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
const { meta, body } = parseFrontmatter(readFileSync(join(root, f), "utf8"));
|
|
47
|
+
const name = f.replace(/\.md$/, "");
|
|
48
|
+
const mode = meta.mode === "primary" || meta.mode === "subagent" ? meta.mode : "all";
|
|
49
|
+
out.set(name, {
|
|
50
|
+
name,
|
|
51
|
+
description: meta.description ?? "",
|
|
52
|
+
mode: mode,
|
|
53
|
+
model: meta.model || undefined,
|
|
54
|
+
steps: meta.steps ? Number(meta.steps) || undefined : undefined,
|
|
55
|
+
prompt: body,
|
|
56
|
+
hidden: meta.hidden === "true",
|
|
57
|
+
disabled: meta.disabled === "true",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// skip malformed
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...out.values()].filter((a) => !a.disabled);
|
|
66
|
+
}
|
|
67
|
+
/** Find one agent by name (built-ins included). */
|
|
68
|
+
export function findAgent(workdir, name) {
|
|
69
|
+
return discoverAgents(workdir).find((a) => a.name === name) ?? null;
|
|
70
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { encode } from "gpt-tokenizer";
|
|
2
|
+
export const DEFAULT_COMPACTION = {
|
|
3
|
+
trigger: 0.8,
|
|
4
|
+
keep: 0.15,
|
|
5
|
+
buffer: 0.15,
|
|
6
|
+
contextWindow: 128_000,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Token-accurate count (I-10): gpt-tokenizer (offline, MIT) thay heuristic
|
|
10
|
+
* ~4 chars/token. Fallback char/4 nếu encode lỗi (never throw).
|
|
11
|
+
*/
|
|
12
|
+
export function estimateTokens(messages) {
|
|
13
|
+
let chars = 0;
|
|
14
|
+
const texts = [];
|
|
15
|
+
for (const m of messages) {
|
|
16
|
+
if (m.content)
|
|
17
|
+
texts.push(m.content);
|
|
18
|
+
if (m.tool_calls) {
|
|
19
|
+
for (const tc of m.tool_calls)
|
|
20
|
+
texts.push(tc.name + tc.arguments);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return encode(texts.join("\n")).length + texts.length;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
for (const t of texts)
|
|
28
|
+
chars += t.length;
|
|
29
|
+
return Math.ceil(chars / 4);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** True when the history should be compacted now. */
|
|
33
|
+
export function shouldCompact(messages, cfg) {
|
|
34
|
+
const budget = cfg.contextWindow * cfg.trigger;
|
|
35
|
+
return estimateTokens(messages) >= budget;
|
|
36
|
+
}
|
|
37
|
+
/** Index where the verbatim tail begins (never inside a tool_calls/tool pair). */
|
|
38
|
+
export function findCutIndex(messages, cfg) {
|
|
39
|
+
const total = estimateTokens(messages);
|
|
40
|
+
if (total <= 0)
|
|
41
|
+
return messages.length;
|
|
42
|
+
const tailBudget = (cfg.keep + cfg.buffer) * total;
|
|
43
|
+
let acc = 0;
|
|
44
|
+
// Walk from the tail, stopping at a user-message boundary.
|
|
45
|
+
let i = messages.length - 1;
|
|
46
|
+
while (i > 0) {
|
|
47
|
+
acc += estimateTokens([messages[i]]);
|
|
48
|
+
if (acc >= tailBudget && messages[i].role === "user") {
|
|
49
|
+
return i;
|
|
50
|
+
}
|
|
51
|
+
i--;
|
|
52
|
+
}
|
|
53
|
+
return messages.length; // nothing to compact
|
|
54
|
+
}
|
|
55
|
+
/** Extract a compact text digest from messages (task + files touched + asks). */
|
|
56
|
+
export function extractSummaryInput(task, messages) {
|
|
57
|
+
const lines = [`Original task: ${task}`];
|
|
58
|
+
const files = new Set();
|
|
59
|
+
for (const m of messages) {
|
|
60
|
+
if (m.tool_calls) {
|
|
61
|
+
for (const tc of m.tool_calls) {
|
|
62
|
+
try {
|
|
63
|
+
const args = JSON.parse(tc.arguments);
|
|
64
|
+
const p = args.path ?? args.file_path ?? args.file;
|
|
65
|
+
if (typeof p === "string")
|
|
66
|
+
files.add(p);
|
|
67
|
+
if (tc.name === "run_command" && typeof args.command === "string") {
|
|
68
|
+
lines.push(`- ran: ${args.command.slice(0, 120)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* skip malformed args */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (m.role === "user" && m.content && !m.content.startsWith("[system]")) {
|
|
75
|
+
lines.push(`- user said: ${m.content.slice(0, 200)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (files.size > 0)
|
|
79
|
+
lines.push(`Files modified: ${[...files].join(", ")}`);
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
/** Message sent to the summarizer LLM. */
|
|
83
|
+
export const COMPACTION_INSTRUCTION = "Summarize the conversation above for a coding agent that will CONTINUE this task. " +
|
|
84
|
+
"Include: (1) the original task, (2) what has been done so far and why, " +
|
|
85
|
+
"(3) files created/modified, (4) current build/test state, (5) exact next steps. " +
|
|
86
|
+
"Be factual and terse. Max 300 words.";
|
|
87
|
+
/** Build the post-compaction message list. */
|
|
88
|
+
export function applyCompaction(messages, summary, cfg) {
|
|
89
|
+
const cut = findCutIndex(messages, cfg);
|
|
90
|
+
const system = messages[0]?.role === "system" ? messages[0] : null;
|
|
91
|
+
const tail = messages.slice(cut);
|
|
92
|
+
// I4: giữ lại "khung" tool-call các turn đã compact — skeleton (name + args
|
|
93
|
+
// truncated) của tool_calls bị cắt, để model biết nó đã thử gì mà không
|
|
94
|
+
// phải trả giá token của output đầy đủ.
|
|
95
|
+
const skeletons = [];
|
|
96
|
+
for (const m of messages.slice(system ? 1 : 0, cut)) {
|
|
97
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
98
|
+
skeletons.push({
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: null,
|
|
101
|
+
tool_calls: m.tool_calls.map((tc) => ({
|
|
102
|
+
id: tc.id,
|
|
103
|
+
name: tc.name,
|
|
104
|
+
arguments: tc.arguments.length > 120 ? tc.arguments.slice(0, 120) + "…" : tc.arguments,
|
|
105
|
+
})),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const summaryMsg = {
|
|
110
|
+
role: "user",
|
|
111
|
+
content: `[system] Session summary (auto-compact). Earlier history was summarized:\n\n${summary}\n\nContinue the task from here.`,
|
|
112
|
+
};
|
|
113
|
+
return [...(system ? [system] : []), summaryMsg, ...skeletons, ...tail];
|
|
114
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,23 +1,85 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve, dirname } from "node:path";
|
|
4
|
+
import { parseCustomProviders, resolveProvider, splitProviderModel } from "./provider-registry.js";
|
|
3
5
|
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
4
6
|
const DEFAULT_MODEL = "gpt-4o-mini";
|
|
5
7
|
const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
|
|
6
8
|
const ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-4-5";
|
|
7
|
-
|
|
9
|
+
/**
|
|
10
|
+
* G7: parse + validate rc "limits" — số nguyên dương; giá trị sai bỏ qua
|
|
11
|
+
* (dùng default). Defaults là nguồn sự thật duy nhất (resolveLimits).
|
|
12
|
+
*/
|
|
13
|
+
export const LIMIT_DEFAULTS = { requestTimeoutMs: 120_000, maxOutputChars: 20_000, toolConcurrency: 4, renderCap: 200 };
|
|
14
|
+
function parseLimits(raw) {
|
|
15
|
+
if (!raw || typeof raw !== "object")
|
|
16
|
+
return undefined;
|
|
17
|
+
const r = raw;
|
|
18
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const k of ["requestTimeoutMs", "maxOutputChars", "toolConcurrency", "renderCap"]) {
|
|
21
|
+
const v = num(r[k]);
|
|
22
|
+
if (v !== undefined)
|
|
23
|
+
out[k] = v;
|
|
24
|
+
}
|
|
25
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
26
|
+
}
|
|
27
|
+
/** G7: limits đã resolve (defaults + rc override) — các module đọc từ đây. */
|
|
28
|
+
export function resolveLimits(limits) {
|
|
29
|
+
return { ...LIMIT_DEFAULTS, ...limits };
|
|
30
|
+
}
|
|
31
|
+
function readRcFile(path, label) {
|
|
32
|
+
if (!existsSync(path))
|
|
33
|
+
return {};
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
// malformed rc file -> ignore it, but tell the user why their config is not applied
|
|
39
|
+
console.error(`⚠ ignoring malformed config file ${label}: ${err.message}`);
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Global (machine-wide) config file. Overridable via SCODER_CONFIG for tests.
|
|
45
|
+
* Lowest precedence of all file sources — the project rc wins field-by-field.
|
|
46
|
+
*/
|
|
47
|
+
function globalRcPath() {
|
|
48
|
+
return process.env.SCODER_CONFIG || join(homedir(), ".scoder", "config.json");
|
|
49
|
+
}
|
|
50
|
+
/** Merge two rc views: global first, project fields win, maps merge per key. */
|
|
51
|
+
function mergeRc(global, project) {
|
|
52
|
+
const merged = { ...global, ...project };
|
|
53
|
+
if (global.mcpServers || project.mcpServers) {
|
|
54
|
+
merged.mcpServers = { ...global.mcpServers, ...project.mcpServers };
|
|
55
|
+
}
|
|
56
|
+
if (global.modelPricing || project.modelPricing) {
|
|
57
|
+
merged.modelPricing = { ...global.modelPricing, ...project.modelPricing };
|
|
58
|
+
}
|
|
59
|
+
if (global.permissions || project.permissions) {
|
|
60
|
+
const concat = (a, b) => {
|
|
61
|
+
if (!Array.isArray(a) && !Array.isArray(b))
|
|
62
|
+
return b ?? a; // non-arrays pass through (parseList filters)
|
|
63
|
+
return [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])];
|
|
64
|
+
};
|
|
65
|
+
merged.permissions = {
|
|
66
|
+
mode: project.permissions?.mode ?? global.permissions?.mode,
|
|
67
|
+
allowCommands: concat(global.permissions?.allowCommands, project.permissions?.allowCommands),
|
|
68
|
+
denyCommands: concat(global.permissions?.denyCommands, project.permissions?.denyCommands),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
/** T-04: exported for Agent.setModel custom-provider resolution. */
|
|
74
|
+
export function loadRcFile(workdir) {
|
|
75
|
+
const global = readRcFile(globalRcPath(), globalRcPath());
|
|
8
76
|
for (const name of [".scoderrc.json", ".scoderrc"]) {
|
|
9
77
|
const p = resolve(workdir, name);
|
|
10
78
|
if (existsSync(p)) {
|
|
11
|
-
|
|
12
|
-
return JSON.parse(readFileSync(p, "utf8"));
|
|
13
|
-
}
|
|
14
|
-
catch (err) {
|
|
15
|
-
// malformed rc file -> ignore it, but tell the user why their config is not applied
|
|
16
|
-
console.error(`⚠ ignoring malformed config file ${name}: ${err.message}`);
|
|
17
|
-
}
|
|
79
|
+
return mergeRc(global, readRcFile(p, name));
|
|
18
80
|
}
|
|
19
81
|
}
|
|
20
|
-
return
|
|
82
|
+
return global;
|
|
21
83
|
}
|
|
22
84
|
function parsePermissionMode(raw) {
|
|
23
85
|
if (raw === "auto" || raw === "ask" || raw === "readonly")
|
|
@@ -51,6 +113,29 @@ function parsePricing(raw) {
|
|
|
51
113
|
}
|
|
52
114
|
return out;
|
|
53
115
|
}
|
|
116
|
+
const VALID_EFFECTS = new Set(["allow", "deny", "ask"]);
|
|
117
|
+
/** Validate permission rules v2; drop malformed entries with a warning. */
|
|
118
|
+
export function parsePermissionRules(raw, label = "permissions.rules") {
|
|
119
|
+
if (raw === undefined)
|
|
120
|
+
return undefined;
|
|
121
|
+
if (!Array.isArray(raw)) {
|
|
122
|
+
console.error(`⚠ ignoring invalid ${label} (expected an array of {action, resource, effect})`);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const out = [];
|
|
126
|
+
for (let i = 0; i < raw.length; i++) {
|
|
127
|
+
const r = raw[i];
|
|
128
|
+
if (typeof r.action === "string" && r.action.trim() &&
|
|
129
|
+
typeof r.resource === "string" && r.resource &&
|
|
130
|
+
typeof r.effect === "string" && VALID_EFFECTS.has(r.effect)) {
|
|
131
|
+
out.push({ action: r.action.trim(), resource: r.resource, effect: r.effect });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
console.error(`⚠ ignoring invalid ${label}[${i}] (expected {action, resource, effect: allow|deny|ask})`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
54
139
|
export function loadConfig(overrides = {}) {
|
|
55
140
|
const workdir = overrides.workdir ?? process.cwd();
|
|
56
141
|
const rc = loadRcFile(workdir);
|
|
@@ -65,6 +150,18 @@ export function loadConfig(overrides = {}) {
|
|
|
65
150
|
(providerRaw === undefined &&
|
|
66
151
|
/^claude-/i.test(overrides.model ?? process.env.SCODER_MODEL ?? rc.model ?? ""));
|
|
67
152
|
const provider = providerRaw === "anthropic" || isAnthropic ? "anthropic" : "openai";
|
|
153
|
+
// I-01: provider registry — support "provider/model" syntax + custom providers.
|
|
154
|
+
const customProviders = parseCustomProviders(rc.providers);
|
|
155
|
+
const rawModel = overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? "";
|
|
156
|
+
const [providerPrefix, modelFromSlash] = splitProviderModel(rawModel);
|
|
157
|
+
const providerId = providerPrefix ?? overrides.provider ?? process.env.SCODER_PROVIDER ?? rc.provider ?? (isAnthropic ? "anthropic" : "openai");
|
|
158
|
+
const providerResolution = resolveProvider(providerId, customProviders);
|
|
159
|
+
const providerDef = "def" in providerResolution ? providerResolution.def : undefined;
|
|
160
|
+
if ("error" in providerResolution) {
|
|
161
|
+
console.error(`⚠ provider: ${providerResolution.error} — falling back to openai-compatible behavior`);
|
|
162
|
+
}
|
|
163
|
+
// API key: provider-specific env var wins (registry), then legacy chain.
|
|
164
|
+
const providerKeyEnv = providerDef ? process.env[providerDef.apiKeyEnv] : undefined;
|
|
68
165
|
// Validate maxSteps: NaN/0/negative would silently break the agent loop
|
|
69
166
|
// (steps < NaN is always false → the loop never runs).
|
|
70
167
|
const rawMaxSteps = Number(process.env.SCODER_MAX_STEPS ?? rc.maxSteps ?? 40);
|
|
@@ -74,11 +171,14 @@ export function loadConfig(overrides = {}) {
|
|
|
74
171
|
baseUrl: overrides.baseUrl ?? process.env.SCODER_BASE_URL ?? process.env.OPENAI_BASE_URL ?? rc.baseUrl ?? (isAnthropic ? ANTHROPIC_BASE_URL : DEFAULT_BASE_URL),
|
|
75
172
|
apiKey: overrides.apiKey ??
|
|
76
173
|
process.env.SCODER_API_KEY ??
|
|
174
|
+
providerKeyEnv ??
|
|
77
175
|
(isAnthropic ? process.env.ANTHROPIC_API_KEY : undefined) ??
|
|
78
176
|
process.env.OPENAI_API_KEY ??
|
|
79
177
|
rc.apiKey ??
|
|
80
178
|
"",
|
|
81
|
-
model: overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (isAnthropic ? ANTHROPIC_DEFAULT_MODEL : DEFAULT_MODEL),
|
|
179
|
+
model: overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (modelFromSlash || (isAnthropic ? ANTHROPIC_DEFAULT_MODEL : DEFAULT_MODEL)),
|
|
180
|
+
providerDef,
|
|
181
|
+
contextWindow: providerDef?.contextWindow,
|
|
82
182
|
workdir,
|
|
83
183
|
buildCmd: overrides.buildCmd ?? process.env.SCODER_BUILD_CMD ?? rc.buildCmd ?? null,
|
|
84
184
|
maxSteps: overrides.maxSteps ?? validatedMaxSteps,
|
|
@@ -94,9 +194,20 @@ export function loadConfig(overrides = {}) {
|
|
|
94
194
|
? true
|
|
95
195
|
: process.env.SCODER_LOG_FILE || undefined),
|
|
96
196
|
mcpServers: rc.mcpServers,
|
|
197
|
+
guardrails: rc.guardrails,
|
|
97
198
|
stream: overrides.stream ?? (process.env.SCODER_STREAM !== "0" ? true : false),
|
|
199
|
+
reasoning: overrides.reasoning ??
|
|
200
|
+
(process.env.SCODER_REASONING === "1" ? "on" : undefined) ??
|
|
201
|
+
rc.reasoning ??
|
|
202
|
+
"off",
|
|
98
203
|
permissionMode: overrides.permissionMode ??
|
|
99
204
|
parsePermissionMode(process.env.SCODER_PERMISSION ?? rc.permission ?? rc.permissions?.mode ?? "auto"),
|
|
205
|
+
permissionRules: parsePermissionRules(rc.permissions?.rules),
|
|
206
|
+
compaction: rc.compaction ?? undefined,
|
|
207
|
+
formatter: rc.formatter ?? undefined,
|
|
208
|
+
sandbox: rc.sandbox,
|
|
209
|
+
limits: parseLimits(rc.limits),
|
|
210
|
+
snapshots: rc.snapshots,
|
|
100
211
|
modelPricing: parsePricing(rc.modelPricing),
|
|
101
212
|
};
|
|
102
213
|
// Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
|
|
@@ -115,3 +226,58 @@ export function loadPermissionLists(workdir) {
|
|
|
115
226
|
denyCommands: parseList(rc.permissions?.denyCommands),
|
|
116
227
|
};
|
|
117
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Policy layer (enterprise hard floor — pattern from opencode /v2/docs/policies):
|
|
231
|
+
* evaluated AFTER config rules but its deny always wins. Sources, lowest first:
|
|
232
|
+
* ~/.scoder/policies.json (user-global), /etc/scoder/policies.json (machine),
|
|
233
|
+
* SCODER_POLICY=<file> env (highest).
|
|
234
|
+
*/
|
|
235
|
+
export function loadPolicyRules(_workdir) {
|
|
236
|
+
const candidates = [
|
|
237
|
+
join(homedir(), ".scoder", "policies.json"),
|
|
238
|
+
"/etc/scoder/policies.json",
|
|
239
|
+
process.env.SCODER_POLICY ?? "",
|
|
240
|
+
].filter(Boolean);
|
|
241
|
+
const out = [];
|
|
242
|
+
for (const p of candidates) {
|
|
243
|
+
if (!existsSync(p))
|
|
244
|
+
continue;
|
|
245
|
+
try {
|
|
246
|
+
const raw = JSON.parse(readFileSync(p, "utf8"));
|
|
247
|
+
const rules = parsePermissionRules(raw, p) ?? [];
|
|
248
|
+
out.push(...rules);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
console.error(`⚠ ignoring malformed policy file ${p}: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Saved "always allow" approvals (pattern from opencode permission/saved.ts):
|
|
258
|
+
* persisted in <workdir>/.scoder/permissions.local.json, project-scoped.
|
|
259
|
+
* Never overrides a configured deny (enforced in PermissionChecker).
|
|
260
|
+
*/
|
|
261
|
+
export function savedApprovalsPath(workdir) {
|
|
262
|
+
return resolve(workdir, ".scoder", "permissions.local.json");
|
|
263
|
+
}
|
|
264
|
+
export function loadSavedApprovals(workdir) {
|
|
265
|
+
const p = savedApprovalsPath(workdir);
|
|
266
|
+
if (!existsSync(p))
|
|
267
|
+
return [];
|
|
268
|
+
try {
|
|
269
|
+
return parsePermissionRules(JSON.parse(readFileSync(p, "utf8")), p) ?? [];
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
export function saveApproval(workdir, rule) {
|
|
276
|
+
const p = savedApprovalsPath(workdir);
|
|
277
|
+
const existing = loadSavedApprovals(workdir);
|
|
278
|
+
const next = [...existing.filter((r) => !(r.action === rule.action && r.resource === rule.resource)), rule];
|
|
279
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
280
|
+
const tmp = `${p}.tmp`;
|
|
281
|
+
writeFileSync(tmp, JSON.stringify({ rules: next }, null, 2), { mode: 0o600 });
|
|
282
|
+
renameSync(tmp, p);
|
|
283
|
+
}
|
package/dist/context.js
CHANGED
|
@@ -1,19 +1,70 @@
|
|
|
1
|
+
import { encode } from "gpt-tokenizer";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
1
4
|
const MAX_MESSAGES = 120; // keep history under control for long runs
|
|
2
5
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
6
|
+
* Total history token budget (I-10: token-accurate via gpt-tokenizer, thay
|
|
7
|
+
* char heuristic 100k chars ≈ 25k tokens). Conservative: system prompt +
|
|
8
|
+
* tool defs + reply buffer chiếm phần còn lại của contextWindow.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_HISTORY_TOKENS = 90_000;
|
|
11
|
+
/** Tool results older than this many recent tool messages get pruned. */
|
|
12
|
+
const KEEP_RECENT_TOOL_RESULTS = 20;
|
|
13
|
+
/** Token count for one message (tool_calls arguments included). Never throws. */
|
|
14
|
+
function messageTokens(m) {
|
|
15
|
+
let text = m.content ?? "";
|
|
16
|
+
if (m.tool_calls)
|
|
17
|
+
for (const tc of m.tool_calls)
|
|
18
|
+
text += tc.name + tc.arguments;
|
|
19
|
+
try {
|
|
20
|
+
return encode(text).length;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return Math.ceil(text.length / 4);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const historyTokens = (msgs) => msgs.reduce((n, m) => n + messageTokens(m), 0);
|
|
27
|
+
/**
|
|
28
|
+
* Trim message history to stay within token budget. Layered strategy
|
|
29
|
+
* (context engineering — inspired by compaction patterns, from scratch):
|
|
30
|
+
* 1. Message-count cap: keep system + newest MAX_MESSAGES-1.
|
|
31
|
+
* 2. Token budget: drop the OLDEST WHOLE TURNS (from a user message up to
|
|
32
|
+
* the next user message) until under MAX_HISTORY_TOKENS — dropping whole
|
|
33
|
+
* turns never orphans an assistant tool_calls / tool result pair.
|
|
34
|
+
* 3. Old tool results (outside the recent window) are pruned in place to a
|
|
35
|
+
* short placeholder — their content dominated the budget anyway.
|
|
5
36
|
*/
|
|
6
37
|
export function trimHistory(messages) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
38
|
+
let out = messages;
|
|
39
|
+
// 1) Message-count cap
|
|
40
|
+
if (out.length > MAX_MESSAGES) {
|
|
41
|
+
const system = out[0].role === "system" ? out[0] : null;
|
|
42
|
+
const rest = system ? out.slice(1) : out;
|
|
43
|
+
out = system ? [system, ...rest.slice(-(MAX_MESSAGES - 1))] : rest.slice(-(MAX_MESSAGES - 1));
|
|
44
|
+
}
|
|
45
|
+
// 2) Token budget — drop oldest whole turns, never the system message
|
|
46
|
+
while (out.length > 2 && historyTokens(out) > MAX_HISTORY_TOKENS) {
|
|
47
|
+
const firstIdx = out[0].role === "system" ? 1 : 0;
|
|
48
|
+
if (out[firstIdx]?.role !== "user")
|
|
49
|
+
break; // no clean turn boundary — stop
|
|
50
|
+
let next = firstIdx + 1;
|
|
51
|
+
while (next < out.length && out[next].role !== "user")
|
|
52
|
+
next++;
|
|
53
|
+
out = [...out.slice(0, firstIdx), ...out.slice(next)];
|
|
54
|
+
}
|
|
55
|
+
// 3) Prune old tool-result contents (safe: contents only, never removals —
|
|
56
|
+
// removing a tool message would orphan its assistant tool_calls entry).
|
|
57
|
+
const toolIdx = [];
|
|
58
|
+
for (let i = 0; i < out.length; i++)
|
|
59
|
+
if (out[i].role === "tool")
|
|
60
|
+
toolIdx.push(i);
|
|
61
|
+
const pruneBefore = toolIdx.length - KEEP_RECENT_TOOL_RESULTS;
|
|
62
|
+
if (pruneBefore > 0) {
|
|
63
|
+
out = out.map((m, i) => toolIdx.includes(i) && toolIdx.indexOf(i) < pruneBefore && (m.content?.length ?? 0) > 200
|
|
64
|
+
? { ...m, content: "[pruned — old tool result]" }
|
|
65
|
+
: m);
|
|
15
66
|
}
|
|
16
|
-
return
|
|
67
|
+
return out;
|
|
17
68
|
}
|
|
18
69
|
/**
|
|
19
70
|
* Strict heuristic: does the assistant's plain-text message claim the task is
|
|
@@ -23,13 +74,16 @@ export function trimHistory(messages) {
|
|
|
23
74
|
* completion phrase instead.
|
|
24
75
|
*/
|
|
25
76
|
export function claimsCompletion(text) {
|
|
26
|
-
|
|
77
|
+
const english = /\b(?:task\s+(?:is\s+)?(?:now\s+)?(?:complete[d]?|done|finished)|all\s+(?:done|finished)|work\s+is\s+(?:now\s+)?(?:complete[d]?|done|finished)|tests?\s+(?:all\s+)?pass(?:ed|ing)?\s*[-—]\s*(?:task|everything)\s+complete[d]?)\b/i.test(text);
|
|
78
|
+
// Vietnamese completion claims — same narrow spirit: explicit phrases, not
|
|
79
|
+
// bare words ("xong" alone appears in "chưa xong" and must NOT match).
|
|
80
|
+
const vietnamese = /(?:nhiệm\s?vụ|công\s?việc|task|việc)\s+(?:đã\s+)?(?:hoàn\s?thành|xong\s?hoàn\s?toàn|đã\s+xong)|(?:hoàn\s?thành|xong)\s+(?:tất\s+cả|toàn\s+bộ)|đã\s+(?:sửa|xử\s+lý|fix)\s+xong\b/i.test(text);
|
|
81
|
+
return english || vietnamese;
|
|
27
82
|
}
|
|
28
83
|
/**
|
|
29
84
|
* Summarize a long output to keep context lean.
|
|
30
85
|
* For very long tool outputs, keep beginning + error lines + end.
|
|
31
|
-
*/
|
|
32
|
-
export function summariseOutput(output, maxChars = 4000) {
|
|
86
|
+
*/ export function summariseOutput(output, maxChars = 4000) {
|
|
33
87
|
if (output.length <= maxChars)
|
|
34
88
|
return output;
|
|
35
89
|
const lines = output.split("\n");
|
|
@@ -42,3 +96,54 @@ export function summariseOutput(output, maxChars = 4000) {
|
|
|
42
96
|
lines.slice(-keepEnd).join("\n");
|
|
43
97
|
return snippet.slice(0, maxChars) + "\n... [truncated]";
|
|
44
98
|
}
|
|
99
|
+
/** @mention trong task: `@path` hoặc `@"path có space"` — không match email. */
|
|
100
|
+
const MENTION_RE = /(?:^|[\s(])@(?:"([^"]+)"|([^\s()'"`,:]+))/g;
|
|
101
|
+
const MAX_MENTION_CHARS = 60_000;
|
|
102
|
+
/**
|
|
103
|
+
* Expand `@file` mentions trong task thành nội dung file (giống Claude Code):
|
|
104
|
+
* `fix @src/a.ts` → task + block nội dung file. Path được đọc relative workdir;
|
|
105
|
+
* mention không tồn tại giữ nguyên text (LLM tự nhận ra path sai).
|
|
106
|
+
*/
|
|
107
|
+
export function expandMentions(task, workdir) {
|
|
108
|
+
if (!task.includes("@"))
|
|
109
|
+
return task;
|
|
110
|
+
const mentions = [];
|
|
111
|
+
for (const m of task.matchAll(MENTION_RE)) {
|
|
112
|
+
const path = (m[1] ?? m[2] ?? "").trim();
|
|
113
|
+
if (path)
|
|
114
|
+
mentions.push({ token: m[0].trimStart(), path });
|
|
115
|
+
}
|
|
116
|
+
if (mentions.length === 0)
|
|
117
|
+
return task;
|
|
118
|
+
let out = task;
|
|
119
|
+
const blocks = [];
|
|
120
|
+
for (const { token, path } of mentions) {
|
|
121
|
+
// Chỉ remove token đầu tiên khớp (đề phòng path trùng lặp).
|
|
122
|
+
const idx = out.indexOf(token);
|
|
123
|
+
if (idx >= 0)
|
|
124
|
+
out = (out.slice(0, idx) + " " + out.slice(idx + token.length)).replace(/ {2,}/g, " ");
|
|
125
|
+
const abs = resolve(workdir, path);
|
|
126
|
+
if (!abs.startsWith(resolve(workdir))) {
|
|
127
|
+
blocks.push(`@${path}: path escapes working directory — skipped.`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const st = statSync(abs);
|
|
132
|
+
if (st.isDirectory()) {
|
|
133
|
+
// Directory mention → list file bên trong (depth 1).
|
|
134
|
+
const entries = readdirSync(abs).slice(0, 100);
|
|
135
|
+
blocks.push(`@${path}/ (directory):\n${entries.join("\n")}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (st.size > MAX_MENTION_CHARS) {
|
|
139
|
+
blocks.push(`@${path}: too large (${(st.size / 1024).toFixed(0)}KB) — use read_file for it.`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
blocks.push(`@${path}:\n${readFileSync(abs, "utf8").slice(0, MAX_MENTION_CHARS)}`);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
blocks.push(`@${path}: not found (${err.message.split("\n")[0]})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return `${out.trim()}\n\n${blocks.join("\n\n")}`;
|
|
149
|
+
}
|
package/dist/cost.js
CHANGED
|
@@ -92,6 +92,38 @@ export function loadSessionUsage(workdir, sessionId) {
|
|
|
92
92
|
const events = readJsonlLog(path);
|
|
93
93
|
return events ? extractUsageEvents(sessionId, events) : [];
|
|
94
94
|
}
|
|
95
|
+
const EMPTY_METRICS = {
|
|
96
|
+
stepCount: 0,
|
|
97
|
+
stepLatencyTotalMs: 0,
|
|
98
|
+
stepLatencyMaxMs: 0,
|
|
99
|
+
toolCallCount: 0,
|
|
100
|
+
guardrailCount: 0,
|
|
101
|
+
success: null,
|
|
102
|
+
};
|
|
103
|
+
/** Trích metrics hiệu năng từ raw JSONL events của một session. */
|
|
104
|
+
export function extractSessionMetrics(events) {
|
|
105
|
+
const m = { ...EMPTY_METRICS };
|
|
106
|
+
for (const ev of events) {
|
|
107
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
108
|
+
switch (ev.type) {
|
|
109
|
+
case "step":
|
|
110
|
+
m.stepCount += 1;
|
|
111
|
+
m.stepLatencyTotalMs += num(ev.durationMs);
|
|
112
|
+
m.stepLatencyMaxMs = Math.max(m.stepLatencyMaxMs, num(ev.durationMs));
|
|
113
|
+
break;
|
|
114
|
+
case "tool":
|
|
115
|
+
m.toolCallCount += 1;
|
|
116
|
+
break;
|
|
117
|
+
case "guardrail":
|
|
118
|
+
m.guardrailCount += 1;
|
|
119
|
+
break;
|
|
120
|
+
case "finish":
|
|
121
|
+
m.success = ev.success === true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return m;
|
|
126
|
+
}
|
|
95
127
|
/**
|
|
96
128
|
* Collect usage for a selection of sessions:
|
|
97
129
|
* - explicit id -> that session
|
|
@@ -110,10 +142,14 @@ export function collectUsage(workdir, opts = {}) {
|
|
|
110
142
|
else {
|
|
111
143
|
ids = listLogSessions(workdir);
|
|
112
144
|
}
|
|
113
|
-
return ids.map((id) =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
145
|
+
return ids.map((id) => {
|
|
146
|
+
const events = readJsonlLog(resolve(workdir, ".scoder/logs", `${id}.jsonl`)) ?? [];
|
|
147
|
+
return {
|
|
148
|
+
sessionId: id,
|
|
149
|
+
aggregate: aggregateUsage(extractUsageEvents(id, events), opts.pricingOverrides),
|
|
150
|
+
metrics: extractSessionMetrics(events),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
117
153
|
}
|
|
118
154
|
/** Render a human-readable cost report (also used by tests to assert output). */
|
|
119
155
|
export function formatCostReport(sessionCosts) {
|
|
@@ -122,17 +158,31 @@ export function formatCostReport(sessionCosts) {
|
|
|
122
158
|
}
|
|
123
159
|
const grand = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, costUsd: 0 };
|
|
124
160
|
const lines = ["COST REPORT"];
|
|
161
|
+
let successes = 0;
|
|
162
|
+
let finished = 0;
|
|
125
163
|
for (const sc of sessionCosts) {
|
|
126
164
|
const a = sc.aggregate;
|
|
165
|
+
const mx = sc.metrics;
|
|
127
166
|
grand.calls += a.calls;
|
|
128
167
|
grand.inputTokens += a.inputTokens;
|
|
129
168
|
grand.outputTokens += a.outputTokens;
|
|
130
169
|
grand.cachedTokens += a.cachedTokens;
|
|
131
170
|
grand.costUsd += a.costUsd;
|
|
132
|
-
|
|
171
|
+
if (mx.success !== null) {
|
|
172
|
+
finished += 1;
|
|
173
|
+
if (mx.success)
|
|
174
|
+
successes += 1;
|
|
175
|
+
}
|
|
176
|
+
const avgS = mx.stepCount > 0 ? (mx.stepLatencyTotalMs / mx.stepCount / 1000).toFixed(1) : null;
|
|
177
|
+
lines.push(` ${sc.sessionId} calls=${a.calls} in=${a.inputTokens.toLocaleString("en-US")} out=${a.outputTokens.toLocaleString("en-US")} cached=${a.cachedTokens.toLocaleString("en-US")} cost≈$${a.costUsd.toFixed(4)}` +
|
|
178
|
+
(mx.stepCount > 0
|
|
179
|
+
? ` | steps=${mx.stepCount}${avgS ? ` llm-avg=${avgS}s` : ""} tools=${mx.toolCallCount} guardrails=${mx.guardrailCount}` +
|
|
180
|
+
(mx.success !== null ? ` ${mx.success ? "✓success" : "✗failed"}` : "")
|
|
181
|
+
: ""));
|
|
133
182
|
}
|
|
134
183
|
grand.costUsd = Math.round(grand.costUsd * 10_000) / 10_000;
|
|
135
|
-
lines.push(`TOTAL (${sessionCosts.length} session(s)) calls=${grand.calls} in=${grand.inputTokens.toLocaleString("en-US")} out=${grand.outputTokens.toLocaleString("en-US")} cached=${grand.cachedTokens.toLocaleString("en-US")} cost≈$${grand.costUsd.toFixed(4)}`
|
|
184
|
+
lines.push(`TOTAL (${sessionCosts.length} session(s)) calls=${grand.calls} in=${grand.inputTokens.toLocaleString("en-US")} out=${grand.outputTokens.toLocaleString("en-US")} cached=${grand.cachedTokens.toLocaleString("en-US")} cost≈$${grand.costUsd.toFixed(4)}` +
|
|
185
|
+
(finished > 0 ? ` | success-rate=${successes}/${finished}` : ""));
|
|
136
186
|
return lines.join("\n");
|
|
137
187
|
}
|
|
138
188
|
/** Join a path under the workdir's log dir (exported for tests). */
|