atom-agent 1.2.0 → 1.4.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 (60) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/diff-panel.js +5 -5
  33. package/dist/ui/diff-view.js +3 -2
  34. package/dist/ui/diff.js +7 -52
  35. package/dist/ui/modals.js +5 -5
  36. package/dist/ui/palette.js +1 -1
  37. package/dist/ui/side-by-side.js +7 -5
  38. package/dist/ui/status-bar.js +80 -5
  39. package/dist/ui/transcript.js +3 -3
  40. package/dist/zen.js +305 -75
  41. package/documentation/architecture.md +114 -0
  42. package/documentation/cli.md +82 -0
  43. package/documentation/compaction.md +50 -0
  44. package/documentation/configuration.md +111 -0
  45. package/documentation/development.md +62 -0
  46. package/documentation/extensions.md +160 -0
  47. package/documentation/getting-started.md +63 -0
  48. package/documentation/goals.md +41 -0
  49. package/documentation/index.md +41 -0
  50. package/documentation/observability.md +70 -0
  51. package/documentation/permissions.md +66 -0
  52. package/documentation/providers.md +78 -0
  53. package/documentation/sessions.md +92 -0
  54. package/documentation/skills.md +57 -0
  55. package/documentation/tools.md +94 -0
  56. package/documentation/troubleshooting.md +54 -0
  57. package/examples/extensions/01-audit-gate.js +24 -0
  58. package/examples/extensions/02-notes-tool.js +32 -0
  59. package/examples/extensions/03-custom-command.js +32 -0
  60. package/package.json +6 -2
package/dist/cli.js CHANGED
@@ -4,8 +4,13 @@ import { render } from "ink";
4
4
  import { App } from "./App.js";
5
5
  import { DEFAULT_ENDPOINT, DEFAULT_MODEL, endpointConfig, } from "./zen.js";
6
6
  import { loadAuth, resolveApiKey } from "./auth.js";
7
+ import { parseExtensionFlags } from "./extensions.js";
7
8
  import { writeTelemetryDashboard } from "./telemetry-dashboard.js";
8
9
  const args = process.argv.slice(2);
10
+ // Extension trust lockdown (ticket 07): --no-extensions (--lockdown alias)
11
+ // boots with zero third-party extensions; --enable/--disable-extension take
12
+ // repeatable `*`/`?` patterns over extension names (CLI wins over atom.json).
13
+ const extFlags = parseExtensionFlags(args);
9
14
  if (args.includes("--dashboard")) {
10
15
  // Local observability dashboard without starting the TUI: render every
11
16
  // stored session to ~/.atom/telemetry/dashboard.html and print the path.
@@ -54,15 +59,18 @@ else if (args.includes("--help") || args.includes("-h")) {
54
59
  Usage: npm start
55
60
  Flags: --dashboard (write ~/.atom/telemetry/dashboard.html and exit)
56
61
  --serve [--port <n>] (serve the live dashboard webUI on loopback and keep running)
62
+ --no-extensions (--lockdown alias: boot with zero third-party extensions; builtins unchanged)
63
+ --enable-extension <glob> (repeatable; only matching extensions load)
64
+ --disable-extension <glob> (repeatable; wins over --enable-extension)
57
65
  Env:
58
66
  KILO_API_KEY optional (Kilo free models work anonymously; get a key at https://kilo.ai) — env wins over ~/.atom/auth.json
59
67
  OPENCODE_ZEN_API_KEY optional when ~/.atom/auth.json has a zen key (get one at https://opencode.ai/auth)
60
68
  OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY / MISTRAL_API_KEY / GEMINI_API_KEY (GOOGLE_API_KEY alias) optional per provider (env wins over stored)
61
69
  OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL}; when set, wins over the saved /model)
62
70
  OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
63
- Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll (toggle follow new output) | /thinking (toggle reasoning visibility) | /mode | /clear | /new (fresh conversation, previous kept) | /rename <name> (rename current session) | /session (switch session picker) | /resume (restore last saved session) | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan
71
+ Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /goal <objective> (pin one session objective; bare shows it, pause/resume/clear manage it) | /compact [focus] (summarize older turns) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll (toggle follow new output) | /thinking (toggle reasoning visibility) | /mode | /trust | /allow | /deny | /rules | /clear | /new (fresh conversation, previous kept) | /rename <name> (rename current session) | /session (switch session picker) | /resume (restore last saved session) | /telemetry | /dashboard | /rewind | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan → normal (extension slash commands appear in the / menu and palette, not in this static list)
64
72
  Providers: kilo (default; anonymous free models, key optional)/opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one) + local auto-discovery: ollama (:11434), lmstudio (:1234), llamacpp (:8080) — no keys needed, overrides via ATOM_OLLAMA_URL/ATOM_LMSTUDIO_URL/ATOM_LLAMACPP_URL.
65
- Note: reasoning_effort is sent only for opencode-zen supported models.`);
73
+ Effort (Auto/Low/Medium/High/Max) applies on every provider: reasoning_effort for OpenAI-chat kinds, thinking budgets for Anthropic, thinking levels for Gemini. Auto omits the knob.`);
66
74
  process.exit(0);
