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 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 catalogWindow = contextWindowFor(this.config.provider, this.config.model);
801
- const compactionCfg = this.config.compaction === false
802
- ? null
803
- : {
804
- ...DEFAULT_COMPACTION,
805
- contextWindow: this.config.contextWindow ?? catalogWindow,
806
- ...(this.config.compaction ?? {}),
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
- this.events.append({ type: "compaction.ran", tokensBefore: 0, summary: (summaryRes.message.content ?? "").slice(0, 2000) });
1015
- ui.info(`compaction: done (history now ${this.messages.length} messages)`);
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
- this.messages = trimHistory(this.messages);
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
- * 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.
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 MAX_HISTORY_TOKENS — dropping whole
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) > MAX_HISTORY_TOKENS) {
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)",
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s-app-scoder-agent",
3
- "version": "0.8.20",
3
+ "version": "0.8.21",
4
4
  "description": "Autonomous coding agent CLI — writes, edits, and builds code autonomously until the project compiles without errors.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",