atom-agent 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +13 -4
- package/atom.example.json +11 -0
- package/dist/App.js +923 -200
- package/dist/adapters.js +82 -13
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop.js +517 -76
- package/dist/cli.js +11 -3
- package/dist/compact.js +41 -15
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +5 -5
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +74 -36
- package/dist/session.js +23 -5
- package/dist/sessions.js +25 -6
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +246 -17
- package/dist/tools.js +44 -0
- package/dist/ui/palette.js +1 -1
- package/dist/ui/status-bar.js +80 -5
- package/dist/zen.js +305 -75
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- 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
|
-
|
|
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 (~
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
`##
|
|
129
|
-
`##
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
`## Next
|
|
134
|
-
`## Relevant
|
|
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:
|
|
23
|
-
//
|
|
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 = ["
|
|
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) {
|
package/dist/context-manager.js
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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 valve — Pi-style.
|
|
22
20
|
//
|
|
23
21
|
// Layering: this module owns measurement + budget math. It imports
|
|
24
|
-
// context-windows (metadata) and config (file fallback) at
|
|
25
|
-
// zen.js types ONLY (no runtime cycle — zen.ts imports this
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
|
76
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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();
|
package/dist/context-windows.js
CHANGED
|
@@ -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 —
|
|
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
|
|
7
|
-
//
|
|
8
|
-
// Caching: the App refreshes history[0] once per turn in submit()
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|