tinker-agent 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +79 -1
  2. package/README.md +81 -11
  3. package/package.json +5 -3
  4. package/src/agent/runtime-context-capabilities.ts +19 -0
  5. package/src/agent/runtime-context-events.ts +127 -0
  6. package/src/agent/runtime-context-maintenance.ts +780 -0
  7. package/src/agent/runtime-hosted-session.ts +443 -0
  8. package/src/agent/runtime-interactions.ts +291 -0
  9. package/src/agent/runtime-prompt-scheduler.ts +182 -0
  10. package/src/agent/runtime-session-contracts.ts +317 -0
  11. package/src/agent/runtime-session.ts +250 -2130
  12. package/src/agent/runtime-skills.ts +544 -0
  13. package/src/cli/command-line.ts +26 -2
  14. package/src/cli/connect-runner.tsx +26 -0
  15. package/src/cli/main.ts +26 -0
  16. package/src/cli/output.ts +1 -1
  17. package/src/cli/public-cli-contract.ts +18 -0
  18. package/src/cli/public-config-contract.ts +1 -1
  19. package/src/cli/runner-dependencies.ts +6 -5
  20. package/src/cli/serve-runner.ts +45 -0
  21. package/src/cli/serve-runtime.ts +100 -0
  22. package/src/context/context-automation-policy.ts +12 -118
  23. package/src/context/context-swap-renderer.ts +14 -0
  24. package/src/events/types.ts +12 -0
  25. package/src/memory/memory-get-tool.ts +1 -1
  26. package/src/observation/observation-builder.ts +128 -48
  27. package/src/remote/client.ts +350 -0
  28. package/src/remote/config.ts +95 -0
  29. package/src/remote/http-server.ts +240 -0
  30. package/src/remote/protocol.ts +228 -0
  31. package/src/remote/service-store.ts +175 -0
  32. package/src/remote/service.ts +219 -0
  33. package/src/remote/sync-hub.ts +95 -0
  34. package/src/session/remote-history-reader.ts +143 -0
  35. package/src/session/resume-projection.ts +47 -21
  36. package/src/session/session-history-access.ts +238 -0
  37. package/src/session/session-store-context-readers.ts +183 -0
  38. package/src/session/session-store-ledger-writer.ts +315 -0
  39. package/src/session/session-store-record-writer.ts +318 -0
  40. package/src/session/session-store-recovery.ts +225 -0
  41. package/src/session/session-store-revisions.ts +1004 -0
  42. package/src/session/session-store-sql.ts +40 -0
  43. package/src/session/session-store-validation.ts +657 -0
  44. package/src/session/session-store.ts +756 -3186
  45. package/src/tools/bash-task.ts +46 -18
  46. package/src/tools/bash.ts +44 -2
  47. package/src/tools/glob.ts +107 -19
  48. package/src/tools/grep-output.ts +130 -0
  49. package/src/tools/grep-pagination.ts +73 -0
  50. package/src/tools/grep-path.ts +11 -0
  51. package/src/tools/grep-snippets.ts +111 -0
  52. package/src/tools/grep.ts +139 -154
  53. package/src/tools/read.ts +0 -9
  54. package/src/tools/recall.ts +106 -50
  55. package/src/tools/registry.ts +4 -6
  56. package/src/tools/ripgrep.ts +19 -26
  57. package/src/tools/shell-process.ts +30 -4
  58. package/src/tools/task-output-range.ts +146 -0
  59. package/src/tools/task-output-tool.ts +35 -5
  60. package/src/tools/task-output.ts +35 -0
  61. package/src/tools/task-stop.ts +2 -1
  62. package/src/tools/task-tool-args.ts +34 -0
  63. package/src/tools/terminal-screen.ts +11 -2
  64. package/src/tools/types.ts +39 -2
  65. package/src/tui/event-store.ts +23 -5
  66. package/src/tui/remote-app.tsx +210 -0
@@ -1,84 +1,56 @@
1
- import path from "node:path";
2
- import { chmod, mkdir, open, readdir, rename, rmdir } from "node:fs/promises";
3
- import { randomUUID } from "node:crypto";
4
1
  import { Database } from "bun:sqlite";
5
- import type {
6
- ContextRevisionId,
7
- ContextSurfaceId,
8
- IterationId,
9
- MessageId,
10
- RuntimeIdFactory,
11
- SessionId,
12
- TurnId,
13
- } from "../ids/runtime-id";
14
- import { sha256, stableJsonStringify } from "../model/model-request-preflight";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, mkdir, open, readdir, rename, rmdir } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import type { MeasuredContextAnchor } from "../agent/context-meter";
15
6
  import {
16
- canonicalHomeRoot,
17
- resolveWorkspaceStorageRoot,
18
- workspaceStorageRoot,
19
- } from "./workspace-storage";
7
+ InMemorySessionLedger,
8
+ type LedgerMutation,
9
+ type SessionLedgerCommitter,
10
+ } from "../agent/session-ledger";
11
+ import type { IterationIdentity } from "../agent/types";
20
12
  import {
21
13
  ContextProtocolError,
22
14
  ContextProtocolValidator,
23
15
  } from "../context/context-protocol-validator";
24
- import {
25
- activeOverrideManifestHash,
26
- canonicalSequenceHash,
27
- renderedMessageHash,
28
- } from "../context/compiled-context-hash";
29
- import {
30
- ContextRevisionCompiler,
31
- createInitialContextRevision,
32
- } from "../context/context-revision-compiler";
33
- import {
34
- ContextSwapRenderer,
35
- SWAP_OBSERVATION_FORMAT,
36
- SWAP_TOOL_IMAGE_FORMAT,
37
- } from "../context/context-swap-renderer";
38
- import {
39
- contentHash,
40
- immutableRecord,
41
- interruptedCompletionInputs,
42
- observationForCompletion,
43
- userMessageHash,
44
- type CanonicalMessageRecord,
45
- type ProtocolContextView,
46
- type ProtocolFrame,
47
- type ToolCompletion,
48
- type ToolResultRecord,
49
- } from "../context/protocol-frame";
50
- import {
51
- contextSurfaceChangeManifestHash,
52
- contextSurfaceChanges,
53
- validateStoredContextSurface,
54
- type StoredContextSurfaceV8,
55
- } from "../context/context-surface";
56
16
  import type {
57
17
  StoredContextRevisionV8,
58
18
  StoredContextSnapshotV8,
59
- StoredContextOverrideV8,
60
- SwapOverride,
61
19
  } from "../context/context-revision";
62
- import type { IterationIdentity } from "../agent/types";
20
+ import { createInitialContextRevision } from "../context/context-revision-compiler";
63
21
  import {
64
- canonicalToolResultContentHash,
65
- toolResultDisplayText,
66
- validateToolResultContent,
67
- } from "../agent/tool-result-content";
68
- import { validateUserMessage, type ImageAssetRef } from "../image/image-types";
69
- import { ImageAssetStore } from "../image/image-asset-store";
70
- import type { MeasuredContextAnchor } from "../agent/context-meter";
71
- import type { ProjectInstructionManifest } from "../instructions/project-instructions";
22
+ validateStoredContextSurface,
23
+ type StoredContextSurfaceV8,
24
+ } from "../context/context-surface";
72
25
  import type {
73
26
  ActiveTurnBoundary,
74
27
  ClosedTurnBoundary,
75
28
  } from "../context/prefix-retirement-planner";
29
+ import { contentHash, type ProtocolContextView } from "../context/protocol-frame";
30
+ import type {
31
+ ContextRevisionId,
32
+ IterationId,
33
+ MessageId,
34
+ RuntimeIdFactory,
35
+ SessionId,
36
+ TurnId,
37
+ } from "../ids/runtime-id";
38
+ import { ImageAssetStore } from "../image/image-asset-store";
39
+ import { type ImageAssetRef } from "../image/image-types";
40
+ import type { ProjectInstructionManifest } from "../instructions/project-instructions";
41
+ import { sha256, stableJsonStringify } from "../model/model-request-preflight";
76
42
  import {
77
- AdmissionStaleError,
78
- InMemorySessionLedger,
79
- type LedgerMutation,
80
- type SessionLedgerCommitter,
81
- } from "../agent/session-ledger";
43
+ cloneDiagnosticFiles,
44
+ rekeyProtocolView,
45
+ rekeyStoredToolCalls,
46
+ rewriteCloneRevisionHashes,
47
+ SESSION_SCOPED_TABLES,
48
+ } from "./session-clone-helpers";
49
+ import {
50
+ compatibilityContractDifferences,
51
+ decodeMeta,
52
+ normalizeSessionCompatibilityContract,
53
+ } from "./session-compatibility-codec";
82
54
  import { SessionError, sessionOpenError, sessionWriteError } from "./session-errors";
83
55
  import {
84
56
  createSessionHistoryReader,
@@ -86,59 +58,26 @@ import {
86
58
  } from "./session-history-reader";
87
59
  import { SessionLease } from "./session-lock";
88
60
  import {
89
- SESSION_SCHEMA_V10_FINGERPRINT,
90
- SESSION_SCHEMA_VERSION,
91
- upgradeActiveTurnRetirementContract,
92
- upgradeRecallIndexContract,
93
61
  configureWritableDatabase,
94
62
  createSessionSchema,
95
63
  dropSessionCloneTriggers,
96
64
  rebuildRecallIndex,
97
65
  reinstallSessionCloneTriggers,
66
+ SESSION_SCHEMA_V10_FINGERPRINT,
67
+ SESSION_SCHEMA_VERSION,
68
+ upgradeActiveTurnRetirementContract,
69
+ upgradeRecallIndexContract,
98
70
  verifyReadableSessionSchema,
99
71
  verifyRecallIndex,
100
72
  verifySessionSchema,
101
73
  verifySqliteIntegrity,
102
74
  } from "./session-schema";
103
75
  import {
104
- SKILL_ACTIVATION_RECEIPT_FORMAT,
105
- SKILL_POLICY_VERSION,
106
- renderSkillActivationReceipt,
107
- } from "../skills/skill-context";
108
- import {
109
- assertMeasuredContextAnchor,
110
- enumFromSql,
111
- nullableStringFromSql,
112
- nullableTextFromSql,
113
- numberFromSql,
114
- recordFromSql,
115
- sha256FromSql,
116
- stringFromSql,
117
- timestampFromSql,
118
- } from "./session-store-value-codecs";
119
- export { decodeStoredToolRawResult } from "./session-tool-result-codec";
120
- import {
121
- cloneDiagnosticFiles,
122
- rekeyProtocolView,
123
- rekeyStoredToolCalls,
124
- rewriteCloneRevisionHashes,
125
- SESSION_SCOPED_TABLES,
126
- } from "./session-clone-helpers";
127
- import {
128
- assertPathMissing,
129
- canonicalWorkspaceRoot,
130
- chmodIfExists,
131
- ensureSessionsRoot,
132
- removeKnownInitializationFiles,
133
- safeSessionDirectory,
134
- unlinkIfExists,
135
- validateSecureDirectory,
136
- validateSecureFile,
137
- validateSecureOptionalFile,
138
- validateSessionsRoot,
139
- } from "./session-store-filesystem";
76
+ loadMeasuredContextState,
77
+ readRetirementBoundaries,
78
+ requireActiveRevisionId,
79
+ } from "./session-store-context-readers";
140
80
  import {
141
- skillActivationManifestSha256,
142
81
  type CloneSessionStoreInput,
143
82
  type CommitPrefixRetirementRevisionInput,
144
83
  type CommitPrefixRetirementRevisionOptions,
@@ -155,43 +94,72 @@ import {
155
94
  type SessionCloseReason,
156
95
  type SessionCompatibilityContract,
157
96
  type SessionRecoveryResult,
158
- type StoredMeasuredContextState,
159
97
  type StoredSessionMetaV10,
160
98
  type StoredSkillActivation,
161
99
  } from "./session-store-contracts";
162
- export * from "./session-store-contracts";
163
- export { createSessionCompatibilityContract } from "./session-compatibility-codec";
164
100
  import {
165
- compatibilityContractDifferences,
166
- decodeMeta,
167
- normalizeSessionCompatibilityContract,
168
- } from "./session-compatibility-codec";
169
- export { decodeStoredToolCalls } from "./session-store-record-codecs";
101
+ assertPathMissing,
102
+ canonicalWorkspaceRoot,
103
+ chmodIfExists,
104
+ ensureSessionsRoot,
105
+ removeKnownInitializationFiles,
106
+ safeSessionDirectory,
107
+ unlinkIfExists,
108
+ validateSecureDirectory,
109
+ validateSecureFile,
110
+ validateSecureOptionalFile,
111
+ validateSessionsRoot,
112
+ } from "./session-store-filesystem";
113
+ import { SessionStoreLedgerWriter } from "./session-store-ledger-writer";
170
114
  import {
171
- decodeContextRevision,
172
- decodeContextSurface,
173
115
  decodeFrame,
174
116
  decodeMessage,
175
117
  decodeSkillActivation,
176
- decodeStoredSwapOverride,
177
- imageAssetRefFromAttachment,
178
118
  decodeToolResult,
119
+ imageAssetRefFromAttachment,
179
120
  loadMessageImageAttachments,
180
121
  loadToolMessageContentBlocks,
181
- protocolPrefixView,
182
- stripStoredOverride,
183
122
  } from "./session-store-record-codecs";
123
+ import {
124
+ insertContextSurface,
125
+ insertFrame,
126
+ insertMessage,
127
+ } from "./session-store-record-writer";
128
+ import { SessionStoreRecovery } from "./session-store-recovery";
129
+ import { SessionStoreRevisions } from "./session-store-revisions";
130
+ import { requireItem, requireSingleChange, runTransaction } from "./session-store-sql";
131
+ import { SessionStoreValidation } from "./session-store-validation";
132
+ import {
133
+ assertMeasuredContextAnchor,
134
+ enumFromSql,
135
+ nullableStringFromSql,
136
+ nullableTextFromSql,
137
+ numberFromSql,
138
+ recordFromSql,
139
+ stringFromSql,
140
+ } from "./session-store-value-codecs";
141
+ import {
142
+ canonicalHomeRoot,
143
+ resolveWorkspaceStorageRoot,
144
+ workspaceStorageRoot,
145
+ } from "./workspace-storage";
146
+ export { createSessionCompatibilityContract } from "./session-compatibility-codec";
147
+ export * from "./session-store-contracts";
148
+ export { decodeStoredToolCalls } from "./session-store-record-codecs";
149
+ export { decodeStoredToolRawResult } from "./session-tool-result-codec";
184
150
 
