@ashlr/hub 3.8.0 → 3.9.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 +201 -0
- package/dist/build-identity.json +2 -2
- package/dist/cli/resource-profile.js +95 -21
- package/dist/cli/resource-profile.js.map +1 -1
- package/dist/core/resources/native-profile.d.ts +56 -0
- package/dist/core/resources/native-profile.js +368 -6
- package/dist/core/resources/native-profile.js.map +1 -1
- package/dist/core/run/model-catalog.js +8 -1
- package/dist/core/run/model-catalog.js.map +1 -1
- package/dist/core/universe/builtins/preparation/manifest.json +1 -1
- package/dist/core/universe/builtins/preparation/preparation-bridge.mjs +9 -2
- package/dist/core/universe/builtins/preparation/preparation-verification-fixtures.mjs +8 -1
- package/dist/core/verse/adapters/claude.d.ts +49 -2
- package/dist/core/verse/adapters/claude.js +317 -56
- package/dist/core/verse/adapters/claude.js.map +1 -1
- package/dist/core/verse/adapters/codex.d.ts +81 -5
- package/dist/core/verse/adapters/codex.js +409 -47
- package/dist/core/verse/adapters/codex.js.map +1 -1
- package/dist/core/verse/adapters/grok.d.ts +13 -1
- package/dist/core/verse/adapters/grok.js +28 -2
- package/dist/core/verse/adapters/grok.js.map +1 -1
- package/dist/core/verse/adapters/index.d.ts +32 -0
- package/dist/core/verse/adapters/index.js +2 -0
- package/dist/core/verse/adapters/index.js.map +1 -1
- package/dist/core/verse/codex-rollout.d.ts +284 -0
- package/dist/core/verse/codex-rollout.js +786 -0
- package/dist/core/verse/codex-rollout.js.map +1 -0
- package/dist/core/verse/context-fit.d.ts +63 -0
- package/dist/core/verse/context-fit.js +355 -0
- package/dist/core/verse/context-fit.js.map +1 -0
- package/dist/core/verse/context-math.d.ts +215 -0
- package/dist/core/verse/context-math.js +365 -0
- package/dist/core/verse/context-math.js.map +1 -0
- package/dist/core/verse/local-models.d.ts +137 -1
- package/dist/core/verse/local-models.js +209 -5
- package/dist/core/verse/local-models.js.map +1 -1
- package/dist/core/verse/model-windows.d.ts +217 -0
- package/dist/core/verse/model-windows.js +616 -0
- package/dist/core/verse/model-windows.js.map +1 -0
- package/dist/core/verse/preferences.d.ts +123 -0
- package/dist/core/verse/preferences.js +403 -0
- package/dist/core/verse/preferences.js.map +1 -0
- package/dist/core/verse/project-memory.d.ts +112 -0
- package/dist/core/verse/project-memory.js +295 -0
- package/dist/core/verse/project-memory.js.map +1 -0
- package/dist/core/verse/seats.d.ts +106 -4
- package/dist/core/verse/seats.js +291 -89
- package/dist/core/verse/seats.js.map +1 -1
- package/dist/core/verse/session-engine.d.ts +76 -2
- package/dist/core/verse/session-engine.js +684 -63
- package/dist/core/verse/session-engine.js.map +1 -1
- package/dist/core/verse/session-handoff.d.ts +94 -0
- package/dist/core/verse/session-handoff.js +551 -0
- package/dist/core/verse/session-handoff.js.map +1 -0
- package/dist/core/verse/session-search.d.ts +63 -0
- package/dist/core/verse/session-search.js +213 -0
- package/dist/core/verse/session-search.js.map +1 -0
- package/dist/core/verse/session-store.d.ts +16 -2
- package/dist/core/verse/session-store.js +80 -6
- package/dist/core/verse/session-store.js.map +1 -1
- package/dist/core/verse/types.d.ts +333 -1
- package/dist/core/verse/types.js +54 -2
- package/dist/core/verse/types.js.map +1 -1
- package/dist/core/verse/verse-api.d.ts +40 -5
- package/dist/core/verse/verse-api.js +724 -25
- package/dist/core/verse/verse-api.js.map +1 -1
- package/dist/core/web/api.d.ts +14 -4
- package/dist/core/web/api.js +26 -11
- package/dist/core/web/api.js.map +1 -1
- package/dist/core/web/public/next/assets/{App-BsbJFA2j.js → App-XURmiAAp.js} +5 -5
- package/dist/core/web/public/next/assets/{App-BsbJFA2j.js.map → App-XURmiAAp.js.map} +1 -1
- package/dist/core/web/public/next/assets/{ApprovalsSection-2CCSoBTJ.js → ApprovalsSection-BriUzoZc.js} +2 -2
- package/dist/core/web/public/next/assets/{ApprovalsSection-2CCSoBTJ.js.map → ApprovalsSection-BriUzoZc.js.map} +1 -1
- package/dist/core/web/public/next/assets/AutonomySection-C4GBsQVc.js +2 -0
- package/dist/core/web/public/next/assets/{AutonomySection-yeNNUPur.js.map → AutonomySection-C4GBsQVc.js.map} +1 -1
- package/dist/core/web/public/next/assets/ChatSection-BpOBOUvg.css +1 -0
- package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js +84 -0
- package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js.map +1 -0
- package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js +2 -0
- package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js.map +1 -0
- package/dist/core/web/public/next/assets/{Dialog-nK15L1M0.js → Dialog-BxdIfmFH.js} +2 -2
- package/dist/core/web/public/next/assets/{Dialog-nK15L1M0.js.map → Dialog-BxdIfmFH.js.map} +1 -1
- package/dist/core/web/public/next/assets/{DiffViewer-D369eZe8.js → DiffViewer-DmeRWuc4.js} +2 -2
- package/dist/core/web/public/next/assets/{DiffViewer-D369eZe8.js.map → DiffViewer-DmeRWuc4.js.map} +1 -1
- package/dist/core/web/public/next/assets/{McpSection-DVgiSYG1.js → McpSection-q34T7Tho.js} +2 -2
- package/dist/core/web/public/next/assets/{McpSection-DVgiSYG1.js.map → McpSection-q34T7Tho.js.map} +1 -1
- package/dist/core/web/public/next/assets/{MutationTokenDialog-B9I4PAcp.js → MutationTokenDialog-CFAkwOEt.js} +2 -2
- package/dist/core/web/public/next/assets/{MutationTokenDialog-B9I4PAcp.js.map → MutationTokenDialog-CFAkwOEt.js.map} +1 -1
- package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-z6tJj3s9.js → ResourcePoolConsoleApp-DHc8GFOY.js} +4 -4
- package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-z6tJj3s9.js.map → ResourcePoolConsoleApp-DHc8GFOY.js.map} +1 -1
- package/dist/core/web/public/next/assets/{RouteErrorBoundary-OSajL45F.js → RouteErrorBoundary-ZL9F4qUn.js} +2 -2
- package/dist/core/web/public/next/assets/{RouteErrorBoundary-OSajL45F.js.map → RouteErrorBoundary-ZL9F4qUn.js.map} +1 -1
- package/dist/core/web/public/next/assets/{Meter-CLcr-4wS.css → Segmented-8cf0Fypn.css} +1 -1
- package/dist/core/web/public/next/assets/{Meter-Cmo1pLbk.js → Segmented-DbRjVye3.js} +2 -2
- package/dist/core/web/public/next/assets/Segmented-DbRjVye3.js.map +1 -0
- package/dist/core/web/public/next/assets/SettingsSection-nJGGGYqv.js +2 -0
- package/dist/core/web/public/next/assets/{SettingsSection-B3YH2Npi.js.map → SettingsSection-nJGGGYqv.js.map} +1 -1
- package/dist/core/web/public/next/assets/UniverseConsoleApp-6GJydgo4.js +2 -0
- package/dist/core/web/public/next/assets/{UniverseConsoleApp-wEKIzl1M.js.map → UniverseConsoleApp-6GJydgo4.js.map} +1 -1
- package/dist/core/web/public/next/assets/{UniverseView-CQVpJqqI.js → UniverseView-DLDtNYL8.js} +2 -2
- package/dist/core/web/public/next/assets/{UniverseView-CQVpJqqI.js.map → UniverseView-DLDtNYL8.js.map} +1 -1
- package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js +2 -0
- package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js.map +1 -0
- package/dist/core/web/public/next/assets/VerseConsoleApp-DsdKQoTH.css +1 -0
- package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js +3 -0
- package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js.map +1 -0
- package/dist/core/web/public/next/assets/{charts-BopViIha.js → charts-CVooqJWN.js} +2 -2
- package/dist/core/web/public/next/assets/{charts-BopViIha.js.map → charts-CVooqJWN.js.map} +1 -1
- package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js +2 -0
- package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js.map +1 -0
- package/dist/core/web/public/next/assets/global-eaY9GyX3.js +2 -0
- package/dist/core/web/public/next/assets/global-eaY9GyX3.js.map +1 -0
- package/dist/core/web/public/next/assets/{icons-L06pQH6j.js → icons-vex8rclC.js} +2 -2
- package/dist/core/web/public/next/assets/{icons-L06pQH6j.js.map → icons-vex8rclC.js.map} +1 -1
- package/dist/core/web/public/next/assets/{index-CXgVQHek.js → index-D2Eik-zt.js} +3 -3
- package/dist/core/web/public/next/assets/{index-CXgVQHek.js.map → index-D2Eik-zt.js.map} +1 -1
- package/dist/core/web/public/next/assets/{queries-D3sSlan1.js → queries-bAYU0VNy.js} +2 -2
- package/dist/core/web/public/next/assets/{queries-D3sSlan1.js.map → queries-bAYU0VNy.js.map} +1 -1
- package/dist/core/web/public/next/assets/use-guarded-action-C7QrP7Yp.js +2 -0
- package/dist/core/web/public/next/assets/{use-guarded-action-BxfJ9E-n.js.map → use-guarded-action-C7QrP7Yp.js.map} +1 -1
- package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js +2 -0
- package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js.map +1 -0
- package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js → workspace-model-CHol5RVp.js} +2 -2
- package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js.map → workspace-model-CHol5RVp.js.map} +1 -1
- package/dist/core/web/public/next/index.html +1 -1
- package/dist/release-dependency-inventory.json +1 -1
- package/docs/README.md +1 -0
- package/package.json +1 -1
- package/dist/core/web/public/next/assets/AutonomySection-yeNNUPur.js +0 -2
- package/dist/core/web/public/next/assets/ChatSection-BfnLe5dT.css +0 -1
- package/dist/core/web/public/next/assets/ChatSection-DwDDnCZQ.js +0 -80
- package/dist/core/web/public/next/assets/ChatSection-DwDDnCZQ.js.map +0 -1
- package/dist/core/web/public/next/assets/ConfirmDialog-C363ty_c.js +0 -2
- package/dist/core/web/public/next/assets/ConfirmDialog-C363ty_c.js.map +0 -1
- package/dist/core/web/public/next/assets/Meter-Cmo1pLbk.js.map +0 -1
- package/dist/core/web/public/next/assets/SettingsSection-B3YH2Npi.js +0 -2
- package/dist/core/web/public/next/assets/UniverseConsoleApp-wEKIzl1M.js +0 -2
- package/dist/core/web/public/next/assets/UsageSection-dToBThXe.js +0 -2
- package/dist/core/web/public/next/assets/UsageSection-dToBThXe.js.map +0 -1
- package/dist/core/web/public/next/assets/VerseConsoleApp-CMkCizvL.css +0 -1
- package/dist/core/web/public/next/assets/VerseConsoleApp-DHU32JCQ.js +0 -3
- package/dist/core/web/public/next/assets/VerseConsoleApp-DHU32JCQ.js.map +0 -1
- package/dist/core/web/public/next/assets/global-DMWakEhP.js +0 -2
- package/dist/core/web/public/next/assets/global-DMWakEhP.js.map +0 -1
- package/dist/core/web/public/next/assets/use-guarded-action-BxfJ9E-n.js +0 -2
- package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js +0 -2
- package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js.map +0 -1
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
* whole group, escalating SIGINT → SIGKILL after a grace period, with a bounded
|
|
12
12
|
* drain so a descendant holding our pipes cannot keep a turn "running" forever.
|
|
13
13
|
*
|
|
14
|
+
* CONTEXT (V3.9, docs/VERSE-CONTEXT.md). The engine is the one place a
|
|
15
|
+
* session's context budget is decided and kept current:
|
|
16
|
+
* - at creation, from the seat's model option and the session's mode
|
|
17
|
+
* (`budgetFor`), recorded on `usage` with where the window came from;
|
|
18
|
+
* - on every reading, a window the CLI reports at runtime WINS over the
|
|
19
|
+
* catalog (a 1M Claude model the CLI clamps to 200k is a 200k session),
|
|
20
|
+
* and the compaction point is recomputed for it (`reconcileAutoCompactAt`);
|
|
21
|
+
* - `context` / `compaction` events replace occupancy and count compactions;
|
|
22
|
+
* - adapters that can only see exact occupancy in the CLI's own files
|
|
23
|
+
* (codex rollouts) get bounded telemetry hooks: polled while the turn
|
|
24
|
+
* runs, and called once after the parser has flushed.
|
|
25
|
+
* None of this spends: it reads what the CLI already reported or wrote.
|
|
26
|
+
*
|
|
14
27
|
* LOCAL-ONLY. Because the spawn is raw rather than routed through
|
|
15
28
|
* `run/engines.spawnEngine`, this module is a SECOND subprocess funnel and none
|
|
16
29
|
* of the daemon's gates cover it. It therefore carries its own call to the one
|
|
@@ -25,9 +38,12 @@ import { basename, dirname, isAbsolute, join, sep } from 'node:path';
|
|
|
25
38
|
import { loadConfigReadOnly } from '../config.js';
|
|
26
39
|
import { endpointPermitted, enginePermitted, } from '../policy/local-only.js';
|
|
27
40
|
import { scrubSecrets } from '../util/scrub.js';
|
|
28
|
-
import { adapterFor } from './adapters/index.js';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
41
|
+
import { adapterFor as defaultAdapterFor, VERSE_TELEMETRY_POLL_MS, } from './adapters/index.js';
|
|
42
|
+
import { budgetFor, canonicalModelId, claudeAutoCompactAt, claudeAutocompactFlag, CODEX_EFFECTIVE_WINDOW_PERCENT, codexAutoCompactAt, grokAutoCompactAt, hasExpansiveMode, reconcileAutoCompactAt, } from './context-math.js';
|
|
43
|
+
import { legacyModelOptionFallback } from './model-windows.js';
|
|
44
|
+
import { stripUnsafeControlChars } from './project-memory.js';
|
|
45
|
+
import { createVerseSessionStore, isVerseWindowSource } from './session-store.js';
|
|
46
|
+
import { VERSE_CONTEXT_MODES, VERSE_DEFAULT_CONTEXT_WINDOWS, VERSE_MAX_TURN_TEXT_BYTES, VERSE_MAX_WORKSPACE_ROOTS, VERSE_TURN_TIMEOUT_MS, } from './types.js';
|
|
31
47
|
const VERSE_ERROR_STATUS = {
|
|
32
48
|
VERSE_SESSION_NOT_FOUND: 404,
|
|
33
49
|
VERSE_SESSION_BUSY: 409,
|
|
@@ -136,6 +152,19 @@ function isObject(value) {
|
|
|
136
152
|
function isStringArray(value) {
|
|
137
153
|
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
138
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Upper bound on a memory block accepted into a launch record. The block U5
|
|
157
|
+
* renders is ≤ 6 KB; this is a sanity cap so a malformed caller cannot pin an
|
|
158
|
+
* arbitrarily large string into every turn's system prompt.
|
|
159
|
+
*/
|
|
160
|
+
const MEMORY_BLOCK_MAX_BYTES = 16 * 1024;
|
|
161
|
+
function isSessionMemory(value) {
|
|
162
|
+
return isObject(value)
|
|
163
|
+
&& typeof value['dir'] === 'string' && value['dir'].length > 0 && isAbsolute(value['dir'])
|
|
164
|
+
&& typeof value['block'] === 'string'
|
|
165
|
+
&& Buffer.byteLength(value['block'], 'utf8') <= MEMORY_BLOCK_MAX_BYTES
|
|
166
|
+
&& typeof value['writable'] === 'boolean';
|
|
167
|
+
}
|
|
139
168
|
function isSeatLaunch(value) {
|
|
140
169
|
if (!isObject(value))
|
|
141
170
|
return false;
|
|
@@ -148,10 +177,17 @@ function isSeatLaunch(value) {
|
|
|
148
177
|
&& typeof value['ollamaBaseUrl'] === 'string'
|
|
149
178
|
// Absent is valid: that is every launch record written before the
|
|
150
179
|
// llama-server lane existed, and it means the Ollama lane.
|
|
151
|
-
&& (value['anthropicBaseUrl'] === undefined || typeof value['anthropicBaseUrl'] === 'string')
|
|
180
|
+
&& (value['anthropicBaseUrl'] === undefined || typeof value['anthropicBaseUrl'] === 'string')
|
|
181
|
+
// Absent is valid (memory off, or a pre-3.9 record). PRESENT but malformed
|
|
182
|
+
// is not: a half-formed memory snapshot would feed a bogus `--add-dir`.
|
|
183
|
+
&& (value['memory'] === undefined || isSessionMemory(value['memory']));
|
|
152
184
|
}
|
|
153
185
|
function cloneSession(session) {
|
|
154
|
-
return {
|
|
186
|
+
return {
|
|
187
|
+
...session,
|
|
188
|
+
usage: { ...session.usage },
|
|
189
|
+
...(session.handoffFrom ? { handoffFrom: { ...session.handoffFrom } } : {}),
|
|
190
|
+
};
|
|
155
191
|
}
|
|
156
192
|
function normaliseTitle(raw) {
|
|
157
193
|
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
@@ -163,14 +199,98 @@ function autoTitle(text) {
|
|
|
163
199
|
return DEFAULT_TITLE;
|
|
164
200
|
return firstLine.length > AUTO_TITLE_CHARS ? `${firstLine.slice(0, AUTO_TITLE_CHARS).trimEnd()}…` : firstLine;
|
|
165
201
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
202
|
+
// ---- context budgets (V3.9) -------------------------------------------------
|
|
203
|
+
function positiveInt(value) {
|
|
204
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
|
|
205
|
+
}
|
|
206
|
+
function nonNegativeInt(value) {
|
|
207
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
|
208
|
+
}
|
|
209
|
+
/** A counter delta from an adapter; anything non-numeric counts as zero rather than poisoning a total. */
|
|
210
|
+
function tokenDelta(value) {
|
|
211
|
+
return nonNegativeInt(value) ?? 0;
|
|
212
|
+
}
|
|
213
|
+
function isContextMode(value) {
|
|
214
|
+
return typeof value === 'string' && VERSE_CONTEXT_MODES.includes(value);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The seat's option for a session model: exact id first, then by canonical id
|
|
218
|
+
* so a request (or record) naming the retired alias `claude-opus-5.5` finds a
|
|
219
|
+
* seat that lists `claude-opus-5-5`, and vice versa.
|
|
220
|
+
*/
|
|
221
|
+
function findModelOption(seat, model) {
|
|
222
|
+
const exact = seat.models.find((m) => m.id === model);
|
|
223
|
+
if (exact)
|
|
224
|
+
return exact;
|
|
225
|
+
const wanted = canonicalModelId(model);
|
|
226
|
+
return seat.models.find((m) => canonicalModelId(m.id) === wanted) ?? null;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The option whose budgets govern a session's readings.
|
|
230
|
+
*
|
|
231
|
+
* Normally the seat's own option. The exception is a launch snapshot written
|
|
232
|
+
* before 3.9: its options carry one flat window and no budgets, yet the live
|
|
233
|
+
* seat the web UI reads (and the claude/codex adapters, which build the CLI
|
|
234
|
+
* flags) take that model's budgets from the documented builders in
|
|
235
|
+
* model-windows.ts. `legacyModelOptionFallback` is that ONE rule, shared with
|
|
236
|
+
* the adapters, so the mode the UI offers is the mode this engine accepts and
|
|
237
|
+
* the compaction point recorded is the one the CLI was told — two sources of
|
|
238
|
+
* truth for one flag would drift (it did: a 3.8 codex chat was offered
|
|
239
|
+
* Expansive and then refused it with a 400).
|
|
240
|
+
*
|
|
241
|
+
* Local snapshots are left as they are: a local window is the runtime's
|
|
242
|
+
* allocation, which `refreshLocalWindow` re-reads from live discovery.
|
|
243
|
+
*/
|
|
244
|
+
function effectiveModelOption(seat, model, engine) {
|
|
245
|
+
return legacyModelOptionFallback(engine, model, findModelOption(seat, model));
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The compaction point each CLI itself uses for a window Verse only knows as
|
|
249
|
+
* a named default. Applied ONLY to `VERSE_DEFAULT_CONTEXT_WINDOWS`, which are
|
|
250
|
+
* the very figures those CLIs assume for an unknown model — so the formula
|
|
251
|
+
* over them is still the CLI's, not a guess of ours.
|
|
252
|
+
*/
|
|
253
|
+
function defaultWindowAutoCompactAt(engine, window, maxOutputTokens) {
|
|
254
|
+
switch (engine) {
|
|
255
|
+
case 'claude':
|
|
256
|
+
case 'local':
|
|
257
|
+
return claudeAutoCompactAt(window, maxOutputTokens);
|
|
258
|
+
case 'codex':
|
|
259
|
+
return codexAutoCompactAt(Math.round((window * 100) / CODEX_EFFECTIVE_WINDOW_PERCENT));
|
|
260
|
+
case 'grok':
|
|
261
|
+
return grokAutoCompactAt(window);
|
|
262
|
+
default:
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A session's budget in a mode: the model option's budget (`budgetFor`), else
|
|
268
|
+
* the seat's default-model window, else the engine's named default. Only the
|
|
269
|
+
* option path carries a real source; everything past it is `fallback`, which
|
|
270
|
+
* the UI draws as an estimate.
|
|
271
|
+
*/
|
|
272
|
+
function sessionBudgetFor(seat, option, mode, engine) {
|
|
273
|
+
const budget = budgetFor(option, mode);
|
|
274
|
+
if (budget) {
|
|
275
|
+
return {
|
|
276
|
+
contextWindow: budget.contextWindow,
|
|
277
|
+
autoCompactAt: budget.autoCompactAt,
|
|
278
|
+
source: option?.windowSource ?? 'fallback',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// A seat-level window is the DEFAULT model's; it says nothing about this
|
|
282
|
+
// model's compaction point, so none is claimed.
|
|
283
|
+
const seatWindow = positiveInt(seat.contextWindow);
|
|
284
|
+
if (seatWindow !== null)
|
|
285
|
+
return { contextWindow: seatWindow, autoCompactAt: null, source: 'fallback' };
|
|
286
|
+
const fallback = positiveInt(VERSE_DEFAULT_CONTEXT_WINDOWS[engine]);
|
|
287
|
+
if (fallback === null)
|
|
288
|
+
return { contextWindow: null, autoCompactAt: null, source: 'fallback' };
|
|
289
|
+
return {
|
|
290
|
+
contextWindow: fallback,
|
|
291
|
+
autoCompactAt: defaultWindowAutoCompactAt(engine, fallback, option?.maxOutputTokens ?? null),
|
|
292
|
+
source: 'fallback',
|
|
293
|
+
};
|
|
174
294
|
}
|
|
175
295
|
function resolveProjectDir(projectPath) {
|
|
176
296
|
if (typeof projectPath !== 'string' || !projectPath.trim()) {
|
|
@@ -331,6 +451,8 @@ export function createVerseEngine(opts = {}) {
|
|
|
331
451
|
const turnTimeoutMs = opts.turnTimeoutMs ?? VERSE_TURN_TIMEOUT_MS;
|
|
332
452
|
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
333
453
|
const loadCfg = opts.loadConfig ?? readConfigForPolicy;
|
|
454
|
+
const adapterFor = opts.adapterFor ?? defaultAdapterFor;
|
|
455
|
+
const telemetryPollMs = positiveInt(opts.telemetryPollMs) ?? VERSE_TELEMETRY_POLL_MS;
|
|
334
456
|
const store = createVerseSessionStore(root);
|
|
335
457
|
const running = new Map();
|
|
336
458
|
const listeners = new Map();
|
|
@@ -344,8 +466,84 @@ export function createVerseEngine(opts = {}) {
|
|
|
344
466
|
const session = store.get(id);
|
|
345
467
|
if (!session)
|
|
346
468
|
throw new VerseError('VERSE_SESSION_NOT_FOUND', `session not found: ${id}`);
|
|
469
|
+
materializeLegacyMode(session);
|
|
347
470
|
return session;
|
|
348
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* A CLAUDE record written before 3.9 has no `contextMode` and no
|
|
474
|
+
* `usage.contextWindowSource` (neither key existed). 3.8 launched it with no
|
|
475
|
+
* `--autocompact`, so on a 1M model the CLI ran its native window and
|
|
476
|
+
* compacted near 967k. Reading "absent" as `standard` would start passing
|
|
477
|
+
* `--autocompact 400000` on the first turn after the upgrade — and a chat
|
|
478
|
+
* already holding 400k–967k would be compacted by the CLI before it answered:
|
|
479
|
+
* an unrequested, paid summarisation of the whole context, with nothing on
|
|
480
|
+
* screen to warn about it (3.8 stored occupancy CLAMPED at its 200k catalog
|
|
481
|
+
* window, so the meter could not know either).
|
|
482
|
+
*
|
|
483
|
+
* So such a record keeps what it had: `expansive` (the CLI's `auto`) when its
|
|
484
|
+
* model has an expansive budget, with that budget recorded on `usage` so the
|
|
485
|
+
* meter draws the compaction point the CLI will actually use. It is written
|
|
486
|
+
* to disk ONCE, the first time the session is touched (engine load, get,
|
|
487
|
+
* list, a turn, a mode switch) — from then on it is an ordinary expansive
|
|
488
|
+
* session the operator can switch to standard like any other.
|
|
489
|
+
*
|
|
490
|
+
* Left alone: a model with no expansive budget (a 200k model's standard IS
|
|
491
|
+
* the CLI's `auto`), codex/grok/local (their standard is their native
|
|
492
|
+
* behaviour), a record that already names a mode, and a record carrying a
|
|
493
|
+
* window source (written by 3.9 code, which knew what it chose). The stored
|
|
494
|
+
* occupancy is NOT touched: it may be 3.8's clamped figure, but the first
|
|
495
|
+
* turn's reading replaces it, and with the native compaction point restored
|
|
496
|
+
* nothing compacts that would not have under 3.8.
|
|
497
|
+
*
|
|
498
|
+
* Written with `store.save`, not `save`: a migration is not activity, and
|
|
499
|
+
* bumping `updatedAt` would reorder every old chat to the top of the list.
|
|
500
|
+
*/
|
|
501
|
+
function materializeLegacyMode(session) {
|
|
502
|
+
if (session.engine !== 'claude' || session.contextMode !== undefined)
|
|
503
|
+
return;
|
|
504
|
+
if (!isObject(session.usage) || session.usage.contextWindowSource !== undefined)
|
|
505
|
+
return;
|
|
506
|
+
const launch = store.loadLaunch(session.id);
|
|
507
|
+
if (!isSeatLaunch(launch))
|
|
508
|
+
return;
|
|
509
|
+
const option = effectiveModelOption(launch.seat, session.model, session.engine);
|
|
510
|
+
if (!hasExpansiveMode(option))
|
|
511
|
+
return;
|
|
512
|
+
const budget = sessionBudgetFor(launch.seat, option, 'expansive', session.engine);
|
|
513
|
+
session.contextMode = 'expansive';
|
|
514
|
+
session.usage.contextWindow = budget.contextWindow;
|
|
515
|
+
session.usage.contextWindowSource = budget.source;
|
|
516
|
+
session.usage.autoCompactAt = budget.autoCompactAt;
|
|
517
|
+
store.save(session);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Handoff provenance. The API hands the resolved source in `opts`; a bare
|
|
521
|
+
* `req.handoffFromSessionId` is accepted too. Either way the SOURCE RECORD
|
|
522
|
+
* in this store is the authority for the title — never a caller's string —
|
|
523
|
+
* and a source that does not exist is refused rather than pinned.
|
|
524
|
+
*/
|
|
525
|
+
function resolveHandoffSource(req, opts) {
|
|
526
|
+
const fromOpts = opts.handoffFrom;
|
|
527
|
+
if (fromOpts !== undefined && fromOpts !== null
|
|
528
|
+
&& !(isObject(fromOpts) && typeof fromOpts.sessionId === 'string' && typeof fromOpts.title === 'string')) {
|
|
529
|
+
throw new VerseError('VERSE_INVALID', 'handoff source is malformed');
|
|
530
|
+
}
|
|
531
|
+
const fromReq = req.handoffFromSessionId;
|
|
532
|
+
if (fromReq !== undefined && typeof fromReq !== 'string') {
|
|
533
|
+
throw new VerseError('VERSE_INVALID', 'handoffFromSessionId must be a string');
|
|
534
|
+
}
|
|
535
|
+
const optsId = fromOpts ? fromOpts.sessionId : undefined;
|
|
536
|
+
if (optsId !== undefined && fromReq !== undefined && optsId !== fromReq) {
|
|
537
|
+
throw new VerseError('VERSE_INVALID', 'handoff source does not match handoffFromSessionId');
|
|
538
|
+
}
|
|
539
|
+
const sourceId = optsId ?? fromReq;
|
|
540
|
+
if (sourceId === undefined)
|
|
541
|
+
return null;
|
|
542
|
+
const source = store.get(sourceId);
|
|
543
|
+
if (!source)
|
|
544
|
+
throw new VerseError('VERSE_INVALID', `handoff source session not found: ${sourceId}`);
|
|
545
|
+
return { sessionId: source.id, title: source.title };
|
|
546
|
+
}
|
|
349
547
|
function emit(id, event) {
|
|
350
548
|
const stored = store.appendEvent(id, event, nowIso());
|
|
351
549
|
const subs = listeners.get(id);
|
|
@@ -394,6 +592,7 @@ export function createVerseEngine(opts = {}) {
|
|
|
394
592
|
save(session);
|
|
395
593
|
}
|
|
396
594
|
for (const session of store.list()) {
|
|
595
|
+
materializeLegacyMode(session);
|
|
397
596
|
if (session.status === 'running')
|
|
398
597
|
reconcileInterrupted(session);
|
|
399
598
|
}
|
|
@@ -425,6 +624,9 @@ export function createVerseEngine(opts = {}) {
|
|
|
425
624
|
clearTimeout(timer);
|
|
426
625
|
turn[key] = null;
|
|
427
626
|
}
|
|
627
|
+
if (turn.pollTimer !== null)
|
|
628
|
+
clearInterval(turn.pollTimer);
|
|
629
|
+
turn.pollTimer = null;
|
|
428
630
|
}
|
|
429
631
|
function beginDrain(id, turn) {
|
|
430
632
|
if (turn.settled || turn.drainTimer !== null)
|
|
@@ -458,39 +660,262 @@ export function createVerseEngine(opts = {}) {
|
|
|
458
660
|
}, killGraceMs);
|
|
459
661
|
}
|
|
460
662
|
// ---- turn completion ----------------------------------------------------
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
663
|
+
/**
|
|
664
|
+
* A window the CLI reported for this call WINS over the catalog: Claude Code
|
|
665
|
+
* clamps a 1M model to 200k when long-context credit runs out, and codex
|
|
666
|
+
* measures against whatever `model_context_window` it was launched with.
|
|
667
|
+
* The compaction point is recomputed for the window actually in force, with
|
|
668
|
+
* the `--autocompact` value this seat's adapter passes for the mode.
|
|
669
|
+
*
|
|
670
|
+
* LOCAL is exempt: Verse TELLS that CLI its window
|
|
671
|
+
* (`CLAUDE_CODE_MAX_CONTEXT_TOKENS`), so the figure it echoes back is ours,
|
|
672
|
+
* and the CLI's own guess for an unknown model id (200k) would be wrong.
|
|
673
|
+
*/
|
|
674
|
+
function applyRuntimeWindow(session, option, reported) {
|
|
675
|
+
if (session.engine === 'local')
|
|
676
|
+
return;
|
|
677
|
+
const runtimeWindow = positiveInt(reported);
|
|
678
|
+
if (runtimeWindow === null)
|
|
679
|
+
return;
|
|
680
|
+
const mode = session.contextMode ?? 'standard';
|
|
681
|
+
session.usage.contextWindow = runtimeWindow;
|
|
682
|
+
session.usage.contextWindowSource = 'runtime';
|
|
683
|
+
session.usage.autoCompactAt = reconcileAutoCompactAt({
|
|
684
|
+
engine: session.engine,
|
|
685
|
+
runtimeWindow,
|
|
686
|
+
budget: budgetFor(option, mode),
|
|
687
|
+
autocompactWindow: session.engine === 'claude' ? claudeAutocompactFlag(option, mode) : null,
|
|
688
|
+
maxOutputTokens: option?.maxOutputTokens ?? null,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* One `context` event after the budget changed BETWEEN turns (a mode switch,
|
|
693
|
+
* a local window refreshed from discovery), so every open client redraws the
|
|
694
|
+
* meter against it; occupancy itself is unchanged. It names the budget's
|
|
695
|
+
* source: a catalog figure must never be drawn as a CLI measurement.
|
|
696
|
+
*/
|
|
697
|
+
function emitBudgetChange(id, session) {
|
|
698
|
+
emit(id, {
|
|
699
|
+
type: 'context',
|
|
700
|
+
turnId: null,
|
|
701
|
+
contextTokens: Math.max(0, Math.floor(session.usage.contextTokens || 0)),
|
|
476
702
|
contextWindow: session.usage.contextWindow,
|
|
703
|
+
exact: session.usage.contextTokensExact !== false,
|
|
704
|
+
autoCompactAt: session.usage.autoCompactAt ?? null,
|
|
705
|
+
...(session.usage.contextWindowSource !== undefined
|
|
706
|
+
? { contextWindowSource: session.usage.contextWindowSource }
|
|
707
|
+
: {}),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
/** Absent means exact, so an exact reading REMOVES the flag rather than writing `true`. */
|
|
711
|
+
function setExactness(usage, exact) {
|
|
712
|
+
if (exact)
|
|
713
|
+
delete usage.contextTokensExact;
|
|
714
|
+
else
|
|
715
|
+
usage.contextTokensExact = false;
|
|
716
|
+
}
|
|
717
|
+
/** The optional V3.9 usage keys, copied only when the session record has them. */
|
|
718
|
+
function contextFields(usage) {
|
|
719
|
+
return {
|
|
720
|
+
...(usage.contextWindowSource !== undefined ? { contextWindowSource: usage.contextWindowSource } : {}),
|
|
721
|
+
...(usage.autoCompactAt !== undefined ? { autoCompactAt: usage.autoCompactAt } : {}),
|
|
722
|
+
...(usage.contextTokensExact === false ? { contextTokensExact: false } : {}),
|
|
477
723
|
};
|
|
478
|
-
return { ...event, usage: u };
|
|
479
724
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Fold one parsed event into the session and return the event as it is to
|
|
727
|
+
* be stored — or null to drop it (a malformed context reading must never
|
|
728
|
+
* reach the log, where the store would skip it on read and the seq it took
|
|
729
|
+
* would be reused after a restart).
|
|
730
|
+
*
|
|
731
|
+
* usage — counters are summed; `contextTokens` is REPLACED and stored
|
|
732
|
+
* UNCLAMPED (a reading past the window is information: the CLI
|
|
733
|
+
* is about to compact or overflow; only the UI decides how to
|
|
734
|
+
* draw it). The stored event carries the window in force and,
|
|
735
|
+
* when the session has resolved them, its window source,
|
|
736
|
+
* compaction point and (only when false) exactness — so a
|
|
737
|
+
* client redraws the meter from the frame alone instead of
|
|
738
|
+
* waiting for a session refresh.
|
|
739
|
+
* A ZERO-COUNT frame is still a reading: a manual `/compact`
|
|
740
|
+
* turn makes no model call (`result.usage` is all zeros) and
|
|
741
|
+
* its `contextTokens` is the CLI's post-compaction size, which
|
|
742
|
+
* must replace the pre-compaction occupancy. Only a frame with
|
|
743
|
+
* NO numeric `contextTokens` leaves occupancy as it was — that
|
|
744
|
+
* is "no reading", and zero would be an invented one.
|
|
745
|
+
* context — a non-summed occupancy reading (codex rollout). Replaces
|
|
746
|
+
* `contextTokens`; the engine fills `autoCompactAt`.
|
|
747
|
+
* compaction — counted on the session; counts normalised to number|null.
|
|
748
|
+
*
|
|
749
|
+
* Every telemetry type is rebuilt from its known fields rather than spread:
|
|
750
|
+
* a stray key from an adapter (or a value JSON cannot encode) never reaches
|
|
751
|
+
* the durable log, where a failed `JSON.stringify` inside a poll timer would
|
|
752
|
+
* be an uncaught exception.
|
|
753
|
+
*/
|
|
754
|
+
function applyUsage(session, option, event, fallbackTurnId) {
|
|
755
|
+
switch (event.type) {
|
|
756
|
+
case 'usage': {
|
|
757
|
+
const reported = isObject(event.usage) ? event.usage : {};
|
|
758
|
+
applyRuntimeWindow(session, option, reported.contextWindow);
|
|
759
|
+
const previous = session.usage;
|
|
760
|
+
const reading = nonNegativeInt(reported.contextTokens);
|
|
761
|
+
const next = {
|
|
762
|
+
inputTokens: previous.inputTokens + tokenDelta(reported.inputTokens),
|
|
763
|
+
outputTokens: previous.outputTokens + tokenDelta(reported.outputTokens),
|
|
764
|
+
cacheReadTokens: previous.cacheReadTokens + tokenDelta(reported.cacheReadTokens),
|
|
765
|
+
cacheCreationTokens: previous.cacheCreationTokens + tokenDelta(reported.cacheCreationTokens),
|
|
766
|
+
contextTokens: reading ?? previous.contextTokens,
|
|
767
|
+
contextWindow: previous.contextWindow,
|
|
768
|
+
...contextFields(previous),
|
|
769
|
+
};
|
|
770
|
+
// Exactness describes a reading; with none, the previous one's stands.
|
|
771
|
+
if (reading !== null)
|
|
772
|
+
setExactness(next, reported.contextTokensExact !== false);
|
|
773
|
+
session.usage = next;
|
|
774
|
+
return {
|
|
775
|
+
type: 'usage',
|
|
776
|
+
turnId: typeof event.turnId === 'string' ? event.turnId : fallbackTurnId,
|
|
777
|
+
usage: {
|
|
778
|
+
inputTokens: tokenDelta(reported.inputTokens),
|
|
779
|
+
outputTokens: tokenDelta(reported.outputTokens),
|
|
780
|
+
cacheReadTokens: tokenDelta(reported.cacheReadTokens),
|
|
781
|
+
cacheCreationTokens: tokenDelta(reported.cacheCreationTokens),
|
|
782
|
+
contextTokens: next.contextTokens,
|
|
783
|
+
contextWindow: next.contextWindow,
|
|
784
|
+
...contextFields(next),
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
case 'context': {
|
|
789
|
+
const tokens = nonNegativeInt(event.contextTokens);
|
|
790
|
+
if (tokens === null)
|
|
791
|
+
return null;
|
|
792
|
+
applyRuntimeWindow(session, option, event.contextWindow);
|
|
793
|
+
const exact = event.exact !== false;
|
|
794
|
+
session.usage.contextTokens = tokens;
|
|
795
|
+
setExactness(session.usage, exact);
|
|
796
|
+
return {
|
|
797
|
+
type: 'context',
|
|
798
|
+
turnId: typeof event.turnId === 'string' ? event.turnId : null,
|
|
799
|
+
contextTokens: tokens,
|
|
800
|
+
// The window IN FORCE, never null when the session knows one: a
|
|
801
|
+
// client that replaces its window from this event must not lose it
|
|
802
|
+
// because the adapter had no window to report.
|
|
803
|
+
contextWindow: session.usage.contextWindow,
|
|
804
|
+
exact,
|
|
805
|
+
autoCompactAt: session.usage.autoCompactAt ?? null,
|
|
806
|
+
// ...and WHERE that window came from. When the adapter reported
|
|
807
|
+
// none, the window in force is still the catalog's, and a client
|
|
808
|
+
// must not relabel it as a CLI measurement.
|
|
809
|
+
...(session.usage.contextWindowSource !== undefined
|
|
810
|
+
? { contextWindowSource: session.usage.contextWindowSource }
|
|
811
|
+
: {}),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
case 'compaction': {
|
|
815
|
+
session.compactionCount = (session.compactionCount ?? 0) + 1;
|
|
816
|
+
return {
|
|
817
|
+
type: 'compaction',
|
|
818
|
+
turnId: typeof event.turnId === 'string' ? event.turnId : null,
|
|
819
|
+
trigger: event.trigger === 'manual' ? 'manual' : 'auto',
|
|
820
|
+
preTokens: nonNegativeInt(event.preTokens),
|
|
821
|
+
postTokens: nonNegativeInt(event.postTokens),
|
|
822
|
+
durationMs: nonNegativeInt(event.durationMs),
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
default:
|
|
826
|
+
return event;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/** Event types whose application changes the session record. */
|
|
830
|
+
const SESSION_MUTATING_EVENTS = new Set(['usage', 'context', 'compaction']);
|
|
831
|
+
/**
|
|
832
|
+
* Telemetry hooks may only contribute readings. An `error` or `turn-done`
|
|
833
|
+
* from a hook would let a best-effort file read decide whether a turn
|
|
834
|
+
* failed, which only the CLI's own output and exit code may do.
|
|
835
|
+
*/
|
|
836
|
+
const TELEMETRY_EVENTS = new Set(['usage', 'context', 'compaction']);
|
|
837
|
+
/**
|
|
838
|
+
* `untilSettled` (the live poll): stop as soon as the turn settles. `emit`
|
|
839
|
+
* runs subscriber callbacks synchronously, so a subscriber that stops or
|
|
840
|
+
* deletes the session can settle the turn in the middle of this loop; the
|
|
841
|
+
* rest of a poll's readings would then land AFTER `turn-done`. The parser's
|
|
842
|
+
* final flush and `afterTurn` deliberately run while settled, so they omit it.
|
|
843
|
+
*/
|
|
844
|
+
function applyEvents(id, turn, events, untilSettled = false) {
|
|
484
845
|
const session = store.get(id);
|
|
846
|
+
// No record, no events: appending would re-create the log of a session
|
|
847
|
+
// that is gone, and `save` its record — a deleted chat back as an orphan.
|
|
848
|
+
if (!session)
|
|
849
|
+
return;
|
|
485
850
|
for (const event of events) {
|
|
851
|
+
if (untilSettled && turn.settled)
|
|
852
|
+
return;
|
|
486
853
|
if (event.type === 'error')
|
|
487
854
|
turn.sawError = true;
|
|
488
|
-
const enriched =
|
|
855
|
+
const enriched = applyUsage(session, turn.option, event, turn.turnId);
|
|
856
|
+
if (enriched === null)
|
|
857
|
+
continue;
|
|
489
858
|
emit(id, enriched);
|
|
490
|
-
|
|
859
|
+
// Deleted by a subscriber of the event just emitted: neither `save` nor
|
|
860
|
+
// the rest of the batch may write the record or log back.
|
|
861
|
+
if (store.get(id) !== session)
|
|
862
|
+
return;
|
|
863
|
+
if (SESSION_MUTATING_EVENTS.has(event.type))
|
|
491
864
|
save(session);
|
|
492
865
|
}
|
|
493
866
|
}
|
|
867
|
+
function handleParsed(id, turn, events) {
|
|
868
|
+
if (events.length === 0)
|
|
869
|
+
return;
|
|
870
|
+
turn.sawOutput = true;
|
|
871
|
+
applyEvents(id, turn, events);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Run one telemetry hook. Hooks read the CLI's own files; they are bounded
|
|
875
|
+
* and synchronous by contract, but a hook that throws, returns garbage or
|
|
876
|
+
* returns a non-telemetry event is contained here — telemetry is never
|
|
877
|
+
* allowed to break, fail or extend a turn.
|
|
878
|
+
*
|
|
879
|
+
* The WHOLE body is guarded, not just the hook call: the poll runs from a
|
|
880
|
+
* timer, where anything thrown (a store write failing, a subscriber's
|
|
881
|
+
* callback) would be an uncaught exception in the server process rather
|
|
882
|
+
* than a failed turn.
|
|
883
|
+
*/
|
|
884
|
+
function runTelemetryHook(id, turn, hook) {
|
|
885
|
+
try {
|
|
886
|
+
const fn = turn.adapter[hook];
|
|
887
|
+
if (typeof fn !== 'function')
|
|
888
|
+
return;
|
|
889
|
+
const current = store.get(id);
|
|
890
|
+
if (!current)
|
|
891
|
+
return;
|
|
892
|
+
const ctx = turn.hookCtx;
|
|
893
|
+
ctx.session = cloneSession(current);
|
|
894
|
+
let observed = null;
|
|
895
|
+
try {
|
|
896
|
+
observed = turn.parser.nativeSessionId();
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
observed = null;
|
|
900
|
+
}
|
|
901
|
+
ctx.nativeSessionId = observed ?? current.nativeSessionId;
|
|
902
|
+
let produced;
|
|
903
|
+
try {
|
|
904
|
+
produced = fn.call(turn.adapter, ctx);
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (!Array.isArray(produced))
|
|
910
|
+
return;
|
|
911
|
+
const events = produced.filter((event) => isObject(event) && typeof event['type'] === 'string' && TELEMETRY_EVENTS.has(event['type']));
|
|
912
|
+
if (events.length > 0)
|
|
913
|
+
applyEvents(id, turn, events, hook === 'pollTelemetry');
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
// Best effort by design; the turn's own outcome is decided elsewhere.
|
|
917
|
+
}
|
|
918
|
+
}
|
|
494
919
|
function pushLine(id, turn, line) {
|
|
495
920
|
if (turn.settled)
|
|
496
921
|
return;
|
|
@@ -535,7 +960,23 @@ export function createVerseEngine(opts = {}) {
|
|
|
535
960
|
if (turn.termination !== null)
|
|
536
961
|
finished = finished.filter((event) => event.type !== 'error');
|
|
537
962
|
handleParsed(id, turn, finished);
|
|
963
|
+
// After the parser flushed (so its own usage is already applied and a
|
|
964
|
+
// file-based reading REPLACES it rather than being overwritten by it), and
|
|
965
|
+
// before `turn-done`, so every client sees the final occupancy and any
|
|
966
|
+
// compaction as part of the turn. Runs for stopped turns too: a turn that
|
|
967
|
+
// was cancelled part-way can still have compacted. Skipped only when the
|
|
968
|
+
// process never existed (async spawn failure, no pid): it wrote nothing,
|
|
969
|
+
// and a file read then could only find some OTHER run's records.
|
|
970
|
+
if (turn.child.pid !== undefined)
|
|
971
|
+
runTelemetryHook(id, turn, 'afterTurn');
|
|
538
972
|
const session = store.get(id);
|
|
973
|
+
if (!session) {
|
|
974
|
+
// Deleted while settling (a subscriber of one of the events above
|
|
975
|
+
// removed the chat). Its subscribers went with it; a close-out written
|
|
976
|
+
// now would only re-create the log of a chat that no longer exists.
|
|
977
|
+
detachChild(turn);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
539
980
|
let ok = exitCode === 0 && !turn.sawError;
|
|
540
981
|
let lastError = null;
|
|
541
982
|
// Stop is a normal action, not a failure: the session returns to idle
|
|
@@ -565,25 +1006,34 @@ export function createVerseEngine(opts = {}) {
|
|
|
565
1006
|
}
|
|
566
1007
|
}
|
|
567
1008
|
const nativeFromOutput = turn.parser.nativeSessionId();
|
|
568
|
-
const nativeSessionId = nativeFromOutput ?? session
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
// Count the turn once the CLI engaged: a resumed conversation exists on
|
|
580
|
-
// the vendor side even when the turn was cancelled part-way.
|
|
581
|
-
if (ok || turn.sawOutput)
|
|
582
|
-
session.turnCount += 1;
|
|
583
|
-
session.status = ok || stopped ? 'idle' : 'error';
|
|
584
|
-
session.lastError = ok || stopped ? null : (lastError ?? session.lastError ?? 'turn failed');
|
|
585
|
-
save(session);
|
|
1009
|
+
const nativeSessionId = nativeFromOutput ?? session.nativeSessionId ?? null;
|
|
1010
|
+
// Same rule for a subscriber of the close-out events themselves.
|
|
1011
|
+
const alive = () => store.get(id) === session;
|
|
1012
|
+
if (alive()) {
|
|
1013
|
+
emit(id, {
|
|
1014
|
+
type: 'turn-done',
|
|
1015
|
+
turnId: turn.turnId,
|
|
1016
|
+
ok,
|
|
1017
|
+
nativeSessionId,
|
|
1018
|
+
durationMs: Math.max(0, Date.now() - turn.startedAt),
|
|
1019
|
+
});
|
|
586
1020
|
}
|
|
1021
|
+
if (!alive()) {
|
|
1022
|
+
detachChild(turn);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (nativeFromOutput && !session.nativeSessionId)
|
|
1026
|
+
session.nativeSessionId = nativeFromOutput;
|
|
1027
|
+
// Count the turn once the CLI engaged: a resumed conversation exists on
|
|
1028
|
+
// the vendor side even when the turn was cancelled part-way.
|
|
1029
|
+
if (ok || turn.sawOutput)
|
|
1030
|
+
session.turnCount += 1;
|
|
1031
|
+
session.status = ok || stopped ? 'idle' : 'error';
|
|
1032
|
+
session.lastError = ok || stopped ? null : (lastError ?? session.lastError ?? 'turn failed');
|
|
1033
|
+
save(session);
|
|
1034
|
+
detachChild(turn);
|
|
1035
|
+
}
|
|
1036
|
+
function detachChild(turn) {
|
|
587
1037
|
turn.child.removeAllListeners();
|
|
588
1038
|
turn.child.on('error', () => { });
|
|
589
1039
|
turn.child.stdout?.removeAllListeners();
|
|
@@ -625,6 +1075,7 @@ export function createVerseEngine(opts = {}) {
|
|
|
625
1075
|
}
|
|
626
1076
|
const adapter = adapterFor(session.engine);
|
|
627
1077
|
const parser = adapter.createParser(turnId);
|
|
1078
|
+
const option = effectiveModelOption(seatLaunch.seat, session.model, session.engine);
|
|
628
1079
|
const env = buildTurnEnv(launch.env);
|
|
629
1080
|
const [bin, ...args] = launch.argv;
|
|
630
1081
|
const detached = process.platform !== 'win32';
|
|
@@ -647,12 +1098,24 @@ export function createVerseEngine(opts = {}) {
|
|
|
647
1098
|
save(session);
|
|
648
1099
|
return;
|
|
649
1100
|
}
|
|
1101
|
+
const startedAt = Date.now();
|
|
650
1102
|
const turn = {
|
|
651
1103
|
turnId,
|
|
652
1104
|
child,
|
|
653
1105
|
pgid: detached && typeof child.pid === 'number' && child.pid > 0 ? child.pid : null,
|
|
654
|
-
startedAt
|
|
1106
|
+
startedAt,
|
|
655
1107
|
parser,
|
|
1108
|
+
adapter,
|
|
1109
|
+
option,
|
|
1110
|
+
hookCtx: {
|
|
1111
|
+
session: cloneSession(session),
|
|
1112
|
+
launch: seatLaunch,
|
|
1113
|
+
turnId,
|
|
1114
|
+
startedAt,
|
|
1115
|
+
nativeSessionId: session.nativeSessionId,
|
|
1116
|
+
parser,
|
|
1117
|
+
state: {},
|
|
1118
|
+
},
|
|
656
1119
|
stdoutBuf: '',
|
|
657
1120
|
stderrTail: [],
|
|
658
1121
|
redactions,
|
|
@@ -663,6 +1126,7 @@ export function createVerseEngine(opts = {}) {
|
|
|
663
1126
|
timeoutTimer: null,
|
|
664
1127
|
escalationTimer: null,
|
|
665
1128
|
drainTimer: null,
|
|
1129
|
+
pollTimer: null,
|
|
666
1130
|
};
|
|
667
1131
|
running.set(id, turn);
|
|
668
1132
|
emit(id, { type: 'turn-started', turnId, pid: typeof child.pid === 'number' ? child.pid : null });
|
|
@@ -716,29 +1180,50 @@ export function createVerseEngine(opts = {}) {
|
|
|
716
1180
|
}, turnTimeoutMs);
|
|
717
1181
|
if (turn.timeoutTimer.unref)
|
|
718
1182
|
turn.timeoutTimer.unref();
|
|
1183
|
+
// Live meter for CLIs that only record exact occupancy in their own files.
|
|
1184
|
+
// Unref'd so a poll never keeps the process alive; cleared in finalize.
|
|
1185
|
+
if (typeof adapter.pollTelemetry === 'function') {
|
|
1186
|
+
turn.pollTimer = setInterval(() => {
|
|
1187
|
+
if (turn.settled)
|
|
1188
|
+
return;
|
|
1189
|
+
runTelemetryHook(id, turn, 'pollTelemetry');
|
|
1190
|
+
}, telemetryPollMs);
|
|
1191
|
+
if (turn.pollTimer.unref)
|
|
1192
|
+
turn.pollTimer.unref();
|
|
1193
|
+
}
|
|
719
1194
|
}
|
|
720
1195
|
// ---- handle -----------------------------------------------------------------
|
|
721
1196
|
return {
|
|
722
1197
|
listSessions() {
|
|
723
|
-
|
|
1198
|
+
const sessions = store.list();
|
|
1199
|
+
// Normally a no-op (the startup pass already ran); covers a record that
|
|
1200
|
+
// reached the store after this engine started.
|
|
1201
|
+
for (const session of sessions)
|
|
1202
|
+
materializeLegacyMode(session);
|
|
1203
|
+
return sessions
|
|
724
1204
|
.map(cloneSession)
|
|
725
1205
|
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
|
|
726
1206
|
},
|
|
727
1207
|
getSession(id) {
|
|
728
1208
|
const session = typeof id === 'string' ? store.get(id) : null;
|
|
729
|
-
|
|
1209
|
+
if (!session)
|
|
1210
|
+
return null;
|
|
1211
|
+
materializeLegacyMode(session);
|
|
1212
|
+
return cloneSession(session);
|
|
730
1213
|
},
|
|
731
1214
|
getEvents(id, fromSeq = 0) {
|
|
732
1215
|
require(id);
|
|
733
1216
|
return store.readEvents(id, fromSeq);
|
|
734
1217
|
},
|
|
735
|
-
createSession(req, launch) {
|
|
1218
|
+
createSession(req, launch, opts = {}) {
|
|
736
1219
|
if (closed)
|
|
737
1220
|
throw new VerseError('VERSE_INVALID', 'verse engine is closed');
|
|
738
1221
|
if (!isObject(req))
|
|
739
1222
|
throw new VerseError('VERSE_INVALID', 'request body must be an object');
|
|
740
1223
|
if (!isSeatLaunch(launch))
|
|
741
1224
|
throw new VerseError('VERSE_INVALID', 'seat launch is malformed');
|
|
1225
|
+
if (!isObject(opts))
|
|
1226
|
+
throw new VerseError('VERSE_INVALID', 'create options must be an object');
|
|
742
1227
|
const projectPath = resolveProjectDir(req.projectPath);
|
|
743
1228
|
const extraRoots = resolveExtraRoots(req.extraRoots, projectPath);
|
|
744
1229
|
if (req.workspaceId !== undefined && typeof req.workspaceId !== 'string') {
|
|
@@ -752,15 +1237,58 @@ export function createVerseEngine(opts = {}) {
|
|
|
752
1237
|
if (engine !== 'local' && (!launch.launcher || launch.launcher.length === 0)) {
|
|
753
1238
|
throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no launcher`);
|
|
754
1239
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1240
|
+
// Model. An explicit request may name the retired alias; the record
|
|
1241
|
+
// stores the id the SEAT lists, so new sessions carry the canonical id.
|
|
1242
|
+
// The default is the first RUNNABLE model — a listed-but-unavailable
|
|
1243
|
+
// model (e.g. one the pinned CLI is too old for) is never picked silently.
|
|
1244
|
+
let option;
|
|
1245
|
+
if (typeof req.model === 'string' && req.model.trim()) {
|
|
1246
|
+
const requested = req.model.trim();
|
|
1247
|
+
option = findModelOption(seat, requested);
|
|
1248
|
+
if (!option)
|
|
1249
|
+
throw new VerseError('VERSE_INVALID', `model ${requested} is not available on seat ${seat.id}`);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
if (seat.models.length === 0)
|
|
1253
|
+
throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no models`);
|
|
1254
|
+
option = seat.models.find((m) => !m.unavailableReason) ?? null;
|
|
1255
|
+
if (!option)
|
|
1256
|
+
throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no runnable models`);
|
|
760
1257
|
}
|
|
1258
|
+
if (typeof option.unavailableReason === 'string' && option.unavailableReason.trim()) {
|
|
1259
|
+
throw new VerseError('VERSE_INVALID', `model ${option.id} is unavailable on seat ${seat.id}: ${option.unavailableReason.trim()}`);
|
|
1260
|
+
}
|
|
1261
|
+
const model = option.id;
|
|
1262
|
+
// Context mode. A request without one gets `standard`. Standard always
|
|
1263
|
+
// exists — it is the CLI's own behaviour, known or estimated — but a
|
|
1264
|
+
// mode the model has no budget for is refused, never faked.
|
|
1265
|
+
let contextMode = 'standard';
|
|
1266
|
+
if (req.contextMode !== undefined) {
|
|
1267
|
+
if (!isContextMode(req.contextMode)) {
|
|
1268
|
+
throw new VerseError('VERSE_INVALID', `contextMode must be one of: ${VERSE_CONTEXT_MODES.join(', ')}`);
|
|
1269
|
+
}
|
|
1270
|
+
contextMode = req.contextMode;
|
|
1271
|
+
}
|
|
1272
|
+
const budgetOption = effectiveModelOption(seat, model, engine);
|
|
1273
|
+
if (contextMode !== 'standard' && !budgetFor(budgetOption, contextMode)) {
|
|
1274
|
+
throw new VerseError('VERSE_INVALID', `model ${model} has no ${contextMode} context mode on seat ${seat.id}`);
|
|
1275
|
+
}
|
|
1276
|
+
const budget = sessionBudgetFor(seat, budgetOption, contextMode, engine);
|
|
761
1277
|
if (req.title !== undefined && typeof req.title !== 'string') {
|
|
762
1278
|
throw new VerseError('VERSE_INVALID', 'title must be a string');
|
|
763
1279
|
}
|
|
1280
|
+
// Memory: `undefined` = the caller did not decide (pre-3.9) → no key;
|
|
1281
|
+
// `null` = decided OFF → `memoryEnabled: false`; a snapshot → pinned.
|
|
1282
|
+
let memory;
|
|
1283
|
+
if (opts.memory !== undefined && opts.memory !== null) {
|
|
1284
|
+
if (!isSessionMemory(opts.memory))
|
|
1285
|
+
throw new VerseError('VERSE_INVALID', 'memory snapshot is malformed');
|
|
1286
|
+
memory = { dir: opts.memory.dir, block: opts.memory.block, writable: opts.memory.writable };
|
|
1287
|
+
}
|
|
1288
|
+
else {
|
|
1289
|
+
memory = opts.memory;
|
|
1290
|
+
}
|
|
1291
|
+
const handoffFrom = resolveHandoffSource(req, opts);
|
|
764
1292
|
const title = req.title ? normaliseTitle(req.title) : '';
|
|
765
1293
|
const at = nowIso();
|
|
766
1294
|
const session = {
|
|
@@ -792,9 +1320,18 @@ export function createVerseEngine(opts = {}) {
|
|
|
792
1320
|
cacheReadTokens: 0,
|
|
793
1321
|
cacheCreationTokens: 0,
|
|
794
1322
|
contextTokens: 0,
|
|
795
|
-
contextWindow:
|
|
1323
|
+
contextWindow: budget.contextWindow,
|
|
1324
|
+
contextWindowSource: budget.source,
|
|
1325
|
+
autoCompactAt: budget.autoCompactAt,
|
|
796
1326
|
},
|
|
797
1327
|
lastError: null,
|
|
1328
|
+
// ALWAYS written, even for `standard`: from 3.9 on, a record with no
|
|
1329
|
+
// `contextMode` key is by definition one created before modes existed
|
|
1330
|
+
// (see `materializeLegacyMode`), and that inference is only sound if
|
|
1331
|
+
// no new record ever omits the key.
|
|
1332
|
+
contextMode,
|
|
1333
|
+
...(handoffFrom ? { handoffFrom } : {}),
|
|
1334
|
+
...(memory !== undefined ? { memoryEnabled: memory !== null } : {}),
|
|
798
1335
|
};
|
|
799
1336
|
store.save(session);
|
|
800
1337
|
store.saveLaunch(session.id, {
|
|
@@ -807,6 +1344,9 @@ export function createVerseEngine(opts = {}) {
|
|
|
807
1344
|
...(typeof launch.anthropicBaseUrl === 'string' && launch.anthropicBaseUrl.length > 0
|
|
808
1345
|
? { anthropicBaseUrl: launch.anthropicBaseUrl }
|
|
809
1346
|
: {}),
|
|
1347
|
+
// Pinned for the same reason, and so the block sent every turn is the
|
|
1348
|
+
// same bytes every turn (prompt-cache stable).
|
|
1349
|
+
...(memory ? { memory } : {}),
|
|
810
1350
|
});
|
|
811
1351
|
return cloneSession(session);
|
|
812
1352
|
},
|
|
@@ -829,6 +1369,13 @@ export function createVerseEngine(opts = {}) {
|
|
|
829
1369
|
if (!isSeatLaunch(launch)) {
|
|
830
1370
|
throw new VerseError('VERSE_INVALID', 'session launch record is missing or unreadable');
|
|
831
1371
|
}
|
|
1372
|
+
// A memory snapshot pinned before control characters were stripped can
|
|
1373
|
+
// hold a NUL, which no OS accepts inside an argv entry — the session
|
|
1374
|
+
// could never start again. Repair the in-memory copy for this launch;
|
|
1375
|
+
// the pinned record stays as written (it is the provenance).
|
|
1376
|
+
if (launch.memory && launch.memory.block !== stripUnsafeControlChars(launch.memory.block)) {
|
|
1377
|
+
launch.memory = { ...launch.memory, block: stripUnsafeControlChars(launch.memory.block) };
|
|
1378
|
+
}
|
|
832
1379
|
const turnId = randomUUID();
|
|
833
1380
|
if (session.turnCount === 0 && session.title === DEFAULT_TITLE)
|
|
834
1381
|
session.title = autoTitle(text);
|
|
@@ -852,6 +1399,80 @@ export function createVerseEngine(opts = {}) {
|
|
|
852
1399
|
startTurn(session, turnId, launch, turnLaunch, redactionsFor(launch, turnLaunch));
|
|
853
1400
|
return { turnId, session: cloneSession(store.get(id) ?? session) };
|
|
854
1401
|
},
|
|
1402
|
+
setContextMode(id, mode) {
|
|
1403
|
+
if (closed)
|
|
1404
|
+
throw new VerseError('VERSE_INVALID', 'verse engine is closed');
|
|
1405
|
+
const session = require(id);
|
|
1406
|
+
if (!isContextMode(mode)) {
|
|
1407
|
+
throw new VerseError('VERSE_INVALID', `mode must be one of: ${VERSE_CONTEXT_MODES.join(', ')}`);
|
|
1408
|
+
}
|
|
1409
|
+
if (running.has(id)) {
|
|
1410
|
+
throw new VerseError('VERSE_SESSION_BUSY', 'the context mode can change between turns; wait for this turn to finish or stop it');
|
|
1411
|
+
}
|
|
1412
|
+
const launch = store.loadLaunch(id);
|
|
1413
|
+
if (!isSeatLaunch(launch)) {
|
|
1414
|
+
throw new VerseError('VERSE_INVALID', 'session launch record is missing or unreadable');
|
|
1415
|
+
}
|
|
1416
|
+
const option = effectiveModelOption(launch.seat, session.model, session.engine);
|
|
1417
|
+
if (mode !== 'standard' && !budgetFor(option, mode)) {
|
|
1418
|
+
throw new VerseError('VERSE_INVALID', `model ${session.model} has no ${mode} context mode on seat ${session.seatId}`);
|
|
1419
|
+
}
|
|
1420
|
+
if ((session.contextMode ?? 'standard') === mode)
|
|
1421
|
+
return cloneSession(session);
|
|
1422
|
+
session.contextMode = mode;
|
|
1423
|
+
const runtimeWindow = session.usage.contextWindowSource === 'runtime' ? positiveInt(session.usage.contextWindow) : null;
|
|
1424
|
+
if (runtimeWindow !== null && session.engine === 'claude') {
|
|
1425
|
+
// Claude's window is a property of the MODEL (the CLI reported it);
|
|
1426
|
+
// the mode only moves the `--autocompact` point. Keep the measured
|
|
1427
|
+
// window and recompute where it now compacts.
|
|
1428
|
+
applyRuntimeWindow(session, option, runtimeWindow);
|
|
1429
|
+
}
|
|
1430
|
+
else {
|
|
1431
|
+
// Codex's window IS the mode (`-c model_context_window=…`), so a
|
|
1432
|
+
// previous runtime reading describes the old mode. Show the new
|
|
1433
|
+
// budget until the next turn reports its own.
|
|
1434
|
+
const budget = sessionBudgetFor(launch.seat, option, mode, session.engine);
|
|
1435
|
+
session.usage.contextWindow = budget.contextWindow;
|
|
1436
|
+
session.usage.contextWindowSource = budget.source;
|
|
1437
|
+
session.usage.autoCompactAt = budget.autoCompactAt;
|
|
1438
|
+
}
|
|
1439
|
+
save(session);
|
|
1440
|
+
emitBudgetChange(id, session);
|
|
1441
|
+
return cloneSession(session);
|
|
1442
|
+
},
|
|
1443
|
+
refreshLocalWindow(id, option) {
|
|
1444
|
+
if (closed)
|
|
1445
|
+
throw new VerseError('VERSE_INVALID', 'verse engine is closed');
|
|
1446
|
+
const session = require(id);
|
|
1447
|
+
if (session.engine !== 'local')
|
|
1448
|
+
return cloneSession(session);
|
|
1449
|
+
if (running.has(id)) {
|
|
1450
|
+
throw new VerseError('VERSE_SESSION_BUSY', 'the window can change between turns; wait for this turn to finish or stop it');
|
|
1451
|
+
}
|
|
1452
|
+
if (!isObject(option) || typeof option.id !== 'string' || canonicalModelId(option.id) !== canonicalModelId(session.model)) {
|
|
1453
|
+
throw new VerseError('VERSE_INVALID', `live option does not describe model ${session.model}`);
|
|
1454
|
+
}
|
|
1455
|
+
// A live option with no usable window is "no reading" — keep the stored
|
|
1456
|
+
// one rather than erase the only window the CLI can be told.
|
|
1457
|
+
const budget = budgetFor(option, 'standard');
|
|
1458
|
+
if (!budget)
|
|
1459
|
+
return cloneSession(session);
|
|
1460
|
+
// The caller's option is data: an unknown source would make the record
|
|
1461
|
+
// fail validation on its next read, so it is recorded as a fallback.
|
|
1462
|
+
const source = isVerseWindowSource(option.windowSource) ? option.windowSource : 'fallback';
|
|
1463
|
+
const usage = session.usage;
|
|
1464
|
+
if (usage.contextWindow === budget.contextWindow
|
|
1465
|
+
&& (usage.autoCompactAt ?? null) === budget.autoCompactAt
|
|
1466
|
+
&& usage.contextWindowSource === source) {
|
|
1467
|
+
return cloneSession(session);
|
|
1468
|
+
}
|
|
1469
|
+
usage.contextWindow = budget.contextWindow;
|
|
1470
|
+
usage.autoCompactAt = budget.autoCompactAt;
|
|
1471
|
+
usage.contextWindowSource = source;
|
|
1472
|
+
save(session);
|
|
1473
|
+
emitBudgetChange(id, session);
|
|
1474
|
+
return cloneSession(session);
|
|
1475
|
+
},
|
|
855
1476
|
cancelTurn(id) {
|
|
856
1477
|
require(id);
|
|
857
1478
|
const turn = running.get(id);
|