67
75
  }
68
76
  const { endpoint, apiKey: envKey } = endpointConfig();
@@ -96,7 +104,7 @@ if (!args.includes("--serve")) {
96
104
  // - concurrent: enables React concurrent features (useTransition /
97
105
  // useDeferredValue) for future deferral of expensive subtrees.
98
106
  // Tests are unaffected: they render via ink-testing-library, not here.
99
- render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true }), {
107
+ render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true, extensionsLockdown: extFlags.lockdown, enableExtensions: extFlags.enable, disableExtensions: extFlags.disable }), {
100
108
  incrementalRendering: process.env.ATOM_INCREMENTAL !== "0",
101
109
  maxFps: 30,
102
110
  concurrent: true,
package/dist/compact.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // by the summary; manual `/compact [focus]`; thrashing guard.
9
9
  // - opencode V2: preflight estimate = JSON-serialized request size at
10
10
  // 4 chars/token; summary via session model with TOOLS DISABLED, ≤4096
11
- // output tokens, structured template; newest tail retained (~8000 tokens,
11
+ // output tokens, structured template; newest tail retained (~20000 tokens,
12
12
  // tool outputs capped 2000 chars); overflow-recovery retry once.
13
13
  //
14
14
  // This module is pure + testable (mocked fetch only in tests, never live).
@@ -23,7 +23,7 @@ import { estimateTokensForChars, messageChars } from "./context-manager.js";
23
23
  import { truncateHead } from "./tools/shared.js";
24
24
  export { COMPACT_PCT_DEFAULT, compactPct, computeContextLoad, estimateTokensForChars, historyChars, shouldAutoCompact, } from "./context-manager.js";
25
25
  // ---- Constants ----
26
- export const COMPACT_KEEP_TOKENS = 8000;
26
+ export const COMPACT_KEEP_TOKENS = 20000;
27
27
  export const COMPACT_SUMMARY_MAX_TOKENS = 4096;
28
28
  export const COMPACT_TOOL_OUTPUT_CAP = 2000;
29
29
  // opencode's 4ch/token heuristic (V2 preflight estimate): chars/4 floors to
@@ -103,7 +103,7 @@ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOK
103
103
  // Everything fits but >1 turn: keep only the newest turn in the tail so
104
104
  // manual /compact still has an older turn to summarize (auto never
105
105
  // reaches here — its load would be far below threshold when everything
106
- // fits in 8000 tokens).
106
+ // fits in 20000 tokens).
107
107
  if (tailStart === starts[0] && starts.length > 1) {
108
108
  tailStart = starts[starts.length - 1];
109
109
  }
@@ -117,28 +117,38 @@ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOK
117
117
  return { head, tail, olderTurnCount };
118
118
  }
119
119
  // ---- Instruction template ----
