tinker-agent 1.0.65 → 1.2.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 (208) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +90 -9
  5. package/bin/tinker.js +39 -0
  6. package/node_modules/ansi-regex/index.d.ts +33 -0
  7. package/node_modules/ansi-regex/index.js +14 -0
  8. package/node_modules/ansi-regex/license +9 -0
  9. package/node_modules/ansi-regex/package.json +61 -0
  10. package/node_modules/ansi-regex/readme.md +66 -0
  11. package/node_modules/ansi-styles/index.d.ts +236 -0
  12. package/node_modules/ansi-styles/index.js +223 -0
  13. package/node_modules/ansi-styles/license +9 -0
  14. package/node_modules/ansi-styles/package.json +54 -0
  15. package/node_modules/ansi-styles/readme.md +173 -0
  16. package/node_modules/chalk/license +9 -0
  17. package/node_modules/chalk/package.json +83 -0
  18. package/node_modules/chalk/readme.md +297 -0
  19. package/node_modules/chalk/source/index.d.ts +325 -0
  20. package/node_modules/chalk/source/index.js +225 -0
  21. package/node_modules/chalk/source/utilities.js +33 -0
  22. package/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  23. package/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  24. package/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  25. package/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  26. package/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  27. package/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  28. package/node_modules/character-entities/index.d.ts +6 -0
  29. package/node_modules/character-entities/index.js +2132 -0
  30. package/node_modules/character-entities/license +22 -0
  31. package/node_modules/character-entities/package.json +78 -0
  32. package/node_modules/character-entities/readme.md +152 -0
  33. package/node_modules/decode-named-character-reference/index.d.ts +13 -0
  34. package/node_modules/decode-named-character-reference/index.d.ts.map +1 -0
  35. package/node_modules/decode-named-character-reference/index.dom.d.ts +6 -0
  36. package/node_modules/decode-named-character-reference/index.dom.d.ts.map +1 -0
  37. package/node_modules/decode-named-character-reference/index.dom.js +32 -0
  38. package/node_modules/decode-named-character-reference/index.js +19 -0
  39. package/node_modules/decode-named-character-reference/license +22 -0
  40. package/node_modules/decode-named-character-reference/package.json +90 -0
  41. package/node_modules/decode-named-character-reference/readme.md +136 -0
  42. package/node_modules/get-east-asian-width/index.d.ts +60 -0
  43. package/node_modules/get-east-asian-width/index.js +30 -0
  44. package/node_modules/get-east-asian-width/license +9 -0
  45. package/node_modules/get-east-asian-width/lookup-data.js +21 -0
  46. package/node_modules/get-east-asian-width/lookup.js +138 -0
  47. package/node_modules/get-east-asian-width/package.json +71 -0
  48. package/node_modules/get-east-asian-width/readme.md +65 -0
  49. package/node_modules/get-east-asian-width/utilities.js +24 -0
  50. package/node_modules/has-flag/index.d.ts +39 -0
  51. package/node_modules/has-flag/index.js +8 -0
  52. package/node_modules/has-flag/license +9 -0
  53. package/node_modules/has-flag/package.json +49 -0
  54. package/node_modules/has-flag/readme.md +74 -0
  55. package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
  56. package/node_modules/is-fullwidth-code-point/index.js +12 -0
  57. package/node_modules/is-fullwidth-code-point/license +9 -0
  58. package/node_modules/is-fullwidth-code-point/package.json +53 -0
  59. package/node_modules/is-fullwidth-code-point/readme.md +31 -0
  60. package/node_modules/markdansi/LICENSE +21 -0
  61. package/node_modules/markdansi/README.md +167 -0
  62. package/node_modules/markdansi/dist/ast.d.ts +99 -0
  63. package/node_modules/markdansi/dist/ast.js +1 -0
  64. package/node_modules/markdansi/dist/cli.d.ts +17 -0
  65. package/node_modules/markdansi/dist/cli.js +208 -0
  66. package/node_modules/markdansi/dist/hyperlink.d.ts +9 -0
  67. package/node_modules/markdansi/dist/hyperlink.js +26 -0
  68. package/node_modules/markdansi/dist/index.d.ts +10 -0
  69. package/node_modules/markdansi/dist/index.js +16 -0
  70. package/node_modules/markdansi/dist/parser.d.ts +2 -0
  71. package/node_modules/markdansi/dist/parser.js +205 -0
  72. package/node_modules/markdansi/dist/render.d.ts +9 -0
  73. package/node_modules/markdansi/dist/render.js +680 -0
  74. package/node_modules/markdansi/dist/stream.d.ts +39 -0
  75. package/node_modules/markdansi/dist/stream.js +192 -0
  76. package/node_modules/markdansi/dist/theme.d.ts +18 -0
  77. package/node_modules/markdansi/dist/theme.js +105 -0
  78. package/node_modules/markdansi/dist/types.d.ts +58 -0
  79. package/node_modules/markdansi/dist/types.js +1 -0
  80. package/node_modules/markdansi/dist/wrap.d.ts +10 -0
  81. package/node_modules/markdansi/dist/wrap.js +73 -0
  82. package/node_modules/markdansi/docs/spec.md +110 -0
  83. package/node_modules/markdansi/package.json +78 -0
  84. package/node_modules/markdansi/tsconfig.json +18 -0
  85. package/node_modules/marked/LICENSE +44 -0
  86. package/node_modules/marked/README.md +115 -0
  87. package/node_modules/marked/bin/main.js +284 -0
  88. package/node_modules/marked/bin/marked.js +16 -0
  89. package/node_modules/marked/lib/marked.d.ts +760 -0
  90. package/node_modules/marked/lib/marked.esm.js +77 -0
  91. package/node_modules/marked/lib/marked.esm.js.map +7 -0
  92. package/node_modules/marked/lib/marked.umd.js +79 -0
  93. package/node_modules/marked/lib/marked.umd.js.map +7 -0
  94. package/node_modules/marked/man/marked.1 +113 -0
  95. package/node_modules/marked/man/marked.1.md +93 -0
  96. package/node_modules/marked/package.json +103 -0
  97. package/node_modules/slice-ansi/index.d.ts +19 -0
  98. package/node_modules/slice-ansi/index.js +317 -0
  99. package/node_modules/slice-ansi/license +10 -0
  100. package/node_modules/slice-ansi/package.json +59 -0
  101. package/node_modules/slice-ansi/readme.md +55 -0
  102. package/node_modules/slice-ansi/tokenize-ansi.js +752 -0
  103. package/node_modules/string-width/index.d.ts +39 -0
  104. package/node_modules/string-width/index.js +203 -0
  105. package/node_modules/string-width/license +9 -0
  106. package/node_modules/string-width/package.json +65 -0
  107. package/node_modules/string-width/readme.md +66 -0
  108. package/node_modules/strip-ansi/index.d.ts +15 -0
  109. package/node_modules/strip-ansi/index.js +19 -0
  110. package/node_modules/strip-ansi/license +9 -0
  111. package/node_modules/strip-ansi/package.json +59 -0
  112. package/node_modules/strip-ansi/readme.md +37 -0
  113. package/node_modules/supports-color/browser.d.ts +1 -0
  114. package/node_modules/supports-color/browser.js +35 -0
  115. package/node_modules/supports-color/index.d.ts +55 -0
  116. package/node_modules/supports-color/index.js +202 -0
  117. package/node_modules/supports-color/license +9 -0
  118. package/node_modules/supports-color/package.json +64 -0
  119. package/node_modules/supports-color/readme.md +75 -0
  120. package/node_modules/supports-hyperlinks/browser.js +10 -0
  121. package/node_modules/supports-hyperlinks/index.d.ts +25 -0
  122. package/node_modules/supports-hyperlinks/index.js +163 -0
  123. package/node_modules/supports-hyperlinks/license +10 -0
  124. package/node_modules/supports-hyperlinks/package.json +47 -0
  125. package/node_modules/supports-hyperlinks/readme.md +45 -0
  126. package/package.json +41 -10
  127. package/patches/markdansi@0.3.2.patch +22 -2
  128. package/src/agent/context-builder.ts +24 -10
  129. package/src/agent/context-meter.ts +89 -5
  130. package/src/agent/loop.ts +107 -10
  131. package/src/agent/runtime-session.ts +1437 -82
  132. package/src/agent/session-ledger.ts +169 -29
  133. package/src/agent/types.ts +12 -1
  134. package/src/agent/user-prompt-projection.ts +62 -0
  135. package/src/cli/config.ts +57 -14
  136. package/src/cli/model-profiles.ts +124 -0
  137. package/src/cli/run-runner.ts +4 -1
  138. package/src/cli/tui-runner.tsx +68 -30
  139. package/src/context/compiled-context-hash.ts +1 -1
  140. package/src/context/compiled-context-validator.ts +92 -15
  141. package/src/context/context-automation-policy.ts +121 -0
  142. package/src/context/context-manager.ts +390 -11
  143. package/src/context/context-policy.ts +8 -0
  144. package/src/context/context-protocol-validator.ts +11 -1
  145. package/src/context/context-revision-compiler.ts +190 -27
  146. package/src/context/context-revision.ts +105 -19
  147. package/src/context/context-surface.ts +329 -0
  148. package/src/context/prefix-retirement-planner.ts +570 -0
  149. package/src/context/protocol-frame.ts +30 -3
  150. package/src/context/recall-retirement-contract.ts +27 -0
  151. package/src/context/swap-planner.ts +34 -10
  152. package/src/events/observation-text-log.ts +3 -3
  153. package/src/events/stdout-event-printer.ts +39 -3
  154. package/src/events/types.ts +172 -35
  155. package/src/ids/runtime-id.ts +6 -0
  156. package/src/ids/uuid-v7.ts +7 -0
  157. package/src/image/image-asset-store.ts +319 -0
  158. package/src/image/image-input-policy.ts +14 -0
  159. package/src/image/image-probe.ts +233 -0
  160. package/src/image/image-types.ts +262 -0
  161. package/src/instructions/project-instructions.ts +4 -1
  162. package/src/mcp/mcp-manager.ts +83 -19
  163. package/src/model/fake-model-client.ts +9 -1
  164. package/src/model/input-token-estimator.ts +25 -0
  165. package/src/model/model-client.ts +60 -0
  166. package/src/model/model-request-preflight.ts +1 -0
  167. package/src/model/moonshot-input-token-estimator.ts +107 -0
  168. package/src/model/openai-chat-mapping.ts +77 -1
  169. package/src/model/openai-chat-model-client.ts +389 -59
  170. package/src/model/openai-chat-stream.ts +272 -0
  171. package/src/model/prompt-prefix-hash.ts +2 -4
  172. package/src/model/token-estimator.ts +4 -0
  173. package/src/observation/observation-builder.ts +29 -0
  174. package/src/session/resume-projection.ts +94 -34
  175. package/src/session/session-catalog.ts +4 -4
  176. package/src/session/session-errors.ts +1 -1
  177. package/src/session/session-history-reader.ts +151 -4
  178. package/src/session/session-last-response-reader.ts +189 -0
  179. package/src/session/session-schema.ts +349 -35
  180. package/src/session/session-store.ts +3266 -255
  181. package/src/session/sqlite-session-ledger.ts +9 -4
  182. package/src/skills/skill-catalog.ts +182 -0
  183. package/src/skills/skill-context.ts +364 -0
  184. package/src/skills/skill-loader.ts +779 -0
  185. package/src/skills/skill-tool.ts +297 -0
  186. package/src/tools/edit.ts +51 -64
  187. package/src/tools/ensure-parent-directory.ts +6 -0
  188. package/src/tools/read.ts +2 -2
  189. package/src/tools/recall.ts +43 -40
  190. package/src/tools/registry.ts +24 -3
  191. package/src/tools/ripgrep.ts +3 -2
  192. package/src/tools/types.ts +43 -4
  193. package/src/tools/write.ts +20 -31
  194. package/src/tui/app.tsx +333 -96
  195. package/src/tui/clipboard.ts +5 -0
  196. package/src/tui/components/mcp-panel.tsx +28 -0
  197. package/src/tui/components/prompt-input.tsx +790 -257
  198. package/src/tui/components/skills-panel.tsx +31 -0
  199. package/src/tui/components/timeline.tsx +30 -1
  200. package/src/tui/event-store.ts +85 -2
  201. package/src/tui/file-mention.ts +234 -0
  202. package/src/tui/line-editor.ts +1 -0
  203. package/src/tui/project-slash-commands.ts +229 -0
  204. package/src/tui/prompt-draft.ts +510 -0
  205. package/src/tui/prompt-history.ts +294 -22
  206. package/src/tui/slash-commands.ts +46 -2
  207. package/src/tui/tui-session-controller.ts +110 -48
  208. package/src/tui/workspace-file-search.ts +94 -0