185
151
  export class SessionStore implements SessionLedgerCommitter {
186
152
  readonly sessionId: SessionId;
187
153
  readonly workspaceRoot: string;
188
154
  readonly sessionDirectory: string;
189
155
  readonly databasePath: string;
156
+ private readonly validation: SessionStoreValidation;
157
+ private readonly recovery: SessionStoreRecovery;
158
+ private readonly revisions: SessionStoreRevisions;
159
+ private readonly ledgerWriter: SessionStoreLedgerWriter;
190
160
  private closed = false;
191
161
  private recallIndexRebuilt = false;
192
162
  private readonly validator = new ContextProtocolValidator();
193
- private readonly revisionCompiler = new ContextRevisionCompiler();
194
- private readonly swapRenderer = new ContextSwapRenderer();
195
163
 
196
164
  private constructor(
197
165
  private readonly database: Database,
@@ -211,12 +179,84 @@ export class SessionStore implements SessionLedgerCommitter {
211
179
  this.databasePath = input.databasePath;
212
180
  this.clock = input.clock;
213
181
  this.homeRoot = input.homeRoot;
182
+ this.validation = new SessionStoreValidation(database, this.sessionId, (states) =>
183
+ this.loadSkillActivations(states),
184
+ );
185
+ this.revisions = new SessionStoreRevisions(
186
+ database,
187
+ this.sessionId,
188
+ this.clock,
189
+ {
190
+ loadContextSnapshot: () => this.loadContextSnapshot(),
191
+ assertContextRevisionBoundary: (turnId) =>
192
+ this.assertContextRevisionBoundary(turnId),
193
+ assertContextRevisionIdle: () => this.assertContextRevisionIdle(),
194
+ readMeta: () => this.readMeta(),
195
+ loadSkillActivations: (states) => this.loadSkillActivations(states),
196
+ },
197
+ () => this.requireOpen(),
198
+ (overrides, canonical) =>
199
+ this.validation.validateAddedOverrides(overrides, canonical),
200
+ );
201
+ this.ledgerWriter = new SessionStoreLedgerWriter(
202
+ database,
203
+ this.sessionId,
204
+ this.clock,
205
+ () => this.requireOpen(),
206
+ {
207
+ readMeta: () => this.readMeta(),
208
+ loadContextSnapshot: () => this.loadContextSnapshot(),
209
+ },
210
+ );
211
+ this.recovery = new SessionStoreRecovery(
212
+ database,
213
+ this.sessionId,
214
+ this.clock,
215
+ () => this.requireOpen(),
216
+ this.ledgerWriter,
217
+ {
218
+ loadProtocolView: () => this.loadProtocolView(),
219
+ validateAll: (options) => this.validateAll(options),
220
+ },
221
+ );
214
222
  }
215
223
 
216
224
  private readonly homeRoot?: string;
217
225
 
218
226
  private readonly clock: () => string;
219
227
 
228
+ commit(mutation: LedgerMutation): void {
229
+ this.ledgerWriter.commit(mutation);
230
+ }
231
+
232
+ commitSwapRevision(
233
+ input: CommitSwapRevisionInput,
234
+ options: CommitSwapRevisionOptions = {},
235
+ ): Extract<StoredContextRevisionV8, { kind: "swap_only" }> {
236
+ return this.revisions.commitSwapRevision(input, options);
237
+ }
238
+
239
+ commitPrefixRetirementRevision(
240
+ input: CommitPrefixRetirementRevisionInput,
241
+ options: CommitPrefixRetirementRevisionOptions = {},
242
+ ): Extract<StoredContextRevisionV8, { kind: "prefix_retirement" }> {
243
+ return this.revisions.commitPrefixRetirementRevision(input, options);
244
+ }
245
+
246
+ commitSurfaceRefresh(
247
+ input: CommitSurfaceRefreshInput,
248
+ options: CommitSurfaceRefreshOptions = {},
249
+ ): Extract<StoredContextRevisionV8, { kind: "surface_refresh" }> {
250
+ return this.revisions.commitSurfaceRefresh(input, options);
251
+ }
252
+
253
+ commitSkillsUpdate(
254
+ input: CommitSkillsUpdateInput,
255
+ options: CommitSkillsUpdateOptions = {},
256
+ ): Extract<StoredContextRevisionV8, { kind: "skills_update" }> {
257
+ return this.revisions.commitSkillsUpdate(input, options);
258
+ }
259
+
220
260
  static async createNew(input: CreateNewSessionStoreInput): Promise<SessionStore> {
221
261
  const clock = input.clock ?? (() => new Date().toISOString());
222
262
  const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
@@ -421,43 +461,12 @@ export class SessionStore implements SessionLedgerCommitter {
421
461
  }
422
462
  }
423
463
 
424
- commit(mutation: LedgerMutation): void {
425
- this.requireOpen();
426
- const now = this.clock();
427
- try {
428
- runTransaction(this.database, () => {
429
- switch (mutation.kind) {
430
- case "begin_turn":
431
- this.commitBeginTurn(mutation, now);
432
- break;
433
- case "append_steering_users":
434
- this.commitSteeringUsers(mutation, now);
435
- break;
436
- case "append_assistant":
437
- this.commitAssistant(mutation, now);
438
- break;
439
- case "commit_tool_completions":
440
- this.commitToolCompletions(mutation, now);
441
- break;
442
- case "finish_turn":
443
- this.commitFinishTurn(mutation, now);
444
- break;
445
- }
446
- });
447
- } catch (error) {
448
- if (error instanceof AdmissionStaleError) {
449
- throw error;
450
- }
451
- throw sessionWriteError(mutation.kind, this.sessionId, error);
452
- }
453
- }
454
-
455
464
  beginIteration(iteration: IterationIdentity): void {
456
465
  this.requireOpen();
457
466
  const now = this.clock();
458
467
  try {
459
468
  runTransaction(this.database, () => {
460
- const turn = this.requireTurnRow(iteration.turnId);
469
+ const turn = this.ledgerWriter.requireTurnRow(iteration.turnId);
461
470
  if (
462
471
  turn.status !== "open" ||
463
472
  numberFromSql(turn.next_iteration_number, "next_iteration_number") !==
@@ -519,7 +528,7 @@ export class SessionStore implements SessionLedgerCommitter {
519
528
  updated.changes,
520
529
  "finish continuing iteration",
521
530
  );
522
- this.touch(now);
531
+ this.ledgerWriter.touch(now);
523
532
  });
524
533
  } catch (error) {
525
534
  throw sessionWriteError("finish_iteration", this.sessionId, error);
@@ -695,7 +704,7 @@ export class SessionStore implements SessionLedgerCommitter {
695
704
  written.changes,
696
705
  "write measured context anchor",
697
706
  );
698
- this.touch(now);
707
+ this.ledgerWriter.touch(now);
699
708
  });
700
709
  } catch (error) {
701
710
  throw sessionWriteError("write_context_measurement", this.sessionId, error);
@@ -704,7 +713,7 @@ export class SessionStore implements SessionLedgerCommitter {
704
713
 
705
714
  readActiveMeasuredContextAnchor(): MeasuredContextAnchor | undefined {
706
715
  this.requireOpen();
707
- const state = this.loadMeasuredContextState();
716
+ const state = loadMeasuredContextState(this.database, this.sessionId);
708
717
  if (state === undefined) {
709
718
  return undefined;
710
719
  }
@@ -757,7 +766,7 @@ export class SessionStore implements SessionLedgerCommitter {
757
766
  this.assertContextRevisionIdle();
758
767
  const canonical = this.loadProtocolView();
759
768
  this.validator.validate(canonical, { fullIntegrity: true });
760
- return this.readRetirementBoundaries(canonical).closedTurns;
769
+ return readRetirementBoundaries(this.database, canonical).closedTurns;
761
770
  }
762
771
 
763
772
  loadRetirementBoundaries(activeTurnId?: TurnId): {
@@ -771,2670 +780,740 @@ export class SessionStore implements SessionLedgerCommitter {
771
780
  allowOpenTail: activeTurnId !== undefined,
772
781
  fullIntegrity: true,
773
782
  });
774
- return this.readRetirementBoundaries(canonical, activeTurnId);
783
+ return readRetirementBoundaries(this.database, canonical, activeTurnId);
775
784
  }
776
785
 
777
- private readRetirementBoundaries(
778
- canonical: ProtocolContextView,
779
- activeTurnId?: TurnId,
780
- ): {
781
- readonly closedTurns: readonly ClosedTurnBoundary[];
782
- readonly activeTurn?: ActiveTurnBoundary;
783
- } {
786
+ loadSkillActivations(
787
+ states?: readonly StoredSkillActivation["state"][],
788
+ ): readonly StoredSkillActivation[] {
789
+ this.requireOpen();
784
790
  const rows = this.database
785
- .query("SELECT * FROM turns ORDER BY turn_number")
786
- .all() as Array<Record<string, unknown>>;
787
- const boundaries: ClosedTurnBoundary[] = [];
788
- let activeTurn: ActiveTurnBoundary | undefined;
789
- let expectedOrdinal = 2;
790
- for (let index = 0; index < rows.length; index += 1) {
791
- const row = requireItem(rows, index, "turn row");
792
- const turnId = stringFromSql(row.turn_id, "turn_id") as TurnId;
793
- const turnNumber = numberFromSql(row.turn_number, "turn_number");
794
- const status = enumFromSql(
795
- row.status,
796
- ["open", "completed", "failed", "cancelled", "interrupted"] as const,
797
- "turn status",
798
- );
799
- const frames = canonical.frames.filter((frame) => frame.turnId === turnId);
800
- const messages = canonical.messages.filter(
801
- (message) => message.role !== "system" && message.turnId === turnId,
802
- );
803
- const firstMessage = messages[0];
804
- const lastMessage = messages.at(-1);
805
- if (status === "open") {
806
- if (
807
- activeTurnId === undefined ||
808
- turnId !== activeTurnId ||
809
- index !== rows.length - 1 ||
810
- turnNumber !== index + 1 ||
811
- messages.length < 1 ||
812
- frames.length < 1 ||
813
- firstMessage?.role !== "user" ||
814
- firstMessage.ordinal !== expectedOrdinal ||
815
- lastMessage?.ordinal !== canonical.messages.length ||
816
- frames.some((frame) => frame.state !== "closed")
817
- ) {
818
- throw new Error(`Turn ${turnId} has an invalid active boundary.`);
819
- }
820
- activeTurn = Object.freeze({
821
- turnId,
822
- turnNumber,
823
- firstOrdinal: expectedOrdinal,
824
- });
825
- expectedOrdinal = canonical.messages.length + 1;
826
- continue;
827
- }
828
- let nextFrameOrdinal = expectedOrdinal;
829
- for (const frame of frames) {
830
- if (
831
- frame.state !== "closed" ||
832
- frame.firstOrdinal !== nextFrameOrdinal ||
833
- frame.lastOrdinal === undefined
834
- ) {
835
- throw new Error(`Turn ${turnId} has an invalid closed frame boundary.`);
836
- }
837
- nextFrameOrdinal = frame.lastOrdinal + 1;
838
- }
839
- if (
840
- turnNumber !== index + 1 ||
841
- frames.length < 1 ||
842
- messages.length < 1 ||
843
- firstMessage?.role !== "user" ||
844
- firstMessage.ordinal !== expectedOrdinal ||
845
- lastMessage === undefined ||
846
- nextFrameOrdinal !== lastMessage.ordinal + 1
847
- ) {
848
- throw new Error(`Turn ${turnId} has an invalid canonical boundary.`);
849
- }
850
- boundaries.push(
851
- Object.freeze({
852
- turnId,
853
- turnNumber,
854
- status,
855
- firstOrdinal: expectedOrdinal,
856
- lastOrdinal: lastMessage.ordinal,
857
- frameCount: frames.length,
858
- messageCount: messages.length,
859
- }),
860
- );
861
- expectedOrdinal = lastMessage.ordinal + 1;
862
- }
863
- if (expectedOrdinal !== canonical.messages.length + 1) {
864
- throw new Error("Closed turn boundaries do not cover canonical history.");
865
- }
866
- if ((activeTurnId === undefined) !== (activeTurn === undefined)) {
867
- throw new Error("Active retirement boundary does not match the open turn.");
868
- }
869
- return Object.freeze({
870
- closedTurns: Object.freeze(boundaries),
871
- ...(activeTurn === undefined ? {} : { activeTurn }),
872
- });
791
+ .query(
792
+ `SELECT sa.*, m.ordinal AS activation_ordinal
793
+ FROM skill_activations sa
794
+ JOIN messages m ON m.message_id = sa.activation_message_id
795
+ ORDER BY m.ordinal`,
796
+ )
797
+ .all()
798
+ .map(decodeSkillActivation);
799
+ const filtered =
800
+ states === undefined ? rows : rows.filter((row) => states.includes(row.state));
801
+ return Object.freeze(filtered);
873
802
  }
874
803
 
875
- commitSwapRevision(
876
- input: CommitSwapRevisionInput,
877
- options: CommitSwapRevisionOptions = {},
878
- ): Extract<StoredContextRevisionV8, { kind: "swap_only" }> {
804
+ markSkillActivationsDispatched(input: {
805
+ iterationId: IterationId;
806
+ activationMessageIds: readonly MessageId[];
807
+ }): readonly StoredSkillActivation[] {
879
808
  this.requireOpen();
880
- assertCommitSwapRevisionInput(input);
809
+ if (input.activationMessageIds.length === 0) {
810
+ return Object.freeze([]);
811
+ }
812
+ if (
813
+ new Set(input.activationMessageIds).size !== input.activationMessageIds.length
814
+ ) {
815
+ throw new Error("Agent Skill dispatch contains duplicate activation messages.");
816
+ }
881
817
  const now = this.clock();
882
818
  try {
883
819
  return runTransaction(this.database, () => {
884
- const snapshot = this.loadContextSnapshot();
885
- const baseRevision = snapshot.revision;
886
- if (
887
- baseRevision.revisionId !== input.expectedBaseRevisionId ||
888
- baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
889
- snapshot.canonical.messages.length !==
890
- input.expectedCanonicalThroughOrdinal ||
891
- baseRevision.activeOverrideManifestSha256 !==
892
- input.expectedBaseActiveOverrideManifestSha256
893
- ) {
894
- throw new Error("Context revision commit base is stale.");
895
- }
896
- this.assertContextRevisionBoundary(input.activeTurnId);
897
-
898
- const active = this.revisionCompiler.compileActive(snapshot);
899
- const candidateOverrides = [
900
- ...snapshot.activeOverrides,
901
- ...input.addedOverrides,
902
- ];
903
- if (
904
- new Set(candidateOverrides.map((override) => override.messageId)).size !==
905
- candidateOverrides.length ||
906
- activeOverrideManifestHash(candidateOverrides) !==
907
- input.nextActiveOverrideManifestSha256
908
- ) {
909
- throw new Error("Candidate override manifest is invalid.");
820
+ const iteration = this.ledgerWriter.requireIterationRow(input.iterationId);
821
+ if (iteration.outcome !== "open") {
822
+ throw new Error(`Iteration ${input.iterationId} is not open for dispatch.`);
910
823
  }
911
- const candidate = this.revisionCompiler.compileProspective({
912
- active,
913
- canonical: snapshot.canonical,
914
- activeOverrides: snapshot.activeOverrides,
915
- addedOverrides: input.addedOverrides,
916
- activeSurface: snapshot.surface,
917
- });
918
- if (
919
- canonicalSequenceHash(
920
- snapshot.canonical,
921
- input.expectedCanonicalThroughOrdinal,
922
- ) !== input.canonicalSequenceSha256 ||
923
- renderedMessageHash(
924
- candidate.entries,
925
- input.expectedCanonicalThroughOrdinal,
926
- ) !== input.renderedMessageSha256
927
- ) {
928
- throw new Error("Candidate context revision prefix hash is invalid.");
929
- }
930
- this.validateAddedOverrides(input.addedOverrides, snapshot.canonical);
931
-
932
- const revisionNumber = baseRevision.revisionNumber + 1;
933
- const activeOverrideCount =
934
- baseRevision.activeOverrideCount + input.addedOverrides.length;
935
- options.faultInjector?.("before_revision_insert");
936
- this.database
937
- .query(
938
- `INSERT INTO context_revisions (
939
- revision_id, session_id, revision_number, parent_revision_id, kind,
940
- surface_id, surface_sha256, keep_from_ordinal,
941
- source_through_ordinal, added_override_count,
942
- active_override_count, active_override_manifest_sha256,
943
- canonical_sequence_sha256, rendered_message_sha256, policy_version,
944
- renderer_format, plan_sha256, change_manifest_sha256, created_at
945
- ) VALUES (?, ?, ?, ?, 'swap_only', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
946
- )
947
- .run(
948
- input.revisionId,
949
- this.sessionId,
950
- revisionNumber,
951
- baseRevision.revisionId,
952
- baseRevision.surfaceId,
953
- baseRevision.surfaceSha256,
954
- baseRevision.keepFromOrdinal,
955
- input.expectedCanonicalThroughOrdinal,
956
- input.addedOverrides.length,
957
- activeOverrideCount,
958
- input.nextActiveOverrideManifestSha256,
959
- input.canonicalSequenceSha256,
960
- input.renderedMessageSha256,
961
- input.policyVersion,
962
- input.rendererFormat,
963
- input.planHash,
964
- now,
965
- );
966
- options.faultInjector?.("after_revision_insert");
967
-
968
- for (let index = 0; index < input.addedOverrides.length; index += 1) {
969
- const override = requireItem(
970
- input.addedOverrides,
971
- index,
972
- "added context override",
973
- );
974
- this.database
824
+ for (const messageId of input.activationMessageIds) {
825
+ const updated = this.database
975
826
  .query(
976
- `INSERT INTO context_overrides (
977
- introduced_revision_id, session_id, message_id, frame_id, ordinal,
978
- representation, renderer_format, source, original_content_sha256,
979
- rendered_content, rendered_content_sha256, original_bytes,
980
- rendered_bytes, byte_savings, created_at
981
- ) VALUES (?, ?, ?, ?, ?, 'swapped', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
827
+ `UPDATE skill_activations
828
+ SET state = 'dispatched', dispatched_iteration_id = ?, updated_at = ?
829
+ WHERE activation_message_id = ? AND session_id = ? AND state = 'pending'`,
982
830
  )
983
- .run(
984
- input.revisionId,
985
- this.sessionId,
986
- override.messageId,
987
- override.frameId,
988
- override.ordinal,
989
- input.rendererFormat,
990
- override.source,
991
- override.originalContentSha256,
992
- override.renderedContent,
993
- override.renderedContentSha256,
994
- override.originalBytes,
995
- override.renderedBytes,
996
- override.byteSavings,
997
- now,
998
- );
999
- if (index === 0) {
1000
- options.faultInjector?.("after_first_override_insert");
1001
- }
1002
- }
1003
- options.faultInjector?.("after_overrides_insert");
1004
-
1005
- const storedCandidateOverrides = this.database
1006
- .query(
1007
- `SELECT co.* FROM context_overrides co
1008
- JOIN context_revisions cr
1009
- ON cr.revision_id = co.introduced_revision_id
1010
- WHERE cr.revision_number <= ? AND co.ordinal >= ?
1011
- ORDER BY co.ordinal`,
1012
- )
1013
- .all(revisionNumber, baseRevision.keepFromOrdinal)
1014
- .map(decodeStoredSwapOverride);
1015
- if (
1016
- storedCandidateOverrides.length !== activeOverrideCount ||
1017
- activeOverrideManifestHash(storedCandidateOverrides) !==
1018
- input.nextActiveOverrideManifestSha256
1019
- ) {
1020
- throw new Error("Stored candidate override readback is invalid.");
831
+ .run(input.iterationId, now, messageId, this.sessionId);
832
+ requireSingleChange(
833
+ this.database,
834
+ updated.changes,
835
+ `dispatch Agent Skill activation ${messageId}`,
836
+ );
1021
837
  }
1022
-
1023
- this.database.query("DELETE FROM context_measurement_state").run();
1024
- options.faultInjector?.("after_measurement_delete");
1025
- const switched = this.database
1026
- .query(
1027
- `UPDATE session_meta
1028
- SET active_revision_id = ?, updated_at = ?
1029
- WHERE singleton = 1 AND active_revision_id = ?`,
1030
- )
1031
- .run(input.revisionId, now, baseRevision.revisionId);
1032
- requireSingleChange(
1033
- this.database,
1034
- switched.changes,
1035
- "activate context revision",
838
+ this.ledgerWriter.touch(now);
839
+ const dispatched = this.loadSkillActivations(["dispatched"]).filter((row) =>
840
+ input.activationMessageIds.includes(row.activationMessageId),
1036
841
  );
1037
- options.faultInjector?.("after_active_update");
1038
-
1039
- const readback = this.loadContextSnapshot();
1040
- if (
1041
- readback.revision.kind !== "swap_only" ||
1042
- readback.revision.revisionId !== input.revisionId ||
1043
- this.loadMeasuredContextState() !== undefined
1044
- ) {
1045
- throw new Error("Committed context revision readback failed.");
842
+ if (dispatched.length !== input.activationMessageIds.length) {
843
+ throw new Error("Agent Skill dispatch readback failed.");
1046
844
  }
1047
- return readback.revision;
845
+ return Object.freeze(dispatched);
1048
846
  });
1049
847
  } catch (error) {
1050
- if (requireActiveRevisionId(this.readMeta()) !== input.expectedBaseRevisionId) {
1051
- throw new Error("Failed context revision transaction changed active state.", {
1052
- cause: error,
1053
- });
1054
- }
1055
- throw sessionWriteError("commit_context_revision", this.sessionId, error);
848
+ throw sessionWriteError("dispatch_skill_activations", this.sessionId, error);
1056
849
  }
1057
850
  }
1058
851
 
1059
- commitPrefixRetirementRevision(
1060
- input: CommitPrefixRetirementRevisionInput,
1061
- options: CommitPrefixRetirementRevisionOptions = {},
1062
- ): Extract<StoredContextRevisionV8, { kind: "prefix_retirement" }> {
852
+ markResumed(): number {
1063
853
  this.requireOpen();
1064
- assertCommitPrefixRetirementRevisionInput(input);
1065
854
  const now = this.clock();
1066
855
  try {
1067
856
  return runTransaction(this.database, () => {
1068
- const snapshot = this.loadContextSnapshot();
1069
- const baseRevision = snapshot.revision;
1070
- if (
1071
- baseRevision.revisionId !== input.expectedBaseRevisionId ||
1072
- baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
1073
- baseRevision.keepFromOrdinal !== input.expectedBaseKeepFromOrdinal ||
1074
- snapshot.canonical.messages.length !==
1075
- input.expectedCanonicalThroughOrdinal ||
1076
- snapshot.surface.surfaceSha256 !== input.expectedSurfaceSha256 ||
1077
- baseRevision.activeOverrideManifestSha256 !==
1078
- input.expectedBaseActiveOverrideManifestSha256
1079
- ) {
1080
- throw new Error("Prefix retirement commit base is stale.");
1081
- }
1082
- this.assertContextRevisionBoundary(input.activeTurnId);
1083
- const retirementBoundaries = this.readRetirementBoundaries(
1084
- snapshot.canonical,
1085
- input.activeTurnId,
1086
- );
1087
- const closedTurns = retirementBoundaries.closedTurns;
1088
- const activeTurns = closedTurns.filter(
1089
- (turn) => turn.firstOrdinal >= baseRevision.keepFromOrdinal,
1090
- );
1091
- const nextBoundary = [
1092
- ...activeTurns,
1093
- ...(retirementBoundaries.activeTurn === undefined
1094
- ? []
1095
- : [retirementBoundaries.activeTurn]),
1096
- ].find((turn) => turn.firstOrdinal === input.nextKeepFromOrdinal);
1097
- const retiredTurns = activeTurns.filter(
1098
- (turn) => turn.lastOrdinal < input.nextKeepFromOrdinal,
1099
- );
1100
- if (
1101
- nextBoundary === undefined ||
1102
- input.nextKeepFromOrdinal <= baseRevision.keepFromOrdinal ||
1103
- retiredTurns.length !== input.retiredTurnCount ||
1104
- retiredTurns.reduce((total, turn) => total + turn.frameCount, 0) !==
1105
- input.retiredFrameCount ||
1106
- retiredTurns.reduce((total, turn) => total + turn.messageCount, 0) !==
1107
- input.retiredMessageCount
1108
- ) {
1109
- throw new Error("Prefix retirement turn boundary is invalid.");
1110
- }
1111
-
1112
- const active = this.revisionCompiler.compileActive(snapshot);
1113
- const nextActiveOverrides = snapshot.activeOverrides.filter(
1114
- (override) => override.ordinal >= input.nextKeepFromOrdinal,
1115
- );
1116
- const candidate = this.revisionCompiler.compileProspective({
1117
- active,
1118
- canonical: snapshot.canonical,
1119
- activeOverrides: snapshot.activeOverrides,
1120
- addedOverrides: [],
1121
- activeSurface: snapshot.surface,
1122
- keepFromOrdinal: input.nextKeepFromOrdinal,
1123
- });
1124
- if (
1125
- input.retiredThroughOrdinal !== input.nextKeepFromOrdinal - 1 ||
1126
- nextActiveOverrides.length !== input.nextActiveOverrideCount ||
1127
- activeOverrideManifestHash(nextActiveOverrides) !==
1128
- input.nextActiveOverrideManifestSha256 ||
1129
- canonicalSequenceHash(
1130
- snapshot.canonical,
1131
- input.expectedCanonicalThroughOrdinal,
1132
- ) !== input.canonicalSequenceSha256 ||
1133
- renderedMessageHash(
1134
- candidate.entries,
1135
- input.expectedCanonicalThroughOrdinal,
1136
- ) !== input.renderedMessageSha256
1137
- ) {
1138
- throw new Error("Prefix retirement candidate is invalid.");
1139
- }
1140
-
1141
- const revisionNumber = baseRevision.revisionNumber + 1;
1142
- options.faultInjector?.("before_revision_insert");
1143
- this.database
1144
- .query(
1145
- `INSERT INTO context_revisions (
1146
- revision_id, session_id, revision_number, parent_revision_id, kind,
1147
- surface_id, surface_sha256, keep_from_ordinal,
1148
- source_through_ordinal, added_override_count, active_override_count,
1149
- active_override_manifest_sha256, canonical_sequence_sha256,
1150
- rendered_message_sha256, policy_version, renderer_format,
1151
- plan_sha256, change_manifest_sha256, retired_through_ordinal,
1152
- retired_turn_count, retired_frame_count, retired_message_count,
1153
- created_at
1154
- ) VALUES (?, ?, ?, ?, 'prefix_retirement', ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, NULL, ?, NULL, ?, ?, ?, ?, ?)`,
1155
- )
1156
- .run(
1157
- input.revisionId,
1158
- this.sessionId,
1159
- revisionNumber,
1160
- baseRevision.revisionId,
1161
- baseRevision.surfaceId,
1162
- baseRevision.surfaceSha256,
1163
- input.nextKeepFromOrdinal,
1164
- input.expectedCanonicalThroughOrdinal,
1165
- input.nextActiveOverrideCount,
1166
- input.nextActiveOverrideManifestSha256,
1167
- input.canonicalSequenceSha256,
1168
- input.renderedMessageSha256,
1169
- input.policyVersion,
1170
- input.planHash,
1171
- input.retiredThroughOrdinal,
1172
- input.retiredTurnCount,
1173
- input.retiredFrameCount,
1174
- input.retiredMessageCount,
1175
- now,
1176
- );
1177
- options.faultInjector?.("after_revision_insert");
1178
-
1179
- const storedActiveOverrides = this.database
1180
- .query(
1181
- `SELECT co.* FROM context_overrides co
1182
- JOIN context_revisions introduced
1183
- ON introduced.revision_id = co.introduced_revision_id
1184
- WHERE introduced.revision_number <= ? AND co.ordinal >= ?
1185
- ORDER BY co.ordinal`,
1186
- )
1187
- .all(revisionNumber, input.nextKeepFromOrdinal)
1188
- .map(decodeStoredSwapOverride);
1189
- if (
1190
- storedActiveOverrides.length !== input.nextActiveOverrideCount ||
1191
- activeOverrideManifestHash(storedActiveOverrides) !==
1192
- input.nextActiveOverrideManifestSha256
1193
- ) {
1194
- throw new Error("Prefix retirement override readback is invalid.");
1195
- }
1196
- options.faultInjector?.("after_override_readback");
1197
-
1198
- this.database.query("DELETE FROM context_measurement_state").run();
1199
- options.faultInjector?.("after_measurement_delete");
1200
- const switched = this.database
857
+ const meta = this.readMeta();
858
+ const next = meta.openCount + 1;
859
+ const updated = this.database
1201
860
  .query(
1202
- `UPDATE session_meta SET active_revision_id = ?, updated_at = ?
1203
- WHERE singleton = 1 AND active_revision_id = ?`,
861
+ `UPDATE session_meta
862
+ SET open_count = ?, last_opened_at = ?, updated_at = ?,
863
+ last_closed_at = NULL, last_close_reason = NULL
864
+ WHERE singleton = 1 AND open_count = ?`,
1204
865
  )
1205
- .run(input.revisionId, now, baseRevision.revisionId);
1206
- requireSingleChange(
1207
- this.database,
1208
- switched.changes,
1209
- "activate prefix retirement revision",
1210
- );
1211
- options.faultInjector?.("after_active_update");
1212
-
1213
- const readback = this.loadContextSnapshot();
1214
- if (
1215
- readback.revision.kind !== "prefix_retirement" ||
1216
- readback.revision.revisionId !== input.revisionId ||
1217
- readback.revision.keepFromOrdinal !== input.nextKeepFromOrdinal ||
1218
- this.loadMeasuredContextState() !== undefined
1219
- ) {
1220
- throw new Error("Committed prefix retirement readback failed.");
1221
- }
1222
- options.faultInjector?.("after_snapshot_readback");
1223
- return readback.revision;
866
+ .run(next, now, now, meta.openCount);
867
+ requireSingleChange(this.database, updated.changes, "increment open count");
868
+ return next;
1224
869
  });
1225
870
  } catch (error) {
1226
- if (requireActiveRevisionId(this.readMeta()) !== input.expectedBaseRevisionId) {
1227
- throw new Error("Failed prefix retirement changed active state.", {
1228
- cause: error,
1229
- });
1230
- }
1231
- throw sessionWriteError("commit_prefix_retirement", this.sessionId, error);
871
+ throw sessionWriteError("mark_resumed", this.sessionId, error);
1232
872
  }
1233
873
  }
1234
874
 
1235
- commitSurfaceRefresh(
1236
- input: CommitSurfaceRefreshInput,
1237
- options: CommitSurfaceRefreshOptions = {},
1238
- ): Extract<StoredContextRevisionV8, { kind: "surface_refresh" }> {
1239
- this.requireOpen();
1240
- assertCommitSurfaceRefreshInput(input);
1241
- const now = this.clock();
1242
- try {
1243
- return runTransaction(this.database, () => {
1244
- const snapshot = this.loadContextSnapshot();
1245
- const baseRevision = snapshot.revision;
1246
- if (
1247
- baseRevision.revisionId !== input.expectedBaseRevisionId ||
1248
- baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
1249
- snapshot.canonical.messages.length !==
1250
- input.expectedCanonicalThroughOrdinal ||
1251
- baseRevision.activeOverrideManifestSha256 !==
1252
- input.expectedBaseActiveOverrideManifestSha256
1253
- ) {
1254
- throw new Error("Context surface refresh base is stale.");
1255
- }
1256
- this.assertContextRevisionIdle();
1257
- validateStoredContextSurface(input.surface);
1258
- if (
1259
- input.surface.sessionId !== this.sessionId ||
1260
- input.surface.surfaceId === snapshot.surface.surfaceId ||
1261
- input.surface.surfaceSha256 === snapshot.surface.surfaceSha256
1262
- ) {
1263
- throw new Error("Context surface refresh does not introduce a new surface.");
1264
- }
1265
- const actualChanges = contextSurfaceChanges(snapshot.surface, input.surface);
1266
- if (
1267
- stableJsonStringify(actualChanges) !== stableJsonStringify(input.changes) ||
1268
- contextSurfaceChangeManifestHash(actualChanges) !==
1269
- input.changeManifestSha256 ||
1270
- !Object.values(actualChanges).some(Boolean)
1271
- ) {
1272
- throw new Error("Context surface refresh change manifest is invalid.");
1273
- }
875
+ recoverInterruptedState(idFactory: RuntimeIdFactory): SessionRecoveryResult {
876
+ return this.recovery.recoverInterruptedState(idFactory, this.recallIndexRebuilt);
877
+ }
1274
878
 
1275
- const active = this.revisionCompiler.compileActive(snapshot);
1276
- const candidate = this.revisionCompiler.compileProspective({
1277
- active,
1278
- canonical: snapshot.canonical,
1279
- activeOverrides: snapshot.activeOverrides,
1280
- addedOverrides: [],
1281
- activeSurface: snapshot.surface,
1282
- surface: input.surface,
1283
- });
1284
- if (
1285
- canonicalSequenceHash(
1286
- snapshot.canonical,
1287
- input.expectedCanonicalThroughOrdinal,
1288
- ) !== input.canonicalSequenceSha256 ||
1289
- renderedMessageHash(
1290
- candidate.entries,
1291
- input.expectedCanonicalThroughOrdinal,
1292
- ) !== input.renderedMessageSha256
1293
- ) {
1294
- throw new Error("Candidate context surface prefix hash is invalid.");
1295
- }
879
+ historyReader(): SessionHistoryReader {
880
+ this.requireOpen();
881
+ return createSessionHistoryReader({
882
+ database: this.database,
883
+ sessionId: this.sessionId,
884
+ requireOpen: () => this.requireOpen(),
885
+ });
886
+ }
1296
887
 
1297
- const revisionNumber = baseRevision.revisionNumber + 1;
1298
- options.faultInjector?.("before_surface_insert");
1299
- insertContextSurface(this.database, input.surface);
1300
- options.faultInjector?.("after_surface_insert");
1301
- this.database
1302
- .query(
1303
- `INSERT INTO context_revisions (
1304
- revision_id, session_id, revision_number, parent_revision_id, kind,
1305
- surface_id, surface_sha256, keep_from_ordinal,
1306
- source_through_ordinal, added_override_count, active_override_count,
1307
- active_override_manifest_sha256, canonical_sequence_sha256,
1308
- rendered_message_sha256, policy_version, renderer_format,
1309
- plan_sha256, change_manifest_sha256, created_at
1310
- ) VALUES (?, ?, ?, ?, 'surface_refresh', ?, ?, ?, ?, 0, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)`,
1311
- )
1312
- .run(
1313
- input.revisionId,
1314
- this.sessionId,
1315
- revisionNumber,
1316
- baseRevision.revisionId,
1317
- input.surface.surfaceId,
1318
- input.surface.surfaceSha256,
1319
- baseRevision.keepFromOrdinal,
1320
- input.expectedCanonicalThroughOrdinal,
1321
- baseRevision.activeOverrideCount,
1322
- baseRevision.activeOverrideManifestSha256,
1323
- input.canonicalSequenceSha256,
1324
- input.renderedMessageSha256,
1325
- input.changeManifestSha256,
1326
- now,
1327
- );
1328
- options.faultInjector?.("after_revision_insert");
888
+ readCompletedTurnSnapshot(turnId: TurnId): CompletedTurnSnapshot {
889
+ this.requireOpen();
890
+ const turnRow = this.database
891
+ .query("SELECT status FROM turns WHERE turn_id = ?")
892
+ .get(turnId);
893
+ const status = enumFromSql(
894
+ recordFromSql(turnRow, "completed turn").status,
895
+ ["open", "completed", "failed", "cancelled", "interrupted"] as const,
896
+ "turn status",
897
+ );
898
+ if (status !== "completed") {
899
+ throw new Error(`Turn ${turnId} is not completed.`);
900
+ }
1329
901
 
1330
- this.database.query("DELETE FROM context_measurement_state").run();
1331
- options.faultInjector?.("after_measurement_delete");
1332
- const switched = this.database
1333
- .query(
1334
- `UPDATE session_meta SET active_revision_id = ?, updated_at = ?
1335
- WHERE singleton = 1 AND active_revision_id = ?`,
1336
- )
1337
- .run(input.revisionId, now, baseRevision.revisionId);
1338
- requireSingleChange(
1339
- this.database,
1340
- switched.changes,
1341
- "activate context surface revision",
1342
- );
1343
- options.faultInjector?.("after_active_update");
902
+ const rows = this.database
903
+ .query(
904
+ `SELECT ordinal, role, content, reasoning_content,
905
+ reasoning_content_present, name
906
+ FROM messages
907
+ WHERE turn_id = ?
908
+ ORDER BY ordinal`,
909
+ )
910
+ .all(turnId);
911
+ if (rows.length === 0) {
912
+ throw new Error(`Completed turn ${turnId} has no messages.`);
913
+ }
1344
914
 
1345
- const readback = this.loadContextSnapshot();
915
+ let previousOrdinal = 0;
916
+ const messages = rows.map((value): CompletedTurnMessageSnapshot => {
917
+ const row = recordFromSql(value, "completed turn message");
918
+ const ordinal = numberFromSql(row.ordinal, "completed turn ordinal");
919
+ if (ordinal < 1 || ordinal <= previousOrdinal) {
920
+ throw new Error("Completed turn message ordinals are invalid.");
921
+ }
922
+ previousOrdinal = ordinal;
923
+ const role = enumFromSql(
924
+ row.role,
925
+ ["user", "assistant", "tool"] as const,
926
+ "completed turn message role",
927
+ );
928
+ if (role === "user") {
1346
929
  if (
1347
- readback.revision.kind !== "surface_refresh" ||
1348
- readback.revision.revisionId !== input.revisionId ||
1349
- readback.surface.surfaceId !== input.surface.surfaceId ||
1350
- this.loadMeasuredContextState() !== undefined
930
+ row.reasoning_content !== null ||
931
+ numberFromSql(row.reasoning_content_present, "reasoning_content_present") !==
932
+ 0 ||
933
+ row.name !== null
1351
934
  ) {
1352
- throw new Error("Committed context surface revision readback failed.");
935
+ throw new Error("Completed user message fields are invalid.");
1353
936
  }
1354
- return readback.revision;
1355
- });
1356
- } catch (error) {
1357
- if (requireActiveRevisionId(this.readMeta()) !== input.expectedBaseRevisionId) {
1358
- throw new Error("Failed context surface transaction changed active state.", {
1359
- cause: error,
937
+ return Object.freeze({
938
+ ordinal,
939
+ role,
940
+ content: stringFromSql(row.content, "completed user content"),
1360
941
  });
1361
942
  }
1362
- throw sessionWriteError("commit_context_surface", this.sessionId, error);
1363
- }
1364
- }
1365
-
1366
- commitSkillsUpdate(
1367
- input: CommitSkillsUpdateInput,
1368
- options: CommitSkillsUpdateOptions = {},
1369
- ): Extract<StoredContextRevisionV8, { kind: "skills_update" }> {
1370
- this.requireOpen();
1371
- assertCommitSkillsUpdateInput(input);
1372
- const now = this.clock();
1373
- try {
1374
- return runTransaction(this.database, () => {
1375
- const snapshot = this.loadContextSnapshot();
1376
- const baseRevision = snapshot.revision;
1377
- if (
1378
- baseRevision.revisionId !== input.expectedBaseRevisionId ||
1379
- baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
1380
- snapshot.canonical.messages.length !==
1381
- input.expectedCanonicalThroughOrdinal ||
1382
- baseRevision.activeOverrideManifestSha256 !==
1383
- input.expectedBaseActiveOverrideManifestSha256
1384
- ) {
1385
- throw new Error("Agent Skills update base is stale.");
1386
- }
1387
- this.assertContextRevisionIdle();
1388
- validateStoredContextSurface(input.surface);
1389
-
1390
- const surfaceChanged =
1391
- input.surface.surfaceSha256 !== snapshot.surface.surfaceSha256;
1392
- const actualChanges = contextSurfaceChanges(snapshot.surface, input.surface);
1393
- if (
1394
- input.surface.sessionId !== this.sessionId ||
1395
- stableJsonStringify(actualChanges) !== stableJsonStringify(input.changes) ||
1396
- contextSurfaceChangeManifestHash(actualChanges) !==
1397
- input.changeManifestSha256 ||
1398
- (surfaceChanged && input.surface.surfaceId === snapshot.surface.surfaceId) ||
1399
- (!surfaceChanged && input.surface.surfaceId !== snapshot.surface.surfaceId) ||
1400
- (!surfaceChanged && Object.values(actualChanges).some(Boolean))
1401
- ) {
1402
- throw new Error("Agent Skills surface change manifest is invalid.");
943
+ if (role === "assistant") {
944
+ if (row.name !== null) {
945
+ throw new Error("Completed assistant message name must be null.");
1403
946
  }
1404
-
1405
- const unresolved = new Map(
1406
- this.loadSkillActivations(["pending", "dispatched"]).map((entry) => [
1407
- entry.activationMessageId,
1408
- entry,
1409
- ]),
947
+ const reasoningPresent = numberFromSql(
948
+ row.reasoning_content_present,
949
+ "reasoning_content_present",
1410
950
  );
1411
- if (
1412
- input.settlements.length !== input.addedOverrides.length ||
1413
- input.settlements.length !== unresolved.size ||
1414
- new Set(input.settlements.map((entry) => entry.activationMessageId)).size !==
1415
- input.settlements.length ||
1416
- skillActivationManifestSha256(input.settlements) !==
1417
- input.activationManifestSha256
1418
- ) {
1419
- throw new Error("Agent Skills settlement manifest is invalid.");
951
+ if (reasoningPresent !== 0 && reasoningPresent !== 1) {
952
+ throw new Error("reasoning_content_present must be 0 or 1.");
1420
953
  }
1421
- const canonicalMessages = new Map(
1422
- snapshot.canonical.messages.map((message) => [message.messageId, message]),
1423
- );
1424
- const providedOverrides = new Map(
1425
- input.addedOverrides.map((override) => [override.messageId, override]),
1426
- );
1427
- for (const settlement of input.settlements) {
1428
- const activation = unresolved.get(settlement.activationMessageId);
1429
- const message = canonicalMessages.get(settlement.activationMessageId);
1430
- const override = providedOverrides.get(settlement.activationMessageId);
1431
- if (
1432
- activation === undefined ||
1433
- message?.role !== "tool" ||
1434
- message.name !== "Skill" ||
1435
- override === undefined ||
1436
- settlement.name !== activation.name ||
1437
- (settlement.state === "promoted" &&
1438
- (activation.state !== "dispatched" ||
1439
- settlement.rejectionReason !== undefined)) ||
1440
- (settlement.state === "rejected" &&
1441
- (settlement.rejectionReason === undefined ||
1442
- settlement.rejectionReason.trim() === "" ||
1443
- settlement.rejectionReason.length > 256))
1444
- ) {
1445
- throw new Error(
1446
- `Agent Skill activation ${settlement.activationMessageId} cannot be settled.`,
1447
- );
1448
- }
1449
- const activeManifest = input.surface.activeSkills.find(
1450
- (entry) =>
1451
- entry.name === activation.name &&
1452
- entry.activationMessageId === activation.activationMessageId,
1453
- );
1454
- if (
1455
- (settlement.state === "promoted" && activeManifest === undefined) ||
1456
- (settlement.state === "rejected" && activeManifest !== undefined)
1457
- ) {
1458
- throw new Error("Agent Skills active manifest does not match settlements.");
1459
- }
1460
- const expected = renderSkillActivationReceipt({
1461
- message: {
1462
- messageId: message.messageId,
1463
- frameId: message.frameId,
1464
- ordinal: message.ordinal,
1465
- content: message.displayText,
1466
- contentSha256: message.contentSha256,
1467
- },
1468
- name: activation.name,
1469
- outcome:
1470
- settlement.state === "promoted"
1471
- ? "promoted"
1472
- : settlement.rejectionReason === "unavailable"
1473
- ? "unavailable"
1474
- : "rejected",
1475
- });
1476
- if (stableJsonStringify(expected) !== stableJsonStringify(override)) {
1477
- throw new Error("Agent Skill activation receipt is not deterministic.");
1478
- }
1479
- }
1480
-
1481
- const active = this.revisionCompiler.compileActive(snapshot);
1482
- const candidateOverrides = [
1483
- ...snapshot.activeOverrides,
1484
- ...input.addedOverrides,
1485
- ];
1486
- if (
1487
- new Set(candidateOverrides.map((override) => override.messageId)).size !==
1488
- candidateOverrides.length ||
1489
- activeOverrideManifestHash(candidateOverrides) !==
1490
- input.nextActiveOverrideManifestSha256
1491
- ) {
1492
- throw new Error("Agent Skills override manifest is invalid.");
1493
- }
1494
- const candidate = this.revisionCompiler.compileProspective({
1495
- active,
1496
- canonical: snapshot.canonical,
1497
- activeOverrides: snapshot.activeOverrides,
1498
- addedOverrides: input.addedOverrides,
1499
- activeSurface: snapshot.surface,
1500
- ...(surfaceChanged ? { surface: input.surface } : {}),
1501
- allowCombinedSurfaceAndOverrides: true,
1502
- });
1503
- if (
1504
- canonicalSequenceHash(
1505
- snapshot.canonical,
1506
- input.expectedCanonicalThroughOrdinal,
1507
- ) !== input.canonicalSequenceSha256 ||
1508
- renderedMessageHash(
1509
- candidate.entries,
1510
- input.expectedCanonicalThroughOrdinal,
1511
- ) !== input.renderedMessageSha256
1512
- ) {
1513
- throw new Error("Agent Skills compiled context hashes are invalid.");
1514
- }
1515
-
1516
- options.faultInjector?.("before_surface_insert");
1517
- if (surfaceChanged) {
1518
- insertContextSurface(this.database, input.surface);
1519
- }
1520
- options.faultInjector?.("after_surface_insert");
1521
- const revisionNumber = baseRevision.revisionNumber + 1;
1522
- const activeOverrideCount =
1523
- baseRevision.activeOverrideCount + input.addedOverrides.length;
1524
- this.database
1525
- .query(
1526
- `INSERT INTO context_revisions (
1527
- revision_id, session_id, revision_number, parent_revision_id, kind,
1528
- surface_id, surface_sha256, keep_from_ordinal,
1529
- source_through_ordinal, added_override_count, active_override_count,
1530
- active_override_manifest_sha256, canonical_sequence_sha256,
1531
- rendered_message_sha256, policy_version, renderer_format,
1532
- plan_sha256, change_manifest_sha256, activation_manifest_sha256,
1533
- retired_through_ordinal, retired_turn_count, retired_frame_count,
1534
- retired_message_count, created_at
1535
- ) VALUES (?, ?, ?, ?, 'skills_update', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, NULL, NULL, ?)`,
1536
- )
1537
- .run(
1538
- input.revisionId,
1539
- this.sessionId,
1540
- revisionNumber,
1541
- baseRevision.revisionId,
1542
- input.surface.surfaceId,
1543
- input.surface.surfaceSha256,
1544
- baseRevision.keepFromOrdinal,
1545
- input.expectedCanonicalThroughOrdinal,
1546
- input.addedOverrides.length,
1547
- activeOverrideCount,
1548
- input.nextActiveOverrideManifestSha256,
1549
- input.canonicalSequenceSha256,
1550
- input.renderedMessageSha256,
1551
- SKILL_POLICY_VERSION,
1552
- SKILL_ACTIVATION_RECEIPT_FORMAT,
1553
- input.changeManifestSha256,
1554
- input.activationManifestSha256,
1555
- now,
1556
- );
1557
- options.faultInjector?.("after_revision_insert");
1558
-
1559
- for (let index = 0; index < input.addedOverrides.length; index += 1) {
1560
- const override = requireItem(
1561
- input.addedOverrides,
1562
- index,
1563
- "Agent Skill receipt override",
1564
- );
1565
- this.database
1566
- .query(
1567
- `INSERT INTO context_overrides (
1568
- introduced_revision_id, session_id, message_id, frame_id, ordinal,
1569
- representation, renderer_format, source, original_content_sha256,
1570
- rendered_content, rendered_content_sha256, original_bytes,
1571
- rendered_bytes, byte_savings, created_at
1572
- ) VALUES (?, ?, ?, ?, ?, 'swapped', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1573
- )
1574
- .run(
1575
- input.revisionId,
1576
- this.sessionId,
1577
- override.messageId,
1578
- override.frameId,
1579
- override.ordinal,
1580
- SKILL_ACTIVATION_RECEIPT_FORMAT,
1581
- override.source,
1582
- override.originalContentSha256,
1583
- override.renderedContent,
1584
- override.renderedContentSha256,
1585
- override.originalBytes,
1586
- override.renderedBytes,
1587
- override.byteSavings,
1588
- now,
1589
- );
1590
- if (index === 0) {
1591
- options.faultInjector?.("after_first_override_insert");
1592
- }
1593
- }
1594
- options.faultInjector?.("after_overrides_insert");
1595
-
1596
- for (const settlement of input.settlements) {
1597
- const activation = unresolved.get(settlement.activationMessageId)!;
1598
- const updated =
1599
- settlement.state === "promoted"
1600
- ? this.database
1601
- .query(
1602
- `UPDATE skill_activations
1603
- SET state = 'promoted', settled_revision_id = ?, updated_at = ?
1604
- WHERE activation_message_id = ? AND state = 'dispatched'`,
1605
- )
1606
- .run(input.revisionId, now, settlement.activationMessageId)
1607
- : this.database
1608
- .query(
1609
- `UPDATE skill_activations
1610
- SET state = 'rejected', settled_revision_id = ?, rejection_reason = ?, updated_at = ?
1611
- WHERE activation_message_id = ? AND state = ?`,
1612
- )
1613
- .run(
1614
- input.revisionId,
1615
- settlement.rejectionReason!,
1616
- now,
1617
- settlement.activationMessageId,
1618
- activation.state,
1619
- );
1620
- requireSingleChange(
1621
- this.database,
1622
- updated.changes,
1623
- `settle Agent Skill activation ${settlement.activationMessageId}`,
1624
- );
1625
- }
1626
- options.faultInjector?.("after_activations_update");
1627
-
1628
- this.database.query("DELETE FROM context_measurement_state").run();
1629
- options.faultInjector?.("after_measurement_delete");
1630
- const switched = this.database
1631
- .query(
1632
- `UPDATE session_meta SET active_revision_id = ?, updated_at = ?
1633
- WHERE singleton = 1 AND active_revision_id = ?`,
1634
- )
1635
- .run(input.revisionId, now, baseRevision.revisionId);
1636
- requireSingleChange(
1637
- this.database,
1638
- switched.changes,
1639
- "activate Agent Skills context revision",
1640
- );
1641
- options.faultInjector?.("after_active_update");
1642
-
1643
- const readback = this.loadContextSnapshot();
1644
- if (
1645
- readback.revision.kind !== "skills_update" ||
1646
- readback.revision.revisionId !== input.revisionId ||
1647
- readback.surface.surfaceId !== input.surface.surfaceId ||
1648
- this.loadMeasuredContextState() !== undefined ||
1649
- this.loadSkillActivations(["pending", "dispatched"]).some((entry) =>
1650
- input.settlements.some(
1651
- (settlement) =>
1652
- settlement.activationMessageId === entry.activationMessageId,
1653
- ),
1654
- )
1655
- ) {
1656
- throw new Error("Committed Agent Skills update readback failed.");
954
+ if (reasoningPresent === 0 && row.reasoning_content !== null) {
955
+ throw new Error("Absent assistant reasoning content must be null.");
1657
956
  }
1658
- return readback.revision;
1659
- });
1660
- } catch (error) {
1661
- if (requireActiveRevisionId(this.readMeta()) !== input.expectedBaseRevisionId) {
1662
- throw new Error("Failed Agent Skills transaction changed active state.", {
1663
- cause: error,
957
+ return Object.freeze({
958
+ ordinal,
959
+ role,
960
+ content: nullableTextFromSql(row.content, "completed assistant content"),
961
+ ...(reasoningPresent === 0
962
+ ? {}
963
+ : {
964
+ reasoningContent: nullableTextFromSql(
965
+ row.reasoning_content,
966
+ "completed assistant reasoning content",
967
+ ),
968
+ }),
1664
969
  });
1665
970
  }
1666
- throw sessionWriteError("commit_skills_update", this.sessionId, error);
1667
- }
1668
- }
1669
-
1670
- private validateAddedOverrides(
1671
- overrides: readonly SwapOverride[],
1672
- canonical: ProtocolContextView,
1673
- ): void {
1674
- const messages = new Map(
1675
- canonical.messages.map((message) => [message.messageId, message] as const),
1676
- );
1677
- const results = new Map(
1678
- canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
1679
- );
1680
- for (const override of overrides) {
1681
- const message = messages.get(override.messageId);
1682
- const result = results.get(override.messageId);
1683
- if (message?.role !== "tool" || result === undefined) {
1684
- throw new Error("Added context override does not target a tool result.");
1685
- }
1686
- const expected = this.swapRenderer.render({ message, result });
1687
- if (stableJsonStringify(expected) !== stableJsonStringify(override)) {
1688
- throw new Error("Added context override is not deterministic.");
971
+ if (
972
+ row.reasoning_content !== null ||
973
+ numberFromSql(row.reasoning_content_present, "reasoning_content_present") !== 0
974
+ ) {
975
+ throw new Error("Completed tool message reasoning fields are invalid.");
1689
976
  }
1690
- }
977
+ return Object.freeze({
978
+ ordinal,
979
+ role,
980
+ name: stringFromSql(row.name, "completed tool name"),
981
+ content: stringFromSql(row.content, "completed tool content"),
982
+ });
983
+ });
984
+ return Object.freeze({ messages: Object.freeze(messages) });
1691
985
  }
1692
986
 
1693
- loadSkillActivations(
1694
- states?: readonly StoredSkillActivation["state"][],
1695
- ): readonly StoredSkillActivation[] {
987
+ loadProtocolView(): ProtocolContextView {
1696
988
  this.requireOpen();
1697
- const rows = this.database
1698
- .query(
1699
- `SELECT sa.*, m.ordinal AS activation_ordinal
1700
- FROM skill_activations sa
1701
- JOIN messages m ON m.message_id = sa.activation_message_id
1702
- ORDER BY m.ordinal`,
1703
- )
989
+ const imageAttachments = loadMessageImageAttachments(this.database);
990
+ const toolContentBlocks = loadToolMessageContentBlocks(this.database);
991
+ const frames = this.database
992
+ .query("SELECT * FROM protocol_frames ORDER BY first_ordinal")
1704
993
  .all()
1705
- .map(decodeSkillActivation);
1706
- const filtered =
1707
- states === undefined ? rows : rows.filter((row) => states.includes(row.state));
1708
- return Object.freeze(filtered);
1709
- }
1710
-
1711
- markSkillActivationsDispatched(input: {
1712
- iterationId: IterationId;
1713
- activationMessageIds: readonly MessageId[];
1714
- }): readonly StoredSkillActivation[] {
1715
- this.requireOpen();
1716
- if (input.activationMessageIds.length === 0) {
1717
- return Object.freeze([]);
1718
- }
1719
- if (
1720
- new Set(input.activationMessageIds).size !== input.activationMessageIds.length
1721
- ) {
1722
- throw new Error("Agent Skill dispatch contains duplicate activation messages.");
1723
- }
1724
- const now = this.clock();
1725
- try {
1726
- return runTransaction(this.database, () => {
1727
- const iteration = this.requireIterationRow(input.iterationId);
1728
- if (iteration.outcome !== "open") {
1729
- throw new Error(`Iteration ${input.iterationId} is not open for dispatch.`);
1730
- }
1731
- for (const messageId of input.activationMessageIds) {
1732
- const updated = this.database
1733
- .query(
1734
- `UPDATE skill_activations
1735
- SET state = 'dispatched', dispatched_iteration_id = ?, updated_at = ?
1736
- WHERE activation_message_id = ? AND session_id = ? AND state = 'pending'`,
1737
- )
1738
- .run(input.iterationId, now, messageId, this.sessionId);
1739
- requireSingleChange(
1740
- this.database,
1741
- updated.changes,
1742
- `dispatch Agent Skill activation ${messageId}`,
1743
- );
1744
- }
1745
- this.touch(now);
1746
- const dispatched = this.loadSkillActivations(["dispatched"]).filter((row) =>
1747
- input.activationMessageIds.includes(row.activationMessageId),
994
+ .map(decodeFrame);
995
+ const messages = this.database
996
+ .query("SELECT * FROM messages ORDER BY ordinal")
997
+ .all()
998
+ .map((row) => {
999
+ const record = recordFromSql(row, "message");
1000
+ const messageId = stringFromSql(record.message_id, "message_id");
1001
+ const message = decodeMessage(
1002
+ row,
1003
+ imageAttachments.get(messageId),
1004
+ toolContentBlocks.get(messageId),
1748
1005
  );
1749
- if (dispatched.length !== input.activationMessageIds.length) {
1750
- throw new Error("Agent Skill dispatch readback failed.");
1751
- }
1752
- return Object.freeze(dispatched);
1753
- });
1754
- } catch (error) {
1755
- throw sessionWriteError("dispatch_skill_activations", this.sessionId, error);
1756
- }
1757
- }
1758
-
1759
- markResumed(): number {
1760
- this.requireOpen();
1761
- const now = this.clock();
1762
- try {
1763
- return runTransaction(this.database, () => {
1764
- const meta = this.readMeta();
1765
- const next = meta.openCount + 1;
1766
- const updated = this.database
1767
- .query(
1768
- `UPDATE session_meta
1769
- SET open_count = ?, last_opened_at = ?, updated_at = ?,
1770
- last_closed_at = NULL, last_close_reason = NULL
1771
- WHERE singleton = 1 AND open_count = ?`,
1772
- )
1773
- .run(next, now, now, meta.openCount);
1774
- requireSingleChange(this.database, updated.changes, "increment open count");
1775
- return next;
1006
+ imageAttachments.delete(messageId);
1007
+ toolContentBlocks.delete(messageId);
1008
+ return message;
1776
1009
  });
1777
- } catch (error) {
1778
- throw sessionWriteError("mark_resumed", this.sessionId, error);
1779
- }
1780
- }
1781
-
1782
- recoverInterruptedState(idFactory: RuntimeIdFactory): SessionRecoveryResult {
1783
- this.requireOpen();
1784
- const view = this.loadProtocolView();
1785
- const openTurns = this.database
1786
- .query("SELECT turn_id FROM turns WHERE status = 'open' ORDER BY turn_number")
1787
- .all() as Array<{ turn_id: string }>;
1788
- const openFrames = view.frames.filter((frame) => frame.state === "open");
1789
- if (openTurns.length === 0 && openFrames.length === 0) {
1790
- return {
1791
- syntheticCompletionCount: 0,
1792
- recallIndexRebuilt: this.recallIndexRebuilt,
1793
- };
1010
+ if (imageAttachments.size > 0) {
1011
+ throw new Error("Image attachment rows reference unknown messages.");
1794
1012
  }
1795
- if (openTurns.length !== 1 || openFrames.length > 1) {
1796
- throw this.recoveryError(
1797
- "Session has an invalid number of open turns or frames.",
1798
- );
1013
+ if (toolContentBlocks.size > 0) {
1014
+ throw new Error("Tool content block rows reference unknown messages.");
1799
1015
  }
1800
- const turnId = openTurns[0].turn_id as TurnId;
1801
- const openIterations = this.database
1016
+ const toolResults = this.database
1802
1017
  .query(
1803
- "SELECT iteration_id FROM iterations WHERE turn_id = ? AND outcome = 'open' ORDER BY iteration_number",
1804
- )
1805
- .all(turnId) as Array<{ iteration_id: string }>;
1806
- if (openIterations.length > 1) {
1807
- throw this.recoveryError(`Turn ${turnId} has multiple open iterations.`);
1808
- }
1809
-
1810
- const frame = openFrames[0];
1811
- if (frame === undefined) {
1812
- this.markOpenTurnInterrupted(
1813
- turnId,
1814
- openIterations[0]?.iteration_id as IterationId | undefined,
1815
- );
1816
- this.validateAll({ allowOpenTail: false });
1817
- return {
1818
- recoveredTurnId: turnId,
1819
- syntheticCompletionCount: 0,
1820
- recallIndexRebuilt: this.recallIndexRebuilt,
1821
- };
1822
- }
1823
- if (
1824
- frame.turnId !== turnId ||
1825
- frame !== view.frames.at(-1) ||
1826
- openIterations.length !== 1 ||
1827
- frame.iterationId !== openIterations[0]?.iteration_id
1828
- ) {
1829
- throw this.recoveryError(`Open frame ${frame.frameId} has invalid ownership.`);
1830
- }
1831
-
1832
- const frameMessages = view.messages.filter(
1833
- (message) => message.frameId === frame.frameId,
1834
- );
1835
- const assistant = frameMessages[0];
1836
- if (assistant?.role !== "assistant" || assistant.toolCalls === undefined) {
1837
- throw this.recoveryError(`Open frame ${frame.frameId} has no tool calls.`);
1838
- }
1839
- const missingCalls = assistant.toolCalls.slice(frameMessages.length - 1);
1840
- if (missingCalls.length === 0) {
1841
- throw this.recoveryError(`Open frame ${frame.frameId} has no missing call.`);
1842
- }
1843
- const completionInputs = interruptedCompletionInputs(missingCalls);
1844
- const messages: CanonicalMessageRecord[] = [];
1845
- const toolResults: ToolResultRecord[] = [];
1846
- for (const input of completionInputs) {
1847
- const createdAt = this.clock();
1848
- const content = observationForCompletion(input);
1849
- const displayText = toolResultDisplayText(content);
1850
- const messageId = idFactory.createMessageId();
1851
- const message = immutableRecord<CanonicalMessageRecord>({
1852
- messageId,
1853
- sessionId: this.sessionId,
1854
- frameId: frame.frameId,
1855
- ordinal: view.messages.length + messages.length + 1,
1856
- contentSha256: canonicalToolResultContentHash(content),
1857
- createdAt,
1858
- role: "tool",
1859
- turnId,
1860
- iterationId: frame.iterationId,
1861
- toolCallId: input.call.toolCallId,
1862
- providerToolCallId: input.call.providerToolCallId,
1863
- name: input.call.name,
1864
- content,
1865
- displayText,
1866
- origin: "runtime",
1867
- });
1868
- const completion: ToolCompletion = immutableRecord({
1869
- kind: "synthetic",
1870
- reason: input.reason,
1871
- });
1872
- const result = immutableRecord<ToolResultRecord>({
1873
- sessionId: this.sessionId,
1874
- frameId: frame.frameId,
1875
- toolCallId: input.call.toolCallId,
1876
- toolMessageId: messageId,
1877
- completion,
1878
- observationSha256: canonicalToolResultContentHash(content),
1879
- createdAt,
1880
- });
1881
- messages.push(message);
1882
- toolResults.push(result);
1883
- }
1884
- const closedAt = this.clock();
1885
- const closedFrame = immutableRecord<ProtocolFrame>({
1886
- ...frame,
1887
- state: "closed",
1888
- lastOrdinal: view.messages.length + messages.length,
1889
- closedAt,
1890
- });
1891
- const candidate: ProtocolContextView = Object.freeze({
1892
- ...view,
1893
- frames: Object.freeze(
1894
- view.frames.map((entry) =>
1895
- entry.frameId === frame.frameId ? closedFrame : entry,
1896
- ),
1897
- ),
1898
- messages: Object.freeze([...view.messages, ...messages]),
1899
- toolResults: Object.freeze([...view.toolResults, ...toolResults]),
1900
- });
1901
- this.validator.validate(candidate, { fullIntegrity: true });
1902
-
1903
- try {
1904
- runTransaction(this.database, () => {
1905
- for (let index = 0; index < messages.length; index += 1) {
1906
- insertMessage(
1907
- this.database,
1908
- requireItem(messages, index, "recovery message"),
1909
- );
1910
- insertToolResult(
1911
- this.database,
1912
- requireItem(toolResults, index, "recovery tool result"),
1913
- );
1914
- }
1915
- const frameUpdate = this.database
1916
- .query(
1917
- `UPDATE protocol_frames SET state = 'closed', last_ordinal = ?, closed_at = ?
1918
- WHERE frame_id = ? AND state = 'open' AND last_ordinal IS NULL`,
1919
- )
1920
- .run(closedFrame.lastOrdinal!, closedAt, frame.frameId);
1921
- requireSingleChange(
1922
- this.database,
1923
- frameUpdate.changes,
1924
- "close recovered frame",
1925
- );
1926
- this.markTerminalRows(
1927
- turnId,
1928
- frame.iterationId!,
1929
- "interrupted",
1930
- "interrupted",
1931
- null,
1932
- stableJsonStringify({ version: 1, reason: "process_interrupted" }),
1933
- closedAt,
1934
- );
1935
- });
1936
- } catch (error) {
1937
- throw new SessionError(
1938
- "SESSION_RECOVERY_FAILED",
1939
- "recover_open_frame",
1940
- `Failed to recover open frame ${frame.frameId}.`,
1941
- { sessionId: this.sessionId, frameId: frame.frameId, cause: error },
1942
- );
1943
- }
1944
- this.validateAll({ allowOpenTail: false });
1945
- return {
1946
- recoveredTurnId: turnId,
1947
- recoveredFrameId: frame.frameId,
1948
- syntheticCompletionCount: messages.length,
1949
- recallIndexRebuilt: this.recallIndexRebuilt,
1950
- };
1951
- }
1952
-
1953
- historyReader(): SessionHistoryReader {
1954
- this.requireOpen();
1955
- return createSessionHistoryReader({
1956
- database: this.database,
1957
- sessionId: this.sessionId,
1958
- requireOpen: () => this.requireOpen(),
1959
- });
1960
- }
1961
-
1962
- readCompletedTurnSnapshot(turnId: TurnId): CompletedTurnSnapshot {
1963
- this.requireOpen();
1964
- const turnRow = this.database
1965
- .query("SELECT status FROM turns WHERE turn_id = ?")
1966
- .get(turnId);
1967
- const status = enumFromSql(
1968
- recordFromSql(turnRow, "completed turn").status,
1969
- ["open", "completed", "failed", "cancelled", "interrupted"] as const,
1970
- "turn status",
1971
- );
1972
- if (status !== "completed") {
1973
- throw new Error(`Turn ${turnId} is not completed.`);
1974
- }
1975
-
1976
- const rows = this.database
1977
- .query(
1978
- `SELECT ordinal, role, content, reasoning_content,
1979
- reasoning_content_present, name
1980
- FROM messages
1981
- WHERE turn_id = ?
1982
- ORDER BY ordinal`,
1983
- )
1984
- .all(turnId);
1985
- if (rows.length === 0) {
1986
- throw new Error(`Completed turn ${turnId} has no messages.`);
1987
- }
1988
-
1989
- let previousOrdinal = 0;
1990
- const messages = rows.map((value): CompletedTurnMessageSnapshot => {
1991
- const row = recordFromSql(value, "completed turn message");
1992
- const ordinal = numberFromSql(row.ordinal, "completed turn ordinal");
1993
- if (ordinal < 1 || ordinal <= previousOrdinal) {
1994
- throw new Error("Completed turn message ordinals are invalid.");
1995
- }
1996
- previousOrdinal = ordinal;
1997
- const role = enumFromSql(
1998
- row.role,
1999
- ["user", "assistant", "tool"] as const,
2000
- "completed turn message role",
2001
- );
2002
- if (role === "user") {
2003
- if (
2004
- row.reasoning_content !== null ||
2005
- numberFromSql(row.reasoning_content_present, "reasoning_content_present") !==
2006
- 0 ||
2007
- row.name !== null
2008
- ) {
2009
- throw new Error("Completed user message fields are invalid.");
2010
- }
2011
- return Object.freeze({
2012
- ordinal,
2013
- role,
2014
- content: stringFromSql(row.content, "completed user content"),
2015
- });
2016
- }
2017
- if (role === "assistant") {
2018
- if (row.name !== null) {
2019
- throw new Error("Completed assistant message name must be null.");
2020
- }
2021
- const reasoningPresent = numberFromSql(
2022
- row.reasoning_content_present,
2023
- "reasoning_content_present",
2024
- );
2025
- if (reasoningPresent !== 0 && reasoningPresent !== 1) {
2026
- throw new Error("reasoning_content_present must be 0 or 1.");
2027
- }
2028
- if (reasoningPresent === 0 && row.reasoning_content !== null) {
2029
- throw new Error("Absent assistant reasoning content must be null.");
2030
- }
2031
- return Object.freeze({
2032
- ordinal,
2033
- role,
2034
- content: nullableTextFromSql(row.content, "completed assistant content"),
2035
- ...(reasoningPresent === 0
2036
- ? {}
2037
- : {
2038
- reasoningContent: nullableTextFromSql(
2039
- row.reasoning_content,
2040
- "completed assistant reasoning content",
2041
- ),
2042
- }),
2043
- });
2044
- }
2045
- if (
2046
- row.reasoning_content !== null ||
2047
- numberFromSql(row.reasoning_content_present, "reasoning_content_present") !== 0
2048
- ) {
2049
- throw new Error("Completed tool message reasoning fields are invalid.");
2050
- }
2051
- return Object.freeze({
2052
- ordinal,
2053
- role,
2054
- name: stringFromSql(row.name, "completed tool name"),
2055
- content: stringFromSql(row.content, "completed tool content"),
2056
- });
2057
- });
2058
- return Object.freeze({ messages: Object.freeze(messages) });
2059
- }
2060
-
2061
- loadProtocolView(): ProtocolContextView {
2062
- this.requireOpen();
2063
- const imageAttachments = loadMessageImageAttachments(this.database);
2064
- const toolContentBlocks = loadToolMessageContentBlocks(this.database);
2065
- const frames = this.database
2066
- .query("SELECT * FROM protocol_frames ORDER BY first_ordinal")
2067
- .all()
2068
- .map(decodeFrame);
2069
- const messages = this.database
2070
- .query("SELECT * FROM messages ORDER BY ordinal")
2071
- .all()
2072
- .map((row) => {
2073
- const record = recordFromSql(row, "message");
2074
- const messageId = stringFromSql(record.message_id, "message_id");
2075
- const message = decodeMessage(
2076
- row,
2077
- imageAttachments.get(messageId),
2078
- toolContentBlocks.get(messageId),
2079
- );
2080
- imageAttachments.delete(messageId);
2081
- toolContentBlocks.delete(messageId);
2082
- return message;
2083
- });
2084
- if (imageAttachments.size > 0) {
2085
- throw new Error("Image attachment rows reference unknown messages.");
2086
- }
2087
- if (toolContentBlocks.size > 0) {
2088
- throw new Error("Tool content block rows reference unknown messages.");
2089
- }
2090
- const toolResults = this.database
2091
- .query(
2092
- `SELECT tr.* FROM tool_results tr
2093
- JOIN messages m ON m.message_id = tr.tool_message_id
2094
- ORDER BY m.ordinal`,
1018
+ `SELECT tr.* FROM tool_results tr
1019
+ JOIN messages m ON m.message_id = tr.tool_message_id
1020
+ ORDER BY m.ordinal`,
2095
1021
  )
2096
1022
  .all()
2097
1023
  .map(decodeToolResult);
2098
- return Object.freeze({
2099
- sessionId: this.sessionId,
2100
- faulted: false,
2101
- frames: Object.freeze(frames),
2102
- messages: Object.freeze(messages),
2103
- toolResults: Object.freeze(toolResults),
2104
- });
2105
- }
2106
-
2107
- async verifyImageAssetFiles(): Promise<void> {
2108
- this.requireOpen();
2109
- const distinct = new Map<string, ImageAssetRef>();
2110
- for (const message of this.loadProtocolView().messages) {
2111
- const assets =
2112
- message.role === "user"
2113
- ? (message.attachments ?? []).map(imageAssetRefFromAttachment)
2114
- : message.role === "tool"
2115
- ? message.content.flatMap((block) =>
2116
- block.type === "image" ? [block.asset] : [],
2117
- )
2118
- : [];
2119
- for (const asset of assets) {
2120
- const previous = distinct.get(asset.assetId);
2121
- if (
2122
- previous !== undefined &&
2123
- stableJsonStringify(previous) !== stableJsonStringify(asset)
2124
- ) {
2125
- throw new Error(`Conflicting metadata for image asset ${asset.assetId}.`);
2126
- }
2127
- distinct.set(asset.assetId, asset);
2128
- }
2129
- }
2130
- if (distinct.size === 0) {
2131
- return;
2132
- }
2133
- const store = await ImageAssetStore.open({
2134
- workspaceRoot: this.workspaceRoot,
2135
- ...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
2136
- });
2137
- for (const asset of distinct.values()) {
2138
- await store.verify(asset);
2139
- }
2140
- }
2141
-
2142
- loadContextSnapshot(): StoredContextSnapshotV8 {
2143
- this.requireOpen();
2144
- const meta = this.readMeta();
2145
- try {
2146
- if (
2147
- meta.sessionId !== this.sessionId ||
2148
- meta.schemaFingerprint !== SESSION_SCHEMA_V10_FINGERPRINT
2149
- ) {
2150
- throw new Error("Session metadata identity or schema fingerprint changed.");
2151
- }
2152
- const canonical = this.loadProtocolView();
2153
- this.validator.validate(canonical, { fullIntegrity: true });
2154
- const systemFrame = canonical.frames[0];
2155
- const systemMessage = canonical.messages[0];
2156
- if (
2157
- systemFrame?.kind !== "system" ||
2158
- systemFrame.firstOrdinal !== 1 ||
2159
- systemMessage?.role !== "system" ||
2160
- systemMessage.ordinal !== 1 ||
2161
- sha256(systemMessage.content) !== meta.systemPromptSha256 ||
2162
- canonical.messages.at(-1)?.ordinal !== canonical.messages.length
2163
- ) {
2164
- throw new Error("Stored context snapshot ordinal or system invariant failed.");
2165
- }
2166
- return this.loadValidatedContextSnapshot(meta, canonical);
2167
- } catch (error) {
2168
- if (error instanceof SessionError) {
2169
- throw error;
2170
- }
2171
- if (error instanceof ContextProtocolError) {
2172
- throw new SessionError(
2173
- "SESSION_PROTOCOL_INVALID",
2174
- "load_context_snapshot",
2175
- error.message,
2176
- {
2177
- sessionId: this.sessionId,
2178
- frameId: error.frameId,
2179
- messageId: error.messageId,
2180
- toolCallId: error.toolCallId,
2181
- cause: error,
2182
- },
2183
- );
2184
- }
2185
- throw new SessionError(
2186
- "SESSION_INTEGRITY_FAILED",
2187
- "load_context_snapshot",
2188
- `Session context snapshot validation failed: ${errorMessage(error)}.`,
2189
- { sessionId: this.sessionId, cause: error },
2190
- );
2191
- }
2192
- }
2193
-
2194
- readMeta(): StoredSessionMetaV10 {
2195
- this.requireOpen();
2196
- const rows = this.database.query("SELECT * FROM session_meta").all();
2197
- if (rows.length !== 1) {
2198
- throw new SessionError(
2199
- "SESSION_INTEGRITY_FAILED",
2200
- "read_meta",
2201
- `Session metadata must contain exactly one row; found ${rows.length}.`,
2202
- { sessionId: this.sessionId },
2203
- );
2204
- }
2205
- return decodeMeta(rows[0], this.sessionId);
2206
- }
2207
-
2208
- readCreationSystemPrompt(): string {
2209
- this.requireOpen();
2210
- try {
2211
- const frames = this.database
2212
- .query("SELECT * FROM protocol_frames WHERE kind = 'system'")
2213
- .all();
2214
- if (frames.length !== 1) {
2215
- throw new Error(`Expected one stored system frame; found ${frames.length}.`);
2216
- }
2217
- const frame = recordFromSql(frames[0], "stored system frame");
2218
- if (
2219
- frame.state !== "closed" ||
2220
- numberFromSql(frame.first_ordinal, "first_ordinal") !== 1 ||
2221
- numberFromSql(frame.last_ordinal, "last_ordinal") !== 1
2222
- ) {
2223
- throw new Error("Stored system frame invariant failed.");
2224
- }
2225
- const frameId = stringFromSql(frame.frame_id, "frame_id");
2226
- const messages = this.database
2227
- .query("SELECT * FROM messages WHERE frame_id = ?")
2228
- .all(frameId);
2229
- if (messages.length !== 1) {
2230
- throw new Error(
2231
- `Expected one stored system message; found ${messages.length}.`,
2232
- );
2233
- }
2234
- const row = recordFromSql(messages[0], "stored system message");
2235
- if (
2236
- numberFromSql(row.ordinal, "ordinal") !== 1 ||
2237
- row.role !== "system" ||
2238
- row.origin !== "runtime"
2239
- ) {
2240
- throw new Error("Stored system message invariant failed.");
2241
- }
2242
- const content = stringFromSql(row.content, "content");
2243
- if (content.trim() === "") {
2244
- throw new Error("Stored system prompt must not be empty.");
2245
- }
2246
- if (
2247
- stringFromSql(row.content_sha256, "content_sha256") !== contentHash(content)
2248
- ) {
2249
- throw new Error("Stored system message content hash does not match.");
2250
- }
2251
- if (this.readMeta().systemPromptSha256 !== sha256(content)) {
2252
- throw new Error("Stored system prompt metadata hash does not match.");
2253
- }
2254
- return content;
2255
- } catch (error) {
2256
- if (error instanceof SessionError && error.code === "SESSION_RECOVERY_FAILED") {
2257
- throw error;
2258
- }
2259
- throw new SessionError(
2260
- "SESSION_RECOVERY_FAILED",
2261
- "read_creation_system_prompt",
2262
- "Creation system prompt is missing or invalid.",
2263
- { sessionId: this.sessionId, cause: error },
2264
- );
2265
- }
2266
- }
2267
-
2268
- readProjectInstructionManifest(): ProjectInstructionManifest | undefined {
2269
- return this.readMeta().projectInstruction;
2270
- }
2271
-
2272
- validateCreatingState(): void {
2273
- this.requireOpen();
2274
- const meta = this.readMeta();
2275
- if (
2276
- meta.initializationState !== "creating" ||
2277
- meta.activeRevisionId !== null ||
2278
- meta.sessionCompatibilityJson !== null ||
2279
- meta.sessionCompatibilitySha256 !== null
2280
- ) {
2281
- throw new SessionError(
2282
- "SESSION_INTEGRITY_FAILED",
2283
- "validate_creating_store",
2284
- "Creating session metadata is invalid.",
2285
- { sessionId: this.sessionId },
2286
- );
2287
- }
2288
- this.readCreationSystemPrompt();
2289
- const counts = this.database
2290
- .query(
2291
- `SELECT
2292
- (SELECT COUNT(*) FROM context_surfaces) AS surfaces,
2293
- (SELECT COUNT(*) FROM context_revisions) AS revisions,
2294
- (SELECT COUNT(*) FROM turns) AS turns`,
2295
- )
2296
- .get() as Record<string, unknown> | null;
2297
- if (
2298
- counts === null ||
2299
- numberFromSql(counts.surfaces, "surfaces") !== 0 ||
2300
- numberFromSql(counts.revisions, "revisions") !== 0 ||
2301
- numberFromSql(counts.turns, "turns") !== 0
2302
- ) {
2303
- throw new SessionError(
2304
- "SESSION_INTEGRITY_FAILED",
2305
- "validate_creating_store",
2306
- "Creating session contains finalized or turn state.",
2307
- { sessionId: this.sessionId },
2308
- );
2309
- }
2310
- const view = this.loadProtocolView();
2311
- this.validator.validate(view, { fullIntegrity: true });
2312
- if (view.messages.length !== 1 || view.frames.length !== 1) {
2313
- throw new SessionError(
2314
- "SESSION_INTEGRITY_FAILED",
2315
- "validate_creating_store",
2316
- "Creating session must contain only its creation system frame.",
2317
- { sessionId: this.sessionId },
2318
- );
2319
- }
2320
- }
2321
-
2322
- nextTurnNumber(): number {
2323
- return this.readMeta().nextTurnNumber;
2324
- }
2325
-
2326
- validateAll(options: { allowOpenTail: boolean }): ProtocolContextView {
2327
- const meta = this.readMeta();
2328
- if (
2329
- meta.sessionId !== this.sessionId ||
2330
- meta.schemaFingerprint !== SESSION_SCHEMA_V10_FINGERPRINT ||
2331
- meta.initializationState !== "ready" ||
2332
- meta.activeRevisionId === null
2333
- ) {
2334
- throw new SessionError(
2335
- "SESSION_SCHEMA_INVALID",
2336
- "validate_store",
2337
- "Session metadata identity or schema fingerprint does not match.",
2338
- { sessionId: this.sessionId },
2339
- );
2340
- }
2341
- try {
2342
- this.readCreationSystemPrompt();
2343
- const view = this.loadProtocolView();
2344
- this.validator.validate(view, {
2345
- allowOpenTail: options.allowOpenTail,
2346
- fullIntegrity: true,
2347
- });
2348
- this.loadValidatedContextSnapshot(meta, view);
2349
- this.validateCounters(meta, view);
2350
- return view;
2351
- } catch (error) {
2352
- if (error instanceof SessionError) {
2353
- throw error;
2354
- }
2355
- if (error instanceof ContextProtocolError) {
2356
- throw new SessionError(
2357
- "SESSION_PROTOCOL_INVALID",
2358
- "validate_store",
2359
- error.message,
2360
- {
2361
- sessionId: this.sessionId,
2362
- frameId: error.frameId,
2363
- messageId: error.messageId,
2364
- toolCallId: error.toolCallId,
2365
- cause: error,
2366
- },
2367
- );
2368
- }
2369
- throw new SessionError(
2370
- "SESSION_INTEGRITY_FAILED",
2371
- "validate_store",
2372
- `Session record validation failed: ${errorMessage(error)}.`,
2373
- { sessionId: this.sessionId, cause: error },
2374
- );
2375
- }
2376
- }
2377
-
2378
- async cloneTo(input: CloneSessionStoreInput): Promise<void> {
2379
- this.requireOpen();
2380
- if (input.targetSessionId === this.sessionId) {
2381
- throw new Error("Session clone target must differ from the source session.");
2382
- }
2383
-
2384
- const sourceView = this.validateAll({ allowOpenTail: false });
2385
- const sourceMeta = this.readMeta();
2386
- const sessionsRoot = path.dirname(this.sessionDirectory);
2387
- await validateSessionsRoot(sessionsRoot, this.sessionId);
2388
- const targetDirectory = safeSessionDirectory(sessionsRoot, input.targetSessionId);
2389
- await assertPathMissing(targetDirectory, input.targetSessionId);
2390
-
2391
- const stagingDirectory = path.join(sessionsRoot, `.cloning-${randomUUID()}`);
2392
- const stagingDatabasePath = path.join(stagingDirectory, "session.sqlite");
2393
- let stagingDatabase: Database | undefined;
2394
- let published = false;
2395
-
2396
- try {
2397
- await mkdir(stagingDirectory, { mode: 0o700 });
2398
- await chmod(stagingDirectory, 0o700);
2399
- input.faultInjector?.("after_staging_mkdir");
2400
- this.database.query("VACUUM INTO ?").run(stagingDatabasePath);
2401
- await chmod(stagingDatabasePath, 0o600);
2402
- input.faultInjector?.("after_snapshot");
2403
-
2404
- stagingDatabase = openWritableDatabase(stagingDatabasePath);
2405
- verifySessionSchema(stagingDatabase, this.sessionId);
2406
- dropSessionCloneTriggers(stagingDatabase);
2407
- input.faultInjector?.("after_trigger_drop");
2408
-
2409
- const targetCanonical = rekeyProtocolView(sourceView, input.targetSessionId);
2410
- runTransaction(stagingDatabase, () => {
2411
- stagingDatabase!.exec("PRAGMA defer_foreign_keys = ON");
2412
- for (const table of SESSION_SCOPED_TABLES) {
2413
- stagingDatabase!
2414
- .query(`UPDATE ${table} SET session_id = ? WHERE session_id = ?`)
2415
- .run(input.targetSessionId, this.sessionId);
2416
- }
2417
- rekeyStoredToolCalls(stagingDatabase!, input.targetSessionId);
2418
- input.faultInjector?.("after_identity_update");
2419
-
2420
- rewriteCloneRevisionHashes(stagingDatabase!, targetCanonical);
2421
- input.faultInjector?.("after_revision_hash_rewrite");
2422
-
2423
- if (stagingDatabase!.query("PRAGMA foreign_key_check").all().length !== 0) {
2424
- throw new Error("Cloned session identity re-key broke foreign keys.");
2425
- }
2426
- });
2427
-
2428
- reinstallSessionCloneTriggers(stagingDatabase);
2429
- input.faultInjector?.("after_trigger_reinstall");
2430
- verifySessionSchema(stagingDatabase, input.targetSessionId);
2431
- verifySqliteIntegrity(stagingDatabase, input.targetSessionId);
2432
- verifyRecallIndex(stagingDatabase, input.targetSessionId);
2433
- input.faultInjector?.("after_recall_validation");
2434
- const clonedStore = new SessionStore(stagingDatabase, this.lease, {
2435
- sessionId: input.targetSessionId,
2436
- workspaceRoot: this.workspaceRoot,
2437
- sessionDirectory: stagingDirectory,
2438
- databasePath: stagingDatabasePath,
2439
- clock: this.clock,
2440
- ...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
2441
- });
2442
- clonedStore.validateAll({ allowOpenTail: false });
2443
- await clonedStore.verifyImageAssetFiles();
2444
-
2445
- await cloneDiagnosticFiles({
2446
- sourceDirectory: this.sessionDirectory,
2447
- stagingDirectory,
2448
- sourceSessionId: this.sessionId,
2449
- targetSessionId: input.targetSessionId,
2450
- nextEventSequence: sourceMeta.nextEventSequence,
2451
- faultInjector: input.faultInjector,
2452
- });
2453
-
2454
- stagingDatabase.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2455
- const standaloneJournal = stagingDatabase
2456
- .query("PRAGMA journal_mode = DELETE")
2457
- .get() as Record<string, unknown> | null;
2458
- if (String(standaloneJournal?.journal_mode).toLowerCase() !== "delete") {
2459
- throw new Error("Cloned session database did not leave WAL mode.");
2460
- }
2461
- stagingDatabase.close();
2462
- stagingDatabase = undefined;
2463
- await unlinkIfExists(`${stagingDatabasePath}-wal`);
2464
- await unlinkIfExists(`${stagingDatabasePath}-shm`);
2465
- await validateSecureDirectory(stagingDirectory, input.targetSessionId);
2466
- await validateSecureFile(stagingDatabasePath, input.targetSessionId);
2467
- await validateSecureOptionalFile(
2468
- path.join(stagingDirectory, "events.jsonl"),
2469
- input.targetSessionId,
2470
- );
2471
- await validateSecureOptionalFile(
2472
- path.join(stagingDirectory, "observations.md"),
2473
- input.targetSessionId,
2474
- );
2475
- input.faultInjector?.("after_artifact_validation");
2476
- await assertPathMissing(targetDirectory, input.targetSessionId);
2477
- input.faultInjector?.("before_publish_rename");
2478
- await rename(stagingDirectory, targetDirectory);
2479
- published = true;
2480
- } finally {
2481
- if (stagingDatabase !== undefined) {
2482
- try {
2483
- stagingDatabase.close();
2484
- } catch {
2485
- // Preserve the clone failure.
1024
+ return Object.freeze({
1025
+ sessionId: this.sessionId,
1026
+ faulted: false,
1027
+ frames: Object.freeze(frames),
1028
+ messages: Object.freeze(messages),
1029
+ toolResults: Object.freeze(toolResults),
1030
+ });
1031
+ }
1032
+
1033
+ async verifyImageAssetFiles(): Promise<void> {
1034
+ this.requireOpen();
1035
+ const distinct = new Map<string, ImageAssetRef>();
1036
+ for (const message of this.loadProtocolView().messages) {
1037
+ const assets =
1038
+ message.role === "user"
1039
+ ? (message.attachments ?? []).map(imageAssetRefFromAttachment)
1040
+ : message.role === "tool"
1041
+ ? message.content.flatMap((block) =>
1042
+ block.type === "image" ? [block.asset] : [],
1043
+ )
1044
+ : [];
1045
+ for (const asset of assets) {
1046
+ const previous = distinct.get(asset.assetId);
1047
+ if (
1048
+ previous !== undefined &&
1049
+ stableJsonStringify(previous) !== stableJsonStringify(asset)
1050
+ ) {
1051
+ throw new Error(`Conflicting metadata for image asset ${asset.assetId}.`);
2486
1052
  }
2487
- }
2488
- if (!published) {
2489
- await removeKnownInitializationFiles(stagingDirectory).catch(() => undefined);
1053
+ distinct.set(asset.assetId, asset);
2490
1054
  }
2491
1055
  }
2492
- }
2493
-
2494
- async close(reason: SessionCloseReason): Promise<void> {
2495
- if (this.closed) {
1056
+ if (distinct.size === 0) {
2496
1057
  return;
2497
1058
  }
2498
- let primaryError: unknown;
2499
- const now = this.clock();
2500
- try {
2501
- runTransaction(this.database, () => {
2502
- const updated = this.database
2503
- .query(
2504
- `UPDATE session_meta SET last_closed_at = ?, last_close_reason = ?, updated_at = ?
2505
- WHERE singleton = 1`,
2506
- )
2507
- .run(now, reason, now);
2508
- requireSingleChange(this.database, updated.changes, "close session activation");
2509
- });
2510
- } catch (error) {
2511
- primaryError = sessionWriteError("close_session", this.sessionId, error);
2512
- }
2513
- try {
2514
- this.database.close();
2515
- } catch (error) {
2516
- primaryError ??= error;
2517
- }
2518
- this.closed = true;
2519
- try {
2520
- await this.lease.release();
2521
- } catch (error) {
2522
- primaryError ??= error;
2523
- }
2524
- if (primaryError !== undefined) {
2525
- throw asError(primaryError);
1059
+ const store = await ImageAssetStore.open({
1060
+ workspaceRoot: this.workspaceRoot,
1061
+ ...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
1062
+ });
1063
+ for (const asset of distinct.values()) {
1064
+ await store.verify(asset);
2526
1065
  }
2527
1066
  }
2528
1067
 
2529
- async abandon(): Promise<void> {
2530
- if (this.closed) {
2531
- return;
2532
- }
1068
+ loadContextSnapshot(): StoredContextSnapshotV8 {
1069
+ this.requireOpen();
1070
+ const meta = this.readMeta();
2533
1071
  try {
2534
- this.database.close();
2535
- } catch {
2536
- // A failed delete path may already have closed the connection.
1072
+ if (
1073
+ meta.sessionId !== this.sessionId ||
1074
+ meta.schemaFingerprint !== SESSION_SCHEMA_V10_FINGERPRINT
1075
+ ) {
1076
+ throw new Error("Session metadata identity or schema fingerprint changed.");
1077
+ }
1078
+ const canonical = this.loadProtocolView();
1079
+ this.validator.validate(canonical, { fullIntegrity: true });
1080
+ const systemFrame = canonical.frames[0];
1081
+ const systemMessage = canonical.messages[0];
1082
+ if (
1083
+ systemFrame?.kind !== "system" ||
1084
+ systemFrame.firstOrdinal !== 1 ||
1085
+ systemMessage?.role !== "system" ||
1086
+ systemMessage.ordinal !== 1 ||
1087
+ sha256(systemMessage.content) !== meta.systemPromptSha256 ||
1088
+ canonical.messages.at(-1)?.ordinal !== canonical.messages.length
1089
+ ) {
1090
+ throw new Error("Stored context snapshot ordinal or system invariant failed.");
1091
+ }
1092
+ return this.validation.loadValidatedContextSnapshot(meta, canonical);
1093
+ } catch (error) {
1094
+ if (error instanceof SessionError) {
1095
+ throw error;
1096
+ }
1097
+ if (error instanceof ContextProtocolError) {
1098
+ throw new SessionError(
1099
+ "SESSION_PROTOCOL_INVALID",
1100
+ "load_context_snapshot",
1101
+ error.message,
1102
+ {
1103
+ sessionId: this.sessionId,
1104
+ frameId: error.frameId,
1105
+ messageId: error.messageId,
1106
+ toolCallId: error.toolCallId,
1107
+ cause: error,
1108
+ },
1109
+ );
1110
+ }
1111
+ throw new SessionError(
1112
+ "SESSION_INTEGRITY_FAILED",
1113
+ "load_context_snapshot",
1114
+ `Session context snapshot validation failed: ${errorMessage(error)}.`,
1115
+ { sessionId: this.sessionId, cause: error },
1116
+ );
2537
1117
  }
2538
- this.closed = true;
2539
- await this.lease.release();
2540
1118
  }
2541
1119
 
2542
- async deleteFromDisk(): Promise<void> {
1120
+ readMeta(): StoredSessionMetaV10 {
2543
1121
  this.requireOpen();
2544
- const known = new Set([
2545
- "session.sqlite",
2546
- "session.sqlite-wal",
2547
- "session.sqlite-shm",
2548
- "events.jsonl",
2549
- "observations.md",
2550
- "active.lock",
2551
- "active.lock.reclaim",
2552
- ]);
2553
- const entries = await readdir(this.sessionDirectory);
2554
- const unknown = entries.filter((entry) => !known.has(entry));
2555
- if (unknown.length > 0) {
1122
+ const rows = this.database.query("SELECT * FROM session_meta").all();
1123
+ if (rows.length !== 1) {
2556
1124
  throw new SessionError(
2557
- "SESSION_DELETE_BLOCKED",
2558
- "delete_session",
2559
- `Session directory contains unknown files: ${unknown.join(", ")}.`,
1125
+ "SESSION_INTEGRITY_FAILED",
1126
+ "read_meta",
1127
+ `Session metadata must contain exactly one row; found ${rows.length}.`,
2560
1128
  { sessionId: this.sessionId },
2561
1129
  );
2562
1130
  }
1131
+ return decodeMeta(rows[0], this.sessionId);
1132
+ }
2563
1133
 
2564
- this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2565
- this.database.close();
2566
- const tombstone = `${this.sessionDirectory}.deleting-${randomUUID()}`;
2567
- try {
2568
- await rename(this.sessionDirectory, tombstone);
2569
- } catch (error) {
2570
- this.closed = true;
2571
- await this.lease.release().catch(() => undefined);
2572
- throw error;
2573
- }
2574
- this.lease.relocate(tombstone);
2575
- await this.lease.release();
2576
- this.closed = true;
2577
-
1134
+ readCreationSystemPrompt(): string {
1135
+ this.requireOpen();
2578
1136
  try {
2579
- for (const name of known) {
2580
- await unlinkIfExists(path.join(tombstone, name));
1137
+ const frames = this.database
1138
+ .query("SELECT * FROM protocol_frames WHERE kind = 'system'")
1139
+ .all();
1140
+ if (frames.length !== 1) {
1141
+ throw new Error(`Expected one stored system frame; found ${frames.length}.`);
2581
1142
  }
2582
- await rmdir(tombstone);
1143
+ const frame = recordFromSql(frames[0], "stored system frame");
1144
+ if (
1145
+ frame.state !== "closed" ||
1146
+ numberFromSql(frame.first_ordinal, "first_ordinal") !== 1 ||
1147
+ numberFromSql(frame.last_ordinal, "last_ordinal") !== 1
1148
+ ) {
1149
+ throw new Error("Stored system frame invariant failed.");
1150
+ }
1151
+ const frameId = stringFromSql(frame.frame_id, "frame_id");
1152
+ const messages = this.database
1153
+ .query("SELECT * FROM messages WHERE frame_id = ?")
1154
+ .all(frameId);
1155
+ if (messages.length !== 1) {
1156
+ throw new Error(
1157
+ `Expected one stored system message; found ${messages.length}.`,
1158
+ );
1159
+ }
1160
+ const row = recordFromSql(messages[0], "stored system message");
1161
+ if (
1162
+ numberFromSql(row.ordinal, "ordinal") !== 1 ||
1163
+ row.role !== "system" ||
1164
+ row.origin !== "runtime"
1165
+ ) {
1166
+ throw new Error("Stored system message invariant failed.");
1167
+ }
1168
+ const content = stringFromSql(row.content, "content");
1169
+ if (content.trim() === "") {
1170
+ throw new Error("Stored system prompt must not be empty.");
1171
+ }
1172
+ if (
1173
+ stringFromSql(row.content_sha256, "content_sha256") !== contentHash(content)
1174
+ ) {
1175
+ throw new Error("Stored system message content hash does not match.");
1176
+ }
1177
+ if (this.readMeta().systemPromptSha256 !== sha256(content)) {
1178
+ throw new Error("Stored system prompt metadata hash does not match.");
1179
+ }
1180
+ return content;
2583
1181
  } catch (error) {
1182
+ if (error instanceof SessionError && error.code === "SESSION_RECOVERY_FAILED") {
1183
+ throw error;
1184
+ }
2584
1185
  throw new SessionError(
2585
- "SESSION_DELETE_BLOCKED",
2586
- "delete_session_cleanup",
2587
- `Session was removed from the catalog, but tombstone cleanup failed: ${tombstone}.`,
1186
+ "SESSION_RECOVERY_FAILED",
1187
+ "read_creation_system_prompt",
1188
+ "Creation system prompt is missing or invalid.",
2588
1189
  { sessionId: this.sessionId, cause: error },
2589
1190
  );
2590
1191
  }
2591
1192
  }
2592
1193
 
2593
- private commitBeginTurn(
2594
- mutation: Extract<LedgerMutation, { kind: "begin_turn" }>,
2595
- now: string,
2596
- ): void {
1194
+ readProjectInstructionManifest(): ProjectInstructionManifest | undefined {
1195
+ return this.readMeta().projectInstruction;
1196
+ }
1197
+
1198
+ validateCreatingState(): void {
1199
+ this.requireOpen();
2597
1200
  const meta = this.readMeta();
2598
- if (mutation.admissionBase !== undefined) {
2599
- const snapshot = this.loadContextSnapshot();
2600
- const head = snapshot.canonical.messages.at(-1);
2601
- const base = mutation.admissionBase;
2602
- if (
2603
- head === undefined ||
2604
- base.canonicalMessageCount !== snapshot.canonical.messages.length ||
2605
- base.canonicalHeadMessageId !== head.messageId ||
2606
- base.canonicalHeadContentSha256 !== head.contentSha256 ||
2607
- base.activeRevisionId !== snapshot.revision.revisionId ||
2608
- base.activeRevisionNumber !== snapshot.revision.revisionNumber ||
2609
- base.surfaceSha256 !== snapshot.surface.surfaceSha256 ||
2610
- base.sessionCompatibilitySha256 !== meta.sessionCompatibilitySha256 ||
2611
- base.nextTurnNumber !== meta.nextTurnNumber
2612
- ) {
2613
- throw new AdmissionStaleError();
2614
- }
2615
- }
2616
1201
  if (
2617
- meta.initializationState !== "ready" ||
2618
- meta.nextTurnNumber !== mutation.turn.turnNumber
1202
+ meta.initializationState !== "creating" ||
1203
+ meta.activeRevisionId !== null ||
1204
+ meta.sessionCompatibilityJson !== null ||
1205
+ meta.sessionCompatibilitySha256 !== null
2619
1206
  ) {
2620
- throw new Error("Session turn counter or state changed before begin_turn.");
1207
+ throw new SessionError(
1208
+ "SESSION_INTEGRITY_FAILED",
1209
+ "validate_creating_store",
1210
+ "Creating session metadata is invalid.",
1211
+ { sessionId: this.sessionId },
1212
+ );
2621
1213
  }
2622
- this.database
2623
- .query(
2624
- `INSERT INTO turns (
2625
- session_id, turn_id, turn_number, status, next_iteration_number,
2626
- last_iteration_id, final_message_id, terminal_detail_json, started_at, finished_at
2627
- ) VALUES (?, ?, ?, 'open', 1, NULL, NULL, NULL, ?, NULL)`,
2628
- )
2629
- .run(this.sessionId, mutation.turn.turnId, mutation.turn.turnNumber, now);
2630
- insertFrame(this.database, mutation.frame);
2631
- insertMessage(this.database, mutation.message);
2632
- const updated = this.database
1214
+ this.readCreationSystemPrompt();
1215
+ const counts = this.database
2633
1216
  .query(
2634
- `UPDATE session_meta SET next_turn_number = ?, updated_at = ?
2635
- WHERE singleton = 1 AND next_turn_number = ?`,
1217
+ `SELECT
1218
+ (SELECT COUNT(*) FROM context_surfaces) AS surfaces,
1219
+ (SELECT COUNT(*) FROM context_revisions) AS revisions,
1220
+ (SELECT COUNT(*) FROM turns) AS turns`,
2636
1221
  )
2637
- .run(mutation.turn.turnNumber + 1, now, mutation.turn.turnNumber);
2638
- requireSingleChange(this.database, updated.changes, "advance turn counter");
2639
- }
2640
-
2641
- private commitSteeringUsers(
2642
- mutation: Extract<LedgerMutation, { kind: "append_steering_users" }>,
2643
- now: string,
2644
- ): void {
2645
- const turn = this.requireTurnRow(mutation.turn.turnId);
2646
- if (turn.status !== "open") {
2647
- throw new Error(`Turn ${mutation.turn.turnId} is not open.`);
2648
- }
1222
+ .get() as Record<string, unknown> | null;
2649
1223
  if (
2650
- mutation.frames.length === 0 ||
2651
- mutation.frames.length !== mutation.messages.length
1224
+ counts === null ||
1225
+ numberFromSql(counts.surfaces, "surfaces") !== 0 ||
1226
+ numberFromSql(counts.revisions, "revisions") !== 0 ||
1227
+ numberFromSql(counts.turns, "turns") !== 0
2652
1228
  ) {
2653
- throw new Error(
2654
- "Steering user mutation must contain matching frames and messages.",
1229
+ throw new SessionError(
1230
+ "SESSION_INTEGRITY_FAILED",
1231
+ "validate_creating_store",
1232
+ "Creating session contains finalized or turn state.",
1233
+ { sessionId: this.sessionId },
2655
1234
  );
2656
1235
  }
2657
- for (let index = 0; index < mutation.frames.length; index += 1) {
2658
- insertFrame(this.database, requireItem(mutation.frames, index, "steering frame"));
2659
- insertMessage(
2660
- this.database,
2661
- requireItem(mutation.messages, index, "steering message"),
1236
+ const view = this.loadProtocolView();
1237
+ this.validator.validate(view, { fullIntegrity: true });
1238
+ if (view.messages.length !== 1 || view.frames.length !== 1) {
1239
+ throw new SessionError(
1240
+ "SESSION_INTEGRITY_FAILED",
1241
+ "validate_creating_store",
1242
+ "Creating session must contain only its creation system frame.",
1243
+ { sessionId: this.sessionId },
2662
1244
  );
2663
1245
  }
2664
- this.touch(now);
2665
1246
  }
2666
1247
 
2667
- private commitAssistant(
2668
- mutation: Extract<LedgerMutation, { kind: "append_assistant" }>,
2669
- now: string,
2670
- ): void {
2671
- const iteration = this.requireIterationRow(mutation.iteration.iterationId);
2672
- if (iteration.outcome !== "open") {
2673
- throw new Error(`Iteration ${mutation.iteration.iterationId} is not open.`);
2674
- }
2675
- insertFrame(this.database, mutation.frame);
2676
- insertMessage(this.database, mutation.message);
1248
+ nextTurnNumber(): number {
1249
+ return this.readMeta().nextTurnNumber;
1250
+ }
1251
+
1252
+ validateAll(options: { allowOpenTail: boolean }): ProtocolContextView {
1253
+ const meta = this.readMeta();
2677
1254
  if (
2678
- mutation.message.role === "assistant" &&
2679
- mutation.message.toolCalls !== undefined
1255
+ meta.sessionId !== this.sessionId ||
1256
+ meta.schemaFingerprint !== SESSION_SCHEMA_V10_FINGERPRINT ||
1257
+ meta.initializationState !== "ready" ||
1258
+ meta.activeRevisionId === null
2680
1259
  ) {
2681
- const expected = numberFromSql(
2682
- iteration.next_tool_call_number,
2683
- "next_tool_call_number",
1260
+ throw new SessionError(
1261
+ "SESSION_SCHEMA_INVALID",
1262
+ "validate_store",
1263
+ "Session metadata identity or schema fingerprint does not match.",
1264
+ { sessionId: this.sessionId },
2684
1265
  );
2685
- if (expected !== 1) {
2686
- throw new Error(
2687
- "Assistant tool calls were already allocated for this iteration.",
2688
- );
2689
- }
2690
- const updated = this.database
2691
- .query(
2692
- `UPDATE iterations SET next_tool_call_number = ?
2693
- WHERE iteration_id = ? AND outcome = 'open' AND next_tool_call_number = 1`,
2694
- )
2695
- .run(mutation.message.toolCalls.length + 1, mutation.iteration.iterationId);
2696
- requireSingleChange(this.database, updated.changes, "advance tool call counter");
2697
- }
2698
- this.touch(now);
2699
- }
2700
-
2701
- private commitToolCompletions(
2702
- mutation: Extract<LedgerMutation, { kind: "commit_tool_completions" }>,
2703
- now: string,
2704
- ): void {
2705
- const current = this.database
2706
- .query("SELECT state, last_ordinal FROM protocol_frames WHERE frame_id = ?")
2707
- .get(mutation.frameBefore.frameId) as {
2708
- state: string;
2709
- last_ordinal: unknown;
2710
- } | null;
2711
- if (current?.state !== "open" || current.last_ordinal !== null) {
2712
- throw new Error(`Frame ${mutation.frameBefore.frameId} is not open.`);
2713
- }
2714
- for (let index = 0; index < mutation.messages.length; index += 1) {
2715
- const message = requireItem(mutation.messages, index, "tool message");
2716
- const result = requireItem(mutation.toolResults, index, "tool result");
2717
- insertMessage(this.database, message);
2718
- insertToolResult(this.database, result);
2719
- insertPendingSkillActivation(this.database, message, result, now);
2720
- }
2721
- if (mutation.frameAfter.state === "closed") {
2722
- const updated = this.database
2723
- .query(
2724
- `UPDATE protocol_frames SET state = 'closed', last_ordinal = ?, closed_at = ?
2725
- WHERE frame_id = ? AND state = 'open' AND last_ordinal IS NULL`,
2726
- )
2727
- .run(
2728
- mutation.frameAfter.lastOrdinal!,
2729
- mutation.frameAfter.closedAt!,
2730
- mutation.frameAfter.frameId,
2731
- );
2732
- requireSingleChange(this.database, updated.changes, "close tool exchange frame");
2733
1266
  }
2734
- this.touch(now);
2735
- }
2736
-
2737
- private commitFinishTurn(
2738
- mutation: Extract<LedgerMutation, { kind: "finish_turn" }>,
2739
- now: string,
2740
- ): void {
2741
- const result = mutation.result;
2742
- const turnStatus = result.status;
2743
- const iterationOutcome = result.status;
2744
- const detail =
2745
- result.status === "completed"
2746
- ? stableJsonStringify({ version: 1, finalTextLength: result.finalText.length })
2747
- : result.status === "failed"
2748
- ? stableJsonStringify({ version: 1, error: result.error.slice(0, 2_000) })
2749
- : stableJsonStringify({ version: 1, cancellation: result.cancellation });
2750
- this.markTerminalRows(
2751
- mutation.turn.turnId,
2752
- result.lastIteration.iterationId,
2753
- turnStatus,
2754
- iterationOutcome,
2755
- mutation.finalMessageId ?? null,
2756
- detail,
2757
- now,
2758
- );
2759
- }
2760
-
2761
- private markTerminalRows(
2762
- turnId: TurnId,
2763
- iterationId: IterationId,
2764
- turnStatus: "completed" | "failed" | "cancelled" | "interrupted",
2765
- iterationOutcome: "completed" | "failed" | "cancelled" | "interrupted",
2766
- finalMessageId: MessageId | null,
2767
- terminalDetailJson: string,
2768
- now: string,
2769
- ): void {
2770
- const iteration = this.database
2771
- .query(
2772
- `UPDATE iterations SET outcome = ?, finished_at = ?
2773
- WHERE iteration_id = ? AND turn_id = ? AND outcome = 'open'`,
2774
- )
2775
- .run(iterationOutcome, now, iterationId, turnId);
2776
- requireSingleChange(this.database, iteration.changes, "finish iteration");
2777
- const turn = this.database
2778
- .query(
2779
- `UPDATE turns SET status = ?, last_iteration_id = ?, final_message_id = ?,
2780
- terminal_detail_json = ?, finished_at = ?
2781
- WHERE turn_id = ? AND status = 'open'`,
2782
- )
2783
- .run(turnStatus, iterationId, finalMessageId, terminalDetailJson, now, turnId);
2784
- requireSingleChange(this.database, turn.changes, "finish turn");
2785
- this.touch(now);
2786
- }
2787
-
2788
- private markOpenTurnInterrupted(
2789
- turnId: TurnId,
2790
- iterationId: IterationId | undefined,
2791
- ): void {
2792
- const now = this.clock();
2793
1267
  try {
2794
- runTransaction(this.database, () => {
2795
- if (iterationId !== undefined) {
2796
- const iteration = this.database
2797
- .query(
2798
- `UPDATE iterations SET outcome = 'interrupted', finished_at = ?
2799
- WHERE iteration_id = ? AND outcome = 'open'`,
2800
- )
2801
- .run(now, iterationId);
2802
- requireSingleChange(this.database, iteration.changes, "interrupt iteration");
2803
- }
2804
- const turn = this.database
2805
- .query(
2806
- `UPDATE turns SET status = 'interrupted', finished_at = ?,
2807
- terminal_detail_json = ?
2808
- WHERE turn_id = ? AND status = 'open'`,
2809
- )
2810
- .run(
2811
- now,
2812
- stableJsonStringify({ version: 1, reason: "process_interrupted" }),
2813
- turnId,
2814
- );
2815
- requireSingleChange(this.database, turn.changes, "interrupt turn");
2816
- this.touch(now);
1268
+ this.readCreationSystemPrompt();
1269
+ const view = this.loadProtocolView();
1270
+ this.validator.validate(view, {
1271
+ allowOpenTail: options.allowOpenTail,
1272
+ fullIntegrity: true,
2817
1273
  });
1274
+ this.validation.loadValidatedContextSnapshot(meta, view);
1275
+ this.validation.validateCounters(meta, view);
1276
+ return view;
2818
1277
  } catch (error) {
1278
+ if (error instanceof SessionError) {
1279
+ throw error;
1280
+ }
1281
+ if (error instanceof ContextProtocolError) {
1282
+ throw new SessionError(
1283
+ "SESSION_PROTOCOL_INVALID",
1284
+ "validate_store",
1285
+ error.message,
1286
+ {
1287
+ sessionId: this.sessionId,
1288
+ frameId: error.frameId,
1289
+ messageId: error.messageId,
1290
+ toolCallId: error.toolCallId,
1291
+ cause: error,
1292
+ },
1293
+ );
1294
+ }
2819
1295
  throw new SessionError(
2820
- "SESSION_RECOVERY_FAILED",
2821
- "recover_open_turn",
2822
- `Failed to mark turn ${turnId} interrupted.`,
1296
+ "SESSION_INTEGRITY_FAILED",
1297
+ "validate_store",
1298
+ `Session record validation failed: ${errorMessage(error)}.`,
2823
1299
  { sessionId: this.sessionId, cause: error },
2824
1300
  );
2825
1301
  }
2826
1302
  }
2827
1303
 
2828
- private loadValidatedContextSnapshot(
2829
- meta: StoredSessionMetaV10,
2830
- canonical: ProtocolContextView,
2831
- ): StoredContextSnapshotV8 {
2832
- const activeRevisionId = requireActiveRevisionId(meta);
2833
- const surfaces = this.database
2834
- .query("SELECT * FROM context_surfaces ORDER BY rowid")
2835
- .all()
2836
- .map(decodeContextSurface);
2837
- const surfacesById = new Map<ContextSurfaceId, StoredContextSurfaceV8>();
2838
- for (const surface of surfaces) {
2839
- validateStoredContextSurface(surface);
2840
- if (surface.sessionId !== this.sessionId || surfacesById.has(surface.surfaceId)) {
2841
- throw new Error("Context surface identity is invalid or duplicated.");
2842
- }
2843
- surfacesById.set(surface.surfaceId, surface);
1304
+ async cloneTo(input: CloneSessionStoreInput): Promise<void> {
1305
+ this.requireOpen();
1306
+ if (input.targetSessionId === this.sessionId) {
1307
+ throw new Error("Session clone target must differ from the source session.");
2844
1308
  }
2845
1309
 
2846
- const revisions = this.database
2847
- .query("SELECT * FROM context_revisions ORDER BY revision_number")
2848
- .all()
2849
- .map(decodeContextRevision);
2850
- if (revisions.length === 0) {
2851
- throw new Error("Session has no context revision.");
2852
- }
1310
+ const sourceView = this.validateAll({ allowOpenTail: false });
1311
+ const sourceMeta = this.readMeta();
1312
+ const sessionsRoot = path.dirname(this.sessionDirectory);
1313
+ await validateSessionsRoot(sessionsRoot, this.sessionId);
1314
+ const targetDirectory = safeSessionDirectory(sessionsRoot, input.targetSessionId);
1315
+ await assertPathMissing(targetDirectory, input.targetSessionId);
2853
1316
 
2854
- const revisionNumberById = new Map<ContextRevisionId, number>();
2855
- for (let index = 0; index < revisions.length; index += 1) {
2856
- const revision = requireItem(revisions, index, "context revision");
2857
- const previous = revisions[index - 1];
2858
- const surface = surfacesById.get(revision.surfaceId);
2859
- if (
2860
- revision.sessionId !== this.sessionId ||
2861
- revision.revisionNumber !== index + 1 ||
2862
- surface === undefined ||
2863
- surface.surfaceSha256 !== revision.surfaceSha256 ||
2864
- (index === 0
2865
- ? revision.kind !== "initial_full" || revision.parentRevisionId !== null
2866
- : revision.kind === "initial_full" ||
2867
- revision.parentRevisionId !== previous?.revisionId) ||
2868
- ((revision.kind === "swap_only" || revision.kind === "prefix_retirement") &&
2869
- revision.surfaceId !== previous?.surfaceId) ||
2870
- (revision.kind === "surface_refresh" &&
2871
- revision.surfaceId === previous?.surfaceId) ||
2872
- (previous !== undefined &&
2873
- (revision.keepFromOrdinal < previous.keepFromOrdinal ||
2874
- ((revision.kind === "swap_only" ||
2875
- revision.kind === "surface_refresh" ||
2876
- revision.kind === "skills_update") &&
2877
- revision.keepFromOrdinal !== previous.keepFromOrdinal) ||
2878
- (revision.kind === "prefix_retirement" &&
2879
- revision.keepFromOrdinal <= previous.keepFromOrdinal)))
2880
- ) {
2881
- throw new Error("Context revision chain is not linear and contiguous.");
2882
- }
2883
- const boundary = canonical.frames.find(
2884
- (frame) => frame.lastOrdinal === revision.sourceThroughOrdinal,
2885
- );
2886
- if (
2887
- revision.sourceThroughOrdinal > canonical.messages.length ||
2888
- boundary?.state !== "closed"
2889
- ) {
2890
- throw new Error(
2891
- `Context revision ${revision.revisionId} has an invalid source boundary.`,
2892
- );
2893
- }
2894
- revisionNumberById.set(revision.revisionId, revision.revisionNumber);
2895
- }
2896
- const introducedSurfaceIds = new Set(
2897
- revisions.flatMap((revision, index) => {
2898
- const previous = revisions[index - 1];
2899
- return revision.kind === "initial_full" ||
2900
- revision.kind === "surface_refresh" ||
2901
- (revision.kind === "skills_update" &&
2902
- revision.surfaceId !== previous?.surfaceId)
2903
- ? [revision.surfaceId]
2904
- : [];
2905
- }),
2906
- );
2907
- if (
2908
- introducedSurfaceIds.size !== surfaces.length ||
2909
- surfaces.some((surface) => !introducedSurfaceIds.has(surface.surfaceId))
2910
- ) {
2911
- throw new Error("Context surface chain contains an orphan or duplicate surface.");
2912
- }
1317
+ const stagingDirectory = path.join(sessionsRoot, `.cloning-${randomUUID()}`);
1318
+ const stagingDatabasePath = path.join(stagingDirectory, "session.sqlite");
1319
+ let stagingDatabase: Database | undefined;
1320
+ let published = false;
2913
1321
 
2914
- const activeRevision = requireItem(
2915
- revisions,
2916
- revisions.length - 1,
2917
- "active context revision",
2918
- );
2919
- if (activeRevision.revisionId !== activeRevisionId) {
2920
- throw new Error("Active context revision is not the latest committed revision.");
2921
- }
2922
- const activeSurface = surfacesById.get(activeRevision.surfaceId);
2923
- if (activeSurface === undefined) {
2924
- throw new Error("Active context revision surface is missing.");
2925
- }
1322
+ try {
1323
+ await mkdir(stagingDirectory, { mode: 0o700 });
1324
+ await chmod(stagingDirectory, 0o700);
1325
+ input.faultInjector?.("after_staging_mkdir");
1326
+ this.database.query("VACUUM INTO ?").run(stagingDatabasePath);
1327
+ await chmod(stagingDatabasePath, 0o600);
1328
+ input.faultInjector?.("after_snapshot");
2926
1329
 
2927
- const overrides = this.database
2928
- .query(
2929
- `SELECT co.* FROM context_overrides co
2930
- JOIN context_revisions cr
2931
- ON cr.revision_id = co.introduced_revision_id
2932
- ORDER BY cr.revision_number, co.ordinal`,
2933
- )
2934
- .all()
2935
- .map(decodeStoredSwapOverride);
2936
- this.validateStoredOverrides(overrides, canonical, revisions, revisionNumberById);
2937
- this.validateSkillActivationRows(canonical, revisions, overrides, surfaces);
2938
-
2939
- for (const revision of revisions) {
2940
- const surface = surfacesById.get(revision.surfaceId);
2941
- if (surface === undefined) {
2942
- throw new Error(`Context revision ${revision.revisionId} has no surface.`);
2943
- }
2944
- const activeOverrides = overrides.filter(
2945
- (override) =>
2946
- (revisionNumberById.get(override.introducedRevisionId) ??
2947
- Number.POSITIVE_INFINITY) <= revision.revisionNumber &&
2948
- override.ordinal >= revision.keepFromOrdinal,
2949
- );
2950
- const introducedCount = overrides.filter(
2951
- (override) => override.introducedRevisionId === revision.revisionId,
2952
- ).length;
2953
- if (
2954
- introducedCount !== revision.addedOverrideCount ||
2955
- activeOverrides.length !== revision.activeOverrideCount ||
2956
- activeOverrideManifestHash(activeOverrides) !==
2957
- revision.activeOverrideManifestSha256
2958
- ) {
2959
- throw new Error(
2960
- `Context revision ${revision.revisionId} override manifest is invalid.`,
2961
- );
2962
- }
2963
- if (
2964
- revision.kind === "surface_refresh" &&
2965
- previousRevision(revisions, revision)?.activeOverrideManifestSha256 !==
2966
- revision.activeOverrideManifestSha256
2967
- ) {
2968
- throw new Error(
2969
- `Context surface revision ${revision.revisionId} changed overrides.`,
2970
- );
2971
- }
2972
- if (revision.kind === "skills_update") {
2973
- const parent = previousRevision(revisions, revision);
2974
- const parentSurface =
2975
- parent === undefined ? undefined : surfacesById.get(parent.surfaceId);
2976
- const settlements = this.loadSkillActivations().filter(
2977
- (activation) => activation.settledRevisionId === revision.revisionId,
2978
- );
2979
- if (
2980
- parentSurface === undefined ||
2981
- contextSurfaceChangeManifestHash(
2982
- contextSurfaceChanges(parentSurface, surface),
2983
- ) !== revision.changeManifestSha256 ||
2984
- settlements.length !== revision.addedOverrideCount ||
2985
- skillActivationManifestSha256(
2986
- settlements.map((activation) => ({
2987
- activationMessageId: activation.activationMessageId,
2988
- name: activation.name,
2989
- state: activation.state === "promoted" ? "promoted" : "rejected",
2990
- ...(activation.rejectionReason === undefined
2991
- ? {}
2992
- : { rejectionReason: activation.rejectionReason }),
2993
- })),
2994
- ) !== revision.activationManifestSha256
2995
- ) {
2996
- throw new Error(
2997
- `Agent Skills revision ${revision.revisionId} manifest is invalid.`,
2998
- );
2999
- }
3000
- }
3001
- if (revision.kind === "prefix_retirement") {
3002
- const parent = previousRevision(revisions, revision);
3003
- if (parent === undefined) {
3004
- throw new Error("Prefix retirement revision has no parent.");
3005
- }
3006
- const retiredStart = Math.max(parent.keepFromOrdinal, 2);
3007
- const retiredMessages = canonical.messages.filter(
3008
- (message) =>
3009
- message.ordinal >= retiredStart &&
3010
- message.ordinal < revision.keepFromOrdinal,
3011
- );
3012
- const retiredFrames = canonical.frames.filter(
3013
- (frame) =>
3014
- frame.firstOrdinal >= retiredStart &&
3015
- (frame.lastOrdinal ?? Number.POSITIVE_INFINITY) < revision.keepFromOrdinal,
3016
- );
3017
- const retiredTurns = new Set(
3018
- retiredMessages.flatMap((message) =>
3019
- message.role === "system" ? [] : [message.turnId],
3020
- ),
3021
- );
3022
- if (
3023
- revision.retiredThroughOrdinal !== revision.keepFromOrdinal - 1 ||
3024
- revision.retiredMessageCount !== retiredMessages.length ||
3025
- revision.retiredFrameCount !== retiredFrames.length ||
3026
- revision.retiredTurnCount !== retiredTurns.size
3027
- ) {
3028
- throw new Error(
3029
- `Context retirement revision ${revision.revisionId} counts are invalid.`,
3030
- );
1330
+ stagingDatabase = openWritableDatabase(stagingDatabasePath);
1331
+ verifySessionSchema(stagingDatabase, this.sessionId);
1332
+ dropSessionCloneTriggers(stagingDatabase);
1333
+ input.faultInjector?.("after_trigger_drop");
1334
+
1335
+ const targetCanonical = rekeyProtocolView(sourceView, input.targetSessionId);
1336
+ runTransaction(stagingDatabase, () => {
1337
+ stagingDatabase!.exec("PRAGMA defer_foreign_keys = ON");
1338
+ for (const table of SESSION_SCOPED_TABLES) {
1339
+ stagingDatabase!
1340
+ .query(`UPDATE ${table} SET session_id = ? WHERE session_id = ?`)
1341
+ .run(input.targetSessionId, this.sessionId);
3031
1342
  }
3032
- }
3033
- if (revision.kind === "surface_refresh") {
3034
- const parent = previousRevision(revisions, revision);
3035
- const parentSurface =
3036
- parent === undefined ? undefined : surfacesById.get(parent.surfaceId);
3037
- if (
3038
- parentSurface === undefined ||
3039
- contextSurfaceChangeManifestHash(
3040
- contextSurfaceChanges(parentSurface, surface),
3041
- ) !== revision.changeManifestSha256
3042
- ) {
3043
- throw new Error(
3044
- `Context surface revision ${revision.revisionId} change manifest is invalid.`,
3045
- );
1343
+ rekeyStoredToolCalls(stagingDatabase!, input.targetSessionId);
1344
+ input.faultInjector?.("after_identity_update");
1345
+
1346
+ rewriteCloneRevisionHashes(stagingDatabase!, targetCanonical);
1347
+ input.faultInjector?.("after_revision_hash_rewrite");
1348
+
1349
+ if (stagingDatabase!.query("PRAGMA foreign_key_check").all().length !== 0) {
1350
+ throw new Error("Cloned session identity re-key broke foreign keys.");
3046
1351
  }
3047
- }
3048
- const prefix = protocolPrefixView(canonical, revision.sourceThroughOrdinal);
3049
- this.revisionCompiler.compileActive({
3050
- meta: Object.freeze({
3051
- sessionId: this.sessionId,
3052
- activeRevisionId: revision.revisionId,
3053
- }),
3054
- revision,
3055
- surface,
3056
- activeOverrides,
3057
- canonical: prefix,
3058
1352
  });
3059
- }
3060
1353
 
3061
- const measurement = this.loadMeasuredContextState();
3062
- if (
3063
- measurement !== undefined &&
3064
- measurement.revisionId !== activeRevision.revisionId
3065
- ) {
3066
- throw new Error("Context measurement is not bound to the active revision.");
3067
- }
1354
+ reinstallSessionCloneTriggers(stagingDatabase);
1355
+ input.faultInjector?.("after_trigger_reinstall");
1356
+ verifySessionSchema(stagingDatabase, input.targetSessionId);
1357
+ verifySqliteIntegrity(stagingDatabase, input.targetSessionId);
1358
+ verifyRecallIndex(stagingDatabase, input.targetSessionId);
1359
+ input.faultInjector?.("after_recall_validation");
1360
+ const clonedStore = new SessionStore(stagingDatabase, this.lease, {
1361
+ sessionId: input.targetSessionId,
1362
+ workspaceRoot: this.workspaceRoot,
1363
+ sessionDirectory: stagingDirectory,
1364
+ databasePath: stagingDatabasePath,
1365
+ clock: this.clock,
1366
+ ...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
1367
+ });
1368
+ clonedStore.validateAll({ allowOpenTail: false });
1369
+ await clonedStore.verifyImageAssetFiles();
3068
1370
 
3069
- return Object.freeze({
3070
- meta: Object.freeze({
3071
- sessionId: meta.sessionId,
3072
- activeRevisionId,
3073
- }),
3074
- revision: activeRevision,
3075
- surface: activeSurface,
3076
- activeOverrides: Object.freeze(
3077
- overrides.filter(
3078
- (override) =>
3079
- (revisionNumberById.get(override.introducedRevisionId) ??
3080
- Number.POSITIVE_INFINITY) <= activeRevision.revisionNumber &&
3081
- override.ordinal >= activeRevision.keepFromOrdinal,
3082
- ),
3083
- ),
3084
- canonical,
3085
- });
3086
- }
1371
+ await cloneDiagnosticFiles({
1372
+ sourceDirectory: this.sessionDirectory,
1373
+ stagingDirectory,
1374
+ sourceSessionId: this.sessionId,
1375
+ targetSessionId: input.targetSessionId,
1376
+ nextEventSequence: sourceMeta.nextEventSequence,
1377
+ faultInjector: input.faultInjector,
1378
+ });
3087
1379
 
3088
- private validateStoredOverrides(
3089
- overrides: readonly StoredContextOverrideV8[],
3090
- canonical: ProtocolContextView,
3091
- revisions: readonly StoredContextRevisionV8[],
3092
- revisionNumberById: ReadonlyMap<ContextRevisionId, number>,
3093
- ): void {
3094
- const messages = new Map(
3095
- canonical.messages.map((message) => [message.messageId, message] as const),
3096
- );
3097
- const frames = new Map(
3098
- canonical.frames.map((frame) => [frame.frameId, frame] as const),
3099
- );
3100
- const results = new Map(
3101
- canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
3102
- );
3103
- const revisionsById = new Map(
3104
- revisions.map((revision) => [revision.revisionId, revision] as const),
3105
- );
3106
- const seenMessages = new Set<MessageId>();
3107
- for (const override of overrides) {
3108
- const revision = revisionsById.get(override.introducedRevisionId);
3109
- const message = messages.get(override.messageId);
3110
- const frame = frames.get(override.frameId);
3111
- const result = results.get(override.messageId);
3112
- if (
3113
- (revision?.kind !== "swap_only" && revision?.kind !== "skills_update") ||
3114
- (revision.kind === "swap_only" &&
3115
- override.rendererFormat !== SWAP_OBSERVATION_FORMAT &&
3116
- override.rendererFormat !== SWAP_TOOL_IMAGE_FORMAT) ||
3117
- (revision.kind === "skills_update" &&
3118
- override.rendererFormat !== SKILL_ACTIVATION_RECEIPT_FORMAT) ||
3119
- revisionNumberById.get(revision.revisionId) === undefined ||
3120
- override.ordinal < revision.keepFromOrdinal ||
3121
- override.ordinal > revision.sourceThroughOrdinal ||
3122
- seenMessages.has(override.messageId) ||
3123
- message?.role !== "tool" ||
3124
- message.frameId !== override.frameId ||
3125
- message.ordinal !== override.ordinal ||
3126
- frame?.kind !== "tool_exchange" ||
3127
- frame.state !== "closed" ||
3128
- result === undefined
3129
- ) {
3130
- throw new Error("Stored context override canonical identity is invalid.");
1380
+ stagingDatabase.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1381
+ const standaloneJournal = stagingDatabase
1382
+ .query("PRAGMA journal_mode = DELETE")
1383
+ .get() as Record<string, unknown> | null;
1384
+ if (String(standaloneJournal?.journal_mode).toLowerCase() !== "delete") {
1385
+ throw new Error("Cloned session database did not leave WAL mode.");
1386
+ }
1387
+ stagingDatabase.close();
1388
+ stagingDatabase = undefined;
1389
+ await unlinkIfExists(`${stagingDatabasePath}-wal`);
1390
+ await unlinkIfExists(`${stagingDatabasePath}-shm`);
1391
+ await validateSecureDirectory(stagingDirectory, input.targetSessionId);
1392
+ await validateSecureFile(stagingDatabasePath, input.targetSessionId);
1393
+ await validateSecureOptionalFile(
1394
+ path.join(stagingDirectory, "events.jsonl"),
1395
+ input.targetSessionId,
1396
+ );
1397
+ await validateSecureOptionalFile(
1398
+ path.join(stagingDirectory, "observations.md"),
1399
+ input.targetSessionId,
1400
+ );
1401
+ input.faultInjector?.("after_artifact_validation");
1402
+ await assertPathMissing(targetDirectory, input.targetSessionId);
1403
+ input.faultInjector?.("before_publish_rename");
1404
+ await rename(stagingDirectory, targetDirectory);
1405
+ published = true;
1406
+ } finally {
1407
+ if (stagingDatabase !== undefined) {
1408
+ try {
1409
+ stagingDatabase.close();
1410
+ } catch {
1411
+ // Preserve the clone failure.
1412
+ }
3131
1413
  }
3132
- const rendered =
3133
- override.rendererFormat === SWAP_OBSERVATION_FORMAT ||
3134
- override.rendererFormat === SWAP_TOOL_IMAGE_FORMAT
3135
- ? this.swapRenderer.render({ message, result })
3136
- : this.renderStoredSkillReceipt(message, result, revision.revisionId);
3137
- if (
3138
- stableJsonStringify(rendered) !==
3139
- stableJsonStringify(stripStoredOverride(override))
3140
- ) {
3141
- throw new Error(
3142
- `Stored context override ${override.messageId} does not match deterministic rendering.`,
3143
- );
1414
+ if (!published) {
1415
+ await removeKnownInitializationFiles(stagingDirectory).catch(() => undefined);
3144
1416
  }
3145
- seenMessages.add(override.messageId);
3146
1417
  }
3147
1418
  }
3148
1419
 
3149
- private validateSkillActivationRows(
3150
- canonical: ProtocolContextView,
3151
- revisions: readonly StoredContextRevisionV8[],
3152
- overrides: readonly StoredContextOverrideV8[],
3153
- surfaces: readonly StoredContextSurfaceV8[],
3154
- ): void {
3155
- const activations = this.loadSkillActivations();
3156
- const activationByMessage = new Map(
3157
- activations.map((activation) => [activation.activationMessageId, activation]),
3158
- );
3159
- const messages = new Map(
3160
- canonical.messages.map((message) => [message.messageId, message]),
3161
- );
3162
- const results = new Map(
3163
- canonical.toolResults.map((result) => [result.toolMessageId, result]),
3164
- );
3165
- const loadedResults = canonical.toolResults.filter(
3166
- (result) =>
3167
- result.completion.kind === "returned" &&
3168
- result.completion.raw.kind === "skill" &&
3169
- result.completion.raw.ok &&
3170
- result.completion.raw.status === "loaded",
3171
- );
3172
- if (loadedResults.length !== activations.length) {
3173
- throw new Error("Loaded Agent Skill results and activation rows differ.");
1420
+ async close(reason: SessionCloseReason): Promise<void> {
1421
+ if (this.closed) {
1422
+ return;
3174
1423
  }
3175
- const revisionsById = new Map(
3176
- revisions.map((revision) => [revision.revisionId, revision]),
3177
- );
3178
- const overridesByMessage = new Map(
3179
- overrides.map((override) => [override.messageId, override]),
3180
- );
3181
- for (const activation of activations) {
3182
- const message = messages.get(activation.activationMessageId);
3183
- const result = results.get(activation.activationMessageId);
3184
- const raw =
3185
- result?.completion.kind === "returned" ? result.completion.raw : undefined;
3186
- if (
3187
- activation.sessionId !== this.sessionId ||
3188
- message?.role !== "tool" ||
3189
- message.name !== "Skill" ||
3190
- message.toolCallId !== activation.toolCallId ||
3191
- raw?.kind !== "skill" ||
3192
- !raw.ok ||
3193
- raw.status !== "loaded" ||
3194
- raw.name !== activation.name ||
3195
- raw.scope !== activation.scope ||
3196
- raw.sha256 !== activation.skillFileSha256
3197
- ) {
3198
- throw new Error("Agent Skill activation canonical identity is invalid.");
3199
- }
3200
- if (activation.settledRevisionId !== undefined) {
3201
- const revision = revisionsById.get(activation.settledRevisionId);
3202
- const override = overridesByMessage.get(activation.activationMessageId);
3203
- if (
3204
- revision?.kind !== "skills_update" ||
3205
- override?.introducedRevisionId !== revision.revisionId ||
3206
- override.rendererFormat !== SKILL_ACTIVATION_RECEIPT_FORMAT
3207
- ) {
3208
- throw new Error("Settled Agent Skill activation receipt is invalid.");
3209
- }
3210
- }
1424
+ let primaryError: unknown;
1425
+ const now = this.clock();
1426
+ try {
1427
+ runTransaction(this.database, () => {
1428
+ const updated = this.database
1429
+ .query(
1430
+ `UPDATE session_meta SET last_closed_at = ?, last_close_reason = ?, updated_at = ?
1431
+ WHERE singleton = 1`,
1432
+ )
1433
+ .run(now, reason, now);
1434
+ requireSingleChange(this.database, updated.changes, "close session activation");
1435
+ });
1436
+ } catch (error) {
1437
+ primaryError = sessionWriteError("close_session", this.sessionId, error);
3211
1438
  }
3212
- for (const surface of surfaces) {
3213
- for (const active of surface.activeSkills) {
3214
- const activation = activationByMessage.get(active.activationMessageId);
3215
- if (activation?.state !== "promoted" || activation.name !== active.name) {
3216
- throw new Error(
3217
- "Context surface references an invalid Agent Skill activation.",
3218
- );
3219
- }
3220
- }
1439
+ try {
1440
+ this.database.close();
1441
+ } catch (error) {
1442
+ primaryError ??= error;
3221
1443
  }
3222
- }
3223
-
3224
- private renderStoredSkillReceipt(
3225
- message: Extract<CanonicalMessageRecord, { role: "tool" }>,
3226
- result: ToolResultRecord,
3227
- revisionId: ContextRevisionId,
3228
- ): SwapOverride {
3229
- if (
3230
- message.name !== "Skill" ||
3231
- result.completion.kind !== "returned" ||
3232
- result.completion.raw.kind !== "skill" ||
3233
- !result.completion.raw.ok ||
3234
- result.completion.raw.status !== "loaded"
3235
- ) {
3236
- throw new Error("Agent Skill receipt does not target a loaded Skill result.");
1444
+ this.closed = true;
1445
+ try {
1446
+ await this.lease.release();
1447
+ } catch (error) {
1448
+ primaryError ??= error;
3237
1449
  }
3238
- const activation = this.loadSkillActivations().find(
3239
- (entry) => entry.activationMessageId === message.messageId,
3240
- );
3241
- if (
3242
- activation === undefined ||
3243
- activation.settledRevisionId !== revisionId ||
3244
- (activation.state !== "promoted" && activation.state !== "rejected")
3245
- ) {
3246
- throw new Error("Agent Skill receipt has no matching settled activation.");
1450
+ if (primaryError !== undefined) {
1451
+ throw asError(primaryError);
3247
1452
  }
3248
- return renderSkillActivationReceipt({
3249
- message: {
3250
- messageId: message.messageId,
3251
- frameId: message.frameId,
3252
- ordinal: message.ordinal,
3253
- content: message.displayText,
3254
- contentSha256: message.contentSha256,
3255
- },
3256
- name: activation.name,
3257
- outcome:
3258
- activation.state === "promoted"
3259
- ? "promoted"
3260
- : activation.rejectionReason === "unavailable"
3261
- ? "unavailable"
3262
- : "rejected",
3263
- });
3264
1453
  }
3265
1454
 
3266
- private loadMeasuredContextState(): StoredMeasuredContextState | undefined {
3267
- const rows = this.database.query("SELECT * FROM context_measurement_state").all();
3268
- if (rows.length > 1) {
3269
- throw new Error(
3270
- `Expected at most one context measurement row; found ${rows.length}.`,
3271
- );
1455
+ async abandon(): Promise<void> {
1456
+ if (this.closed) {
1457
+ return;
1458
+ }
1459
+ try {
1460
+ this.database.close();
1461
+ } catch {
1462
+ // A failed delete path may already have closed the connection.
3272
1463
  }
3273
- const row = rows[0];
3274
- return row === undefined
3275
- ? undefined
3276
- : decodeMeasuredContextState(row, this.sessionId);
1464
+ this.closed = true;
1465
+ await this.lease.release();
3277
1466
  }
3278
1467
 
3279
- private validateCounters(
3280
- meta: StoredSessionMetaV10,
3281
- view: ProtocolContextView,
3282
- ): void {
3283
- const turns = this.database
3284
- .query("SELECT * FROM turns ORDER BY turn_number")
3285
- .all() as Array<Record<string, unknown>>;
3286
- for (let index = 0; index < turns.length; index += 1) {
3287
- const turn = turns[index];
3288
- if (numberFromSql(turn.turn_number, "turn_number") !== index + 1) {
3289
- throw new Error("Turn number sequence has a gap.");
3290
- }
3291
- const turnId = stringFromSql(turn.turn_id, "turn_id");
3292
- const turnStatus = enumFromSql(
3293
- turn.status,
3294
- ["open", "completed", "failed", "cancelled", "interrupted"] as const,
3295
- "turn status",
3296
- );
3297
- const iterations = this.database
3298
- .query("SELECT * FROM iterations WHERE turn_id = ? ORDER BY iteration_number")
3299
- .all(turnId) as Array<Record<string, unknown>>;
3300
- const storedLastIterationId = nullableStringFromSql(
3301
- turn.last_iteration_id,
3302
- "last_iteration_id",
3303
- );
3304
- const actualLastIterationId =
3305
- iterations.length === 0
3306
- ? null
3307
- : stringFromSql(iterations.at(-1)!.iteration_id, "iteration_id");
3308
- if (storedLastIterationId !== actualLastIterationId) {
3309
- throw new Error(`Last iteration identity is invalid in turn ${turnId}.`);
3310
- }
3311
- for (
3312
- let iterationIndex = 0;
3313
- iterationIndex < iterations.length;
3314
- iterationIndex += 1
3315
- ) {
3316
- const iteration = iterations[iterationIndex];
3317
- if (
3318
- numberFromSql(iteration.iteration_number, "iteration_number") !==
3319
- iterationIndex + 1
3320
- ) {
3321
- throw new Error(`Iteration number sequence has a gap in turn ${turnId}.`);
3322
- }
3323
- const iterationId = stringFromSql(iteration.iteration_id, "iteration_id");
3324
- const outcome = enumFromSql(
3325
- iteration.outcome,
3326
- [
3327
- "open",
3328
- "continue",
3329
- "completed",
3330
- "failed",
3331
- "cancelled",
3332
- "interrupted",
3333
- ] as const,
3334
- "iteration outcome",
3335
- );
3336
- if (iterationIndex < iterations.length - 1 && outcome !== "continue") {
3337
- throw new Error(
3338
- `Non-final iteration ${iterationId} must have continue outcome.`,
3339
- );
3340
- }
3341
- const toolCalls = view.messages.flatMap((message) =>
3342
- message.role === "assistant" && message.iterationId === iterationId
3343
- ? (message.toolCalls ?? [])
3344
- : [],
3345
- );
3346
- if (
3347
- numberFromSql(iteration.next_tool_call_number, "next_tool_call_number") !==
3348
- toolCalls.length + 1
3349
- ) {
3350
- throw new Error(`Tool call counter is invalid in iteration ${iterationId}.`);
3351
- }
3352
- }
3353
- if (
3354
- numberFromSql(turn.next_iteration_number, "next_iteration_number") !==
3355
- iterations.length + 1
3356
- ) {
3357
- throw new Error(`Iteration counter is invalid in turn ${turnId}.`);
3358
- }
3359
- const openIterationCount = iterations.filter(
3360
- (iteration) => iteration.outcome === "open",
3361
- ).length;
3362
- if (turnStatus === "open" && openIterationCount > 1) {
3363
- throw new Error(`Open turn ${turnId} has multiple open iterations.`);
3364
- }
3365
- if (turnStatus !== "open" && openIterationCount !== 0) {
3366
- throw new Error(`Terminal turn ${turnId} still has an open iteration.`);
3367
- }
3368
- const lastOutcome = iterations.at(-1)?.outcome;
3369
- if (
3370
- turnStatus !== "open" &&
3371
- iterations.length > 0 &&
3372
- lastOutcome !== turnStatus &&
3373
- !(turnStatus === "interrupted" && lastOutcome === "continue")
3374
- ) {
3375
- throw new Error(
3376
- `Terminal turn ${turnId} does not match its last iteration outcome.`,
3377
- );
3378
- }
3379
- const finalMessageId = nullableStringFromSql(
3380
- turn.final_message_id,
3381
- "final_message_id",
1468
+ async deleteFromDisk(): Promise<void> {
1469
+ this.requireOpen();
1470
+ const known = new Set([
1471
+ "session.sqlite",
1472
+ "session.sqlite-wal",
1473
+ "session.sqlite-shm",
1474
+ "events.jsonl",
1475
+ "observations.md",
1476
+ "active.lock",
1477
+ "active.lock.reclaim",
1478
+ ]);
1479
+ const entries = await readdir(this.sessionDirectory);
1480
+ const unknown = entries.filter((entry) => !known.has(entry));
1481
+ if (unknown.length > 0) {
1482
+ throw new SessionError(
1483
+ "SESSION_DELETE_BLOCKED",
1484
+ "delete_session",
1485
+ `Session directory contains unknown files: ${unknown.join(", ")}.`,
1486
+ { sessionId: this.sessionId },
3382
1487
  );
3383
- if (turnStatus === "completed") {
3384
- const finalMessage = view.messages.find(
3385
- (message) => message.messageId === finalMessageId,
3386
- );
3387
- const lastTurnMessage = [...view.messages]
3388
- .reverse()
3389
- .find((message) => "turnId" in message && message.turnId === turnId);
3390
- if (
3391
- finalMessage?.role !== "assistant" ||
3392
- finalMessage.turnId !== turnId ||
3393
- (finalMessage.toolCalls?.length ?? 0) !== 0 ||
3394
- lastTurnMessage?.messageId !== finalMessage.messageId
3395
- ) {
3396
- throw new Error(`Final message identity is invalid in turn ${turnId}.`);
3397
- }
3398
- } else if (finalMessageId !== null) {
3399
- throw new Error(`Non-completed turn ${turnId} has a final message.`);
3400
- }
3401
1488
  }
3402
- if (meta.nextTurnNumber !== turns.length + 1) {
3403
- throw new Error("Session turn counter is invalid.");
3404
- }
3405
- }
3406
1489
 
3407
- private requireTurnRow(turnId: TurnId): Record<string, unknown> {
3408
- const row = this.database
3409
- .query("SELECT * FROM turns WHERE turn_id = ?")
3410
- .get(turnId) as Record<string, unknown> | null;
3411
- if (row === null) {
3412
- throw new Error(`Unknown turn ${turnId}.`);
1490
+ this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1491
+ this.database.close();
1492
+ const tombstone = `${this.sessionDirectory}.deleting-${randomUUID()}`;
1493
+ try {
1494
+ await rename(this.sessionDirectory, tombstone);
1495
+ } catch (error) {
1496
+ this.closed = true;
1497
+ await this.lease.release().catch(() => undefined);
1498
+ throw error;
3413
1499
  }
3414
- return row;
3415
- }
1500
+ this.lease.relocate(tombstone);
1501
+ await this.lease.release();
1502
+ this.closed = true;
3416
1503
 
3417
- private requireIterationRow(iterationId: IterationId): Record<string, unknown> {
3418
- const row = this.database
3419
- .query("SELECT * FROM iterations WHERE iteration_id = ?")
3420
- .get(iterationId) as Record<string, unknown> | null;
3421
- if (row === null) {
3422
- throw new Error(`Unknown iteration ${iterationId}.`);
1504
+ try {
1505
+ for (const name of known) {
1506
+ await unlinkIfExists(path.join(tombstone, name));
1507
+ }
1508
+ await rmdir(tombstone);
1509
+ } catch (error) {
1510
+ throw new SessionError(
1511
+ "SESSION_DELETE_BLOCKED",
1512
+ "delete_session_cleanup",
1513
+ `Session was removed from the catalog, but tombstone cleanup failed: ${tombstone}.`,
1514
+ { sessionId: this.sessionId, cause: error },
1515
+ );
3423
1516
  }
3424
- return row;
3425
- }
3426
-
3427
- private touch(timestamp: string): void {
3428
- const updated = this.database
3429
- .query("UPDATE session_meta SET updated_at = ? WHERE singleton = 1")
3430
- .run(timestamp);
3431
- requireSingleChange(this.database, updated.changes, "touch session");
3432
- }
3433
-
3434
- private recoveryError(message: string): SessionError {
3435
- return new SessionError("SESSION_RECOVERY_FAILED", "recover_session", message, {
3436
- sessionId: this.sessionId,
3437
- });
3438
1517
  }
3439
1518
 
3440
1519
  private requireOpen(): void {
@@ -3450,45 +1529,6 @@ export class SessionStore implements SessionLedgerCommitter {
3450
1529
  }
3451
1530
  }
3452
1531
 
3453
- function insertPendingSkillActivation(
3454
- database: Database,
3455
- message: CanonicalMessageRecord,
3456
- result: ToolResultRecord,
3457
- now: string,
3458
- ): void {
3459
- if (
3460
- message.role !== "tool" ||
3461
- result.completion.kind !== "returned" ||
3462
- result.completion.raw.kind !== "skill" ||
3463
- !result.completion.raw.ok ||
3464
- result.completion.raw.status !== "loaded"
3465
- ) {
3466
- return;
3467
- }
3468
- const raw = result.completion.raw;
3469
- if (message.name !== "Skill" || message.messageId !== result.toolMessageId) {
3470
- throw new Error("Loaded Agent Skill completion has invalid tool identity.");
3471
- }
3472
- database
3473
- .query(
3474
- `INSERT INTO skill_activations (
3475
- activation_message_id, tool_call_id, session_id, name, scope,
3476
- skill_file_sha256, state, dispatched_iteration_id, settled_revision_id,
3477
- rejection_reason, created_at, updated_at
3478
- ) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, NULL, ?, ?)`,
3479
- )
3480
- .run(
3481
- message.messageId,
3482
- result.toolCallId,
3483
- result.sessionId,
3484
- raw.name,
3485
- raw.scope,
3486
- raw.sha256,
3487
- now,
3488
- now,
3489
- );
3490
- }
3491
-
3492
1532
  export async function resolveSessionDatabasePath(
3493
1533
  workspaceRoot: string,
3494
1534
  sessionId: SessionId,
@@ -3502,437 +1542,6 @@ export async function resolveSessionDatabasePath(
3502
1542
  );
3503
1543
  }
3504
1544
 
3505
- function insertFrame(database: Database, frame: ProtocolFrame): void {
3506
- database
3507
- .query(
3508
- `INSERT INTO protocol_frames (
3509
- frame_id, session_id, turn_id, iteration_id, kind, state,
3510
- first_ordinal, last_ordinal, created_at, closed_at
3511
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3512
- )
3513
- .run(
3514
- frame.frameId,
3515
- frame.sessionId,
3516
- frame.turnId ?? null,
3517
- frame.iterationId ?? null,
3518
- frame.kind,
3519
- frame.state,
3520
- frame.firstOrdinal,
3521
- frame.lastOrdinal ?? null,
3522
- frame.createdAt,
3523
- frame.closedAt ?? null,
3524
- );
3525
- }
3526
-
3527
- function insertMessage(database: Database, message: CanonicalMessageRecord): void {
3528
- const assistant = message.role === "assistant" ? message : undefined;
3529
- const tool = message.role === "tool" ? message : undefined;
3530
- const turnId = "turnId" in message ? message.turnId : null;
3531
- const iterationId = "iterationId" in message ? message.iterationId : null;
3532
- const reasoningPresent =
3533
- assistant !== undefined && assistant.reasoningContent !== undefined ? 1 : 0;
3534
- database
3535
- .query(
3536
- `INSERT INTO messages (
3537
- message_id, session_id, frame_id, ordinal, role, turn_id, iteration_id,
3538
- content, content_sha256, reasoning_content, reasoning_content_present,
3539
- tool_calls_json, provider, model, tool_call_id, provider_tool_call_id,
3540
- name, origin, created_at
3541
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3542
- )
3543
- .run(
3544
- message.messageId,
3545
- message.sessionId,
3546
- message.frameId,
3547
- message.ordinal,
3548
- message.role,
3549
- turnId,
3550
- iterationId,
3551
- message.role === "tool" ? message.displayText : message.content,
3552
- message.contentSha256,
3553
- assistant?.reasoningContent ?? null,
3554
- reasoningPresent,
3555
- assistant?.toolCalls === undefined
3556
- ? null
3557
- : stableJsonStringify(assistant.toolCalls),
3558
- assistant?.provider ?? null,
3559
- assistant?.model ?? null,
3560
- tool?.toolCallId ?? null,
3561
- tool?.providerToolCallId ?? null,
3562
- tool?.name ?? null,
3563
- message.origin,
3564
- message.createdAt,
3565
- );
3566
- if (message.role === "user" && message.attachments !== undefined) {
3567
- insertMessageImageAttachments(database, message);
3568
- }
3569
- if (message.role === "tool") {
3570
- insertToolMessageContentBlocks(database, message);
3571
- }
3572
- }
3573
-
3574
- function insertMessageImageAttachments(
3575
- database: Database,
3576
- message: Extract<CanonicalMessageRecord, { role: "user" }>,
3577
- ): void {
3578
- const userMessage = {
3579
- role: "user" as const,
3580
- content: message.content,
3581
- attachments: message.attachments,
3582
- };
3583
- validateUserMessage(userMessage);
3584
- if (userMessageHash(userMessage) !== message.contentSha256) {
3585
- throw new Error("User image attachment hash does not match the message hash.");
3586
- }
3587
- for (let position = 0; position < message.attachments!.length; position += 1) {
3588
- const attachment = requireItem(message.attachments!, position, "image attachment");
3589
- ensureImageAsset(
3590
- database,
3591
- imageAssetRefFromAttachment(attachment),
3592
- message.createdAt,
3593
- );
3594
- database
3595
- .query(
3596
- `INSERT INTO message_image_attachments (
3597
- message_id, attachment_id, asset_id, position, label,
3598
- range_start, range_end, original_name
3599
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
3600
- )
3601
- .run(
3602
- message.messageId,
3603
- attachment.attachmentId,
3604
- attachment.assetId,
3605
- position,
3606
- attachment.label,
3607
- attachment.range.start,
3608
- attachment.range.end,
3609
- attachment.originalName,
3610
- );
3611
- }
3612
- }
3613
-
3614
- function insertToolMessageContentBlocks(
3615
- database: Database,
3616
- message: Extract<CanonicalMessageRecord, { role: "tool" }>,
3617
- ): void {
3618
- validateToolResultContent(message.content);
3619
- if (
3620
- canonicalToolResultContentHash(message.content) !== message.contentSha256 ||
3621
- toolResultDisplayText(message.content) !== message.displayText
3622
- ) {
3623
- throw new Error("Tool content blocks do not match canonical message metadata.");
3624
- }
3625
- for (let position = 0; position < message.content.length; position += 1) {
3626
- const block = requireItem(message.content, position, "tool content block");
3627
- if (block.type === "image") {
3628
- ensureImageAsset(database, block.asset, message.createdAt);
3629
- }
3630
- database
3631
- .query(
3632
- `INSERT INTO tool_message_content_blocks (
3633
- message_id, position, kind, text_content, asset_id
3634
- ) VALUES (?, ?, ?, ?, ?)`,
3635
- )
3636
- .run(
3637
- message.messageId,
3638
- position,
3639
- block.type,
3640
- block.type === "text" ? block.text : null,
3641
- block.type === "image" ? block.asset.assetId : null,
3642
- );
3643
- }
3644
- }
3645
-
3646
- function ensureImageAsset(
3647
- database: Database,
3648
- asset: ImageAssetRef,
3649
- createdAt: string,
3650
- ): void {
3651
- const existing = database
3652
- .query(
3653
- `SELECT mime_type, byte_length, width, height, created_at
3654
- FROM image_assets WHERE asset_id = ?`,
3655
- )
3656
- .get(asset.assetId) as {
3657
- mime_type: unknown;
3658
- byte_length: unknown;
3659
- width: unknown;
3660
- height: unknown;
3661
- created_at: unknown;
3662
- } | null;
3663
- if (existing === null) {
3664
- database
3665
- .query(
3666
- `INSERT INTO image_assets (
3667
- asset_id, mime_type, byte_length, width, height, created_at
3668
- ) VALUES (?, ?, ?, ?, ?, ?)`,
3669
- )
3670
- .run(
3671
- asset.assetId,
3672
- asset.mimeType,
3673
- asset.byteLength,
3674
- asset.width,
3675
- asset.height,
3676
- createdAt,
3677
- );
3678
- return;
3679
- }
3680
- timestampFromSql(existing.created_at, "image asset created_at");
3681
- if (
3682
- existing.mime_type !== asset.mimeType ||
3683
- numberFromSql(existing.byte_length, "image asset byte_length") !==
3684
- asset.byteLength ||
3685
- numberFromSql(existing.width, "image asset width") !== asset.width ||
3686
- numberFromSql(existing.height, "image asset height") !== asset.height
3687
- ) {
3688
- throw new Error(`Image asset metadata conflicts for ${asset.assetId}.`);
3689
- }
3690
- }
3691
-
3692
- function insertToolResult(database: Database, result: ToolResultRecord): void {
3693
- const returned = result.completion.kind === "returned" ? result.completion : null;
3694
- const synthetic = result.completion.kind === "synthetic" ? result.completion : null;
3695
- database
3696
- .query(
3697
- `INSERT INTO tool_results (
3698
- tool_call_id, session_id, frame_id, tool_message_id, completion_kind,
3699
- raw_json, raw_sha256, observation_format, synthetic_reason,
3700
- synthetic_detail, observation_sha256, created_at
3701
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3702
- )
3703
- .run(
3704
- result.toolCallId,
3705
- result.sessionId,
3706
- result.frameId,
3707
- result.toolMessageId,
3708
- result.completion.kind,
3709
- returned === null ? null : stableJsonStringify(returned.raw),
3710
- returned?.rawSha256 ?? null,
3711
- returned?.observationFormat ?? null,
3712
- synthetic?.reason ?? null,
3713
- synthetic?.detail ?? null,
3714
- result.observationSha256,
3715
- result.createdAt,
3716
- );
3717
- }
3718
-
3719
- function insertContextSurface(
3720
- database: Database,
3721
- surface: StoredContextSurfaceV8,
3722
- ): void {
3723
- validateStoredContextSurface(surface);
3724
- database
3725
- .query(
3726
- `INSERT INTO context_surfaces (
3727
- surface_id, session_id, system_prompt, system_prompt_sha256,
3728
- recall_contract_version,
3729
- project_instruction_json, skill_catalog_json, skill_catalog_sha256,
3730
- active_skills_json, active_skills_sha256, tool_definitions_json,
3731
- tool_definitions_sha256, tool_schema_sha256, request_config_sha256,
3732
- request_max_output_tokens, surface_sha256, created_at
3733
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3734
- )
3735
- .run(
3736
- surface.surfaceId,
3737
- surface.sessionId,
3738
- surface.systemPrompt,
3739
- surface.systemPromptSha256,
3740
- surface.recallContractVersion,
3741
- surface.projectInstruction === undefined
3742
- ? null
3743
- : stableJsonStringify(surface.projectInstruction),
3744
- stableJsonStringify(surface.skillCatalog),
3745
- surface.skillCatalogSha256,
3746
- stableJsonStringify(surface.activeSkills),
3747
- surface.activeSkillsSha256,
3748
- stableJsonStringify(surface.toolDefinitions),
3749
- surface.toolDefinitionsSha256,
3750
- surface.toolSchemaSha256,
3751
- surface.requestConfigSha256,
3752
- surface.requestMaxOutputTokens,
3753
- surface.surfaceSha256,
3754
- surface.createdAt,
3755
- );
3756
- }
3757
-
3758
- function decodeMeasuredContextState(
3759
- value: unknown,
3760
- expectedSessionId: SessionId,
3761
- ): StoredMeasuredContextState {
3762
- const row = recordFromSql(value, "context measurement state");
3763
- const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
3764
- if (sessionId !== expectedSessionId) {
3765
- throw new Error(
3766
- `Context measurement session ID ${sessionId} does not match store.`,
3767
- );
3768
- }
3769
- const promptTokens = numberFromSql(row.prompt_tokens, "prompt_tokens");
3770
- const completionTokens = numberFromSql(row.completion_tokens, "completion_tokens");
3771
- const totalTokens = numberFromSql(row.total_tokens, "total_tokens");
3772
- if (totalTokens !== promptTokens + completionTokens) {
3773
- throw new Error(
3774
- "Context measurement total_tokens must equal prompt_tokens + completion_tokens.",
3775
- );
3776
- }
3777
- timestampFromSql(row.updated_at, "updated_at");
3778
- return Object.freeze({
3779
- revisionId: stringFromSql(row.revision_id, "revision_id") as ContextRevisionId,
3780
- anchor: Object.freeze({
3781
- totalTokens,
3782
- promptTokens,
3783
- completionTokens,
3784
- segmentCount: numberFromSql(row.segment_count, "segment_count"),
3785
- prefixHash: sha256FromSql(row.prefix_hash, "prefix_hash"),
3786
- requestConfigHash: sha256FromSql(row.request_config_hash, "request_config_hash"),
3787
- toolSchemaHash: sha256FromSql(row.tool_schema_hash, "tool_schema_hash"),
3788
- }),
3789
- });
3790
- }
3791
-
3792
- function assertCommitSwapRevisionInput(input: CommitSwapRevisionInput): void {
3793
- if (
3794
- input.revisionId.trim() === "" ||
3795
- input.expectedBaseRevisionId.trim() === "" ||
3796
- !Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
3797
- input.expectedBaseRevisionNumber < 1 ||
3798
- !Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
3799
- input.expectedCanonicalThroughOrdinal < 1 ||
3800
- input.addedOverrides.length < 1 ||
3801
- input.policyVersion !== "swap-only-v1" ||
3802
- input.rendererFormat !== SWAP_OBSERVATION_FORMAT
3803
- ) {
3804
- throw new Error("Commit swap revision input is invalid.");
3805
- }
3806
- for (const [name, hash] of [
3807
- [
3808
- "expectedBaseActiveOverrideManifestSha256",
3809
- input.expectedBaseActiveOverrideManifestSha256,
3810
- ],
3811
- ["planHash", input.planHash],
3812
- ["nextActiveOverrideManifestSha256", input.nextActiveOverrideManifestSha256],
3813
- ["canonicalSequenceSha256", input.canonicalSequenceSha256],
3814
- ["renderedMessageSha256", input.renderedMessageSha256],
3815
- ] as const) {
3816
- if (!/^[0-9a-f]{64}$/.test(hash)) {
3817
- throw new Error(`Commit swap revision ${name} must be a SHA-256 hash.`);
3818
- }
3819
- }
3820
- }
3821
-
3822
- function assertCommitSurfaceRefreshInput(input: CommitSurfaceRefreshInput): void {
3823
- if (
3824
- input.revisionId.trim() === "" ||
3825
- input.expectedBaseRevisionId.trim() === "" ||
3826
- !Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
3827
- input.expectedBaseRevisionNumber < 1 ||
3828
- !Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
3829
- input.expectedCanonicalThroughOrdinal < 1
3830
- ) {
3831
- throw new Error("Commit surface refresh input is invalid.");
3832
- }
3833
- for (const [name, hash] of [
3834
- [
3835
- "expectedBaseActiveOverrideManifestSha256",
3836
- input.expectedBaseActiveOverrideManifestSha256,
3837
- ],
3838
- ["changeManifestSha256", input.changeManifestSha256],
3839
- ["canonicalSequenceSha256", input.canonicalSequenceSha256],
3840
- ["renderedMessageSha256", input.renderedMessageSha256],
3841
- ] as const) {
3842
- if (!/^[0-9a-f]{64}$/.test(hash)) {
3843
- throw new Error(`Commit surface refresh ${name} must be a SHA-256 hash.`);
3844
- }
3845
- }
3846
- }
3847
-
3848
- function assertCommitSkillsUpdateInput(input: CommitSkillsUpdateInput): void {
3849
- if (
3850
- input.revisionId.trim() === "" ||
3851
- input.expectedBaseRevisionId.trim() === "" ||
3852
- !Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
3853
- input.expectedBaseRevisionNumber < 1 ||
3854
- !Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
3855
- input.expectedCanonicalThroughOrdinal < 1 ||
3856
- input.addedOverrides.length < 1 ||
3857
- input.settlements.length !== input.addedOverrides.length
3858
- ) {
3859
- throw new Error("Commit Agent Skills update input is invalid.");
3860
- }
3861
- for (const [name, hash] of [
3862
- [
3863
- "expectedBaseActiveOverrideManifestSha256",
3864
- input.expectedBaseActiveOverrideManifestSha256,
3865
- ],
3866
- ["changeManifestSha256", input.changeManifestSha256],
3867
- ["activationManifestSha256", input.activationManifestSha256],
3868
- ["nextActiveOverrideManifestSha256", input.nextActiveOverrideManifestSha256],
3869
- ["canonicalSequenceSha256", input.canonicalSequenceSha256],
3870
- ["renderedMessageSha256", input.renderedMessageSha256],
3871
- ] as const) {
3872
- if (!/^[0-9a-f]{64}$/.test(hash)) {
3873
- throw new Error(`Commit Agent Skills update ${name} must be a SHA-256 hash.`);
3874
- }
3875
- }
3876
- }
3877
-
3878
- function assertCommitPrefixRetirementRevisionInput(
3879
- input: CommitPrefixRetirementRevisionInput,
3880
- ): void {
3881
- if (
3882
- input.revisionId.trim() === "" ||
3883
- input.expectedBaseRevisionId.trim() === "" ||
3884
- input.policyVersion !== "recall-first-retirement-v1" ||
3885
- !Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
3886
- input.expectedBaseRevisionNumber < 1 ||
3887
- !Number.isSafeInteger(input.expectedBaseKeepFromOrdinal) ||
3888
- input.expectedBaseKeepFromOrdinal < 1 ||
3889
- !Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
3890
- input.expectedCanonicalThroughOrdinal < 1 ||
3891
- !Number.isSafeInteger(input.nextKeepFromOrdinal) ||
3892
- input.nextKeepFromOrdinal <= input.expectedBaseKeepFromOrdinal ||
3893
- input.retiredThroughOrdinal !== input.nextKeepFromOrdinal - 1 ||
3894
- !Number.isSafeInteger(input.retiredTurnCount) ||
3895
- input.retiredTurnCount < 1 ||
3896
- !Number.isSafeInteger(input.retiredFrameCount) ||
3897
- input.retiredFrameCount < 1 ||
3898
- !Number.isSafeInteger(input.retiredMessageCount) ||
3899
- input.retiredMessageCount < 1 ||
3900
- !Number.isSafeInteger(input.nextActiveOverrideCount) ||
3901
- input.nextActiveOverrideCount < 0
3902
- ) {
3903
- throw new Error("Commit prefix retirement input is invalid.");
3904
- }
3905
- for (const [name, hash] of [
3906
- ["expectedSurfaceSha256", input.expectedSurfaceSha256],
3907
- [
3908
- "expectedBaseActiveOverrideManifestSha256",
3909
- input.expectedBaseActiveOverrideManifestSha256,
3910
- ],
3911
- ["planHash", input.planHash],
3912
- ["nextActiveOverrideManifestSha256", input.nextActiveOverrideManifestSha256],
3913
- ["canonicalSequenceSha256", input.canonicalSequenceSha256],
3914
- ["renderedMessageSha256", input.renderedMessageSha256],
3915
- ] as const) {
3916
- if (!/^[0-9a-f]{64}$/.test(hash)) {
3917
- throw new Error(`Commit prefix retirement ${name} must be a SHA-256 hash.`);
3918
- }
3919
- }
3920
- }
3921
-
3922
- function requireActiveRevisionId(meta: StoredSessionMetaV10): ContextRevisionId {
3923
- if (meta.initializationState !== "ready" || meta.activeRevisionId === null) {
3924
- throw new Error("Session has no active context revision.");
3925
- }
3926
- return meta.activeRevisionId;
3927
- }
3928
-
3929
- function previousRevision(
3930
- revisions: readonly StoredContextRevisionV8[],
3931
- revision: StoredContextRevisionV8,
3932
- ): StoredContextRevisionV8 | undefined {
3933
- return revisions[revision.revisionNumber - 2];
3934
- }
3935
-
3936
1545
  function openWritableDatabase(databasePath: string): Database {
3937
1546
  const database = new Database(databasePath, {
3938
1547
  create: false,
@@ -3944,45 +1553,6 @@ function openWritableDatabase(databasePath: string): Database {
3944
1553
  return database;
3945
1554
  }
3946
1555
 
3947
- function runTransaction<T>(database: Database, operation: () => T): T {
3948
- database.exec("BEGIN IMMEDIATE");
3949
- try {
3950
- const result = operation();
3951
- database.exec("COMMIT");
3952
- return result;
3953
- } catch (error) {
3954
- try {
3955
- database.exec("ROLLBACK");
3956
- } catch {
3957
- // Preserve the mutation error; the session will fault and close the database.
3958
- }
3959
- throw error;
3960
- }
3961
- }
3962
-
3963
- function requireSingleChange(
3964
- database: Database,
3965
- reportedChanges: number | bigint,
3966
- operation: string,
3967
- ): void {
3968
- const row = database.query("SELECT changes() AS changes").get() as {
3969
- changes: number | bigint;
3970
- };
3971
- if (Number(row.changes) !== 1) {
3972
- throw new Error(
3973
- `${operation} must change exactly one row; changed ${row.changes} (driver reported ${reportedChanges}).`,
3974
- );
3975
- }
3976
- }
3977
-
3978
- function requireItem<T>(items: readonly T[], index: number, name: string): T {
3979
- const item = items[index];
3980
- if (item === undefined) {
3981
- throw new Error(`Missing ${name} at index ${index}.`);
3982
- }
3983
- return item;
3984
- }
3985
-
3986
1556
  function errorMessage(error: unknown): string {
3987
1557
  return error instanceof Error ? error.message : String(error);
3988
1558
  }