120
- export function buildCompactionInstruction(focusText) {
120
+ // goalObjective (ticket 08) is a prompt hint only: when a session goal is
121
+ // live, the summarizer is told to preserve goal-relevant progress, evidence,
122
+ // and next steps inside its prose (the canonical `Goal:` block is appended
123
+ // separately after the POST). Absent/blank reads exactly as before, so
124
+ // non-goal compaction output stays byte-identical.
125
+ export function buildCompactionInstruction(focusText, goalObjective) {
121
126
  const focus = typeof focusText === "string" && focusText.trim().length > 0
122
127
  ? `\nFocus for this summary: ${focusText.trim()}\n`
123
128
  : "";
129
+ const goal = typeof goalObjective === "string" && goalObjective.trim().length > 0
130
+ ? `\nSession goal to preserve: "${goalObjective.trim()}" — keep goal-relevant progress, ` +
131
+ `evidence, and next steps for it in the summary so the next turn can continue it ` +
132
+ `without re-exploring.\n`
133
+ : "";
124
134
  return (`Summarize the conversation so far for context compaction. Be concise but preserve all information needed to continue the work without re-reading the full history.` +
125
- `${focus}\n` +
135
+ `${focus}${goal}\n` +
126
136
  `Structure your summary with these headings (omit a section only when it has no content):\n` +
127
137
  `## Objective\n` +
128
- `## Requirements\n` +
129
- `## Decisions\n` +
130
- `## Completed work\n` +
131
- `## Active work\n` +
132
- `## Blockers\n` +
133
- `## Next moves\n` +
134
- `## Relevant files\n` +
138
+ `## Important Details\n` +
139
+ `## Work State\n` +
140
+ `### Completed\n` +
141
+ `### Active\n` +
142
+ `### Blocked\n` +
143
+ `## Next Move\n` +
144
+ `## Relevant Files\n` +
135
145
  `Rules: no tools are available for this request — answer with the summary text only, no tool calls, no preamble beyond the headings.`);
136
146
  }
137
- export function buildSummaryMessages(systemContent, head, focusText) {
147
+ export function buildSummaryMessages(systemContent, head, focusText, goalObjective) {
138
148
  return [
139
149
  { role: "system", content: systemContent },
140
150
  ...head.map((m) => ({ ...m })),
141
- { role: "user", content: buildCompactionInstruction(focusText) },
151
+ { role: "user", content: buildCompactionInstruction(focusText, goalObjective) },
142
152
  ];
143
153
  }
144
154
  export function buildCompactedHistory(systemMessage, summaryText, tail, olderTurnCount, nowISO) {
@@ -193,7 +203,7 @@ export function truncateHeadForRetry(head) {
193
203
  // throw immediately with history untouched (caller must not swap).
194
204
  export async function requestCompactSummary(req) {
195
205
  const attempt = async (head) => {
196
- const messages = buildSummaryMessages(req.systemContent, head, req.focusText);
206
+ const messages = buildSummaryMessages(req.systemContent, head, req.focusText, req.goalObjective);
197
207
  const res = await chatCompletionForProvider(req.provider, req.apiKey, req.model, messages, {
198
208
  baseURL: req.baseURL,
199
209
  endpointOverride: req.endpointOverride,
@@ -335,6 +345,22 @@ export function fitSummaryWithFiles(summaryText, touched, maxChars = COMPACT_SUM
335
345
  return { text: summaryText, truncated };
336
346
  return { text: `${summaryText}\n\n${shrunkBlock}`, truncated };
337
347
  }
348
+ // ---- Goal block fitting (ticket 08) ----
349
+ // Append the canonical `Goal:` block plus the touched-files lists within one
350
+ // shared budget: the goal block rides with the model text (never shrunk — it
351
+ // is one short line plus a capped checklist tail, see formatGoalForCompact),
352
+ // so only the touched-files lists shrink via the same rule above. The goal
353
+ // block lands BEFORE the files block, keeping `Touched files:` last so its
354
+ // verbatim extractor (lastIndexOf below) still finds the appended block. An
355
+ // empty goal block degrades exactly to fitSummaryWithFiles (non-goal output
356
+ // byte-identical); an over-budget summary still stands alone and compaction
357
+ // still succeeds.
358
+ export function fitSummaryWithFilesAndGoal(summaryText, touched, goalBlock, maxChars = COMPACT_SUMMARY_MAX_CHARS) {
359
+ const base = typeof goalBlock === "string" && goalBlock.length > 0
360
+ ? `${summaryText}\n\n${goalBlock}`
361
+ : summaryText;
362
+ return fitSummaryWithFiles(base, touched, maxChars);
363
+ }
338
364
  // ---- Resume surfacing ----
339
365
  // Pull the stored block(s) verbatim out of compacted summary messages — the
340
366
  // same format as stored, no reformatting. lastIndexOf prefers the appended
package/dist/config.js CHANGED
@@ -19,13 +19,15 @@
19
19
  // Keys:
20
20
  // - provider: ProviderId for first-run default (needs its key, else zen)
21
21
  // - model: default model id (non-empty string)
22
- // - reasoningEffort: default/low/medium/high/max
23
- // - maxHistoryMessages: 10–1000 (message-count safety ceiling)
24
- // - maxHistoryChars: 10_000–2_000_000 (char safety ceiling — caps the
25
- // window-derived budget, never the primary limit)
22
+ // - reasoningEffort: auto/low/medium/high/max ("default" is accepted as a
23
+ // deprecated alias for "auto")
26
24
  // - maxToolSteps: 5–100 (tool rounds per turn)
27
25
  // - compactPct: 50–95 (auto-compact percent of verified window)
28
26
  // - telemetry: {enabled?: boolean} (local observability recording, default on)
27
+ // - extensions: {enabled?: string[], disabled?: string[]} (per-extension
28
+ // enable/disable patterns over the extension name, `*`/`?` globs; disabled
29
+ // wins over enabled, non-empty enabled is an allowlist — see extensions.ts
30
+ // precedence. CLI --enable/--disable-extension wins over this when set.)
29
31
  import { existsSync, readFileSync } from "node:fs";
30
32
  import * as path from "node:path";
31
33
  import { homeDir } from "./auth.js";
@@ -35,7 +37,11 @@ export const ATOM_CONFIG_FILENAME = "atom.json";
35
37
  // Kept local (not imported from zen.js) so config.ts has no runtime import
36
38
  // of zen.js — zen.js imports loadAtomConfig for budget fallbacks, and a
37
39
  // runtime cycle would be fragile. Mirrors EFFORT_OPTIONS exactly.
38
- const EFFORT_VALUES = ["default", "low", "medium", "high", "max"];
40
+ const EFFORT_VALUES = ["auto", "low", "medium", "high", "max"];
41
+ // Pre-auto name for the same level (old atom.json files keep working).
42
+ const LEGACY_EFFORT_VALUES = {
43
+ default: "auto",
44
+ };
39
45
  export function projectConfigPath(projectDir) {
40
46
  return path.join(projectDir ?? process.cwd(), ATOM_CONFIG_FILENAME);
41
47
  }
@@ -106,13 +112,14 @@ function parseLevel(filePath, label) {
106
112
  if (typeof effort === "string" && EFFORT_VALUES.includes(effort)) {
107
113
  config.reasoningEffort = effort;
108
114
  }
115
+ else if (typeof effort === "string" && effort in LEGACY_EFFORT_VALUES) {
116
+ config.reasoningEffort = LEGACY_EFFORT_VALUES[effort];
117
+ }
109
118
  else {
110
119
  bad("reasoningEffort", `must be one of ${EFFORT_VALUES.join("/")}`);
111
120
  }
112
121
  }
113
122
  const ranged = [
114
- { key: "maxHistoryMessages", min: 10, max: 1000 },
115
- { key: "maxHistoryChars", min: 10_000, max: 2_000_000 },
116
123
  { key: "maxToolSteps", min: 5, max: 100 },
117
124
  { key: "compactPct", min: 50, max: 95 },
118
125
  ];
@@ -158,6 +165,35 @@ function parseLevel(filePath, label) {
158
165
  }
159
166
  }
160
167
  }
168
+ // Per-extension patterns (ticket 07): validated arrays of non-empty
169
+ // strings; a non-array key is ignored wholesale, bad entries are dropped
170
+ // with a warning (never throw, like every other key here).
171
+ const extensions = data["extensions"];
172
+ if (extensions !== undefined) {
173
+ if (!isRecord(extensions)) {
174
+ warnings.push(`${label} atom.json: ignoring invalid "extensions" (must be an object)`);
175
+ }
176
+ else {
177
+ const parsed = {};
178
+ for (const key of ["enabled", "disabled"]) {
179
+ const v = extensions[key];
180
+ if (v === undefined)
181
+ continue;
182
+ if (!Array.isArray(v)) {
183
+ warnings.push(`${label} atom.json: ignoring invalid "extensions.${key}" (must be an array of patterns)`);
184
+ continue;
185
+ }
186
+ const kept = v.filter((e) => typeof e === "string" && e.length > 0);
187
+ if (kept.length !== v.length) {
188
+ warnings.push(`${label} atom.json: "extensions.${key}" dropped ${v.length - kept.length} empty/non-string pattern(s)`);
189
+ }
190
+ parsed[key] = kept;
191
+ }
192
+ if (parsed.enabled !== undefined || parsed.disabled !== undefined) {
193
+ config.extensions = parsed;
194
+ }
195
+ }
196
+ }
161
197
  return { config, warnings, present: true };
162
198
  }
163
199
  export function loadAtomConfig(projectDir, homeDir) {
@@ -2,7 +2,6 @@
2
2
  // - How much context is available? (budget())
3
3
  // - How much is currently used? (usage())
4
4
  // - Should we compact? (needsCompaction())
5
- // - What messages should be sent? (trimForSend())
6
5
  //
7
6
  // Budget derivation (no fixed-200K assumption): the history allowance comes
8
7
  // from the model's ACTUAL verified context window:
@@ -12,20 +11,18 @@
12
11
  //
13
12
  // measured in tokens via the shared 4ch/token estimator. A model with a 1M
14
13
  // window therefore gets ~1M of usable history instead of ~50K tokens.
14
+ // Models with NO verified window report no allowance (a window is never
15
+ // invented; auto-compact stays off for them).
15
16
  //
16
- // Hard safety ceiling (configurable, never primary): env ATOM_MAX_HISTORY_*
17
- // and atom.json maxHistory* still resolve through historyCharSource /
18
- // historyMessageSource. The ceiling only ever CAPS the derived budget with
19
- // nothing configured it never binds for known-window models. Models with NO
20
- // verified window keep the legacy 200K-char / 100-message behavior exactly
21
- // (a window is never invented; auto-compact stays off for them).
17
+ // History itself is NEVER truncated: there are no message/char caps.
18
+ // Compaction (manual /compact, auto at ~83% of the verified window) is the
19
+ // only pressure valvePi-style.
22
20
  //
23
21
  // Layering: this module owns measurement + budget math. It imports
24
- // context-windows (metadata) and config (file fallback) at runtime, and
25
- // zen.js types ONLY (no runtime cycle — zen.ts imports this module for its
26
- // loop trim). The agent loop, compaction mechanics, and providers are
27
- // untouched: truncateHistory/shouldAutoCompact/compactPct keep working via
28
- // re-exports from their original modules.
22
+ // context-windows (metadata) and config (file fallback for compactPct) at
23
+ // runtime, and zen.js types ONLY (no runtime cycle — zen.ts imports this
24
+ // module for context math). The agent loop, compaction mechanics, and
25
+ // providers are untouched.
29
26
  //
30
27
  // Prompt-caching foundation (NOT implemented): all inputs here are explicit
31
28
  // values (system/tools/history split, measured sizes, stable options), so a
@@ -72,8 +69,10 @@ export function historyChars(history) {
72
69
  total += messageChars(m);
73
70
  return total;
74
71
  }
75
- // Load = last POST's reported prompt_tokens when available, else the
76
- // 4ch/token estimate of the sent history chars.
72
+ // Load = last POST's reported input-side tokens (prompt_tokens, normalized at
73
+ // parse time to include exclusive prefix-cache counters like Anthropic's
74
+ // cache_read/_creation) when available, else the 4ch/token estimate of the
75
+ // sent history chars.
77
76
  export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
78
77
  if (typeof lastPromptTokens === "number" &&
79
78
  Number.isFinite(lastPromptTokens) &&
@@ -82,51 +81,6 @@ export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
82
81
  }
83
82
  return estimateTokensForChars(sentHistoryChars);
84
83
  }
85
- // ---- Safety-ceiling sources (env > atom.json > compiled default) ----
86
- export const MAX_HISTORY_MESSAGES = 100;
87
- export const MAX_HISTORY_CHARS = 200_000;
88
- function clampInt(n, min, max) {
89
- return Math.min(Math.max(Math.floor(n), min), max);
90
- }
91
- function envInt(raw) {
92
- if (raw === undefined)
93
- return undefined;
94
- const text = raw.trim();
95
- if (!/^\d+$/.test(text))
96
- return undefined;
97
- const n = Number(text);
98
- return Number.isFinite(n) ? Math.floor(n) : undefined;
99
- }
100
- // Message-count ceiling source. `explicit` tells whether a human configured
101
- // it (env or file) as opposed to the compiled default.
102
- export function historyMessageSource() {
103
- const env = envInt(process.env.ATOM_MAX_HISTORY_MESSAGES);
104
- if (env !== undefined)
105
- return { value: clampInt(env, 10, 1000), explicit: true };
106
- const file = loadAtomConfig().config.maxHistoryMessages;
107
- if (file !== undefined)
108
- return { value: file, explicit: true };
109
- return { value: MAX_HISTORY_MESSAGES, explicit: false };
110
- }
111
- // Char-count safety ceiling source. Same explicit contract.
112
- export function historyCharSource() {
113
- const env = envInt(process.env.ATOM_MAX_HISTORY_CHARS);
114
- if (env !== undefined)
115
- return { value: clampInt(env, 10_000, 2_000_000), explicit: true };
116
- const file = loadAtomConfig().config.maxHistoryChars;
117
- if (file !== undefined)
118
- return { value: file, explicit: true };
119
- return { value: MAX_HISTORY_CHARS, explicit: false };
120
- }
121
- // Legacy accessors (env → file → default). Kept for the loop's legacy path
122
- // and existing callers; the manager uses the sources above so it can tell
123
- // configured ceilings apart from defaults.
124
- export function historyMessageBudget() {
125
- return historyMessageSource().value;
126
- }
127
- export function historyCharBudget() {
128
- return historyCharSource().value;
129
- }
130
84
  // ---- Compaction threshold (moved here: the manager owns "should compact") ----
131
85
  export const COMPACT_PCT_DEFAULT = 0.83;
132
86
  function clampPctPercent(n) {
@@ -159,135 +113,20 @@ export function shouldAutoCompact(load, model, pctOverride) {
159
113
  : compactPct();
160
114
  return load / window >= pct;
161
115
  }
162
- // Searchable text for todo matching: message content plus the assistant's
163
- // tool_calls payload (todowrite CALLS carry the list, tool RESULTS echo it).
164
- // Tool call ids are NOT searched — they are pairing keys, not goal text, so
165
- // a todo that reads like an id can never false-pin a turn.
166
- function todoHaystack(m) {
167
- let hay = "";
168
- const content = m.content;
169
- if (typeof content === "string")
170
- hay += content;
171
- if (m.role === "assistant" && m.tool_calls !== undefined) {
172
- try {
173
- hay += JSON.stringify(m.tool_calls);
174
- }
175
- catch {
176
- // unstringifiable payload pins nothing
177
- }
178
- }
179
- return hay;
180
- }
181
- function turnMentionsTodo(history, start, end, needles) {
182
- for (let i = start; i < end; i++) {
183
- const hay = todoHaystack(history[i]);
184
- if (hay.length === 0)
185
- continue;
186
- for (const n of needles) {
187
- if (n.length > 0 && hay.includes(n))
188
- return true;
189
- }
190
- }
191
- return false;
192
- }
193
- // Drop oldest user-turns until history fits BOTH caps (message count AND
194
- // total chars, each plus the caller's `reserve` headroom for a message it is
195
- // about to push). A user turn = the `user` message plus all following
196
- // messages up to (excluding) the next `user` message, so assistant
197
- // tool_calls always stay paired with their tool results across all three
198
- // wire formats. NEVER drops history[0] (system prompt), the first user turn
199
- // (the task prompt — the goal a long run must never forget), any turn that
200
- // still quotes a CURRENT open todo, or the latest turn (the one being
201
- // sent/built). Budget-aware edge: when the pinned content alone (first turn
202
- // + todo turns + latest) already exceeds a cap, there is nothing left to
203
- // drop — stop and still send (same never-drop-the-live-turn principle).
204
- // Mutates `history` in place via splice (so caller indices captured after
205
- // this call stay valid) and, when at least one turn dropped, fires ONE
206
- // `notify` (the caller surfaces it dim in the TUI); silence otherwise.
207
- // Returns what was dropped.
208
- export function truncateHistoryWithCaps(history, caps, opts) {
209
- const result = { droppedTurns: 0, droppedMessages: 0 };
210
- if (history.length <= 1)
211
- return result;
212
- const maxMessages = caps.maxMessages;
213
- const maxChars = caps.maxChars;
214
- const reserve = opts?.reserve;
215
- const needles = opts?.todoNeedles ?? [];
216
- const roomMessages = reserve?.messages !== undefined && Number.isFinite(reserve.messages)
217
- ? Math.max(0, Math.floor(reserve.messages))
218
- : 0;
219
- const roomChars = reserve?.chars !== undefined && Number.isFinite(reserve.chars)
220
- ? Math.max(0, reserve.chars)
221
- : 0;
222
- for (;;) {
223
- const over = history.length + roomMessages > maxMessages ||
224
- historyChars(history) + roomChars > maxChars;
225
- if (!over)
226
- break;
227
- // Turn boundaries over history[1..]: each turn starts at a `user`
228
- // message (the oldest slice starts at 1 even when it isn't one, matching
229
- // the pre-pin drop unit). Whole-turn drops keep assistant/tool pairing.
230
- const starts = [1];
231
- for (let i = 2; i < history.length; i++) {
232
- if (history[i]?.role === "user")
233
- starts.push(i);
234
- }
235
- // Oldest NON-pinned, non-latest turn goes first: the first turn (task
236
- // prompt) and any turn still quoting a current open todo stay, and the
237
- // latest turn is never dropped. No candidate means pinned content alone
238
- // is over budget — stop and send it as-is (see edge above).
239
- let drop = -1;
240
- for (let t = 0; t < starts.length; t++) {
241
- if (t === starts.length - 1)
242
- continue; // latest turn
243
- if (t === 0)
244
- continue; // task prompt
245
- const end = t + 1 < starts.length ? starts[t + 1] : history.length;
246
- if (needles.length > 0 && turnMentionsTodo(history, starts[t], end, needles))
247
- continue;
248
- drop = t;
249
- break;
250
- }
251
- if (drop === -1)
252
- break;
253
- const end = drop + 1 < starts.length ? starts[drop + 1] : history.length;
254
- const removed = history.splice(starts[drop], end - starts[drop]);
255
- result.droppedTurns += 1;
256
- result.droppedMessages += removed.length;
257
- }
258
- if (result.droppedTurns > 0) {
259
- try {
260
- opts?.notify?.(`(history truncated: dropped ${result.droppedTurns} oldest turn(s))`);
261
- }
262
- catch {
263
- // observer errors never break the loop
264
- }
265
- }
266
- return result;
267
- }
268
116
  // ---- Budget derivation ----
269
117
  // Expected completion/output reserve: one full summary-sized generation must
270
118
  // always fit alongside history (mirrors the compaction output cap).
271
119
  export const OUTPUT_RESERVE_TOKENS = 4096;
272
- // Safety headroom below the raw window: the trim cap never plans to use the
120
+ // Safety headroom below the raw window: the budget never plans to use the
273
121
  // last 5% (auto-compact at ~83% fires long before this matters — the margin
274
- // is the last defense, not the trigger).
122
+ // is informational, not a trigger).
275
123
  export const SAFETY_MARGIN_PCT = 0.05;
276
- // Default safety ceiling when nothing is configured AND no window is known
277
- // (the legacy 200K-char budget, preserved byte-for-byte as fallback).
278
- export const HARD_CEILING_FLOOR_CHARS = 200_000;
279
124
  export function createContextManager(opts) {
280
125
  const model = opts.model;
281
126
  const toolsChars = opts.toolsChars ?? 0;
282
127
  const reserveTokens = opts.outputReserveTokens ?? OUTPUT_RESERVE_TOKENS;
283
128
  const marginPct = opts.safetyMarginPct ?? SAFETY_MARGIN_PCT;
284
129
  const pct = opts.compactPct ?? compactPct();
285
- function ceilingChars() {
286
- if (opts.hardCeilingChars !== undefined) {
287
- return { value: opts.hardCeilingChars, explicit: opts.hardCeilingExplicit ?? true };
288
- }
289
- return historyCharSource();
290
- }
291
130
  function budget(history) {
292
131
  const window = contextWindowFor(model);
293
132
  const stats = ledgerStats(history);
@@ -298,18 +137,6 @@ export function createContextManager(opts) {
298
137
  ? Math.max(0, window - systemTokens - toolsTokens - reserveTokens - margin)
299
138
  : undefined;
300
139
  const historyCharsCap = historyTokens !== undefined ? historyTokens * CHARS_PER_TOKEN : undefined;
301
- const ceil = ceilingChars();
302
- const ceilMsgs = opts.hardCeilingMessages ?? historyMessageSource().value;
303
- // The ceiling only ever CAPS: with nothing configured the derived budget
304
- // rules (large windows stay usable); an explicit ceiling still binds as
305
- // the safety net it is. Unknown windows fall back to the legacy floor.
306
- const effectiveMaxChars = historyCharsCap !== undefined
307
- ? ceil.explicit
308
- ? Math.min(historyCharsCap, ceil.value)
309
- : historyCharsCap
310
- : ceil.explicit
311
- ? ceil.value
312
- : HARD_CEILING_FLOOR_CHARS;
313
140
  return {
314
141
  windowTokens: window,
315
142
  systemTokens,
@@ -318,11 +145,6 @@ export function createContextManager(opts) {
318
145
  safetyMarginTokens: margin,
319
146
  historyTokens,
320
147
  historyChars: historyCharsCap,
321
- hardCeilingChars: ceil.value,
322
- hardCeilingExplicit: ceil.explicit,
323
- hardCeilingMessages: ceilMsgs,
324
- effectiveMaxChars,
325
- effectiveMaxMessages: ceilMsgs,
326
148
  };
327
149
  }
328
150
  function usage(history, lastPromptTokens) {
@@ -340,11 +162,7 @@ export function createContextManager(opts) {
340
162
  function needsCompaction(loadTokens) {
341
163
  return shouldAutoCompact(loadTokens, model, pct);
342
164
  }
343
- function trimForSend(history, notify, reserve, todoNeedles = []) {
344
- const b = budget(history);
345
- return truncateHistoryWithCaps(history, { maxMessages: b.effectiveMaxMessages, maxChars: b.effectiveMaxChars }, { notify, reserve, todoNeedles });
346
- }
347
- return { model, budget, usage, needsCompaction, trimForSend };
165
+ return { model, budget, usage, needsCompaction };
348
166
  }
349
167
  const ledgerByRaw = new WeakMap();
350
168
  const ledgerByProxy = new WeakMap();
@@ -77,7 +77,9 @@ export function contextWindowFor(model) {
77
77
  return CONTEXT_WINDOWS[model];
78
78
  }
79
79
  // Total session tokens: prefer usage.total_tokens when present, else
80
- // prompt_tokens + completion_tokens (missing keys count as 0).
80
+ // prompt_tokens + completion_tokens (missing keys count as 0). prompt_tokens
81
+ // is normalized at parse time to total input-side tokens (exclusive
82
+ // prefix-cache counters folded in), so both paths count cached context.
81
83
  export function totalTokens(usage) {
82
84
  if (typeof usage.total_tokens === "number") {
83
85
  return Math.max(0, Math.floor(usage.total_tokens));
@@ -90,7 +92,7 @@ export function totalTokens(usage) {
90
92
  // - no usage reported yet: `token: n/a` (never estimated)
91
93
  // - known window: `token: (P%) NK` (NK = round(total/1024) + "K" from the
92
94
  // CUMULATIVE session spend; P = round(100*load/window) from the CURRENT
93
- // context load — prompt_tokens of the last POST, else the 4ch/token
95
+ // context load — input-side tokens of the last POST, else the 4ch/token
94
96
  // estimate. Cumulative spend keeps growing after compaction, so it must
95
97
  // NOT drive P; load does. Pass load explicitly; when omitted it falls
96
98
  // back to the cumulative total for backward compat.)
package/dist/env-block.js CHANGED
@@ -3,11 +3,11 @@
3
3
  // timestamp.
4
4
  //
5
5
  // Placement: pinned to the SYSTEM message only (suffix to history[0]'s
6
- // content via withEnvBlock), NEVER into user content. history[0] is the only
7
- // slot truncateHistory never drops, so the block survives budget trimming.
8
- // Caching: the App refreshes history[0] once per turn in submit() (before the
9
- // budget check, so truncation accounts for it) the loop's POSTs reuse the
10
- // same history[0], so git is shelled at most once per turn.
6
+ // content via withEnvBlock), NEVER into user content. history[0] is the
7
+ // system prompt, so the block survives every turn.
8
+ // Caching: the App refreshes history[0] once per turn in submit() the
9
+ // loop's POSTs reuse the same history[0], so git is shelled at most once
10
+ // per turn.
11
11
  // Failure-silent: missing git / non-repo cwd / timeout → the block shrinks
12
12
  // (cwd + node + time only), never throws, never blocks the turn. No new
13
13
  // dependencies; one cheap `git status` invocation with a short timeout, and