atom-agent 0.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 +27 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/App.js +2428 -0
- package/dist/adapters.js +926 -0
- package/dist/auth.js +122 -0
- package/dist/cli.js +28 -0
- package/dist/compact.js +277 -0
- package/dist/context-windows.js +112 -0
- package/dist/env-block.js +166 -0
- package/dist/permissions.js +129 -0
- package/dist/providers.js +224 -0
- package/dist/session.js +218 -0
- package/dist/skills.js +283 -0
- package/dist/snapshots.js +243 -0
- package/dist/system.js +22 -0
- package/dist/tools.js +1867 -0
- package/dist/zen.js +1862 -0
- package/package.json +54 -0
package/dist/auth.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Auth store mirroring opencode's auth.json (manual-key only).
|
|
2
|
+
// Credentials in ~/.atom/auth.json shaped:
|
|
3
|
+
// {version:1, providers:{"<id>":{apiKey, baseURL?}}}
|
|
4
|
+
// 0600 perms on POSIX via chmod; best-effort on Windows.
|
|
5
|
+
// Key resolution per provider: standard env var wins when set, else stored.
|
|
6
|
+
// Env names: OPENCODE_ZEN_API_KEY (zen), OPENAI_API_KEY, ANTHROPIC_API_KEY,
|
|
7
|
+
// DEEPSEEK_API_KEY, MISTRAL_API_KEY, GEMINI_API_KEY (also GOOGLE_API_KEY
|
|
8
|
+
// alias). openai-compatible: stored key + stored baseURL only.
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { getProvider } from "./providers.js";
|
|
13
|
+
export const AUTH_VERSION = 1;
|
|
14
|
+
export function homeDir() {
|
|
15
|
+
return (process.env.ATOM_HOME ??
|
|
16
|
+
process.env.HOME ??
|
|
17
|
+
process.env.USERPROFILE ??
|
|
18
|
+
os.homedir());
|
|
19
|
+
}
|
|
20
|
+
export function atomDir(home) {
|
|
21
|
+
return path.join(home ?? homeDir(), ".atom");
|
|
22
|
+
}
|
|
23
|
+
export function authFilePath(home) {
|
|
24
|
+
return path.join(atomDir(home), "auth.json");
|
|
25
|
+
}
|
|
26
|
+
export function emptyAuth() {
|
|
27
|
+
return { version: AUTH_VERSION, providers: {} };
|
|
28
|
+
}
|
|
29
|
+
// Load auth.json; missing/corrupt file yields empty auth (never throws).
|
|
30
|
+
export function loadAuth(home) {
|
|
31
|
+
try {
|
|
32
|
+
const p = authFilePath(home);
|
|
33
|
+
if (!existsSync(p))
|
|
34
|
+
return emptyAuth();
|
|
35
|
+
const raw = readFileSync(p, "utf8");
|
|
36
|
+
const data = JSON.parse(raw);
|
|
37
|
+
if (typeof data !== "object" || data === null)
|
|
38
|
+
return emptyAuth();
|
|
39
|
+
const o = data;
|
|
40
|
+
const providers = {};
|
|
41
|
+
const src = o["providers"];
|
|
42
|
+
if (typeof src === "object" && src !== null) {
|
|
43
|
+
for (const [k, v] of Object.entries(src)) {
|
|
44
|
+
if (typeof v !== "object" || v === null)
|
|
45
|
+
continue;
|
|
46
|
+
const e = v;
|
|
47
|
+
if (typeof e["apiKey"] !== "string")
|
|
48
|
+
continue;
|
|
49
|
+
const entry = { apiKey: e["apiKey"] };
|
|
50
|
+
if (typeof e["baseURL"] === "string" && e["baseURL"].length > 0) {
|
|
51
|
+
entry.baseURL = e["baseURL"];
|
|
52
|
+
}
|
|
53
|
+
providers[k] = entry;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { version: AUTH_VERSION, providers };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return emptyAuth();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Save auth.json (mkdir -p + 0600 on POSIX, best-effort on Windows).
|
|
63
|
+
export function saveAuth(auth, home) {
|
|
64
|
+
const dir = atomDir(home);
|
|
65
|
+
mkdirSync(dir, { recursive: true });
|
|
66
|
+
const p = path.join(dir, "auth.json");
|
|
67
|
+
const payload = { version: AUTH_VERSION, providers: auth.providers ?? {} };
|
|
68
|
+
writeFileSync(p, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
69
|
+
try {
|
|
70
|
+
chmodSync(p, 0o600);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// best-effort on Windows; ignore
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function getStoredAuth(auth, id) {
|
|
77
|
+
return auth.providers[id];
|
|
78
|
+
}
|
|
79
|
+
export function getStoredKey(auth, id) {
|
|
80
|
+
return auth.providers[id]?.apiKey ?? "";
|
|
81
|
+
}
|
|
82
|
+
export function getStoredBaseURL(auth, id) {
|
|
83
|
+
return auth.providers[id]?.baseURL ?? "";
|
|
84
|
+
}
|
|
85
|
+
// Env key for a provider (first non-empty env var wins). Empty when none.
|
|
86
|
+
export function getEnvKey(id) {
|
|
87
|
+
const def = getProvider(id);
|
|
88
|
+
if (!def)
|
|
89
|
+
return "";
|
|
90
|
+
for (const name of def.envVars) {
|
|
91
|
+
const v = process.env[name];
|
|
92
|
+
if (typeof v === "string" && v.length > 0)
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
// Resolved key: env wins when set, else stored. openai-compatible has no
|
|
98
|
+
// env vars, so it is stored-only by construction.
|
|
99
|
+
export function resolveApiKey(id, auth) {
|
|
100
|
+
return getEnvKey(id) || getStoredKey(auth, id);
|
|
101
|
+
}
|
|
102
|
+
export function hasKey(id, auth) {
|
|
103
|
+
return resolveApiKey(id, auth).length > 0;
|
|
104
|
+
}
|
|
105
|
+
// Set (or replace) the stored key for a provider; returns the updated auth.
|
|
106
|
+
export function setStoredKey(auth, id, apiKey, baseURL) {
|
|
107
|
+
const next = {
|
|
108
|
+
version: AUTH_VERSION,
|
|
109
|
+
providers: { ...auth.providers },
|
|
110
|
+
};
|
|
111
|
+
const prev = next.providers[id];
|
|
112
|
+
const entry = { apiKey };
|
|
113
|
+
const base = baseURL !== undefined ? baseURL : prev?.baseURL;
|
|
114
|
+
if (typeof base === "string" && base.length > 0)
|
|
115
|
+
entry.baseURL = base;
|
|
116
|
+
next.providers[id] = entry;
|
|
117
|
+
return next;
|
|
118
|
+
}
|
|
119
|
+
export function setStoredBaseURL(auth, id, baseURL) {
|
|
120
|
+
const prev = auth.providers[id];
|
|
121
|
+
return setStoredKey(auth, id, prev?.apiKey ?? "", baseURL);
|
|
122
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { App } from "./App.js";
|
|
5
|
+
import { DEFAULT_ENDPOINT, DEFAULT_MODEL, endpointConfig, } from "./zen.js";
|
|
6
|
+
import { loadAuth, resolveApiKey } from "./auth.js";
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
9
|
+
console.log(`Atom chatbot (Ink TUI)
|
|
10
|
+
Usage: npm start
|
|
11
|
+
Env:
|
|
12
|
+
OPENCODE_ZEN_API_KEY optional when ~/.atom/auth.json has a zen key (get one at https://opencode.ai/auth)
|
|
13
|
+
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)
|
|
14
|
+
OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL})
|
|
15
|
+
OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
|
|
16
|
+
Commands: /model (model picker) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /mode | /yolo (toggle) | /plan (read-only plan mode) | /clear | /resume (restore last saved session) | /help | /exit | /quit
|
|
17
|
+
Providers: opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one).
|
|
18
|
+
Note: reasoning_effort is sent only for opencode-zen supported models.`);
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
const { endpoint, apiKey: envKey, model } = endpointConfig();
|
|
22
|
+
// Stored zen key (from a previous /provider paste) applies when no env key.
|
|
23
|
+
const storedZen = resolveApiKey("opencode-zen", loadAuth());
|
|
24
|
+
const apiKey = envKey || storedZen;
|
|
25
|
+
// Always start the TUI (even without a key) so /provider can paste one.
|
|
26
|
+
// Chatting without a key for the active provider errors inline with a
|
|
27
|
+
// /provider pointer; nothing is POSTed.
|
|
28
|
+
render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: model }));
|
package/dist/compact.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Context compaction (Claude-Code/opencode-style) for the Ink chatbot.
|
|
2
|
+
// AUTO-COMPACT at ~83% of the model's verified context window plus manual
|
|
3
|
+
// `/compact [focus]` (see App.tsx slash registry).
|
|
4
|
+
//
|
|
5
|
+
// Mechanics mirrored from research (verified this session):
|
|
6
|
+
// - Claude Code: auto-compact = ONE extra request (same system prompt +
|
|
7
|
+
// tools + history, summarization instruction appended); history replaced
|
|
8
|
+
// by the summary; manual `/compact [focus]`; thrashing guard.
|
|
9
|
+
// - opencode V2: preflight estimate = JSON-serialized request size at
|
|
10
|
+
// 4 chars/token; summary via session model with TOOLS DISABLED, ≤4096
|
|
11
|
+
// output tokens, structured template; newest tail retained (~8000 tokens,
|
|
12
|
+
// tool outputs capped 2000 chars); overflow-recovery retry once.
|
|
13
|
+
//
|
|
14
|
+
// This module is pure + testable (mocked fetch only in tests, never live).
|
|
15
|
+
// The App owns session refs (load/streak/disabled/pending) and the atomic
|
|
16
|
+
// swap + save; this module owns math, splitting, instruction, and the
|
|
17
|
+
// summary POST (tools disabled, 4096 cap).
|
|
18
|
+
import { contextWindowFor } from "./context-windows.js";
|
|
19
|
+
import { historyChars, messageChars, chatCompletionForProvider, } from "./zen.js";
|
|
20
|
+
// ---- Constants ----
|
|
21
|
+
export const COMPACT_PCT_DEFAULT = 0.83;
|
|
22
|
+
export const COMPACT_KEEP_TOKENS = 8000;
|
|
23
|
+
export const COMPACT_SUMMARY_MAX_TOKENS = 4096;
|
|
24
|
+
export const COMPACT_TOOL_OUTPUT_CAP = 2000;
|
|
25
|
+
// opencode's 4ch/token heuristic (V2 preflight estimate): chars/4 floors to
|
|
26
|
+
// estimated tokens. Used for load fallback + tail split only, never for the
|
|
27
|
+
// `token: n/a` honesty rule or the NK cumulative spend.
|
|
28
|
+
export const COMPACT_CHARS_PER_TOKEN = 4;
|
|
29
|
+
export const COMPACT_THRASH_LIMIT = 3;
|
|
30
|
+
// ---- Threshold ----
|
|
31
|
+
function clampPctPercent(n) {
|
|
32
|
+
return Math.min(Math.max(n, 50), 95) / 100;
|
|
33
|
+
}
|
|
34
|
+
// Auto-compact threshold as a fraction (default 0.83). Env ATOM_COMPACT_PCT
|
|
35
|
+
// is a percent (e.g. "83"), clamped 50–95; invalid/unset → default.
|
|
36
|
+
export function compactPct() {
|
|
37
|
+
const raw = process.env.ATOM_COMPACT_PCT;
|
|
38
|
+
if (raw === undefined)
|
|
39
|
+
return COMPACT_PCT_DEFAULT;
|
|
40
|
+
const text = raw.trim();
|
|
41
|
+
if (!/^\d+(\.\d+)?$/.test(text))
|
|
42
|
+
return COMPACT_PCT_DEFAULT;
|
|
43
|
+
const n = Number(text);
|
|
44
|
+
if (!Number.isFinite(n))
|
|
45
|
+
return COMPACT_PCT_DEFAULT;
|
|
46
|
+
return clampPctPercent(n);
|
|
47
|
+
}
|
|
48
|
+
// ---- Load metric ----
|
|
49
|
+
export function estimateTokensForChars(chars) {
|
|
50
|
+
// opencode's 4ch/token heuristic.
|
|
51
|
+
const c = Number.isFinite(chars) && chars > 0 ? Math.floor(chars) : 0;
|
|
52
|
+
return Math.floor(c / COMPACT_CHARS_PER_TOKEN);
|
|
53
|
+
}
|
|
54
|
+
// Load = last POST's reported prompt_tokens when available, else the
|
|
55
|
+
// 4ch/token estimate of the sent history chars.
|
|
56
|
+
export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
|
|
57
|
+
if (typeof lastPromptTokens === "number" &&
|
|
58
|
+
Number.isFinite(lastPromptTokens) &&
|
|
59
|
+
lastPromptTokens >= 0) {
|
|
60
|
+
return Math.floor(lastPromptTokens);
|
|
61
|
+
}
|
|
62
|
+
return estimateTokensForChars(sentHistoryChars);
|
|
63
|
+
}
|
|
64
|
+
export function shouldAutoCompact(load, model, pctOverride) {
|
|
65
|
+
const window = contextWindowFor(model);
|
|
66
|
+
if (window === undefined)
|
|
67
|
+
return false; // never invent a window
|
|
68
|
+
const pct = typeof pctOverride === "number" && Number.isFinite(pctOverride)
|
|
69
|
+
? pctOverride
|
|
70
|
+
: compactPct();
|
|
71
|
+
return load / window >= pct;
|
|
72
|
+
}
|
|
73
|
+
// ---- Turn helpers ----
|
|
74
|
+
export function countUserTurns(history) {
|
|
75
|
+
let n = 0;
|
|
76
|
+
for (const m of history) {
|
|
77
|
+
if (m?.role === "user")
|
|
78
|
+
n += 1;
|
|
79
|
+
}
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
function turnStarts(history) {
|
|
83
|
+
const starts = [];
|
|
84
|
+
for (let i = 1; i < history.length; i++) {
|
|
85
|
+
if (history[i]?.role === "user")
|
|
86
|
+
starts.push(i);
|
|
87
|
+
}
|
|
88
|
+
return starts;
|
|
89
|
+
}
|
|
90
|
+
function turnEnd(history, startIdx, starts) {
|
|
91
|
+
const pos = starts.indexOf(startIdx);
|
|
92
|
+
if (pos < 0)
|
|
93
|
+
return history.length;
|
|
94
|
+
return pos + 1 < starts.length ? starts[pos + 1] : history.length;
|
|
95
|
+
}
|
|
96
|
+
function turnChars(history, start, end) {
|
|
97
|
+
let n = 0;
|
|
98
|
+
for (let i = start; i < end; i++)
|
|
99
|
+
n += messageChars(history[i]);
|
|
100
|
+
return n;
|
|
101
|
+
}
|
|
102
|
+
export function capToolOutputsInTail(tail) {
|
|
103
|
+
return tail.map((m) => {
|
|
104
|
+
if (m.role === "tool" && typeof m.content === "string" && m.content.length > COMPACT_TOOL_OUTPUT_CAP) {
|
|
105
|
+
return {
|
|
106
|
+
...m,
|
|
107
|
+
content: m.content.slice(0, COMPACT_TOOL_OUTPUT_CAP) +
|
|
108
|
+
"\n[truncated: tool output exceeded 2000 chars]",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { ...m };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Split history (after system) into head + retained newest tail of whole
|
|
115
|
+
// user-turns up to KEEP_TOKENS estimated tokens (chars/4). Tool outputs in
|
|
116
|
+
// the tail are capped at 2000 chars each. Never drops history[0]; always
|
|
117
|
+
// keeps at least the newest turn; when everything fits but there is more
|
|
118
|
+
// than one turn, keeps only the newest turn in the tail so manual /compact
|
|
119
|
+
// still has an older turn to summarize.
|
|
120
|
+
export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOKENS) {
|
|
121
|
+
if (history.length <= 1)
|
|
122
|
+
return { head: [], tail: [], olderTurnCount: 0 };
|
|
123
|
+
const starts = turnStarts(history);
|
|
124
|
+
if (starts.length === 0)
|
|
125
|
+
return { head: [], tail: [], olderTurnCount: 0 };
|
|
126
|
+
let totalChars = 0;
|
|
127
|
+
let tailStart = starts[starts.length - 1];
|
|
128
|
+
for (let s = starts.length - 1; s >= 0; s--) {
|
|
129
|
+
const start = starts[s];
|
|
130
|
+
const end = turnEnd(history, start, starts);
|
|
131
|
+
totalChars += turnChars(history, start, end);
|
|
132
|
+
const est = estimateTokensForChars(totalChars);
|
|
133
|
+
if (est <= keepTokens) {
|
|
134
|
+
tailStart = start;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Everything fits but >1 turn: keep only the newest turn in the tail so
|
|
141
|
+
// manual /compact still has an older turn to summarize (auto never
|
|
142
|
+
// reaches here — its load would be far below threshold when everything
|
|
143
|
+
// fits in 8000 tokens).
|
|
144
|
+
if (tailStart === starts[0] && starts.length > 1) {
|
|
145
|
+
tailStart = starts[starts.length - 1];
|
|
146
|
+
}
|
|
147
|
+
const head = history.slice(1, tailStart);
|
|
148
|
+
const rawTail = history.slice(tailStart);
|
|
149
|
+
const tail = capToolOutputsInTail(rawTail);
|
|
150
|
+
let olderTurnCount = 0;
|
|
151
|
+
for (const m of head)
|
|
152
|
+
if (m?.role === "user")
|
|
153
|
+
olderTurnCount += 1;
|
|
154
|
+
return { head, tail, olderTurnCount };
|
|
155
|
+
}
|
|
156
|
+
// ---- Instruction template ----
|
|
157
|
+
export function buildCompactionInstruction(focusText) {
|
|
158
|
+
const focus = typeof focusText === "string" && focusText.trim().length > 0
|
|
159
|
+
? `\nFocus for this summary: ${focusText.trim()}\n`
|
|
160
|
+
: "";
|
|
161
|
+
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.` +
|
|
162
|
+
`${focus}\n` +
|
|
163
|
+
`Structure your summary with these headings (omit a section only when it has no content):\n` +
|
|
164
|
+
`## Objective\n` +
|
|
165
|
+
`## Requirements\n` +
|
|
166
|
+
`## Decisions\n` +
|
|
167
|
+
`## Completed work\n` +
|
|
168
|
+
`## Active work\n` +
|
|
169
|
+
`## Blockers\n` +
|
|
170
|
+
`## Next moves\n` +
|
|
171
|
+
`## Relevant files\n` +
|
|
172
|
+
`Rules: no tools are available for this request — answer with the summary text only, no tool calls, no preamble beyond the headings.`);
|
|
173
|
+
}
|
|
174
|
+
export function buildSummaryMessages(systemContent, head, focusText) {
|
|
175
|
+
return [
|
|
176
|
+
{ role: "system", content: systemContent },
|
|
177
|
+
...head.map((m) => ({ ...m })),
|
|
178
|
+
{ role: "user", content: buildCompactionInstruction(focusText) },
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
export function buildCompactedHistory(systemMessage, summaryText, tail, olderTurnCount, nowISO) {
|
|
182
|
+
const iso = nowISO ?? new Date().toISOString();
|
|
183
|
+
const summaryUser = {
|
|
184
|
+
role: "user",
|
|
185
|
+
content: `[Compacted context ${iso}: summary of ${olderTurnCount} older turns]\n${summaryText}`,
|
|
186
|
+
};
|
|
187
|
+
return [
|
|
188
|
+
{ ...systemMessage },
|
|
189
|
+
summaryUser,
|
|
190
|
+
...tail.map((m) => ({ ...m })),
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
export function compactBoundaryLine(olderTurnCount) {
|
|
194
|
+
return `(context compacted: ${olderTurnCount} turns → summary)`;
|
|
195
|
+
}
|
|
196
|
+
// ---- Thrash guard ----
|
|
197
|
+
export function isThrashDisabled(streak) {
|
|
198
|
+
return streak >= COMPACT_THRASH_LIMIT;
|
|
199
|
+
}
|
|
200
|
+
// ---- Size-error detection + head truncation for the once-retry ----
|
|
201
|
+
export function isSizeError(e) {
|
|
202
|
+
const msg = e instanceof Error ? e.message : String(e ?? "");
|
|
203
|
+
if (/HTTP\s+(400|413)\b/.test(msg))
|
|
204
|
+
return true;
|
|
205
|
+
return /context|too long|maximum|too many|overflow|too large|token[^.]{0,40}limit|length|exceed/i.test(msg);
|
|
206
|
+
}
|
|
207
|
+
// Truncate head to budget ONCE: drop the oldest half of its user-turns,
|
|
208
|
+
// preserving assistant/tool pairing (turn boundaries).
|
|
209
|
+
export function truncateHeadForRetry(head) {
|
|
210
|
+
if (head.length <= 1)
|
|
211
|
+
return [...head];
|
|
212
|
+
const starts = [];
|
|
213
|
+
for (let i = 0; i < head.length; i++) {
|
|
214
|
+
if (head[i]?.role === "user")
|
|
215
|
+
starts.push(i);
|
|
216
|
+
}
|
|
217
|
+
if (starts.length <= 1) {
|
|
218
|
+
// No turn structure: keep the newest half of messages.
|
|
219
|
+
return head.slice(Math.ceil(head.length / 2));
|
|
220
|
+
}
|
|
221
|
+
const keepTurns = Math.max(1, Math.ceil(starts.length / 2));
|
|
222
|
+
const keepFrom = starts[starts.length - keepTurns];
|
|
223
|
+
return head.slice(keepFrom);
|
|
224
|
+
}
|
|
225
|
+
// Summary POST: SAME provider/model via the existing chat path but TOOLS
|
|
226
|
+
// DISABLED (no `tools` key in the POST body) and output capped at 4096
|
|
227
|
+
// (max_tokens/maxOutputTokens per kind — see zen.ts/adapters.ts). Returns
|
|
228
|
+
// the summary text. On size-overflow it truncates head to budget ONCE and
|
|
229
|
+
// retries once, then gives up with a `/clear` suggestion; other failures
|
|
230
|
+
// throw immediately with history untouched (caller must not swap).
|
|
231
|
+
export async function requestCompactSummary(req) {
|
|
232
|
+
const attempt = async (head) => {
|
|
233
|
+
const messages = buildSummaryMessages(req.systemContent, head, req.focusText);
|
|
234
|
+
const res = await chatCompletionForProvider(req.provider, req.apiKey, req.model, messages, {
|
|
235
|
+
baseURL: req.baseURL,
|
|
236
|
+
endpointOverride: req.endpointOverride,
|
|
237
|
+
disableTools: true,
|
|
238
|
+
maxOutputTokens: COMPACT_SUMMARY_MAX_TOKENS,
|
|
239
|
+
...(req.signal ? { signal: req.signal } : {}),
|
|
240
|
+
});
|
|
241
|
+
// Totals keep accumulating: forward real summary usage when present.
|
|
242
|
+
// The caller must NOT feed this into lastPromptTokens (load tracks the
|
|
243
|
+
// main context, not the head-sized summary request).
|
|
244
|
+
if (res.usage !== undefined) {
|
|
245
|
+
try {
|
|
246
|
+
req.onUsage?.(res.usage);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// observer errors never break compaction
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const text = (res.content ?? "").trim();
|
|
253
|
+
if (!text)
|
|
254
|
+
throw new Error("Empty reply from model (unexpected payload).");
|
|
255
|
+
return text;
|
|
256
|
+
};
|
|
257
|
+
try {
|
|
258
|
+
return await attempt(req.head);
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
if (!isSizeError(e))
|
|
262
|
+
throw e;
|
|
263
|
+
// Overflow/fails-from-size: truncate head to budget ONCE and retry once.
|
|
264
|
+
if (req.head.length <= 1) {
|
|
265
|
+
throw new Error(`${e instanceof Error ? e.message : String(e)} (compact failed — use /clear)`);
|
|
266
|
+
}
|
|
267
|
+
const truncated = truncateHeadForRetry(req.head);
|
|
268
|
+
try {
|
|
269
|
+
return await attempt(truncated);
|
|
270
|
+
}
|
|
271
|
+
catch (e2) {
|
|
272
|
+
throw new Error(`${e2 instanceof Error ? e2.message : String(e2)} (compact failed — use /clear)`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Re-export for callers that need the post-turn history size.
|
|
277
|
+
export { historyChars };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export const CONTEXT_WINDOWS = {
|
|
2
|
+
// DeepSeek V4 family: 1M context.
|
|
3
|
+
// Sources: https://www.deepseek.com/en/news/v4-preview/
|
|
4
|
+
// ("1M context is now the default"), https://arxiv.org/abs/2606.19348
|
|
5
|
+
// ("supporting a context length of one million tokens").
|
|
6
|
+
"deepseek-v4-pro": 1_000_000,
|
|
7
|
+
"deepseek-v4-flash": 1_000_000,
|
|
8
|
+
"deepseek-v4-flash-vision-exp": 1_000_000,
|
|
9
|
+
// Kimi K2.5 / K2.6: 256K context (262144 tokens).
|
|
10
|
+
// Sources: https://github.com/MoonshotAI/Kimi-K2.5 (Context Length 256K),
|
|
11
|
+
// https://huggingface.co/moonshotai/Kimi-K2.5 (Context Length 256K),
|
|
12
|
+
// https://platform.kimi.ai/docs/guide/kimi-k2-6-quickstart
|
|
13
|
+
// ("kimi-k2.7-code and kimi-k2.6 models both provide a 256K context window").
|
|
14
|
+
"kimi-k2.5": 262_144,
|
|
15
|
+
"kimi-k2.6": 262_144,
|
|
16
|
+
// Kimi K2.7 Code: 256K context.
|
|
17
|
+
// Source: https://www.kimi.com/code/docs/en/kimi-code/models.html
|
|
18
|
+
// (kimi-for-coding / K2.7 Code: 256k context window).
|
|
19
|
+
"kimi-k2.7-code": 262_144,
|
|
20
|
+
// Kimi K3: 1M context (1048576 tokens).
|
|
21
|
+
// Sources: https://github.com/MoonshotAI/Kimi-K3 (Context Length 1048576),
|
|
22
|
+
// https://docs.api.nvidia.com/nim/reference/moonshotai-kimi-k3
|
|
23
|
+
// (Input Context Length: 1,048,576 tokens).
|
|
24
|
+
"kimi-k3": 1_048_576,
|
|
25
|
+
// GLM-5.1: 200K context.
|
|
26
|
+
// Sources: https://llm-stats.com/models/compare/glm-5.1-vs-glm-5.3
|
|
27
|
+
// (GLM-5.1: 200,000 tokens), https://z.ai/blog/glm-5.1
|
|
28
|
+
// (eval settings "with a 200K context window").
|
|
29
|
+
"glm-5.1": 200_000,
|
|
30
|
+
// GLM-5.2: solid 1M context.
|
|
31
|
+
// Source: https://z.ai/blog/glm-5.2 ("a solid 1M-token context").
|
|
32
|
+
"glm-5.2": 1_000_000,
|
|
33
|
+
// GLM-5.3: 1M context.
|
|
34
|
+
// Sources: https://docs.aimlapi.com/api-references/text-models-llm/zhipu/glm-5.3
|
|
35
|
+
// ("1M-token context window"), https://kie.ai/blog/what-is-glm-5-3
|
|
36
|
+
// ("Context window 1M tokens").
|
|
37
|
+
"glm-5.3": 1_000_000,
|
|
38
|
+
// MiniMax M2.5 / M2.7: 204800 context; MiniMax M3: 1M context.
|
|
39
|
+
// Sources: https://platform.minimax.io/docs/guides/text-generation
|
|
40
|
+
// (context-window table: M3 1,000,000; M2.7/M2.5 204,800),
|
|
41
|
+
// https://www.minimax.io/models/text/m3 ("up to 1M tokens context window").
|
|
42
|
+
"minimax-m2.5": 204_800,
|
|
43
|
+
"minimax-m2.7": 204_800,
|
|
44
|
+
"minimax-m3": 1_000_000,
|
|
45
|
+
// OpenAI flagship chat models: 1.05M context window.
|
|
46
|
+
// Source: https://platform.openai.com/docs/models (Context window 1.05M
|
|
47
|
+
// for GPT-6 Astra, GPT-5.6 Sol/Terra/Luna).
|
|
48
|
+
"gpt-6-astra": 1_050_000,
|
|
49
|
+
"gpt-5.6-sol": 1_050_000,
|
|
50
|
+
"gpt-5.6-terra": 1_050_000,
|
|
51
|
+
"gpt-5.6-luna": 1_050_000,
|
|
52
|
+
// Anthropic Claude: 1M context (Opus 5 / Sonnet 5 / Fable 5.1),
|
|
53
|
+
// 200K for Haiku 4.5.
|
|
54
|
+
// Source: https://platform.claude.com/docs/en/models/overview
|
|
55
|
+
// (Context window row: 1M tokens / 1M tokens / 1M tokens / 200K tokens).
|
|
56
|
+
"claude-fable-5-1": 1_000_000,
|
|
57
|
+
"claude-opus-5": 1_000_000,
|
|
58
|
+
"claude-sonnet-5": 1_000_000,
|
|
59
|
+
"claude-haiku-4-5": 200_000,
|
|
60
|
+
"claude-haiku-4-5-20251001": 200_000,
|
|
61
|
+
// Google Gemini 3 family: 1M context (1,048,576 tokens).
|
|
62
|
+
// Sources: https://ai.google.dev/gemini-api/docs/gemini-3
|
|
63
|
+
// (Context Window 1M), https://ai.google.dev/gemini-api/docs/long-context
|
|
64
|
+
// ("large context windows of 1 million or more tokens"),
|
|
65
|
+
// https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/3-7-flash
|
|
66
|
+
// (Context window 1,048,576).
|
|
67
|
+
"gemini-3-flash-preview": 1_048_576,
|
|
68
|
+
"gemini-3.1-pro-preview": 1_048_576,
|
|
69
|
+
"gemini-3.1-flash-lite": 1_048_576,
|
|
70
|
+
"gemini-3.5-flash": 1_048_576,
|
|
71
|
+
"gemini-3.7-flash": 1_048_576,
|
|
72
|
+
"gemini-3.8-flash": 1_048_576,
|
|
73
|
+
};
|
|
74
|
+
// Context window for a model id, or undefined when the model has no
|
|
75
|
+
// verified window (callers render the bare `token: NK` form).
|
|
76
|
+
export function contextWindowFor(model) {
|
|
77
|
+
return CONTEXT_WINDOWS[model];
|
|
78
|
+
}
|
|
79
|
+
// Total session tokens: prefer usage.total_tokens when present, else
|
|
80
|
+
// prompt_tokens + completion_tokens (missing keys count as 0).
|
|
81
|
+
export function totalTokens(usage) {
|
|
82
|
+
if (typeof usage.total_tokens === "number") {
|
|
83
|
+
return Math.max(0, Math.floor(usage.total_tokens));
|
|
84
|
+
}
|
|
85
|
+
const prompt = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0;
|
|
86
|
+
const completion = typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0;
|
|
87
|
+
return Math.max(0, Math.floor(prompt + completion));
|
|
88
|
+
}
|
|
89
|
+
// Footer token segment, EXACT format:
|
|
90
|
+
// - no usage reported yet: `token: n/a` (never estimated)
|
|
91
|
+
// - known window: `token: (P%) NK` (NK = round(total/1024) + "K" from the
|
|
92
|
+
// CUMULATIVE session spend; P = round(100*load/window) from the CURRENT
|
|
93
|
+
// context load — prompt_tokens of the last POST, else the 4ch/token
|
|
94
|
+
// estimate. Cumulative spend keeps growing after compaction, so it must
|
|
95
|
+
// NOT drive P; load does. Pass load explicitly; when omitted it falls
|
|
96
|
+
// back to the cumulative total for backward compat.)
|
|
97
|
+
// - unknown window: `token: NK` (never invent a window)
|
|
98
|
+
// Zero usage with a known window is `token: (0%) 0K`; without one `token: 0K`.
|
|
99
|
+
export function formatTokenSegment(usage, model, load) {
|
|
100
|
+
if (!usage)
|
|
101
|
+
return "token: n/a";
|
|
102
|
+
const total = totalTokens(usage);
|
|
103
|
+
const k = `${Math.round(total / 1024)}K`;
|
|
104
|
+
const window = contextWindowFor(model);
|
|
105
|
+
if (window === undefined)
|
|
106
|
+
return `token: ${k}`;
|
|
107
|
+
const loadTokens = typeof load === "number" && Number.isFinite(load) && load >= 0
|
|
108
|
+
? Math.floor(load)
|
|
109
|
+
: total;
|
|
110
|
+
const pct = Math.round((100 * loadTokens) / window);
|
|
111
|
+
return `token: (${pct}%) ${k}`;
|
|
112
|
+
}
|