@ashlr/hub 3.7.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 +257 -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/policy/local-only.d.ts +142 -2
- package/dist/core/policy/local-only.js +204 -15
- package/dist/core/policy/local-only.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/budget.d.ts +22 -8
- package/dist/core/run/budget.js +80 -12
- package/dist/core/run/budget.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/run/sandboxed-engine.js +3 -3
- package/dist/core/run/sandboxed-engine.js.map +1 -1
- package/dist/core/universe/builtins/preparation/manifest.json +1 -1
- package/dist/core/universe/builtins/preparation/preparation-bridge.mjs +69 -10
- package/dist/core/universe/builtins/preparation/preparation-verification-fixtures.mjs +68 -9
- 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 +117 -2
- package/dist/core/verse/session-engine.js +763 -65
- 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-vtaHB9mp.js → App-XURmiAAp.js} +5 -5
- package/dist/core/web/public/next/assets/{App-vtaHB9mp.js.map → App-XURmiAAp.js.map} +1 -1
- package/dist/core/web/public/next/assets/{ApprovalsSection-B-bFJ0I9.js → ApprovalsSection-BriUzoZc.js} +2 -2
- package/dist/core/web/public/next/assets/{ApprovalsSection-B-bFJ0I9.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-DoqmsCjw.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-CPM9JxBc.js → Dialog-BxdIfmFH.js} +2 -2
- package/dist/core/web/public/next/assets/{Dialog-CPM9JxBc.js.map → Dialog-BxdIfmFH.js.map} +1 -1
- package/dist/core/web/public/next/assets/{DiffViewer-B84qVef9.js → DiffViewer-DmeRWuc4.js} +2 -2
- package/dist/core/web/public/next/assets/{DiffViewer-B84qVef9.js.map → DiffViewer-DmeRWuc4.js.map} +1 -1
- package/dist/core/web/public/next/assets/{McpSection-CEUpV_5X.js → McpSection-q34T7Tho.js} +2 -2
- package/dist/core/web/public/next/assets/{McpSection-CEUpV_5X.js.map → McpSection-q34T7Tho.js.map} +1 -1
- package/dist/core/web/public/next/assets/{MutationTokenDialog-DA8t97XT.js → MutationTokenDialog-CFAkwOEt.js} +2 -2
- package/dist/core/web/public/next/assets/{MutationTokenDialog-DA8t97XT.js.map → MutationTokenDialog-CFAkwOEt.js.map} +1 -1
- package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-Bq7Vyud0.js → ResourcePoolConsoleApp-DHc8GFOY.js} +4 -4
- package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-Bq7Vyud0.js.map → ResourcePoolConsoleApp-DHc8GFOY.js.map} +1 -1
- package/dist/core/web/public/next/assets/{RouteErrorBoundary-Badio_uO.js → RouteErrorBoundary-ZL9F4qUn.js} +2 -2
- package/dist/core/web/public/next/assets/{RouteErrorBoundary-Badio_uO.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--h3MmZS-.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-BwrpQlpP.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-_FSohAdN.js.map → UniverseConsoleApp-6GJydgo4.js.map} +1 -1
- package/dist/core/web/public/next/assets/{UniverseView-DqcXbed4.js → UniverseView-DLDtNYL8.js} +2 -2
- package/dist/core/web/public/next/assets/{UniverseView-DqcXbed4.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-CTZJ-JrH.js → charts-CVooqJWN.js} +2 -2
- package/dist/core/web/public/next/assets/{charts-CTZJ-JrH.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-D9vlf85u.js → icons-vex8rclC.js} +2 -2
- package/dist/core/web/public/next/assets/{icons-D9vlf85u.js.map → icons-vex8rclC.js.map} +1 -1
- package/dist/core/web/public/next/assets/{index-CZamKRUg.js → index-D2Eik-zt.js} +3 -3
- package/dist/core/web/public/next/assets/{index-CZamKRUg.js.map → index-D2Eik-zt.js.map} +1 -1
- package/dist/core/web/public/next/assets/{queries-B8eBnFox.js → queries-bAYU0VNy.js} +2 -2
- package/dist/core/web/public/next/assets/{queries-B8eBnFox.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-bzrVVAYq.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-DoqmsCjw.js +0 -2
- package/dist/core/web/public/next/assets/ChatSection-BfnLe5dT.css +0 -1
- package/dist/core/web/public/next/assets/ChatSection-vNPIJffd.js +0 -80
- package/dist/core/web/public/next/assets/ChatSection-vNPIJffd.js.map +0 -1
- package/dist/core/web/public/next/assets/ConfirmDialog-Dd2APQhB.js +0 -2
- package/dist/core/web/public/next/assets/ConfirmDialog-Dd2APQhB.js.map +0 -1
- package/dist/core/web/public/next/assets/Meter--h3MmZS-.js.map +0 -1
- package/dist/core/web/public/next/assets/SettingsSection-BwrpQlpP.js +0 -2
- package/dist/core/web/public/next/assets/UniverseConsoleApp-_FSohAdN.js +0 -2
- package/dist/core/web/public/next/assets/UsageSection-Bq6Daaby.js +0 -2
- package/dist/core/web/public/next/assets/UsageSection-Bq6Daaby.js.map +0 -1
- package/dist/core/web/public/next/assets/VerseConsoleApp-CMkCizvL.css +0 -1
- package/dist/core/web/public/next/assets/VerseConsoleApp-CsIQv9id.js +0 -3
- package/dist/core/web/public/next/assets/VerseConsoleApp-CsIQv9id.js.map +0 -1
- package/dist/core/web/public/next/assets/global-D6bTxIRn.js +0 -2
- package/dist/core/web/public/next/assets/global-D6bTxIRn.js.map +0 -1
- package/dist/core/web/public/next/assets/use-guarded-action-bzrVVAYq.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
|
@@ -10,16 +10,40 @@
|
|
|
10
10
|
* its own process-group leader (`detached: true`) so cancellation signals the
|
|
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
|
+
*
|
|
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
|
+
*
|
|
27
|
+
* LOCAL-ONLY. Because the spawn is raw rather than routed through
|
|
28
|
+
* `run/engines.spawnEngine`, this module is a SECOND subprocess funnel and none
|
|
29
|
+
* of the daemon's gates cover it. It therefore carries its own call to the one
|
|
30
|
+
* shared predicate (`policy/local-only.decidePermission`, reached through its
|
|
31
|
+
* published wrappers) at the top of `startTurn` — see `verseSeatPermitted`.
|
|
13
32
|
*/
|
|
14
33
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
15
34
|
import { randomUUID } from 'node:crypto';
|
|
16
35
|
import { realpathSync, statSync } from 'node:fs';
|
|
17
36
|
import { homedir } from 'node:os';
|
|
18
37
|
import { basename, dirname, isAbsolute, join, sep } from 'node:path';
|
|
38
|
+
import { loadConfigReadOnly } from '../config.js';
|
|
39
|
+
import { endpointPermitted, enginePermitted, } from '../policy/local-only.js';
|
|
19
40
|
import { scrubSecrets } from '../util/scrub.js';
|
|
20
|
-
import { adapterFor } from './adapters/index.js';
|
|
21
|
-
import {
|
|
22
|
-
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';
|
|
23
47
|
const VERSE_ERROR_STATUS = {
|
|
24
48
|
VERSE_SESSION_NOT_FOUND: 404,
|
|
25
49
|
VERSE_SESSION_BUSY: 409,
|
|
@@ -37,6 +61,45 @@ export class VerseError extends Error {
|
|
|
37
61
|
}
|
|
38
62
|
}
|
|
39
63
|
// ---------------------------------------------------------------------------
|
|
64
|
+
// Local-only
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
/**
|
|
67
|
+
* Is this SEAT permitted to run a turn right now?
|
|
68
|
+
*
|
|
69
|
+
* NOT a second policy predicate — that is the point. Both branches delegate to
|
|
70
|
+
* `decidePermission()` through its published wrappers, so "local" means here
|
|
71
|
+
* exactly what it means at the daemon's chokepoints. This function contributes
|
|
72
|
+
* only the mapping from a Verse seat to a subject the predicate understands:
|
|
73
|
+
*
|
|
74
|
+
* claude / codex / grok — a Verse engine id IS the registry engine id, so
|
|
75
|
+
* `enginePermitted` classifies it directly (cli-agent → cloud for claude and
|
|
76
|
+
* codex, api-model at api.x.ai → cloud for grok).
|
|
77
|
+
*
|
|
78
|
+
* local — a local seat is discovered from a serving runtime and has no
|
|
79
|
+
* registry engine to name, so the ENDPOINT it dispatches to is the truth
|
|
80
|
+
* and `endpointPermitted` classifies that. A loopback runtime (the default,
|
|
81
|
+
* and every local seat today) is permitted exactly as before. One repointed
|
|
82
|
+
* at a remote inference host is refused, because that one would spend — the
|
|
83
|
+
* same rule the raw-transport sites apply to `cfg.foundry.ollamaBaseUrl`.
|
|
84
|
+
*/
|
|
85
|
+
export function verseSeatPermitted(engine, launch, cfg) {
|
|
86
|
+
if (engine === 'local') {
|
|
87
|
+
const dispatchUrl = launch.anthropicBaseUrl?.trim() || launch.ollamaBaseUrl;
|
|
88
|
+
return endpointPermitted(dispatchUrl, cfg);
|
|
89
|
+
}
|
|
90
|
+
return enginePermitted(engine, cfg);
|
|
91
|
+
}
|
|
92
|
+
/** Read the live config for the gate. A config we cannot read is not an excuse to dispatch. */
|
|
93
|
+
function readConfigForPolicy() {
|
|
94
|
+
try {
|
|
95
|
+
return loadConfigReadOnly();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// undefined → the policy falls back to env + latch, which errs toward refusal.
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
40
103
|
// Env containment
|
|
41
104
|
// ---------------------------------------------------------------------------
|
|
42
105
|
const BASE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL'];
|
|
@@ -89,6 +152,19 @@ function isObject(value) {
|
|
|
89
152
|
function isStringArray(value) {
|
|
90
153
|
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
91
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
|
+
}
|
|
92
168
|
function isSeatLaunch(value) {
|
|
93
169
|
if (!isObject(value))
|
|
94
170
|
return false;
|
|
@@ -101,10 +177,17 @@ function isSeatLaunch(value) {
|
|
|
101
177
|
&& typeof value['ollamaBaseUrl'] === 'string'
|
|
102
178
|
// Absent is valid: that is every launch record written before the
|
|
103
179
|
// llama-server lane existed, and it means the Ollama lane.
|
|
104
|
-
&& (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']));
|
|
105
184
|
}
|
|
106
185
|
function cloneSession(session) {
|
|
107
|
-
return {
|
|
186
|
+
return {
|
|
187
|
+
...session,
|
|
188
|
+
usage: { ...session.usage },
|
|
189
|
+
...(session.handoffFrom ? { handoffFrom: { ...session.handoffFrom } } : {}),
|
|
190
|
+
};
|
|
108
191
|
}
|
|
109
192
|
function normaliseTitle(raw) {
|
|
110
193
|
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
@@ -116,14 +199,98 @@ function autoTitle(text) {
|
|
|
116
199
|
return DEFAULT_TITLE;
|
|
117
200
|
return firstLine.length > AUTO_TITLE_CHARS ? `${firstLine.slice(0, AUTO_TITLE_CHARS).trimEnd()}…` : firstLine;
|
|
118
201
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
};
|
|
127
294
|
}
|
|
128
295
|
function resolveProjectDir(projectPath) {
|
|
129
296
|
if (typeof projectPath !== 'string' || !projectPath.trim()) {
|
|
@@ -283,6 +450,9 @@ export function createVerseEngine(opts = {}) {
|
|
|
283
450
|
const now = opts.now ?? (() => new Date());
|
|
284
451
|
const turnTimeoutMs = opts.turnTimeoutMs ?? VERSE_TURN_TIMEOUT_MS;
|
|
285
452
|
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
453
|
+
const loadCfg = opts.loadConfig ?? readConfigForPolicy;
|
|
454
|
+
const adapterFor = opts.adapterFor ?? defaultAdapterFor;
|
|
455
|
+
const telemetryPollMs = positiveInt(opts.telemetryPollMs) ?? VERSE_TELEMETRY_POLL_MS;
|
|
286
456
|
const store = createVerseSessionStore(root);
|
|
287
457
|
const running = new Map();
|
|
288
458
|
const listeners = new Map();
|
|
@@ -296,8 +466,84 @@ export function createVerseEngine(opts = {}) {
|
|
|
296
466
|
const session = store.get(id);
|
|
297
467
|
if (!session)
|
|
298
468
|
throw new VerseError('VERSE_SESSION_NOT_FOUND', `session not found: ${id}`);
|
|
469
|
+
materializeLegacyMode(session);
|
|
299
470
|
return session;
|
|
300
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
|
+
}
|
|
301
547
|
function emit(id, event) {
|
|
302
548
|
const stored = store.appendEvent(id, event, nowIso());
|
|
303
549
|
const subs = listeners.get(id);
|
|
@@ -346,6 +592,7 @@ export function createVerseEngine(opts = {}) {
|
|
|
346
592
|
save(session);
|
|
347
593
|
}
|
|
348
594
|
for (const session of store.list()) {
|
|
595
|
+
materializeLegacyMode(session);
|
|
349
596
|
if (session.status === 'running')
|
|
350
597
|
reconcileInterrupted(session);
|
|
351
598
|
}
|
|
@@ -377,6 +624,9 @@ export function createVerseEngine(opts = {}) {
|
|
|
377
624
|
clearTimeout(timer);
|
|
378
625
|
turn[key] = null;
|
|
379
626
|
}
|
|
627
|
+
if (turn.pollTimer !== null)
|
|
628
|
+
clearInterval(turn.pollTimer);
|
|
629
|
+
turn.pollTimer = null;
|
|
380
630
|
}
|
|
381
631
|
function beginDrain(id, turn) {
|
|
382
632
|
if (turn.settled || turn.drainTimer !== null)
|
|
@@ -410,39 +660,262 @@ export function createVerseEngine(opts = {}) {
|
|
|
410
660
|
}, killGraceMs);
|
|
411
661
|
}
|
|
412
662
|
// ---- turn completion ----------------------------------------------------
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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)),
|
|
428
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 } : {}),
|
|
429
723
|
};
|
|
430
|
-
return { ...event, usage: u };
|
|
431
724
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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) {
|
|
436
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;
|
|
437
850
|
for (const event of events) {
|
|
851
|
+
if (untilSettled && turn.settled)
|
|
852
|
+
return;
|
|
438
853
|
if (event.type === 'error')
|
|
439
854
|
turn.sawError = true;
|
|
440
|
-
const enriched =
|
|
855
|
+
const enriched = applyUsage(session, turn.option, event, turn.turnId);
|
|
856
|
+
if (enriched === null)
|
|
857
|
+
continue;
|
|
441
858
|
emit(id, enriched);
|
|
442
|
-
|
|
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))
|
|
443
864
|
save(session);
|
|
444
865
|
}
|
|
445
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
|
+
}
|
|
446
919
|
function pushLine(id, turn, line) {
|
|
447
920
|
if (turn.settled)
|
|
448
921
|
return;
|
|
@@ -487,7 +960,23 @@ export function createVerseEngine(opts = {}) {
|
|
|
487
960
|
if (turn.termination !== null)
|
|
488
961
|
finished = finished.filter((event) => event.type !== 'error');
|
|
489
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');
|
|
490
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
|
+
}
|
|
491
980
|
let ok = exitCode === 0 && !turn.sawError;
|
|
492
981
|
let lastError = null;
|
|
493
982
|
// Stop is a normal action, not a failure: the session returns to idle
|
|
@@ -517,25 +1006,34 @@ export function createVerseEngine(opts = {}) {
|
|
|
517
1006
|
}
|
|
518
1007
|
}
|
|
519
1008
|
const nativeFromOutput = turn.parser.nativeSessionId();
|
|
520
|
-
const nativeSessionId = nativeFromOutput ?? session
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
// Count the turn once the CLI engaged: a resumed conversation exists on
|
|
532
|
-
// the vendor side even when the turn was cancelled part-way.
|
|
533
|
-
if (ok || turn.sawOutput)
|
|
534
|
-
session.turnCount += 1;
|
|
535
|
-
session.status = ok || stopped ? 'idle' : 'error';
|
|
536
|
-
session.lastError = ok || stopped ? null : (lastError ?? session.lastError ?? 'turn failed');
|
|
537
|
-
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
|
+
});
|
|
538
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) {
|
|
539
1037
|
turn.child.removeAllListeners();
|
|
540
1038
|
turn.child.on('error', () => { });
|
|
541
1039
|
turn.child.stdout?.removeAllListeners();
|
|
@@ -544,10 +1042,40 @@ export function createVerseEngine(opts = {}) {
|
|
|
544
1042
|
turn.child.unref();
|
|
545
1043
|
}
|
|
546
1044
|
// ---- spawn ----------------------------------------------------------------
|
|
547
|
-
function startTurn(session, turnId, launch, redactions) {
|
|
1045
|
+
function startTurn(session, turnId, seatLaunch, launch, redactions) {
|
|
548
1046
|
const id = session.id;
|
|
1047
|
+
// ---- LOCAL-ONLY: the last gate before a vendor process exists ----------
|
|
1048
|
+
//
|
|
1049
|
+
// The cockpit's Local-only panel tells the operator that nothing can spend
|
|
1050
|
+
// money while the mode is on. That claim is only true if the refusal lands
|
|
1051
|
+
// HERE — before the spawn — because an interactive turn bypasses
|
|
1052
|
+
// `run/engines.spawnEngine` and every gate the daemon path carries.
|
|
1053
|
+
//
|
|
1054
|
+
// Reported as an ordinary failed turn (`error` then `turn-done`) rather
|
|
1055
|
+
// than thrown: the refusal has to be legible in the session the person is
|
|
1056
|
+
// looking at. A silent no-op, or an exception escaping into the control
|
|
1057
|
+
// API, both read as a broken app rather than a policy doing its job. The
|
|
1058
|
+
// reason is the policy's OWN sentence, quoted verbatim — it already names
|
|
1059
|
+
// the seat, how the mode resolved, and how to turn it off.
|
|
1060
|
+
const verdict = verseSeatPermitted(session.engine, seatLaunch, loadCfg());
|
|
1061
|
+
if (!verdict.permitted) {
|
|
1062
|
+
const message = verdict.reason ?? 'local-only: refused';
|
|
1063
|
+
emit(id, { type: 'error', turnId, message });
|
|
1064
|
+
emit(id, {
|
|
1065
|
+
type: 'turn-done',
|
|
1066
|
+
turnId,
|
|
1067
|
+
ok: false,
|
|
1068
|
+
nativeSessionId: session.nativeSessionId,
|
|
1069
|
+
durationMs: 0,
|
|
1070
|
+
});
|
|
1071
|
+
session.status = 'error';
|
|
1072
|
+
session.lastError = message;
|
|
1073
|
+
save(session);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
549
1076
|
const adapter = adapterFor(session.engine);
|
|
550
1077
|
const parser = adapter.createParser(turnId);
|
|
1078
|
+
const option = effectiveModelOption(seatLaunch.seat, session.model, session.engine);
|
|
551
1079
|
const env = buildTurnEnv(launch.env);
|
|
552
1080
|
const [bin, ...args] = launch.argv;
|
|
553
1081
|
const detached = process.platform !== 'win32';
|
|
@@ -570,12 +1098,24 @@ export function createVerseEngine(opts = {}) {
|
|
|
570
1098
|
save(session);
|
|
571
1099
|
return;
|
|
572
1100
|
}
|
|
1101
|
+
const startedAt = Date.now();
|
|
573
1102
|
const turn = {
|
|
574
1103
|
turnId,
|
|
575
1104
|
child,
|
|
576
1105
|
pgid: detached && typeof child.pid === 'number' && child.pid > 0 ? child.pid : null,
|
|
577
|
-
startedAt
|
|
1106
|
+
startedAt,
|
|
578
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
|
+
},
|
|
579
1119
|
stdoutBuf: '',
|
|
580
1120
|
stderrTail: [],
|
|
581
1121
|
redactions,
|
|
@@ -586,6 +1126,7 @@ export function createVerseEngine(opts = {}) {
|
|
|
586
1126
|
timeoutTimer: null,
|
|
587
1127
|
escalationTimer: null,
|
|
588
1128
|
drainTimer: null,
|
|
1129
|
+
pollTimer: null,
|
|
589
1130
|
};
|
|
590
1131
|
running.set(id, turn);
|
|
591
1132
|
emit(id, { type: 'turn-started', turnId, pid: typeof child.pid === 'number' ? child.pid : null });
|
|
@@ -639,29 +1180,50 @@ export function createVerseEngine(opts = {}) {
|
|
|
639
1180
|
}, turnTimeoutMs);
|
|
640
1181
|
if (turn.timeoutTimer.unref)
|
|
641
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
|
+
}
|
|
642
1194
|
}
|
|
643
1195
|
// ---- handle -----------------------------------------------------------------
|
|
644
1196
|
return {
|
|
645
1197
|
listSessions() {
|
|
646
|
-
|
|
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
|
|
647
1204
|
.map(cloneSession)
|
|
648
1205
|
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
|
|
649
1206
|
},
|
|
650
1207
|
getSession(id) {
|
|
651
1208
|
const session = typeof id === 'string' ? store.get(id) : null;
|
|
652
|
-
|
|
1209
|
+
if (!session)
|
|
1210
|
+
return null;
|
|
1211
|
+
materializeLegacyMode(session);
|
|
1212
|
+
return cloneSession(session);
|
|
653
1213
|
},
|
|
654
1214
|
getEvents(id, fromSeq = 0) {
|
|
655
1215
|
require(id);
|
|
656
1216
|
return store.readEvents(id, fromSeq);
|
|
657
1217
|
},
|
|
658
|
-
createSession(req, launch) {
|
|
1218
|
+
createSession(req, launch, opts = {}) {
|
|
659
1219
|
if (closed)
|
|
660
1220
|
throw new VerseError('VERSE_INVALID', 'verse engine is closed');
|
|
661
1221
|
if (!isObject(req))
|
|
662
1222
|
throw new VerseError('VERSE_INVALID', 'request body must be an object');
|
|
663
1223
|
if (!isSeatLaunch(launch))
|
|
664
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');
|
|
665
1227
|
const projectPath = resolveProjectDir(req.projectPath);
|
|
666
1228
|
const extraRoots = resolveExtraRoots(req.extraRoots, projectPath);
|
|
667
1229
|
if (req.workspaceId !== undefined && typeof req.workspaceId !== 'string') {
|
|
@@ -675,15 +1237,58 @@ export function createVerseEngine(opts = {}) {
|
|
|
675
1237
|
if (engine !== 'local' && (!launch.launcher || launch.launcher.length === 0)) {
|
|
676
1238
|
throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no launcher`);
|
|
677
1239
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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`);
|
|
683
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);
|
|
684
1277
|
if (req.title !== undefined && typeof req.title !== 'string') {
|
|
685
1278
|
throw new VerseError('VERSE_INVALID', 'title must be a string');
|
|
686
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);
|
|
687
1292
|
const title = req.title ? normaliseTitle(req.title) : '';
|
|
688
1293
|
const at = nowIso();
|
|
689
1294
|
const session = {
|
|
@@ -715,9 +1320,18 @@ export function createVerseEngine(opts = {}) {
|
|
|
715
1320
|
cacheReadTokens: 0,
|
|
716
1321
|
cacheCreationTokens: 0,
|
|
717
1322
|
contextTokens: 0,
|
|
718
|
-
contextWindow:
|
|
1323
|
+
contextWindow: budget.contextWindow,
|
|
1324
|
+
contextWindowSource: budget.source,
|
|
1325
|
+
autoCompactAt: budget.autoCompactAt,
|
|
719
1326
|
},
|
|
720
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 } : {}),
|
|
721
1335
|
};
|
|
722
1336
|
store.save(session);
|
|
723
1337
|
store.saveLaunch(session.id, {
|
|
@@ -730,6 +1344,9 @@ export function createVerseEngine(opts = {}) {
|
|
|
730
1344
|
...(typeof launch.anthropicBaseUrl === 'string' && launch.anthropicBaseUrl.length > 0
|
|
731
1345
|
? { anthropicBaseUrl: launch.anthropicBaseUrl }
|
|
732
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 } : {}),
|
|
733
1350
|
});
|
|
734
1351
|
return cloneSession(session);
|
|
735
1352
|
},
|
|
@@ -752,6 +1369,13 @@ export function createVerseEngine(opts = {}) {
|
|
|
752
1369
|
if (!isSeatLaunch(launch)) {
|
|
753
1370
|
throw new VerseError('VERSE_INVALID', 'session launch record is missing or unreadable');
|
|
754
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
|
+
}
|
|
755
1379
|
const turnId = randomUUID();
|
|
756
1380
|
if (session.turnCount === 0 && session.title === DEFAULT_TITLE)
|
|
757
1381
|
session.title = autoTitle(text);
|
|
@@ -772,9 +1396,83 @@ export function createVerseEngine(opts = {}) {
|
|
|
772
1396
|
save(session);
|
|
773
1397
|
return { turnId, session: cloneSession(session) };
|
|
774
1398
|
}
|
|
775
|
-
startTurn(session, turnId, turnLaunch, redactionsFor(launch, turnLaunch));
|
|
1399
|
+
startTurn(session, turnId, launch, turnLaunch, redactionsFor(launch, turnLaunch));
|
|
776
1400
|
return { turnId, session: cloneSession(store.get(id) ?? session) };
|
|
777
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
|
+
},
|
|
778
1476
|
cancelTurn(id) {
|
|
779
1477
|
require(id);
|
|
780
1478
|
const turn = running.get(id);
|