tinker-agent 1.0.65

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.
Files changed (110) hide show
  1. package/README.md +173 -0
  2. package/package.json +78 -0
  3. package/patches/markdansi@0.3.2.patch +37 -0
  4. package/src/agent/context-builder.ts +43 -0
  5. package/src/agent/context-meter.ts +310 -0
  6. package/src/agent/loop.ts +525 -0
  7. package/src/agent/runtime-session.ts +1212 -0
  8. package/src/agent/session-ledger.ts +828 -0
  9. package/src/agent/turn-cancellation.ts +44 -0
  10. package/src/agent/types.ts +77 -0
  11. package/src/cli/config.ts +283 -0
  12. package/src/cli/index.ts +29 -0
  13. package/src/cli/model-profiles.ts +289 -0
  14. package/src/cli/run-runner.ts +107 -0
  15. package/src/cli/tui-runner.tsx +290 -0
  16. package/src/context/compiled-context-hash.ts +138 -0
  17. package/src/context/compiled-context-validator.ts +209 -0
  18. package/src/context/context-manager.ts +362 -0
  19. package/src/context/context-policy.ts +8 -0
  20. package/src/context/context-protocol-validator.ts +463 -0
  21. package/src/context/context-revision-compiler.ts +281 -0
  22. package/src/context/context-revision.ts +111 -0
  23. package/src/context/context-source.ts +30 -0
  24. package/src/context/context-swap-renderer.ts +272 -0
  25. package/src/context/protocol-frame.ts +240 -0
  26. package/src/context/swap-planner.ts +725 -0
  27. package/src/events/append-private-file.ts +16 -0
  28. package/src/events/bash-result-detail.ts +70 -0
  29. package/src/events/composite-event-sink.ts +82 -0
  30. package/src/events/event-sink.ts +16 -0
  31. package/src/events/jsonl-event-log.ts +13 -0
  32. package/src/events/observation-text-log.ts +195 -0
  33. package/src/events/stdout-event-printer.ts +396 -0
  34. package/src/events/types.ts +263 -0
  35. package/src/ids/runtime-id.ts +68 -0
  36. package/src/ids/uuid-v7.ts +5 -0
  37. package/src/instructions/project-instructions.ts +242 -0
  38. package/src/mcp/mcp-config.ts +144 -0
  39. package/src/mcp/mcp-manager.ts +216 -0
  40. package/src/mcp/mcp-tool-executor.ts +178 -0
  41. package/src/model/committed-prefix-auditor.ts +68 -0
  42. package/src/model/fake-model-client.ts +280 -0
  43. package/src/model/model-client.ts +64 -0
  44. package/src/model/model-context-profile.ts +134 -0
  45. package/src/model/model-request-preflight.ts +120 -0
  46. package/src/model/openai-chat-mapping.ts +444 -0
  47. package/src/model/openai-chat-model-client.ts +190 -0
  48. package/src/model/prompt-prefix-hash.ts +47 -0
  49. package/src/model/token-estimator.ts +148 -0
  50. package/src/observation/observation-builder.ts +481 -0
  51. package/src/session/resume-projection.ts +616 -0
  52. package/src/session/session-catalog.ts +270 -0
  53. package/src/session/session-errors.ts +121 -0
  54. package/src/session/session-history-reader.ts +535 -0
  55. package/src/session/session-lock.ts +291 -0
  56. package/src/session/session-schema.ts +741 -0
  57. package/src/session/session-store.ts +3067 -0
  58. package/src/session/sqlite-session-ledger.ts +153 -0
  59. package/src/tools/bash-task.ts +617 -0
  60. package/src/tools/bash.ts +450 -0
  61. package/src/tools/cwd-state.ts +22 -0
  62. package/src/tools/edit.ts +428 -0
  63. package/src/tools/file-diff.ts +116 -0
  64. package/src/tools/glob.ts +202 -0
  65. package/src/tools/grep.ts +550 -0
  66. package/src/tools/hash.ts +9 -0
  67. package/src/tools/path-safety.ts +33 -0
  68. package/src/tools/read.ts +319 -0
  69. package/src/tools/recall.ts +400 -0
  70. package/src/tools/registry.ts +213 -0
  71. package/src/tools/ripgrep.ts +220 -0
  72. package/src/tools/task-list.ts +59 -0
  73. package/src/tools/task-output-snapshot.ts +47 -0
  74. package/src/tools/task-output-tool.ts +62 -0
  75. package/src/tools/task-output.ts +159 -0
  76. package/src/tools/task-stop.ts +59 -0
  77. package/src/tools/task-tool-args.ts +29 -0
  78. package/src/tools/types.ts +330 -0
  79. package/src/tools/web-fetch/backend.ts +27 -0
  80. package/src/tools/web-fetch/browser-backend.ts +126 -0
  81. package/src/tools/web-fetch/exa-backend.ts +172 -0
  82. package/src/tools/web-fetch/index.ts +298 -0
  83. package/src/tools/web-fetch/local-backend.ts +267 -0
  84. package/src/tools/web-fetch/refiner.ts +78 -0
  85. package/src/tools/web-fetch/route.ts +95 -0
  86. package/src/tools/web-search.ts +300 -0
  87. package/src/tools/write.ts +244 -0
  88. package/src/tui/app.tsx +497 -0
  89. package/src/tui/components/assistant-markdown.tsx +47 -0
  90. package/src/tui/components/background-tasks.tsx +92 -0
  91. package/src/tui/components/bash-result-view.tsx +47 -0
  92. package/src/tui/components/context-status.tsx +127 -0
  93. package/src/tui/components/diff-view.tsx +151 -0
  94. package/src/tui/components/file-viewer.tsx +212 -0
  95. package/src/tui/components/footer.tsx +60 -0
  96. package/src/tui/components/header.tsx +21 -0
  97. package/src/tui/components/model-picker.tsx +142 -0
  98. package/src/tui/components/prompt-input.tsx +432 -0
  99. package/src/tui/components/resume-session-picker.tsx +273 -0
  100. package/src/tui/components/timeline.tsx +121 -0
  101. package/src/tui/context-format.ts +24 -0
  102. package/src/tui/event-store.ts +865 -0
  103. package/src/tui/git-branch.ts +23 -0
  104. package/src/tui/line-editor.ts +157 -0
  105. package/src/tui/prompt-history.ts +94 -0
  106. package/src/tui/slash-commands.ts +126 -0
  107. package/src/tui/tui-projection-policy.ts +35 -0
  108. package/src/tui/tui-projection-store.ts +123 -0
  109. package/src/tui/tui-session-controller.ts +170 -0
  110. package/src/tui/view-file.ts +122 -0
