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.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +600 -468
  4. package/dist/agent.js +841 -197
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +423 -0
  11. package/dist/index.js +129 -76
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +123 -9
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +103 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +32 -2
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +513 -19
  30. package/dist/tui/app.js +505 -198
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +146 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +200 -2
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +99 -0
  44. package/dist/tui/state.js +192 -6
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/watch.js +52 -0
  48. package/package.json +16 -8
  49. 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 { resolve } from "node:path";
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
- function loadRcFile(workdir) {
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
- try {
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
- * Trim message history to stay within token budget.
4
- * Strategy: keep system prompt + last N messages, compact older user/assistant pairs.
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
- if (messages.length <= MAX_MESSAGES)
8
- return messages;
9
- // Keep system + last MAX_MESSAGES-1
10
- const system = messages[0].role === "system" ? messages[0] : null;
11
- const rest = system ? messages.slice(1) : messages;
12
- const trimmed = rest.slice(-(MAX_MESSAGES - 1));
13
- if (system) {
14
- return [system, ...trimmed];
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 trimmed;
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
- return /\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);
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
- sessionId: id,
115
- aggregate: aggregateUsage(loadSessionUsage(workdir, id), opts.pricingOverrides),
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
- 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)}`);
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). */