@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
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* - GETs sit behind the read-session boundary in server.ts.
|
|
7
7
|
* - POSTs are 404 unless ctx.allowDispatch, then passesMutationGate()
|
|
8
8
|
* (constant-time x-ashlr-token + JSON Content-Type), then readBody()
|
|
9
|
-
* (64 KB cap).
|
|
9
|
+
* (64 KB cap; POST /memory alone gets VERSE_MEMORY_BODY_MAX_BYTES).
|
|
10
10
|
* - Every response goes through sendJson() → sanitizePublicJson().
|
|
11
11
|
*
|
|
12
|
-
* Routes (see docs/VERSE-CONTRACT-V1.md):
|
|
12
|
+
* Routes (see docs/VERSE-CONTRACT-V1.md, docs/VERSE-CONTEXT.md for V3.9):
|
|
13
13
|
* GET /api/verse/bootstrap → VerseBootstrap
|
|
14
14
|
* GET /api/verse/seats → VerseSeatsResponse (pollable)
|
|
15
15
|
* GET /api/verse/sessions → VerseSession[]
|
|
@@ -20,28 +20,57 @@
|
|
|
20
20
|
* POST /api/verse/sessions/:id/delete → { ok: true }
|
|
21
21
|
* POST /api/verse/sessions/:id/rename → VerseSession
|
|
22
22
|
* GET /api/verse/sessions/:id/events → SSE (verse-stream.ts)
|
|
23
|
+
* V3.9 context orchestration — every one of these is ZERO SPEND: none starts
|
|
24
|
+
* a model call; the only way a token is spent is still POST .../turns.
|
|
25
|
+
* POST /api/verse/sessions/:id/context-mode → VerseSession
|
|
26
|
+
* POST /api/verse/sessions/:id/handoff-preview → VerseHandoffPreview
|
|
27
|
+
* GET /api/verse/preferences → VersePreferences
|
|
28
|
+
* POST /api/verse/preferences → VersePreferences
|
|
29
|
+
* GET /api/verse/context-fit → VerseContextFit
|
|
30
|
+
* GET /api/verse/search → VerseSearchResponse
|
|
31
|
+
* GET /api/verse/memory → VerseProjectMemory (+ contentSanitized?)
|
|
32
|
+
* POST /api/verse/memory → VerseProjectMemory (+ contentSanitized?)
|
|
23
33
|
*
|
|
24
34
|
* Errors: { error, code? } with VERSE_SESSION_NOT_FOUND 404,
|
|
25
|
-
* VERSE_SESSION_BUSY 409, VERSE_INVALID 400, VERSE_TOO_LARGE 413
|
|
35
|
+
* VERSE_SESSION_BUSY 409, VERSE_INVALID 400, VERSE_TOO_LARGE 413, and two
|
|
36
|
+
* route-local 409s: VERSE_MODEL_UNAVAILABLE (turns: the session's model is
|
|
37
|
+
* listed but unrunnable on its seat) and VERSE_MEMORY_REDACTED (memory: the
|
|
38
|
+
* content would write redaction placeholders over real values).
|
|
39
|
+
*
|
|
40
|
+
* STRICT BODIES AND QUERIES (V3.9 routes and POST /sessions): an unknown body
|
|
41
|
+
* key or query parameter is a 400, never silently ignored — a misspelt
|
|
42
|
+
* `contextMode` would otherwise create a session in a mode nobody asked for.
|
|
26
43
|
*
|
|
27
44
|
* ENGINE LIFETIME: one engine handle per server process — a module-level
|
|
28
45
|
* lazy singleton (`getVerseEngine()`), with `resetVerseEngine()` for tests to
|
|
29
46
|
* inject a fake or force re-creation under a relocated HOME. The engine
|
|
30
47
|
* module is loaded lazily (dynamic import) so this file has no load-time
|
|
31
|
-
* dependency on it; tests that inject a fake never touch it.
|
|
48
|
+
* dependency on it; tests that inject a fake never touch it. The V3.9 service
|
|
49
|
+
* modules keep that property: they report bad input with their own
|
|
50
|
+
* `VerseServiceError` (same `code` vocabulary, mapped by duck type below)
|
|
51
|
+
* rather than importing the engine's `VerseError`.
|
|
32
52
|
*
|
|
33
53
|
* PRIVACY: seat launchers (the native-profile commands) come from seats.ts's
|
|
34
54
|
* private `launches` map and go straight to engine.createSession(). They are
|
|
35
55
|
* never part of any response, log line, or session record this file writes.
|
|
56
|
+
* The same holds for the shared-memory snapshot (`VerseCreateOptions.memory`):
|
|
57
|
+
* its directory and prompt block go into the private launch record only.
|
|
36
58
|
*/
|
|
37
59
|
import { homedir } from 'node:os';
|
|
38
60
|
import { join } from 'node:path';
|
|
39
61
|
import { passesMutationGate, readBody, sendJson } from '../web/api.js';
|
|
62
|
+
import { sanitizePublicJson } from '../util/public-json.js';
|
|
63
|
+
import { budgetFor, canonicalModelId, hasExpansiveMode } from './context-math.js';
|
|
64
|
+
import { estimateContextFit } from './context-fit.js';
|
|
40
65
|
import { checkWorkspaceRootPath, expandHomePrefix } from './path-guard.js';
|
|
66
|
+
import { loadVersePreferences, memoryEnabledFor, parseVersePreferencesUpdate, seatDefaultMode, updateVersePreferences, } from './preferences.js';
|
|
67
|
+
import { prepareProjectMemory, readProjectMemory, writeProjectMemory } from './project-memory.js';
|
|
41
68
|
import { discoverProjects } from './projects.js';
|
|
42
69
|
import { discoverSeats, refreshSeatTelemetry } from './seats.js';
|
|
70
|
+
import { buildHandoffPreview } from './session-handoff.js';
|
|
71
|
+
import { searchSessions } from './session-search.js';
|
|
43
72
|
import { buildAutonomyScopeView, createVerseWorkspaceStore, describeRoots, rootNotes, VerseWorkspaceError, } from './workspaces.js';
|
|
44
|
-
import { VERSE_MAX_TURN_TEXT_BYTES, VERSE_MAX_WORKSPACE_ROOTS, VERSE_ROOT_PRIORITIES, verseSessionRoots, } from './types.js';
|
|
73
|
+
import { VERSE_CONTEXT_MODES, VERSE_MAX_TURN_TEXT_BYTES, VERSE_MAX_WORKSPACE_ROOTS, VERSE_MEMORY_MAX_BYTES, VERSE_ROOT_PRIORITIES, verseSessionRoots, VERSE_MEMORY_BODY_MAX_BYTES, } from './types.js';
|
|
45
74
|
import { handleVerseEventsSse, VERSE_EVENTS_PATH_RE, VERSE_SESSION_ID_RE } from './verse-stream.js';
|
|
46
75
|
// ---------------------------------------------------------------------------
|
|
47
76
|
// Route matching
|
|
@@ -218,11 +247,15 @@ function sendInvalid(res, message) {
|
|
|
218
247
|
function isRecord(value) {
|
|
219
248
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
220
249
|
}
|
|
221
|
-
/**
|
|
222
|
-
|
|
250
|
+
/**
|
|
251
|
+
* readBody() + JSON.parse with the contract's error shape. Returns null after
|
|
252
|
+
* responding. `maxBytes` is omitted by every route but POST /memory, so each
|
|
253
|
+
* of them keeps readBody's shared 64 KB cap.
|
|
254
|
+
*/
|
|
255
|
+
async function readJsonBody(req, res, maxBytes) {
|
|
223
256
|
let raw;
|
|
224
257
|
try {
|
|
225
|
-
raw = await readBody(req);
|
|
258
|
+
raw = await readBody(req, maxBytes);
|
|
226
259
|
}
|
|
227
260
|
catch {
|
|
228
261
|
sendJson(res, 413, { code: 'VERSE_TOO_LARGE', error: 'request body too large' });
|
|
@@ -244,6 +277,80 @@ async function readJsonBody(req, res) {
|
|
|
244
277
|
}
|
|
245
278
|
const MAX_TITLE_CHARS = 200;
|
|
246
279
|
const MAX_PATH_CHARS = 4096;
|
|
280
|
+
/**
|
|
281
|
+
* Respond 400 and return true when `body` carries a key outside `allowed`.
|
|
282
|
+
*
|
|
283
|
+
* Unknown keys are refused rather than ignored for the same reason
|
|
284
|
+
* control-api.ts refuses them: a misspelt field (`contextmode`) that is
|
|
285
|
+
* silently dropped produces a request that "worked" and did something other
|
|
286
|
+
* than what the operator asked for.
|
|
287
|
+
*/
|
|
288
|
+
function rejectUnknownKeys(body, allowed, res) {
|
|
289
|
+
for (const key of Object.keys(body)) {
|
|
290
|
+
if (!allowed.has(key)) {
|
|
291
|
+
sendInvalid(res, `unknown key: ${key}`);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Parse the query string of a V3.9 GET under the same strictness as a body:
|
|
299
|
+
* an unknown parameter is a 400, and a parameter that is not `repeatable` may
|
|
300
|
+
* appear at most once (two `projectPath`s have no honest meaning). Returns
|
|
301
|
+
* null after responding.
|
|
302
|
+
*
|
|
303
|
+
* The read boundary's `?client=` proof never reaches these routes — only the
|
|
304
|
+
* SSE paths accept it (web/read-session.ts) — so no auth parameter needs to be
|
|
305
|
+
* tolerated here.
|
|
306
|
+
*/
|
|
307
|
+
function readQuery(req, res, allowed, repeatable = []) {
|
|
308
|
+
let params;
|
|
309
|
+
try {
|
|
310
|
+
params = new URL(req.url ?? '/', 'http://localhost').searchParams;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
sendInvalid(res, 'invalid query string');
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
for (const key of new Set(params.keys())) {
|
|
317
|
+
if (!allowed.includes(key)) {
|
|
318
|
+
sendInvalid(res, `unknown query parameter: ${key}`);
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
if (!repeatable.includes(key) && params.getAll(key).length > 1) {
|
|
322
|
+
sendInvalid(res, `query parameter ${key} may appear only once`);
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return params;
|
|
327
|
+
}
|
|
328
|
+
function isContextMode(value) {
|
|
329
|
+
return typeof value === 'string' && VERSE_CONTEXT_MODES.includes(value);
|
|
330
|
+
}
|
|
331
|
+
const CONTEXT_MODE_LIST = VERSE_CONTEXT_MODES.join(', ');
|
|
332
|
+
/**
|
|
333
|
+
* Validate one project/root path with EXACTLY the rule POST /sessions uses
|
|
334
|
+
* (`checkWorkspaceRootPath`: absolute, an existing directory, never `/`,
|
|
335
|
+
* `$HOME`, `~/.ashlr` or `~/.codex/artifacts`, lexically or through a
|
|
336
|
+
* symlink). Every V3.9 route that takes a path goes through here, so a path
|
|
337
|
+
* that could never be a session root can never be measured, remembered or
|
|
338
|
+
* opted out either.
|
|
339
|
+
*
|
|
340
|
+
* `path` is the `~`-expanded spelling the caller sent (what the engine would
|
|
341
|
+
* receive); `physical` is the resolved one the memory and preference stores
|
|
342
|
+
* key on, so `/tmp/x` and `/private/tmp/x` are one project.
|
|
343
|
+
*/
|
|
344
|
+
function guardRoot(raw, field) {
|
|
345
|
+
if (typeof raw !== 'string' || raw.trim().length === 0 || raw.length > MAX_PATH_CHARS) {
|
|
346
|
+
return { ok: false, error: `${field} is required` };
|
|
347
|
+
}
|
|
348
|
+
const trimmed = raw.trim();
|
|
349
|
+
const check = checkWorkspaceRootPath(trimmed);
|
|
350
|
+
if (!check.ok)
|
|
351
|
+
return { ok: false, error: check.error };
|
|
352
|
+
return { ok: true, path: expandHomePrefix(trimmed), physical: check.path };
|
|
353
|
+
}
|
|
247
354
|
/**
|
|
248
355
|
* sanitizePublicJson() rewrites the home directory as `~` on every outbound
|
|
249
356
|
* payload, so a project path the UI read from bootstrap comes back as
|
|
@@ -275,12 +382,54 @@ function parseSeatFields(body, res) {
|
|
|
275
382
|
return null;
|
|
276
383
|
}
|
|
277
384
|
const out = { seatId: seatId.trim() };
|
|
385
|
+
// Canonicalised, never refused, when it is a retired alias: a client that
|
|
386
|
+
// remembered `claude-opus-5.5` from an older session (the per-project seat
|
|
387
|
+
// memory does exactly that) means the model the label promised, and the
|
|
388
|
+
// canonical id is what the seat catalog lists — and what the CLI resolves.
|
|
278
389
|
if (typeof model === 'string')
|
|
279
|
-
out.model = model;
|
|
390
|
+
out.model = canonicalModelId(model);
|
|
280
391
|
if (typeof title === 'string' && title.trim().length > 0)
|
|
281
392
|
out.title = title.trim();
|
|
282
393
|
return out;
|
|
283
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Keys POST /api/verse/sessions accepts. `workspaceName` is deliberately
|
|
397
|
+
* absent: it is SERVER-FILLED from the registry (types.ts), and is refused
|
|
398
|
+
* with its own message below rather than as an unknown key.
|
|
399
|
+
*/
|
|
400
|
+
const CREATE_SESSION_KEYS = new Set([
|
|
401
|
+
'projectPath',
|
|
402
|
+
'seatId',
|
|
403
|
+
'model',
|
|
404
|
+
'title',
|
|
405
|
+
'extraRoots',
|
|
406
|
+
'workspaceId',
|
|
407
|
+
'contextMode',
|
|
408
|
+
'handoffFromSessionId',
|
|
409
|
+
]);
|
|
410
|
+
/**
|
|
411
|
+
* The V3.9 half of a create request: an EXPLICIT context mode and the handoff
|
|
412
|
+
* source id. Both are only shape-checked here — whether the mode exists for
|
|
413
|
+
* the chosen model, and whether the source session exists, are decided once
|
|
414
|
+
* the seat and engine are in hand (`resolveCreation`). Returns null after
|
|
415
|
+
* responding.
|
|
416
|
+
*/
|
|
417
|
+
function parseContextFields(body, res) {
|
|
418
|
+
const contextMode = body['contextMode'];
|
|
419
|
+
const handoffFrom = body['handoffFromSessionId'];
|
|
420
|
+
if (contextMode !== undefined && !isContextMode(contextMode)) {
|
|
421
|
+
sendInvalid(res, `contextMode must be one of: ${CONTEXT_MODE_LIST}`);
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
if (handoffFrom !== undefined && (typeof handoffFrom !== 'string' || !VERSE_SESSION_ID_RE.test(handoffFrom))) {
|
|
425
|
+
sendInvalid(res, 'handoffFromSessionId must be a session id');
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
...(contextMode !== undefined ? { contextMode } : {}),
|
|
430
|
+
...(typeof handoffFrom === 'string' ? { handoffFromSessionId: handoffFrom } : {}),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
284
433
|
/**
|
|
285
434
|
* Parse a create request in either of its two spellings.
|
|
286
435
|
*
|
|
@@ -294,9 +443,18 @@ function parseSeatFields(body, res) {
|
|
|
294
443
|
* KILL sentinel, the 0600 launcher records) can never become a session root.
|
|
295
444
|
*/
|
|
296
445
|
function parseCreateRequest(body, res, store) {
|
|
446
|
+
if (body['workspaceName'] !== undefined) {
|
|
447
|
+
sendInvalid(res, 'workspaceName is filled in by the server from workspaceId; do not send it');
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
if (rejectUnknownKeys(body, CREATE_SESSION_KEYS, res))
|
|
451
|
+
return null;
|
|
297
452
|
const seatFields = parseSeatFields(body, res);
|
|
298
453
|
if (!seatFields)
|
|
299
454
|
return null;
|
|
455
|
+
const contextFields = parseContextFields(body, res);
|
|
456
|
+
if (!contextFields)
|
|
457
|
+
return null;
|
|
300
458
|
const workspaceId = body['workspaceId'];
|
|
301
459
|
const projectPath = body['projectPath'];
|
|
302
460
|
const extraRoots = body['extraRoots'];
|
|
@@ -327,6 +485,7 @@ function parseCreateRequest(body, res, store) {
|
|
|
327
485
|
// Read from the registry, never from the body.
|
|
328
486
|
workspaceName: workspace.name,
|
|
329
487
|
...seatFields,
|
|
488
|
+
...contextFields,
|
|
330
489
|
};
|
|
331
490
|
}
|
|
332
491
|
if (typeof projectPath !== 'string' || projectPath.trim().length === 0 || projectPath.length > MAX_PATH_CHARS) {
|
|
@@ -380,6 +539,103 @@ function parseCreateRequest(body, res, store) {
|
|
|
380
539
|
projectPath: primary,
|
|
381
540
|
...(resolvedExtras.length > 0 ? { extraRoots: resolvedExtras } : {}),
|
|
382
541
|
...seatFields,
|
|
542
|
+
...contextFields,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Everything POST /sessions resolves SERVER-SIDE before the engine sees the
|
|
547
|
+
* request: the context mode (explicit, else the seat's preference), the
|
|
548
|
+
* handoff provenance, and the shared-memory snapshot. Returns null after
|
|
549
|
+
* responding.
|
|
550
|
+
*
|
|
551
|
+
* ORDER MATTERS. Every refusal below happens BEFORE `prepareProjectMemory`,
|
|
552
|
+
* which creates the project's private memory directory — a request that is
|
|
553
|
+
* going to be refused must not leave one behind. The engine re-validates the
|
|
554
|
+
* model and mode (it is the authority and also serves non-HTTP callers); these
|
|
555
|
+
* checks exist so the refusal comes first and carries a precise message.
|
|
556
|
+
*/
|
|
557
|
+
function resolveCreation(create, launch, engine, res) {
|
|
558
|
+
const seat = launch.seat;
|
|
559
|
+
// Never forwarded: the engine takes provenance from `options.handoffFrom`,
|
|
560
|
+
// which is resolved from the store below, never from the body.
|
|
561
|
+
const { handoffFromSessionId, ...request } = create;
|
|
562
|
+
// 1. Handoff source — the TITLE is read from the store, so a session can
|
|
563
|
+
// never be labelled "Continued from <anything the client chose>".
|
|
564
|
+
let handoffFrom = null;
|
|
565
|
+
if (handoffFromSessionId !== undefined) {
|
|
566
|
+
const source = engine.getSession(handoffFromSessionId);
|
|
567
|
+
if (!source) {
|
|
568
|
+
sendInvalid(res, `handoff source session not found: ${handoffFromSessionId}`);
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
handoffFrom = { sessionId: source.id, title: source.title };
|
|
572
|
+
}
|
|
573
|
+
// 2. The model this session will run: the requested one, else the seat's
|
|
574
|
+
// first RUNNABLE model — the same default the engine applies, so a
|
|
575
|
+
// listed-but-unavailable model (one the pinned CLI is too old for) is
|
|
576
|
+
// never picked silently.
|
|
577
|
+
if (seat.models.length === 0) {
|
|
578
|
+
sendInvalid(res, `seat ${seat.id} has no models`);
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
const modelId = request.model ?? seat.models.find((m) => !m.unavailableReason?.trim())?.id;
|
|
582
|
+
if (!modelId) {
|
|
583
|
+
sendInvalid(res, `seat ${seat.id} has no runnable models`);
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
const option = seat.models.find((m) => m.id === modelId);
|
|
587
|
+
if (!option) {
|
|
588
|
+
sendInvalid(res, `model ${modelId} is not available on seat ${seat.id}`);
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
if (typeof option.unavailableReason === 'string' && option.unavailableReason.trim().length > 0) {
|
|
592
|
+
sendInvalid(res, `model ${modelId} cannot run on seat ${seat.id}: ${option.unavailableReason.trim()}`);
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
// 3. Context mode. An EXPLICIT mode the model has no budget for is refused;
|
|
596
|
+
// a PREFERRED one is only a default, so it quietly yields to `standard`
|
|
597
|
+
// rather than failing a request that never named a mode. The mode is
|
|
598
|
+
// ALWAYS passed explicitly, `standard` included, so every 3.9 record
|
|
599
|
+
// states its mode and an ABSENT `contextMode` reliably means a pre-3.9
|
|
600
|
+
// record (a legacy Claude session ran at the CLI's native window, which
|
|
601
|
+
// the engine preserves as `expansive`). (Loading preferences is total —
|
|
602
|
+
// a missing or mangled file reads as defaults.)
|
|
603
|
+
const prefs = loadVersePreferences();
|
|
604
|
+
if (request.contextMode !== undefined) {
|
|
605
|
+
if (request.contextMode !== 'standard' && budgetFor(option, request.contextMode) === null) {
|
|
606
|
+
sendInvalid(res, `model ${modelId} has no ${request.contextMode} context mode on seat ${seat.id}`);
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
const preferred = seatDefaultMode(prefs, seat.id);
|
|
612
|
+
request.contextMode = preferred !== 'standard' && budgetFor(option, preferred) !== null ? preferred : 'standard';
|
|
613
|
+
}
|
|
614
|
+
// 4. Shared project memory, snapshotted now and pinned for the session's
|
|
615
|
+
// life. Grok can reach only its `--cwd`, so it is offered the memory
|
|
616
|
+
// read-only (the block says so); every other engine can be granted the
|
|
617
|
+
// directory. A failure to prepare it never blocks the chat.
|
|
618
|
+
//
|
|
619
|
+
// `memory` is ALWAYS passed: a snapshot, or `null` for "decided off"
|
|
620
|
+
// (disabled by preference, or it could not be prepared). The engine
|
|
621
|
+
// records `null` as `memoryEnabled: false`, so the record says exactly
|
|
622
|
+
// what the session was given rather than leaving it unknown — absent is
|
|
623
|
+
// reserved for callers that never decided (pre-3.9 records).
|
|
624
|
+
let memory = null;
|
|
625
|
+
if (memoryEnabledFor(prefs, request.projectPath)) {
|
|
626
|
+
try {
|
|
627
|
+
memory = prepareProjectMemory(request.projectPath, { writable: seat.engine !== 'grok' });
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
memory = null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
request,
|
|
635
|
+
options: {
|
|
636
|
+
memory,
|
|
637
|
+
...(handoffFrom ? { handoffFrom } : {}),
|
|
638
|
+
},
|
|
383
639
|
};
|
|
384
640
|
}
|
|
385
641
|
// ---------------------------------------------------------------------------
|
|
@@ -539,6 +795,368 @@ async function handleWorkspaces(ctx, req, res, path, method) {
|
|
|
539
795
|
return sendWorkspaceError(res, err);
|
|
540
796
|
}
|
|
541
797
|
}
|
|
798
|
+
const CONTEXT_ROUTE_METHODS = {
|
|
799
|
+
preferences: ['GET', 'POST'],
|
|
800
|
+
'context-fit': ['GET'],
|
|
801
|
+
search: ['GET'],
|
|
802
|
+
memory: ['GET', 'POST'],
|
|
803
|
+
};
|
|
804
|
+
function contextRouteOf(path) {
|
|
805
|
+
const segment = path.slice(`${VERSE_API_PREFIX}/`.length);
|
|
806
|
+
return Object.prototype.hasOwnProperty.call(CONTEXT_ROUTE_METHODS, segment) ? segment : null;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Every key any `VersePreferencesUpdate` form may carry. Which COMBINATION is
|
|
810
|
+
* valid is decided by preferences.ts's own parser, so the API and the store
|
|
811
|
+
* can never disagree about what a well-formed update is.
|
|
812
|
+
*/
|
|
813
|
+
const PREFERENCES_KEYS = new Set(['seatId', 'contextMode', 'memoryEnabled', 'projectPath']);
|
|
814
|
+
const MEMORY_WRITE_KEYS = new Set(['projectPath', 'content']);
|
|
815
|
+
const CONTEXT_MODE_KEYS = new Set(['mode']);
|
|
816
|
+
const HANDOFF_PREVIEW_KEYS = new Set(['includeLastAssistant', 'focus']);
|
|
817
|
+
/** Every POST /api/verse/sessions/:id/<action>; anything else is a 404. */
|
|
818
|
+
const SESSION_POST_ACTIONS = new Set([
|
|
819
|
+
'turns',
|
|
820
|
+
'cancel',
|
|
821
|
+
'delete',
|
|
822
|
+
'rename',
|
|
823
|
+
'context-mode',
|
|
824
|
+
'handoff-preview',
|
|
825
|
+
]);
|
|
826
|
+
/**
|
|
827
|
+
* The gate every POST in this file sits behind, in the same order: 404 unless
|
|
828
|
+
* the server allows dispatch, then the constant-time mutation token + JSON
|
|
829
|
+
* Content-Type, then the body cap (64 KB unless `maxBytes` raises it for the
|
|
830
|
+
* one route that needs more). Null after responding.
|
|
831
|
+
*/
|
|
832
|
+
async function readMutationBody(ctx, req, res, maxBytes) {
|
|
833
|
+
if (!ctx.allowDispatch) {
|
|
834
|
+
sendJson(res, 404, { error: 'not found' });
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
if (!passesMutationGate(req, res, ctx.token))
|
|
838
|
+
return null;
|
|
839
|
+
return readJsonBody(req, res, maxBytes);
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* The body cap for POST /api/verse/memory ONLY.
|
|
843
|
+
*
|
|
844
|
+
* The memory file may be VERSE_MEMORY_MAX_BYTES of content, and JSON escaping
|
|
845
|
+
* can roughly double that (every `"`, `\` and newline becomes two bytes; a
|
|
846
|
+
* control character becomes six, but a memory file is prose). Under the shared
|
|
847
|
+
* 65,536-byte cap a file anywhere near the documented limit could never be
|
|
848
|
+
* saved: 64 KiB of content made a 65,711-byte body. 2× plus 8 KiB for the
|
|
849
|
+
* envelope (`projectPath`, keys) admits any realistic file at the limit while
|
|
850
|
+
* staying a bounded, small read. The CONTENT itself is still held to
|
|
851
|
+
* VERSE_MEMORY_MAX_BYTES (413) after parsing.
|
|
852
|
+
*/
|
|
853
|
+
export { VERSE_MEMORY_BODY_MAX_BYTES }; // defined in types.ts so the browser mirrors it exactly
|
|
854
|
+
// ── Memory round-trip honesty ─────────────────────────────────────────────
|
|
855
|
+
//
|
|
856
|
+
// Every response goes through sendJson() → sanitizePublicJson(), which rewrites
|
|
857
|
+
// the home directory as `~` and replaces secret-shaped strings with
|
|
858
|
+
// scrubSecrets()' marker. For most payloads that is pure hygiene; for
|
|
859
|
+
// `VerseProjectMemory.content` it is a lossy view of a file the operator can
|
|
860
|
+
// EDIT AND SAVE BACK. The sanitizer has no per-field opt-out, and adding one
|
|
861
|
+
// would mean serving raw secrets to the browser, so the view stays sanitized
|
|
862
|
+
// and the API is honest about it instead:
|
|
863
|
+
//
|
|
864
|
+
// - GET/POST responses carry `contentSanitized: true` whenever the content
|
|
865
|
+
// the client receives differs from the bytes on disk (absent otherwise, so
|
|
866
|
+
// the wire shape is unchanged for the common case). The editor can say so
|
|
867
|
+
// before the operator saves.
|
|
868
|
+
// - POST refuses (409 VERSE_MEMORY_REDACTED) content with MORE redaction
|
|
869
|
+
// markers than the file on disk — i.e. a save that would overwrite real
|
|
870
|
+
// values with placeholders. The `~` rewrite is NOT refused: `~/x` still
|
|
871
|
+
// names the same path for every reader of MEMORY.md, the flag discloses it,
|
|
872
|
+
// and refusing it would make any memory that mentions a home path unsavable.
|
|
873
|
+
/** The placeholder scrubSecrets() (src/core/util/scrub.ts) writes in every rule. */
|
|
874
|
+
const SCRUB_REDACTION_MARKER = '[REDACTED]';
|
|
875
|
+
function countRedactionMarkers(text) {
|
|
876
|
+
return text.split(SCRUB_REDACTION_MARKER).length - 1;
|
|
877
|
+
}
|
|
878
|
+
function flagSanitizedMemory(memory) {
|
|
879
|
+
// The exact transform sendJson() is about to apply, so the flag is true iff
|
|
880
|
+
// the client will receive something other than the file's bytes. Spread
|
|
881
|
+
// only when set: an unsanitized memory keeps the pre-flag response shape.
|
|
882
|
+
return sanitizePublicJson(memory.content) === memory.content ? memory : { ...memory, contentSanitized: true };
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* `/api/verse/{preferences,context-fit,search,memory}`.
|
|
886
|
+
*
|
|
887
|
+
* NOTHING HERE SPENDS. Preferences and memory are private files under
|
|
888
|
+
* ~/.ashlr/verse; context-fit runs `git ls-files` and stats; search reads the
|
|
889
|
+
* durable event logs. None of them reaches a model, so none of them needs —
|
|
890
|
+
* or bypasses — the local-only gate, which stays exactly where it was: at the
|
|
891
|
+
* top of the engine's `startTurn`.
|
|
892
|
+
*
|
|
893
|
+
* Service modules report bad input as `VerseServiceError` (VERSE_INVALID →
|
|
894
|
+
* 400, VERSE_TOO_LARGE → 413); those propagate to `handleVerseApi`'s catch
|
|
895
|
+
* and reach the client through its shared, duck-typed `sendVerseError`.
|
|
896
|
+
*/
|
|
897
|
+
async function handleContextRoutes(ctx, req, res, path, method, route) {
|
|
898
|
+
if (!CONTEXT_ROUTE_METHODS[route].includes(method)) {
|
|
899
|
+
sendJson(res, 404, { error: `not found: ${method} ${path}` });
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
switch (route) {
|
|
903
|
+
// ── /api/verse/preferences ───────────────────────────────────────────
|
|
904
|
+
case 'preferences': {
|
|
905
|
+
if (method === 'GET') {
|
|
906
|
+
if (!readQuery(req, res, []))
|
|
907
|
+
return true;
|
|
908
|
+
const body = loadVersePreferences();
|
|
909
|
+
sendJson(res, 200, body);
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
const body = await readMutationBody(ctx, req, res);
|
|
913
|
+
if (!body)
|
|
914
|
+
return true;
|
|
915
|
+
if (rejectUnknownKeys(body, PREFERENCES_KEYS, res))
|
|
916
|
+
return true;
|
|
917
|
+
// Exactly one form, every value typed; throws VERSE_INVALID (400).
|
|
918
|
+
parseVersePreferencesUpdate(body);
|
|
919
|
+
let update = body;
|
|
920
|
+
if ('seatId' in update) {
|
|
921
|
+
// Setting a NON-default mode must name a seat that exists and has at
|
|
922
|
+
// least one runnable model with a real budget for it — otherwise the
|
|
923
|
+
// preference could never take effect and the dialog would show a
|
|
924
|
+
// default the server silently ignores. Resetting to `standard` only
|
|
925
|
+
// removes an entry, so it is allowed for a seat that is not currently
|
|
926
|
+
// discovered (a local seat while Ollama is down).
|
|
927
|
+
const { seatId, contextMode } = update;
|
|
928
|
+
if (contextMode !== 'standard') {
|
|
929
|
+
const discovery = await cachedSeats(ctx.cfg);
|
|
930
|
+
const seat = discovery.seats.find((s) => s.id === seatId);
|
|
931
|
+
if (!seat) {
|
|
932
|
+
sendInvalid(res, `unknown seat: ${seatId}`);
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
const supports = seat.models.some((m) => !m.unavailableReason && budgetFor(m, contextMode) !== null
|
|
936
|
+
&& (contextMode !== 'expansive' || hasExpansiveMode(m)));
|
|
937
|
+
if (!supports) {
|
|
938
|
+
sendInvalid(res, `seat ${seat.id} has no model with a ${contextMode} context mode`);
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
else if ('projectPath' in update) {
|
|
944
|
+
// The same path rule a session root obeys, and the PHYSICAL spelling
|
|
945
|
+
// the store keys opt-outs on.
|
|
946
|
+
const guard = guardRoot(update.projectPath, 'projectPath');
|
|
947
|
+
if (!guard.ok) {
|
|
948
|
+
sendInvalid(res, guard.error);
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
update = { projectPath: guard.physical, memoryEnabled: update.memoryEnabled };
|
|
952
|
+
}
|
|
953
|
+
const stored = updateVersePreferences(update);
|
|
954
|
+
sendJson(res, 200, stored);
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
// ── GET /api/verse/context-fit ───────────────────────────────────────
|
|
958
|
+
// ?workspaceId=<id> | ?projectPath=<abs>[&extraRoots=<abs>]…
|
|
959
|
+
// `extraRoots` REPEATS rather than being comma-joined: a comma is legal
|
|
960
|
+
// in a path, a repeated parameter is unambiguous.
|
|
961
|
+
case 'context-fit': {
|
|
962
|
+
const params = readQuery(req, res, ['workspaceId', 'projectPath', 'extraRoots'], ['extraRoots']);
|
|
963
|
+
if (!params)
|
|
964
|
+
return true;
|
|
965
|
+
const workspaceId = params.get('workspaceId');
|
|
966
|
+
const projectPath = params.get('projectPath');
|
|
967
|
+
const extraRoots = params.getAll('extraRoots');
|
|
968
|
+
let candidates;
|
|
969
|
+
if (workspaceId !== null) {
|
|
970
|
+
// Never mixed — the same rule POST /sessions applies, for the same
|
|
971
|
+
// reason: the roots measured must be the roots the operator named.
|
|
972
|
+
if (projectPath !== null || extraRoots.length > 0) {
|
|
973
|
+
sendInvalid(res, 'send workspaceId or projectPath/extraRoots, not both');
|
|
974
|
+
return true;
|
|
975
|
+
}
|
|
976
|
+
const id = workspaceId.trim();
|
|
977
|
+
if (id.length === 0) {
|
|
978
|
+
sendInvalid(res, 'workspaceId must be a non-empty string');
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
const workspace = getVerseWorkspaceStore().get(id);
|
|
982
|
+
if (!workspace) {
|
|
983
|
+
sendInvalid(res, `unknown workspace: ${id}`);
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
986
|
+
const primary = workspace.roots.find((r) => r.primary) ?? workspace.roots[0];
|
|
987
|
+
if (!primary) {
|
|
988
|
+
sendInvalid(res, `workspace ${workspace.name} has no roots`);
|
|
989
|
+
return true;
|
|
990
|
+
}
|
|
991
|
+
candidates = [primary.path, ...workspace.roots.filter((r) => r.path !== primary.path).map((r) => r.path)];
|
|
992
|
+
}
|
|
993
|
+
else {
|
|
994
|
+
if (projectPath === null) {
|
|
995
|
+
sendInvalid(res, 'workspaceId or projectPath is required');
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
if (extraRoots.length > VERSE_MAX_WORKSPACE_ROOTS - 1) {
|
|
999
|
+
sendInvalid(res, `a session may have at most ${VERSE_MAX_WORKSPACE_ROOTS} roots`);
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
1002
|
+
candidates = [projectPath, ...extraRoots];
|
|
1003
|
+
}
|
|
1004
|
+
// Workspace roots are re-checked too: they were valid when saved, but a
|
|
1005
|
+
// root deleted since would otherwise be measured as zero tokens and the
|
|
1006
|
+
// badge would say "fits" about code that is not there. Duplicates (a
|
|
1007
|
+
// symlink and its target) are measured once.
|
|
1008
|
+
const roots = [];
|
|
1009
|
+
const seen = new Set();
|
|
1010
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
1011
|
+
const guard = guardRoot(candidate, index === 0 ? 'projectPath' : 'extraRoots entry');
|
|
1012
|
+
if (!guard.ok) {
|
|
1013
|
+
sendInvalid(res, guard.error);
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
if (seen.has(guard.physical))
|
|
1017
|
+
continue;
|
|
1018
|
+
seen.add(guard.physical);
|
|
1019
|
+
roots.push(guard.path);
|
|
1020
|
+
}
|
|
1021
|
+
const fit = await estimateContextFit(roots);
|
|
1022
|
+
sendJson(res, 200, fit);
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
// ── GET /api/verse/search?q=&limit= ──────────────────────────────────
|
|
1026
|
+
case 'search': {
|
|
1027
|
+
const params = readQuery(req, res, ['q', 'limit']);
|
|
1028
|
+
if (!params)
|
|
1029
|
+
return true;
|
|
1030
|
+
const q = params.get('q');
|
|
1031
|
+
if (q === null || q.trim().length === 0) {
|
|
1032
|
+
sendInvalid(res, 'q is required');
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
const rawLimit = params.get('limit');
|
|
1036
|
+
let limit;
|
|
1037
|
+
if (rawLimit !== null) {
|
|
1038
|
+
// Positive integer or 400, then CLAMPED by the service to its maximum
|
|
1039
|
+
// — the same convention GET /api/verse/audit follows.
|
|
1040
|
+
if (!/^\d{1,9}$/.test(rawLimit) || Number(rawLimit) < 1) {
|
|
1041
|
+
sendInvalid(res, 'limit must be a positive integer');
|
|
1042
|
+
return true;
|
|
1043
|
+
}
|
|
1044
|
+
limit = Number(rawLimit);
|
|
1045
|
+
}
|
|
1046
|
+
const engine = await getVerseEngine();
|
|
1047
|
+
const result = searchSessions({
|
|
1048
|
+
sessions: engine.listSessions(),
|
|
1049
|
+
readEvents: (id) => engine.getEvents(id),
|
|
1050
|
+
query: q.trim(),
|
|
1051
|
+
...(limit !== undefined ? { limit } : {}),
|
|
1052
|
+
});
|
|
1053
|
+
sendJson(res, 200, result);
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
// ── /api/verse/memory ────────────────────────────────────────────────
|
|
1057
|
+
case 'memory': {
|
|
1058
|
+
if (method === 'GET') {
|
|
1059
|
+
const params = readQuery(req, res, ['projectPath']);
|
|
1060
|
+
if (!params)
|
|
1061
|
+
return true;
|
|
1062
|
+
const guard = guardRoot(params.get('projectPath'), 'projectPath');
|
|
1063
|
+
if (!guard.ok) {
|
|
1064
|
+
sendInvalid(res, guard.error);
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
// `enabled` is what a NEW session on this project would be offered;
|
|
1068
|
+
// sessions already running keep what they were created with.
|
|
1069
|
+
const memory = readProjectMemory(guard.physical, memoryEnabledFor(loadVersePreferences(), guard.physical));
|
|
1070
|
+
sendJson(res, 200, flagSanitizedMemory(memory));
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
const body = await readMutationBody(ctx, req, res, VERSE_MEMORY_BODY_MAX_BYTES);
|
|
1074
|
+
if (!body)
|
|
1075
|
+
return true;
|
|
1076
|
+
if (rejectUnknownKeys(body, MEMORY_WRITE_KEYS, res))
|
|
1077
|
+
return true;
|
|
1078
|
+
const guard = guardRoot(body['projectPath'], 'projectPath');
|
|
1079
|
+
if (!guard.ok) {
|
|
1080
|
+
sendInvalid(res, guard.error);
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
const content = body['content'];
|
|
1084
|
+
if (typeof content !== 'string') {
|
|
1085
|
+
sendInvalid(res, 'content must be a string ("" clears the memory)');
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
if (Buffer.byteLength(content, 'utf8') > VERSE_MEMORY_MAX_BYTES) {
|
|
1089
|
+
sendJson(res, 413, { code: 'VERSE_TOO_LARGE', error: `content exceeds ${VERSE_MEMORY_MAX_BYTES} bytes` });
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
// A save must not write the sanitizer's placeholders over the secrets
|
|
1093
|
+
// they stand for. Only an INCREASE is refused: a file that already holds
|
|
1094
|
+
// the literal marker (an agent quoting a scrubbed log) stays editable.
|
|
1095
|
+
const onDisk = readProjectMemory(guard.physical, false).content;
|
|
1096
|
+
if (countRedactionMarkers(content) > countRedactionMarkers(onDisk)) {
|
|
1097
|
+
sendJson(res, 409, {
|
|
1098
|
+
code: 'VERSE_MEMORY_REDACTED',
|
|
1099
|
+
error: `content contains ${SCRUB_REDACTION_MARKER} placeholders that stand in for secret-looking text in MEMORY.md; `
|
|
1100
|
+
+ 'saving would replace the real values. Remove the placeholders, or edit MEMORY.md directly.',
|
|
1101
|
+
});
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
// Allowed whether or not memory is enabled for the project: it is the
|
|
1105
|
+
// operator's own file, and clearing it must always work.
|
|
1106
|
+
const written = writeProjectMemory(guard.physical, content);
|
|
1107
|
+
sendJson(res, 200, flagSanitizedMemory(written));
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
1110
|
+
default: {
|
|
1111
|
+
const never = route;
|
|
1112
|
+
sendJson(res, 404, { error: `not found: ${method} ${String(never)}` });
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
// ---------------------------------------------------------------------------
|
|
1118
|
+
// Turn-time model check
|
|
1119
|
+
// ---------------------------------------------------------------------------
|
|
1120
|
+
/**
|
|
1121
|
+
* The session's model as its seat lists it RIGHT NOW: `reason` is why it
|
|
1122
|
+
* cannot run (null lets the turn proceed), `option` the live catalog entry
|
|
1123
|
+
* (null when discovery failed, the seat is gone or the model is not listed).
|
|
1124
|
+
*
|
|
1125
|
+
* Create refuses an unavailable model, but a session outlives the check: a
|
|
1126
|
+
* chat created on `claude-opus-5.5` before its seat was pinned to a CLI that
|
|
1127
|
+
* predates the model (2.1.257 < 2.1.280) is stored with that id and would
|
|
1128
|
+
* otherwise start a CLI that rejects it — a spawned process and a confusing
|
|
1129
|
+
* native error instead of the seat's own one-line reason. The model id is
|
|
1130
|
+
* canonicalised first, so a retired dotted alias finds its option.
|
|
1131
|
+
*
|
|
1132
|
+
* Deliberately permissive: only a CURRENT seat that LISTS the model with a
|
|
1133
|
+
* non-empty `unavailableReason` blocks. Discovery failing, the seat being gone
|
|
1134
|
+
* or the model not being listed at all keeps today's behaviour — the engine
|
|
1135
|
+
* starts the turn and reports whatever happens — because this is a fail-fast
|
|
1136
|
+
* convenience, not a new gate, and a flaky discovery must never lock a chat.
|
|
1137
|
+
*/
|
|
1138
|
+
async function liveModelFor(cfg, session) {
|
|
1139
|
+
const none = { reason: null, option: null };
|
|
1140
|
+
if (!session)
|
|
1141
|
+
return none; // sendTurn owns the 404
|
|
1142
|
+
let discovery;
|
|
1143
|
+
try {
|
|
1144
|
+
discovery = await cachedSeats(cfg);
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
return none;
|
|
1148
|
+
}
|
|
1149
|
+
const seat = discovery.seats.find((s) => s.id === session.seatId);
|
|
1150
|
+
if (!seat)
|
|
1151
|
+
return none;
|
|
1152
|
+
const modelId = canonicalModelId(session.model);
|
|
1153
|
+
const option = seat.models.find((m) => m.id === modelId) ?? null;
|
|
1154
|
+
const reason = option?.unavailableReason?.trim();
|
|
1155
|
+
return {
|
|
1156
|
+
reason: reason ? `model ${modelId} cannot run on seat ${seat.id}: ${reason}` : null,
|
|
1157
|
+
option,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
542
1160
|
// ---------------------------------------------------------------------------
|
|
543
1161
|
// Handler
|
|
544
1162
|
// ---------------------------------------------------------------------------
|
|
@@ -602,6 +1220,14 @@ export async function handleVerseApi(ctx, req, res, path, method) {
|
|
|
602
1220
|
sendJson(res, 200, view);
|
|
603
1221
|
return true;
|
|
604
1222
|
}
|
|
1223
|
+
// ── /api/verse/{preferences,context-fit,search,memory} (V3.9) ────────
|
|
1224
|
+
// AWAITED, not returned: a rejection must land in this function's catch
|
|
1225
|
+
// (which maps VERSE_INVALID → 400, VERSE_TOO_LARGE → 413) rather than
|
|
1226
|
+
// escape to handleApi's generic 500.
|
|
1227
|
+
const contextRoute = contextRouteOf(path);
|
|
1228
|
+
if (contextRoute !== null) {
|
|
1229
|
+
return await handleContextRoutes(ctx, req, res, path, method, contextRoute);
|
|
1230
|
+
}
|
|
605
1231
|
// ── /api/verse/sessions ──────────────────────────────────────────────
|
|
606
1232
|
if (path === `${VERSE_API_PREFIX}/sessions`) {
|
|
607
1233
|
if (method === 'GET') {
|
|
@@ -610,13 +1236,7 @@ export async function handleVerseApi(ctx, req, res, path, method) {
|
|
|
610
1236
|
return true;
|
|
611
1237
|
}
|
|
612
1238
|
if (method === 'POST') {
|
|
613
|
-
|
|
614
|
-
sendJson(res, 404, { error: 'not found' });
|
|
615
|
-
return true;
|
|
616
|
-
}
|
|
617
|
-
if (!passesMutationGate(req, res, ctx.token))
|
|
618
|
-
return true;
|
|
619
|
-
const body = await readJsonBody(req, res);
|
|
1239
|
+
const body = await readMutationBody(ctx, req, res);
|
|
620
1240
|
if (!body)
|
|
621
1241
|
return true;
|
|
622
1242
|
const create = parseCreateRequest(body, res, getVerseWorkspaceStore());
|
|
@@ -629,7 +1249,10 @@ export async function handleVerseApi(ctx, req, res, path, method) {
|
|
|
629
1249
|
return true;
|
|
630
1250
|
}
|
|
631
1251
|
const engine = await getVerseEngine();
|
|
632
|
-
const
|
|
1252
|
+
const resolved = resolveCreation(create, launch, engine, res);
|
|
1253
|
+
if (!resolved)
|
|
1254
|
+
return true;
|
|
1255
|
+
const session = engine.createSession(resolved.request, launch, resolved.options);
|
|
633
1256
|
sendJson(res, 201, session);
|
|
634
1257
|
return true;
|
|
635
1258
|
}
|
|
@@ -697,20 +1320,71 @@ export async function handleVerseApi(ctx, req, res, path, method) {
|
|
|
697
1320
|
sendJson(res, 200, detail);
|
|
698
1321
|
return true;
|
|
699
1322
|
}
|
|
700
|
-
if (method !== 'POST' || !
|
|
1323
|
+
if (method !== 'POST' || !SESSION_POST_ACTIONS.has(action)) {
|
|
701
1324
|
sendJson(res, 404, { error: `not found: ${method} ${path}` });
|
|
702
1325
|
return true;
|
|
703
1326
|
}
|
|
704
|
-
|
|
705
|
-
sendJson(res, 404, { error: 'not found' });
|
|
706
|
-
return true;
|
|
707
|
-
}
|
|
708
|
-
if (!passesMutationGate(req, res, ctx.token))
|
|
709
|
-
return true;
|
|
710
|
-
const body = await readJsonBody(req, res);
|
|
1327
|
+
const body = await readMutationBody(ctx, req, res);
|
|
711
1328
|
if (!body)
|
|
712
1329
|
return true;
|
|
713
1330
|
const engine = await getVerseEngine();
|
|
1331
|
+
// POST /api/verse/sessions/:id/context-mode {mode}
|
|
1332
|
+
//
|
|
1333
|
+
// Changes CLI FLAGS from the next turn on, never prompt content, so the
|
|
1334
|
+
// provider cache survives the switch. The engine is the authority on
|
|
1335
|
+
// whether the model has a budget for the mode (VERSE_INVALID) and
|
|
1336
|
+
// refuses a switch while a turn runs (VERSE_SESSION_BUSY).
|
|
1337
|
+
if (action === 'context-mode') {
|
|
1338
|
+
if (rejectUnknownKeys(body, CONTEXT_MODE_KEYS, res))
|
|
1339
|
+
return true;
|
|
1340
|
+
const mode = body['mode'];
|
|
1341
|
+
if (!isContextMode(mode)) {
|
|
1342
|
+
sendInvalid(res, `mode must be one of: ${CONTEXT_MODE_LIST}`);
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
if (!engine.getSession(id)) {
|
|
1346
|
+
sendJson(res, 404, { code: 'VERSE_SESSION_NOT_FOUND', error: `session not found: ${id}` });
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
const updated = engine.setContextMode(id, mode);
|
|
1350
|
+
sendJson(res, 200, updated);
|
|
1351
|
+
return true;
|
|
1352
|
+
}
|
|
1353
|
+
// POST /api/verse/sessions/:id/handoff-preview {includeLastAssistant?, focus?}
|
|
1354
|
+
//
|
|
1355
|
+
// A POST although it changes nothing, because building it runs
|
|
1356
|
+
// `git diff --stat` in every root — a subprocess, so it sits behind the
|
|
1357
|
+
// same gate as every other route that starts one. ZERO SPEND: the text
|
|
1358
|
+
// is assembled from the event log; the new session's first turn is
|
|
1359
|
+
// spent only when the operator presses send on it.
|
|
1360
|
+
if (action === 'handoff-preview') {
|
|
1361
|
+
if (rejectUnknownKeys(body, HANDOFF_PREVIEW_KEYS, res))
|
|
1362
|
+
return true;
|
|
1363
|
+
const includeLastAssistant = body['includeLastAssistant'];
|
|
1364
|
+
const focus = body['focus'];
|
|
1365
|
+
if (includeLastAssistant !== undefined && typeof includeLastAssistant !== 'boolean') {
|
|
1366
|
+
sendInvalid(res, 'includeLastAssistant must be a boolean');
|
|
1367
|
+
return true;
|
|
1368
|
+
}
|
|
1369
|
+
// Its LENGTH (≤ 500 after trimming) is session-handoff.ts's rule and is
|
|
1370
|
+
// enforced there, so the API and the builder cannot disagree.
|
|
1371
|
+
if (focus !== undefined && typeof focus !== 'string') {
|
|
1372
|
+
sendInvalid(res, 'focus must be a string');
|
|
1373
|
+
return true;
|
|
1374
|
+
}
|
|
1375
|
+
const session = engine.getSession(id);
|
|
1376
|
+
if (!session) {
|
|
1377
|
+
sendJson(res, 404, { code: 'VERSE_SESSION_NOT_FOUND', error: `session not found: ${id}` });
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
const focusLine = typeof focus === 'string' ? focus.trim() : '';
|
|
1381
|
+
const preview = buildHandoffPreview(session, engine.getEvents(id), {
|
|
1382
|
+
...(includeLastAssistant === true ? { includeLastAssistant: true } : {}),
|
|
1383
|
+
...(focusLine.length > 0 ? { focus: focusLine } : {}),
|
|
1384
|
+
});
|
|
1385
|
+
sendJson(res, 200, preview);
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
714
1388
|
if (action === 'turns') {
|
|
715
1389
|
const text = body['text'];
|
|
716
1390
|
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
@@ -721,6 +1395,31 @@ export async function handleVerseApi(ctx, req, res, path, method) {
|
|
|
721
1395
|
sendJson(res, 413, { code: 'VERSE_TOO_LARGE', error: `text exceeds ${VERSE_MAX_TURN_TEXT_BYTES} bytes` });
|
|
722
1396
|
return true;
|
|
723
1397
|
}
|
|
1398
|
+
// Turn text rides on the CLI's argv (`-- <text>`), where a NUL makes
|
|
1399
|
+
// spawn throw ERR_INVALID_ARG_VALUE after the turn was already recorded.
|
|
1400
|
+
if (text.includes('\0')) {
|
|
1401
|
+
sendInvalid(res, 'text must not contain NUL bytes');
|
|
1402
|
+
return true;
|
|
1403
|
+
}
|
|
1404
|
+
const current = engine.getSession(id);
|
|
1405
|
+
const live = await liveModelFor(ctx.cfg, current);
|
|
1406
|
+
if (live.reason !== null) {
|
|
1407
|
+
// A distinct code, not VERSE_SESSION_BUSY's: the UI must be able to
|
|
1408
|
+
// tell "a turn is running" from "this model cannot run on this seat".
|
|
1409
|
+
sendJson(res, 409, { code: 'VERSE_MODEL_UNAVAILABLE', error: live.reason });
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
// Local windows are volatile (server default, dispatch lane, a re-pulled
|
|
1413
|
+
// tag), and the SAME number must reach both the CLI
|
|
1414
|
+
// (CLAUDE_CODE_MAX_CONTEXT_TOKENS) and the meter: the session's stored
|
|
1415
|
+
// `usage.contextWindow`. Refresh it from live discovery before the CLI
|
|
1416
|
+
// launches, so a stale record (a pre-3.9 262144 for a 64k tag) is
|
|
1417
|
+
// corrected rather than told to the CLI turn after turn. No live option
|
|
1418
|
+
// (Ollama down, tag gone) keeps the stored window: discovery failing
|
|
1419
|
+
// must never rewrite a session.
|
|
1420
|
+
if (current?.engine === 'local' && live.option !== null) {
|
|
1421
|
+
engine.refreshLocalWindow(id, live.option);
|
|
1422
|
+
}
|
|
724
1423
|
const result = engine.sendTurn(id, text);
|
|
725
1424
|
sendJson(res, 202, result);
|
|
726
1425
|
return true;
|