@@ -0,0 +1,3067 @@
1
+ import path from "node:path";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readdir,
8
+ realpath,
9
+ rename,
10
+ rmdir,
11
+ unlink,
12
+ } from "node:fs/promises";
13
+ import { randomUUID } from "node:crypto";
14
+ import { Database } from "bun:sqlite";
15
+ import type {
16
+ ContextRevisionId,
17
+ IterationId,
18
+ MessageId,
19
+ ProtocolFrameId,
20
+ RuntimeIdFactory,
21
+ SessionId,
22
+ ToolCallId,
23
+ TurnId,
24
+ } from "../ids/runtime-id";
25
+ import type {
26
+ ModelContextBudget,
27
+ ModelContextProfile,
28
+ } from "../model/model-context-profile";
29
+ import type { ToolRawResult } from "../tools/types";
30
+ import { sha256, stableJsonStringify } from "../model/model-request-preflight";
31
+ import {
32
+ ContextProtocolError,
33
+ ContextProtocolValidator,
34
+ } from "../context/context-protocol-validator";
35
+ import {
36
+ activeOverrideManifestHash,
37
+ canonicalSequenceHash,
38
+ renderedMessageHash,
39
+ } from "../context/compiled-context-hash";
40
+ import {
41
+ ContextRevisionCompiler,
42
+ createInitialContextRevision,
43
+ } from "../context/context-revision-compiler";
44
+ import {
45
+ ContextSwapRenderer,
46
+ SWAP_OBSERVATION_FORMAT,
47
+ } from "../context/context-swap-renderer";
48
+ import {
49
+ TOOL_OBSERVATION_FORMAT,
50
+ contentHash,
51
+ immutableCanonicalClone,
52
+ immutableRecord,
53
+ interruptedCompletionInputs,
54
+ observationForCompletion,
55
+ type CanonicalMessageRecord,
56
+ type ProtocolContextView,
57
+ type ProtocolFrame,
58
+ type ToolCompletion,
59
+ type ToolResultRecord,
60
+ } from "../context/protocol-frame";
61
+ import type {
62
+ StoredContextRevisionV5,
63
+ StoredContextSnapshotV5,
64
+ StoredSwapOverrideV5,
65
+ SwapOverride,
66
+ } from "../context/context-revision";
67
+ import type { IterationIdentity, ToolCall } from "../agent/types";
68
+ import type { MeasuredContextAnchor } from "../agent/context-meter";
69
+ import type { ProjectInstructionManifest } from "../instructions/project-instructions";
70
+ import {
71
+ InMemorySessionLedger,
72
+ type LedgerMutation,
73
+ type SessionLedgerCommitter,
74
+ } from "../agent/session-ledger";
75
+ import { SessionError, sessionOpenError, sessionWriteError } from "./session-errors";
76
+ import {
77
+ createSessionHistoryReader,
78
+ type SessionHistoryReader,
79
+ } from "./session-history-reader";
80
+ import { SessionLease } from "./session-lock";
81
+ import {
82
+ SESSION_SCHEMA_V5_FINGERPRINT,
83
+ SESSION_SCHEMA_VERSION,
84
+ configureWritableDatabase,
85
+ createSessionSchema,
86
+ rebuildRecallIndex,
87
+ verifyRecallIndex,
88
+ verifySessionSchema,
89
+ verifySqliteIntegrity,
90
+ } from "./session-schema";
91
+
92
+ export type RuntimeContractV1 = {
93
+ version: 1;
94
+ modelName: string;
95
+ profileName?: string;
96
+ includeReasoningContent: boolean;
97
+ contextProfile: ModelContextProfile;
98
+ contextBudget: ModelContextBudget;
99
+ systemPromptSha256: string;
100
+ toolSchemaSha256: string;
101
+ requestConfigSha256: string;
102
+ observationFormat: typeof TOOL_OBSERVATION_FORMAT;
103
+ };
104
+
105
+ export type StoredSessionMetaV5 = {
106
+ schemaVersion: 5;
107
+ schemaFingerprint: string;
108
+ initializationState: "creating" | "ready";
109
+ sessionId: SessionId;
110
+ workspaceRoot: string;
111
+ modelName: string;
112
+ systemPromptSha256: string;
113
+ projectInstruction?: ProjectInstructionManifest;
114
+ toolSchemaSha256: string | null;
115
+ runtimeContractJson: string | null;
116
+ runtimeContractSha256: string | null;
117
+ activeRevisionId: ContextRevisionId;
118
+ nextTurnNumber: number;
119
+ nextEventSequence: number;
120
+ openCount: number;
121
+ createdAt: string;
122
+ updatedAt: string;
123
+ lastOpenedAt: string;
124
+ lastClosedAt: string | null;
125
+ lastCloseReason:
126
+ | "oneshot_complete"
127
+ | "tui_exit"
128
+ | "session_switch"
129
+ | "runner_failed"
130
+ | "initialization_failed"
131
+ | null;
132
+ };
133
+
134
+ export type SessionCloseReason = NonNullable<StoredSessionMetaV5["lastCloseReason"]>;
135
+
136
+ export type SessionRecoveryResult = {
137
+ recoveredTurnId?: TurnId;
138
+ recoveredFrameId?: ProtocolFrameId;
139
+ syntheticCompletionCount: number;
140
+ recallIndexRebuilt: boolean;
141
+ };
142
+
143
+ type StoredMeasuredContextState = {
144
+ revisionId: ContextRevisionId;
145
+ anchor: MeasuredContextAnchor;
146
+ };
147
+
148
+ export type CommitSwapRevisionInput = {
149
+ revisionId: ContextRevisionId;
150
+ expectedBaseRevisionId: ContextRevisionId;
151
+ expectedBaseRevisionNumber: number;
152
+ expectedCanonicalThroughOrdinal: number;
153
+ expectedBaseOverrideManifestSha256: string;
154
+ policyVersion: "swap-only-v1";
155
+ rendererFormat: typeof SWAP_OBSERVATION_FORMAT;
156
+ planHash: string;
157
+ addedOverrides: readonly SwapOverride[];
158
+ nextOverrideManifestSha256: string;
159
+ canonicalSequenceSha256: string;
160
+ renderedMessageSha256: string;
161
+ };
162
+
163
+ export type CommitSwapRevisionFaultStage =
164
+ | "before_revision_insert"
165
+ | "after_revision_insert"
166
+ | "after_first_override_insert"
167
+ | "after_overrides_insert"
168
+ | "after_measurement_delete"
169
+ | "after_active_update";
170
+
171
+ export type CommitSwapRevisionOptions = {
172
+ faultInjector?: (stage: CommitSwapRevisionFaultStage) => void;
173
+ };
174
+
175
+ export type CreateNewSessionStoreInput = {
176
+ workspaceRoot: string;
177
+ sessionId: SessionId;
178
+ modelName: string;
179
+ systemPrompt: string;
180
+ projectInstruction?: ProjectInstructionManifest;
181
+ idFactory: RuntimeIdFactory;
182
+ clock?: () => string;
183
+ };
184
+
185
+ export type OpenSessionStoreInput = {
186
+ workspaceRoot: string;
187
+ sessionId: SessionId;
188
+ clock?: () => string;
189
+ allowIncomplete?: boolean;
190
+ };
191
+
192
+ export class SessionStore implements SessionLedgerCommitter {
193
+ readonly sessionId: SessionId;
194
+ readonly workspaceRoot: string;
195
+ readonly sessionDirectory: string;
196
+ readonly databasePath: string;
197
+ private closed = false;
198
+ private recallIndexRebuilt = false;
199
+ private readonly validator = new ContextProtocolValidator();
200
+ private readonly revisionCompiler = new ContextRevisionCompiler();
201
+ private readonly swapRenderer = new ContextSwapRenderer();
202
+
203
+ private constructor(
204
+ private readonly database: Database,
205
+ private readonly lease: SessionLease,
206
+ input: {
207
+ sessionId: SessionId;
208
+ workspaceRoot: string;
209
+ sessionDirectory: string;
210
+ databasePath: string;
211
+ clock: () => string;
212
+ },
213
+ ) {
214
+ this.sessionId = input.sessionId;
215
+ this.workspaceRoot = input.workspaceRoot;
216
+ this.sessionDirectory = input.sessionDirectory;
217
+ this.databasePath = input.databasePath;
218
+ this.clock = input.clock;
219
+ }
220
+
221
+ private readonly clock: () => string;
222
+
223
+ static async createNew(input: CreateNewSessionStoreInput): Promise<SessionStore> {
224
+ const clock = input.clock ?? (() => new Date().toISOString());
225
+ const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
226
+ const sessionsRoot = await ensureSessionsRoot(workspaceRoot);
227
+ const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
228
+ try {
229
+ await mkdir(sessionDirectory, { mode: 0o700 });
230
+ } catch (error) {
231
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
232
+ throw new SessionError(
233
+ "SESSION_ALREADY_EXISTS",
234
+ "create_session",
235
+ `Session directory already exists: ${sessionDirectory}.`,
236
+ { sessionId: input.sessionId, cause: error },
237
+ );
238
+ }
239
+ throw error;
240
+ }
241
+ await chmod(sessionDirectory, 0o700);
242
+
243
+ let lease: SessionLease | undefined;
244
+ let database: Database | undefined;
245
+ const databasePath = path.join(sessionDirectory, "session.sqlite");
246
+ try {
247
+ lease = await SessionLease.acquire({
248
+ sessionDirectory,
249
+ sessionId: input.sessionId,
250
+ });
251
+ const handle = await open(databasePath, "wx", 0o600);
252
+ await handle.close();
253
+ database = openWritableDatabase(databasePath);
254
+ createSessionSchema(database);
255
+ verifySessionSchema(database, input.sessionId);
256
+
257
+ const revisionId = input.idFactory.createContextRevisionId();
258
+ const initialLedger = new InMemorySessionLedger({
259
+ sessionId: input.sessionId,
260
+ systemPrompt: input.systemPrompt,
261
+ idFactory: input.idFactory,
262
+ initialRevisionId: revisionId,
263
+ clock,
264
+ });
265
+ const initialView = initialLedger.snapshot({ fullIntegrity: true });
266
+ const createdAt = clock();
267
+ const initialRevision = createInitialContextRevision({
268
+ revisionId,
269
+ canonical: initialView,
270
+ createdAt,
271
+ });
272
+ runTransaction(database, () => {
273
+ database!
274
+ .query(
275
+ `INSERT INTO session_meta (
276
+ singleton, schema_version, schema_fingerprint, initialization_state,
277
+ session_id, workspace_root, model_name, system_prompt_sha256,
278
+ project_instruction_file, project_instruction_byte_length,
279
+ project_instruction_sha256,
280
+ tool_schema_sha256, runtime_contract_json, runtime_contract_sha256,
281
+ active_revision_id, next_turn_number, next_event_sequence, open_count,
282
+ created_at, updated_at, last_opened_at, last_closed_at, last_close_reason
283
+ ) VALUES (1, ?, ?, 'creating', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, 1, 1, 1, ?, ?, ?, NULL, NULL)`,
284
+ )
285
+ .run(
286
+ SESSION_SCHEMA_VERSION,
287
+ SESSION_SCHEMA_V5_FINGERPRINT,
288
+ input.sessionId,
289
+ workspaceRoot,
290
+ input.modelName,
291
+ sha256(input.systemPrompt),
292
+ input.projectInstruction?.path ?? null,
293
+ input.projectInstruction?.byteLength ?? null,
294
+ input.projectInstruction?.sha256 ?? null,
295
+ revisionId,
296
+ createdAt,
297
+ createdAt,
298
+ createdAt,
299
+ );
300
+ insertFrame(database!, requireItem(initialView.frames, 0, "system frame"));
301
+ insertMessage(
302
+ database!,
303
+ requireItem(initialView.messages, 0, "system message"),
304
+ );
305
+ database!
306
+ .query(
307
+ `INSERT INTO context_revisions (
308
+ revision_id, session_id, revision_number, parent_revision_id, kind,
309
+ keep_from_ordinal, source_through_ordinal, added_override_count,
310
+ total_override_count, override_manifest_sha256,
311
+ canonical_sequence_sha256, rendered_message_sha256, policy_version,
312
+ renderer_format, plan_sha256, created_at
313
+ ) VALUES (?, ?, 1, NULL, 'initial_full', 1, 1, 0, 0, ?, ?, ?, NULL, NULL, NULL, ?)`,
314
+ )
315
+ .run(
316
+ revisionId,
317
+ input.sessionId,
318
+ initialRevision.overrideManifestSha256,
319
+ initialRevision.canonicalSequenceSha256,
320
+ initialRevision.renderedMessageSha256,
321
+ createdAt,
322
+ );
323
+ });
324
+
325
+ const store = new SessionStore(database, lease, {
326
+ sessionId: input.sessionId,
327
+ workspaceRoot,
328
+ sessionDirectory,
329
+ databasePath,
330
+ clock,
331
+ });
332
+ await store.correctDatabaseModes();
333
+ store.validateAll({ allowOpenTail: false });
334
+ verifyRecallIndex(database, input.sessionId);
335
+ return store;
336
+ } catch (error) {
337
+ database?.close();
338
+ if (lease !== undefined) {
339
+ await lease.release().catch(() => undefined);
340
+ }
341
+ await removeKnownInitializationFiles(sessionDirectory);
342
+ throw error;
343
+ }
344
+ }
345
+
346
+ static async openExisting(input: OpenSessionStoreInput): Promise<SessionStore> {
347
+ const clock = input.clock ?? (() => new Date().toISOString());
348
+ const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
349
+ const sessionsRoot = path.join(workspaceRoot, ".tinker", "sessions");
350
+ await validateSessionsRoot(sessionsRoot, input.sessionId);
351
+ const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
352
+ await validateSecureDirectory(sessionDirectory, input.sessionId);
353
+ const databasePath = path.join(sessionDirectory, "session.sqlite");
354
+ await validateSecureFile(databasePath, input.sessionId);
355
+ for (const optionalFile of [
356
+ `${databasePath}-wal`,
357
+ `${databasePath}-shm`,
358
+ path.join(sessionDirectory, "events.jsonl"),
359
+ path.join(sessionDirectory, "observations.md"),
360
+ ]) {
361
+ await validateSecureOptionalFile(optionalFile, input.sessionId);
362
+ }
363
+
364
+ const lease = await SessionLease.acquire({
365
+ sessionDirectory,
366
+ sessionId: input.sessionId,
367
+ });
368
+ let database: Database | undefined;
369
+ try {
370
+ database = openWritableDatabase(databasePath);
371
+ verifySessionSchema(database, input.sessionId);
372
+ verifySqliteIntegrity(database, input.sessionId);
373
+ const store = new SessionStore(database, lease, {
374
+ sessionId: input.sessionId,
375
+ workspaceRoot,
376
+ sessionDirectory,
377
+ databasePath,
378
+ clock,
379
+ });
380
+ const meta = store.readMeta();
381
+ if (meta.initializationState !== "ready" && input.allowIncomplete !== true) {
382
+ throw new SessionError(
383
+ "SESSION_INTEGRITY_FAILED",
384
+ "open_session",
385
+ `Session ${input.sessionId} did not finish initialization.`,
386
+ { sessionId: input.sessionId },
387
+ );
388
+ }
389
+ if (meta.workspaceRoot !== workspaceRoot) {
390
+ throw new SessionError(
391
+ "SESSION_WORKSPACE_MISMATCH",
392
+ "open_session",
393
+ `Session workspace is ${meta.workspaceRoot}, current workspace is ${workspaceRoot}.`,
394
+ { sessionId: input.sessionId },
395
+ );
396
+ }
397
+ store.validateAll({ allowOpenTail: true });
398
+ try {
399
+ verifyRecallIndex(database, input.sessionId);
400
+ } catch (error) {
401
+ if (
402
+ !(error instanceof SessionError) ||
403
+ error.code !== "SESSION_RECALL_INDEX_INVALID"
404
+ ) {
405
+ throw error;
406
+ }
407
+ try {
408
+ runTransaction(database, () =>
409
+ rebuildRecallIndex(database!, input.sessionId),
410
+ );
411
+ verifyRecallIndex(database, input.sessionId);
412
+ } catch (rebuildError) {
413
+ if (
414
+ rebuildError instanceof SessionError &&
415
+ rebuildError.code === "SESSION_RECALL_INDEX_INVALID"
416
+ ) {
417
+ throw rebuildError;
418
+ }
419
+ throw new SessionError(
420
+ "SESSION_RECALL_INDEX_INVALID",
421
+ "rebuild_recall_index",
422
+ "Session Recall index rebuild transaction failed.",
423
+ { sessionId: input.sessionId, cause: rebuildError },
424
+ );
425
+ }
426
+ store.recallIndexRebuilt = true;
427
+ }
428
+ await store.correctDatabaseModes();
429
+ return store;
430
+ } catch (error) {
431
+ database?.close();
432
+ await lease.release().catch(() => undefined);
433
+ throw sessionOpenError("open_session", input.sessionId, error);
434
+ }
435
+ }
436
+
437
+ commit(mutation: LedgerMutation): void {
438
+ this.requireOpen();
439
+ const now = this.clock();
440
+ try {
441
+ runTransaction(this.database, () => {
442
+ switch (mutation.kind) {
443
+ case "begin_turn":
444
+ this.commitBeginTurn(mutation, now);
445
+ break;
446
+ case "append_assistant":
447
+ this.commitAssistant(mutation, now);
448
+ break;
449
+ case "commit_tool_completions":
450
+ this.commitToolCompletions(mutation, now);
451
+ break;
452
+ case "finish_turn":
453
+ this.commitFinishTurn(mutation, now);
454
+ break;
455
+ }
456
+ });
457
+ } catch (error) {
458
+ throw sessionWriteError(mutation.kind, this.sessionId, error);
459
+ }
460
+ }
461
+
462
+ beginIteration(iteration: IterationIdentity): void {
463
+ this.requireOpen();
464
+ const now = this.clock();
465
+ try {
466
+ runTransaction(this.database, () => {
467
+ const turn = this.requireTurnRow(iteration.turnId);
468
+ if (
469
+ turn.status !== "open" ||
470
+ numberFromSql(turn.next_iteration_number, "next_iteration_number") !==
471
+ iteration.iterationNumber
472
+ ) {
473
+ throw new Error(
474
+ `Iteration ${iteration.iterationId} does not match the open turn counter.`,
475
+ );
476
+ }
477
+ this.database
478
+ .query(
479
+ `INSERT INTO iterations (
480
+ session_id, turn_id, iteration_id, iteration_number, outcome,
481
+ next_tool_call_number, started_at, finished_at
482
+ ) VALUES (?, ?, ?, ?, 'open', 1, ?, NULL)`,
483
+ )
484
+ .run(
485
+ this.sessionId,
486
+ iteration.turnId,
487
+ iteration.iterationId,
488
+ iteration.iterationNumber,
489
+ now,
490
+ );
491
+ const updated = this.database
492
+ .query(
493
+ `UPDATE turns SET next_iteration_number = ?, last_iteration_id = ?
494
+ WHERE turn_id = ? AND status = 'open' AND next_iteration_number = ?`,
495
+ )
496
+ .run(
497
+ iteration.iterationNumber + 1,
498
+ iteration.iterationId,
499
+ iteration.turnId,
500
+ iteration.iterationNumber,
501
+ );
502
+ requireSingleChange(
503
+ this.database,
504
+ updated.changes,
505
+ "advance iteration counter",
506
+ );
507
+ });
508
+ } catch (error) {
509
+ throw sessionWriteError("begin_iteration", this.sessionId, error);
510
+ }
511
+ }
512
+
513
+ finishIterationForContinuation(iteration: IterationIdentity): void {
514
+ this.requireOpen();
515
+ const now = this.clock();
516
+ try {
517
+ runTransaction(this.database, () => {
518
+ const updated = this.database
519
+ .query(
520
+ `UPDATE iterations SET outcome = 'continue', finished_at = ?
521
+ WHERE iteration_id = ? AND turn_id = ? AND outcome = 'open'`,
522
+ )
523
+ .run(now, iteration.iterationId, iteration.turnId);
524
+ requireSingleChange(
525
+ this.database,
526
+ updated.changes,
527
+ "finish continuing iteration",
528
+ );
529
+ this.touch(now);
530
+ });
531
+ } catch (error) {
532
+ throw sessionWriteError("finish_iteration", this.sessionId, error);
533
+ }
534
+ }
535
+
536
+ allocateEventSequence(): number {
537
+ this.requireOpen();
538
+ const now = this.clock();
539
+ try {
540
+ return runTransaction(this.database, () => {
541
+ const meta = this.readMeta();
542
+ const sequence = meta.nextEventSequence;
543
+ const updated = this.database
544
+ .query(
545
+ `UPDATE session_meta SET next_event_sequence = ?, updated_at = ?
546
+ WHERE singleton = 1 AND next_event_sequence = ?`,
547
+ )
548
+ .run(sequence + 1, now, sequence);
549
+ requireSingleChange(this.database, updated.changes, "advance event sequence");
550
+ return sequence;
551
+ });
552
+ } catch (error) {
553
+ throw sessionWriteError("allocate_event_sequence", this.sessionId, error);
554
+ }
555
+ }
556
+
557
+ finalizeRuntimeContract(contract: RuntimeContractV1): void {
558
+ this.requireOpen();
559
+ const json = stableJsonStringify(contract);
560
+ const contractSha256 = sha256(json);
561
+ const now = this.clock();
562
+ try {
563
+ runTransaction(this.database, () => {
564
+ const updated = this.database
565
+ .query(
566
+ `UPDATE session_meta
567
+ SET initialization_state = 'ready', tool_schema_sha256 = ?,
568
+ runtime_contract_json = ?, runtime_contract_sha256 = ?, updated_at = ?
569
+ WHERE singleton = 1 AND initialization_state = 'creating'
570
+ AND runtime_contract_json IS NULL`,
571
+ )
572
+ .run(contract.toolSchemaSha256, json, contractSha256, now);
573
+ requireSingleChange(
574
+ this.database,
575
+ updated.changes,
576
+ "finalize runtime contract",
577
+ );
578
+ });
579
+ } catch (error) {
580
+ throw sessionWriteError("finalize_runtime_contract", this.sessionId, error);
581
+ }
582
+ }
583
+
584
+ assertRuntimeContract(contract: RuntimeContractV1): void {
585
+ const meta = this.readMeta();
586
+ const current = stableJsonStringify(contract);
587
+ const currentHash = sha256(current);
588
+ if (
589
+ meta.runtimeContractJson !== current ||
590
+ meta.runtimeContractSha256 !== currentHash
591
+ ) {
592
+ const changed = runtimeContractDifferences(meta.runtimeContractJson, contract);
593
+ throw new SessionError(
594
+ "SESSION_RUNTIME_MISMATCH",
595
+ "compare_runtime_contract",
596
+ `Session runtime contract changed: ${changed.join(", ") || "stored contract is invalid"}.`,
597
+ { sessionId: this.sessionId },
598
+ );
599
+ }
600
+ }
601
+
602
+ writeMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
603
+ this.requireOpen();
604
+ assertMeasuredContextAnchor(anchor);
605
+ const revisionId = this.readMeta().activeRevisionId;
606
+ const now = this.clock();
607
+ try {
608
+ runTransaction(this.database, () => {
609
+ const written = this.database
610
+ .query(
611
+ `INSERT INTO context_measurement_state (
612
+ singleton, session_id, revision_id, total_tokens, prompt_tokens,
613
+ completion_tokens, segment_count, prefix_hash, request_config_hash,
614
+ tool_schema_hash, updated_at
615
+ ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
616
+ ON CONFLICT(singleton) DO UPDATE SET
617
+ revision_id = excluded.revision_id,
618
+ total_tokens = excluded.total_tokens,
619
+ prompt_tokens = excluded.prompt_tokens,
620
+ completion_tokens = excluded.completion_tokens,
621
+ segment_count = excluded.segment_count,
622
+ prefix_hash = excluded.prefix_hash,
623
+ request_config_hash = excluded.request_config_hash,
624
+ tool_schema_hash = excluded.tool_schema_hash,
625
+ updated_at = excluded.updated_at
626
+ WHERE context_measurement_state.session_id = excluded.session_id`,
627
+ )
628
+ .run(
629
+ this.sessionId,
630
+ revisionId,
631
+ anchor.totalTokens,
632
+ anchor.promptTokens,
633
+ anchor.completionTokens,
634
+ anchor.segmentCount,
635
+ anchor.prefixHash,
636
+ anchor.requestConfigHash,
637
+ anchor.toolSchemaHash,
638
+ now,
639
+ );
640
+ requireSingleChange(
641
+ this.database,
642
+ written.changes,
643
+ "write measured context anchor",
644
+ );
645
+ this.touch(now);
646
+ });
647
+ } catch (error) {
648
+ throw sessionWriteError("write_context_measurement", this.sessionId, error);
649
+ }
650
+ }
651
+
652
+ readActiveMeasuredContextAnchor(): MeasuredContextAnchor | undefined {
653
+ this.requireOpen();
654
+ const state = this.loadMeasuredContextState();
655
+ if (state === undefined) {
656
+ return undefined;
657
+ }
658
+ if (state.revisionId !== this.readMeta().activeRevisionId) {
659
+ return undefined;
660
+ }
661
+ return state.anchor;
662
+ }
663
+
664
+ assertContextRevisionIdle(): void {
665
+ this.requireOpen();
666
+ const row = this.database
667
+ .query(
668
+ `SELECT
669
+ (SELECT COUNT(*) FROM turns WHERE status = 'open') AS open_turns,
670
+ (SELECT COUNT(*) FROM iterations WHERE outcome = 'open') AS open_iterations,
671
+ (SELECT COUNT(*) FROM protocol_frames WHERE state = 'open') AS open_frames`,
672
+ )
673
+ .get() as Record<string, unknown> | null;
674
+ if (
675
+ row === null ||
676
+ numberFromSql(row.open_turns, "open_turns") !== 0 ||
677
+ numberFromSql(row.open_iterations, "open_iterations") !== 0 ||
678
+ numberFromSql(row.open_frames, "open_frames") !== 0
679
+ ) {
680
+ throw new SessionError(
681
+ "SESSION_INTEGRITY_FAILED",
682
+ "assert_context_revision_idle",
683
+ "Context revision requires a fully idle session store.",
684
+ { sessionId: this.sessionId },
685
+ );
686
+ }
687
+ }
688
+
689
+ commitSwapRevision(
690
+ input: CommitSwapRevisionInput,
691
+ options: CommitSwapRevisionOptions = {},
692
+ ): Extract<StoredContextRevisionV5, { kind: "swap_only" }> {
693
+ this.requireOpen();
694
+ assertCommitSwapRevisionInput(input);
695
+ const now = this.clock();
696
+ try {
697
+ return runTransaction(this.database, () => {
698
+ const snapshot = this.loadContextSnapshot();
699
+ const baseRevision = snapshot.revision;
700
+ if (
701
+ baseRevision.revisionId !== input.expectedBaseRevisionId ||
702
+ baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
703
+ snapshot.canonical.messages.length !==
704
+ input.expectedCanonicalThroughOrdinal ||
705
+ baseRevision.overrideManifestSha256 !==
706
+ input.expectedBaseOverrideManifestSha256
707
+ ) {
708
+ throw new Error("Context revision commit base is stale.");
709
+ }
710
+ this.assertContextRevisionIdle();
711
+
712
+ const active = this.revisionCompiler.compileActive(snapshot);
713
+ const candidateOverrides = [
714
+ ...snapshot.activeOverrides,
715
+ ...input.addedOverrides,
716
+ ];
717
+ if (
718
+ new Set(candidateOverrides.map((override) => override.messageId)).size !==
719
+ candidateOverrides.length ||
720
+ activeOverrideManifestHash(candidateOverrides) !==
721
+ input.nextOverrideManifestSha256
722
+ ) {
723
+ throw new Error("Candidate override manifest is invalid.");
724
+ }
725
+ const candidate = this.revisionCompiler.compileProspective({
726
+ active,
727
+ canonical: snapshot.canonical,
728
+ activeOverrides: snapshot.activeOverrides,
729
+ addedOverrides: input.addedOverrides,
730
+ });
731
+ if (
732
+ canonicalSequenceHash(
733
+ snapshot.canonical,
734
+ input.expectedCanonicalThroughOrdinal,
735
+ ) !== input.canonicalSequenceSha256 ||
736
+ renderedMessageHash(
737
+ candidate.entries,
738
+ input.expectedCanonicalThroughOrdinal,
739
+ ) !== input.renderedMessageSha256
740
+ ) {
741
+ throw new Error("Candidate context revision prefix hash is invalid.");
742
+ }
743
+ this.validateAddedOverrides(input.addedOverrides, snapshot.canonical);
744
+
745
+ const revisionNumber = baseRevision.revisionNumber + 1;
746
+ const totalOverrideCount =
747
+ baseRevision.totalOverrideCount + input.addedOverrides.length;
748
+ options.faultInjector?.("before_revision_insert");
749
+ this.database
750
+ .query(
751
+ `INSERT INTO context_revisions (
752
+ revision_id, session_id, revision_number, parent_revision_id, kind,
753
+ keep_from_ordinal, source_through_ordinal, added_override_count,
754
+ total_override_count, override_manifest_sha256,
755
+ canonical_sequence_sha256, rendered_message_sha256, policy_version,
756
+ renderer_format, plan_sha256, created_at
757
+ ) VALUES (?, ?, ?, ?, 'swap_only', 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
758
+ )
759
+ .run(
760
+ input.revisionId,
761
+ this.sessionId,
762
+ revisionNumber,
763
+ baseRevision.revisionId,
764
+ input.expectedCanonicalThroughOrdinal,
765
+ input.addedOverrides.length,
766
+ totalOverrideCount,
767
+ input.nextOverrideManifestSha256,
768
+ input.canonicalSequenceSha256,
769
+ input.renderedMessageSha256,
770
+ input.policyVersion,
771
+ input.rendererFormat,
772
+ input.planHash,
773
+ now,
774
+ );
775
+ options.faultInjector?.("after_revision_insert");
776
+
777
+ for (let index = 0; index < input.addedOverrides.length; index += 1) {
778
+ const override = requireItem(
779
+ input.addedOverrides,
780
+ index,
781
+ "added context override",
782
+ );
783
+ this.database
784
+ .query(
785
+ `INSERT INTO context_overrides (
786
+ introduced_revision_id, session_id, message_id, frame_id, ordinal,
787
+ representation, renderer_format, source, original_content_sha256,
788
+ rendered_content, rendered_content_sha256, original_bytes,
789
+ rendered_bytes, byte_savings, created_at
790
+ ) VALUES (?, ?, ?, ?, ?, 'swapped', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
791
+ )
792
+ .run(
793
+ input.revisionId,
794
+ this.sessionId,
795
+ override.messageId,
796
+ override.frameId,
797
+ override.ordinal,
798
+ input.rendererFormat,
799
+ override.source,
800
+ override.originalContentSha256,
801
+ override.renderedContent,
802
+ override.renderedContentSha256,
803
+ override.originalBytes,
804
+ override.renderedBytes,
805
+ override.byteSavings,
806
+ now,
807
+ );
808
+ if (index === 0) {
809
+ options.faultInjector?.("after_first_override_insert");
810
+ }
811
+ }
812
+ options.faultInjector?.("after_overrides_insert");
813
+
814
+ const storedCandidateOverrides = this.database
815
+ .query(
816
+ `SELECT co.* FROM context_overrides co
817
+ JOIN context_revisions cr
818
+ ON cr.revision_id = co.introduced_revision_id
819
+ WHERE cr.revision_number <= ?
820
+ ORDER BY co.ordinal`,
821
+ )
822
+ .all(revisionNumber)
823
+ .map(decodeStoredSwapOverride);
824
+ if (
825
+ storedCandidateOverrides.length !== totalOverrideCount ||
826
+ activeOverrideManifestHash(storedCandidateOverrides) !==
827
+ input.nextOverrideManifestSha256
828
+ ) {
829
+ throw new Error("Stored candidate override readback is invalid.");
830
+ }
831
+
832
+ this.database.query("DELETE FROM context_measurement_state").run();
833
+ options.faultInjector?.("after_measurement_delete");
834
+ const switched = this.database
835
+ .query(
836
+ `UPDATE session_meta
837
+ SET active_revision_id = ?, updated_at = ?
838
+ WHERE singleton = 1 AND active_revision_id = ?`,
839
+ )
840
+ .run(input.revisionId, now, baseRevision.revisionId);
841
+ requireSingleChange(
842
+ this.database,
843
+ switched.changes,
844
+ "activate context revision",
845
+ );
846
+ options.faultInjector?.("after_active_update");
847
+
848
+ const readback = this.loadContextSnapshot();
849
+ if (
850
+ readback.revision.kind !== "swap_only" ||
851
+ readback.revision.revisionId !== input.revisionId ||
852
+ this.loadMeasuredContextState() !== undefined
853
+ ) {
854
+ throw new Error("Committed context revision readback failed.");
855
+ }
856
+ return readback.revision;
857
+ });
858
+ } catch (error) {
859
+ if (this.readMeta().activeRevisionId !== input.expectedBaseRevisionId) {
860
+ throw new Error("Failed context revision transaction changed active state.", {
861
+ cause: error,
862
+ });
863
+ }
864
+ throw sessionWriteError("commit_context_revision", this.sessionId, error);
865
+ }
866
+ }
867
+
868
+ private validateAddedOverrides(
869
+ overrides: readonly SwapOverride[],
870
+ canonical: ProtocolContextView,
871
+ ): void {
872
+ const messages = new Map(
873
+ canonical.messages.map((message) => [message.messageId, message] as const),
874
+ );
875
+ const results = new Map(
876
+ canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
877
+ );
878
+ for (const override of overrides) {
879
+ const message = messages.get(override.messageId);
880
+ const result = results.get(override.messageId);
881
+ if (message?.role !== "tool" || result === undefined) {
882
+ throw new Error("Added context override does not target a tool result.");
883
+ }
884
+ const expected = this.swapRenderer.render({ message, result });
885
+ if (stableJsonStringify(expected) !== stableJsonStringify(override)) {
886
+ throw new Error("Added context override is not deterministic.");
887
+ }
888
+ }
889
+ }
890
+
891
+ markResumed(): number {
892
+ this.requireOpen();
893
+ const now = this.clock();
894
+ try {
895
+ return runTransaction(this.database, () => {
896
+ const meta = this.readMeta();
897
+ const next = meta.openCount + 1;
898
+ const updated = this.database
899
+ .query(
900
+ `UPDATE session_meta
901
+ SET open_count = ?, last_opened_at = ?, updated_at = ?,
902
+ last_closed_at = NULL, last_close_reason = NULL
903
+ WHERE singleton = 1 AND open_count = ?`,
904
+ )
905
+ .run(next, now, now, meta.openCount);
906
+ requireSingleChange(this.database, updated.changes, "increment open count");
907
+ return next;
908
+ });
909
+ } catch (error) {
910
+ throw sessionWriteError("mark_resumed", this.sessionId, error);
911
+ }
912
+ }
913
+
914
+ recoverInterruptedState(idFactory: RuntimeIdFactory): SessionRecoveryResult {
915
+ this.requireOpen();
916
+ const view = this.loadProtocolView();
917
+ const openTurns = this.database
918
+ .query("SELECT turn_id FROM turns WHERE status = 'open' ORDER BY turn_number")
919
+ .all() as Array<{ turn_id: string }>;
920
+ const openFrames = view.frames.filter((frame) => frame.state === "open");
921
+ if (openTurns.length === 0 && openFrames.length === 0) {
922
+ return {
923
+ syntheticCompletionCount: 0,
924
+ recallIndexRebuilt: this.recallIndexRebuilt,
925
+ };
926
+ }
927
+ if (openTurns.length !== 1 || openFrames.length > 1) {
928
+ throw this.recoveryError(
929
+ "Session has an invalid number of open turns or frames.",
930
+ );
931
+ }
932
+ const turnId = openTurns[0].turn_id as TurnId;
933
+ const openIterations = this.database
934
+ .query(
935
+ "SELECT iteration_id FROM iterations WHERE turn_id = ? AND outcome = 'open' ORDER BY iteration_number",
936
+ )
937
+ .all(turnId) as Array<{ iteration_id: string }>;
938
+ if (openIterations.length > 1) {
939
+ throw this.recoveryError(`Turn ${turnId} has multiple open iterations.`);
940
+ }
941
+
942
+ const frame = openFrames[0];
943
+ if (frame === undefined) {
944
+ this.markOpenTurnInterrupted(
945
+ turnId,
946
+ openIterations[0]?.iteration_id as IterationId | undefined,
947
+ );
948
+ this.validateAll({ allowOpenTail: false });
949
+ return {
950
+ recoveredTurnId: turnId,
951
+ syntheticCompletionCount: 0,
952
+ recallIndexRebuilt: this.recallIndexRebuilt,
953
+ };
954
+ }
955
+ if (
956
+ frame.turnId !== turnId ||
957
+ frame !== view.frames.at(-1) ||
958
+ openIterations.length !== 1 ||
959
+ frame.iterationId !== openIterations[0]?.iteration_id
960
+ ) {
961
+ throw this.recoveryError(`Open frame ${frame.frameId} has invalid ownership.`);
962
+ }
963
+
964
+ const frameMessages = view.messages.filter(
965
+ (message) => message.frameId === frame.frameId,
966
+ );
967
+ const assistant = frameMessages[0];
968
+ if (assistant?.role !== "assistant" || assistant.toolCalls === undefined) {
969
+ throw this.recoveryError(`Open frame ${frame.frameId} has no tool calls.`);
970
+ }
971
+ const missingCalls = assistant.toolCalls.slice(frameMessages.length - 1);
972
+ if (missingCalls.length === 0) {
973
+ throw this.recoveryError(`Open frame ${frame.frameId} has no missing call.`);
974
+ }
975
+ const completionInputs = interruptedCompletionInputs(missingCalls);
976
+ const messages: CanonicalMessageRecord[] = [];
977
+ const toolResults: ToolResultRecord[] = [];
978
+ for (const input of completionInputs) {
979
+ const createdAt = this.clock();
980
+ const content = observationForCompletion(input);
981
+ const messageId = idFactory.createMessageId();
982
+ const message = immutableRecord<CanonicalMessageRecord>({
983
+ messageId,
984
+ sessionId: this.sessionId,
985
+ frameId: frame.frameId,
986
+ ordinal: view.messages.length + messages.length + 1,
987
+ contentSha256: contentHash(content),
988
+ createdAt,
989
+ role: "tool",
990
+ turnId,
991
+ iterationId: frame.iterationId,
992
+ toolCallId: input.call.toolCallId,
993
+ providerToolCallId: input.call.providerToolCallId,
994
+ name: input.call.name,
995
+ content,
996
+ origin: "runtime",
997
+ });
998
+ const completion: ToolCompletion = immutableRecord({
999
+ kind: "synthetic",
1000
+ reason: input.reason,
1001
+ });
1002
+ const result = immutableRecord<ToolResultRecord>({
1003
+ sessionId: this.sessionId,
1004
+ frameId: frame.frameId,
1005
+ toolCallId: input.call.toolCallId,
1006
+ toolMessageId: messageId,
1007
+ completion,
1008
+ observationSha256: contentHash(content),
1009
+ createdAt,
1010
+ });
1011
+ messages.push(message);
1012
+ toolResults.push(result);
1013
+ }
1014
+ const closedAt = this.clock();
1015
+ const closedFrame = immutableRecord<ProtocolFrame>({
1016
+ ...frame,
1017
+ state: "closed",
1018
+ lastOrdinal: view.messages.length + messages.length,
1019
+ closedAt,
1020
+ });
1021
+ const candidate: ProtocolContextView = Object.freeze({
1022
+ ...view,
1023
+ frames: Object.freeze(
1024
+ view.frames.map((entry) =>
1025
+ entry.frameId === frame.frameId ? closedFrame : entry,
1026
+ ),
1027
+ ),
1028
+ messages: Object.freeze([...view.messages, ...messages]),
1029
+ toolResults: Object.freeze([...view.toolResults, ...toolResults]),
1030
+ });
1031
+ this.validator.validate(candidate, { fullIntegrity: true });
1032
+
1033
+ try {
1034
+ runTransaction(this.database, () => {
1035
+ for (let index = 0; index < messages.length; index += 1) {
1036
+ insertMessage(
1037
+ this.database,
1038
+ requireItem(messages, index, "recovery message"),
1039
+ );
1040
+ insertToolResult(
1041
+ this.database,
1042
+ requireItem(toolResults, index, "recovery tool result"),
1043
+ );
1044
+ }
1045
+ const frameUpdate = this.database
1046
+ .query(
1047
+ `UPDATE protocol_frames SET state = 'closed', last_ordinal = ?, closed_at = ?
1048
+ WHERE frame_id = ? AND state = 'open' AND last_ordinal IS NULL`,
1049
+ )
1050
+ .run(closedFrame.lastOrdinal!, closedAt, frame.frameId);
1051
+ requireSingleChange(
1052
+ this.database,
1053
+ frameUpdate.changes,
1054
+ "close recovered frame",
1055
+ );
1056
+ this.markTerminalRows(
1057
+ turnId,
1058
+ frame.iterationId!,
1059
+ "interrupted",
1060
+ "interrupted",
1061
+ null,
1062
+ stableJsonStringify({ version: 1, reason: "process_interrupted" }),
1063
+ closedAt,
1064
+ );
1065
+ });
1066
+ } catch (error) {
1067
+ throw new SessionError(
1068
+ "SESSION_RECOVERY_FAILED",
1069
+ "recover_open_frame",
1070
+ `Failed to recover open frame ${frame.frameId}.`,
1071
+ { sessionId: this.sessionId, frameId: frame.frameId, cause: error },
1072
+ );
1073
+ }
1074
+ this.validateAll({ allowOpenTail: false });
1075
+ return {
1076
+ recoveredTurnId: turnId,
1077
+ recoveredFrameId: frame.frameId,
1078
+ syntheticCompletionCount: messages.length,
1079
+ recallIndexRebuilt: this.recallIndexRebuilt,
1080
+ };
1081
+ }
1082
+
1083
+ historyReader(): SessionHistoryReader {
1084
+ this.requireOpen();
1085
+ return createSessionHistoryReader({
1086
+ database: this.database,
1087
+ sessionId: this.sessionId,
1088
+ requireOpen: () => this.requireOpen(),
1089
+ });
1090
+ }
1091
+
1092
+ loadProtocolView(): ProtocolContextView {
1093
+ this.requireOpen();
1094
+ const frames = this.database
1095
+ .query("SELECT * FROM protocol_frames ORDER BY first_ordinal")
1096
+ .all()
1097
+ .map(decodeFrame);
1098
+ const messages = this.database
1099
+ .query("SELECT * FROM messages ORDER BY ordinal")
1100
+ .all()
1101
+ .map(decodeMessage);
1102
+ const toolResults = this.database
1103
+ .query(
1104
+ `SELECT tr.* FROM tool_results tr
1105
+ JOIN messages m ON m.message_id = tr.tool_message_id
1106
+ ORDER BY m.ordinal`,
1107
+ )
1108
+ .all()
1109
+ .map(decodeToolResult);
1110
+ return Object.freeze({
1111
+ sessionId: this.sessionId,
1112
+ faulted: false,
1113
+ frames: Object.freeze(frames),
1114
+ messages: Object.freeze(messages),
1115
+ toolResults: Object.freeze(toolResults),
1116
+ });
1117
+ }
1118
+
1119
+ loadContextSnapshot(): StoredContextSnapshotV5 {
1120
+ this.requireOpen();
1121
+ const meta = this.readMeta();
1122
+ try {
1123
+ if (
1124
+ meta.sessionId !== this.sessionId ||
1125
+ meta.schemaFingerprint !== SESSION_SCHEMA_V5_FINGERPRINT
1126
+ ) {
1127
+ throw new Error("Session metadata identity or schema fingerprint changed.");
1128
+ }
1129
+ const canonical = this.loadProtocolView();
1130
+ this.validator.validate(canonical, { fullIntegrity: true });
1131
+ const systemFrame = canonical.frames[0];
1132
+ const systemMessage = canonical.messages[0];
1133
+ if (
1134
+ systemFrame?.kind !== "system" ||
1135
+ systemFrame.firstOrdinal !== 1 ||
1136
+ systemMessage?.role !== "system" ||
1137
+ systemMessage.ordinal !== 1 ||
1138
+ sha256(systemMessage.content) !== meta.systemPromptSha256 ||
1139
+ canonical.messages.at(-1)?.ordinal !== canonical.messages.length
1140
+ ) {
1141
+ throw new Error("Stored context snapshot ordinal or system invariant failed.");
1142
+ }
1143
+ return this.loadValidatedContextSnapshot(meta, canonical);
1144
+ } catch (error) {
1145
+ if (error instanceof SessionError) {
1146
+ throw error;
1147
+ }
1148
+ if (error instanceof ContextProtocolError) {
1149
+ throw new SessionError(
1150
+ "SESSION_PROTOCOL_INVALID",
1151
+ "load_context_snapshot",
1152
+ error.message,
1153
+ {
1154
+ sessionId: this.sessionId,
1155
+ frameId: error.frameId,
1156
+ messageId: error.messageId,
1157
+ toolCallId: error.toolCallId,
1158
+ cause: error,
1159
+ },
1160
+ );
1161
+ }
1162
+ throw new SessionError(
1163
+ "SESSION_INTEGRITY_FAILED",
1164
+ "load_context_snapshot",
1165
+ `Session context snapshot validation failed: ${errorMessage(error)}.`,
1166
+ { sessionId: this.sessionId, cause: error },
1167
+ );
1168
+ }
1169
+ }
1170
+
1171
+ readMeta(): StoredSessionMetaV5 {
1172
+ this.requireOpen();
1173
+ const rows = this.database.query("SELECT * FROM session_meta").all();
1174
+ if (rows.length !== 1) {
1175
+ throw new SessionError(
1176
+ "SESSION_INTEGRITY_FAILED",
1177
+ "read_meta",
1178
+ `Session metadata must contain exactly one row; found ${rows.length}.`,
1179
+ { sessionId: this.sessionId },
1180
+ );
1181
+ }
1182
+ return decodeMeta(rows[0], this.sessionId);
1183
+ }
1184
+
1185
+ readStoredSystemPrompt(): string {
1186
+ this.requireOpen();
1187
+ try {
1188
+ const frames = this.database
1189
+ .query("SELECT * FROM protocol_frames WHERE kind = 'system'")
1190
+ .all();
1191
+ if (frames.length !== 1) {
1192
+ throw new Error(`Expected one stored system frame; found ${frames.length}.`);
1193
+ }
1194
+ const frame = recordFromSql(frames[0], "stored system frame");
1195
+ if (
1196
+ frame.state !== "closed" ||
1197
+ numberFromSql(frame.first_ordinal, "first_ordinal") !== 1 ||
1198
+ numberFromSql(frame.last_ordinal, "last_ordinal") !== 1
1199
+ ) {
1200
+ throw new Error("Stored system frame invariant failed.");
1201
+ }
1202
+ const frameId = stringFromSql(frame.frame_id, "frame_id");
1203
+ const messages = this.database
1204
+ .query("SELECT * FROM messages WHERE frame_id = ?")
1205
+ .all(frameId);
1206
+ if (messages.length !== 1) {
1207
+ throw new Error(
1208
+ `Expected one stored system message; found ${messages.length}.`,
1209
+ );
1210
+ }
1211
+ const row = recordFromSql(messages[0], "stored system message");
1212
+ if (
1213
+ numberFromSql(row.ordinal, "ordinal") !== 1 ||
1214
+ row.role !== "system" ||
1215
+ row.origin !== "runtime"
1216
+ ) {
1217
+ throw new Error("Stored system message invariant failed.");
1218
+ }
1219
+ const content = stringFromSql(row.content, "content");
1220
+ if (content.trim() === "") {
1221
+ throw new Error("Stored system prompt must not be empty.");
1222
+ }
1223
+ if (
1224
+ stringFromSql(row.content_sha256, "content_sha256") !== contentHash(content)
1225
+ ) {
1226
+ throw new Error("Stored system message content hash does not match.");
1227
+ }
1228
+ if (this.readMeta().systemPromptSha256 !== sha256(content)) {
1229
+ throw new Error("Stored system prompt metadata hash does not match.");
1230
+ }
1231
+ return content;
1232
+ } catch (error) {
1233
+ if (error instanceof SessionError && error.code === "SESSION_RECOVERY_FAILED") {
1234
+ throw error;
1235
+ }
1236
+ throw new SessionError(
1237
+ "SESSION_RECOVERY_FAILED",
1238
+ "read_stored_system_prompt",
1239
+ "Stored system prompt is missing or invalid.",
1240
+ { sessionId: this.sessionId, cause: error },
1241
+ );
1242
+ }
1243
+ }
1244
+
1245
+ readProjectInstructionManifest(): ProjectInstructionManifest | undefined {
1246
+ return this.readMeta().projectInstruction;
1247
+ }
1248
+
1249
+ nextTurnNumber(): number {
1250
+ return this.readMeta().nextTurnNumber;
1251
+ }
1252
+
1253
+ validateAll(options: { allowOpenTail: boolean }): ProtocolContextView {
1254
+ const meta = this.readMeta();
1255
+ if (
1256
+ meta.sessionId !== this.sessionId ||
1257
+ meta.schemaFingerprint !== SESSION_SCHEMA_V5_FINGERPRINT
1258
+ ) {
1259
+ throw new SessionError(
1260
+ "SESSION_SCHEMA_INVALID",
1261
+ "validate_store",
1262
+ "Session metadata identity or schema fingerprint does not match.",
1263
+ { sessionId: this.sessionId },
1264
+ );
1265
+ }
1266
+ this.readStoredSystemPrompt();
1267
+ const view = this.loadProtocolView();
1268
+ try {
1269
+ this.validator.validate(view, {
1270
+ allowOpenTail: options.allowOpenTail,
1271
+ fullIntegrity: true,
1272
+ });
1273
+ this.loadValidatedContextSnapshot(meta, view);
1274
+ this.validateCounters(meta, view);
1275
+ } catch (error) {
1276
+ if (error instanceof SessionError) {
1277
+ throw error;
1278
+ }
1279
+ if (error instanceof ContextProtocolError) {
1280
+ throw new SessionError(
1281
+ "SESSION_PROTOCOL_INVALID",
1282
+ "validate_store",
1283
+ error.message,
1284
+ {
1285
+ sessionId: this.sessionId,
1286
+ frameId: error.frameId,
1287
+ messageId: error.messageId,
1288
+ toolCallId: error.toolCallId,
1289
+ cause: error,
1290
+ },
1291
+ );
1292
+ }
1293
+ throw new SessionError(
1294
+ "SESSION_INTEGRITY_FAILED",
1295
+ "validate_store",
1296
+ `Session record validation failed: ${errorMessage(error)}.`,
1297
+ { sessionId: this.sessionId, cause: error },
1298
+ );
1299
+ }
1300
+ return view;
1301
+ }
1302
+
1303
+ async close(reason: SessionCloseReason): Promise<void> {
1304
+ if (this.closed) {
1305
+ return;
1306
+ }
1307
+ let primaryError: unknown;
1308
+ const now = this.clock();
1309
+ try {
1310
+ runTransaction(this.database, () => {
1311
+ const updated = this.database
1312
+ .query(
1313
+ `UPDATE session_meta SET last_closed_at = ?, last_close_reason = ?, updated_at = ?
1314
+ WHERE singleton = 1`,
1315
+ )
1316
+ .run(now, reason, now);
1317
+ requireSingleChange(this.database, updated.changes, "close session activation");
1318
+ });
1319
+ } catch (error) {
1320
+ primaryError = sessionWriteError("close_session", this.sessionId, error);
1321
+ }
1322
+ try {
1323
+ this.database.close();
1324
+ } catch (error) {
1325
+ primaryError ??= error;
1326
+ }
1327
+ this.closed = true;
1328
+ try {
1329
+ await this.lease.release();
1330
+ } catch (error) {
1331
+ primaryError ??= error;
1332
+ }
1333
+ if (primaryError !== undefined) {
1334
+ throw asError(primaryError);
1335
+ }
1336
+ }
1337
+
1338
+ async abandon(): Promise<void> {
1339
+ if (this.closed) {
1340
+ return;
1341
+ }
1342
+ try {
1343
+ this.database.close();
1344
+ } catch {
1345
+ // A failed delete path may already have closed the connection.
1346
+ }
1347
+ this.closed = true;
1348
+ await this.lease.release();
1349
+ }
1350
+
1351
+ async deleteFromDisk(): Promise<void> {
1352
+ this.requireOpen();
1353
+ const known = new Set([
1354
+ "session.sqlite",
1355
+ "session.sqlite-wal",
1356
+ "session.sqlite-shm",
1357
+ "events.jsonl",
1358
+ "observations.md",
1359
+ "active.lock",
1360
+ "active.lock.reclaim",
1361
+ ]);
1362
+ const entries = await readdir(this.sessionDirectory);
1363
+ const unknown = entries.filter((entry) => !known.has(entry));
1364
+ if (unknown.length > 0) {
1365
+ throw new SessionError(
1366
+ "SESSION_DELETE_BLOCKED",
1367
+ "delete_session",
1368
+ `Session directory contains unknown files: ${unknown.join(", ")}.`,
1369
+ { sessionId: this.sessionId },
1370
+ );
1371
+ }
1372
+
1373
+ this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1374
+ this.database.close();
1375
+ const tombstone = `${this.sessionDirectory}.deleting-${randomUUID()}`;
1376
+ try {
1377
+ await rename(this.sessionDirectory, tombstone);
1378
+ } catch (error) {
1379
+ this.closed = true;
1380
+ await this.lease.release().catch(() => undefined);
1381
+ throw error;
1382
+ }
1383
+ this.lease.relocate(tombstone);
1384
+ await this.lease.release();
1385
+ this.closed = true;
1386
+
1387
+ try {
1388
+ for (const name of known) {
1389
+ await unlinkIfExists(path.join(tombstone, name));
1390
+ }
1391
+ await rmdir(tombstone);
1392
+ } catch (error) {
1393
+ throw new SessionError(
1394
+ "SESSION_DELETE_BLOCKED",
1395
+ "delete_session_cleanup",
1396
+ `Session was removed from the catalog, but tombstone cleanup failed: ${tombstone}.`,
1397
+ { sessionId: this.sessionId, cause: error },
1398
+ );
1399
+ }
1400
+ }
1401
+
1402
+ private commitBeginTurn(
1403
+ mutation: Extract<LedgerMutation, { kind: "begin_turn" }>,
1404
+ now: string,
1405
+ ): void {
1406
+ const meta = this.readMeta();
1407
+ if (
1408
+ meta.initializationState !== "ready" ||
1409
+ meta.nextTurnNumber !== mutation.turn.turnNumber
1410
+ ) {
1411
+ throw new Error("Session turn counter or state changed before begin_turn.");
1412
+ }
1413
+ this.database
1414
+ .query(
1415
+ `INSERT INTO turns (
1416
+ session_id, turn_id, turn_number, status, next_iteration_number,
1417
+ last_iteration_id, final_message_id, terminal_detail_json, started_at, finished_at
1418
+ ) VALUES (?, ?, ?, 'open', 1, NULL, NULL, NULL, ?, NULL)`,
1419
+ )
1420
+ .run(this.sessionId, mutation.turn.turnId, mutation.turn.turnNumber, now);
1421
+ insertFrame(this.database, mutation.frame);
1422
+ insertMessage(this.database, mutation.message);
1423
+ const updated = this.database
1424
+ .query(
1425
+ `UPDATE session_meta SET next_turn_number = ?, updated_at = ?
1426
+ WHERE singleton = 1 AND next_turn_number = ?`,
1427
+ )
1428
+ .run(mutation.turn.turnNumber + 1, now, mutation.turn.turnNumber);
1429
+ requireSingleChange(this.database, updated.changes, "advance turn counter");
1430
+ }
1431
+
1432
+ private commitAssistant(
1433
+ mutation: Extract<LedgerMutation, { kind: "append_assistant" }>,
1434
+ now: string,
1435
+ ): void {
1436
+ const iteration = this.requireIterationRow(mutation.iteration.iterationId);
1437
+ if (iteration.outcome !== "open") {
1438
+ throw new Error(`Iteration ${mutation.iteration.iterationId} is not open.`);
1439
+ }
1440
+ insertFrame(this.database, mutation.frame);
1441
+ insertMessage(this.database, mutation.message);
1442
+ if (
1443
+ mutation.message.role === "assistant" &&
1444
+ mutation.message.toolCalls !== undefined
1445
+ ) {
1446
+ const expected = numberFromSql(
1447
+ iteration.next_tool_call_number,
1448
+ "next_tool_call_number",
1449
+ );
1450
+ if (expected !== 1) {
1451
+ throw new Error(
1452
+ "Assistant tool calls were already allocated for this iteration.",
1453
+ );
1454
+ }
1455
+ const updated = this.database
1456
+ .query(
1457
+ `UPDATE iterations SET next_tool_call_number = ?
1458
+ WHERE iteration_id = ? AND outcome = 'open' AND next_tool_call_number = 1`,
1459
+ )
1460
+ .run(mutation.message.toolCalls.length + 1, mutation.iteration.iterationId);
1461
+ requireSingleChange(this.database, updated.changes, "advance tool call counter");
1462
+ }
1463
+ this.touch(now);
1464
+ }
1465
+
1466
+ private commitToolCompletions(
1467
+ mutation: Extract<LedgerMutation, { kind: "commit_tool_completions" }>,
1468
+ now: string,
1469
+ ): void {
1470
+ const current = this.database
1471
+ .query("SELECT state, last_ordinal FROM protocol_frames WHERE frame_id = ?")
1472
+ .get(mutation.frameBefore.frameId) as {
1473
+ state: string;
1474
+ last_ordinal: unknown;
1475
+ } | null;
1476
+ if (current?.state !== "open" || current.last_ordinal !== null) {
1477
+ throw new Error(`Frame ${mutation.frameBefore.frameId} is not open.`);
1478
+ }
1479
+ for (let index = 0; index < mutation.messages.length; index += 1) {
1480
+ insertMessage(
1481
+ this.database,
1482
+ requireItem(mutation.messages, index, "tool message"),
1483
+ );
1484
+ insertToolResult(
1485
+ this.database,
1486
+ requireItem(mutation.toolResults, index, "tool result"),
1487
+ );
1488
+ }
1489
+ if (mutation.frameAfter.state === "closed") {
1490
+ const updated = this.database
1491
+ .query(
1492
+ `UPDATE protocol_frames SET state = 'closed', last_ordinal = ?, closed_at = ?
1493
+ WHERE frame_id = ? AND state = 'open' AND last_ordinal IS NULL`,
1494
+ )
1495
+ .run(
1496
+ mutation.frameAfter.lastOrdinal!,
1497
+ mutation.frameAfter.closedAt!,
1498
+ mutation.frameAfter.frameId,
1499
+ );
1500
+ requireSingleChange(this.database, updated.changes, "close tool exchange frame");
1501
+ }
1502
+ this.touch(now);
1503
+ }
1504
+
1505
+ private commitFinishTurn(
1506
+ mutation: Extract<LedgerMutation, { kind: "finish_turn" }>,
1507
+ now: string,
1508
+ ): void {
1509
+ const result = mutation.result;
1510
+ const turnStatus = result.status;
1511
+ const iterationOutcome = result.status;
1512
+ const detail =
1513
+ result.status === "completed"
1514
+ ? stableJsonStringify({ version: 1, finalTextLength: result.finalText.length })
1515
+ : result.status === "failed"
1516
+ ? stableJsonStringify({ version: 1, error: result.error.slice(0, 2_000) })
1517
+ : stableJsonStringify({ version: 1, cancellation: result.cancellation });
1518
+ this.markTerminalRows(
1519
+ mutation.turn.turnId,
1520
+ result.lastIteration.iterationId,
1521
+ turnStatus,
1522
+ iterationOutcome,
1523
+ mutation.finalMessageId ?? null,
1524
+ detail,
1525
+ now,
1526
+ );
1527
+ }
1528
+
1529
+ private markTerminalRows(
1530
+ turnId: TurnId,
1531
+ iterationId: IterationId,
1532
+ turnStatus: "completed" | "failed" | "cancelled" | "interrupted",
1533
+ iterationOutcome: "completed" | "failed" | "cancelled" | "interrupted",
1534
+ finalMessageId: MessageId | null,
1535
+ terminalDetailJson: string,
1536
+ now: string,
1537
+ ): void {
1538
+ const iteration = this.database
1539
+ .query(
1540
+ `UPDATE iterations SET outcome = ?, finished_at = ?
1541
+ WHERE iteration_id = ? AND turn_id = ? AND outcome = 'open'`,
1542
+ )
1543
+ .run(iterationOutcome, now, iterationId, turnId);
1544
+ requireSingleChange(this.database, iteration.changes, "finish iteration");
1545
+ const turn = this.database
1546
+ .query(
1547
+ `UPDATE turns SET status = ?, last_iteration_id = ?, final_message_id = ?,
1548
+ terminal_detail_json = ?, finished_at = ?
1549
+ WHERE turn_id = ? AND status = 'open'`,
1550
+ )
1551
+ .run(turnStatus, iterationId, finalMessageId, terminalDetailJson, now, turnId);
1552
+ requireSingleChange(this.database, turn.changes, "finish turn");
1553
+ this.touch(now);
1554
+ }
1555
+
1556
+ private markOpenTurnInterrupted(
1557
+ turnId: TurnId,
1558
+ iterationId: IterationId | undefined,
1559
+ ): void {
1560
+ const now = this.clock();
1561
+ try {
1562
+ runTransaction(this.database, () => {
1563
+ if (iterationId !== undefined) {
1564
+ const iteration = this.database
1565
+ .query(
1566
+ `UPDATE iterations SET outcome = 'interrupted', finished_at = ?
1567
+ WHERE iteration_id = ? AND outcome = 'open'`,
1568
+ )
1569
+ .run(now, iterationId);
1570
+ requireSingleChange(this.database, iteration.changes, "interrupt iteration");
1571
+ }
1572
+ const turn = this.database
1573
+ .query(
1574
+ `UPDATE turns SET status = 'interrupted', finished_at = ?,
1575
+ terminal_detail_json = ?
1576
+ WHERE turn_id = ? AND status = 'open'`,
1577
+ )
1578
+ .run(
1579
+ now,
1580
+ stableJsonStringify({ version: 1, reason: "process_interrupted" }),
1581
+ turnId,
1582
+ );
1583
+ requireSingleChange(this.database, turn.changes, "interrupt turn");
1584
+ this.touch(now);
1585
+ });
1586
+ } catch (error) {
1587
+ throw new SessionError(
1588
+ "SESSION_RECOVERY_FAILED",
1589
+ "recover_open_turn",
1590
+ `Failed to mark turn ${turnId} interrupted.`,
1591
+ { sessionId: this.sessionId, cause: error },
1592
+ );
1593
+ }
1594
+ }
1595
+
1596
+ private loadValidatedContextSnapshot(
1597
+ meta: StoredSessionMetaV5,
1598
+ canonical: ProtocolContextView,
1599
+ ): StoredContextSnapshotV5 {
1600
+ const revisions = this.database
1601
+ .query("SELECT * FROM context_revisions ORDER BY revision_number")
1602
+ .all()
1603
+ .map(decodeContextRevision);
1604
+ if (revisions.length === 0) {
1605
+ throw new Error("Session has no context revision.");
1606
+ }
1607
+
1608
+ const revisionNumberById = new Map<ContextRevisionId, number>();
1609
+ for (let index = 0; index < revisions.length; index += 1) {
1610
+ const revision = requireItem(revisions, index, "context revision");
1611
+ const previous = revisions[index - 1];
1612
+ if (
1613
+ revision.sessionId !== this.sessionId ||
1614
+ revision.revisionNumber !== index + 1 ||
1615
+ (index === 0
1616
+ ? revision.kind !== "initial_full" || revision.parentRevisionId !== null
1617
+ : revision.kind !== "swap_only" ||
1618
+ revision.parentRevisionId !== previous?.revisionId)
1619
+ ) {
1620
+ throw new Error("Context revision chain is not linear and contiguous.");
1621
+ }
1622
+ const boundary = canonical.frames.find(
1623
+ (frame) => frame.lastOrdinal === revision.sourceThroughOrdinal,
1624
+ );
1625
+ if (
1626
+ revision.sourceThroughOrdinal > canonical.messages.length ||
1627
+ boundary?.state !== "closed"
1628
+ ) {
1629
+ throw new Error(
1630
+ `Context revision ${revision.revisionId} has an invalid source boundary.`,
1631
+ );
1632
+ }
1633
+ revisionNumberById.set(revision.revisionId, revision.revisionNumber);
1634
+ }
1635
+
1636
+ const activeRevision = requireItem(
1637
+ revisions,
1638
+ revisions.length - 1,
1639
+ "active context revision",
1640
+ );
1641
+ if (activeRevision.revisionId !== meta.activeRevisionId) {
1642
+ throw new Error("Active context revision is not the latest committed revision.");
1643
+ }
1644
+
1645
+ const overrides = this.database
1646
+ .query(
1647
+ `SELECT co.* FROM context_overrides co
1648
+ JOIN context_revisions cr
1649
+ ON cr.revision_id = co.introduced_revision_id
1650
+ ORDER BY cr.revision_number, co.ordinal`,
1651
+ )
1652
+ .all()
1653
+ .map(decodeStoredSwapOverride);
1654
+ this.validateStoredOverrides(overrides, canonical, revisions, revisionNumberById);
1655
+
1656
+ let cumulativeCount = 0;
1657
+ for (const revision of revisions) {
1658
+ const activeOverrides = overrides.filter(
1659
+ (override) =>
1660
+ (revisionNumberById.get(override.introducedRevisionId) ??
1661
+ Number.POSITIVE_INFINITY) <= revision.revisionNumber,
1662
+ );
1663
+ const introducedCount = overrides.filter(
1664
+ (override) => override.introducedRevisionId === revision.revisionId,
1665
+ ).length;
1666
+ cumulativeCount += introducedCount;
1667
+ if (
1668
+ introducedCount !== revision.addedOverrideCount ||
1669
+ cumulativeCount !== revision.totalOverrideCount ||
1670
+ activeOverrideManifestHash(activeOverrides) !== revision.overrideManifestSha256
1671
+ ) {
1672
+ throw new Error(
1673
+ `Context revision ${revision.revisionId} override manifest is invalid.`,
1674
+ );
1675
+ }
1676
+ const prefix = protocolPrefixView(canonical, revision.sourceThroughOrdinal);
1677
+ this.revisionCompiler.compileActive({
1678
+ meta: Object.freeze({
1679
+ sessionId: this.sessionId,
1680
+ activeRevisionId: revision.revisionId,
1681
+ }),
1682
+ revision,
1683
+ activeOverrides,
1684
+ canonical: prefix,
1685
+ });
1686
+ }
1687
+
1688
+ const measurement = this.loadMeasuredContextState();
1689
+ if (
1690
+ measurement !== undefined &&
1691
+ measurement.revisionId !== activeRevision.revisionId
1692
+ ) {
1693
+ throw new Error("Context measurement is not bound to the active revision.");
1694
+ }
1695
+
1696
+ return Object.freeze({
1697
+ meta: Object.freeze({
1698
+ sessionId: meta.sessionId,
1699
+ activeRevisionId: meta.activeRevisionId,
1700
+ }),
1701
+ revision: activeRevision,
1702
+ activeOverrides: Object.freeze(overrides),
1703
+ canonical,
1704
+ });
1705
+ }
1706
+
1707
+ private validateStoredOverrides(
1708
+ overrides: readonly StoredSwapOverrideV5[],
1709
+ canonical: ProtocolContextView,
1710
+ revisions: readonly StoredContextRevisionV5[],
1711
+ revisionNumberById: ReadonlyMap<ContextRevisionId, number>,
1712
+ ): void {
1713
+ const messages = new Map(
1714
+ canonical.messages.map((message) => [message.messageId, message] as const),
1715
+ );
1716
+ const frames = new Map(
1717
+ canonical.frames.map((frame) => [frame.frameId, frame] as const),
1718
+ );
1719
+ const results = new Map(
1720
+ canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
1721
+ );
1722
+ const revisionsById = new Map(
1723
+ revisions.map((revision) => [revision.revisionId, revision] as const),
1724
+ );
1725
+ const seenMessages = new Set<MessageId>();
1726
+ for (const override of overrides) {
1727
+ const revision = revisionsById.get(override.introducedRevisionId);
1728
+ const message = messages.get(override.messageId);
1729
+ const frame = frames.get(override.frameId);
1730
+ const result = results.get(override.messageId);
1731
+ if (
1732
+ revision?.kind !== "swap_only" ||
1733
+ revisionNumberById.get(revision.revisionId) === undefined ||
1734
+ override.ordinal > revision.sourceThroughOrdinal ||
1735
+ seenMessages.has(override.messageId) ||
1736
+ message?.role !== "tool" ||
1737
+ message.frameId !== override.frameId ||
1738
+ message.ordinal !== override.ordinal ||
1739
+ frame?.kind !== "tool_exchange" ||
1740
+ frame.state !== "closed" ||
1741
+ result === undefined
1742
+ ) {
1743
+ throw new Error("Stored context override canonical identity is invalid.");
1744
+ }
1745
+ const rendered = this.swapRenderer.render({ message, result });
1746
+ if (
1747
+ stableJsonStringify(rendered) !==
1748
+ stableJsonStringify(stripStoredOverride(override))
1749
+ ) {
1750
+ throw new Error(
1751
+ `Stored context override ${override.messageId} does not match deterministic rendering.`,
1752
+ );
1753
+ }
1754
+ seenMessages.add(override.messageId);
1755
+ }
1756
+ }
1757
+
1758
+ private loadMeasuredContextState(): StoredMeasuredContextState | undefined {
1759
+ const rows = this.database.query("SELECT * FROM context_measurement_state").all();
1760
+ if (rows.length > 1) {
1761
+ throw new Error(
1762
+ `Expected at most one context measurement row; found ${rows.length}.`,
1763
+ );
1764
+ }
1765
+ const row = rows[0];
1766
+ return row === undefined
1767
+ ? undefined
1768
+ : decodeMeasuredContextState(row, this.sessionId);
1769
+ }
1770
+
1771
+ private validateCounters(meta: StoredSessionMetaV5, view: ProtocolContextView): void {
1772
+ const turns = this.database
1773
+ .query("SELECT * FROM turns ORDER BY turn_number")
1774
+ .all() as Array<Record<string, unknown>>;
1775
+ for (let index = 0; index < turns.length; index += 1) {
1776
+ const turn = turns[index];
1777
+ if (numberFromSql(turn.turn_number, "turn_number") !== index + 1) {
1778
+ throw new Error("Turn number sequence has a gap.");
1779
+ }
1780
+ const turnId = stringFromSql(turn.turn_id, "turn_id");
1781
+ const turnStatus = enumFromSql(
1782
+ turn.status,
1783
+ ["open", "completed", "failed", "cancelled", "interrupted"] as const,
1784
+ "turn status",
1785
+ );
1786
+ const iterations = this.database
1787
+ .query("SELECT * FROM iterations WHERE turn_id = ? ORDER BY iteration_number")
1788
+ .all(turnId) as Array<Record<string, unknown>>;
1789
+ const storedLastIterationId = nullableStringFromSql(
1790
+ turn.last_iteration_id,
1791
+ "last_iteration_id",
1792
+ );
1793
+ const actualLastIterationId =
1794
+ iterations.length === 0
1795
+ ? null
1796
+ : stringFromSql(iterations.at(-1)!.iteration_id, "iteration_id");
1797
+ if (storedLastIterationId !== actualLastIterationId) {
1798
+ throw new Error(`Last iteration identity is invalid in turn ${turnId}.`);
1799
+ }
1800
+ for (
1801
+ let iterationIndex = 0;
1802
+ iterationIndex < iterations.length;
1803
+ iterationIndex += 1
1804
+ ) {
1805
+ const iteration = iterations[iterationIndex];
1806
+ if (
1807
+ numberFromSql(iteration.iteration_number, "iteration_number") !==
1808
+ iterationIndex + 1
1809
+ ) {
1810
+ throw new Error(`Iteration number sequence has a gap in turn ${turnId}.`);
1811
+ }
1812
+ const iterationId = stringFromSql(iteration.iteration_id, "iteration_id");
1813
+ const outcome = enumFromSql(
1814
+ iteration.outcome,
1815
+ [
1816
+ "open",
1817
+ "continue",
1818
+ "completed",
1819
+ "failed",
1820
+ "cancelled",
1821
+ "interrupted",
1822
+ ] as const,
1823
+ "iteration outcome",
1824
+ );
1825
+ if (iterationIndex < iterations.length - 1 && outcome !== "continue") {
1826
+ throw new Error(
1827
+ `Non-final iteration ${iterationId} must have continue outcome.`,
1828
+ );
1829
+ }
1830
+ const toolCalls = view.messages.flatMap((message) =>
1831
+ message.role === "assistant" && message.iterationId === iterationId
1832
+ ? (message.toolCalls ?? [])
1833
+ : [],
1834
+ );
1835
+ if (
1836
+ numberFromSql(iteration.next_tool_call_number, "next_tool_call_number") !==
1837
+ toolCalls.length + 1
1838
+ ) {
1839
+ throw new Error(`Tool call counter is invalid in iteration ${iterationId}.`);
1840
+ }
1841
+ }
1842
+ if (
1843
+ numberFromSql(turn.next_iteration_number, "next_iteration_number") !==
1844
+ iterations.length + 1
1845
+ ) {
1846
+ throw new Error(`Iteration counter is invalid in turn ${turnId}.`);
1847
+ }
1848
+ const openIterationCount = iterations.filter(
1849
+ (iteration) => iteration.outcome === "open",
1850
+ ).length;
1851
+ if (turnStatus === "open" && openIterationCount > 1) {
1852
+ throw new Error(`Open turn ${turnId} has multiple open iterations.`);
1853
+ }
1854
+ if (turnStatus !== "open" && openIterationCount !== 0) {
1855
+ throw new Error(`Terminal turn ${turnId} still has an open iteration.`);
1856
+ }
1857
+ const lastOutcome = iterations.at(-1)?.outcome;
1858
+ if (
1859
+ turnStatus !== "open" &&
1860
+ iterations.length > 0 &&
1861
+ lastOutcome !== turnStatus &&
1862
+ !(turnStatus === "interrupted" && lastOutcome === "continue")
1863
+ ) {
1864
+ throw new Error(
1865
+ `Terminal turn ${turnId} does not match its last iteration outcome.`,
1866
+ );
1867
+ }
1868
+ const finalMessageId = nullableStringFromSql(
1869
+ turn.final_message_id,
1870
+ "final_message_id",
1871
+ );
1872
+ if (turnStatus === "completed") {
1873
+ const finalMessage = view.messages.find(
1874
+ (message) => message.messageId === finalMessageId,
1875
+ );
1876
+ const lastTurnMessage = [...view.messages]
1877
+ .reverse()
1878
+ .find((message) => "turnId" in message && message.turnId === turnId);
1879
+ if (
1880
+ finalMessage?.role !== "assistant" ||
1881
+ finalMessage.turnId !== turnId ||
1882
+ (finalMessage.toolCalls?.length ?? 0) !== 0 ||
1883
+ lastTurnMessage?.messageId !== finalMessage.messageId
1884
+ ) {
1885
+ throw new Error(`Final message identity is invalid in turn ${turnId}.`);
1886
+ }
1887
+ } else if (finalMessageId !== null) {
1888
+ throw new Error(`Non-completed turn ${turnId} has a final message.`);
1889
+ }
1890
+ }
1891
+ if (meta.nextTurnNumber !== turns.length + 1) {
1892
+ throw new Error("Session turn counter is invalid.");
1893
+ }
1894
+ }
1895
+
1896
+ private requireTurnRow(turnId: TurnId): Record<string, unknown> {
1897
+ const row = this.database
1898
+ .query("SELECT * FROM turns WHERE turn_id = ?")
1899
+ .get(turnId) as Record<string, unknown> | null;
1900
+ if (row === null) {
1901
+ throw new Error(`Unknown turn ${turnId}.`);
1902
+ }
1903
+ return row;
1904
+ }
1905
+
1906
+ private requireIterationRow(iterationId: IterationId): Record<string, unknown> {
1907
+ const row = this.database
1908
+ .query("SELECT * FROM iterations WHERE iteration_id = ?")
1909
+ .get(iterationId) as Record<string, unknown> | null;
1910
+ if (row === null) {
1911
+ throw new Error(`Unknown iteration ${iterationId}.`);
1912
+ }
1913
+ return row;
1914
+ }
1915
+
1916
+ private touch(timestamp: string): void {
1917
+ const updated = this.database
1918
+ .query("UPDATE session_meta SET updated_at = ? WHERE singleton = 1")
1919
+ .run(timestamp);
1920
+ requireSingleChange(this.database, updated.changes, "touch session");
1921
+ }
1922
+
1923
+ private recoveryError(message: string): SessionError {
1924
+ return new SessionError("SESSION_RECOVERY_FAILED", "recover_session", message, {
1925
+ sessionId: this.sessionId,
1926
+ });
1927
+ }
1928
+
1929
+ private requireOpen(): void {
1930
+ if (this.closed) {
1931
+ throw new Error(`SessionStore ${this.sessionId} is closed.`);
1932
+ }
1933
+ }
1934
+
1935
+ private async correctDatabaseModes(): Promise<void> {
1936
+ await chmod(this.databasePath, 0o600);
1937
+ await chmodIfExists(`${this.databasePath}-wal`, 0o600);
1938
+ await chmodIfExists(`${this.databasePath}-shm`, 0o600);
1939
+ }
1940
+ }
1941
+
1942
+ export function createRuntimeContract(input: {
1943
+ modelName: string;
1944
+ profileName?: string;
1945
+ includeReasoningContent: boolean;
1946
+ contextProfile: ModelContextProfile;
1947
+ contextBudget: ModelContextBudget;
1948
+ systemPrompt: string;
1949
+ toolSchemaSha256: string;
1950
+ requestConfigSha256: string;
1951
+ }): RuntimeContractV1 {
1952
+ return Object.freeze({
1953
+ version: 1,
1954
+ modelName: input.modelName,
1955
+ ...(input.profileName === undefined ? {} : { profileName: input.profileName }),
1956
+ includeReasoningContent: input.includeReasoningContent,
1957
+ contextProfile: immutableCanonicalClone(input.contextProfile),
1958
+ contextBudget: immutableCanonicalClone(input.contextBudget),
1959
+ systemPromptSha256: sha256(input.systemPrompt),
1960
+ toolSchemaSha256: input.toolSchemaSha256,
1961
+ requestConfigSha256: input.requestConfigSha256,
1962
+ observationFormat: TOOL_OBSERVATION_FORMAT,
1963
+ });
1964
+ }
1965
+
1966
+ export function sessionDatabasePath(
1967
+ workspaceRoot: string,
1968
+ sessionId: SessionId,
1969
+ ): string {
1970
+ return path.join(workspaceRoot, ".tinker", "sessions", sessionId, "session.sqlite");
1971
+ }
1972
+
1973
+ function insertFrame(database: Database, frame: ProtocolFrame): void {
1974
+ database
1975
+ .query(
1976
+ `INSERT INTO protocol_frames (
1977
+ frame_id, session_id, turn_id, iteration_id, kind, state,
1978
+ first_ordinal, last_ordinal, created_at, closed_at
1979
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1980
+ )
1981
+ .run(
1982
+ frame.frameId,
1983
+ frame.sessionId,
1984
+ frame.turnId ?? null,
1985
+ frame.iterationId ?? null,
1986
+ frame.kind,
1987
+ frame.state,
1988
+ frame.firstOrdinal,
1989
+ frame.lastOrdinal ?? null,
1990
+ frame.createdAt,
1991
+ frame.closedAt ?? null,
1992
+ );
1993
+ }
1994
+
1995
+ function insertMessage(database: Database, message: CanonicalMessageRecord): void {
1996
+ const assistant = message.role === "assistant" ? message : undefined;
1997
+ const tool = message.role === "tool" ? message : undefined;
1998
+ const turnId = "turnId" in message ? message.turnId : null;
1999
+ const iterationId = "iterationId" in message ? message.iterationId : null;
2000
+ const reasoningPresent =
2001
+ assistant !== undefined && assistant.reasoningContent !== undefined ? 1 : 0;
2002
+ database
2003
+ .query(
2004
+ `INSERT INTO messages (
2005
+ message_id, session_id, frame_id, ordinal, role, turn_id, iteration_id,
2006
+ content, content_sha256, reasoning_content, reasoning_content_present,
2007
+ tool_calls_json, provider, model, tool_call_id, provider_tool_call_id,
2008
+ name, origin, created_at
2009
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2010
+ )
2011
+ .run(
2012
+ message.messageId,
2013
+ message.sessionId,
2014
+ message.frameId,
2015
+ message.ordinal,
2016
+ message.role,
2017
+ turnId,
2018
+ iterationId,
2019
+ message.content,
2020
+ message.contentSha256,
2021
+ assistant?.reasoningContent ?? null,
2022
+ reasoningPresent,
2023
+ assistant?.toolCalls === undefined
2024
+ ? null
2025
+ : stableJsonStringify(assistant.toolCalls),
2026
+ assistant?.provider ?? null,
2027
+ assistant?.model ?? null,
2028
+ tool?.toolCallId ?? null,
2029
+ tool?.providerToolCallId ?? null,
2030
+ tool?.name ?? null,
2031
+ message.origin,
2032
+ message.createdAt,
2033
+ );
2034
+ }
2035
+
2036
+ function insertToolResult(database: Database, result: ToolResultRecord): void {
2037
+ const returned = result.completion.kind === "returned" ? result.completion : null;
2038
+ const synthetic = result.completion.kind === "synthetic" ? result.completion : null;
2039
+ database
2040
+ .query(
2041
+ `INSERT INTO tool_results (
2042
+ tool_call_id, session_id, frame_id, tool_message_id, completion_kind,
2043
+ raw_json, raw_sha256, observation_format, synthetic_reason,
2044
+ synthetic_detail, observation_sha256, created_at
2045
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2046
+ )
2047
+ .run(
2048
+ result.toolCallId,
2049
+ result.sessionId,
2050
+ result.frameId,
2051
+ result.toolMessageId,
2052
+ result.completion.kind,
2053
+ returned === null ? null : stableJsonStringify(returned.raw),
2054
+ returned?.rawSha256 ?? null,
2055
+ returned?.observationFormat ?? null,
2056
+ synthetic?.reason ?? null,
2057
+ synthetic?.detail ?? null,
2058
+ result.observationSha256,
2059
+ result.createdAt,
2060
+ );
2061
+ }
2062
+
2063
+ function decodeFrame(rowValue: unknown): ProtocolFrame {
2064
+ const row = recordFromSql(rowValue, "protocol frame");
2065
+ const state = enumFromSql(row.state, ["open", "closed"] as const, "frame state");
2066
+ const kind = enumFromSql(
2067
+ row.kind,
2068
+ ["system", "user", "assistant_text", "tool_exchange"] as const,
2069
+ "frame kind",
2070
+ );
2071
+ const lastOrdinal = nullableNumberFromSql(row.last_ordinal, "last_ordinal");
2072
+ const closedAt = nullableStringFromSql(row.closed_at, "closed_at");
2073
+ return immutableRecord({
2074
+ frameId: stringFromSql(row.frame_id, "frame_id") as ProtocolFrameId,
2075
+ sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
2076
+ ...(row.turn_id === null
2077
+ ? {}
2078
+ : { turnId: stringFromSql(row.turn_id, "turn_id") as TurnId }),
2079
+ ...(row.iteration_id === null
2080
+ ? {}
2081
+ : {
2082
+ iterationId: stringFromSql(row.iteration_id, "iteration_id") as IterationId,
2083
+ }),
2084
+ kind,
2085
+ state,
2086
+ firstOrdinal: numberFromSql(row.first_ordinal, "first_ordinal"),
2087
+ ...(lastOrdinal === null ? {} : { lastOrdinal }),
2088
+ createdAt: timestampFromSql(row.created_at, "created_at"),
2089
+ ...(closedAt === null ? {} : { closedAt: timestampValue(closedAt, "closed_at") }),
2090
+ });
2091
+ }
2092
+
2093
+ function decodeMessage(rowValue: unknown): CanonicalMessageRecord {
2094
+ const row = recordFromSql(rowValue, "message");
2095
+ const base = {
2096
+ messageId: stringFromSql(row.message_id, "message_id") as MessageId,
2097
+ sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
2098
+ frameId: stringFromSql(row.frame_id, "frame_id") as ProtocolFrameId,
2099
+ ordinal: numberFromSql(row.ordinal, "ordinal"),
2100
+ contentSha256: stringFromSql(row.content_sha256, "content_sha256"),
2101
+ createdAt: timestampFromSql(row.created_at, "created_at"),
2102
+ };
2103
+ const role = enumFromSql(
2104
+ row.role,
2105
+ ["system", "user", "assistant", "tool"] as const,
2106
+ "message role",
2107
+ );
2108
+ switch (role) {
2109
+ case "system":
2110
+ return immutableRecord({
2111
+ ...base,
2112
+ role,
2113
+ content: stringFromSql(row.content, "content"),
2114
+ origin: "runtime",
2115
+ });
2116
+ case "user":
2117
+ return immutableRecord({
2118
+ ...base,
2119
+ role,
2120
+ turnId: stringFromSql(row.turn_id, "turn_id") as TurnId,
2121
+ content: stringFromSql(row.content, "content"),
2122
+ origin: "user",
2123
+ });
2124
+ case "assistant": {
2125
+ const content = nullableTextFromSql(row.content, "content");
2126
+ const reasoningPresent = numberFromSql(
2127
+ row.reasoning_content_present,
2128
+ "reasoning_content_present",
2129
+ );
2130
+ if (reasoningPresent !== 0 && reasoningPresent !== 1) {
2131
+ throw new Error("reasoning_content_present must be 0 or 1.");
2132
+ }
2133
+ const toolCalls =
2134
+ row.tool_calls_json === null
2135
+ ? undefined
2136
+ : decodeStoredToolCalls(
2137
+ stringFromSql(row.tool_calls_json, "tool_calls_json"),
2138
+ );
2139
+ return immutableRecord({
2140
+ ...base,
2141
+ role,
2142
+ turnId: stringFromSql(row.turn_id, "turn_id") as TurnId,
2143
+ iterationId: stringFromSql(row.iteration_id, "iteration_id") as IterationId,
2144
+ content,
2145
+ ...(reasoningPresent === 0
2146
+ ? {}
2147
+ : {
2148
+ reasoningContent: nullableTextFromSql(
2149
+ row.reasoning_content,
2150
+ "reasoning_content",
2151
+ ),
2152
+ }),
2153
+ ...(toolCalls === undefined ? {} : { toolCalls }),
2154
+ provider: stringFromSql(row.provider, "provider"),
2155
+ model: stringFromSql(row.model, "model"),
2156
+ origin: "model",
2157
+ });
2158
+ }
2159
+ case "tool":
2160
+ return immutableRecord({
2161
+ ...base,
2162
+ role,
2163
+ turnId: stringFromSql(row.turn_id, "turn_id") as TurnId,
2164
+ iterationId: stringFromSql(row.iteration_id, "iteration_id") as IterationId,
2165
+ toolCallId: stringFromSql(row.tool_call_id, "tool_call_id") as ToolCallId,
2166
+ providerToolCallId: stringFromSql(
2167
+ row.provider_tool_call_id,
2168
+ "provider_tool_call_id",
2169
+ ),
2170
+ name: stringFromSql(row.name, "name"),
2171
+ content: stringFromSql(row.content, "content"),
2172
+ origin: enumFromSql(row.origin, ["tool", "runtime"] as const, "tool origin"),
2173
+ });
2174
+ }
2175
+ }
2176
+
2177
+ function decodeToolResult(rowValue: unknown): ToolResultRecord {
2178
+ const row = recordFromSql(rowValue, "tool result");
2179
+ const kind = enumFromSql(
2180
+ row.completion_kind,
2181
+ ["returned", "synthetic"] as const,
2182
+ "completion kind",
2183
+ );
2184
+ let completion: ToolCompletion;
2185
+ if (kind === "returned") {
2186
+ completion = immutableRecord({
2187
+ kind,
2188
+ raw: decodeStoredToolRawResult(
2189
+ parseJson(stringFromSql(row.raw_json, "raw_json"), "raw_json"),
2190
+ ),
2191
+ rawSha256: stringFromSql(row.raw_sha256, "raw_sha256"),
2192
+ observationFormat: enumFromSql(
2193
+ row.observation_format,
2194
+ [TOOL_OBSERVATION_FORMAT] as const,
2195
+ "observation format",
2196
+ ),
2197
+ });
2198
+ } else {
2199
+ const reason = enumFromSql(
2200
+ row.synthetic_reason,
2201
+ [
2202
+ "cancelled_active",
2203
+ "skipped_after_cancel",
2204
+ "failed_active",
2205
+ "skipped_after_failure",
2206
+ "interrupted_active",
2207
+ "skipped_after_interruption",
2208
+ ] as const,
2209
+ "synthetic reason",
2210
+ );
2211
+ completion = immutableRecord({
2212
+ kind,
2213
+ reason,
2214
+ ...(row.synthetic_detail === null
2215
+ ? {}
2216
+ : {
2217
+ detail: stringFromSql(row.synthetic_detail, "synthetic_detail"),
2218
+ }),
2219
+ });
2220
+ }
2221
+ return immutableRecord({
2222
+ sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
2223
+ frameId: stringFromSql(row.frame_id, "frame_id") as ProtocolFrameId,
2224
+ toolCallId: stringFromSql(row.tool_call_id, "tool_call_id") as ToolCallId,
2225
+ toolMessageId: stringFromSql(row.tool_message_id, "tool_message_id") as MessageId,
2226
+ completion,
2227
+ observationSha256: stringFromSql(row.observation_sha256, "observation_sha256"),
2228
+ createdAt: timestampFromSql(row.created_at, "created_at"),
2229
+ });
2230
+ }
2231
+
2232
+ function decodeContextRevision(rowValue: unknown): StoredContextRevisionV5 {
2233
+ const row = recordFromSql(rowValue, "context revision");
2234
+ const kind = enumFromSql(
2235
+ row.kind,
2236
+ ["initial_full", "swap_only"] as const,
2237
+ "context revision kind",
2238
+ );
2239
+ const common = {
2240
+ revisionId: stringFromSql(row.revision_id, "revision_id") as ContextRevisionId,
2241
+ sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
2242
+ keepFromOrdinal: numberFromSql(row.keep_from_ordinal, "keep_from_ordinal"),
2243
+ sourceThroughOrdinal: numberFromSql(
2244
+ row.source_through_ordinal,
2245
+ "source_through_ordinal",
2246
+ ),
2247
+ addedOverrideCount: numberFromSql(row.added_override_count, "added_override_count"),
2248
+ totalOverrideCount: numberFromSql(row.total_override_count, "total_override_count"),
2249
+ overrideManifestSha256: sha256FromSql(
2250
+ row.override_manifest_sha256,
2251
+ "override_manifest_sha256",
2252
+ ),
2253
+ canonicalSequenceSha256: sha256FromSql(
2254
+ row.canonical_sequence_sha256,
2255
+ "canonical_sequence_sha256",
2256
+ ),
2257
+ renderedMessageSha256: sha256FromSql(
2258
+ row.rendered_message_sha256,
2259
+ "rendered_message_sha256",
2260
+ ),
2261
+ createdAt: timestampFromSql(row.created_at, "created_at"),
2262
+ };
2263
+ if (common.keepFromOrdinal !== 1) {
2264
+ throw new Error("Context revision keep_from_ordinal must be 1 in schema v5.");
2265
+ }
2266
+ const revisionNumber = numberFromSql(row.revision_number, "revision_number");
2267
+ const parentRevisionId = nullableStringFromSql(
2268
+ row.parent_revision_id,
2269
+ "parent_revision_id",
2270
+ ) as ContextRevisionId | null;
2271
+ if (kind === "initial_full") {
2272
+ if (
2273
+ revisionNumber !== 1 ||
2274
+ parentRevisionId !== null ||
2275
+ common.sourceThroughOrdinal !== 1 ||
2276
+ common.addedOverrideCount !== 0 ||
2277
+ common.totalOverrideCount !== 0 ||
2278
+ row.policy_version !== null ||
2279
+ row.renderer_format !== null ||
2280
+ row.plan_sha256 !== null
2281
+ ) {
2282
+ throw new Error("Initial context revision row is invalid.");
2283
+ }
2284
+ return Object.freeze({
2285
+ ...common,
2286
+ revisionNumber: 1,
2287
+ parentRevisionId: null,
2288
+ kind,
2289
+ keepFromOrdinal: 1,
2290
+ sourceThroughOrdinal: 1,
2291
+ addedOverrideCount: 0,
2292
+ totalOverrideCount: 0,
2293
+ });
2294
+ }
2295
+ if (
2296
+ revisionNumber < 2 ||
2297
+ parentRevisionId === null ||
2298
+ common.addedOverrideCount < 1 ||
2299
+ common.totalOverrideCount < common.addedOverrideCount
2300
+ ) {
2301
+ throw new Error("Swap context revision row is invalid.");
2302
+ }
2303
+ return Object.freeze({
2304
+ ...common,
2305
+ revisionNumber,
2306
+ parentRevisionId,
2307
+ kind,
2308
+ keepFromOrdinal: 1,
2309
+ policyVersion: enumFromSql(
2310
+ row.policy_version,
2311
+ ["swap-only-v1"] as const,
2312
+ "context revision policy",
2313
+ ),
2314
+ rendererFormat: enumFromSql(
2315
+ row.renderer_format,
2316
+ [SWAP_OBSERVATION_FORMAT] as const,
2317
+ "context revision renderer format",
2318
+ ),
2319
+ planSha256: sha256FromSql(row.plan_sha256, "plan_sha256"),
2320
+ });
2321
+ }
2322
+
2323
+ function decodeStoredSwapOverride(rowValue: unknown): StoredSwapOverrideV5 {
2324
+ const row = recordFromSql(rowValue, "context override");
2325
+ if (row.representation !== "swapped") {
2326
+ throw new Error("Context override representation must be swapped.");
2327
+ }
2328
+ return Object.freeze({
2329
+ introducedRevisionId: stringFromSql(
2330
+ row.introduced_revision_id,
2331
+ "introduced_revision_id",
2332
+ ) as ContextRevisionId,
2333
+ frameId: stringFromSql(row.frame_id, "frame_id") as ProtocolFrameId,
2334
+ messageId: stringFromSql(row.message_id, "message_id") as MessageId,
2335
+ ordinal: numberFromSql(row.ordinal, "ordinal"),
2336
+ rendererFormat: enumFromSql(
2337
+ row.renderer_format,
2338
+ [SWAP_OBSERVATION_FORMAT] as const,
2339
+ "context override renderer format",
2340
+ ),
2341
+ source: stringFromSql(row.source, "source") as StoredSwapOverrideV5["source"],
2342
+ originalContentSha256: sha256FromSql(
2343
+ row.original_content_sha256,
2344
+ "original_content_sha256",
2345
+ ),
2346
+ renderedContent: stringFromSql(row.rendered_content, "rendered_content"),
2347
+ renderedContentSha256: sha256FromSql(
2348
+ row.rendered_content_sha256,
2349
+ "rendered_content_sha256",
2350
+ ),
2351
+ originalBytes: numberFromSql(row.original_bytes, "original_bytes"),
2352
+ renderedBytes: numberFromSql(row.rendered_bytes, "rendered_bytes"),
2353
+ byteSavings: numberFromSql(row.byte_savings, "byte_savings"),
2354
+ createdAt: timestampFromSql(row.created_at, "created_at"),
2355
+ });
2356
+ }
2357
+
2358
+ function stripStoredOverride(override: StoredSwapOverrideV5): SwapOverride {
2359
+ return Object.freeze({
2360
+ frameId: override.frameId,
2361
+ messageId: override.messageId,
2362
+ ordinal: override.ordinal,
2363
+ source: override.source,
2364
+ originalContentSha256: override.originalContentSha256,
2365
+ renderedContent: override.renderedContent,
2366
+ renderedContentSha256: override.renderedContentSha256,
2367
+ originalBytes: override.originalBytes,
2368
+ renderedBytes: override.renderedBytes,
2369
+ byteSavings: override.byteSavings,
2370
+ });
2371
+ }
2372
+
2373
+ function protocolPrefixView(
2374
+ canonical: ProtocolContextView,
2375
+ throughOrdinal: number,
2376
+ ): ProtocolContextView {
2377
+ const messages = canonical.messages.filter(
2378
+ (message) => message.ordinal <= throughOrdinal,
2379
+ );
2380
+ const messageIds = new Set(messages.map((message) => message.messageId));
2381
+ return Object.freeze({
2382
+ sessionId: canonical.sessionId,
2383
+ faulted: false,
2384
+ frames: Object.freeze(
2385
+ canonical.frames.filter(
2386
+ (frame) =>
2387
+ frame.state === "closed" &&
2388
+ frame.lastOrdinal !== undefined &&
2389
+ frame.lastOrdinal <= throughOrdinal,
2390
+ ),
2391
+ ),
2392
+ messages: Object.freeze(messages),
2393
+ toolResults: Object.freeze(
2394
+ canonical.toolResults.filter((result) => messageIds.has(result.toolMessageId)),
2395
+ ),
2396
+ });
2397
+ }
2398
+
2399
+ export function decodeStoredToolCalls(json: string): readonly ToolCall[] {
2400
+ const value = parseJson(json, "tool_calls_json");
2401
+ if (!Array.isArray(value) || value.length === 0) {
2402
+ throw new Error("tool_calls_json must contain a non-empty array.");
2403
+ }
2404
+ const calls = value.map((entry, index): ToolCall => {
2405
+ const call = recordFromSql(entry, `tool call ${index}`);
2406
+ assertObjectKeys(
2407
+ call,
2408
+ [
2409
+ "args",
2410
+ "argsParseError",
2411
+ "iterationId",
2412
+ "iterationNumber",
2413
+ "name",
2414
+ "providerToolCallId",
2415
+ "rawArgs",
2416
+ "sessionId",
2417
+ "toolCallId",
2418
+ "toolCallNumber",
2419
+ "turnId",
2420
+ "turnNumber",
2421
+ ],
2422
+ [
2423
+ "args",
2424
+ "iterationId",
2425
+ "iterationNumber",
2426
+ "name",
2427
+ "providerToolCallId",
2428
+ "sessionId",
2429
+ "toolCallId",
2430
+ "toolCallNumber",
2431
+ "turnId",
2432
+ "turnNumber",
2433
+ ],
2434
+ `tool call ${index}`,
2435
+ );
2436
+ const toolCallNumber = numberFromJson(call.toolCallNumber, "toolCallNumber");
2437
+ return {
2438
+ sessionId: stringFromSql(call.sessionId, "sessionId") as SessionId,
2439
+ turnId: stringFromSql(call.turnId, "turnId") as TurnId,
2440
+ turnNumber: numberFromJson(call.turnNumber, "turnNumber"),
2441
+ iterationId: stringFromSql(call.iterationId, "iterationId") as IterationId,
2442
+ iterationNumber: numberFromJson(call.iterationNumber, "iterationNumber"),
2443
+ toolCallId: stringFromSql(call.toolCallId, "toolCallId") as ToolCallId,
2444
+ toolCallNumber,
2445
+ providerToolCallId: stringFromSql(call.providerToolCallId, "providerToolCallId"),
2446
+ name: stringFromSql(call.name, "name"),
2447
+ args: immutableCanonicalClone(call.args),
2448
+ ...(call.rawArgs === undefined
2449
+ ? {}
2450
+ : { rawArgs: stringFromSql(call.rawArgs, "rawArgs") }),
2451
+ ...(call.argsParseError === undefined
2452
+ ? {}
2453
+ : {
2454
+ argsParseError: stringFromSql(call.argsParseError, "argsParseError"),
2455
+ }),
2456
+ };
2457
+ });
2458
+ return Object.freeze(calls);
2459
+ }
2460
+
2461
+ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
2462
+ const raw = recordFromSql(value, "tool raw result");
2463
+ enumFromSql(
2464
+ raw.kind,
2465
+ [
2466
+ "read",
2467
+ "write",
2468
+ "edit",
2469
+ "glob",
2470
+ "grep",
2471
+ "bash",
2472
+ "task_list",
2473
+ "task_output",
2474
+ "task_stop",
2475
+ "web_search",
2476
+ "web_fetch",
2477
+ "recall",
2478
+ "mcp",
2479
+ "generic",
2480
+ ] as const,
2481
+ "tool raw result kind",
2482
+ );
2483
+ if (typeof raw.ok !== "boolean") {
2484
+ throw new Error("tool raw result ok must be a boolean.");
2485
+ }
2486
+ return immutableCanonicalClone(raw) as ToolRawResult;
2487
+ }
2488
+
2489
+ function decodeMeasuredContextState(
2490
+ value: unknown,
2491
+ expectedSessionId: SessionId,
2492
+ ): StoredMeasuredContextState {
2493
+ const row = recordFromSql(value, "context measurement state");
2494
+ const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
2495
+ if (sessionId !== expectedSessionId) {
2496
+ throw new Error(
2497
+ `Context measurement session ID ${sessionId} does not match store.`,
2498
+ );
2499
+ }
2500
+ const promptTokens = numberFromSql(row.prompt_tokens, "prompt_tokens");
2501
+ const completionTokens = numberFromSql(row.completion_tokens, "completion_tokens");
2502
+ const totalTokens = numberFromSql(row.total_tokens, "total_tokens");
2503
+ if (totalTokens !== promptTokens + completionTokens) {
2504
+ throw new Error(
2505
+ "Context measurement total_tokens must equal prompt_tokens + completion_tokens.",
2506
+ );
2507
+ }
2508
+ timestampFromSql(row.updated_at, "updated_at");
2509
+ return Object.freeze({
2510
+ revisionId: stringFromSql(row.revision_id, "revision_id") as ContextRevisionId,
2511
+ anchor: Object.freeze({
2512
+ totalTokens,
2513
+ promptTokens,
2514
+ completionTokens,
2515
+ segmentCount: numberFromSql(row.segment_count, "segment_count"),
2516
+ prefixHash: sha256FromSql(row.prefix_hash, "prefix_hash"),
2517
+ requestConfigHash: sha256FromSql(row.request_config_hash, "request_config_hash"),
2518
+ toolSchemaHash: sha256FromSql(row.tool_schema_hash, "tool_schema_hash"),
2519
+ }),
2520
+ });
2521
+ }
2522
+
2523
+ function assertCommitSwapRevisionInput(input: CommitSwapRevisionInput): void {
2524
+ if (
2525
+ input.revisionId.trim() === "" ||
2526
+ input.expectedBaseRevisionId.trim() === "" ||
2527
+ !Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
2528
+ input.expectedBaseRevisionNumber < 1 ||
2529
+ !Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
2530
+ input.expectedCanonicalThroughOrdinal < 1 ||
2531
+ input.addedOverrides.length < 1 ||
2532
+ input.policyVersion !== "swap-only-v1" ||
2533
+ input.rendererFormat !== SWAP_OBSERVATION_FORMAT
2534
+ ) {
2535
+ throw new Error("Commit swap revision input is invalid.");
2536
+ }
2537
+ for (const [name, hash] of [
2538
+ ["expectedBaseOverrideManifestSha256", input.expectedBaseOverrideManifestSha256],
2539
+ ["planHash", input.planHash],
2540
+ ["nextOverrideManifestSha256", input.nextOverrideManifestSha256],
2541
+ ["canonicalSequenceSha256", input.canonicalSequenceSha256],
2542
+ ["renderedMessageSha256", input.renderedMessageSha256],
2543
+ ] as const) {
2544
+ if (!/^[0-9a-f]{64}$/.test(hash)) {
2545
+ throw new Error(`Commit swap revision ${name} must be a SHA-256 hash.`);
2546
+ }
2547
+ }
2548
+ }
2549
+
2550
+ function decodeMeta(value: unknown, expectedSessionId: SessionId): StoredSessionMetaV5 {
2551
+ const row = recordFromSql(value, "session metadata");
2552
+ const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
2553
+ if (sessionId !== expectedSessionId) {
2554
+ throw new Error(`Metadata session ID ${sessionId} does not match directory.`);
2555
+ }
2556
+ const schemaVersion = numberFromSql(row.schema_version, "schema_version");
2557
+ if (schemaVersion !== 5) {
2558
+ throw new Error(
2559
+ `Session metadata schema version must be 5; received ${schemaVersion}.`,
2560
+ );
2561
+ }
2562
+ const projectInstructionFile = nullableStringFromSql(
2563
+ row.project_instruction_file,
2564
+ "project_instruction_file",
2565
+ );
2566
+ const projectInstructionByteLength = nullableNumberFromSql(
2567
+ row.project_instruction_byte_length,
2568
+ "project_instruction_byte_length",
2569
+ );
2570
+ const projectInstructionSha256 = nullableStringFromSql(
2571
+ row.project_instruction_sha256,
2572
+ "project_instruction_sha256",
2573
+ );
2574
+ if (
2575
+ (projectInstructionFile === null) !== (projectInstructionByteLength === null) ||
2576
+ (projectInstructionFile === null) !== (projectInstructionSha256 === null)
2577
+ ) {
2578
+ throw new Error("Project instruction metadata must be entirely set or null.");
2579
+ }
2580
+ if (
2581
+ projectInstructionFile !== null &&
2582
+ projectInstructionFile !== "AGENTS.md" &&
2583
+ projectInstructionFile !== "CLAUDE.md"
2584
+ ) {
2585
+ throw new Error(`Invalid project instruction file ${projectInstructionFile}.`);
2586
+ }
2587
+ const projectInstruction: ProjectInstructionManifest | undefined =
2588
+ projectInstructionFile === null ||
2589
+ projectInstructionByteLength === null ||
2590
+ projectInstructionSha256 === null
2591
+ ? undefined
2592
+ : {
2593
+ path: projectInstructionFile === "AGENTS.md" ? "AGENTS.md" : "CLAUDE.md",
2594
+ byteLength: projectInstructionByteLength,
2595
+ sha256: sha256FromSql(projectInstructionSha256, "project_instruction_sha256"),
2596
+ };
2597
+ return {
2598
+ schemaVersion,
2599
+ schemaFingerprint: stringFromSql(row.schema_fingerprint, "schema_fingerprint"),
2600
+ initializationState: enumFromSql(
2601
+ row.initialization_state,
2602
+ ["creating", "ready"] as const,
2603
+ "initialization_state",
2604
+ ),
2605
+ sessionId,
2606
+ workspaceRoot: stringFromSql(row.workspace_root, "workspace_root"),
2607
+ modelName: stringFromSql(row.model_name, "model_name"),
2608
+ systemPromptSha256: stringFromSql(row.system_prompt_sha256, "system_prompt_sha256"),
2609
+ ...(projectInstruction === undefined ? {} : { projectInstruction }),
2610
+ toolSchemaSha256: nullableStringFromSql(
2611
+ row.tool_schema_sha256,
2612
+ "tool_schema_sha256",
2613
+ ),
2614
+ runtimeContractJson: nullableStringFromSql(
2615
+ row.runtime_contract_json,
2616
+ "runtime_contract_json",
2617
+ ),
2618
+ runtimeContractSha256: nullableStringFromSql(
2619
+ row.runtime_contract_sha256,
2620
+ "runtime_contract_sha256",
2621
+ ),
2622
+ activeRevisionId: stringFromSql(
2623
+ row.active_revision_id,
2624
+ "active_revision_id",
2625
+ ) as ContextRevisionId,
2626
+ nextTurnNumber: numberFromSql(row.next_turn_number, "next_turn_number"),
2627
+ nextEventSequence: numberFromSql(row.next_event_sequence, "next_event_sequence"),
2628
+ openCount: numberFromSql(row.open_count, "open_count"),
2629
+ createdAt: timestampFromSql(row.created_at, "created_at"),
2630
+ updatedAt: timestampFromSql(row.updated_at, "updated_at"),
2631
+ lastOpenedAt: timestampFromSql(row.last_opened_at, "last_opened_at"),
2632
+ lastClosedAt:
2633
+ row.last_closed_at === null
2634
+ ? null
2635
+ : timestampFromSql(row.last_closed_at, "last_closed_at"),
2636
+ lastCloseReason:
2637
+ row.last_close_reason === null
2638
+ ? null
2639
+ : enumFromSql(
2640
+ row.last_close_reason,
2641
+ [
2642
+ "oneshot_complete",
2643
+ "tui_exit",
2644
+ "session_switch",
2645
+ "runner_failed",
2646
+ "initialization_failed",
2647
+ ] as const,
2648
+ "last_close_reason",
2649
+ ),
2650
+ };
2651
+ }
2652
+
2653
+ function runtimeContractDifferences(
2654
+ storedJson: string | null,
2655
+ current: RuntimeContractV1,
2656
+ ): string[] {
2657
+ if (storedJson === null) {
2658
+ return ["runtimeContract"];
2659
+ }
2660
+ const stored = parseJson(storedJson, "runtime_contract_json");
2661
+ if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
2662
+ return ["runtimeContract"];
2663
+ }
2664
+ const record = stored as Record<string, unknown>;
2665
+ return Object.keys(current).filter(
2666
+ (key) =>
2667
+ stableJsonStringify(record[key]) !==
2668
+ stableJsonStringify(current[key as keyof RuntimeContractV1]),
2669
+ );
2670
+ }
2671
+
2672
+ function openWritableDatabase(databasePath: string): Database {
2673
+ const database = new Database(databasePath, {
2674
+ create: false,
2675
+ readwrite: true,
2676
+ strict: true,
2677
+ safeIntegers: true,
2678
+ });
2679
+ configureWritableDatabase(database);
2680
+ return database;
2681
+ }
2682
+
2683
+ function runTransaction<T>(database: Database, operation: () => T): T {
2684
+ database.exec("BEGIN IMMEDIATE");
2685
+ try {
2686
+ const result = operation();
2687
+ database.exec("COMMIT");
2688
+ return result;
2689
+ } catch (error) {
2690
+ try {
2691
+ database.exec("ROLLBACK");
2692
+ } catch {
2693
+ // Preserve the mutation error; the session will fault and close the database.
2694
+ }
2695
+ throw error;
2696
+ }
2697
+ }
2698
+
2699
+ async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
2700
+ if (!path.isAbsolute(workspaceRoot)) {
2701
+ throw new Error("Session workspace root must be absolute.");
2702
+ }
2703
+ return realpath(workspaceRoot);
2704
+ }
2705
+
2706
+ async function ensureSessionsRoot(workspaceRoot: string): Promise<string> {
2707
+ const tinkerRoot = path.join(workspaceRoot, ".tinker");
2708
+ const sessionsRoot = path.join(tinkerRoot, "sessions");
2709
+ await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
2710
+ await validateSessionsRoot(sessionsRoot);
2711
+ await chmod(tinkerRoot, 0o700);
2712
+ await chmod(sessionsRoot, 0o700);
2713
+ return sessionsRoot;
2714
+ }
2715
+
2716
+ async function validateSessionsRoot(
2717
+ sessionsRoot: string,
2718
+ sessionId?: SessionId,
2719
+ ): Promise<void> {
2720
+ const tinkerRoot = path.dirname(sessionsRoot);
2721
+ for (const directory of [tinkerRoot, sessionsRoot]) {
2722
+ let stats;
2723
+ try {
2724
+ stats = await lstat(directory);
2725
+ } catch (error) {
2726
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2727
+ throw new SessionError(
2728
+ "SESSION_STORE_NOT_FOUND",
2729
+ "validate_session_root",
2730
+ `Session root does not exist: ${directory}.`,
2731
+ { sessionId, cause: error },
2732
+ );
2733
+ }
2734
+ throw error;
2735
+ }
2736
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
2737
+ throw new SessionError(
2738
+ "SESSION_PERMISSION_INVALID",
2739
+ "validate_session_root",
2740
+ `Session root must not be a symlink: ${directory}.`,
2741
+ { sessionId },
2742
+ );
2743
+ }
2744
+ }
2745
+ if ((await realpath(sessionsRoot)) !== sessionsRoot) {
2746
+ throw new SessionError(
2747
+ "SESSION_PERMISSION_INVALID",
2748
+ "validate_session_root",
2749
+ `Session root resolves outside its canonical path: ${sessionsRoot}.`,
2750
+ { sessionId },
2751
+ );
2752
+ }
2753
+ }
2754
+
2755
+ function safeSessionDirectory(sessionsRoot: string, sessionId: SessionId): string {
2756
+ const value = String(sessionId);
2757
+ if (
2758
+ value.trim() === "" ||
2759
+ value !== value.trim() ||
2760
+ value.includes("/") ||
2761
+ value.includes("\\") ||
2762
+ value === "." ||
2763
+ value === ".."
2764
+ ) {
2765
+ throw new SessionError(
2766
+ "SESSION_ID_INVALID",
2767
+ "resolve_session_path",
2768
+ `Unsafe session ID: ${JSON.stringify(value)}.`,
2769
+ { sessionId },
2770
+ );
2771
+ }
2772
+ const directory = path.join(sessionsRoot, value);
2773
+ if (path.dirname(directory) !== sessionsRoot) {
2774
+ throw new SessionError(
2775
+ "SESSION_ID_INVALID",
2776
+ "resolve_session_path",
2777
+ `Session ID escapes the sessions directory: ${JSON.stringify(value)}.`,
2778
+ { sessionId },
2779
+ );
2780
+ }
2781
+ return directory;
2782
+ }
2783
+
2784
+ async function validateSecureDirectory(
2785
+ directory: string,
2786
+ sessionId: SessionId,
2787
+ ): Promise<void> {
2788
+ let stats;
2789
+ try {
2790
+ stats = await lstat(directory);
2791
+ } catch (error) {
2792
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2793
+ throw new SessionError(
2794
+ "SESSION_STORE_NOT_FOUND",
2795
+ "open_session",
2796
+ `Session directory does not exist: ${directory}.`,
2797
+ { sessionId, cause: error },
2798
+ );
2799
+ }
2800
+ throw error;
2801
+ }
2802
+ if (
2803
+ !stats.isDirectory() ||
2804
+ stats.isSymbolicLink() ||
2805
+ (stats.mode & 0o077) !== 0 ||
2806
+ (typeof process.getuid === "function" && stats.uid !== process.getuid())
2807
+ ) {
2808
+ throw new SessionError(
2809
+ "SESSION_PERMISSION_INVALID",
2810
+ "open_session",
2811
+ `Session directory must be an owner-only real directory: ${directory}.`,
2812
+ { sessionId },
2813
+ );
2814
+ }
2815
+ }
2816
+
2817
+ async function validateSecureFile(
2818
+ filePath: string,
2819
+ sessionId: SessionId,
2820
+ ): Promise<void> {
2821
+ let stats;
2822
+ try {
2823
+ stats = await lstat(filePath);
2824
+ } catch (error) {
2825
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2826
+ throw new SessionError(
2827
+ "SESSION_STORE_NOT_FOUND",
2828
+ "open_session",
2829
+ `Session database does not exist: ${filePath}.`,
2830
+ { sessionId, cause: error },
2831
+ );
2832
+ }
2833
+ throw error;
2834
+ }
2835
+ if (
2836
+ !stats.isFile() ||
2837
+ stats.isSymbolicLink() ||
2838
+ (stats.mode & 0o077) !== 0 ||
2839
+ (typeof process.getuid === "function" && stats.uid !== process.getuid())
2840
+ ) {
2841
+ throw new SessionError(
2842
+ "SESSION_PERMISSION_INVALID",
2843
+ "open_session",
2844
+ `Session database must be an owner-only regular file: ${filePath}.`,
2845
+ { sessionId },
2846
+ );
2847
+ }
2848
+ }
2849
+
2850
+ async function validateSecureOptionalFile(
2851
+ filePath: string,
2852
+ sessionId: SessionId,
2853
+ ): Promise<void> {
2854
+ try {
2855
+ await lstat(filePath);
2856
+ } catch (error) {
2857
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2858
+ return;
2859
+ }
2860
+ throw error;
2861
+ }
2862
+ await validateSecureFile(filePath, sessionId);
2863
+ }
2864
+
2865
+ async function removeKnownInitializationFiles(sessionDirectory: string): Promise<void> {
2866
+ for (const name of [
2867
+ "session.sqlite-wal",
2868
+ "session.sqlite-shm",
2869
+ "session.sqlite",
2870
+ "events.jsonl",
2871
+ "observations.md",
2872
+ "active.lock.reclaim",
2873
+ "active.lock",
2874
+ ]) {
2875
+ await unlinkIfExists(path.join(sessionDirectory, name));
2876
+ }
2877
+ try {
2878
+ await rmdir(sessionDirectory);
2879
+ } catch (error) {
2880
+ if (
2881
+ !new Set(["ENOENT", "ENOTEMPTY"]).has((error as NodeJS.ErrnoException).code ?? "")
2882
+ ) {
2883
+ throw error;
2884
+ }
2885
+ }
2886
+ }
2887
+
2888
+ async function chmodIfExists(filePath: string, mode: number): Promise<void> {
2889
+ try {
2890
+ await chmod(filePath, mode);
2891
+ } catch (error) {
2892
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
2893
+ throw error;
2894
+ }
2895
+ }
2896
+ }
2897
+
2898
+ async function unlinkIfExists(filePath: string): Promise<void> {
2899
+ try {
2900
+ await unlink(filePath);
2901
+ } catch (error) {
2902
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
2903
+ throw error;
2904
+ }
2905
+ }
2906
+ }
2907
+
2908
+ function requireSingleChange(
2909
+ database: Database,
2910
+ reportedChanges: number | bigint,
2911
+ operation: string,
2912
+ ): void {
2913
+ const row = database.query("SELECT changes() AS changes").get() as {
2914
+ changes: number | bigint;
2915
+ };
2916
+ if (Number(row.changes) !== 1) {
2917
+ throw new Error(
2918
+ `${operation} must change exactly one row; changed ${row.changes} (driver reported ${reportedChanges}).`,
2919
+ );
2920
+ }
2921
+ }
2922
+
2923
+ function recordFromSql(value: unknown, name: string): Record<string, unknown> {
2924
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2925
+ throw new Error(`${name} must be an object.`);
2926
+ }
2927
+ return value as Record<string, unknown>;
2928
+ }
2929
+
2930
+ function assertObjectKeys(
2931
+ record: Record<string, unknown>,
2932
+ allowed: readonly string[],
2933
+ required: readonly string[],
2934
+ name: string,
2935
+ ): void {
2936
+ const allowedSet = new Set(allowed);
2937
+ const unknown = Object.keys(record).filter((key) => !allowedSet.has(key));
2938
+ const missing = required.filter((key) => !(key in record));
2939
+ if (unknown.length > 0 || missing.length > 0) {
2940
+ throw new Error(
2941
+ `${name} has invalid keys; unknown=${unknown.join(",") || "none"} missing=${missing.join(",") || "none"}.`,
2942
+ );
2943
+ }
2944
+ }
2945
+
2946
+ function stringFromSql(value: unknown, name: string): string {
2947
+ if (typeof value !== "string" || value === "") {
2948
+ throw new Error(`${name} must be a non-empty string.`);
2949
+ }
2950
+ return value;
2951
+ }
2952
+
2953
+ function sha256FromSql(value: unknown, name: string): string {
2954
+ const hash = stringFromSql(value, name);
2955
+ if (!/^[0-9a-f]{64}$/.test(hash)) {
2956
+ throw new Error(`${name} must be a lowercase SHA-256 digest.`);
2957
+ }
2958
+ return hash;
2959
+ }
2960
+
2961
+ function assertMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
2962
+ for (const [name, value] of [
2963
+ ["promptTokens", anchor.promptTokens],
2964
+ ["completionTokens", anchor.completionTokens],
2965
+ ["totalTokens", anchor.totalTokens],
2966
+ ["segmentCount", anchor.segmentCount],
2967
+ ] as const) {
2968
+ if (!Number.isSafeInteger(value) || value < 0) {
2969
+ throw new Error(
2970
+ `Measured context anchor ${name} must be a non-negative safe integer; received ${value}.`,
2971
+ );
2972
+ }
2973
+ }
2974
+ if (anchor.totalTokens !== anchor.promptTokens + anchor.completionTokens) {
2975
+ throw new Error(
2976
+ "Measured context anchor totalTokens must equal promptTokens + completionTokens.",
2977
+ );
2978
+ }
2979
+ for (const [name, value] of [
2980
+ ["prefixHash", anchor.prefixHash],
2981
+ ["requestConfigHash", anchor.requestConfigHash],
2982
+ ["toolSchemaHash", anchor.toolSchemaHash],
2983
+ ] as const) {
2984
+ if (!/^[0-9a-f]{64}$/.test(value)) {
2985
+ throw new Error(`Measured context anchor ${name} must be a SHA-256 digest.`);
2986
+ }
2987
+ }
2988
+ }
2989
+
2990
+ function nullableStringFromSql(value: unknown, name: string): string | null {
2991
+ return value === null ? null : stringFromSql(value, name);
2992
+ }
2993
+
2994
+ function nullableTextFromSql(value: unknown, name: string): string | null {
2995
+ if (value === null) {
2996
+ return null;
2997
+ }
2998
+ if (typeof value !== "string") {
2999
+ throw new Error(`${name} must be a string or null.`);
3000
+ }
3001
+ return value;
3002
+ }
3003
+
3004
+ function numberFromSql(value: unknown, name: string): number {
3005
+ const number = typeof value === "bigint" ? Number(value) : value;
3006
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) {
3007
+ throw new Error(`${name} must be a safe non-negative integer.`);
3008
+ }
3009
+ return number;
3010
+ }
3011
+
3012
+ function nullableNumberFromSql(value: unknown, name: string): number | null {
3013
+ return value === null ? null : numberFromSql(value, name);
3014
+ }
3015
+
3016
+ function numberFromJson(value: unknown, name: string): number {
3017
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
3018
+ throw new Error(`${name} must be a positive safe integer.`);
3019
+ }
3020
+ return value as number;
3021
+ }
3022
+
3023
+ function enumFromSql<const T extends readonly string[]>(
3024
+ value: unknown,
3025
+ values: T,
3026
+ name: string,
3027
+ ): T[number] {
3028
+ if (typeof value !== "string" || !values.includes(value)) {
3029
+ throw new Error(`${name} has unsupported value ${JSON.stringify(value)}.`);
3030
+ }
3031
+ return value;
3032
+ }
3033
+
3034
+ function timestampFromSql(value: unknown, name: string): string {
3035
+ return timestampValue(stringFromSql(value, name), name);
3036
+ }
3037
+
3038
+ function timestampValue(value: string, name: string): string {
3039
+ if (Number.isNaN(Date.parse(value)) || !value.endsWith("Z")) {
3040
+ throw new Error(`${name} must be a UTC ISO-8601 timestamp.`);
3041
+ }
3042
+ return value;
3043
+ }
3044
+
3045
+ function parseJson(value: string, name: string): unknown {
3046
+ try {
3047
+ return JSON.parse(value);
3048
+ } catch (error) {
3049
+ throw new Error(`${name} is not valid JSON.`, { cause: error });
3050
+ }
3051
+ }
3052
+
3053
+ function requireItem<T>(items: readonly T[], index: number, name: string): T {
3054
+ const item = items[index];
3055
+ if (item === undefined) {
3056
+ throw new Error(`Missing ${name} at index ${index}.`);
3057
+ }
3058
+ return item;
3059
+ }
3060
+
3061
+ function errorMessage(error: unknown): string {
3062
+ return error instanceof Error ? error.message : String(error);
3063
+ }
3064
+
3065
+ function asError(error: unknown): Error {
3066
+ return error instanceof Error ? error : new Error(String(error));
3067
+ }