@@ -4,6 +4,7 @@ import {
4
4
  lstat,
5
5
  mkdir,
6
6
  open,
7
+ readFile,
7
8
  readdir,
8
9
  realpath,
9
10
  rename,
@@ -14,6 +15,7 @@ import { randomUUID } from "node:crypto";
14
15
  import { Database } from "bun:sqlite";
15
16
  import type {
16
17
  ContextRevisionId,
18
+ ContextSurfaceId,
17
19
  IterationId,
18
20
  MessageId,
19
21
  ProtocolFrameId,
@@ -22,11 +24,13 @@ import type {
22
24
  ToolCallId,
23
25
  TurnId,
24
26
  } from "../ids/runtime-id";
25
- import type {
26
- ModelContextBudget,
27
- ModelContextProfile,
27
+ import {
28
+ createModelContextProfile,
29
+ type ModelContextProfile,
28
30
  } from "../model/model-context-profile";
29
- import type { ToolRawResult } from "../tools/types";
31
+ import type { ModelMessageProtocol } from "../model/model-client";
32
+ import type { InputTokenEstimatorCompatibility } from "../model/input-token-estimator";
33
+ import type { ToolDefinition, ToolRawResult } from "../tools/types";
30
34
  import { sha256, stableJsonStringify } from "../model/model-request-preflight";
31
35
  import {
32
36
  ContextProtocolError,
@@ -41,33 +45,54 @@ import {
41
45
  ContextRevisionCompiler,
42
46
  createInitialContextRevision,
43
47
  } from "../context/context-revision-compiler";
48
+ import { recallFirstRetirementPolicyV1 } from "../context/context-policy";
44
49
  import {
45
50
  ContextSwapRenderer,
46
51
  SWAP_OBSERVATION_FORMAT,
47
52
  } from "../context/context-swap-renderer";
48
53
  import {
49
- TOOL_OBSERVATION_FORMAT,
54
+ SUPPORTED_TOOL_OBSERVATION_FORMATS,
50
55
  contentHash,
51
56
  immutableCanonicalClone,
52
57
  immutableRecord,
53
58
  interruptedCompletionInputs,
54
59
  observationForCompletion,
60
+ userMessageHash,
55
61
  type CanonicalMessageRecord,
56
62
  type ProtocolContextView,
57
63
  type ProtocolFrame,
58
64
  type ToolCompletion,
59
65
  type ToolResultRecord,
60
66
  } from "../context/protocol-frame";
67
+ import {
68
+ contextSurfaceChangeManifestHash,
69
+ contextSurfaceChanges,
70
+ validateStoredContextSurface,
71
+ type ContextSurfaceChanges,
72
+ type StoredContextSurfaceV8,
73
+ } from "../context/context-surface";
61
74
  import type {
62
- StoredContextRevisionV5,
63
- StoredContextSnapshotV5,
64
- StoredSwapOverrideV5,
75
+ StoredContextRevisionV8,
76
+ StoredContextSnapshotV8,
77
+ StoredContextOverrideV8,
65
78
  SwapOverride,
66
79
  } from "../context/context-revision";
67
80
  import type { IterationIdentity, ToolCall } from "../agent/types";
81
+ import {
82
+ parseImageAssetId,
83
+ parseImageAttachmentId,
84
+ validateOriginalImageName,
85
+ validateUserMessage,
86
+ type ImageAssetRef,
87
+ type UserImageAttachment,
88
+ } from "../image/image-types";
89
+ import { ImageAssetStore } from "../image/image-asset-store";
68
90
  import type { MeasuredContextAnchor } from "../agent/context-meter";
69
91
  import type { ProjectInstructionManifest } from "../instructions/project-instructions";
92
+ import { SUPPORTED_RECALL_RETIREMENT_CONTRACT_VERSIONS } from "../context/recall-retirement-contract";
93
+ import type { ClosedTurnBoundary } from "../context/prefix-retirement-planner";
70
94
  import {
95
+ AdmissionStaleError,
71
96
  InMemorySessionLedger,
72
97
  type LedgerMutation,
73
98
  type SessionLedgerCommitter,
@@ -79,31 +104,57 @@ import {
79
104
  } from "./session-history-reader";
80
105
  import { SessionLease } from "./session-lock";
81
106
  import {
82
- SESSION_SCHEMA_V5_FINGERPRINT,
107
+ SESSION_SCHEMA_V9_FINGERPRINT,
83
108
  SESSION_SCHEMA_VERSION,
84
109
  configureWritableDatabase,
85
110
  createSessionSchema,
111
+ dropSessionCloneTriggers,
86
112
  rebuildRecallIndex,
113
+ reinstallSessionCloneTriggers,
87
114
  verifyRecallIndex,
88
115
  verifySessionSchema,
89
116
  verifySqliteIntegrity,
90
117
  } from "./session-schema";
118
+ import type { AgentEvent } from "../events/types";
119
+ import { renderObservationLogEvent } from "../events/observation-text-log";
120
+ import {
121
+ SKILL_FILE_MAX_BYTES,
122
+ SKILL_RESOURCE_MAX_DEPTH,
123
+ SKILL_RESOURCE_MAX_ENTRIES,
124
+ type SkillScope,
125
+ } from "../skills/skill-loader";
126
+ import type {
127
+ ActiveSkillManifestEntry,
128
+ SkillCatalogManifestEntry,
129
+ } from "../skills/skill-catalog";
130
+ import {
131
+ SKILL_ACTIVATION_RECEIPT_FORMAT,
132
+ SKILL_POLICY_VERSION,
133
+ renderSkillActivationReceipt,
134
+ } from "../skills/skill-context";
135
+ import {
136
+ IMAGE_INPUT_POLICY,
137
+ IMAGE_INPUT_POLICY_VERSION,
138
+ } from "../image/image-input-policy";
91
139
 
92
- export type RuntimeContractV1 = {
93
- version: 1;
140
+ export type SessionImageInputCompatibility = {
141
+ readonly policyVersion: string;
142
+ readonly policySha256: string;
143
+ readonly inputModalities: readonly ("text" | "image")[];
144
+ readonly tokenEstimator?: InputTokenEstimatorCompatibility;
145
+ };
146
+
147
+ export type SessionCompatibilityContract = {
94
148
  modelName: string;
95
149
  profileName?: string;
96
150
  includeReasoningContent: boolean;
97
151
  contextProfile: ModelContextProfile;
98
- contextBudget: ModelContextBudget;
99
- systemPromptSha256: string;
100
- toolSchemaSha256: string;
101
- requestConfigSha256: string;
102
- observationFormat: typeof TOOL_OBSERVATION_FORMAT;
152
+ messageProtocol: ModelMessageProtocol;
153
+ imageInput: SessionImageInputCompatibility;
103
154
  };
104
155
 
105
- export type StoredSessionMetaV5 = {
106
- schemaVersion: 5;
156
+ export type StoredSessionMetaV9 = {
157
+ schemaVersion: 9;
107
158
  schemaFingerprint: string;
108
159
  initializationState: "creating" | "ready";
109
160
  sessionId: SessionId;
@@ -111,10 +162,9 @@ export type StoredSessionMetaV5 = {
111
162
  modelName: string;
112
163
  systemPromptSha256: string;
113
164
  projectInstruction?: ProjectInstructionManifest;
114
- toolSchemaSha256: string | null;
115
- runtimeContractJson: string | null;
116
- runtimeContractSha256: string | null;
117
- activeRevisionId: ContextRevisionId;
165
+ sessionCompatibilityJson: string | null;
166
+ sessionCompatibilitySha256: string | null;
167
+ activeRevisionId: ContextRevisionId | null;
118
168
  nextTurnNumber: number;
119
169
  nextEventSequence: number;
120
170
  openCount: number;
@@ -131,7 +181,7 @@ export type StoredSessionMetaV5 = {
131
181
  | null;
132
182
  };
133
183
 
134
- export type SessionCloseReason = NonNullable<StoredSessionMetaV5["lastCloseReason"]>;
184
+ export type SessionCloseReason = NonNullable<StoredSessionMetaV9["lastCloseReason"]>;
135
185
 
136
186
  export type SessionRecoveryResult = {
137
187
  recoveredTurnId?: TurnId;
@@ -150,12 +200,12 @@ export type CommitSwapRevisionInput = {
150
200
  expectedBaseRevisionId: ContextRevisionId;
151
201
  expectedBaseRevisionNumber: number;
152
202
  expectedCanonicalThroughOrdinal: number;
153
- expectedBaseOverrideManifestSha256: string;
203
+ expectedBaseActiveOverrideManifestSha256: string;
154
204
  policyVersion: "swap-only-v1";
155
205
  rendererFormat: typeof SWAP_OBSERVATION_FORMAT;
156
206
  planHash: string;
157
207
  addedOverrides: readonly SwapOverride[];
158
- nextOverrideManifestSha256: string;
208
+ nextActiveOverrideManifestSha256: string;
159
209
  canonicalSequenceSha256: string;
160
210
  renderedMessageSha256: string;
161
211
  };
@@ -172,6 +222,155 @@ export type CommitSwapRevisionOptions = {
172
222
  faultInjector?: (stage: CommitSwapRevisionFaultStage) => void;
173
223
  };
174
224
 
225
+ export type CommitSurfaceRefreshInput = {
226
+ revisionId: ContextRevisionId;
227
+ expectedBaseRevisionId: ContextRevisionId;
228
+ expectedBaseRevisionNumber: number;
229
+ expectedCanonicalThroughOrdinal: number;
230
+ expectedBaseActiveOverrideManifestSha256: string;
231
+ surface: StoredContextSurfaceV8;
232
+ changes: ContextSurfaceChanges;
233
+ changeManifestSha256: string;
234
+ canonicalSequenceSha256: string;
235
+ renderedMessageSha256: string;
236
+ };
237
+
238
+ export type CommitSurfaceRefreshFaultStage =
239
+ | "before_surface_insert"
240
+ | "after_surface_insert"
241
+ | "after_revision_insert"
242
+ | "after_measurement_delete"
243
+ | "after_active_update";
244
+
245
+ export type CommitSurfaceRefreshOptions = {
246
+ faultInjector?: (stage: CommitSurfaceRefreshFaultStage) => void;
247
+ };
248
+
249
+ export type CommitPrefixRetirementRevisionInput = {
250
+ revisionId: ContextRevisionId;
251
+ expectedBaseRevisionId: ContextRevisionId;
252
+ expectedBaseRevisionNumber: number;
253
+ expectedBaseKeepFromOrdinal: number;
254
+ expectedCanonicalThroughOrdinal: number;
255
+ expectedSurfaceSha256: string;
256
+ expectedBaseActiveOverrideManifestSha256: string;
257
+ policyVersion: "recall-first-retirement-v1";
258
+ planHash: string;
259
+ nextKeepFromOrdinal: number;
260
+ retiredThroughOrdinal: number;
261
+ retiredTurnCount: number;
262
+ retiredFrameCount: number;
263
+ retiredMessageCount: number;
264
+ nextActiveOverrideCount: number;
265
+ nextActiveOverrideManifestSha256: string;
266
+ canonicalSequenceSha256: string;
267
+ renderedMessageSha256: string;
268
+ };
269
+
270
+ export type CommitPrefixRetirementRevisionFaultStage =
271
+ | "before_revision_insert"
272
+ | "after_revision_insert"
273
+ | "after_override_readback"
274
+ | "after_measurement_delete"
275
+ | "after_active_update"
276
+ | "after_snapshot_readback";
277
+
278
+ export type CommitPrefixRetirementRevisionOptions = {
279
+ faultInjector?: (stage: CommitPrefixRetirementRevisionFaultStage) => void;
280
+ };
281
+
282
+ export type StoredSkillActivation = {
283
+ readonly activationMessageId: MessageId;
284
+ readonly toolCallId: ToolCallId;
285
+ readonly sessionId: SessionId;
286
+ readonly name: string;
287
+ readonly scope: SkillScope;
288
+ readonly skillFileSha256: string;
289
+ readonly state: "pending" | "dispatched" | "promoted" | "rejected";
290
+ readonly dispatchedIterationId?: IterationId;
291
+ readonly settledRevisionId?: ContextRevisionId;
292
+ readonly rejectionReason?: string;
293
+ readonly createdAt: string;
294
+ readonly updatedAt: string;
295
+ };
296
+
297
+ export type CommitSkillsUpdateInput = {
298
+ revisionId: ContextRevisionId;
299
+ expectedBaseRevisionId: ContextRevisionId;
300
+ expectedBaseRevisionNumber: number;
301
+ expectedCanonicalThroughOrdinal: number;
302
+ expectedBaseActiveOverrideManifestSha256: string;
303
+ surface: StoredContextSurfaceV8;
304
+ changes: ContextSurfaceChanges;
305
+ changeManifestSha256: string;
306
+ activationManifestSha256: string;
307
+ addedOverrides: readonly SwapOverride[];
308
+ nextActiveOverrideManifestSha256: string;
309
+ settlements: readonly {
310
+ activationMessageId: MessageId;
311
+ name: string;
312
+ state: "promoted" | "rejected";
313
+ rejectionReason?: string;
314
+ }[];
315
+ canonicalSequenceSha256: string;
316
+ renderedMessageSha256: string;
317
+ };
318
+
319
+ export type CommitSkillsUpdateFaultStage =
320
+ | "before_surface_insert"
321
+ | "after_surface_insert"
322
+ | "after_revision_insert"
323
+ | "after_first_override_insert"
324
+ | "after_overrides_insert"
325
+ | "after_activations_update"
326
+ | "after_measurement_delete"
327
+ | "after_active_update";
328
+
329
+ export type CommitSkillsUpdateOptions = {
330
+ faultInjector?: (stage: CommitSkillsUpdateFaultStage) => void;
331
+ };
332
+
333
+ export type CloneSessionFaultStage =
334
+ | "after_staging_mkdir"
335
+ | "after_snapshot"
336
+ | "after_trigger_drop"
337
+ | "after_identity_update"
338
+ | "after_revision_hash_rewrite"
339
+ | "after_trigger_reinstall"
340
+ | "after_recall_validation"
341
+ | "after_event_rewrite"
342
+ | "after_observation_render"
343
+ | "after_artifact_validation"
344
+ | "before_publish_rename";
345
+
346
+ export type CloneSessionStoreInput = {
347
+ targetSessionId: SessionId;
348
+ faultInjector?: (stage: CloneSessionFaultStage) => void;
349
+ };
350
+
351
+ export function skillActivationManifestSha256(
352
+ settlements: CommitSkillsUpdateInput["settlements"],
353
+ ): string {
354
+ return sha256(
355
+ stableJsonStringify(
356
+ [...settlements]
357
+ .sort(
358
+ (left, right) =>
359
+ compareCanonicalText(left.name, right.name) ||
360
+ compareCanonicalText(left.activationMessageId, right.activationMessageId),
361
+ )
362
+ .map((settlement) => ({
363
+ activationMessageId: settlement.activationMessageId,
364
+ name: settlement.name,
365
+ state: settlement.state,
366
+ ...(settlement.rejectionReason === undefined
367
+ ? {}
368
+ : { rejectionReason: settlement.rejectionReason }),
369
+ })),
370
+ ),
371
+ );
372
+ }
373
+
175
374
  export type CreateNewSessionStoreInput = {
176
375
  workspaceRoot: string;
177
376
  sessionId: SessionId;
@@ -254,21 +453,14 @@ export class SessionStore implements SessionLedgerCommitter {
254
453
  createSessionSchema(database);
255
454
  verifySessionSchema(database, input.sessionId);
256
455
 
257
- const revisionId = input.idFactory.createContextRevisionId();
258
456
  const initialLedger = new InMemorySessionLedger({
259
457
  sessionId: input.sessionId,
260
458
  systemPrompt: input.systemPrompt,
261
459
  idFactory: input.idFactory,
262
- initialRevisionId: revisionId,
263
460
  clock,
264
461
  });
265
462
  const initialView = initialLedger.snapshot({ fullIntegrity: true });
266
463
  const createdAt = clock();
267
- const initialRevision = createInitialContextRevision({
268
- revisionId,
269
- canonical: initialView,
270
- createdAt,
271
- });
272
464
  runTransaction(database, () => {
273
465
  database!
274
466
  .query(
@@ -277,14 +469,14 @@ export class SessionStore implements SessionLedgerCommitter {
277
469
  session_id, workspace_root, model_name, system_prompt_sha256,
278
470
  project_instruction_file, project_instruction_byte_length,
279
471
  project_instruction_sha256,
280
- tool_schema_sha256, runtime_contract_json, runtime_contract_sha256,
472
+ session_compatibility_json, session_compatibility_sha256,
281
473
  active_revision_id, next_turn_number, next_event_sequence, open_count,
282
474
  created_at, updated_at, last_opened_at, last_closed_at, last_close_reason
283
- ) VALUES (1, ?, ?, 'creating', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, 1, 1, 1, ?, ?, ?, NULL, NULL)`,
475
+ ) VALUES (1, ?, ?, 'creating', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, 1, 1, 1, ?, ?, ?, NULL, NULL)`,
284
476
  )
285
477
  .run(
286
478
  SESSION_SCHEMA_VERSION,
287
- SESSION_SCHEMA_V5_FINGERPRINT,
479
+ SESSION_SCHEMA_V9_FINGERPRINT,
288
480
  input.sessionId,
289
481
  workspaceRoot,
290
482
  input.modelName,
@@ -292,7 +484,6 @@ export class SessionStore implements SessionLedgerCommitter {
292
484
  input.projectInstruction?.path ?? null,
293
485
  input.projectInstruction?.byteLength ?? null,
294
486
  input.projectInstruction?.sha256 ?? null,
295
- revisionId,
296
487
  createdAt,
297
488
  createdAt,
298
489
  createdAt,
@@ -302,24 +493,6 @@ export class SessionStore implements SessionLedgerCommitter {
302
493
  database!,
303
494
  requireItem(initialView.messages, 0, "system message"),
304
495
  );
305
- database!
306
- .query(
307
- `INSERT INTO context_revisions (
308
- revision_id, session_id, revision_number, parent_revision_id, kind,
309
- keep_from_ordinal, source_through_ordinal, added_override_count,
310
- total_override_count, override_manifest_sha256,
311
- canonical_sequence_sha256, rendered_message_sha256, policy_version,
312
- renderer_format, plan_sha256, created_at
313
- ) VALUES (?, ?, 1, NULL, 'initial_full', 1, 1, 0, 0, ?, ?, ?, NULL, NULL, NULL, ?)`,
314
- )
315
- .run(
316
- revisionId,
317
- input.sessionId,
318
- initialRevision.overrideManifestSha256,
319
- initialRevision.canonicalSequenceSha256,
320
- initialRevision.renderedMessageSha256,
321
- createdAt,
322
- );
323
496
  });
324
497
 
325
498
  const store = new SessionStore(database, lease, {
@@ -330,7 +503,7 @@ export class SessionStore implements SessionLedgerCommitter {
330
503
  clock,
331
504
  });
332
505
  await store.correctDatabaseModes();
333
- store.validateAll({ allowOpenTail: false });
506
+ store.validateCreatingState();
334
507
  verifyRecallIndex(database, input.sessionId);
335
508
  return store;
336
509
  } catch (error) {
@@ -394,7 +567,12 @@ export class SessionStore implements SessionLedgerCommitter {
394
567
  { sessionId: input.sessionId },
395
568
  );
396
569
  }
397
- store.validateAll({ allowOpenTail: true });
570
+ if (meta.initializationState === "creating") {
571
+ store.validateCreatingState();
572
+ } else {
573
+ store.validateAll({ allowOpenTail: true });
574
+ await store.verifyImageAssetFiles();
575
+ }
398
576
  try {
399
577
  verifyRecallIndex(database, input.sessionId);
400
578
  } catch (error) {
@@ -455,6 +633,9 @@ export class SessionStore implements SessionLedgerCommitter {
455
633
  }
456
634
  });
457
635
  } catch (error) {
636
+ if (error instanceof AdmissionStaleError) {
637
+ throw error;
638
+ }
458
639
  throw sessionWriteError(mutation.kind, this.sessionId, error);
459
640
  }
460
641
  }
@@ -554,46 +735,106 @@ export class SessionStore implements SessionLedgerCommitter {
554
735
  }
555
736
  }
556
737
 
557
- finalizeRuntimeContract(contract: RuntimeContractV1): void {
738
+ finalizeInitialization(input: {
739
+ contract: SessionCompatibilityContract;
740
+ surface: StoredContextSurfaceV8;
741
+ revisionId: ContextRevisionId;
742
+ }): void {
558
743
  this.requireOpen();
744
+ const contract = normalizeSessionCompatibilityContract(input.contract);
559
745
  const json = stableJsonStringify(contract);
560
746
  const contractSha256 = sha256(json);
561
747
  const now = this.clock();
562
748
  try {
563
749
  runTransaction(this.database, () => {
750
+ const meta = this.readMeta();
751
+ if (
752
+ meta.initializationState !== "creating" ||
753
+ meta.activeRevisionId !== null ||
754
+ input.surface.sessionId !== this.sessionId
755
+ ) {
756
+ throw new Error("Session initialization base is invalid.");
757
+ }
758
+ validateStoredContextSurface(input.surface);
759
+ const canonical = this.loadProtocolView();
760
+ const creationPrompt = this.readCreationSystemPrompt();
761
+ if (input.surface.systemPrompt !== creationPrompt) {
762
+ throw new Error(
763
+ "Initial context surface must match the creation system prompt.",
764
+ );
765
+ }
766
+ const revision = createInitialContextRevision({
767
+ revisionId: input.revisionId,
768
+ canonical,
769
+ surface: input.surface,
770
+ createdAt: now,
771
+ });
772
+ insertContextSurface(this.database, input.surface);
773
+ this.database
774
+ .query(
775
+ `INSERT INTO context_revisions (
776
+ revision_id, session_id, revision_number, parent_revision_id, kind,
777
+ surface_id, surface_sha256, keep_from_ordinal,
778
+ source_through_ordinal, added_override_count, active_override_count,
779
+ active_override_manifest_sha256, canonical_sequence_sha256,
780
+ rendered_message_sha256, policy_version, renderer_format,
781
+ plan_sha256, change_manifest_sha256, created_at
782
+ ) VALUES (?, ?, 1, NULL, 'initial_full', ?, ?, 1, 1, 0, 0, ?, ?, ?, NULL, NULL, NULL, NULL, ?)`,
783
+ )
784
+ .run(
785
+ revision.revisionId,
786
+ this.sessionId,
787
+ revision.surfaceId,
788
+ revision.surfaceSha256,
789
+ revision.activeOverrideManifestSha256,
790
+ revision.canonicalSequenceSha256,
791
+ revision.renderedMessageSha256,
792
+ revision.createdAt,
793
+ );
564
794
  const updated = this.database
565
795
  .query(
566
796
  `UPDATE session_meta
567
- SET initialization_state = 'ready', tool_schema_sha256 = ?,
568
- runtime_contract_json = ?, runtime_contract_sha256 = ?, updated_at = ?
797
+ SET initialization_state = 'ready', session_compatibility_json = ?,
798
+ session_compatibility_sha256 = ?, active_revision_id = ?, updated_at = ?
569
799
  WHERE singleton = 1 AND initialization_state = 'creating'
570
- AND runtime_contract_json IS NULL`,
800
+ AND session_compatibility_json IS NULL AND active_revision_id IS NULL`,
571
801
  )
572
- .run(contract.toolSchemaSha256, json, contractSha256, now);
802
+ .run(json, contractSha256, revision.revisionId, now);
573
803
  requireSingleChange(
574
804
  this.database,
575
805
  updated.changes,
576
- "finalize runtime contract",
806
+ "finalize session initialization",
577
807
  );
808
+ const readback = this.loadContextSnapshot();
809
+ if (
810
+ readback.revision.revisionId !== revision.revisionId ||
811
+ readback.surface.surfaceId !== input.surface.surfaceId
812
+ ) {
813
+ throw new Error("Finalized session initialization readback failed.");
814
+ }
578
815
  });
579
816
  } catch (error) {
580
- throw sessionWriteError("finalize_runtime_contract", this.sessionId, error);
817
+ throw sessionWriteError("finalize_session_initialization", this.sessionId, error);
581
818
  }
582
819
  }
583
820
 
584
- assertRuntimeContract(contract: RuntimeContractV1): void {
821
+ assertSessionCompatibility(contract: SessionCompatibilityContract): void {
585
822
  const meta = this.readMeta();
586
- const current = stableJsonStringify(contract);
823
+ const currentContract = normalizeSessionCompatibilityContract(contract);
824
+ const current = stableJsonStringify(currentContract);
587
825
  const currentHash = sha256(current);
588
826
  if (
589
- meta.runtimeContractJson !== current ||
590
- meta.runtimeContractSha256 !== currentHash
827
+ meta.sessionCompatibilityJson !== current ||
828
+ meta.sessionCompatibilitySha256 !== currentHash
591
829
  ) {
592
- const changed = runtimeContractDifferences(meta.runtimeContractJson, contract);
830
+ const changed = compatibilityContractDifferences(
831
+ meta.sessionCompatibilityJson,
832
+ currentContract,
833
+ );
593
834
  throw new SessionError(
594
- "SESSION_RUNTIME_MISMATCH",
595
- "compare_runtime_contract",
596
- `Session runtime contract changed: ${changed.join(", ") || "stored contract is invalid"}.`,
835
+ "SESSION_COMPATIBILITY_MISMATCH",
836
+ "compare_session_compatibility",
837
+ `Session compatibility contract changed: ${changed.join(", ") || "stored contract is invalid"}.`,
597
838
  { sessionId: this.sessionId },
598
839
  );
599
840
  }
@@ -602,7 +843,7 @@ export class SessionStore implements SessionLedgerCommitter {
602
843
  writeMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
603
844
  this.requireOpen();
604
845
  assertMeasuredContextAnchor(anchor);
605
- const revisionId = this.readMeta().activeRevisionId;
846
+ const revisionId = requireActiveRevisionId(this.readMeta());
606
847
  const now = this.clock();
607
848
  try {
608
849
  runTransaction(this.database, () => {
@@ -655,7 +896,7 @@ export class SessionStore implements SessionLedgerCommitter {
655
896
  if (state === undefined) {
656
897
  return undefined;
657
898
  }
658
- if (state.revisionId !== this.readMeta().activeRevisionId) {
899
+ if (state.revisionId !== requireActiveRevisionId(this.readMeta())) {
659
900
  return undefined;
660
901
  }
661
902
  return state.anchor;
@@ -686,10 +927,82 @@ export class SessionStore implements SessionLedgerCommitter {
686
927
  }
687
928
  }
688
929
 
930
+ loadClosedTurnBoundaries(): readonly ClosedTurnBoundary[] {
931
+ this.requireOpen();
932
+ this.assertContextRevisionIdle();
933
+ const canonical = this.loadProtocolView();
934
+ this.validator.validate(canonical, { fullIntegrity: true });
935
+ return this.readClosedTurnBoundaries(canonical);
936
+ }
937
+
938
+ private readClosedTurnBoundaries(
939
+ canonical: ProtocolContextView,
940
+ ): readonly ClosedTurnBoundary[] {
941
+ const rows = this.database
942
+ .query("SELECT * FROM turns ORDER BY turn_number")
943
+ .all() as Array<Record<string, unknown>>;
944
+ const boundaries: ClosedTurnBoundary[] = [];
945
+ let expectedOrdinal = 2;
946
+ for (let index = 0; index < rows.length; index += 1) {
947
+ const row = requireItem(rows, index, "turn row");
948
+ const turnId = stringFromSql(row.turn_id, "turn_id") as TurnId;
949
+ const turnNumber = numberFromSql(row.turn_number, "turn_number");
950
+ const status = enumFromSql(
951
+ row.status,
952
+ ["completed", "failed", "cancelled", "interrupted"] as const,
953
+ "closed turn status",
954
+ );
955
+ const frames = canonical.frames.filter((frame) => frame.turnId === turnId);
956
+ const messages = canonical.messages.filter(
957
+ (message) => message.role !== "system" && message.turnId === turnId,
958
+ );
959
+ const firstMessage = messages[0];
960
+ const lastMessage = messages.at(-1);
961
+ let nextFrameOrdinal = expectedOrdinal;
962
+ for (const frame of frames) {
963
+ if (
964
+ frame.state !== "closed" ||
965
+ frame.firstOrdinal !== nextFrameOrdinal ||
966
+ frame.lastOrdinal === undefined
967
+ ) {
968
+ throw new Error(`Turn ${turnId} has an invalid closed frame boundary.`);
969
+ }
970
+ nextFrameOrdinal = frame.lastOrdinal + 1;
971
+ }
972
+ if (
973
+ turnNumber !== index + 1 ||
974
+ frames.length < 1 ||
975
+ messages.length < 1 ||
976
+ firstMessage?.role !== "user" ||
977
+ firstMessage.ordinal !== expectedOrdinal ||
978
+ lastMessage === undefined ||
979
+ nextFrameOrdinal !== lastMessage.ordinal + 1
980
+ ) {
981
+ throw new Error(`Turn ${turnId} has an invalid canonical boundary.`);
982
+ }
983
+ boundaries.push(
984
+ Object.freeze({
985
+ turnId,
986
+ turnNumber,
987
+ status,
988
+ firstOrdinal: expectedOrdinal,
989
+ lastOrdinal: lastMessage.ordinal,
990
+ frameCount: frames.length,
991
+ messageCount: messages.length,
992
+ }),
993
+ );
994
+ expectedOrdinal = lastMessage.ordinal + 1;
995
+ }
996
+ if (expectedOrdinal !== canonical.messages.length + 1) {
997
+ throw new Error("Closed turn boundaries do not cover canonical history.");
998
+ }
999
+ return Object.freeze(boundaries);
1000
+ }
1001
+
689
1002
  commitSwapRevision(
690
1003
  input: CommitSwapRevisionInput,
691
1004
  options: CommitSwapRevisionOptions = {},
692
- ): Extract<StoredContextRevisionV5, { kind: "swap_only" }> {
1005
+ ): Extract<StoredContextRevisionV8, { kind: "swap_only" }> {
693
1006
  this.requireOpen();
694
1007
  assertCommitSwapRevisionInput(input);
695
1008
  const now = this.clock();
@@ -702,8 +1015,8 @@ export class SessionStore implements SessionLedgerCommitter {
702
1015
  baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
703
1016
  snapshot.canonical.messages.length !==
704
1017
  input.expectedCanonicalThroughOrdinal ||
705
- baseRevision.overrideManifestSha256 !==
706
- input.expectedBaseOverrideManifestSha256
1018
+ baseRevision.activeOverrideManifestSha256 !==
1019
+ input.expectedBaseActiveOverrideManifestSha256
707
1020
  ) {
708
1021
  throw new Error("Context revision commit base is stale.");
709
1022
  }
@@ -718,7 +1031,7 @@ export class SessionStore implements SessionLedgerCommitter {
718
1031
  new Set(candidateOverrides.map((override) => override.messageId)).size !==
719
1032
  candidateOverrides.length ||
720
1033
  activeOverrideManifestHash(candidateOverrides) !==
721
- input.nextOverrideManifestSha256
1034
+ input.nextActiveOverrideManifestSha256
722
1035
  ) {
723
1036
  throw new Error("Candidate override manifest is invalid.");
724
1037
  }
@@ -727,6 +1040,7 @@ export class SessionStore implements SessionLedgerCommitter {
727
1040
  canonical: snapshot.canonical,
728
1041
  activeOverrides: snapshot.activeOverrides,
729
1042
  addedOverrides: input.addedOverrides,
1043
+ activeSurface: snapshot.surface,
730
1044
  });
731
1045
  if (
732
1046
  canonicalSequenceHash(
@@ -743,28 +1057,32 @@ export class SessionStore implements SessionLedgerCommitter {
743
1057
  this.validateAddedOverrides(input.addedOverrides, snapshot.canonical);
744
1058
 
745
1059
  const revisionNumber = baseRevision.revisionNumber + 1;
746
- const totalOverrideCount =
747
- baseRevision.totalOverrideCount + input.addedOverrides.length;
1060
+ const activeOverrideCount =
1061
+ baseRevision.activeOverrideCount + input.addedOverrides.length;
748
1062
  options.faultInjector?.("before_revision_insert");
749
1063
  this.database
750
1064
  .query(
751
1065
  `INSERT INTO context_revisions (
752
1066
  revision_id, session_id, revision_number, parent_revision_id, kind,
753
- keep_from_ordinal, source_through_ordinal, added_override_count,
754
- total_override_count, override_manifest_sha256,
1067
+ surface_id, surface_sha256, keep_from_ordinal,
1068
+ source_through_ordinal, added_override_count,
1069
+ active_override_count, active_override_manifest_sha256,
755
1070
  canonical_sequence_sha256, rendered_message_sha256, policy_version,
756
- renderer_format, plan_sha256, created_at
757
- ) VALUES (?, ?, ?, ?, 'swap_only', 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1071
+ renderer_format, plan_sha256, change_manifest_sha256, created_at
1072
+ ) VALUES (?, ?, ?, ?, 'swap_only', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
758
1073
  )
759
1074
  .run(
760
1075
  input.revisionId,
761
1076
  this.sessionId,
762
1077
  revisionNumber,
763
1078
  baseRevision.revisionId,
1079
+ baseRevision.surfaceId,
1080
+ baseRevision.surfaceSha256,
1081
+ baseRevision.keepFromOrdinal,
764
1082
  input.expectedCanonicalThroughOrdinal,
765
1083
  input.addedOverrides.length,
766
- totalOverrideCount,
767
- input.nextOverrideManifestSha256,
1084
+ activeOverrideCount,
1085
+ input.nextActiveOverrideManifestSha256,
768
1086
  input.canonicalSequenceSha256,
769
1087
  input.renderedMessageSha256,
770
1088
  input.policyVersion,
@@ -816,15 +1134,15 @@ export class SessionStore implements SessionLedgerCommitter {
816
1134
  `SELECT co.* FROM context_overrides co
817
1135
  JOIN context_revisions cr
818
1136
  ON cr.revision_id = co.introduced_revision_id
819
- WHERE cr.revision_number <= ?
1137
+ WHERE cr.revision_number <= ? AND co.ordinal >= ?
820
1138
  ORDER BY co.ordinal`,
821
1139
  )
822
- .all(revisionNumber)
1140
+ .all(revisionNumber, baseRevision.keepFromOrdinal)
823
1141
  .map(decodeStoredSwapOverride);
824
1142
  if (
825
- storedCandidateOverrides.length !== totalOverrideCount ||
1143
+ storedCandidateOverrides.length !== activeOverrideCount ||
826
1144
  activeOverrideManifestHash(storedCandidateOverrides) !==
827
- input.nextOverrideManifestSha256
1145
+ input.nextActiveOverrideManifestSha256
828
1146
  ) {
829
1147
  throw new Error("Stored candidate override readback is invalid.");
830
1148
  }
@@ -856,7 +1174,7 @@ export class SessionStore implements SessionLedgerCommitter {
856
1174
  return readback.revision;
857
1175
  });
858
1176
  } catch (error) {
859
- if (this.readMeta().activeRevisionId !== input.expectedBaseRevisionId) {
1177
+ if (requireActiveRevisionId(this.readMeta()) !== input.expectedBaseRevisionId) {
860
1178
  throw new Error("Failed context revision transaction changed active state.", {
861
1179
  cause: error,
862
1180
  });
@@ -865,59 +1183,734 @@ export class SessionStore implements SessionLedgerCommitter {
865
1183
  }
866
1184
  }
867
1185
 
868
- private validateAddedOverrides(
869
- overrides: readonly SwapOverride[],
870
- canonical: ProtocolContextView,
871
- ): void {
872
- const messages = new Map(
873
- canonical.messages.map((message) => [message.messageId, message] as const),
874
- );
875
- const results = new Map(
876
- canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
877
- );
878
- for (const override of overrides) {
879
- const message = messages.get(override.messageId);
880
- const result = results.get(override.messageId);
881
- if (message?.role !== "tool" || result === undefined) {
882
- throw new Error("Added context override does not target a tool result.");
883
- }
884
- const expected = this.swapRenderer.render({ message, result });
885
- if (stableJsonStringify(expected) !== stableJsonStringify(override)) {
886
- throw new Error("Added context override is not deterministic.");
887
- }
888
- }
889
- }
890
-
891
- markResumed(): number {
1186
+ commitPrefixRetirementRevision(
1187
+ input: CommitPrefixRetirementRevisionInput,
1188
+ options: CommitPrefixRetirementRevisionOptions = {},
1189
+ ): Extract<StoredContextRevisionV8, { kind: "prefix_retirement" }> {
892
1190
  this.requireOpen();
1191
+ assertCommitPrefixRetirementRevisionInput(input);
893
1192
  const now = this.clock();
894
1193
  try {
895
1194
  return runTransaction(this.database, () => {
896
- const meta = this.readMeta();
897
- const next = meta.openCount + 1;
898
- const updated = this.database
1195
+ const snapshot = this.loadContextSnapshot();
1196
+ const baseRevision = snapshot.revision;
1197
+ if (
1198
+ baseRevision.revisionId !== input.expectedBaseRevisionId ||
1199
+ baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
1200
+ baseRevision.keepFromOrdinal !== input.expectedBaseKeepFromOrdinal ||
1201
+ snapshot.canonical.messages.length !==
1202
+ input.expectedCanonicalThroughOrdinal ||
1203
+ snapshot.surface.surfaceSha256 !== input.expectedSurfaceSha256 ||
1204
+ baseRevision.activeOverrideManifestSha256 !==
1205
+ input.expectedBaseActiveOverrideManifestSha256
1206
+ ) {
1207
+ throw new Error("Prefix retirement commit base is stale.");
1208
+ }
1209
+ this.assertContextRevisionIdle();
1210
+ const closedTurns = this.readClosedTurnBoundaries(snapshot.canonical);
1211
+ const activeTurns = closedTurns.filter(
1212
+ (turn) => turn.firstOrdinal >= baseRevision.keepFromOrdinal,
1213
+ );
1214
+ const nextBoundary = activeTurns.find(
1215
+ (turn) => turn.firstOrdinal === input.nextKeepFromOrdinal,
1216
+ );
1217
+ const retainedTurns = activeTurns.filter(
1218
+ (turn) => turn.firstOrdinal >= input.nextKeepFromOrdinal,
1219
+ );
1220
+ const retiredTurns = activeTurns.filter(
1221
+ (turn) => turn.lastOrdinal < input.nextKeepFromOrdinal,
1222
+ );
1223
+ if (
1224
+ nextBoundary === undefined ||
1225
+ input.nextKeepFromOrdinal <= baseRevision.keepFromOrdinal ||
1226
+ retainedTurns.length <
1227
+ recallFirstRetirementPolicyV1.protectedRecentTurnCount ||
1228
+ retiredTurns.length !== input.retiredTurnCount ||
1229
+ retiredTurns.reduce((total, turn) => total + turn.frameCount, 0) !==
1230
+ input.retiredFrameCount ||
1231
+ retiredTurns.reduce((total, turn) => total + turn.messageCount, 0) !==
1232
+ input.retiredMessageCount
1233
+ ) {
1234
+ throw new Error("Prefix retirement turn boundary is invalid.");
1235
+ }
1236
+
1237
+ const active = this.revisionCompiler.compileActive(snapshot);
1238
+ const nextActiveOverrides = snapshot.activeOverrides.filter(
1239
+ (override) => override.ordinal >= input.nextKeepFromOrdinal,
1240
+ );
1241
+ const candidate = this.revisionCompiler.compileProspective({
1242
+ active,
1243
+ canonical: snapshot.canonical,
1244
+ activeOverrides: snapshot.activeOverrides,
1245
+ addedOverrides: [],
1246
+ activeSurface: snapshot.surface,
1247
+ keepFromOrdinal: input.nextKeepFromOrdinal,
1248
+ });
1249
+ if (
1250
+ input.retiredThroughOrdinal !== input.nextKeepFromOrdinal - 1 ||
1251
+ nextActiveOverrides.length !== input.nextActiveOverrideCount ||
1252
+ activeOverrideManifestHash(nextActiveOverrides) !==
1253
+ input.nextActiveOverrideManifestSha256 ||
1254
+ canonicalSequenceHash(
1255
+ snapshot.canonical,
1256
+ input.expectedCanonicalThroughOrdinal,
1257
+ ) !== input.canonicalSequenceSha256 ||
1258
+ renderedMessageHash(
1259
+ candidate.entries,
1260
+ input.expectedCanonicalThroughOrdinal,
1261
+ ) !== input.renderedMessageSha256
1262
+ ) {
1263
+ throw new Error("Prefix retirement candidate is invalid.");
1264
+ }
1265
+
1266
+ const revisionNumber = baseRevision.revisionNumber + 1;
1267
+ options.faultInjector?.("before_revision_insert");
1268
+ this.database
899
1269
  .query(
900
- `UPDATE session_meta
901
- SET open_count = ?, last_opened_at = ?, updated_at = ?,
902
- last_closed_at = NULL, last_close_reason = NULL
903
- WHERE singleton = 1 AND open_count = ?`,
1270
+ `INSERT INTO context_revisions (
1271
+ revision_id, session_id, revision_number, parent_revision_id, kind,
1272
+ surface_id, surface_sha256, keep_from_ordinal,
1273
+ source_through_ordinal, added_override_count, active_override_count,
1274
+ active_override_manifest_sha256, canonical_sequence_sha256,
1275
+ rendered_message_sha256, policy_version, renderer_format,
1276
+ plan_sha256, change_manifest_sha256, retired_through_ordinal,
1277
+ retired_turn_count, retired_frame_count, retired_message_count,
1278
+ created_at
1279
+ ) VALUES (?, ?, ?, ?, 'prefix_retirement', ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, NULL, ?, NULL, ?, ?, ?, ?, ?)`,
904
1280
  )
905
- .run(next, now, now, meta.openCount);
906
- requireSingleChange(this.database, updated.changes, "increment open count");
907
- return next;
1281
+ .run(
1282
+ input.revisionId,
1283
+ this.sessionId,
1284
+ revisionNumber,
1285
+ baseRevision.revisionId,
1286
+ baseRevision.surfaceId,
1287
+ baseRevision.surfaceSha256,
1288
+ input.nextKeepFromOrdinal,
1289
+ input.expectedCanonicalThroughOrdinal,
1290
+ input.nextActiveOverrideCount,
1291
+ input.nextActiveOverrideManifestSha256,
1292
+ input.canonicalSequenceSha256,
1293
+ input.renderedMessageSha256,
1294
+ input.policyVersion,
1295
+ input.planHash,
1296
+ input.retiredThroughOrdinal,
1297
+ input.retiredTurnCount,
1298
+ input.retiredFrameCount,
1299
+ input.retiredMessageCount,
1300
+ now,
1301
+ );
1302
+ options.faultInjector?.("after_revision_insert");
1303
+
1304
+ const storedActiveOverrides = this.database
1305
+ .query(
1306
+ `SELECT co.* FROM context_overrides co
1307
+ JOIN context_revisions introduced
1308
+ ON introduced.revision_id = co.introduced_revision_id
1309
+ WHERE introduced.revision_number <= ? AND co.ordinal >= ?
1310
+ ORDER BY co.ordinal`,
1311
+ )
1312
+ .all(revisionNumber, input.nextKeepFromOrdinal)
1313
+ .map(decodeStoredSwapOverride);
1314
+ if (
1315
+ storedActiveOverrides.length !== input.nextActiveOverrideCount ||
1316
+ activeOverrideManifestHash(storedActiveOverrides) !==
1317
+ input.nextActiveOverrideManifestSha256
1318
+ ) {
1319
+ throw new Error("Prefix retirement override readback is invalid.");
1320
+ }
1321
+ options.faultInjector?.("after_override_readback");
1322
+
1323
+ this.database.query("DELETE FROM context_measurement_state").run();
1324
+ options.faultInjector?.("after_measurement_delete");
1325
+ const switched = this.database
1326
+ .query(
1327
+ `UPDATE session_meta SET active_revision_id = ?, updated_at = ?
1328
+ WHERE singleton = 1 AND active_revision_id = ?`,
1329
+ )
1330
+ .run(input.revisionId, now, baseRevision.revisionId);
1331
+ requireSingleChange(
1332
+ this.database,
1333
+ switched.changes,
1334
+ "activate prefix retirement revision",
1335
+ );
1336
+ options.faultInjector?.("after_active_update");
1337
+
1338
+ const readback = this.loadContextSnapshot();
1339
+ if (
1340
+ readback.revision.kind !== "prefix_retirement" ||
1341
+ readback.revision.revisionId !== input.revisionId ||
1342
+ readback.revision.keepFromOrdinal !== input.nextKeepFromOrdinal ||
1343
+ this.loadMeasuredContextState() !== undefined
1344
+ ) {
1345
+ throw new Error("Committed prefix retirement readback failed.");
1346
+ }
1347
+ options.faultInjector?.("after_snapshot_readback");
1348
+ return readback.revision;
908
1349
  });
909
1350
  } catch (error) {
910
- throw sessionWriteError("mark_resumed", this.sessionId, error);
1351
+ if (requireActiveRevisionId(this.readMeta()) !== input.expectedBaseRevisionId) {
1352
+ throw new Error("Failed prefix retirement changed active state.", {
1353
+ cause: error,
1354
+ });
1355
+ }
1356
+ throw sessionWriteError("commit_prefix_retirement", this.sessionId, error);
911
1357
  }
912
1358
  }
913
1359
 
914
- recoverInterruptedState(idFactory: RuntimeIdFactory): SessionRecoveryResult {
1360
+ commitSurfaceRefresh(
1361
+ input: CommitSurfaceRefreshInput,
1362
+ options: CommitSurfaceRefreshOptions = {},
1363
+ ): Extract<StoredContextRevisionV8, { kind: "surface_refresh" }> {
915
1364
  this.requireOpen();
916
- const view = this.loadProtocolView();
917
- const openTurns = this.database
918
- .query("SELECT turn_id FROM turns WHERE status = 'open' ORDER BY turn_number")
919
- .all() as Array<{ turn_id: string }>;
920
- const openFrames = view.frames.filter((frame) => frame.state === "open");
1365
+ assertCommitSurfaceRefreshInput(input);
1366
+ const now = this.clock();
1367
+ try {
1368
+ return runTransaction(this.database, () => {
1369
+ const snapshot = this.loadContextSnapshot();
1370
+ const baseRevision = snapshot.revision;
1371
+ if (
1372
+ baseRevision.revisionId !== input.expectedBaseRevisionId ||
1373
+ baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
1374
+ snapshot.canonical.messages.length !==
1375
+ input.expectedCanonicalThroughOrdinal ||
1376
+ baseRevision.activeOverrideManifestSha256 !==
1377
+ input.expectedBaseActiveOverrideManifestSha256
1378
+ ) {
1379
+ throw new Error("Context surface refresh base is stale.");
1380
+ }
1381
+ this.assertContextRevisionIdle();
1382
+ validateStoredContextSurface(input.surface);
1383
+ if (
1384
+ input.surface.sessionId !== this.sessionId ||
1385
+ input.surface.surfaceId === snapshot.surface.surfaceId ||
1386
+ input.surface.surfaceSha256 === snapshot.surface.surfaceSha256
1387
+ ) {
1388
+ throw new Error("Context surface refresh does not introduce a new surface.");
1389
+ }
1390
+ const actualChanges = contextSurfaceChanges(snapshot.surface, input.surface);
1391
+ if (
1392
+ stableJsonStringify(actualChanges) !== stableJsonStringify(input.changes) ||
1393
+ contextSurfaceChangeManifestHash(actualChanges) !==
1394
+ input.changeManifestSha256 ||
1395
+ !Object.values(actualChanges).some(Boolean)
1396
+ ) {
1397
+ throw new Error("Context surface refresh change manifest is invalid.");
1398
+ }
1399
+
1400
+ const active = this.revisionCompiler.compileActive(snapshot);
1401
+ const candidate = this.revisionCompiler.compileProspective({
1402
+ active,
1403
+ canonical: snapshot.canonical,
1404
+ activeOverrides: snapshot.activeOverrides,
1405
+ addedOverrides: [],
1406
+ activeSurface: snapshot.surface,
1407
+ surface: input.surface,
1408
+ });
1409
+ if (
1410
+ canonicalSequenceHash(
1411
+ snapshot.canonical,
1412
+ input.expectedCanonicalThroughOrdinal,
1413
+ ) !== input.canonicalSequenceSha256 ||
1414
+ renderedMessageHash(
1415
+ candidate.entries,
1416
+ input.expectedCanonicalThroughOrdinal,
1417
+ ) !== input.renderedMessageSha256
1418
+ ) {
1419
+ throw new Error("Candidate context surface prefix hash is invalid.");
1420
+ }
1421
+
1422
+ const revisionNumber = baseRevision.revisionNumber + 1;
1423
+ options.faultInjector?.("before_surface_insert");
1424
+ insertContextSurface(this.database, input.surface);
1425
+ options.faultInjector?.("after_surface_insert");
1426
+ this.database
1427
+ .query(
1428
+ `INSERT INTO context_revisions (
1429
+ revision_id, session_id, revision_number, parent_revision_id, kind,
1430
+ surface_id, surface_sha256, keep_from_ordinal,
1431
+ source_through_ordinal, added_override_count, active_override_count,
1432
+ active_override_manifest_sha256, canonical_sequence_sha256,
1433
+ rendered_message_sha256, policy_version, renderer_format,
1434
+ plan_sha256, change_manifest_sha256, created_at
1435
+ ) VALUES (?, ?, ?, ?, 'surface_refresh', ?, ?, ?, ?, 0, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)`,
1436
+ )
1437
+ .run(
1438
+ input.revisionId,
1439
+ this.sessionId,
1440
+ revisionNumber,
1441
+ baseRevision.revisionId,
1442
+ input.surface.surfaceId,
1443
+ input.surface.surfaceSha256,
1444
+ baseRevision.keepFromOrdinal,
1445
+ input.expectedCanonicalThroughOrdinal,
1446
+ baseRevision.activeOverrideCount,
1447
+ baseRevision.activeOverrideManifestSha256,
1448
+ input.canonicalSequenceSha256,
1449
+ input.renderedMessageSha256,
1450
+ input.changeManifestSha256,
1451
+ now,
1452
+ );
1453
+ options.faultInjector?.("after_revision_insert");
1454
+
1455
+ this.database.query("DELETE FROM context_measurement_state").run();
1456
+ options.faultInjector?.("after_measurement_delete");
1457
+ const switched = this.database
1458
+ .query(
1459
+ `UPDATE session_meta SET active_revision_id = ?, updated_at = ?
1460
+ WHERE singleton = 1 AND active_revision_id = ?`,
1461
+ )
1462
+ .run(input.revisionId, now, baseRevision.revisionId);
1463
+ requireSingleChange(
1464
+ this.database,
1465
+ switched.changes,
1466
+ "activate context surface revision",
1467
+ );
1468
+ options.faultInjector?.("after_active_update");
1469
+
1470
+ const readback = this.loadContextSnapshot();
1471
+ if (
1472
+ readback.revision.kind !== "surface_refresh" ||
1473
+ readback.revision.revisionId !== input.revisionId ||
1474
+ readback.surface.surfaceId !== input.surface.surfaceId ||
1475
+ this.loadMeasuredContextState() !== undefined
1476
+ ) {
1477
+ throw new Error("Committed context surface revision readback failed.");
1478
+ }
1479
+ return readback.revision;
1480
+ });
1481
+ } catch (error) {
1482
+ if (requireActiveRevisionId(this.readMeta()) !== input.expectedBaseRevisionId) {
1483
+ throw new Error("Failed context surface transaction changed active state.", {
1484
+ cause: error,
1485
+ });
1486
+ }
1487
+ throw sessionWriteError("commit_context_surface", this.sessionId, error);
1488
+ }
1489
+ }
1490
+
1491
+ commitSkillsUpdate(
1492
+ input: CommitSkillsUpdateInput,
1493
+ options: CommitSkillsUpdateOptions = {},
1494
+ ): Extract<StoredContextRevisionV8, { kind: "skills_update" }> {
1495
+ this.requireOpen();
1496
+ assertCommitSkillsUpdateInput(input);
1497
+ const now = this.clock();
1498
+ try {
1499
+ return runTransaction(this.database, () => {
1500
+ const snapshot = this.loadContextSnapshot();
1501
+ const baseRevision = snapshot.revision;
1502
+ if (
1503
+ baseRevision.revisionId !== input.expectedBaseRevisionId ||
1504
+ baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
1505
+ snapshot.canonical.messages.length !==
1506
+ input.expectedCanonicalThroughOrdinal ||
1507
+ baseRevision.activeOverrideManifestSha256 !==
1508
+ input.expectedBaseActiveOverrideManifestSha256
1509
+ ) {
1510
+ throw new Error("Agent Skills update base is stale.");
1511
+ }
1512
+ this.assertContextRevisionIdle();
1513
+ validateStoredContextSurface(input.surface);
1514
+
1515
+ const surfaceChanged =
1516
+ input.surface.surfaceSha256 !== snapshot.surface.surfaceSha256;
1517
+ const actualChanges = contextSurfaceChanges(snapshot.surface, input.surface);
1518
+ if (
1519
+ input.surface.sessionId !== this.sessionId ||
1520
+ stableJsonStringify(actualChanges) !== stableJsonStringify(input.changes) ||
1521
+ contextSurfaceChangeManifestHash(actualChanges) !==
1522
+ input.changeManifestSha256 ||
1523
+ (surfaceChanged && input.surface.surfaceId === snapshot.surface.surfaceId) ||
1524
+ (!surfaceChanged && input.surface.surfaceId !== snapshot.surface.surfaceId) ||
1525
+ (!surfaceChanged && Object.values(actualChanges).some(Boolean))
1526
+ ) {
1527
+ throw new Error("Agent Skills surface change manifest is invalid.");
1528
+ }
1529
+
1530
+ const unresolved = new Map(
1531
+ this.loadSkillActivations(["pending", "dispatched"]).map((entry) => [
1532
+ entry.activationMessageId,
1533
+ entry,
1534
+ ]),
1535
+ );
1536
+ if (
1537
+ input.settlements.length !== input.addedOverrides.length ||
1538
+ input.settlements.length !== unresolved.size ||
1539
+ new Set(input.settlements.map((entry) => entry.activationMessageId)).size !==
1540
+ input.settlements.length ||
1541
+ skillActivationManifestSha256(input.settlements) !==
1542
+ input.activationManifestSha256
1543
+ ) {
1544
+ throw new Error("Agent Skills settlement manifest is invalid.");
1545
+ }
1546
+ const canonicalMessages = new Map(
1547
+ snapshot.canonical.messages.map((message) => [message.messageId, message]),
1548
+ );
1549
+ const providedOverrides = new Map(
1550
+ input.addedOverrides.map((override) => [override.messageId, override]),
1551
+ );
1552
+ for (const settlement of input.settlements) {
1553
+ const activation = unresolved.get(settlement.activationMessageId);
1554
+ const message = canonicalMessages.get(settlement.activationMessageId);
1555
+ const override = providedOverrides.get(settlement.activationMessageId);
1556
+ if (
1557
+ activation === undefined ||
1558
+ message?.role !== "tool" ||
1559
+ message.name !== "Skill" ||
1560
+ override === undefined ||
1561
+ settlement.name !== activation.name ||
1562
+ (settlement.state === "promoted" &&
1563
+ (activation.state !== "dispatched" ||
1564
+ settlement.rejectionReason !== undefined)) ||
1565
+ (settlement.state === "rejected" &&
1566
+ (settlement.rejectionReason === undefined ||
1567
+ settlement.rejectionReason.trim() === "" ||
1568
+ settlement.rejectionReason.length > 256))
1569
+ ) {
1570
+ throw new Error(
1571
+ `Agent Skill activation ${settlement.activationMessageId} cannot be settled.`,
1572
+ );
1573
+ }
1574
+ const activeManifest = input.surface.activeSkills.find(
1575
+ (entry) =>
1576
+ entry.name === activation.name &&
1577
+ entry.activationMessageId === activation.activationMessageId,
1578
+ );
1579
+ if (
1580
+ (settlement.state === "promoted" && activeManifest === undefined) ||
1581
+ (settlement.state === "rejected" && activeManifest !== undefined)
1582
+ ) {
1583
+ throw new Error("Agent Skills active manifest does not match settlements.");
1584
+ }
1585
+ const expected = renderSkillActivationReceipt({
1586
+ message: {
1587
+ messageId: message.messageId,
1588
+ frameId: message.frameId,
1589
+ ordinal: message.ordinal,
1590
+ content: message.content,
1591
+ contentSha256: message.contentSha256,
1592
+ },
1593
+ name: activation.name,
1594
+ outcome:
1595
+ settlement.state === "promoted"
1596
+ ? "promoted"
1597
+ : settlement.rejectionReason === "unavailable"
1598
+ ? "unavailable"
1599
+ : "rejected",
1600
+ });
1601
+ if (stableJsonStringify(expected) !== stableJsonStringify(override)) {
1602
+ throw new Error("Agent Skill activation receipt is not deterministic.");
1603
+ }
1604
+ }
1605
+
1606
+ const active = this.revisionCompiler.compileActive(snapshot);
1607
+ const candidateOverrides = [
1608
+ ...snapshot.activeOverrides,
1609
+ ...input.addedOverrides,
1610
+ ];
1611
+ if (
1612
+ new Set(candidateOverrides.map((override) => override.messageId)).size !==
1613
+ candidateOverrides.length ||
1614
+ activeOverrideManifestHash(candidateOverrides) !==
1615
+ input.nextActiveOverrideManifestSha256
1616
+ ) {
1617
+ throw new Error("Agent Skills override manifest is invalid.");
1618
+ }
1619
+ const candidate = this.revisionCompiler.compileProspective({
1620
+ active,
1621
+ canonical: snapshot.canonical,
1622
+ activeOverrides: snapshot.activeOverrides,
1623
+ addedOverrides: input.addedOverrides,
1624
+ activeSurface: snapshot.surface,
1625
+ ...(surfaceChanged ? { surface: input.surface } : {}),
1626
+ allowCombinedSurfaceAndOverrides: true,
1627
+ });
1628
+ if (
1629
+ canonicalSequenceHash(
1630
+ snapshot.canonical,
1631
+ input.expectedCanonicalThroughOrdinal,
1632
+ ) !== input.canonicalSequenceSha256 ||
1633
+ renderedMessageHash(
1634
+ candidate.entries,
1635
+ input.expectedCanonicalThroughOrdinal,
1636
+ ) !== input.renderedMessageSha256
1637
+ ) {
1638
+ throw new Error("Agent Skills compiled context hashes are invalid.");
1639
+ }
1640
+
1641
+ options.faultInjector?.("before_surface_insert");
1642
+ if (surfaceChanged) {
1643
+ insertContextSurface(this.database, input.surface);
1644
+ }
1645
+ options.faultInjector?.("after_surface_insert");
1646
+ const revisionNumber = baseRevision.revisionNumber + 1;
1647
+ const activeOverrideCount =
1648
+ baseRevision.activeOverrideCount + input.addedOverrides.length;
1649
+ this.database
1650
+ .query(
1651
+ `INSERT INTO context_revisions (
1652
+ revision_id, session_id, revision_number, parent_revision_id, kind,
1653
+ surface_id, surface_sha256, keep_from_ordinal,
1654
+ source_through_ordinal, added_override_count, active_override_count,
1655
+ active_override_manifest_sha256, canonical_sequence_sha256,
1656
+ rendered_message_sha256, policy_version, renderer_format,
1657
+ plan_sha256, change_manifest_sha256, activation_manifest_sha256,
1658
+ retired_through_ordinal, retired_turn_count, retired_frame_count,
1659
+ retired_message_count, created_at
1660
+ ) VALUES (?, ?, ?, ?, 'skills_update', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, NULL, NULL, ?)`,
1661
+ )
1662
+ .run(
1663
+ input.revisionId,
1664
+ this.sessionId,
1665
+ revisionNumber,
1666
+ baseRevision.revisionId,
1667
+ input.surface.surfaceId,
1668
+ input.surface.surfaceSha256,
1669
+ baseRevision.keepFromOrdinal,
1670
+ input.expectedCanonicalThroughOrdinal,
1671
+ input.addedOverrides.length,
1672
+ activeOverrideCount,
1673
+ input.nextActiveOverrideManifestSha256,
1674
+ input.canonicalSequenceSha256,
1675
+ input.renderedMessageSha256,
1676
+ SKILL_POLICY_VERSION,
1677
+ SKILL_ACTIVATION_RECEIPT_FORMAT,
1678
+ input.changeManifestSha256,
1679
+ input.activationManifestSha256,
1680
+ now,
1681
+ );
1682
+ options.faultInjector?.("after_revision_insert");
1683
+
1684
+ for (let index = 0; index < input.addedOverrides.length; index += 1) {
1685
+ const override = requireItem(
1686
+ input.addedOverrides,
1687
+ index,
1688
+ "Agent Skill receipt override",
1689
+ );
1690
+ this.database
1691
+ .query(
1692
+ `INSERT INTO context_overrides (
1693
+ introduced_revision_id, session_id, message_id, frame_id, ordinal,
1694
+ representation, renderer_format, source, original_content_sha256,
1695
+ rendered_content, rendered_content_sha256, original_bytes,
1696
+ rendered_bytes, byte_savings, created_at
1697
+ ) VALUES (?, ?, ?, ?, ?, 'swapped', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1698
+ )
1699
+ .run(
1700
+ input.revisionId,
1701
+ this.sessionId,
1702
+ override.messageId,
1703
+ override.frameId,
1704
+ override.ordinal,
1705
+ SKILL_ACTIVATION_RECEIPT_FORMAT,
1706
+ override.source,
1707
+ override.originalContentSha256,
1708
+ override.renderedContent,
1709
+ override.renderedContentSha256,
1710
+ override.originalBytes,
1711
+ override.renderedBytes,
1712
+ override.byteSavings,
1713
+ now,
1714
+ );
1715
+ if (index === 0) {
1716
+ options.faultInjector?.("after_first_override_insert");
1717
+ }
1718
+ }
1719
+ options.faultInjector?.("after_overrides_insert");
1720
+
1721
+ for (const settlement of input.settlements) {
1722
+ const activation = unresolved.get(settlement.activationMessageId)!;
1723
+ const updated =
1724
+ settlement.state === "promoted"
1725
+ ? this.database
1726
+ .query(
1727
+ `UPDATE skill_activations
1728
+ SET state = 'promoted', settled_revision_id = ?, updated_at = ?
1729
+ WHERE activation_message_id = ? AND state = 'dispatched'`,
1730
+ )
1731
+ .run(input.revisionId, now, settlement.activationMessageId)
1732
+ : this.database
1733
+ .query(
1734
+ `UPDATE skill_activations
1735
+ SET state = 'rejected', settled_revision_id = ?, rejection_reason = ?, updated_at = ?
1736
+ WHERE activation_message_id = ? AND state = ?`,
1737
+ )
1738
+ .run(
1739
+ input.revisionId,
1740
+ settlement.rejectionReason!,
1741
+ now,
1742
+ settlement.activationMessageId,
1743
+ activation.state,
1744
+ );
1745
+ requireSingleChange(
1746
+ this.database,
1747
+ updated.changes,
1748
+ `settle Agent Skill activation ${settlement.activationMessageId}`,
1749
+ );
1750
+ }
1751
+ options.faultInjector?.("after_activations_update");
1752
+
1753
+ this.database.query("DELETE FROM context_measurement_state").run();
1754
+ options.faultInjector?.("after_measurement_delete");
1755
+ const switched = this.database
1756
+ .query(
1757
+ `UPDATE session_meta SET active_revision_id = ?, updated_at = ?
1758
+ WHERE singleton = 1 AND active_revision_id = ?`,
1759
+ )
1760
+ .run(input.revisionId, now, baseRevision.revisionId);
1761
+ requireSingleChange(
1762
+ this.database,
1763
+ switched.changes,
1764
+ "activate Agent Skills context revision",
1765
+ );
1766
+ options.faultInjector?.("after_active_update");
1767
+
1768
+ const readback = this.loadContextSnapshot();
1769
+ if (
1770
+ readback.revision.kind !== "skills_update" ||
1771
+ readback.revision.revisionId !== input.revisionId ||
1772
+ readback.surface.surfaceId !== input.surface.surfaceId ||
1773
+ this.loadMeasuredContextState() !== undefined ||
1774
+ this.loadSkillActivations(["pending", "dispatched"]).some((entry) =>
1775
+ input.settlements.some(
1776
+ (settlement) =>
1777
+ settlement.activationMessageId === entry.activationMessageId,
1778
+ ),
1779
+ )
1780
+ ) {
1781
+ throw new Error("Committed Agent Skills update readback failed.");
1782
+ }
1783
+ return readback.revision;
1784
+ });
1785
+ } catch (error) {
1786
+ if (requireActiveRevisionId(this.readMeta()) !== input.expectedBaseRevisionId) {
1787
+ throw new Error("Failed Agent Skills transaction changed active state.", {
1788
+ cause: error,
1789
+ });
1790
+ }
1791
+ throw sessionWriteError("commit_skills_update", this.sessionId, error);
1792
+ }
1793
+ }
1794
+
1795
+ private validateAddedOverrides(
1796
+ overrides: readonly SwapOverride[],
1797
+ canonical: ProtocolContextView,
1798
+ ): void {
1799
+ const messages = new Map(
1800
+ canonical.messages.map((message) => [message.messageId, message] as const),
1801
+ );
1802
+ const results = new Map(
1803
+ canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
1804
+ );
1805
+ for (const override of overrides) {
1806
+ const message = messages.get(override.messageId);
1807
+ const result = results.get(override.messageId);
1808
+ if (message?.role !== "tool" || result === undefined) {
1809
+ throw new Error("Added context override does not target a tool result.");
1810
+ }
1811
+ const expected = this.swapRenderer.render({ message, result });
1812
+ if (stableJsonStringify(expected) !== stableJsonStringify(override)) {
1813
+ throw new Error("Added context override is not deterministic.");
1814
+ }
1815
+ }
1816
+ }
1817
+
1818
+ loadSkillActivations(
1819
+ states?: readonly StoredSkillActivation["state"][],
1820
+ ): readonly StoredSkillActivation[] {
1821
+ this.requireOpen();
1822
+ const rows = this.database
1823
+ .query(
1824
+ `SELECT sa.*, m.ordinal AS activation_ordinal
1825
+ FROM skill_activations sa
1826
+ JOIN messages m ON m.message_id = sa.activation_message_id
1827
+ ORDER BY m.ordinal`,
1828
+ )
1829
+ .all()
1830
+ .map(decodeSkillActivation);
1831
+ const filtered =
1832
+ states === undefined ? rows : rows.filter((row) => states.includes(row.state));
1833
+ return Object.freeze(filtered);
1834
+ }
1835
+
1836
+ markSkillActivationsDispatched(input: {
1837
+ iterationId: IterationId;
1838
+ activationMessageIds: readonly MessageId[];
1839
+ }): readonly StoredSkillActivation[] {
1840
+ this.requireOpen();
1841
+ if (input.activationMessageIds.length === 0) {
1842
+ return Object.freeze([]);
1843
+ }
1844
+ if (
1845
+ new Set(input.activationMessageIds).size !== input.activationMessageIds.length
1846
+ ) {
1847
+ throw new Error("Agent Skill dispatch contains duplicate activation messages.");
1848
+ }
1849
+ const now = this.clock();
1850
+ try {
1851
+ return runTransaction(this.database, () => {
1852
+ const iteration = this.requireIterationRow(input.iterationId);
1853
+ if (iteration.outcome !== "open") {
1854
+ throw new Error(`Iteration ${input.iterationId} is not open for dispatch.`);
1855
+ }
1856
+ for (const messageId of input.activationMessageIds) {
1857
+ const updated = this.database
1858
+ .query(
1859
+ `UPDATE skill_activations
1860
+ SET state = 'dispatched', dispatched_iteration_id = ?, updated_at = ?
1861
+ WHERE activation_message_id = ? AND session_id = ? AND state = 'pending'`,
1862
+ )
1863
+ .run(input.iterationId, now, messageId, this.sessionId);
1864
+ requireSingleChange(
1865
+ this.database,
1866
+ updated.changes,
1867
+ `dispatch Agent Skill activation ${messageId}`,
1868
+ );
1869
+ }
1870
+ this.touch(now);
1871
+ const dispatched = this.loadSkillActivations(["dispatched"]).filter((row) =>
1872
+ input.activationMessageIds.includes(row.activationMessageId),
1873
+ );
1874
+ if (dispatched.length !== input.activationMessageIds.length) {
1875
+ throw new Error("Agent Skill dispatch readback failed.");
1876
+ }
1877
+ return Object.freeze(dispatched);
1878
+ });
1879
+ } catch (error) {
1880
+ throw sessionWriteError("dispatch_skill_activations", this.sessionId, error);
1881
+ }
1882
+ }
1883
+
1884
+ markResumed(): number {
1885
+ this.requireOpen();
1886
+ const now = this.clock();
1887
+ try {
1888
+ return runTransaction(this.database, () => {
1889
+ const meta = this.readMeta();
1890
+ const next = meta.openCount + 1;
1891
+ const updated = this.database
1892
+ .query(
1893
+ `UPDATE session_meta
1894
+ SET open_count = ?, last_opened_at = ?, updated_at = ?,
1895
+ last_closed_at = NULL, last_close_reason = NULL
1896
+ WHERE singleton = 1 AND open_count = ?`,
1897
+ )
1898
+ .run(next, now, now, meta.openCount);
1899
+ requireSingleChange(this.database, updated.changes, "increment open count");
1900
+ return next;
1901
+ });
1902
+ } catch (error) {
1903
+ throw sessionWriteError("mark_resumed", this.sessionId, error);
1904
+ }
1905
+ }
1906
+
1907
+ recoverInterruptedState(idFactory: RuntimeIdFactory): SessionRecoveryResult {
1908
+ this.requireOpen();
1909
+ const view = this.loadProtocolView();
1910
+ const openTurns = this.database
1911
+ .query("SELECT turn_id FROM turns WHERE status = 'open' ORDER BY turn_number")
1912
+ .all() as Array<{ turn_id: string }>;
1913
+ const openFrames = view.frames.filter((frame) => frame.state === "open");
921
1914
  if (openTurns.length === 0 && openFrames.length === 0) {
922
1915
  return {
923
1916
  syntheticCompletionCount: 0,
@@ -1091,6 +2084,7 @@ export class SessionStore implements SessionLedgerCommitter {
1091
2084
 
1092
2085
  loadProtocolView(): ProtocolContextView {
1093
2086
  this.requireOpen();
2087
+ const imageAttachments = loadMessageImageAttachments(this.database);
1094
2088
  const frames = this.database
1095
2089
  .query("SELECT * FROM protocol_frames ORDER BY first_ordinal")
1096
2090
  .all()
@@ -1098,7 +2092,16 @@ export class SessionStore implements SessionLedgerCommitter {
1098
2092
  const messages = this.database
1099
2093
  .query("SELECT * FROM messages ORDER BY ordinal")
1100
2094
  .all()
1101
- .map(decodeMessage);
2095
+ .map((row) => {
2096
+ const record = recordFromSql(row, "message");
2097
+ const messageId = stringFromSql(record.message_id, "message_id");
2098
+ const message = decodeMessage(row, imageAttachments.get(messageId));
2099
+ imageAttachments.delete(messageId);
2100
+ return message;
2101
+ });
2102
+ if (imageAttachments.size > 0) {
2103
+ throw new Error("Image attachment rows reference unknown messages.");
2104
+ }
1102
2105
  const toolResults = this.database
1103
2106
  .query(
1104
2107
  `SELECT tr.* FROM tool_results tr
@@ -1116,13 +2119,43 @@ export class SessionStore implements SessionLedgerCommitter {
1116
2119
  });
1117
2120
  }
1118
2121
 
1119
- loadContextSnapshot(): StoredContextSnapshotV5 {
2122
+ async verifyImageAssetFiles(): Promise<void> {
2123
+ this.requireOpen();
2124
+ const distinct = new Map<string, ImageAssetRef>();
2125
+ for (const message of this.loadProtocolView().messages) {
2126
+ if (message.role !== "user") {
2127
+ continue;
2128
+ }
2129
+ for (const attachment of message.attachments ?? []) {
2130
+ const previous = distinct.get(attachment.assetId);
2131
+ const asset = imageAssetRefFromAttachment(attachment);
2132
+ if (
2133
+ previous !== undefined &&
2134
+ stableJsonStringify(previous) !== stableJsonStringify(asset)
2135
+ ) {
2136
+ throw new Error(
2137
+ `Conflicting metadata for image asset ${attachment.assetId}.`,
2138
+ );
2139
+ }
2140
+ distinct.set(attachment.assetId, asset);
2141
+ }
2142
+ }
2143
+ if (distinct.size === 0) {
2144
+ return;
2145
+ }
2146
+ const store = await ImageAssetStore.open({ workspaceRoot: this.workspaceRoot });
2147
+ for (const asset of distinct.values()) {
2148
+ await store.verify(asset);
2149
+ }
2150
+ }
2151
+
2152
+ loadContextSnapshot(): StoredContextSnapshotV8 {
1120
2153
  this.requireOpen();
1121
2154
  const meta = this.readMeta();
1122
2155
  try {
1123
2156
  if (
1124
2157
  meta.sessionId !== this.sessionId ||
1125
- meta.schemaFingerprint !== SESSION_SCHEMA_V5_FINGERPRINT
2158
+ meta.schemaFingerprint !== SESSION_SCHEMA_V9_FINGERPRINT
1126
2159
  ) {
1127
2160
  throw new Error("Session metadata identity or schema fingerprint changed.");
1128
2161
  }
@@ -1168,7 +2201,7 @@ export class SessionStore implements SessionLedgerCommitter {
1168
2201
  }
1169
2202
  }
1170
2203
 
1171
- readMeta(): StoredSessionMetaV5 {
2204
+ readMeta(): StoredSessionMetaV9 {
1172
2205
  this.requireOpen();
1173
2206
  const rows = this.database.query("SELECT * FROM session_meta").all();
1174
2207
  if (rows.length !== 1) {
@@ -1182,7 +2215,7 @@ export class SessionStore implements SessionLedgerCommitter {
1182
2215
  return decodeMeta(rows[0], this.sessionId);
1183
2216
  }
1184
2217
 
1185
- readStoredSystemPrompt(): string {
2218
+ readCreationSystemPrompt(): string {
1186
2219
  this.requireOpen();
1187
2220
  try {
1188
2221
  const frames = this.database
@@ -1235,8 +2268,8 @@ export class SessionStore implements SessionLedgerCommitter {
1235
2268
  }
1236
2269
  throw new SessionError(
1237
2270
  "SESSION_RECOVERY_FAILED",
1238
- "read_stored_system_prompt",
1239
- "Stored system prompt is missing or invalid.",
2271
+ "read_creation_system_prompt",
2272
+ "Creation system prompt is missing or invalid.",
1240
2273
  { sessionId: this.sessionId, cause: error },
1241
2274
  );
1242
2275
  }
@@ -1246,6 +2279,56 @@ export class SessionStore implements SessionLedgerCommitter {
1246
2279
  return this.readMeta().projectInstruction;
1247
2280
  }
1248
2281
 
2282
+ validateCreatingState(): void {
2283
+ this.requireOpen();
2284
+ const meta = this.readMeta();
2285
+ if (
2286
+ meta.initializationState !== "creating" ||
2287
+ meta.activeRevisionId !== null ||
2288
+ meta.sessionCompatibilityJson !== null ||
2289
+ meta.sessionCompatibilitySha256 !== null
2290
+ ) {
2291
+ throw new SessionError(
2292
+ "SESSION_INTEGRITY_FAILED",
2293
+ "validate_creating_store",
2294
+ "Creating session metadata is invalid.",
2295
+ { sessionId: this.sessionId },
2296
+ );
2297
+ }
2298
+ this.readCreationSystemPrompt();
2299
+ const counts = this.database
2300
+ .query(
2301
+ `SELECT
2302
+ (SELECT COUNT(*) FROM context_surfaces) AS surfaces,
2303
+ (SELECT COUNT(*) FROM context_revisions) AS revisions,
2304
+ (SELECT COUNT(*) FROM turns) AS turns`,
2305
+ )
2306
+ .get() as Record<string, unknown> | null;
2307
+ if (
2308
+ counts === null ||
2309
+ numberFromSql(counts.surfaces, "surfaces") !== 0 ||
2310
+ numberFromSql(counts.revisions, "revisions") !== 0 ||
2311
+ numberFromSql(counts.turns, "turns") !== 0
2312
+ ) {
2313
+ throw new SessionError(
2314
+ "SESSION_INTEGRITY_FAILED",
2315
+ "validate_creating_store",
2316
+ "Creating session contains finalized or turn state.",
2317
+ { sessionId: this.sessionId },
2318
+ );
2319
+ }
2320
+ const view = this.loadProtocolView();
2321
+ this.validator.validate(view, { fullIntegrity: true });
2322
+ if (view.messages.length !== 1 || view.frames.length !== 1) {
2323
+ throw new SessionError(
2324
+ "SESSION_INTEGRITY_FAILED",
2325
+ "validate_creating_store",
2326
+ "Creating session must contain only its creation system frame.",
2327
+ { sessionId: this.sessionId },
2328
+ );
2329
+ }
2330
+ }
2331
+
1249
2332
  nextTurnNumber(): number {
1250
2333
  return this.readMeta().nextTurnNumber;
1251
2334
  }
@@ -1254,7 +2337,9 @@ export class SessionStore implements SessionLedgerCommitter {
1254
2337
  const meta = this.readMeta();
1255
2338
  if (
1256
2339
  meta.sessionId !== this.sessionId ||
1257
- meta.schemaFingerprint !== SESSION_SCHEMA_V5_FINGERPRINT
2340
+ meta.schemaFingerprint !== SESSION_SCHEMA_V9_FINGERPRINT ||
2341
+ meta.initializationState !== "ready" ||
2342
+ meta.activeRevisionId === null
1258
2343
  ) {
1259
2344
  throw new SessionError(
1260
2345
  "SESSION_SCHEMA_INVALID",
@@ -1263,15 +2348,16 @@ export class SessionStore implements SessionLedgerCommitter {
1263
2348
  { sessionId: this.sessionId },
1264
2349
  );
1265
2350
  }
1266
- this.readStoredSystemPrompt();
1267
- const view = this.loadProtocolView();
1268
2351
  try {
2352
+ this.readCreationSystemPrompt();
2353
+ const view = this.loadProtocolView();
1269
2354
  this.validator.validate(view, {
1270
2355
  allowOpenTail: options.allowOpenTail,
1271
2356
  fullIntegrity: true,
1272
2357
  });
1273
2358
  this.loadValidatedContextSnapshot(meta, view);
1274
2359
  this.validateCounters(meta, view);
2360
+ return view;
1275
2361
  } catch (error) {
1276
2362
  if (error instanceof SessionError) {
1277
2363
  throw error;
@@ -1297,7 +2383,121 @@ export class SessionStore implements SessionLedgerCommitter {
1297
2383
  { sessionId: this.sessionId, cause: error },
1298
2384
  );
1299
2385
  }
1300
- return view;
2386
+ }
2387
+
2388
+ async cloneTo(input: CloneSessionStoreInput): Promise<void> {
2389
+ this.requireOpen();
2390
+ if (input.targetSessionId === this.sessionId) {
2391
+ throw new Error("Session clone target must differ from the source session.");
2392
+ }
2393
+
2394
+ const sourceView = this.validateAll({ allowOpenTail: false });
2395
+ const sourceMeta = this.readMeta();
2396
+ const sessionsRoot = path.dirname(this.sessionDirectory);
2397
+ await validateSessionsRoot(sessionsRoot, this.sessionId);
2398
+ const targetDirectory = safeSessionDirectory(sessionsRoot, input.targetSessionId);
2399
+ await assertPathMissing(targetDirectory, input.targetSessionId);
2400
+
2401
+ const stagingDirectory = path.join(sessionsRoot, `.cloning-${randomUUID()}`);
2402
+ const stagingDatabasePath = path.join(stagingDirectory, "session.sqlite");
2403
+ let stagingDatabase: Database | undefined;
2404
+ let published = false;
2405
+
2406
+ try {
2407
+ await mkdir(stagingDirectory, { mode: 0o700 });
2408
+ await chmod(stagingDirectory, 0o700);
2409
+ input.faultInjector?.("after_staging_mkdir");
2410
+ this.database.query("VACUUM INTO ?").run(stagingDatabasePath);
2411
+ await chmod(stagingDatabasePath, 0o600);
2412
+ input.faultInjector?.("after_snapshot");
2413
+
2414
+ stagingDatabase = openWritableDatabase(stagingDatabasePath);
2415
+ verifySessionSchema(stagingDatabase, this.sessionId);
2416
+ dropSessionCloneTriggers(stagingDatabase);
2417
+ input.faultInjector?.("after_trigger_drop");
2418
+
2419
+ const targetCanonical = rekeyProtocolView(sourceView, input.targetSessionId);
2420
+ runTransaction(stagingDatabase, () => {
2421
+ stagingDatabase!.exec("PRAGMA defer_foreign_keys = ON");
2422
+ for (const table of SESSION_SCOPED_TABLES) {
2423
+ stagingDatabase!
2424
+ .query(`UPDATE ${table} SET session_id = ? WHERE session_id = ?`)
2425
+ .run(input.targetSessionId, this.sessionId);
2426
+ }
2427
+ rekeyStoredToolCalls(stagingDatabase!, input.targetSessionId);
2428
+ input.faultInjector?.("after_identity_update");
2429
+
2430
+ rewriteCloneRevisionHashes(stagingDatabase!, targetCanonical);
2431
+ input.faultInjector?.("after_revision_hash_rewrite");
2432
+
2433
+ if (stagingDatabase!.query("PRAGMA foreign_key_check").all().length !== 0) {
2434
+ throw new Error("Cloned session identity re-key broke foreign keys.");
2435
+ }
2436
+ });
2437
+
2438
+ reinstallSessionCloneTriggers(stagingDatabase);
2439
+ input.faultInjector?.("after_trigger_reinstall");
2440
+ verifySessionSchema(stagingDatabase, input.targetSessionId);
2441
+ verifySqliteIntegrity(stagingDatabase, input.targetSessionId);
2442
+ verifyRecallIndex(stagingDatabase, input.targetSessionId);
2443
+ input.faultInjector?.("after_recall_validation");
2444
+ const clonedStore = new SessionStore(stagingDatabase, this.lease, {
2445
+ sessionId: input.targetSessionId,
2446
+ workspaceRoot: this.workspaceRoot,
2447
+ sessionDirectory: stagingDirectory,
2448
+ databasePath: stagingDatabasePath,
2449
+ clock: this.clock,
2450
+ });
2451
+ clonedStore.validateAll({ allowOpenTail: false });
2452
+ await clonedStore.verifyImageAssetFiles();
2453
+
2454
+ await cloneDiagnosticFiles({
2455
+ sourceDirectory: this.sessionDirectory,
2456
+ stagingDirectory,
2457
+ sourceSessionId: this.sessionId,
2458
+ targetSessionId: input.targetSessionId,
2459
+ nextEventSequence: sourceMeta.nextEventSequence,
2460
+ faultInjector: input.faultInjector,
2461
+ });
2462
+
2463
+ stagingDatabase.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2464
+ const standaloneJournal = stagingDatabase
2465
+ .query("PRAGMA journal_mode = DELETE")
2466
+ .get() as Record<string, unknown> | null;
2467
+ if (String(standaloneJournal?.journal_mode).toLowerCase() !== "delete") {
2468
+ throw new Error("Cloned session database did not leave WAL mode.");
2469
+ }
2470
+ stagingDatabase.close();
2471
+ stagingDatabase = undefined;
2472
+ await unlinkIfExists(`${stagingDatabasePath}-wal`);
2473
+ await unlinkIfExists(`${stagingDatabasePath}-shm`);
2474
+ await validateSecureDirectory(stagingDirectory, input.targetSessionId);
2475
+ await validateSecureFile(stagingDatabasePath, input.targetSessionId);
2476
+ await validateSecureOptionalFile(
2477
+ path.join(stagingDirectory, "events.jsonl"),
2478
+ input.targetSessionId,
2479
+ );
2480
+ await validateSecureOptionalFile(
2481
+ path.join(stagingDirectory, "observations.md"),
2482
+ input.targetSessionId,
2483
+ );
2484
+ input.faultInjector?.("after_artifact_validation");
2485
+ await assertPathMissing(targetDirectory, input.targetSessionId);
2486
+ input.faultInjector?.("before_publish_rename");
2487
+ await rename(stagingDirectory, targetDirectory);
2488
+ published = true;
2489
+ } finally {
2490
+ if (stagingDatabase !== undefined) {
2491
+ try {
2492
+ stagingDatabase.close();
2493
+ } catch {
2494
+ // Preserve the clone failure.
2495
+ }
2496
+ }
2497
+ if (!published) {
2498
+ await removeKnownInitializationFiles(stagingDirectory).catch(() => undefined);
2499
+ }
2500
+ }
1301
2501
  }
1302
2502
 
1303
2503
  async close(reason: SessionCloseReason): Promise<void> {
@@ -1404,6 +2604,24 @@ export class SessionStore implements SessionLedgerCommitter {
1404
2604
  now: string,
1405
2605
  ): void {
1406
2606
  const meta = this.readMeta();
2607
+ if (mutation.admissionBase !== undefined) {
2608
+ const snapshot = this.loadContextSnapshot();
2609
+ const head = snapshot.canonical.messages.at(-1);
2610
+ const base = mutation.admissionBase;
2611
+ if (
2612
+ head === undefined ||
2613
+ base.canonicalMessageCount !== snapshot.canonical.messages.length ||
2614
+ base.canonicalHeadMessageId !== head.messageId ||
2615
+ base.canonicalHeadContentSha256 !== head.contentSha256 ||
2616
+ base.activeRevisionId !== snapshot.revision.revisionId ||
2617
+ base.activeRevisionNumber !== snapshot.revision.revisionNumber ||
2618
+ base.surfaceSha256 !== snapshot.surface.surfaceSha256 ||
2619
+ base.sessionCompatibilitySha256 !== meta.sessionCompatibilitySha256 ||
2620
+ base.nextTurnNumber !== meta.nextTurnNumber
2621
+ ) {
2622
+ throw new AdmissionStaleError();
2623
+ }
2624
+ }
1407
2625
  if (
1408
2626
  meta.initializationState !== "ready" ||
1409
2627
  meta.nextTurnNumber !== mutation.turn.turnNumber
@@ -1477,14 +2695,11 @@ export class SessionStore implements SessionLedgerCommitter {
1477
2695
  throw new Error(`Frame ${mutation.frameBefore.frameId} is not open.`);
1478
2696
  }
1479
2697
  for (let index = 0; index < mutation.messages.length; index += 1) {
1480
- insertMessage(
1481
- this.database,
1482
- requireItem(mutation.messages, index, "tool message"),
1483
- );
1484
- insertToolResult(
1485
- this.database,
1486
- requireItem(mutation.toolResults, index, "tool result"),
1487
- );
2698
+ const message = requireItem(mutation.messages, index, "tool message");
2699
+ const result = requireItem(mutation.toolResults, index, "tool result");
2700
+ insertMessage(this.database, message);
2701
+ insertToolResult(this.database, result);
2702
+ insertPendingSkillActivation(this.database, message, result, now);
1488
2703
  }
1489
2704
  if (mutation.frameAfter.state === "closed") {
1490
2705
  const updated = this.database
@@ -1594,9 +2809,23 @@ export class SessionStore implements SessionLedgerCommitter {
1594
2809
  }
1595
2810
 
1596
2811
  private loadValidatedContextSnapshot(
1597
- meta: StoredSessionMetaV5,
2812
+ meta: StoredSessionMetaV9,
1598
2813
  canonical: ProtocolContextView,
1599
- ): StoredContextSnapshotV5 {
2814
+ ): StoredContextSnapshotV8 {
2815
+ const activeRevisionId = requireActiveRevisionId(meta);
2816
+ const surfaces = this.database
2817
+ .query("SELECT * FROM context_surfaces ORDER BY rowid")
2818
+ .all()
2819
+ .map(decodeContextSurface);
2820
+ const surfacesById = new Map<ContextSurfaceId, StoredContextSurfaceV8>();
2821
+ for (const surface of surfaces) {
2822
+ validateStoredContextSurface(surface);
2823
+ if (surface.sessionId !== this.sessionId || surfacesById.has(surface.surfaceId)) {
2824
+ throw new Error("Context surface identity is invalid or duplicated.");
2825
+ }
2826
+ surfacesById.set(surface.surfaceId, surface);
2827
+ }
2828
+
1600
2829
  const revisions = this.database
1601
2830
  .query("SELECT * FROM context_revisions ORDER BY revision_number")
1602
2831
  .all()
@@ -1609,13 +2838,28 @@ export class SessionStore implements SessionLedgerCommitter {
1609
2838
  for (let index = 0; index < revisions.length; index += 1) {
1610
2839
  const revision = requireItem(revisions, index, "context revision");
1611
2840
  const previous = revisions[index - 1];
2841
+ const surface = surfacesById.get(revision.surfaceId);
1612
2842
  if (
1613
2843
  revision.sessionId !== this.sessionId ||
1614
2844
  revision.revisionNumber !== index + 1 ||
2845
+ surface === undefined ||
2846
+ surface.surfaceSha256 !== revision.surfaceSha256 ||
1615
2847
  (index === 0
1616
2848
  ? revision.kind !== "initial_full" || revision.parentRevisionId !== null
1617
- : revision.kind !== "swap_only" ||
1618
- revision.parentRevisionId !== previous?.revisionId)
2849
+ : revision.kind === "initial_full" ||
2850
+ revision.parentRevisionId !== previous?.revisionId) ||
2851
+ ((revision.kind === "swap_only" || revision.kind === "prefix_retirement") &&
2852
+ revision.surfaceId !== previous?.surfaceId) ||
2853
+ (revision.kind === "surface_refresh" &&
2854
+ revision.surfaceId === previous?.surfaceId) ||
2855
+ (previous !== undefined &&
2856
+ (revision.keepFromOrdinal < previous.keepFromOrdinal ||
2857
+ ((revision.kind === "swap_only" ||
2858
+ revision.kind === "surface_refresh" ||
2859
+ revision.kind === "skills_update") &&
2860
+ revision.keepFromOrdinal !== previous.keepFromOrdinal) ||
2861
+ (revision.kind === "prefix_retirement" &&
2862
+ revision.keepFromOrdinal <= previous.keepFromOrdinal)))
1619
2863
  ) {
1620
2864
  throw new Error("Context revision chain is not linear and contiguous.");
1621
2865
  }
@@ -1632,15 +2876,36 @@ export class SessionStore implements SessionLedgerCommitter {
1632
2876
  }
1633
2877
  revisionNumberById.set(revision.revisionId, revision.revisionNumber);
1634
2878
  }
2879
+ const introducedSurfaceIds = new Set(
2880
+ revisions.flatMap((revision, index) => {
2881
+ const previous = revisions[index - 1];
2882
+ return revision.kind === "initial_full" ||
2883
+ revision.kind === "surface_refresh" ||
2884
+ (revision.kind === "skills_update" &&
2885
+ revision.surfaceId !== previous?.surfaceId)
2886
+ ? [revision.surfaceId]
2887
+ : [];
2888
+ }),
2889
+ );
2890
+ if (
2891
+ introducedSurfaceIds.size !== surfaces.length ||
2892
+ surfaces.some((surface) => !introducedSurfaceIds.has(surface.surfaceId))
2893
+ ) {
2894
+ throw new Error("Context surface chain contains an orphan or duplicate surface.");
2895
+ }
1635
2896
 
1636
2897
  const activeRevision = requireItem(
1637
2898
  revisions,
1638
2899
  revisions.length - 1,
1639
2900
  "active context revision",
1640
2901
  );
1641
- if (activeRevision.revisionId !== meta.activeRevisionId) {
2902
+ if (activeRevision.revisionId !== activeRevisionId) {
1642
2903
  throw new Error("Active context revision is not the latest committed revision.");
1643
2904
  }
2905
+ const activeSurface = surfacesById.get(activeRevision.surfaceId);
2906
+ if (activeSurface === undefined) {
2907
+ throw new Error("Active context revision surface is missing.");
2908
+ }
1644
2909
 
1645
2910
  const overrides = this.database
1646
2911
  .query(
@@ -1652,27 +2917,117 @@ export class SessionStore implements SessionLedgerCommitter {
1652
2917
  .all()
1653
2918
  .map(decodeStoredSwapOverride);
1654
2919
  this.validateStoredOverrides(overrides, canonical, revisions, revisionNumberById);
2920
+ this.validateSkillActivationRows(canonical, revisions, overrides, surfaces);
1655
2921
 
1656
- let cumulativeCount = 0;
1657
2922
  for (const revision of revisions) {
2923
+ const surface = surfacesById.get(revision.surfaceId);
2924
+ if (surface === undefined) {
2925
+ throw new Error(`Context revision ${revision.revisionId} has no surface.`);
2926
+ }
1658
2927
  const activeOverrides = overrides.filter(
1659
2928
  (override) =>
1660
2929
  (revisionNumberById.get(override.introducedRevisionId) ??
1661
- Number.POSITIVE_INFINITY) <= revision.revisionNumber,
2930
+ Number.POSITIVE_INFINITY) <= revision.revisionNumber &&
2931
+ override.ordinal >= revision.keepFromOrdinal,
1662
2932
  );
1663
2933
  const introducedCount = overrides.filter(
1664
2934
  (override) => override.introducedRevisionId === revision.revisionId,
1665
2935
  ).length;
1666
- cumulativeCount += introducedCount;
1667
2936
  if (
1668
2937
  introducedCount !== revision.addedOverrideCount ||
1669
- cumulativeCount !== revision.totalOverrideCount ||
1670
- activeOverrideManifestHash(activeOverrides) !== revision.overrideManifestSha256
2938
+ activeOverrides.length !== revision.activeOverrideCount ||
2939
+ activeOverrideManifestHash(activeOverrides) !==
2940
+ revision.activeOverrideManifestSha256
1671
2941
  ) {
1672
2942
  throw new Error(
1673
2943
  `Context revision ${revision.revisionId} override manifest is invalid.`,
1674
2944
  );
1675
2945
  }
2946
+ if (
2947
+ revision.kind === "surface_refresh" &&
2948
+ previousRevision(revisions, revision)?.activeOverrideManifestSha256 !==
2949
+ revision.activeOverrideManifestSha256
2950
+ ) {
2951
+ throw new Error(
2952
+ `Context surface revision ${revision.revisionId} changed overrides.`,
2953
+ );
2954
+ }
2955
+ if (revision.kind === "skills_update") {
2956
+ const parent = previousRevision(revisions, revision);
2957
+ const parentSurface =
2958
+ parent === undefined ? undefined : surfacesById.get(parent.surfaceId);
2959
+ const settlements = this.loadSkillActivations().filter(
2960
+ (activation) => activation.settledRevisionId === revision.revisionId,
2961
+ );
2962
+ if (
2963
+ parentSurface === undefined ||
2964
+ contextSurfaceChangeManifestHash(
2965
+ contextSurfaceChanges(parentSurface, surface),
2966
+ ) !== revision.changeManifestSha256 ||
2967
+ settlements.length !== revision.addedOverrideCount ||
2968
+ skillActivationManifestSha256(
2969
+ settlements.map((activation) => ({
2970
+ activationMessageId: activation.activationMessageId,
2971
+ name: activation.name,
2972
+ state: activation.state === "promoted" ? "promoted" : "rejected",
2973
+ ...(activation.rejectionReason === undefined
2974
+ ? {}
2975
+ : { rejectionReason: activation.rejectionReason }),
2976
+ })),
2977
+ ) !== revision.activationManifestSha256
2978
+ ) {
2979
+ throw new Error(
2980
+ `Agent Skills revision ${revision.revisionId} manifest is invalid.`,
2981
+ );
2982
+ }
2983
+ }
2984
+ if (revision.kind === "prefix_retirement") {
2985
+ const parent = previousRevision(revisions, revision);
2986
+ if (parent === undefined) {
2987
+ throw new Error("Prefix retirement revision has no parent.");
2988
+ }
2989
+ const retiredStart = Math.max(parent.keepFromOrdinal, 2);
2990
+ const retiredMessages = canonical.messages.filter(
2991
+ (message) =>
2992
+ message.ordinal >= retiredStart &&
2993
+ message.ordinal < revision.keepFromOrdinal,
2994
+ );
2995
+ const retiredFrames = canonical.frames.filter(
2996
+ (frame) =>
2997
+ frame.firstOrdinal >= retiredStart &&
2998
+ (frame.lastOrdinal ?? Number.POSITIVE_INFINITY) < revision.keepFromOrdinal,
2999
+ );
3000
+ const retiredTurns = new Set(
3001
+ retiredMessages.flatMap((message) =>
3002
+ message.role === "system" ? [] : [message.turnId],
3003
+ ),
3004
+ );
3005
+ if (
3006
+ revision.retiredThroughOrdinal !== revision.keepFromOrdinal - 1 ||
3007
+ revision.retiredMessageCount !== retiredMessages.length ||
3008
+ revision.retiredFrameCount !== retiredFrames.length ||
3009
+ revision.retiredTurnCount !== retiredTurns.size
3010
+ ) {
3011
+ throw new Error(
3012
+ `Context retirement revision ${revision.revisionId} counts are invalid.`,
3013
+ );
3014
+ }
3015
+ }
3016
+ if (revision.kind === "surface_refresh") {
3017
+ const parent = previousRevision(revisions, revision);
3018
+ const parentSurface =
3019
+ parent === undefined ? undefined : surfacesById.get(parent.surfaceId);
3020
+ if (
3021
+ parentSurface === undefined ||
3022
+ contextSurfaceChangeManifestHash(
3023
+ contextSurfaceChanges(parentSurface, surface),
3024
+ ) !== revision.changeManifestSha256
3025
+ ) {
3026
+ throw new Error(
3027
+ `Context surface revision ${revision.revisionId} change manifest is invalid.`,
3028
+ );
3029
+ }
3030
+ }
1676
3031
  const prefix = protocolPrefixView(canonical, revision.sourceThroughOrdinal);
1677
3032
  this.revisionCompiler.compileActive({
1678
3033
  meta: Object.freeze({
@@ -1680,6 +3035,7 @@ export class SessionStore implements SessionLedgerCommitter {
1680
3035
  activeRevisionId: revision.revisionId,
1681
3036
  }),
1682
3037
  revision,
3038
+ surface,
1683
3039
  activeOverrides,
1684
3040
  canonical: prefix,
1685
3041
  });
@@ -1696,18 +3052,26 @@ export class SessionStore implements SessionLedgerCommitter {
1696
3052
  return Object.freeze({
1697
3053
  meta: Object.freeze({
1698
3054
  sessionId: meta.sessionId,
1699
- activeRevisionId: meta.activeRevisionId,
3055
+ activeRevisionId,
1700
3056
  }),
1701
3057
  revision: activeRevision,
1702
- activeOverrides: Object.freeze(overrides),
3058
+ surface: activeSurface,
3059
+ activeOverrides: Object.freeze(
3060
+ overrides.filter(
3061
+ (override) =>
3062
+ (revisionNumberById.get(override.introducedRevisionId) ??
3063
+ Number.POSITIVE_INFINITY) <= activeRevision.revisionNumber &&
3064
+ override.ordinal >= activeRevision.keepFromOrdinal,
3065
+ ),
3066
+ ),
1703
3067
  canonical,
1704
3068
  });
1705
3069
  }
1706
3070
 
1707
3071
  private validateStoredOverrides(
1708
- overrides: readonly StoredSwapOverrideV5[],
3072
+ overrides: readonly StoredContextOverrideV8[],
1709
3073
  canonical: ProtocolContextView,
1710
- revisions: readonly StoredContextRevisionV5[],
3074
+ revisions: readonly StoredContextRevisionV8[],
1711
3075
  revisionNumberById: ReadonlyMap<ContextRevisionId, number>,
1712
3076
  ): void {
1713
3077
  const messages = new Map(
@@ -1729,8 +3093,13 @@ export class SessionStore implements SessionLedgerCommitter {
1729
3093
  const frame = frames.get(override.frameId);
1730
3094
  const result = results.get(override.messageId);
1731
3095
  if (
1732
- revision?.kind !== "swap_only" ||
3096
+ (revision?.kind !== "swap_only" && revision?.kind !== "skills_update") ||
3097
+ (revision.kind === "swap_only" &&
3098
+ override.rendererFormat !== SWAP_OBSERVATION_FORMAT) ||
3099
+ (revision.kind === "skills_update" &&
3100
+ override.rendererFormat !== SKILL_ACTIVATION_RECEIPT_FORMAT) ||
1733
3101
  revisionNumberById.get(revision.revisionId) === undefined ||
3102
+ override.ordinal < revision.keepFromOrdinal ||
1734
3103
  override.ordinal > revision.sourceThroughOrdinal ||
1735
3104
  seenMessages.has(override.messageId) ||
1736
3105
  message?.role !== "tool" ||
@@ -1742,17 +3111,137 @@ export class SessionStore implements SessionLedgerCommitter {
1742
3111
  ) {
1743
3112
  throw new Error("Stored context override canonical identity is invalid.");
1744
3113
  }
1745
- const rendered = this.swapRenderer.render({ message, result });
3114
+ const rendered =
3115
+ override.rendererFormat === SWAP_OBSERVATION_FORMAT
3116
+ ? this.swapRenderer.render({ message, result })
3117
+ : this.renderStoredSkillReceipt(message, result, revision.revisionId);
1746
3118
  if (
1747
3119
  stableJsonStringify(rendered) !==
1748
3120
  stableJsonStringify(stripStoredOverride(override))
1749
3121
  ) {
1750
- throw new Error(
1751
- `Stored context override ${override.messageId} does not match deterministic rendering.`,
1752
- );
3122
+ throw new Error(
3123
+ `Stored context override ${override.messageId} does not match deterministic rendering.`,
3124
+ );
3125
+ }
3126
+ seenMessages.add(override.messageId);
3127
+ }
3128
+ }
3129
+
3130
+ private validateSkillActivationRows(
3131
+ canonical: ProtocolContextView,
3132
+ revisions: readonly StoredContextRevisionV8[],
3133
+ overrides: readonly StoredContextOverrideV8[],
3134
+ surfaces: readonly StoredContextSurfaceV8[],
3135
+ ): void {
3136
+ const activations = this.loadSkillActivations();
3137
+ const activationByMessage = new Map(
3138
+ activations.map((activation) => [activation.activationMessageId, activation]),
3139
+ );
3140
+ const messages = new Map(
3141
+ canonical.messages.map((message) => [message.messageId, message]),
3142
+ );
3143
+ const results = new Map(
3144
+ canonical.toolResults.map((result) => [result.toolMessageId, result]),
3145
+ );
3146
+ const loadedResults = canonical.toolResults.filter(
3147
+ (result) =>
3148
+ result.completion.kind === "returned" &&
3149
+ result.completion.raw.kind === "skill" &&
3150
+ result.completion.raw.ok &&
3151
+ result.completion.raw.status === "loaded",
3152
+ );
3153
+ if (loadedResults.length !== activations.length) {
3154
+ throw new Error("Loaded Agent Skill results and activation rows differ.");
3155
+ }
3156
+ const revisionsById = new Map(
3157
+ revisions.map((revision) => [revision.revisionId, revision]),
3158
+ );
3159
+ const overridesByMessage = new Map(
3160
+ overrides.map((override) => [override.messageId, override]),
3161
+ );
3162
+ for (const activation of activations) {
3163
+ const message = messages.get(activation.activationMessageId);
3164
+ const result = results.get(activation.activationMessageId);
3165
+ const raw =
3166
+ result?.completion.kind === "returned" ? result.completion.raw : undefined;
3167
+ if (
3168
+ activation.sessionId !== this.sessionId ||
3169
+ message?.role !== "tool" ||
3170
+ message.name !== "Skill" ||
3171
+ message.toolCallId !== activation.toolCallId ||
3172
+ raw?.kind !== "skill" ||
3173
+ !raw.ok ||
3174
+ raw.status !== "loaded" ||
3175
+ raw.name !== activation.name ||
3176
+ raw.scope !== activation.scope ||
3177
+ raw.sha256 !== activation.skillFileSha256
3178
+ ) {
3179
+ throw new Error("Agent Skill activation canonical identity is invalid.");
1753
3180
  }
1754
- seenMessages.add(override.messageId);
3181
+ if (activation.settledRevisionId !== undefined) {
3182
+ const revision = revisionsById.get(activation.settledRevisionId);
3183
+ const override = overridesByMessage.get(activation.activationMessageId);
3184
+ if (
3185
+ revision?.kind !== "skills_update" ||
3186
+ override?.introducedRevisionId !== revision.revisionId ||
3187
+ override.rendererFormat !== SKILL_ACTIVATION_RECEIPT_FORMAT
3188
+ ) {
3189
+ throw new Error("Settled Agent Skill activation receipt is invalid.");
3190
+ }
3191
+ }
3192
+ }
3193
+ for (const surface of surfaces) {
3194
+ for (const active of surface.activeSkills) {
3195
+ const activation = activationByMessage.get(active.activationMessageId);
3196
+ if (activation?.state !== "promoted" || activation.name !== active.name) {
3197
+ throw new Error(
3198
+ "Context surface references an invalid Agent Skill activation.",
3199
+ );
3200
+ }
3201
+ }
3202
+ }
3203
+ }
3204
+
3205
+ private renderStoredSkillReceipt(
3206
+ message: Extract<CanonicalMessageRecord, { role: "tool" }>,
3207
+ result: ToolResultRecord,
3208
+ revisionId: ContextRevisionId,
3209
+ ): SwapOverride {
3210
+ if (
3211
+ message.name !== "Skill" ||
3212
+ result.completion.kind !== "returned" ||
3213
+ result.completion.raw.kind !== "skill" ||
3214
+ !result.completion.raw.ok ||
3215
+ result.completion.raw.status !== "loaded"
3216
+ ) {
3217
+ throw new Error("Agent Skill receipt does not target a loaded Skill result.");
3218
+ }
3219
+ const activation = this.loadSkillActivations().find(
3220
+ (entry) => entry.activationMessageId === message.messageId,
3221
+ );
3222
+ if (
3223
+ activation === undefined ||
3224
+ activation.settledRevisionId !== revisionId ||
3225
+ (activation.state !== "promoted" && activation.state !== "rejected")
3226
+ ) {
3227
+ throw new Error("Agent Skill receipt has no matching settled activation.");
1755
3228
  }
3229
+ return renderSkillActivationReceipt({
3230
+ message: {
3231
+ messageId: message.messageId,
3232
+ frameId: message.frameId,
3233
+ ordinal: message.ordinal,
3234
+ content: message.content,
3235
+ contentSha256: message.contentSha256,
3236
+ },
3237
+ name: activation.name,
3238
+ outcome:
3239
+ activation.state === "promoted"
3240
+ ? "promoted"
3241
+ : activation.rejectionReason === "unavailable"
3242
+ ? "unavailable"
3243
+ : "rejected",
3244
+ });
1756
3245
  }
1757
3246
 
1758
3247
  private loadMeasuredContextState(): StoredMeasuredContextState | undefined {
@@ -1768,7 +3257,7 @@ export class SessionStore implements SessionLedgerCommitter {
1768
3257
  : decodeMeasuredContextState(row, this.sessionId);
1769
3258
  }
1770
3259
 
1771
- private validateCounters(meta: StoredSessionMetaV5, view: ProtocolContextView): void {
3260
+ private validateCounters(meta: StoredSessionMetaV9, view: ProtocolContextView): void {
1772
3261
  const turns = this.database
1773
3262
  .query("SELECT * FROM turns ORDER BY turn_number")
1774
3263
  .all() as Array<Record<string, unknown>>;
@@ -1939,30 +3428,168 @@ export class SessionStore implements SessionLedgerCommitter {
1939
3428
  }
1940
3429
  }
1941
3430
 
1942
- export function createRuntimeContract(input: {
3431
+ function insertPendingSkillActivation(
3432
+ database: Database,
3433
+ message: CanonicalMessageRecord,
3434
+ result: ToolResultRecord,
3435
+ now: string,
3436
+ ): void {
3437
+ if (
3438
+ message.role !== "tool" ||
3439
+ result.completion.kind !== "returned" ||
3440
+ result.completion.raw.kind !== "skill" ||
3441
+ !result.completion.raw.ok ||
3442
+ result.completion.raw.status !== "loaded"
3443
+ ) {
3444
+ return;
3445
+ }
3446
+ const raw = result.completion.raw;
3447
+ if (message.name !== "Skill" || message.messageId !== result.toolMessageId) {
3448
+ throw new Error("Loaded Agent Skill completion has invalid tool identity.");
3449
+ }
3450
+ database
3451
+ .query(
3452
+ `INSERT INTO skill_activations (
3453
+ activation_message_id, tool_call_id, session_id, name, scope,
3454
+ skill_file_sha256, state, dispatched_iteration_id, settled_revision_id,
3455
+ rejection_reason, created_at, updated_at
3456
+ ) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, NULL, ?, ?)`,
3457
+ )
3458
+ .run(
3459
+ message.messageId,
3460
+ result.toolCallId,
3461
+ result.sessionId,
3462
+ raw.name,
3463
+ raw.scope,
3464
+ raw.sha256,
3465
+ now,
3466
+ now,
3467
+ );
3468
+ }
3469
+
3470
+ export function createSessionCompatibilityContract(input: {
1943
3471
  modelName: string;
1944
3472
  profileName?: string;
1945
3473
  includeReasoningContent: boolean;
1946
3474
  contextProfile: ModelContextProfile;
1947
- contextBudget: ModelContextBudget;
1948
- systemPrompt: string;
1949
- toolSchemaSha256: string;
1950
- requestConfigSha256: string;
1951
- }): RuntimeContractV1 {
3475
+ messageProtocol: ModelMessageProtocol;
3476
+ inputModalities?: readonly ("text" | "image")[];
3477
+ tokenEstimator?: InputTokenEstimatorCompatibility;
3478
+ }): SessionCompatibilityContract {
3479
+ if (input.modelName.trim() === "") {
3480
+ throw new Error("Session compatibility model name must not be empty.");
3481
+ }
3482
+ if (input.profileName !== undefined && input.profileName.trim() === "") {
3483
+ throw new Error("Session compatibility profile name must not be empty.");
3484
+ }
3485
+ if (typeof input.includeReasoningContent !== "boolean") {
3486
+ throw new Error("Session compatibility reasoning replay flag must be boolean.");
3487
+ }
3488
+ if (
3489
+ !["openai-chat", "fake"].includes(input.messageProtocol.adapter) ||
3490
+ input.messageProtocol.serializationVersion.trim() === ""
3491
+ ) {
3492
+ throw new Error("Session compatibility message protocol is invalid.");
3493
+ }
3494
+ const inputModalities = normalizeInputModalities(input.inputModalities ?? ["text"]);
3495
+ if (inputModalities.includes("image") && input.tokenEstimator === undefined) {
3496
+ throw new Error(
3497
+ "Session compatibility image input requires a token estimator identity.",
3498
+ );
3499
+ }
3500
+ if (input.tokenEstimator !== undefined) {
3501
+ validateTokenEstimatorCompatibility(input.tokenEstimator);
3502
+ }
1952
3503
  return Object.freeze({
1953
- version: 1,
1954
3504
  modelName: input.modelName,
1955
3505
  ...(input.profileName === undefined ? {} : { profileName: input.profileName }),
1956
3506
  includeReasoningContent: input.includeReasoningContent,
1957
- contextProfile: immutableCanonicalClone(input.contextProfile),
1958
- contextBudget: immutableCanonicalClone(input.contextBudget),
1959
- systemPromptSha256: sha256(input.systemPrompt),
1960
- toolSchemaSha256: input.toolSchemaSha256,
1961
- requestConfigSha256: input.requestConfigSha256,
1962
- observationFormat: TOOL_OBSERVATION_FORMAT,
3507
+ contextProfile: Object.freeze(createModelContextProfile(input.contextProfile)),
3508
+ messageProtocol: immutableCanonicalClone(input.messageProtocol),
3509
+ imageInput: Object.freeze({
3510
+ policyVersion: IMAGE_INPUT_POLICY_VERSION,
3511
+ policySha256: sha256(
3512
+ stableJsonStringify({
3513
+ version: IMAGE_INPUT_POLICY_VERSION,
3514
+ ...IMAGE_INPUT_POLICY,
3515
+ }),
3516
+ ),
3517
+ inputModalities,
3518
+ ...(input.tokenEstimator === undefined
3519
+ ? {}
3520
+ : { tokenEstimator: immutableCanonicalClone(input.tokenEstimator) }),
3521
+ }),
3522
+ });
3523
+ }
3524
+
3525
+ function normalizeSessionCompatibilityContract(
3526
+ contract: SessionCompatibilityContract,
3527
+ ): SessionCompatibilityContract {
3528
+ return createSessionCompatibilityContract({
3529
+ modelName: contract.modelName,
3530
+ ...(contract.profileName === undefined
3531
+ ? {}
3532
+ : { profileName: contract.profileName }),
3533
+ includeReasoningContent: contract.includeReasoningContent,
3534
+ contextProfile: contract.contextProfile,
3535
+ messageProtocol: contract.messageProtocol,
3536
+ inputModalities: contract.imageInput.inputModalities,
3537
+ ...(contract.imageInput.tokenEstimator === undefined
3538
+ ? {}
3539
+ : { tokenEstimator: contract.imageInput.tokenEstimator }),
1963
3540
  });
1964
3541
  }
1965
3542
 
3543
+ function normalizeInputModalities(
3544
+ modalities: readonly ("text" | "image")[],
3545
+ ): readonly ("text" | "image")[] {
3546
+ if (
3547
+ modalities.length === 0 ||
3548
+ modalities.some((value) => value !== "text" && value !== "image") ||
3549
+ new Set(modalities).size !== modalities.length ||
3550
+ !modalities.includes("text")
3551
+ ) {
3552
+ throw new Error("Session compatibility input modalities are invalid.");
3553
+ }
3554
+ return Object.freeze(
3555
+ modalities.includes("image") ? (["text", "image"] as const) : (["text"] as const),
3556
+ );
3557
+ }
3558
+
3559
+ function validateTokenEstimatorCompatibility(
3560
+ estimator: InputTokenEstimatorCompatibility,
3561
+ ): void {
3562
+ if (
3563
+ estimator.kind !== "moonshot-estimate-token-count-v1" ||
3564
+ estimator.coverageVersion !== "full-request-v1" ||
3565
+ estimator.model.trim() === "" ||
3566
+ !isNormalizedEstimatorEndpoint(estimator.endpoint) ||
3567
+ !Number.isSafeInteger(estimator.timeoutMs) ||
3568
+ estimator.timeoutMs < 1_000 ||
3569
+ estimator.timeoutMs > 60_000 ||
3570
+ estimator.maxRetries !== 0
3571
+ ) {
3572
+ throw new Error("Session compatibility token estimator identity is invalid.");
3573
+ }
3574
+ }
3575
+
3576
+ function isNormalizedEstimatorEndpoint(value: string): boolean {
3577
+ try {
3578
+ const url = new URL(value);
3579
+ return (
3580
+ (url.protocol === "https:" || url.protocol === "http:") &&
3581
+ url.username === "" &&
3582
+ url.password === "" &&
3583
+ url.search === "" &&
3584
+ url.hash === "" &&
3585
+ url.pathname.endsWith("/tokenizers/estimate-token-count") &&
3586
+ url.toString() === value
3587
+ );
3588
+ } catch {
3589
+ return false;
3590
+ }
3591
+ }
3592
+
1966
3593
  export function sessionDatabasePath(
1967
3594
  workspaceRoot: string,
1968
3595
  sessionId: SessionId,
@@ -2031,6 +3658,83 @@ function insertMessage(database: Database, message: CanonicalMessageRecord): voi
2031
3658
  message.origin,
2032
3659
  message.createdAt,
2033
3660
  );
3661
+ if (message.role === "user" && message.attachments !== undefined) {
3662
+ insertMessageImageAttachments(database, message);
3663
+ }
3664
+ }
3665
+
3666
+ function insertMessageImageAttachments(
3667
+ database: Database,
3668
+ message: Extract<CanonicalMessageRecord, { role: "user" }>,
3669
+ ): void {
3670
+ const userMessage = {
3671
+ role: "user" as const,
3672
+ content: message.content,
3673
+ attachments: message.attachments,
3674
+ };
3675
+ validateUserMessage(userMessage);
3676
+ if (userMessageHash(userMessage) !== message.contentSha256) {
3677
+ throw new Error("User image attachment hash does not match the message hash.");
3678
+ }
3679
+ for (let position = 0; position < message.attachments!.length; position += 1) {
3680
+ const attachment = requireItem(message.attachments!, position, "image attachment");
3681
+ const existing = database
3682
+ .query(
3683
+ `SELECT mime_type, byte_length, width, height, created_at
3684
+ FROM image_assets WHERE asset_id = ?`,
3685
+ )
3686
+ .get(attachment.assetId) as {
3687
+ mime_type: unknown;
3688
+ byte_length: unknown;
3689
+ width: unknown;
3690
+ height: unknown;
3691
+ created_at: unknown;
3692
+ } | null;
3693
+ if (existing === null) {
3694
+ database
3695
+ .query(
3696
+ `INSERT INTO image_assets (
3697
+ asset_id, mime_type, byte_length, width, height, created_at
3698
+ ) VALUES (?, ?, ?, ?, ?, ?)`,
3699
+ )
3700
+ .run(
3701
+ attachment.assetId,
3702
+ attachment.mimeType,
3703
+ attachment.byteLength,
3704
+ attachment.width,
3705
+ attachment.height,
3706
+ message.createdAt,
3707
+ );
3708
+ } else {
3709
+ timestampFromSql(existing.created_at, "image asset created_at");
3710
+ if (
3711
+ existing.mime_type !== attachment.mimeType ||
3712
+ numberFromSql(existing.byte_length, "image asset byte_length") !==
3713
+ attachment.byteLength ||
3714
+ numberFromSql(existing.width, "image asset width") !== attachment.width ||
3715
+ numberFromSql(existing.height, "image asset height") !== attachment.height
3716
+ ) {
3717
+ throw new Error(`Image asset metadata conflicts for ${attachment.assetId}.`);
3718
+ }
3719
+ }
3720
+ database
3721
+ .query(
3722
+ `INSERT INTO message_image_attachments (
3723
+ message_id, attachment_id, asset_id, position, label,
3724
+ range_start, range_end, original_name
3725
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
3726
+ )
3727
+ .run(
3728
+ message.messageId,
3729
+ attachment.attachmentId,
3730
+ attachment.assetId,
3731
+ position,
3732
+ attachment.label,
3733
+ attachment.range.start,
3734
+ attachment.range.end,
3735
+ attachment.originalName,
3736
+ );
3737
+ }
2034
3738
  }
2035
3739
 
2036
3740
  function insertToolResult(database: Database, result: ToolResultRecord): void {
@@ -2060,6 +3764,45 @@ function insertToolResult(database: Database, result: ToolResultRecord): void {
2060
3764
  );
2061
3765
  }
2062
3766
 
3767
+ function insertContextSurface(
3768
+ database: Database,
3769
+ surface: StoredContextSurfaceV8,
3770
+ ): void {
3771
+ validateStoredContextSurface(surface);
3772
+ database
3773
+ .query(
3774
+ `INSERT INTO context_surfaces (
3775
+ surface_id, session_id, system_prompt, system_prompt_sha256,
3776
+ recall_contract_version,
3777
+ project_instruction_json, skill_catalog_json, skill_catalog_sha256,
3778
+ active_skills_json, active_skills_sha256, tool_definitions_json,
3779
+ tool_definitions_sha256, tool_schema_sha256, request_config_sha256,
3780
+ request_max_output_tokens, surface_sha256, created_at
3781
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3782
+ )
3783
+ .run(
3784
+ surface.surfaceId,
3785
+ surface.sessionId,
3786
+ surface.systemPrompt,
3787
+ surface.systemPromptSha256,
3788
+ surface.recallContractVersion,
3789
+ surface.projectInstruction === undefined
3790
+ ? null
3791
+ : stableJsonStringify(surface.projectInstruction),
3792
+ stableJsonStringify(surface.skillCatalog),
3793
+ surface.skillCatalogSha256,
3794
+ stableJsonStringify(surface.activeSkills),
3795
+ surface.activeSkillsSha256,
3796
+ stableJsonStringify(surface.toolDefinitions),
3797
+ surface.toolDefinitionsSha256,
3798
+ surface.toolSchemaSha256,
3799
+ surface.requestConfigSha256,
3800
+ surface.requestMaxOutputTokens,
3801
+ surface.surfaceSha256,
3802
+ surface.createdAt,
3803
+ );
3804
+ }
3805
+
2063
3806
  function decodeFrame(rowValue: unknown): ProtocolFrame {
2064
3807
  const row = recordFromSql(rowValue, "protocol frame");
2065
3808
  const state = enumFromSql(row.state, ["open", "closed"] as const, "frame state");
@@ -2090,7 +3833,67 @@ function decodeFrame(rowValue: unknown): ProtocolFrame {
2090
3833
  });
2091
3834
  }
2092
3835
 
2093
- function decodeMessage(rowValue: unknown): CanonicalMessageRecord {
3836
+ function loadMessageImageAttachments(
3837
+ database: Database,
3838
+ ): Map<string, readonly UserImageAttachment[]> {
3839
+ const rows = database
3840
+ .query(
3841
+ `SELECT mia.*, ia.mime_type, ia.byte_length, ia.width, ia.height,
3842
+ ia.created_at AS asset_created_at
3843
+ FROM message_image_attachments mia
3844
+ JOIN image_assets ia ON ia.asset_id = mia.asset_id
3845
+ JOIN messages m ON m.message_id = mia.message_id
3846
+ ORDER BY m.ordinal, mia.position`,
3847
+ )
3848
+ .all();
3849
+ const mutable = new Map<string, UserImageAttachment[]>();
3850
+ for (const rowValue of rows) {
3851
+ const row = recordFromSql(rowValue, "message image attachment");
3852
+ const messageId = stringFromSql(row.message_id, "attachment message_id");
3853
+ const attachments = mutable.get(messageId) ?? [];
3854
+ const position = numberFromSql(row.position, "attachment position");
3855
+ if (position !== attachments.length) {
3856
+ throw new Error(
3857
+ `Image attachment positions for message ${messageId} are not continuous.`,
3858
+ );
3859
+ }
3860
+ timestampFromSql(row.asset_created_at, "image asset created_at");
3861
+ const attachment = immutableRecord<UserImageAttachment>({
3862
+ attachmentId: parseImageAttachmentId(
3863
+ stringFromSql(row.attachment_id, "attachment_id"),
3864
+ ),
3865
+ assetId: parseImageAssetId(stringFromSql(row.asset_id, "asset_id")),
3866
+ mimeType: enumFromSql(
3867
+ row.mime_type,
3868
+ ["image/png", "image/jpeg", "image/webp"] as const,
3869
+ "image mime_type",
3870
+ ),
3871
+ byteLength: numberFromSql(row.byte_length, "image byte_length"),
3872
+ width: numberFromSql(row.width, "image width"),
3873
+ height: numberFromSql(row.height, "image height"),
3874
+ label: stringFromSql(row.label, "attachment label"),
3875
+ range: immutableRecord({
3876
+ start: numberFromSql(row.range_start, "attachment range_start"),
3877
+ end: numberFromSql(row.range_end, "attachment range_end"),
3878
+ }),
3879
+ originalName: stringFromSql(row.original_name, "attachment original_name"),
3880
+ });
3881
+ validateOriginalImageName(attachment.originalName);
3882
+ attachments.push(attachment);
3883
+ mutable.set(messageId, attachments);
3884
+ }
3885
+ return new Map(
3886
+ [...mutable].map(([messageId, attachments]) => [
3887
+ messageId,
3888
+ Object.freeze(attachments),
3889
+ ]),
3890
+ );
3891
+ }
3892
+
3893
+ function decodeMessage(
3894
+ rowValue: unknown,
3895
+ attachments?: readonly UserImageAttachment[],
3896
+ ): CanonicalMessageRecord {
2094
3897
  const row = recordFromSql(rowValue, "message");
2095
3898
  const base = {
2096
3899
  messageId: stringFromSql(row.message_id, "message_id") as MessageId,
@@ -2107,21 +3910,37 @@ function decodeMessage(rowValue: unknown): CanonicalMessageRecord {
2107
3910
  );
2108
3911
  switch (role) {
2109
3912
  case "system":
3913
+ if (attachments !== undefined) {
3914
+ throw new Error("System messages cannot have image attachments.");
3915
+ }
2110
3916
  return immutableRecord({
2111
3917
  ...base,
2112
3918
  role,
2113
3919
  content: stringFromSql(row.content, "content"),
2114
3920
  origin: "runtime",
2115
3921
  });
2116
- case "user":
2117
- return immutableRecord({
3922
+ case "user": {
3923
+ const message = {
2118
3924
  ...base,
2119
3925
  role,
2120
3926
  turnId: stringFromSql(row.turn_id, "turn_id") as TurnId,
2121
3927
  content: stringFromSql(row.content, "content"),
3928
+ ...(attachments === undefined ? {} : { attachments }),
2122
3929
  origin: "user",
3930
+ } as const;
3931
+ validateUserMessage({
3932
+ role: "user",
3933
+ content: message.content,
3934
+ ...(message.attachments === undefined
3935
+ ? {}
3936
+ : { attachments: message.attachments }),
2123
3937
  });
3938
+ return immutableRecord(message);
3939
+ }
2124
3940
  case "assistant": {
3941
+ if (attachments !== undefined) {
3942
+ throw new Error("Assistant messages cannot have image attachments.");
3943
+ }
2125
3944
  const content = nullableTextFromSql(row.content, "content");
2126
3945
  const reasoningPresent = numberFromSql(
2127
3946
  row.reasoning_content_present,
@@ -2157,6 +3976,9 @@ function decodeMessage(rowValue: unknown): CanonicalMessageRecord {
2157
3976
  });
2158
3977
  }
2159
3978
  case "tool":
3979
+ if (attachments !== undefined) {
3980
+ throw new Error("Tool messages cannot have image attachments.");
3981
+ }
2160
3982
  return immutableRecord({
2161
3983
  ...base,
2162
3984
  role,
@@ -2174,6 +3996,16 @@ function decodeMessage(rowValue: unknown): CanonicalMessageRecord {
2174
3996
  }
2175
3997
  }
2176
3998
 
3999
+ function imageAssetRefFromAttachment(attachment: UserImageAttachment): ImageAssetRef {
4000
+ return Object.freeze({
4001
+ assetId: attachment.assetId,
4002
+ mimeType: attachment.mimeType,
4003
+ byteLength: attachment.byteLength,
4004
+ width: attachment.width,
4005
+ height: attachment.height,
4006
+ });
4007
+ }
4008
+
2177
4009
  function decodeToolResult(rowValue: unknown): ToolResultRecord {
2178
4010
  const row = recordFromSql(rowValue, "tool result");
2179
4011
  const kind = enumFromSql(
@@ -2191,7 +4023,7 @@ function decodeToolResult(rowValue: unknown): ToolResultRecord {
2191
4023
  rawSha256: stringFromSql(row.raw_sha256, "raw_sha256"),
2192
4024
  observationFormat: enumFromSql(
2193
4025
  row.observation_format,
2194
- [TOOL_OBSERVATION_FORMAT] as const,
4026
+ SUPPORTED_TOOL_OBSERVATION_FORMATS,
2195
4027
  "observation format",
2196
4028
  ),
2197
4029
  });
@@ -2229,26 +4061,102 @@ function decodeToolResult(rowValue: unknown): ToolResultRecord {
2229
4061
  });
2230
4062
  }
2231
4063
 
2232
- function decodeContextRevision(rowValue: unknown): StoredContextRevisionV5 {
4064
+ function decodeContextSurface(rowValue: unknown): StoredContextSurfaceV8 {
4065
+ const row = recordFromSql(rowValue, "context surface");
4066
+ const projectInstruction =
4067
+ row.project_instruction_json === null
4068
+ ? undefined
4069
+ : decodeProjectInstructionManifest(
4070
+ parseJson(
4071
+ stringFromSql(row.project_instruction_json, "project_instruction_json"),
4072
+ "project_instruction_json",
4073
+ ),
4074
+ );
4075
+ const toolDefinitions = decodeToolDefinitions(
4076
+ parseJson(
4077
+ stringFromSql(row.tool_definitions_json, "tool_definitions_json"),
4078
+ "tool_definitions_json",
4079
+ ),
4080
+ );
4081
+ const skillCatalog = decodeSkillCatalogManifest(
4082
+ parseJson(
4083
+ stringFromSql(row.skill_catalog_json, "skill_catalog_json"),
4084
+ "skill_catalog_json",
4085
+ ),
4086
+ );
4087
+ const activeSkills = decodeActiveSkillsManifest(
4088
+ parseJson(
4089
+ stringFromSql(row.active_skills_json, "active_skills_json"),
4090
+ "active_skills_json",
4091
+ ),
4092
+ );
4093
+ const surface = Object.freeze({
4094
+ surfaceId: stringFromSql(row.surface_id, "surface_id") as ContextSurfaceId,
4095
+ sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
4096
+ systemPrompt: stringFromSql(row.system_prompt, "system_prompt"),
4097
+ systemPromptSha256: sha256FromSql(row.system_prompt_sha256, "system_prompt_sha256"),
4098
+ recallContractVersion: enumFromSql(
4099
+ row.recall_contract_version,
4100
+ SUPPORTED_RECALL_RETIREMENT_CONTRACT_VERSIONS,
4101
+ "recall_contract_version",
4102
+ ),
4103
+ ...(projectInstruction === undefined ? {} : { projectInstruction }),
4104
+ skillCatalog,
4105
+ skillCatalogSha256: sha256FromSql(row.skill_catalog_sha256, "skill_catalog_sha256"),
4106
+ activeSkills,
4107
+ activeSkillsSha256: sha256FromSql(row.active_skills_sha256, "active_skills_sha256"),
4108
+ toolDefinitions,
4109
+ toolDefinitionsSha256: sha256FromSql(
4110
+ row.tool_definitions_sha256,
4111
+ "tool_definitions_sha256",
4112
+ ),
4113
+ toolSchemaSha256: sha256FromSql(row.tool_schema_sha256, "tool_schema_sha256"),
4114
+ requestConfigSha256: sha256FromSql(
4115
+ row.request_config_sha256,
4116
+ "request_config_sha256",
4117
+ ),
4118
+ requestMaxOutputTokens: numberFromSql(
4119
+ row.request_max_output_tokens,
4120
+ "request_max_output_tokens",
4121
+ ),
4122
+ surfaceSha256: sha256FromSql(row.surface_sha256, "surface_sha256"),
4123
+ createdAt: timestampFromSql(row.created_at, "created_at"),
4124
+ });
4125
+ validateStoredContextSurface(surface);
4126
+ return surface;
4127
+ }
4128
+
4129
+ function decodeContextRevision(rowValue: unknown): StoredContextRevisionV8 {
2233
4130
  const row = recordFromSql(rowValue, "context revision");
2234
4131
  const kind = enumFromSql(
2235
4132
  row.kind,
2236
- ["initial_full", "swap_only"] as const,
4133
+ [
4134
+ "initial_full",
4135
+ "swap_only",
4136
+ "surface_refresh",
4137
+ "prefix_retirement",
4138
+ "skills_update",
4139
+ ] as const,
2237
4140
  "context revision kind",
2238
4141
  );
2239
4142
  const common = {
2240
4143
  revisionId: stringFromSql(row.revision_id, "revision_id") as ContextRevisionId,
2241
4144
  sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
4145
+ surfaceId: stringFromSql(row.surface_id, "surface_id") as ContextSurfaceId,
4146
+ surfaceSha256: sha256FromSql(row.surface_sha256, "surface_sha256"),
2242
4147
  keepFromOrdinal: numberFromSql(row.keep_from_ordinal, "keep_from_ordinal"),
2243
4148
  sourceThroughOrdinal: numberFromSql(
2244
4149
  row.source_through_ordinal,
2245
4150
  "source_through_ordinal",
2246
4151
  ),
2247
4152
  addedOverrideCount: numberFromSql(row.added_override_count, "added_override_count"),
2248
- totalOverrideCount: numberFromSql(row.total_override_count, "total_override_count"),
2249
- overrideManifestSha256: sha256FromSql(
2250
- row.override_manifest_sha256,
2251
- "override_manifest_sha256",
4153
+ activeOverrideCount: numberFromSql(
4154
+ row.active_override_count,
4155
+ "active_override_count",
4156
+ ),
4157
+ activeOverrideManifestSha256: sha256FromSql(
4158
+ row.active_override_manifest_sha256,
4159
+ "active_override_manifest_sha256",
2252
4160
  ),
2253
4161
  canonicalSequenceSha256: sha256FromSql(
2254
4162
  row.canonical_sequence_sha256,
@@ -2260,8 +4168,8 @@ function decodeContextRevision(rowValue: unknown): StoredContextRevisionV5 {
2260
4168
  ),
2261
4169
  createdAt: timestampFromSql(row.created_at, "created_at"),
2262
4170
  };
2263
- if (common.keepFromOrdinal !== 1) {
2264
- throw new Error("Context revision keep_from_ordinal must be 1 in schema v5.");
4171
+ if (common.keepFromOrdinal < 1) {
4172
+ throw new Error("Context revision keep_from_ordinal must be positive.");
2265
4173
  }
2266
4174
  const revisionNumber = numberFromSql(row.revision_number, "revision_number");
2267
4175
  const parentRevisionId = nullableStringFromSql(
@@ -2274,10 +4182,14 @@ function decodeContextRevision(rowValue: unknown): StoredContextRevisionV5 {
2274
4182
  parentRevisionId !== null ||
2275
4183
  common.sourceThroughOrdinal !== 1 ||
2276
4184
  common.addedOverrideCount !== 0 ||
2277
- common.totalOverrideCount !== 0 ||
4185
+ common.activeOverrideCount !== 0 ||
4186
+ common.keepFromOrdinal !== 1 ||
2278
4187
  row.policy_version !== null ||
2279
4188
  row.renderer_format !== null ||
2280
- row.plan_sha256 !== null
4189
+ row.plan_sha256 !== null ||
4190
+ row.change_manifest_sha256 !== null ||
4191
+ row.activation_manifest_sha256 !== null ||
4192
+ !hasNoRetirementFields(row)
2281
4193
  ) {
2282
4194
  throw new Error("Initial context revision row is invalid.");
2283
4195
  }
@@ -2289,38 +4201,165 @@ function decodeContextRevision(rowValue: unknown): StoredContextRevisionV5 {
2289
4201
  keepFromOrdinal: 1,
2290
4202
  sourceThroughOrdinal: 1,
2291
4203
  addedOverrideCount: 0,
2292
- totalOverrideCount: 0,
4204
+ activeOverrideCount: 0,
4205
+ });
4206
+ }
4207
+ if (
4208
+ kind === "swap_only" &&
4209
+ (revisionNumber < 2 ||
4210
+ parentRevisionId === null ||
4211
+ common.addedOverrideCount < 1 ||
4212
+ common.activeOverrideCount < common.addedOverrideCount)
4213
+ ) {
4214
+ throw new Error("Swap context revision row is invalid.");
4215
+ }
4216
+ if (kind === "swap_only") {
4217
+ if (
4218
+ row.change_manifest_sha256 !== null ||
4219
+ row.activation_manifest_sha256 !== null ||
4220
+ !hasNoRetirementFields(row)
4221
+ ) {
4222
+ throw new Error("Swap context revision has a change manifest.");
4223
+ }
4224
+ return Object.freeze({
4225
+ ...common,
4226
+ revisionNumber,
4227
+ parentRevisionId: parentRevisionId!,
4228
+ kind,
4229
+ policyVersion: enumFromSql(
4230
+ row.policy_version,
4231
+ ["swap-only-v1"] as const,
4232
+ "context revision policy",
4233
+ ),
4234
+ rendererFormat: enumFromSql(
4235
+ row.renderer_format,
4236
+ [SWAP_OBSERVATION_FORMAT] as const,
4237
+ "context revision renderer format",
4238
+ ),
4239
+ planSha256: sha256FromSql(row.plan_sha256, "plan_sha256"),
4240
+ });
4241
+ }
4242
+ if (kind === "skills_update") {
4243
+ if (
4244
+ revisionNumber < 2 ||
4245
+ parentRevisionId === null ||
4246
+ common.addedOverrideCount < 1 ||
4247
+ common.activeOverrideCount < common.addedOverrideCount ||
4248
+ row.plan_sha256 !== null ||
4249
+ !hasNoRetirementFields(row)
4250
+ ) {
4251
+ throw new Error("Agent Skills context revision row is invalid.");
4252
+ }
4253
+ return Object.freeze({
4254
+ ...common,
4255
+ revisionNumber,
4256
+ parentRevisionId,
4257
+ kind,
4258
+ policyVersion: enumFromSql(
4259
+ row.policy_version,
4260
+ [SKILL_POLICY_VERSION] as const,
4261
+ "Agent Skills context revision policy",
4262
+ ),
4263
+ rendererFormat: enumFromSql(
4264
+ row.renderer_format,
4265
+ [SKILL_ACTIVATION_RECEIPT_FORMAT] as const,
4266
+ "Agent Skills context revision renderer format",
4267
+ ),
4268
+ changeManifestSha256: sha256FromSql(
4269
+ row.change_manifest_sha256,
4270
+ "change_manifest_sha256",
4271
+ ),
4272
+ activationManifestSha256: sha256FromSql(
4273
+ row.activation_manifest_sha256,
4274
+ "activation_manifest_sha256",
4275
+ ),
4276
+ });
4277
+ }
4278
+ if (kind === "prefix_retirement") {
4279
+ const retiredThroughOrdinal = numberFromSql(
4280
+ row.retired_through_ordinal,
4281
+ "retired_through_ordinal",
4282
+ );
4283
+ const retiredTurnCount = numberFromSql(
4284
+ row.retired_turn_count,
4285
+ "retired_turn_count",
4286
+ );
4287
+ const retiredFrameCount = numberFromSql(
4288
+ row.retired_frame_count,
4289
+ "retired_frame_count",
4290
+ );
4291
+ const retiredMessageCount = numberFromSql(
4292
+ row.retired_message_count,
4293
+ "retired_message_count",
4294
+ );
4295
+ if (
4296
+ revisionNumber < 2 ||
4297
+ parentRevisionId === null ||
4298
+ common.keepFromOrdinal <= 1 ||
4299
+ common.addedOverrideCount !== 0 ||
4300
+ retiredThroughOrdinal !== common.keepFromOrdinal - 1 ||
4301
+ retiredTurnCount < 1 ||
4302
+ retiredFrameCount < 1 ||
4303
+ retiredMessageCount < 1 ||
4304
+ row.renderer_format !== null ||
4305
+ row.change_manifest_sha256 !== null ||
4306
+ row.activation_manifest_sha256 !== null
4307
+ ) {
4308
+ throw new Error("Prefix retirement revision row is invalid.");
4309
+ }
4310
+ return Object.freeze({
4311
+ ...common,
4312
+ revisionNumber,
4313
+ parentRevisionId,
4314
+ kind,
4315
+ addedOverrideCount: 0,
4316
+ policyVersion: enumFromSql(
4317
+ row.policy_version,
4318
+ ["recall-first-retirement-v1"] as const,
4319
+ "context revision policy",
4320
+ ),
4321
+ planSha256: sha256FromSql(row.plan_sha256, "plan_sha256"),
4322
+ retiredThroughOrdinal,
4323
+ retiredTurnCount,
4324
+ retiredFrameCount,
4325
+ retiredMessageCount,
2293
4326
  });
2294
4327
  }
2295
4328
  if (
2296
4329
  revisionNumber < 2 ||
2297
4330
  parentRevisionId === null ||
2298
- common.addedOverrideCount < 1 ||
2299
- common.totalOverrideCount < common.addedOverrideCount
4331
+ common.addedOverrideCount !== 0 ||
4332
+ row.policy_version !== null ||
4333
+ row.renderer_format !== null ||
4334
+ row.plan_sha256 !== null ||
4335
+ row.activation_manifest_sha256 !== null ||
4336
+ !hasNoRetirementFields(row)
2300
4337
  ) {
2301
- throw new Error("Swap context revision row is invalid.");
4338
+ throw new Error("Surface context revision row is invalid.");
2302
4339
  }
2303
4340
  return Object.freeze({
2304
4341
  ...common,
2305
4342
  revisionNumber,
2306
4343
  parentRevisionId,
2307
4344
  kind,
2308
- keepFromOrdinal: 1,
2309
- policyVersion: enumFromSql(
2310
- row.policy_version,
2311
- ["swap-only-v1"] as const,
2312
- "context revision policy",
2313
- ),
2314
- rendererFormat: enumFromSql(
2315
- row.renderer_format,
2316
- [SWAP_OBSERVATION_FORMAT] as const,
2317
- "context revision renderer format",
4345
+ addedOverrideCount: 0,
4346
+ changeManifestSha256: sha256FromSql(
4347
+ row.change_manifest_sha256,
4348
+ "change_manifest_sha256",
2318
4349
  ),
2319
- planSha256: sha256FromSql(row.plan_sha256, "plan_sha256"),
2320
4350
  });
2321
4351
  }
2322
4352
 
2323
- function decodeStoredSwapOverride(rowValue: unknown): StoredSwapOverrideV5 {
4353
+ function hasNoRetirementFields(row: Record<string, unknown>): boolean {
4354
+ return (
4355
+ row.retired_through_ordinal === null &&
4356
+ row.retired_turn_count === null &&
4357
+ row.retired_frame_count === null &&
4358
+ row.retired_message_count === null
4359
+ );
4360
+ }
4361
+
4362
+ function decodeStoredSwapOverride(rowValue: unknown): StoredContextOverrideV8 {
2324
4363
  const row = recordFromSql(rowValue, "context override");
2325
4364
  if (row.representation !== "swapped") {
2326
4365
  throw new Error("Context override representation must be swapped.");
@@ -2335,10 +4374,10 @@ function decodeStoredSwapOverride(rowValue: unknown): StoredSwapOverrideV5 {
2335
4374
  ordinal: numberFromSql(row.ordinal, "ordinal"),
2336
4375
  rendererFormat: enumFromSql(
2337
4376
  row.renderer_format,
2338
- [SWAP_OBSERVATION_FORMAT] as const,
4377
+ [SWAP_OBSERVATION_FORMAT, SKILL_ACTIVATION_RECEIPT_FORMAT] as const,
2339
4378
  "context override renderer format",
2340
4379
  ),
2341
- source: stringFromSql(row.source, "source") as StoredSwapOverrideV5["source"],
4380
+ source: stringFromSql(row.source, "source") as StoredContextOverrideV8["source"],
2342
4381
  originalContentSha256: sha256FromSql(
2343
4382
  row.original_content_sha256,
2344
4383
  "original_content_sha256",
@@ -2355,7 +4394,7 @@ function decodeStoredSwapOverride(rowValue: unknown): StoredSwapOverrideV5 {
2355
4394
  });
2356
4395
  }
2357
4396
 
2358
- function stripStoredOverride(override: StoredSwapOverrideV5): SwapOverride {
4397
+ function stripStoredOverride(override: StoredContextOverrideV8): SwapOverride {
2359
4398
  return Object.freeze({
2360
4399
  frameId: override.frameId,
2361
4400
  messageId: override.messageId,
@@ -2367,6 +4406,71 @@ function stripStoredOverride(override: StoredSwapOverrideV5): SwapOverride {
2367
4406
  originalBytes: override.originalBytes,
2368
4407
  renderedBytes: override.renderedBytes,
2369
4408
  byteSavings: override.byteSavings,
4409
+ ...(override.rendererFormat === SWAP_OBSERVATION_FORMAT
4410
+ ? {}
4411
+ : { rendererFormat: override.rendererFormat }),
4412
+ });
4413
+ }
4414
+
4415
+ function decodeSkillActivation(rowValue: unknown): StoredSkillActivation {
4416
+ const row = recordFromSql(rowValue, "skill activation");
4417
+ const state = enumFromSql(
4418
+ row.state,
4419
+ ["pending", "dispatched", "promoted", "rejected"] as const,
4420
+ "skill activation state",
4421
+ );
4422
+ const dispatchedIterationId = nullableStringFromSql(
4423
+ row.dispatched_iteration_id,
4424
+ "dispatched_iteration_id",
4425
+ ) as IterationId | null;
4426
+ const settledRevisionId = nullableStringFromSql(
4427
+ row.settled_revision_id,
4428
+ "settled_revision_id",
4429
+ ) as ContextRevisionId | null;
4430
+ const rejectionReason = nullableStringFromSql(
4431
+ row.rejection_reason,
4432
+ "rejection_reason",
4433
+ );
4434
+ if (
4435
+ (state === "pending" &&
4436
+ (dispatchedIterationId !== null ||
4437
+ settledRevisionId !== null ||
4438
+ rejectionReason !== null)) ||
4439
+ (state === "dispatched" &&
4440
+ (dispatchedIterationId === null ||
4441
+ settledRevisionId !== null ||
4442
+ rejectionReason !== null)) ||
4443
+ (state === "promoted" &&
4444
+ (dispatchedIterationId === null ||
4445
+ settledRevisionId === null ||
4446
+ rejectionReason !== null)) ||
4447
+ (state === "rejected" &&
4448
+ (settledRevisionId === null ||
4449
+ rejectionReason === null ||
4450
+ rejectionReason.trim() === ""))
4451
+ ) {
4452
+ throw new Error("Skill activation lifecycle fields are invalid.");
4453
+ }
4454
+ return Object.freeze({
4455
+ activationMessageId: stringFromSql(
4456
+ row.activation_message_id,
4457
+ "activation_message_id",
4458
+ ) as MessageId,
4459
+ toolCallId: stringFromSql(row.tool_call_id, "tool_call_id") as ToolCallId,
4460
+ sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
4461
+ name: stringFromSql(row.name, "skill activation name"),
4462
+ scope: enumFromSql(
4463
+ row.scope,
4464
+ ["project", "user"] as const,
4465
+ "skill activation scope",
4466
+ ),
4467
+ skillFileSha256: sha256FromSql(row.skill_file_sha256, "skill_file_sha256"),
4468
+ state,
4469
+ ...(dispatchedIterationId === null ? {} : { dispatchedIterationId }),
4470
+ ...(settledRevisionId === null ? {} : { settledRevisionId }),
4471
+ ...(rejectionReason === null ? {} : { rejectionReason }),
4472
+ createdAt: timestampFromSql(row.created_at, "created_at"),
4473
+ updatedAt: timestampFromSql(row.updated_at, "updated_at"),
2370
4474
  });
2371
4475
  }
2372
4476
 
@@ -2455,12 +4559,158 @@ export function decodeStoredToolCalls(json: string): readonly ToolCall[] {
2455
4559
  }),
2456
4560
  };
2457
4561
  });
2458
- return Object.freeze(calls);
4562
+ return Object.freeze(calls);
4563
+ }
4564
+
4565
+ function decodeProjectInstructionManifest(value: unknown): ProjectInstructionManifest {
4566
+ const record = recordFromSql(value, "project instruction manifest");
4567
+ assertObjectKeys(
4568
+ record,
4569
+ ["path", "byteLength", "sha256"],
4570
+ ["path", "byteLength", "sha256"],
4571
+ "project instruction manifest",
4572
+ );
4573
+ return Object.freeze({
4574
+ path: enumFromSql(
4575
+ record.path,
4576
+ ["AGENTS.md", "CLAUDE.md"] as const,
4577
+ "project instruction path",
4578
+ ),
4579
+ byteLength: numberFromJson(record.byteLength, "project instruction byteLength"),
4580
+ sha256: sha256FromSql(record.sha256, "project instruction sha256"),
4581
+ });
4582
+ }
4583
+
4584
+ function decodeSkillCatalogManifest(
4585
+ value: unknown,
4586
+ ): readonly SkillCatalogManifestEntry[] {
4587
+ if (!Array.isArray(value)) {
4588
+ throw new Error("skill_catalog_json must contain an array.");
4589
+ }
4590
+ return Object.freeze(
4591
+ value.map((entry, index) => {
4592
+ const record = recordFromSql(entry, `skill catalog entry ${index}`);
4593
+ assertObjectKeys(
4594
+ record,
4595
+ [
4596
+ "name",
4597
+ "scope",
4598
+ "directorySha256",
4599
+ "descriptionSha256",
4600
+ "skillFileSha256",
4601
+ "byteLength",
4602
+ ],
4603
+ [
4604
+ "name",
4605
+ "scope",
4606
+ "directorySha256",
4607
+ "descriptionSha256",
4608
+ "skillFileSha256",
4609
+ "byteLength",
4610
+ ],
4611
+ `skill catalog entry ${index}`,
4612
+ );
4613
+ return Object.freeze({
4614
+ name: stringFromSql(record.name, "skill name"),
4615
+ scope: enumFromSql(record.scope, ["project", "user"] as const, "skill scope"),
4616
+ directorySha256: sha256FromSql(record.directorySha256, "skill directorySha256"),
4617
+ descriptionSha256: sha256FromSql(
4618
+ record.descriptionSha256,
4619
+ "skill descriptionSha256",
4620
+ ),
4621
+ skillFileSha256: sha256FromSql(record.skillFileSha256, "skill skillFileSha256"),
4622
+ byteLength: numberFromJson(record.byteLength, "skill byteLength"),
4623
+ });
4624
+ }),
4625
+ );
4626
+ }
4627
+
4628
+ function decodeActiveSkillsManifest(
4629
+ value: unknown,
4630
+ ): readonly ActiveSkillManifestEntry[] {
4631
+ if (!Array.isArray(value)) {
4632
+ throw new Error("active_skills_json must contain an array.");
4633
+ }
4634
+ return Object.freeze(
4635
+ value.map((entry, index) => {
4636
+ const record = recordFromSql(entry, `active skill entry ${index}`);
4637
+ assertObjectKeys(
4638
+ record,
4639
+ [
4640
+ "name",
4641
+ "scope",
4642
+ "directorySha256",
4643
+ "descriptionSha256",
4644
+ "skillFileSha256",
4645
+ "byteLength",
4646
+ "activationMessageId",
4647
+ ],
4648
+ [
4649
+ "name",
4650
+ "scope",
4651
+ "directorySha256",
4652
+ "descriptionSha256",
4653
+ "skillFileSha256",
4654
+ "byteLength",
4655
+ "activationMessageId",
4656
+ ],
4657
+ `active skill entry ${index}`,
4658
+ );
4659
+ const [catalogEntry] = decodeSkillCatalogManifest([
4660
+ {
4661
+ name: record.name,
4662
+ scope: record.scope,
4663
+ directorySha256: record.directorySha256,
4664
+ descriptionSha256: record.descriptionSha256,
4665
+ skillFileSha256: record.skillFileSha256,
4666
+ byteLength: record.byteLength,
4667
+ },
4668
+ ]);
4669
+ if (catalogEntry === undefined) {
4670
+ throw new Error(`Active skill entry ${index} is missing.`);
4671
+ }
4672
+ return Object.freeze({
4673
+ ...catalogEntry,
4674
+ activationMessageId: stringFromSql(
4675
+ record.activationMessageId,
4676
+ "skill activationMessageId",
4677
+ ) as MessageId,
4678
+ });
4679
+ }),
4680
+ );
4681
+ }
4682
+
4683
+ function decodeToolDefinitions(value: unknown): readonly ToolDefinition[] {
4684
+ if (!Array.isArray(value)) {
4685
+ throw new Error("tool_definitions_json must contain an array.");
4686
+ }
4687
+ const definitions = value.map((entry, index): ToolDefinition => {
4688
+ const record = recordFromSql(entry, `tool definition ${index}`);
4689
+ assertObjectKeys(
4690
+ record,
4691
+ ["name", "description", "parameters"],
4692
+ ["name", "description", "parameters"],
4693
+ `tool definition ${index}`,
4694
+ );
4695
+ const parameters = recordFromSql(
4696
+ record.parameters,
4697
+ `tool definition ${index} parameters`,
4698
+ );
4699
+ return Object.freeze({
4700
+ name: stringFromSql(record.name, `tool definition ${index} name`),
4701
+ description: stringFromSql(
4702
+ record.description,
4703
+ `tool definition ${index} description`,
4704
+ ),
4705
+ parameters: immutableCanonicalClone(parameters),
4706
+ });
4707
+ });
4708
+ return Object.freeze(definitions);
2459
4709
  }
2460
4710
 
2461
4711
  export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
2462
4712
  const raw = recordFromSql(value, "tool raw result");
2463
- enumFromSql(
4713
+ const kind = enumFromSql(
2464
4714
  raw.kind,
2465
4715
  [
2466
4716
  "read",
@@ -2475,6 +4725,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
2475
4725
  "web_search",
2476
4726
  "web_fetch",
2477
4727
  "recall",
4728
+ "skill",
2478
4729
  "mcp",
2479
4730
  "generic",
2480
4731
  ] as const,
@@ -2483,9 +4734,206 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
2483
4734
  if (typeof raw.ok !== "boolean") {
2484
4735
  throw new Error("tool raw result ok must be a boolean.");
2485
4736
  }
4737
+ if (kind === "skill") {
4738
+ return decodeStoredSkillRawResult(raw);
4739
+ }
2486
4740
  return immutableCanonicalClone(raw) as ToolRawResult;
2487
4741
  }
2488
4742
 
4743
+ function decodeStoredSkillRawResult(
4744
+ raw: Record<string, unknown>,
4745
+ ): Extract<ToolRawResult, { kind: "skill" }> {
4746
+ const status = enumFromSql(
4747
+ raw.status,
4748
+ ["loaded", "already_loaded", "already_active", "failed"] as const,
4749
+ "Skill raw result status",
4750
+ );
4751
+ const name = stringFromSql(raw.name, "Skill raw result name");
4752
+ if (status === "failed") {
4753
+ if (raw.ok !== false) {
4754
+ throw new Error("Failed Skill raw result must have ok=false.");
4755
+ }
4756
+ assertObjectKeys(
4757
+ raw,
4758
+ ["kind", "ok", "status", "name", "errorCode", "error"],
4759
+ ["kind", "ok", "status", "name", "errorCode", "error"],
4760
+ "failed Skill raw result",
4761
+ );
4762
+ const errorCode = stringFromSql(raw.errorCode, "Skill errorCode");
4763
+ const error = stringFromSql(raw.error, "Skill error");
4764
+ if (
4765
+ (name !== "" && !isValidSkillName(name)) ||
4766
+ !/^[A-Z][A-Z0-9_]{0,79}$/.test(errorCode) ||
4767
+ error.trim() === ""
4768
+ ) {
4769
+ throw new Error("Failed Skill raw result fields are invalid.");
4770
+ }
4771
+ return immutableRecord({
4772
+ kind: "skill" as const,
4773
+ ok: false as const,
4774
+ status,
4775
+ name,
4776
+ errorCode,
4777
+ error,
4778
+ });
4779
+ }
4780
+ if (raw.ok !== true) {
4781
+ throw new Error("Successful Skill raw result must have ok=true.");
4782
+ }
4783
+ const scope = enumFromSql(
4784
+ raw.scope,
4785
+ ["project", "user"] as const,
4786
+ "Skill raw result scope",
4787
+ );
4788
+ const skillFileSha256 = sha256FromSql(raw.sha256, "Skill raw result sha256");
4789
+ if (!isValidSkillName(name)) {
4790
+ throw new Error("Successful Skill raw result name is invalid.");
4791
+ }
4792
+ if (status === "already_loaded") {
4793
+ assertObjectKeys(
4794
+ raw,
4795
+ ["kind", "ok", "status", "name", "scope", "lifecycle", "sha256"],
4796
+ ["kind", "ok", "status", "name", "scope", "lifecycle", "sha256"],
4797
+ "already loaded Skill raw result",
4798
+ );
4799
+ return immutableRecord({
4800
+ kind: "skill" as const,
4801
+ ok: true as const,
4802
+ status,
4803
+ name,
4804
+ scope,
4805
+ lifecycle: enumFromSql(
4806
+ raw.lifecycle,
4807
+ ["pending", "dispatched"] as const,
4808
+ "Skill lifecycle",
4809
+ ),
4810
+ sha256: skillFileSha256,
4811
+ });
4812
+ }
4813
+ if (status === "already_active") {
4814
+ assertObjectKeys(
4815
+ raw,
4816
+ ["kind", "ok", "status", "name", "scope", "sha256"],
4817
+ ["kind", "ok", "status", "name", "scope", "sha256"],
4818
+ "already active Skill raw result",
4819
+ );
4820
+ return immutableRecord({
4821
+ kind: "skill" as const,
4822
+ ok: true as const,
4823
+ status,
4824
+ name,
4825
+ scope,
4826
+ sha256: skillFileSha256,
4827
+ });
4828
+ }
4829
+ assertObjectKeys(
4830
+ raw,
4831
+ [
4832
+ "kind",
4833
+ "ok",
4834
+ "status",
4835
+ "name",
4836
+ "scope",
4837
+ "directory",
4838
+ "skillFilePath",
4839
+ "content",
4840
+ "byteLength",
4841
+ "sha256",
4842
+ "resources",
4843
+ "resourcesTruncated",
4844
+ ],
4845
+ [
4846
+ "kind",
4847
+ "ok",
4848
+ "status",
4849
+ "name",
4850
+ "scope",
4851
+ "directory",
4852
+ "skillFilePath",
4853
+ "content",
4854
+ "byteLength",
4855
+ "sha256",
4856
+ "resources",
4857
+ "resourcesTruncated",
4858
+ ],
4859
+ "loaded Skill raw result",
4860
+ );
4861
+ if (
4862
+ !Array.isArray(raw.resources) ||
4863
+ raw.resources.some((entry) => typeof entry !== "string") ||
4864
+ raw.resources.length > SKILL_RESOURCE_MAX_ENTRIES ||
4865
+ typeof raw.resourcesTruncated !== "boolean"
4866
+ ) {
4867
+ throw new Error("Loaded Skill resource manifest is invalid.");
4868
+ }
4869
+ const directory = stringFromSql(raw.directory, "Skill directory");
4870
+ const skillFilePath = stringFromSql(raw.skillFilePath, "Skill file path");
4871
+ const content = stringFromSql(raw.content, "Skill content");
4872
+ const byteLength = numberFromJson(raw.byteLength, "Skill byteLength");
4873
+ const resources = raw.resources as string[];
4874
+ if (
4875
+ !path.isAbsolute(directory) ||
4876
+ !path.isAbsolute(skillFilePath) ||
4877
+ !isPathWithin(directory, skillFilePath) ||
4878
+ byteLength < 1 ||
4879
+ byteLength > SKILL_FILE_MAX_BYTES ||
4880
+ Buffer.byteLength(content, "utf8") !== byteLength ||
4881
+ sha256(content) !== skillFileSha256 ||
4882
+ !isValidResourceManifest(resources)
4883
+ ) {
4884
+ throw new Error("Loaded Skill raw result snapshot is invalid.");
4885
+ }
4886
+ return immutableRecord({
4887
+ kind: "skill" as const,
4888
+ ok: true as const,
4889
+ status,
4890
+ name,
4891
+ scope,
4892
+ directory,
4893
+ skillFilePath,
4894
+ content,
4895
+ byteLength,
4896
+ sha256: skillFileSha256,
4897
+ resources: Object.freeze([...resources]),
4898
+ resourcesTruncated: raw.resourcesTruncated,
4899
+ });
4900
+ }
4901
+
4902
+ function isValidSkillName(value: string): boolean {
4903
+ return value.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
4904
+ }
4905
+
4906
+ function isPathWithin(root: string, candidate: string): boolean {
4907
+ const relative = path.relative(root, candidate);
4908
+ return (
4909
+ relative !== "" &&
4910
+ relative !== ".." &&
4911
+ !relative.startsWith(`..${path.sep}`) &&
4912
+ !path.isAbsolute(relative)
4913
+ );
4914
+ }
4915
+
4916
+ function isValidResourceManifest(resources: readonly string[]): boolean {
4917
+ let previous: string | undefined;
4918
+ for (const resource of resources) {
4919
+ const parts = resource.split("/");
4920
+ if (
4921
+ resource === "" ||
4922
+ resource.includes("\\") ||
4923
+ path.posix.isAbsolute(resource) ||
4924
+ path.posix.normalize(resource) !== resource ||
4925
+ !["assets", "references", "scripts"].includes(parts[0] ?? "") ||
4926
+ parts.length < 2 ||
4927
+ parts.length > SKILL_RESOURCE_MAX_DEPTH + 1 ||
4928
+ (previous !== undefined && previous >= resource)
4929
+ ) {
4930
+ return false;
4931
+ }
4932
+ previous = resource;
4933
+ }
4934
+ return true;
4935
+ }
4936
+
2489
4937
  function decodeMeasuredContextState(
2490
4938
  value: unknown,
2491
4939
  expectedSessionId: SessionId,
@@ -2535,9 +4983,12 @@ function assertCommitSwapRevisionInput(input: CommitSwapRevisionInput): void {
2535
4983
  throw new Error("Commit swap revision input is invalid.");
2536
4984
  }
2537
4985
  for (const [name, hash] of [
2538
- ["expectedBaseOverrideManifestSha256", input.expectedBaseOverrideManifestSha256],
4986
+ [
4987
+ "expectedBaseActiveOverrideManifestSha256",
4988
+ input.expectedBaseActiveOverrideManifestSha256,
4989
+ ],
2539
4990
  ["planHash", input.planHash],
2540
- ["nextOverrideManifestSha256", input.nextOverrideManifestSha256],
4991
+ ["nextActiveOverrideManifestSha256", input.nextActiveOverrideManifestSha256],
2541
4992
  ["canonicalSequenceSha256", input.canonicalSequenceSha256],
2542
4993
  ["renderedMessageSha256", input.renderedMessageSha256],
2543
4994
  ] as const) {
@@ -2547,16 +4998,130 @@ function assertCommitSwapRevisionInput(input: CommitSwapRevisionInput): void {
2547
4998
  }
2548
4999
  }
2549
5000
 
2550
- function decodeMeta(value: unknown, expectedSessionId: SessionId): StoredSessionMetaV5 {
5001
+ function assertCommitSurfaceRefreshInput(input: CommitSurfaceRefreshInput): void {
5002
+ if (
5003
+ input.revisionId.trim() === "" ||
5004
+ input.expectedBaseRevisionId.trim() === "" ||
5005
+ !Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
5006
+ input.expectedBaseRevisionNumber < 1 ||
5007
+ !Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
5008
+ input.expectedCanonicalThroughOrdinal < 1
5009
+ ) {
5010
+ throw new Error("Commit surface refresh input is invalid.");
5011
+ }
5012
+ for (const [name, hash] of [
5013
+ [
5014
+ "expectedBaseActiveOverrideManifestSha256",
5015
+ input.expectedBaseActiveOverrideManifestSha256,
5016
+ ],
5017
+ ["changeManifestSha256", input.changeManifestSha256],
5018
+ ["canonicalSequenceSha256", input.canonicalSequenceSha256],
5019
+ ["renderedMessageSha256", input.renderedMessageSha256],
5020
+ ] as const) {
5021
+ if (!/^[0-9a-f]{64}$/.test(hash)) {
5022
+ throw new Error(`Commit surface refresh ${name} must be a SHA-256 hash.`);
5023
+ }
5024
+ }
5025
+ }
5026
+
5027
+ function assertCommitSkillsUpdateInput(input: CommitSkillsUpdateInput): void {
5028
+ if (
5029
+ input.revisionId.trim() === "" ||
5030
+ input.expectedBaseRevisionId.trim() === "" ||
5031
+ !Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
5032
+ input.expectedBaseRevisionNumber < 1 ||
5033
+ !Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
5034
+ input.expectedCanonicalThroughOrdinal < 1 ||
5035
+ input.addedOverrides.length < 1 ||
5036
+ input.settlements.length !== input.addedOverrides.length
5037
+ ) {
5038
+ throw new Error("Commit Agent Skills update input is invalid.");
5039
+ }
5040
+ for (const [name, hash] of [
5041
+ [
5042
+ "expectedBaseActiveOverrideManifestSha256",
5043
+ input.expectedBaseActiveOverrideManifestSha256,
5044
+ ],
5045
+ ["changeManifestSha256", input.changeManifestSha256],
5046
+ ["activationManifestSha256", input.activationManifestSha256],
5047
+ ["nextActiveOverrideManifestSha256", input.nextActiveOverrideManifestSha256],
5048
+ ["canonicalSequenceSha256", input.canonicalSequenceSha256],
5049
+ ["renderedMessageSha256", input.renderedMessageSha256],
5050
+ ] as const) {
5051
+ if (!/^[0-9a-f]{64}$/.test(hash)) {
5052
+ throw new Error(`Commit Agent Skills update ${name} must be a SHA-256 hash.`);
5053
+ }
5054
+ }
5055
+ }
5056
+
5057
+ function assertCommitPrefixRetirementRevisionInput(
5058
+ input: CommitPrefixRetirementRevisionInput,
5059
+ ): void {
5060
+ if (
5061
+ input.revisionId.trim() === "" ||
5062
+ input.expectedBaseRevisionId.trim() === "" ||
5063
+ input.policyVersion !== "recall-first-retirement-v1" ||
5064
+ !Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
5065
+ input.expectedBaseRevisionNumber < 1 ||
5066
+ !Number.isSafeInteger(input.expectedBaseKeepFromOrdinal) ||
5067
+ input.expectedBaseKeepFromOrdinal < 1 ||
5068
+ !Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
5069
+ input.expectedCanonicalThroughOrdinal < 1 ||
5070
+ !Number.isSafeInteger(input.nextKeepFromOrdinal) ||
5071
+ input.nextKeepFromOrdinal <= input.expectedBaseKeepFromOrdinal ||
5072
+ input.retiredThroughOrdinal !== input.nextKeepFromOrdinal - 1 ||
5073
+ !Number.isSafeInteger(input.retiredTurnCount) ||
5074
+ input.retiredTurnCount < 1 ||
5075
+ !Number.isSafeInteger(input.retiredFrameCount) ||
5076
+ input.retiredFrameCount < 1 ||
5077
+ !Number.isSafeInteger(input.retiredMessageCount) ||
5078
+ input.retiredMessageCount < 1 ||
5079
+ !Number.isSafeInteger(input.nextActiveOverrideCount) ||
5080
+ input.nextActiveOverrideCount < 0
5081
+ ) {
5082
+ throw new Error("Commit prefix retirement input is invalid.");
5083
+ }
5084
+ for (const [name, hash] of [
5085
+ ["expectedSurfaceSha256", input.expectedSurfaceSha256],
5086
+ [
5087
+ "expectedBaseActiveOverrideManifestSha256",
5088
+ input.expectedBaseActiveOverrideManifestSha256,
5089
+ ],
5090
+ ["planHash", input.planHash],
5091
+ ["nextActiveOverrideManifestSha256", input.nextActiveOverrideManifestSha256],
5092
+ ["canonicalSequenceSha256", input.canonicalSequenceSha256],
5093
+ ["renderedMessageSha256", input.renderedMessageSha256],
5094
+ ] as const) {
5095
+ if (!/^[0-9a-f]{64}$/.test(hash)) {
5096
+ throw new Error(`Commit prefix retirement ${name} must be a SHA-256 hash.`);
5097
+ }
5098
+ }
5099
+ }
5100
+
5101
+ function requireActiveRevisionId(meta: StoredSessionMetaV9): ContextRevisionId {
5102
+ if (meta.initializationState !== "ready" || meta.activeRevisionId === null) {
5103
+ throw new Error("Session has no active context revision.");
5104
+ }
5105
+ return meta.activeRevisionId;
5106
+ }
5107
+
5108
+ function previousRevision(
5109
+ revisions: readonly StoredContextRevisionV8[],
5110
+ revision: StoredContextRevisionV8,
5111
+ ): StoredContextRevisionV8 | undefined {
5112
+ return revisions[revision.revisionNumber - 2];
5113
+ }
5114
+
5115
+ function decodeMeta(value: unknown, expectedSessionId: SessionId): StoredSessionMetaV9 {
2551
5116
  const row = recordFromSql(value, "session metadata");
2552
5117
  const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
2553
5118
  if (sessionId !== expectedSessionId) {
2554
5119
  throw new Error(`Metadata session ID ${sessionId} does not match directory.`);
2555
5120
  }
2556
5121
  const schemaVersion = numberFromSql(row.schema_version, "schema_version");
2557
- if (schemaVersion !== 5) {
5122
+ if (schemaVersion !== 9) {
2558
5123
  throw new Error(
2559
- `Session metadata schema version must be 5; received ${schemaVersion}.`,
5124
+ `Session metadata schema version must be 9; received ${schemaVersion}.`,
2560
5125
  );
2561
5126
  }
2562
5127
  const projectInstructionFile = nullableStringFromSql(
@@ -2594,35 +5159,52 @@ function decodeMeta(value: unknown, expectedSessionId: SessionId): StoredSession
2594
5159
  byteLength: projectInstructionByteLength,
2595
5160
  sha256: sha256FromSql(projectInstructionSha256, "project_instruction_sha256"),
2596
5161
  };
5162
+ const initializationState = enumFromSql(
5163
+ row.initialization_state,
5164
+ ["creating", "ready"] as const,
5165
+ "initialization_state",
5166
+ );
5167
+ const sessionCompatibilityJson = nullableStringFromSql(
5168
+ row.session_compatibility_json,
5169
+ "session_compatibility_json",
5170
+ );
5171
+ const sessionCompatibilitySha256 = nullableStringFromSql(
5172
+ row.session_compatibility_sha256,
5173
+ "session_compatibility_sha256",
5174
+ );
5175
+ const activeRevisionId = nullableStringFromSql(
5176
+ row.active_revision_id,
5177
+ "active_revision_id",
5178
+ ) as ContextRevisionId | null;
5179
+ const modelName = stringFromSql(row.model_name, "model_name");
5180
+ const storedContract =
5181
+ sessionCompatibilityJson === null
5182
+ ? undefined
5183
+ : decodeSessionCompatibilityContract(sessionCompatibilityJson);
5184
+ if (
5185
+ (sessionCompatibilityJson === null) !== (sessionCompatibilitySha256 === null) ||
5186
+ (sessionCompatibilityJson !== null &&
5187
+ sha256(sessionCompatibilityJson) !== sessionCompatibilitySha256) ||
5188
+ (initializationState === "creating") !== (activeRevisionId === null) ||
5189
+ (initializationState === "creating") !== (sessionCompatibilityJson === null) ||
5190
+ (storedContract !== undefined &&
5191
+ (storedContract.modelName !== modelName ||
5192
+ stableJsonStringify(storedContract) !== sessionCompatibilityJson))
5193
+ ) {
5194
+ throw new Error("Session compatibility or initialization metadata is invalid.");
5195
+ }
2597
5196
  return {
2598
5197
  schemaVersion,
2599
5198
  schemaFingerprint: stringFromSql(row.schema_fingerprint, "schema_fingerprint"),
2600
- initializationState: enumFromSql(
2601
- row.initialization_state,
2602
- ["creating", "ready"] as const,
2603
- "initialization_state",
2604
- ),
5199
+ initializationState,
2605
5200
  sessionId,
2606
5201
  workspaceRoot: stringFromSql(row.workspace_root, "workspace_root"),
2607
- modelName: stringFromSql(row.model_name, "model_name"),
5202
+ modelName,
2608
5203
  systemPromptSha256: stringFromSql(row.system_prompt_sha256, "system_prompt_sha256"),
2609
5204
  ...(projectInstruction === undefined ? {} : { projectInstruction }),
2610
- toolSchemaSha256: nullableStringFromSql(
2611
- row.tool_schema_sha256,
2612
- "tool_schema_sha256",
2613
- ),
2614
- runtimeContractJson: nullableStringFromSql(
2615
- row.runtime_contract_json,
2616
- "runtime_contract_json",
2617
- ),
2618
- runtimeContractSha256: nullableStringFromSql(
2619
- row.runtime_contract_sha256,
2620
- "runtime_contract_sha256",
2621
- ),
2622
- activeRevisionId: stringFromSql(
2623
- row.active_revision_id,
2624
- "active_revision_id",
2625
- ) as ContextRevisionId,
5205
+ sessionCompatibilityJson,
5206
+ sessionCompatibilitySha256,
5207
+ activeRevisionId,
2626
5208
  nextTurnNumber: numberFromSql(row.next_turn_number, "next_turn_number"),
2627
5209
  nextEventSequence: numberFromSql(row.next_event_sequence, "next_event_sequence"),
2628
5210
  openCount: numberFromSql(row.open_count, "open_count"),
@@ -2650,23 +5232,197 @@ function decodeMeta(value: unknown, expectedSessionId: SessionId): StoredSession
2650
5232
  };
2651
5233
  }
2652
5234
 
2653
- function runtimeContractDifferences(
5235
+ function compatibilityContractDifferences(
2654
5236
  storedJson: string | null,
2655
- current: RuntimeContractV1,
5237
+ current: SessionCompatibilityContract,
2656
5238
  ): string[] {
2657
5239
  if (storedJson === null) {
2658
- return ["runtimeContract"];
5240
+ return ["sessionCompatibility"];
2659
5241
  }
2660
- const stored = parseJson(storedJson, "runtime_contract_json");
5242
+ const stored = parseJson(storedJson, "session_compatibility_json");
2661
5243
  if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
2662
- return ["runtimeContract"];
5244
+ return ["sessionCompatibility"];
2663
5245
  }
2664
5246
  const record = stored as Record<string, unknown>;
2665
- return Object.keys(current).filter(
2666
- (key) =>
2667
- stableJsonStringify(record[key]) !==
2668
- stableJsonStringify(current[key as keyof RuntimeContractV1]),
5247
+ const fields: readonly (keyof SessionCompatibilityContract)[] = [
5248
+ "modelName",
5249
+ "profileName",
5250
+ "includeReasoningContent",
5251
+ "contextProfile",
5252
+ "messageProtocol",
5253
+ "imageInput",
5254
+ ];
5255
+ return fields.filter((key) => {
5256
+ const storedValue = record[key];
5257
+ const currentValue = current[key];
5258
+ if (storedValue === undefined || currentValue === undefined) {
5259
+ return storedValue !== currentValue;
5260
+ }
5261
+ return stableJsonStringify(storedValue) !== stableJsonStringify(currentValue);
5262
+ });
5263
+ }
5264
+
5265
+ function decodeSessionCompatibilityContract(
5266
+ json: string,
5267
+ ): SessionCompatibilityContract {
5268
+ const record = recordFromSql(
5269
+ parseJson(json, "session_compatibility_json"),
5270
+ "session compatibility contract",
5271
+ );
5272
+ assertObjectKeys(
5273
+ record,
5274
+ [
5275
+ "modelName",
5276
+ "profileName",
5277
+ "includeReasoningContent",
5278
+ "contextProfile",
5279
+ "messageProtocol",
5280
+ "imageInput",
5281
+ ],
5282
+ [
5283
+ "modelName",
5284
+ "includeReasoningContent",
5285
+ "contextProfile",
5286
+ "messageProtocol",
5287
+ "imageInput",
5288
+ ],
5289
+ "session compatibility contract",
5290
+ );
5291
+ const contextProfile = recordFromSql(
5292
+ record.contextProfile,
5293
+ "session compatibility context profile",
5294
+ );
5295
+ assertObjectKeys(
5296
+ contextProfile,
5297
+ ["contextWindowTokens", "maxSupportedOutputTokens"],
5298
+ ["contextWindowTokens", "maxSupportedOutputTokens"],
5299
+ "session compatibility context profile",
5300
+ );
5301
+ const messageProtocol = recordFromSql(
5302
+ record.messageProtocol,
5303
+ "session compatibility message protocol",
5304
+ );
5305
+ assertObjectKeys(
5306
+ messageProtocol,
5307
+ ["adapter", "serializationVersion"],
5308
+ ["adapter", "serializationVersion"],
5309
+ "session compatibility message protocol",
5310
+ );
5311
+ const imageInput = recordFromSql(
5312
+ record.imageInput,
5313
+ "session compatibility image input",
5314
+ );
5315
+ assertObjectKeys(
5316
+ imageInput,
5317
+ ["policyVersion", "policySha256", "inputModalities", "tokenEstimator"],
5318
+ ["policyVersion", "policySha256", "inputModalities"],
5319
+ "session compatibility image input",
5320
+ );
5321
+ const inputModalities = imageInput.inputModalities;
5322
+ if (!Array.isArray(inputModalities)) {
5323
+ throw new Error("Session compatibility input modalities must be an array.");
5324
+ }
5325
+ const tokenEstimator =
5326
+ imageInput.tokenEstimator === undefined
5327
+ ? undefined
5328
+ : decodeTokenEstimatorCompatibility(imageInput.tokenEstimator);
5329
+ if (typeof record.includeReasoningContent !== "boolean") {
5330
+ throw new Error("Session compatibility reasoning replay flag must be boolean.");
5331
+ }
5332
+ const modelName = stringFromSql(record.modelName, "compatibility modelName");
5333
+ const profileName =
5334
+ record.profileName === undefined
5335
+ ? undefined
5336
+ : stringFromSql(record.profileName, "compatibility profileName");
5337
+ const context = createModelContextProfile({
5338
+ contextWindowTokens: numberFromJson(
5339
+ contextProfile.contextWindowTokens,
5340
+ "compatibility contextWindowTokens",
5341
+ ),
5342
+ maxSupportedOutputTokens: numberFromJson(
5343
+ contextProfile.maxSupportedOutputTokens,
5344
+ "compatibility maxSupportedOutputTokens",
5345
+ ),
5346
+ });
5347
+ const protocol: ModelMessageProtocol = Object.freeze({
5348
+ adapter: enumFromSql(
5349
+ messageProtocol.adapter,
5350
+ ["openai-chat", "fake"] as const,
5351
+ "compatibility message adapter",
5352
+ ),
5353
+ serializationVersion: stringFromSql(
5354
+ messageProtocol.serializationVersion,
5355
+ "compatibility serializationVersion",
5356
+ ),
5357
+ });
5358
+ const modalities = normalizeInputModalities(
5359
+ inputModalities.map((value) =>
5360
+ enumFromSql(value, ["text", "image"] as const, "compatibility input modality"),
5361
+ ),
5362
+ );
5363
+ if (modalities.includes("image") && tokenEstimator === undefined) {
5364
+ throw new Error("Stored image input compatibility has no token estimator.");
5365
+ }
5366
+ const policyVersion = stringFromSql(
5367
+ imageInput.policyVersion,
5368
+ "compatibility image policyVersion",
5369
+ );
5370
+ const policySha256 = stringFromSql(
5371
+ imageInput.policySha256,
5372
+ "compatibility image policySha256",
5373
+ );
5374
+ if (!/^[0-9a-f]{64}$/.test(policySha256)) {
5375
+ throw new Error("Session image policy hash is invalid.");
5376
+ }
5377
+ return Object.freeze({
5378
+ modelName,
5379
+ ...(record.profileName === undefined ? {} : { profileName: profileName! }),
5380
+ includeReasoningContent: record.includeReasoningContent,
5381
+ contextProfile: Object.freeze(context),
5382
+ messageProtocol: protocol,
5383
+ imageInput: Object.freeze({
5384
+ policyVersion,
5385
+ policySha256,
5386
+ inputModalities: modalities,
5387
+ ...(tokenEstimator === undefined ? {} : { tokenEstimator }),
5388
+ }),
5389
+ });
5390
+ }
5391
+
5392
+ function decodeTokenEstimatorCompatibility(
5393
+ value: unknown,
5394
+ ): InputTokenEstimatorCompatibility {
5395
+ const record = recordFromSql(value, "session compatibility token estimator");
5396
+ assertObjectKeys(
5397
+ record,
5398
+ ["kind", "coverageVersion", "model", "endpoint", "timeoutMs", "maxRetries"],
5399
+ ["kind", "coverageVersion", "model", "endpoint", "timeoutMs", "maxRetries"],
5400
+ "session compatibility token estimator",
2669
5401
  );
5402
+ const estimator: InputTokenEstimatorCompatibility = {
5403
+ kind: enumFromSql(
5404
+ record.kind,
5405
+ ["moonshot-estimate-token-count-v1"] as const,
5406
+ "compatibility token estimator kind",
5407
+ ),
5408
+ coverageVersion: enumFromSql(
5409
+ record.coverageVersion,
5410
+ ["full-request-v1"] as const,
5411
+ "compatibility token estimator coverage",
5412
+ ),
5413
+ model: stringFromSql(record.model, "compatibility token estimator model"),
5414
+ endpoint: stringFromSql(record.endpoint, "compatibility token estimator endpoint"),
5415
+ timeoutMs: numberFromJson(
5416
+ record.timeoutMs,
5417
+ "compatibility token estimator timeoutMs",
5418
+ ),
5419
+ maxRetries: zeroFromJson(
5420
+ record.maxRetries,
5421
+ "compatibility token estimator maxRetries",
5422
+ ),
5423
+ };
5424
+ validateTokenEstimatorCompatibility(estimator);
5425
+ return Object.freeze(estimator);
2670
5426
  }
2671
5427
 
2672
5428
  function openWritableDatabase(databasePath: string): Database {
@@ -2696,6 +5452,250 @@ function runTransaction<T>(database: Database, operation: () => T): T {
2696
5452
  }
2697
5453
  }
2698
5454
 
5455
+ const SESSION_SCOPED_TABLES = [
5456
+ "session_meta",
5457
+ "turns",
5458
+ "iterations",
5459
+ "protocol_frames",
5460
+ "messages",
5461
+ "tool_results",
5462
+ "context_surfaces",
5463
+ "context_revisions",
5464
+ "context_overrides",
5465
+ "skill_activations",
5466
+ "context_measurement_state",
5467
+ ] as const;
5468
+
5469
+ function rekeyStoredToolCalls(database: Database, targetSessionId: SessionId): void {
5470
+ const rows = database
5471
+ .query(
5472
+ `SELECT message_id, tool_calls_json FROM messages
5473
+ WHERE tool_calls_json IS NOT NULL ORDER BY ordinal`,
5474
+ )
5475
+ .all() as Array<{ message_id: string; tool_calls_json: string }>;
5476
+ for (const row of rows) {
5477
+ const calls = decodeStoredToolCalls(row.tool_calls_json).map((call) => ({
5478
+ ...call,
5479
+ sessionId: targetSessionId,
5480
+ }));
5481
+ database
5482
+ .query("UPDATE messages SET tool_calls_json = ? WHERE message_id = ?")
5483
+ .run(stableJsonStringify(calls), row.message_id);
5484
+ }
5485
+ }
5486
+
5487
+ function rekeyProtocolView(
5488
+ source: ProtocolContextView,
5489
+ targetSessionId: SessionId,
5490
+ ): ProtocolContextView {
5491
+ return {
5492
+ sessionId: targetSessionId,
5493
+ faulted: source.faulted,
5494
+ frames: source.frames.map((frame) => ({
5495
+ ...frame,
5496
+ sessionId: targetSessionId,
5497
+ })),
5498
+ messages: source.messages.map((message) => ({
5499
+ ...message,
5500
+ sessionId: targetSessionId,
5501
+ ...(message.role === "assistant" && message.toolCalls !== undefined
5502
+ ? {
5503
+ toolCalls: message.toolCalls.map((call) => ({
5504
+ ...call,
5505
+ sessionId: targetSessionId,
5506
+ })),
5507
+ }
5508
+ : {}),
5509
+ })),
5510
+ toolResults: source.toolResults.map((result) => ({
5511
+ ...result,
5512
+ sessionId: targetSessionId,
5513
+ })),
5514
+ };
5515
+ }
5516
+
5517
+ function rewriteCloneRevisionHashes(
5518
+ database: Database,
5519
+ canonical: ProtocolContextView,
5520
+ ): void {
5521
+ const surfaces = database
5522
+ .query("SELECT * FROM context_surfaces")
5523
+ .all()
5524
+ .map(decodeContextSurface);
5525
+ const surfacesById = new Map(surfaces.map((surface) => [surface.surfaceId, surface]));
5526
+ const revisions = database
5527
+ .query("SELECT * FROM context_revisions ORDER BY revision_number")
5528
+ .all()
5529
+ .map(decodeContextRevision);
5530
+ const revisionNumberById = new Map(
5531
+ revisions.map((revision) => [revision.revisionId, revision.revisionNumber]),
5532
+ );
5533
+ const overrides = database
5534
+ .query(
5535
+ `SELECT co.* FROM context_overrides co
5536
+ JOIN context_revisions cr ON cr.revision_id = co.introduced_revision_id
5537
+ ORDER BY cr.revision_number, co.ordinal`,
5538
+ )
5539
+ .all()
5540
+ .map(decodeStoredSwapOverride);
5541
+ const compiler = new ContextRevisionCompiler();
5542
+ for (const revision of revisions) {
5543
+ const surface = surfacesById.get(revision.surfaceId);
5544
+ if (surface === undefined) {
5545
+ throw new Error(`Cloned revision ${revision.revisionId} has no surface.`);
5546
+ }
5547
+ const activeOverrides = overrides.filter(
5548
+ (override) =>
5549
+ (revisionNumberById.get(override.introducedRevisionId) ??
5550
+ Number.POSITIVE_INFINITY) <= revision.revisionNumber &&
5551
+ override.ordinal >= revision.keepFromOrdinal,
5552
+ );
5553
+ const prefix = protocolPrefixView(canonical, revision.sourceThroughOrdinal);
5554
+ const compiled = compiler.compileForIdentityRekey({
5555
+ canonical: prefix,
5556
+ revisionId: revision.revisionId,
5557
+ activeOverrides,
5558
+ keepFromOrdinal: revision.keepFromOrdinal,
5559
+ surface,
5560
+ });
5561
+ database
5562
+ .query(
5563
+ `UPDATE context_revisions
5564
+ SET canonical_sequence_sha256 = ?, rendered_message_sha256 = ?
5565
+ WHERE revision_id = ?`,
5566
+ )
5567
+ .run(
5568
+ canonicalSequenceHash(canonical, revision.sourceThroughOrdinal),
5569
+ renderedMessageHash(compiled.entries, revision.sourceThroughOrdinal),
5570
+ revision.revisionId,
5571
+ );
5572
+ }
5573
+ }
5574
+
5575
+ async function cloneDiagnosticFiles(input: {
5576
+ sourceDirectory: string;
5577
+ stagingDirectory: string;
5578
+ sourceSessionId: SessionId;
5579
+ targetSessionId: SessionId;
5580
+ nextEventSequence: number;
5581
+ faultInjector?: (stage: CloneSessionFaultStage) => void;
5582
+ }): Promise<void> {
5583
+ const sourcePath = path.join(input.sourceDirectory, "events.jsonl");
5584
+ try {
5585
+ await lstat(sourcePath);
5586
+ } catch (error) {
5587
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
5588
+ return;
5589
+ }
5590
+ throw error;
5591
+ }
5592
+ await validateSecureFile(sourcePath, input.sourceSessionId);
5593
+
5594
+ const bytes = await readFile(sourcePath);
5595
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
5596
+ const rawLines = text.split("\n");
5597
+ if (rawLines.at(-1) === "") {
5598
+ rawLines.pop();
5599
+ }
5600
+ const events: AgentEvent[] = [];
5601
+ let previousSequence = 0;
5602
+ for (const [index, line] of rawLines.entries()) {
5603
+ if (line === "") {
5604
+ throw new Error(`Session event log contains an empty line at ${index + 1}.`);
5605
+ }
5606
+ let value: unknown;
5607
+ try {
5608
+ value = JSON.parse(line);
5609
+ } catch (error) {
5610
+ throw new Error(`Session event log has invalid JSON at line ${index + 1}.`, {
5611
+ cause: error,
5612
+ });
5613
+ }
5614
+ if (!isEventEnvelope(value)) {
5615
+ throw new Error(
5616
+ `Session event log has an invalid envelope at line ${index + 1}.`,
5617
+ );
5618
+ }
5619
+ if (value.sessionId !== input.sourceSessionId) {
5620
+ throw new Error(`Session event log identity changed at line ${index + 1}.`);
5621
+ }
5622
+ if (value.eventSequence <= previousSequence) {
5623
+ throw new Error(
5624
+ `Session event sequence is not strictly increasing at line ${index + 1}.`,
5625
+ );
5626
+ }
5627
+ if (value.eventSequence >= input.nextEventSequence) {
5628
+ throw new Error(
5629
+ `Session event sequence exceeds the canonical next counter at line ${index + 1}.`,
5630
+ );
5631
+ }
5632
+ previousSequence = value.eventSequence;
5633
+ events.push({ ...value, sessionId: input.targetSessionId });
5634
+ }
5635
+
5636
+ const eventText = events.map((event) => JSON.stringify(event)).join("\n");
5637
+ await writePrivateNewFile(
5638
+ path.join(input.stagingDirectory, "events.jsonl"),
5639
+ eventText === "" ? "" : `${eventText}\n`,
5640
+ );
5641
+ input.faultInjector?.("after_event_rewrite");
5642
+ const observationText = events
5643
+ .map((event) => renderObservationLogEvent(event))
5644
+ .filter((block): block is string => block !== undefined)
5645
+ .join("");
5646
+ await writePrivateNewFile(
5647
+ path.join(input.stagingDirectory, "observations.md"),
5648
+ observationText,
5649
+ );
5650
+ input.faultInjector?.("after_observation_render");
5651
+ }
5652
+
5653
+ function isEventEnvelope(value: unknown): value is AgentEvent {
5654
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
5655
+ return false;
5656
+ }
5657
+ const record = value as Record<string, unknown>;
5658
+ return (
5659
+ typeof record.sessionId === "string" &&
5660
+ Number.isSafeInteger(record.eventSequence) &&
5661
+ Number(record.eventSequence) >= 1 &&
5662
+ typeof record.timestamp === "string" &&
5663
+ typeof record.type === "string" &&
5664
+ record.data !== null &&
5665
+ typeof record.data === "object"
5666
+ );
5667
+ }
5668
+
5669
+ async function writePrivateNewFile(filePath: string, content: string): Promise<void> {
5670
+ const handle = await open(filePath, "wx", 0o600);
5671
+ try {
5672
+ await handle.writeFile(content, "utf8");
5673
+ } finally {
5674
+ await handle.close();
5675
+ }
5676
+ await chmod(filePath, 0o600);
5677
+ }
5678
+
5679
+ async function assertPathMissing(
5680
+ filePath: string,
5681
+ sessionId: SessionId,
5682
+ ): Promise<void> {
5683
+ try {
5684
+ await lstat(filePath);
5685
+ } catch (error) {
5686
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
5687
+ return;
5688
+ }
5689
+ throw error;
5690
+ }
5691
+ throw new SessionError(
5692
+ "SESSION_ALREADY_EXISTS",
5693
+ "clone_session",
5694
+ `Session directory already exists: ${filePath}.`,
5695
+ { sessionId },
5696
+ );
5697
+ }
5698
+
2699
5699
  async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
2700
5700
  if (!path.isAbsolute(workspaceRoot)) {
2701
5701
  throw new Error("Session workspace root must be absolute.");
@@ -3020,6 +6020,13 @@ function numberFromJson(value: unknown, name: string): number {
3020
6020
  return value as number;
3021
6021
  }
3022
6022
 
6023
+ function zeroFromJson(value: unknown, name: string): 0 {
6024
+ if (value !== 0) {
6025
+ throw new Error(`${name} must be 0.`);
6026
+ }
6027
+ return 0;
6028
+ }
6029
+
3023
6030
  function enumFromSql<const T extends readonly string[]>(
3024
6031
  value: unknown,
3025
6032
  values: T,
@@ -3058,6 +6065,10 @@ function requireItem<T>(items: readonly T[], index: number, name: string): T {
3058
6065
  return item;
3059
6066
  }
3060
6067
 
6068
+ function compareCanonicalText(left: string, right: string): number {
6069
+ return left < right ? -1 : left > right ? 1 : 0;
6070
+ }
6071
+
3061
6072
  function errorMessage(error: unknown): string {
3062
6073
  return error instanceof Error ? error.message : String(error);
3063
6074
  }