s-app-scoder-agent 0.8.20 → 0.8.21
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 +20 -2
- package/dist/agent.js +95 -17
- package/dist/context.js +22 -8
- package/dist/tui/command-registry.js +13 -0
- package/dist/tui/commands.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # optional, for custom endpoin
|
|
|
50
50
|
# Run a task
|
|
51
51
|
scoder "add a ping function to src/network.ts"
|
|
52
52
|
|
|
53
|
-
# Interactive REPL (supports /help, /clear, /cost, /model, /skills, /skill, /mcp, /review, /permission)
|
|
53
|
+
# Interactive REPL (supports /help, /clear, /compact, /cost, /model, /skills, /skill, /mcp, /review, /permission)
|
|
54
54
|
scoder
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -234,6 +234,7 @@ Read the current diff. Check for: null derefs, injection risks, ...
|
|
|
234
234
|
|---|---|
|
|
235
235
|
| `/help` | command list |
|
|
236
236
|
| `/clear` | reset conversation + agent context |
|
|
237
|
+
| `/compact` | summarize older history now (manual compaction) |
|
|
237
238
|
| `/cost` | session token usage |
|
|
238
239
|
| `/model` | active model/provider/build |
|
|
239
240
|
| `/skills` | list discovered skills |
|
|
@@ -245,6 +246,23 @@ Read the current diff. Check for: null derefs, injection risks, ...
|
|
|
245
246
|
|
|
246
247
|
In the TUI, **Tab** completes commands (and `/skill` names, `/mcp <server> <tool>` arguments), **↑/↓** recall sent-message history.
|
|
247
248
|
|
|
249
|
+
### Context compaction
|
|
250
|
+
|
|
251
|
+
Long runs are summarized automatically when history reaches `contextWindow × trigger` (default **80%**). Mechanical trimming only starts later, at **95%** of the window, so compaction gets the first chance to preserve task state. `/compact` runs the same summarization immediately (useful mid-session or when auto-compaction is disabled).
|
|
252
|
+
|
|
253
|
+
```jsonc
|
|
254
|
+
// .scoderrc.json — all fields optional
|
|
255
|
+
"compaction": {
|
|
256
|
+
"trigger": 0.8, // fraction of the model window that triggers compaction
|
|
257
|
+
"keep": 0.15, // recent-history fraction kept verbatim
|
|
258
|
+
"buffer": 0.15, // extra recent-history fraction never summarized
|
|
259
|
+
"contextWindow": 128000 // override the model-catalog window
|
|
260
|
+
}
|
|
261
|
+
// "compaction": false disables automatic compaction (mechanical trim only)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`/compact` remains available even when automatic compaction is disabled.
|
|
265
|
+
|
|
248
266
|
### @file mentions
|
|
249
267
|
|
|
250
268
|
Prefix any path with `@` in your task to inline its content into the agent's context:
|
|
@@ -377,7 +395,7 @@ Features:
|
|
|
377
395
|
- **Header row 2** — (no longer present; phase breadcrumb + metrics moved into the sidebar / status bar to avoid duplication)
|
|
378
396
|
- **Diff cards** (`✎`) — every file write/edit renders as a unified diff (`+` green / `-` red / `@@` cyan hunk headers); the same diff also flows out of the HTTP SSE stream
|
|
379
397
|
- **Status bar** — model, token usage (input/output/cached), scroll position, keybind hints
|
|
380
|
-
- **Slash commands** — `/help`, `/clear`, `/cost`, `/model`, `/skills`, `/skill`, `/mcp`, `/review`, `/permission`, `/exit` (identical to the REPL)
|
|
398
|
+
- **Slash commands** — `/help`, `/clear`, `/compact`, `/cost`, `/model`, `/skills`, `/skill`, `/mcp`, `/review`, `/permission`, `/exit` (identical to the REPL)
|
|
381
399
|
- **Multi-turn** — keep typing follow-ups after a task finishes; the conversation context carries over (same as the REPL)
|
|
382
400
|
- **Scrollback** — `PgUp`/`PgDn` page in 0.8-viewport steps, `Home`/`End` jump to top/bottom, `Esc` returns to the live tail (press again to abort); rendering is capped at the last 200 items for smooth scrolling on long runs
|
|
383
401
|
- **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+C` quit
|
package/dist/agent.js
CHANGED
|
@@ -4,13 +4,13 @@ import { AnthropicClient } from "./provider-anthropic.js";
|
|
|
4
4
|
import { getTool, allToolSpecs, setMaxOutputChars } from "./tools.js";
|
|
5
5
|
import { invalidateFileIndex } from "./tui/at-complete.js";
|
|
6
6
|
import { detectBuildCommand, runBuild } from "./build.js";
|
|
7
|
-
import { trimHistory, summariseOutput, expandMentions } from "./context.js";
|
|
7
|
+
import { trimHistory, summariseOutput, expandMentions, MAX_HISTORY_TOKENS, historyTokenBudget } from "./context.js";
|
|
8
8
|
import { createGuardrailEngine, CHAT_DONE_MARKER } from "./guardrails.js";
|
|
9
9
|
import { PROMPT_VERSION } from "./prompt.js";
|
|
10
10
|
import { buildSystemPrompt, buildEnvInfo, buildSkillsBlock, buildAgentsBlock, buildMcpBlock } from "./prompt-builder.js";
|
|
11
11
|
import { PermissionChecker } from "./permissions.js";
|
|
12
12
|
import { SessionStore, EventSessionStore } from "./session.js";
|
|
13
|
-
import { DEFAULT_COMPACTION, shouldCompact, extractSummaryInput, COMPACTION_INSTRUCTION, applyCompaction } from "./compaction.js";
|
|
13
|
+
import { DEFAULT_COMPACTION, shouldCompact, estimateTokens, findCutIndex, extractSummaryInput, COMPACTION_INSTRUCTION, applyCompaction } from "./compaction.js";
|
|
14
14
|
import { SnapshotManager } from "./snapshot.js";
|
|
15
15
|
import { contextWindowFor } from "./model-catalog.js";
|
|
16
16
|
import { JsonlLogger } from "./logger.js";
|
|
@@ -784,6 +784,20 @@ export class Agent {
|
|
|
784
784
|
await this.connectMcp();
|
|
785
785
|
return this.runLoop(session.steps ?? 0, buildCmd, lastBuild);
|
|
786
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* Resolve compaction settings from config + model catalog (H-10/I-02).
|
|
789
|
+
* Also used by `/compact`: manual compaction is an explicit user action and
|
|
790
|
+
* stays available even when auto-compaction is disabled.
|
|
791
|
+
*/
|
|
792
|
+
compactionSettings() {
|
|
793
|
+
const catalogWindow = contextWindowFor(this.config.provider, this.config.model);
|
|
794
|
+
const overrides = this.config.compaction;
|
|
795
|
+
return {
|
|
796
|
+
...DEFAULT_COMPACTION,
|
|
797
|
+
contextWindow: this.config.contextWindow ?? catalogWindow,
|
|
798
|
+
...(overrides && typeof overrides === "object" ? overrides : {}),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
787
801
|
/**
|
|
788
802
|
* The shared autonomous loop:
|
|
789
803
|
* LLM plans -> tool calls -> build -> on failure: read error, patch, retry.
|
|
@@ -797,14 +811,16 @@ export class Agent {
|
|
|
797
811
|
// H-10 compaction config (false = disabled → mechanical trim only).
|
|
798
812
|
// I-02: contextWindow từ model catalog (lookup sync, cache/fallback) thay
|
|
799
813
|
// vì default 128k cứng — compaction trigger chính xác theo model.
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
814
|
+
const compactionCfg = this.config.compaction === false ? null : this.compactionSettings();
|
|
815
|
+
// Compaction-aware trim: mechanical trim scales with the model window and
|
|
816
|
+
// starts AFTER compaction (95% vs 80% trigger). Disabled compaction keeps
|
|
817
|
+
// the legacy conservative 90k cap.
|
|
818
|
+
const trimBudget = compactionCfg
|
|
819
|
+
? historyTokenBudget(compactionCfg.contextWindow)
|
|
820
|
+
: MAX_HISTORY_TOKENS;
|
|
821
|
+
// When a run ends early (guardrail finish/build verification) compaction is
|
|
822
|
+
// skipped, so never leave a near-full window for the next turn.
|
|
823
|
+
const safeTrimBudget = Math.min(trimBudget, MAX_HISTORY_TOKENS);
|
|
808
824
|
while (steps < this.config.maxSteps && !this.aborted) {
|
|
809
825
|
// ── Safe step boundary: deliver steered user input (H-08) ────────────
|
|
810
826
|
const steered = this.drainSteerInbox();
|
|
@@ -980,7 +996,7 @@ export class Agent {
|
|
|
980
996
|
lastBuild = await this.checkBuild(buildCmd);
|
|
981
997
|
if (lastBuild.success) {
|
|
982
998
|
if (doTrim)
|
|
983
|
-
this.messages = trimHistory(this.messages);
|
|
999
|
+
this.messages = trimHistory(this.messages, safeTrimBudget);
|
|
984
1000
|
return this.finish(true, steps, buildCmd, a.successSummary);
|
|
985
1001
|
}
|
|
986
1002
|
reminders.push(`${a.failReminder}\n${summariseOutput(lastBuild.output)}\nFix and continue.`);
|
|
@@ -992,34 +1008,46 @@ export class Agent {
|
|
|
992
1008
|
for (const r of reminders)
|
|
993
1009
|
this.messages.push({ role: "user", content: r });
|
|
994
1010
|
if (doTrim)
|
|
995
|
-
this.messages = trimHistory(this.messages);
|
|
1011
|
+
this.messages = trimHistory(this.messages, safeTrimBudget);
|
|
996
1012
|
return this.finish(a.success, steps, buildCmd, a.summary, a.chatOnly ?? false);
|
|
997
1013
|
}
|
|
998
1014
|
}
|
|
999
1015
|
for (const r of reminders)
|
|
1000
1016
|
this.messages.push({ role: "user", content: r });
|
|
1001
|
-
if (doTrim)
|
|
1002
|
-
this.messages = trimHistory(this.messages);
|
|
1003
1017
|
// H-10: LLM compaction when history approaches the context window.
|
|
1018
|
+
// Runs BEFORE mechanical trim: trim drops whole turns, which could push
|
|
1019
|
+
// the history below the trigger before compaction ever sees it.
|
|
1004
1020
|
if (compactionCfg && shouldCompact(this.messages, compactionCfg)) {
|
|
1005
1021
|
try {
|
|
1006
1022
|
ui.info("compaction: summarizing older history…");
|
|
1023
|
+
const tokensBefore = estimateTokens(this.messages);
|
|
1007
1024
|
const summaryInput = extractSummaryInput(this.currentTask, this.messages);
|
|
1008
1025
|
const summaryRes = await this.client.chat([
|
|
1009
1026
|
...this.messages,
|
|
1010
1027
|
{ role: "user", content: `${summaryInput}\n\n${COMPACTION_INSTRUCTION}` },
|
|
1011
1028
|
], [], // no tools for the summarizer call
|
|
1012
1029
|
{ signal: this.runSignal.signal });
|
|
1030
|
+
this.recordLlmUsage(summaryRes.usage);
|
|
1013
1031
|
this.messages = applyCompaction(this.messages, redactSecrets(summaryRes.message.content ?? "History summarized."), compactionCfg);
|
|
1014
|
-
|
|
1015
|
-
|
|
1032
|
+
const tokensAfter = estimateTokens(this.messages);
|
|
1033
|
+
this.events.append({
|
|
1034
|
+
type: "compaction.ran",
|
|
1035
|
+
tokensBefore,
|
|
1036
|
+
tokensAfter,
|
|
1037
|
+
summary: (summaryRes.message.content ?? "").slice(0, 2000),
|
|
1038
|
+
});
|
|
1039
|
+
ui.info(`compaction: done (${tokensBefore} → ${tokensAfter} tokens, history now ${this.messages.length} messages)`);
|
|
1016
1040
|
}
|
|
1017
1041
|
catch (err) {
|
|
1018
1042
|
// Compaction is best-effort — mechanical trim stays as fallback.
|
|
1019
1043
|
ui.warn(`compaction failed (${err.message.slice(0, 100)}) — using mechanical trim`);
|
|
1020
|
-
|
|
1044
|
+
// Fallback uses the legacy conservative cap: the next LLM call must
|
|
1045
|
+
// not leave a near-full window if summarization failed.
|
|
1046
|
+
this.messages = trimHistory(this.messages, safeTrimBudget);
|
|
1021
1047
|
}
|
|
1022
1048
|
}
|
|
1049
|
+
if (doTrim)
|
|
1050
|
+
this.messages = trimHistory(this.messages, trimBudget);
|
|
1023
1051
|
// Persist session state after every step (enables resume after crash/SIGINT)
|
|
1024
1052
|
this.saveSession(steps, buildCmd, lastBuild);
|
|
1025
1053
|
this.events.releaseExecution(steps);
|
|
@@ -1077,6 +1105,56 @@ export class Agent {
|
|
|
1077
1105
|
return res;
|
|
1078
1106
|
}
|
|
1079
1107
|
// ── Frontend-facing APIs (REPL/TUI slash commands) ─────────────────────────
|
|
1108
|
+
/**
|
|
1109
|
+
* Manual compaction (`/compact`): summarize older history now, keeping the
|
|
1110
|
+
* system prompt + the most recent turns. Explicit user action — available
|
|
1111
|
+
* even when auto-compaction is disabled via `"compaction": false`.
|
|
1112
|
+
*/
|
|
1113
|
+
async compactHistory() {
|
|
1114
|
+
if (this.messages.length === 0) {
|
|
1115
|
+
return { ok: false, message: "nothing to compact — no conversation yet" };
|
|
1116
|
+
}
|
|
1117
|
+
const cfg = this.compactionSettings();
|
|
1118
|
+
const firstContent = this.messages[0]?.role === "system" ? 1 : 0;
|
|
1119
|
+
if (findCutIndex(this.messages, cfg) <= firstContent) {
|
|
1120
|
+
return { ok: false, message: "nothing to compact — conversation is already short" };
|
|
1121
|
+
}
|
|
1122
|
+
const tokensBefore = estimateTokens(this.messages);
|
|
1123
|
+
const messagesBefore = this.messages.length;
|
|
1124
|
+
const summaryInput = extractSummaryInput(this.currentTask, this.messages);
|
|
1125
|
+
ui.info("compaction: summarizing older history…");
|
|
1126
|
+
try {
|
|
1127
|
+
// A previous run interrupted with Esc leaves the shared signal aborted.
|
|
1128
|
+
if (this.runSignal.signal.aborted)
|
|
1129
|
+
this.resetRunSignal();
|
|
1130
|
+
const summaryRes = await this.client.chat([
|
|
1131
|
+
...this.messages,
|
|
1132
|
+
{ role: "user", content: `${summaryInput}\n\n${COMPACTION_INSTRUCTION}` },
|
|
1133
|
+
], [], // no tools for the summarizer call
|
|
1134
|
+
{ signal: this.runSignal.signal });
|
|
1135
|
+
this.recordLlmUsage(summaryRes.usage);
|
|
1136
|
+
const summary = redactSecrets(summaryRes.message.content ?? "History summarized.");
|
|
1137
|
+
this.messages = applyCompaction(this.messages, summary, cfg);
|
|
1138
|
+
const tokensAfter = estimateTokens(this.messages);
|
|
1139
|
+
this.events.append({
|
|
1140
|
+
type: "compaction.ran",
|
|
1141
|
+
tokensBefore,
|
|
1142
|
+
tokensAfter,
|
|
1143
|
+
manual: true,
|
|
1144
|
+
summary: summary.slice(0, 2000),
|
|
1145
|
+
});
|
|
1146
|
+
ui.info(`compaction: done (${tokensBefore} → ${tokensAfter} tokens)`);
|
|
1147
|
+
return {
|
|
1148
|
+
ok: true,
|
|
1149
|
+
message: `compaction done: ${messagesBefore} → ${this.messages.length} messages · ~${tokensBefore} → ~${tokensAfter} tokens`,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
catch (err) {
|
|
1153
|
+
const msg = err.message.slice(0, 200);
|
|
1154
|
+
ui.warn(`compaction failed (${msg})`);
|
|
1155
|
+
return { ok: false, message: `compaction failed: ${msg}` };
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1080
1158
|
/** Skills visible to this project (for /skills). */
|
|
1081
1159
|
getSkills() {
|
|
1082
1160
|
return this.skills;
|
package/dist/context.js
CHANGED
|
@@ -3,11 +3,22 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
3
3
|
import { resolve } 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
|
|
@@ -55,6 +55,19 @@ export function coreCommands() {
|
|
|
55
55
|
notice(formatUsage(host.agent()?.getUsage() ?? { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 }));
|
|
56
56
|
},
|
|
57
57
|
},
|
|
58
|
+
{
|
|
59
|
+
name: "/compact",
|
|
60
|
+
description: "summarize older history now (manual compaction)",
|
|
61
|
+
async run(_slash, host, notice) {
|
|
62
|
+
const agent = host.agent();
|
|
63
|
+
if (!agent)
|
|
64
|
+
return notice("agent not ready — start a task first", "warn");
|
|
65
|
+
if (host.isRunning?.())
|
|
66
|
+
return notice("agent is running — wait or Esc first", "warn");
|
|
67
|
+
const r = await agent.compactHistory();
|
|
68
|
+
notice(r.message, r.ok ? "info" : "warn");
|
|
69
|
+
},
|
|
70
|
+
},
|
|
58
71
|
{
|
|
59
72
|
name: "/skills",
|
|
60
73
|
description: "list discovered skills (SKILL.md)",
|
package/dist/tui/commands.js
CHANGED
|
@@ -9,6 +9,7 @@ export const SLASH_COMMANDS = [
|
|
|
9
9
|
{ cmd: "/diff", desc: "show diffs: /diff turn | session | branch" },
|
|
10
10
|
{ cmd: "/theme", desc: "switch palette: /theme <name> — /theme list (mission|paper|mono|light|high-contrast|solarized)" },
|
|
11
11
|
{ cmd: "/clear", desc: "clear the conversation and reset agent context" },
|
|
12
|
+
{ cmd: "/compact", desc: "summarize older history now (manual compaction)" },
|
|
12
13
|
{ cmd: "/cost", desc: "token usage of the current session" },
|
|
13
14
|
{ cmd: "/model", desc: "show the active model & provider" },
|
|
14
15
|
{ cmd: "/skills", desc: "list discovered skills (SKILL.md)" },
|
package/package.json
CHANGED