tinker-agent 2.0.0 → 2.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 (51) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +27 -2
  3. package/package.json +2 -1
  4. package/src/agent/context-meter.ts +2 -4
  5. package/src/agent/runtime-session.ts +9 -2
  6. package/src/agent/session-ledger.ts +12 -5
  7. package/src/agent/tool-result-content.ts +76 -0
  8. package/src/agent/types.ts +14 -2
  9. package/src/cli/config.ts +4 -0
  10. package/src/cli/model-profiles.ts +41 -2
  11. package/src/cli/public-config-contract.ts +30 -7
  12. package/src/cli/runner-dependencies.ts +5 -0
  13. package/src/cli/tui-memory.ts +1 -0
  14. package/src/cli/tui-runner.tsx +4 -0
  15. package/src/context/compiled-context-hash.ts +2 -1
  16. package/src/context/compiled-context-validator.ts +13 -4
  17. package/src/context/context-protocol-validator.ts +33 -2
  18. package/src/context/context-revision-compiler.ts +2 -1
  19. package/src/context/context-revision.ts +8 -2
  20. package/src/context/context-swap-renderer.ts +46 -12
  21. package/src/context/prefix-retirement-planner.ts +13 -9
  22. package/src/context/protocol-frame.ts +74 -7
  23. package/src/context/swap-planner.ts +19 -14
  24. package/src/events/observation-text-log.ts +1 -1
  25. package/src/events/stdout-event-printer.ts +6 -0
  26. package/src/image/image-asset-store.ts +32 -3
  27. package/src/memory/contracts.ts +63 -3
  28. package/src/memory/memory-coordinator.ts +319 -49
  29. package/src/memory/memory-extractor.ts +48 -48
  30. package/src/memory/memory-get-tool.ts +86 -0
  31. package/src/memory/memory-search-tool.ts +122 -33
  32. package/src/memory/memory-store.ts +227 -20
  33. package/src/model/fake-model-client.ts +129 -76
  34. package/src/model/model-client.ts +62 -11
  35. package/src/model/openai-chat-mapping.ts +2 -1
  36. package/src/model/openai-chat-model-client.ts +22 -10
  37. package/src/model/openai-model-utils.ts +61 -30
  38. package/src/model/openai-responses-mapping.ts +25 -1
  39. package/src/model/openai-responses-model-client.ts +27 -11
  40. package/src/model/token-estimator.ts +10 -0
  41. package/src/observation/observation-builder.ts +100 -25
  42. package/src/session/session-history-reader.ts +128 -5
  43. package/src/session/session-schema.ts +59 -9
  44. package/src/session/session-store.ts +343 -196
  45. package/src/tools/registry.ts +18 -0
  46. package/src/tools/types.ts +46 -0
  47. package/src/tools/view-image.ts +89 -0
  48. package/src/tools/wait.ts +85 -0
  49. package/src/tui/components/memory-browser.tsx +3 -0
  50. package/src/tui/components/prompt-input.tsx +48 -25
  51. package/src/tui/event-store.ts +61 -2
@@ -50,6 +50,7 @@ import {
50
50
  import {
51
51
  ContextSwapRenderer,
52
52
  SWAP_OBSERVATION_FORMAT,
53
+ SWAP_TOOL_IMAGE_FORMAT,
53
54
  } from "../context/context-swap-renderer";
54
55
  import {
55
56
  SUPPORTED_TOOL_OBSERVATION_FORMATS,
@@ -78,10 +79,16 @@ import type {
78
79
  StoredContextOverrideV8,
79
80
  SwapOverride,
80
81
  } from "../context/context-revision";
81
- import type { IterationIdentity, ToolCall } from "../agent/types";
82
+ import type { IterationIdentity, ToolCall, ToolResultContent } from "../agent/types";
83
+ import {
84
+ canonicalToolResultContentHash,
85
+ toolResultDisplayText,
86
+ validateToolResultContent,
87
+ } from "../agent/tool-result-content";
82
88
  import {
83
89
  parseImageAssetId,
84
90
  parseImageAttachmentId,
91
+ validateImageAssetRef,
85
92
  validateOriginalImageName,
86
93
  validateUserMessage,
87
94
  type ImageAssetRef,
@@ -108,7 +115,7 @@ import {
108
115
  } from "./session-history-reader";
109
116
  import { SessionLease } from "./session-lock";
110
117
  import {
111
- SESSION_SCHEMA_V9_FINGERPRINT,
118
+ SESSION_SCHEMA_V10_FINGERPRINT,
112
119
  SESSION_SCHEMA_VERSION,
113
120
  upgradeActiveTurnRetirementContract,
114
121
  upgradeRecallIndexContract,
@@ -144,20 +151,11 @@ import {
144
151
  IMAGE_INPUT_POLICY_VERSION,
145
152
  } from "../image/image-input-policy";
146
153
 
147
- export type SessionImageInputCompatibility = {
154
+ export type SessionMediaCompatibility = {
148
155
  readonly policyVersion: string;
149
156
  readonly policySha256: string;
150
157
  readonly inputModalities: readonly ("text" | "image")[];
151
- readonly tokenEstimator?: LegacyInputTokenEstimatorCompatibility;
152
- };
153
-
154
- type LegacyInputTokenEstimatorCompatibility = {
155
- readonly kind: "moonshot-estimate-token-count-v1";
156
- readonly coverageVersion: "full-request-v1";
157
- readonly model: string;
158
- readonly endpoint: string;
159
- readonly timeoutMs: number;
160
- readonly maxRetries: 0;
158
+ readonly toolResultModalities: readonly ("text" | "image")[];
161
159
  };
162
160
 
163
161
  export type CompletedTurnMessageSnapshot =
@@ -189,11 +187,11 @@ export type SessionCompatibilityContract = {
189
187
  includeReasoningContent: boolean;
190
188
  contextProfile: ModelContextProfile;
191
189
  messageProtocol: ModelMessageProtocol;
192
- imageInput: SessionImageInputCompatibility;
190
+ media: SessionMediaCompatibility;
193
191
  };
194
192
 
195
- export type StoredSessionMetaV9 = {
196
- schemaVersion: 9;
193
+ export type StoredSessionMetaV10 = {
194
+ schemaVersion: 10;
197
195
  schemaFingerprint: string;
198
196
  initializationState: "creating" | "ready";
199
197
  sessionId: SessionId;
@@ -220,7 +218,7 @@ export type StoredSessionMetaV9 = {
220
218
  | null;
221
219
  };
222
220
 
223
- export type SessionCloseReason = NonNullable<StoredSessionMetaV9["lastCloseReason"]>;
221
+ export type SessionCloseReason = NonNullable<StoredSessionMetaV10["lastCloseReason"]>;
224
222
 
225
223
  export type SessionRecoveryResult = {
226
224
  recoveredTurnId?: TurnId;
@@ -517,7 +515,7 @@ export class SessionStore implements SessionLedgerCommitter {
517
515
  )
518
516
  .run(
519
517
  SESSION_SCHEMA_VERSION,
520
- SESSION_SCHEMA_V9_FINGERPRINT,
518
+ SESSION_SCHEMA_V10_FINGERPRINT,
521
519
  input.sessionId,
522
520
  workspaceRoot,
523
521
  input.modelName,
@@ -1700,7 +1698,7 @@ export class SessionStore implements SessionLedgerCommitter {
1700
1698
  messageId: message.messageId,
1701
1699
  frameId: message.frameId,
1702
1700
  ordinal: message.ordinal,
1703
- content: message.content,
1701
+ content: message.displayText,
1704
1702
  contentSha256: message.contentSha256,
1705
1703
  },
1706
1704
  name: activation.name,
@@ -2084,13 +2082,14 @@ export class SessionStore implements SessionLedgerCommitter {
2084
2082
  for (const input of completionInputs) {
2085
2083
  const createdAt = this.clock();
2086
2084
  const content = observationForCompletion(input);
2085
+ const displayText = toolResultDisplayText(content);
2087
2086
  const messageId = idFactory.createMessageId();
2088
2087
  const message = immutableRecord<CanonicalMessageRecord>({
2089
2088
  messageId,
2090
2089
  sessionId: this.sessionId,
2091
2090
  frameId: frame.frameId,
2092
2091
  ordinal: view.messages.length + messages.length + 1,
2093
- contentSha256: contentHash(content),
2092
+ contentSha256: canonicalToolResultContentHash(content),
2094
2093
  createdAt,
2095
2094
  role: "tool",
2096
2095
  turnId,
@@ -2099,6 +2098,7 @@ export class SessionStore implements SessionLedgerCommitter {
2099
2098
  providerToolCallId: input.call.providerToolCallId,
2100
2099
  name: input.call.name,
2101
2100
  content,
2101
+ displayText,
2102
2102
  origin: "runtime",
2103
2103
  });
2104
2104
  const completion: ToolCompletion = immutableRecord({
@@ -2111,7 +2111,7 @@ export class SessionStore implements SessionLedgerCommitter {
2111
2111
  toolCallId: input.call.toolCallId,
2112
2112
  toolMessageId: messageId,
2113
2113
  completion,
2114
- observationSha256: contentHash(content),
2114
+ observationSha256: canonicalToolResultContentHash(content),
2115
2115
  createdAt,
2116
2116
  });
2117
2117
  messages.push(message);
@@ -2297,6 +2297,7 @@ export class SessionStore implements SessionLedgerCommitter {
2297
2297
  loadProtocolView(): ProtocolContextView {
2298
2298
  this.requireOpen();
2299
2299
  const imageAttachments = loadMessageImageAttachments(this.database);
2300
+ const toolContentBlocks = loadToolMessageContentBlocks(this.database);
2300
2301
  const frames = this.database
2301
2302
  .query("SELECT * FROM protocol_frames ORDER BY first_ordinal")
2302
2303
  .all()
@@ -2307,13 +2308,21 @@ export class SessionStore implements SessionLedgerCommitter {
2307
2308
  .map((row) => {
2308
2309
  const record = recordFromSql(row, "message");
2309
2310
  const messageId = stringFromSql(record.message_id, "message_id");
2310
- const message = decodeMessage(row, imageAttachments.get(messageId));
2311
+ const message = decodeMessage(
2312
+ row,
2313
+ imageAttachments.get(messageId),
2314
+ toolContentBlocks.get(messageId),
2315
+ );
2311
2316
  imageAttachments.delete(messageId);
2317
+ toolContentBlocks.delete(messageId);
2312
2318
  return message;
2313
2319
  });
2314
2320
  if (imageAttachments.size > 0) {
2315
2321
  throw new Error("Image attachment rows reference unknown messages.");
2316
2322
  }
2323
+ if (toolContentBlocks.size > 0) {
2324
+ throw new Error("Tool content block rows reference unknown messages.");
2325
+ }
2317
2326
  const toolResults = this.database
2318
2327
  .query(
2319
2328
  `SELECT tr.* FROM tool_results tr
@@ -2335,21 +2344,23 @@ export class SessionStore implements SessionLedgerCommitter {
2335
2344
  this.requireOpen();
2336
2345
  const distinct = new Map<string, ImageAssetRef>();
2337
2346
  for (const message of this.loadProtocolView().messages) {
2338
- if (message.role !== "user") {
2339
- continue;
2340
- }
2341
- for (const attachment of message.attachments ?? []) {
2342
- const previous = distinct.get(attachment.assetId);
2343
- const asset = imageAssetRefFromAttachment(attachment);
2347
+ const assets =
2348
+ message.role === "user"
2349
+ ? (message.attachments ?? []).map(imageAssetRefFromAttachment)
2350
+ : message.role === "tool"
2351
+ ? message.content.flatMap((block) =>
2352
+ block.type === "image" ? [block.asset] : [],
2353
+ )
2354
+ : [];
2355
+ for (const asset of assets) {
2356
+ const previous = distinct.get(asset.assetId);
2344
2357
  if (
2345
2358
  previous !== undefined &&
2346
2359
  stableJsonStringify(previous) !== stableJsonStringify(asset)
2347
2360
  ) {
2348
- throw new Error(
2349
- `Conflicting metadata for image asset ${attachment.assetId}.`,
2350
- );
2361
+ throw new Error(`Conflicting metadata for image asset ${asset.assetId}.`);
2351
2362
  }
2352
- distinct.set(attachment.assetId, asset);
2363
+ distinct.set(asset.assetId, asset);
2353
2364
  }
2354
2365
  }
2355
2366
  if (distinct.size === 0) {
@@ -2367,7 +2378,7 @@ export class SessionStore implements SessionLedgerCommitter {
2367
2378
  try {
2368
2379
  if (
2369
2380
  meta.sessionId !== this.sessionId ||
2370
- meta.schemaFingerprint !== SESSION_SCHEMA_V9_FINGERPRINT
2381
+ meta.schemaFingerprint !== SESSION_SCHEMA_V10_FINGERPRINT
2371
2382
  ) {
2372
2383
  throw new Error("Session metadata identity or schema fingerprint changed.");
2373
2384
  }
@@ -2413,7 +2424,7 @@ export class SessionStore implements SessionLedgerCommitter {
2413
2424
  }
2414
2425
  }
2415
2426
 
2416
- readMeta(): StoredSessionMetaV9 {
2427
+ readMeta(): StoredSessionMetaV10 {
2417
2428
  this.requireOpen();
2418
2429
  const rows = this.database.query("SELECT * FROM session_meta").all();
2419
2430
  if (rows.length !== 1) {
@@ -2549,7 +2560,7 @@ export class SessionStore implements SessionLedgerCommitter {
2549
2560
  const meta = this.readMeta();
2550
2561
  if (
2551
2562
  meta.sessionId !== this.sessionId ||
2552
- meta.schemaFingerprint !== SESSION_SCHEMA_V9_FINGERPRINT ||
2563
+ meta.schemaFingerprint !== SESSION_SCHEMA_V10_FINGERPRINT ||
2553
2564
  meta.initializationState !== "ready" ||
2554
2565
  meta.activeRevisionId === null
2555
2566
  ) {
@@ -3047,7 +3058,7 @@ export class SessionStore implements SessionLedgerCommitter {
3047
3058
  }
3048
3059
 
3049
3060
  private loadValidatedContextSnapshot(
3050
- meta: StoredSessionMetaV9,
3061
+ meta: StoredSessionMetaV10,
3051
3062
  canonical: ProtocolContextView,
3052
3063
  ): StoredContextSnapshotV8 {
3053
3064
  const activeRevisionId = requireActiveRevisionId(meta);
@@ -3333,7 +3344,8 @@ export class SessionStore implements SessionLedgerCommitter {
3333
3344
  if (
3334
3345
  (revision?.kind !== "swap_only" && revision?.kind !== "skills_update") ||
3335
3346
  (revision.kind === "swap_only" &&
3336
- override.rendererFormat !== SWAP_OBSERVATION_FORMAT) ||
3347
+ override.rendererFormat !== SWAP_OBSERVATION_FORMAT &&
3348
+ override.rendererFormat !== SWAP_TOOL_IMAGE_FORMAT) ||
3337
3349
  (revision.kind === "skills_update" &&
3338
3350
  override.rendererFormat !== SKILL_ACTIVATION_RECEIPT_FORMAT) ||
3339
3351
  revisionNumberById.get(revision.revisionId) === undefined ||
@@ -3350,7 +3362,8 @@ export class SessionStore implements SessionLedgerCommitter {
3350
3362
  throw new Error("Stored context override canonical identity is invalid.");
3351
3363
  }
3352
3364
  const rendered =
3353
- override.rendererFormat === SWAP_OBSERVATION_FORMAT
3365
+ override.rendererFormat === SWAP_OBSERVATION_FORMAT ||
3366
+ override.rendererFormat === SWAP_TOOL_IMAGE_FORMAT
3354
3367
  ? this.swapRenderer.render({ message, result })
3355
3368
  : this.renderStoredSkillReceipt(message, result, revision.revisionId);
3356
3369
  if (
@@ -3469,7 +3482,7 @@ export class SessionStore implements SessionLedgerCommitter {
3469
3482
  messageId: message.messageId,
3470
3483
  frameId: message.frameId,
3471
3484
  ordinal: message.ordinal,
3472
- content: message.content,
3485
+ content: message.displayText,
3473
3486
  contentSha256: message.contentSha256,
3474
3487
  },
3475
3488
  name: activation.name,
@@ -3495,7 +3508,10 @@ export class SessionStore implements SessionLedgerCommitter {
3495
3508
  : decodeMeasuredContextState(row, this.sessionId);
3496
3509
  }
3497
3510
 
3498
- private validateCounters(meta: StoredSessionMetaV9, view: ProtocolContextView): void {
3511
+ private validateCounters(
3512
+ meta: StoredSessionMetaV10,
3513
+ view: ProtocolContextView,
3514
+ ): void {
3499
3515
  const turns = this.database
3500
3516
  .query("SELECT * FROM turns ORDER BY turn_number")
3501
3517
  .all() as Array<Record<string, unknown>>;
@@ -3711,7 +3727,8 @@ export function createSessionCompatibilityContract(input: {
3711
3727
  includeReasoningContent: boolean;
3712
3728
  contextProfile: ModelContextProfile;
3713
3729
  messageProtocol: ModelMessageProtocol;
3714
- inputModalities?: readonly ("text" | "image")[];
3730
+ inputModalities: readonly ("text" | "image")[];
3731
+ toolResultModalities: readonly ("text" | "image")[];
3715
3732
  }): SessionCompatibilityContract {
3716
3733
  if (input.modelName.trim() === "") {
3717
3734
  throw new Error("Session compatibility model name must not be empty.");
@@ -3728,14 +3745,18 @@ export function createSessionCompatibilityContract(input: {
3728
3745
  ) {
3729
3746
  throw new Error("Session compatibility message protocol is invalid.");
3730
3747
  }
3731
- const inputModalities = normalizeInputModalities(input.inputModalities ?? ["text"]);
3748
+ const inputModalities = normalizeInputModalities(input.inputModalities);
3749
+ const toolResultModalities = normalizeInputModalities(input.toolResultModalities);
3750
+ if (toolResultModalities.includes("image") && !inputModalities.includes("image")) {
3751
+ throw new Error("Session compatibility image tool results require image input.");
3752
+ }
3732
3753
  return Object.freeze({
3733
3754
  modelName: input.modelName,
3734
3755
  ...(input.profileName === undefined ? {} : { profileName: input.profileName }),
3735
3756
  includeReasoningContent: input.includeReasoningContent,
3736
3757
  contextProfile: Object.freeze(createModelContextProfile(input.contextProfile)),
3737
3758
  messageProtocol: immutableCanonicalClone(input.messageProtocol),
3738
- imageInput: Object.freeze({
3759
+ media: Object.freeze({
3739
3760
  policyVersion: IMAGE_INPUT_POLICY_VERSION,
3740
3761
  policySha256: sha256(
3741
3762
  stableJsonStringify({
@@ -3744,6 +3765,7 @@ export function createSessionCompatibilityContract(input: {
3744
3765
  }),
3745
3766
  ),
3746
3767
  inputModalities,
3768
+ toolResultModalities,
3747
3769
  }),
3748
3770
  });
3749
3771
  }
@@ -3759,7 +3781,8 @@ function normalizeSessionCompatibilityContract(
3759
3781
  includeReasoningContent: contract.includeReasoningContent,
3760
3782
  contextProfile: contract.contextProfile,
3761
3783
  messageProtocol: contract.messageProtocol,
3762
- inputModalities: contract.imageInput.inputModalities,
3784
+ inputModalities: contract.media.inputModalities,
3785
+ toolResultModalities: contract.media.toolResultModalities,
3763
3786
  });
3764
3787
  }
3765
3788
 
@@ -3779,40 +3802,6 @@ function normalizeInputModalities(
3779
3802
  );
3780
3803
  }
3781
3804
 
3782
- function validateTokenEstimatorCompatibility(
3783
- estimator: LegacyInputTokenEstimatorCompatibility,
3784
- ): void {
3785
- if (
3786
- estimator.kind !== "moonshot-estimate-token-count-v1" ||
3787
- estimator.coverageVersion !== "full-request-v1" ||
3788
- estimator.model.trim() === "" ||
3789
- !isNormalizedEstimatorEndpoint(estimator.endpoint) ||
3790
- !Number.isSafeInteger(estimator.timeoutMs) ||
3791
- estimator.timeoutMs < 1_000 ||
3792
- estimator.timeoutMs > 60_000 ||
3793
- estimator.maxRetries !== 0
3794
- ) {
3795
- throw new Error("Session compatibility token estimator identity is invalid.");
3796
- }
3797
- }
3798
-
3799
- function isNormalizedEstimatorEndpoint(value: string): boolean {
3800
- try {
3801
- const url = new URL(value);
3802
- return (
3803
- (url.protocol === "https:" || url.protocol === "http:") &&
3804
- url.username === "" &&
3805
- url.password === "" &&
3806
- url.search === "" &&
3807
- url.hash === "" &&
3808
- url.pathname.endsWith("/tokenizers/estimate-token-count") &&
3809
- url.toString() === value
3810
- );
3811
- } catch {
3812
- return false;
3813
- }
3814
- }
3815
-
3816
3805
  export function sessionDatabasePath(
3817
3806
  workspaceRoot: string,
3818
3807
  sessionId: SessionId,
@@ -3866,7 +3855,7 @@ function insertMessage(database: Database, message: CanonicalMessageRecord): voi
3866
3855
  message.role,
3867
3856
  turnId,
3868
3857
  iterationId,
3869
- message.content,
3858
+ message.role === "tool" ? message.displayText : message.content,
3870
3859
  message.contentSha256,
3871
3860
  assistant?.reasoningContent ?? null,
3872
3861
  reasoningPresent,
@@ -3884,6 +3873,9 @@ function insertMessage(database: Database, message: CanonicalMessageRecord): voi
3884
3873
  if (message.role === "user" && message.attachments !== undefined) {
3885
3874
  insertMessageImageAttachments(database, message);
3886
3875
  }
3876
+ if (message.role === "tool") {
3877
+ insertToolMessageContentBlocks(database, message);
3878
+ }
3887
3879
  }
3888
3880
 
3889
3881
  function insertMessageImageAttachments(
@@ -3901,45 +3893,11 @@ function insertMessageImageAttachments(
3901
3893
  }
3902
3894
  for (let position = 0; position < message.attachments!.length; position += 1) {
3903
3895
  const attachment = requireItem(message.attachments!, position, "image attachment");
3904
- const existing = database
3905
- .query(
3906
- `SELECT mime_type, byte_length, width, height, created_at
3907
- FROM image_assets WHERE asset_id = ?`,
3908
- )
3909
- .get(attachment.assetId) as {
3910
- mime_type: unknown;
3911
- byte_length: unknown;
3912
- width: unknown;
3913
- height: unknown;
3914
- created_at: unknown;
3915
- } | null;
3916
- if (existing === null) {
3917
- database
3918
- .query(
3919
- `INSERT INTO image_assets (
3920
- asset_id, mime_type, byte_length, width, height, created_at
3921
- ) VALUES (?, ?, ?, ?, ?, ?)`,
3922
- )
3923
- .run(
3924
- attachment.assetId,
3925
- attachment.mimeType,
3926
- attachment.byteLength,
3927
- attachment.width,
3928
- attachment.height,
3929
- message.createdAt,
3930
- );
3931
- } else {
3932
- timestampFromSql(existing.created_at, "image asset created_at");
3933
- if (
3934
- existing.mime_type !== attachment.mimeType ||
3935
- numberFromSql(existing.byte_length, "image asset byte_length") !==
3936
- attachment.byteLength ||
3937
- numberFromSql(existing.width, "image asset width") !== attachment.width ||
3938
- numberFromSql(existing.height, "image asset height") !== attachment.height
3939
- ) {
3940
- throw new Error(`Image asset metadata conflicts for ${attachment.assetId}.`);
3941
- }
3942
- }
3896
+ ensureImageAsset(
3897
+ database,
3898
+ imageAssetRefFromAttachment(attachment),
3899
+ message.createdAt,
3900
+ );
3943
3901
  database
3944
3902
  .query(
3945
3903
  `INSERT INTO message_image_attachments (
@@ -3960,6 +3918,84 @@ function insertMessageImageAttachments(
3960
3918
  }
3961
3919
  }
3962
3920
 
3921
+ function insertToolMessageContentBlocks(
3922
+ database: Database,
3923
+ message: Extract<CanonicalMessageRecord, { role: "tool" }>,
3924
+ ): void {
3925
+ validateToolResultContent(message.content);
3926
+ if (
3927
+ canonicalToolResultContentHash(message.content) !== message.contentSha256 ||
3928
+ toolResultDisplayText(message.content) !== message.displayText
3929
+ ) {
3930
+ throw new Error("Tool content blocks do not match canonical message metadata.");
3931
+ }
3932
+ for (let position = 0; position < message.content.length; position += 1) {
3933
+ const block = requireItem(message.content, position, "tool content block");
3934
+ if (block.type === "image") {
3935
+ ensureImageAsset(database, block.asset, message.createdAt);
3936
+ }
3937
+ database
3938
+ .query(
3939
+ `INSERT INTO tool_message_content_blocks (
3940
+ message_id, position, kind, text_content, asset_id
3941
+ ) VALUES (?, ?, ?, ?, ?)`,
3942
+ )
3943
+ .run(
3944
+ message.messageId,
3945
+ position,
3946
+ block.type,
3947
+ block.type === "text" ? block.text : null,
3948
+ block.type === "image" ? block.asset.assetId : null,
3949
+ );
3950
+ }
3951
+ }
3952
+
3953
+ function ensureImageAsset(
3954
+ database: Database,
3955
+ asset: ImageAssetRef,
3956
+ createdAt: string,
3957
+ ): void {
3958
+ const existing = database
3959
+ .query(
3960
+ `SELECT mime_type, byte_length, width, height, created_at
3961
+ FROM image_assets WHERE asset_id = ?`,
3962
+ )
3963
+ .get(asset.assetId) as {
3964
+ mime_type: unknown;
3965
+ byte_length: unknown;
3966
+ width: unknown;
3967
+ height: unknown;
3968
+ created_at: unknown;
3969
+ } | null;
3970
+ if (existing === null) {
3971
+ database
3972
+ .query(
3973
+ `INSERT INTO image_assets (
3974
+ asset_id, mime_type, byte_length, width, height, created_at
3975
+ ) VALUES (?, ?, ?, ?, ?, ?)`,
3976
+ )
3977
+ .run(
3978
+ asset.assetId,
3979
+ asset.mimeType,
3980
+ asset.byteLength,
3981
+ asset.width,
3982
+ asset.height,
3983
+ createdAt,
3984
+ );
3985
+ return;
3986
+ }
3987
+ timestampFromSql(existing.created_at, "image asset created_at");
3988
+ if (
3989
+ existing.mime_type !== asset.mimeType ||
3990
+ numberFromSql(existing.byte_length, "image asset byte_length") !==
3991
+ asset.byteLength ||
3992
+ numberFromSql(existing.width, "image asset width") !== asset.width ||
3993
+ numberFromSql(existing.height, "image asset height") !== asset.height
3994
+ ) {
3995
+ throw new Error(`Image asset metadata conflicts for ${asset.assetId}.`);
3996
+ }
3997
+ }
3998
+
3963
3999
  function insertToolResult(database: Database, result: ToolResultRecord): void {
3964
4000
  const returned = result.completion.kind === "returned" ? result.completion : null;
3965
4001
  const synthetic = result.completion.kind === "synthetic" ? result.completion : null;
@@ -4113,9 +4149,74 @@ function loadMessageImageAttachments(
4113
4149
  );
4114
4150
  }
4115
4151
 
4152
+ function loadToolMessageContentBlocks(
4153
+ database: Database,
4154
+ ): Map<string, readonly ToolResultContent[]> {
4155
+ const rows = database
4156
+ .query(
4157
+ `SELECT tcb.*, ia.mime_type, ia.byte_length, ia.width, ia.height,
4158
+ ia.created_at AS asset_created_at
4159
+ FROM tool_message_content_blocks tcb
4160
+ LEFT JOIN image_assets ia ON ia.asset_id = tcb.asset_id
4161
+ JOIN messages m ON m.message_id = tcb.message_id
4162
+ ORDER BY m.ordinal, tcb.position`,
4163
+ )
4164
+ .all();
4165
+ const mutable = new Map<string, ToolResultContent[]>();
4166
+ for (const rowValue of rows) {
4167
+ const row = recordFromSql(rowValue, "tool message content block");
4168
+ const messageId = stringFromSql(row.message_id, "tool block message_id");
4169
+ const blocks = mutable.get(messageId) ?? [];
4170
+ const position = numberFromSql(row.position, "tool block position");
4171
+ if (position !== blocks.length) {
4172
+ throw new Error(
4173
+ `Tool content block positions for message ${messageId} are not continuous.`,
4174
+ );
4175
+ }
4176
+ const kind = enumFromSql(row.kind, ["text", "image"] as const, "tool block kind");
4177
+ const block: ToolResultContent =
4178
+ kind === "text"
4179
+ ? immutableRecord({
4180
+ type: "text",
4181
+ text: stringFromSql(row.text_content, "tool block text_content"),
4182
+ })
4183
+ : immutableRecord({
4184
+ type: "image",
4185
+ asset: immutableRecord({
4186
+ assetId: parseImageAssetId(
4187
+ stringFromSql(row.asset_id, "tool block asset_id"),
4188
+ ),
4189
+ mimeType: enumFromSql(
4190
+ row.mime_type,
4191
+ ["image/png", "image/jpeg", "image/webp"] as const,
4192
+ "tool block image mime_type",
4193
+ ),
4194
+ byteLength: numberFromSql(
4195
+ row.byte_length,
4196
+ "tool block image byte_length",
4197
+ ),
4198
+ width: numberFromSql(row.width, "tool block image width"),
4199
+ height: numberFromSql(row.height, "tool block image height"),
4200
+ }),
4201
+ });
4202
+ if (kind === "image") {
4203
+ timestampFromSql(row.asset_created_at, "tool block image asset created_at");
4204
+ }
4205
+ blocks.push(block);
4206
+ mutable.set(messageId, blocks);
4207
+ }
4208
+ return new Map(
4209
+ [...mutable].map(([messageId, blocks]) => {
4210
+ validateToolResultContent(blocks);
4211
+ return [messageId, Object.freeze(blocks)] as const;
4212
+ }),
4213
+ );
4214
+ }
4215
+
4116
4216
  function decodeMessage(
4117
4217
  rowValue: unknown,
4118
4218
  attachments?: readonly UserImageAttachment[],
4219
+ toolContentBlocks?: readonly ToolResultContent[],
4119
4220
  ): CanonicalMessageRecord {
4120
4221
  const row = recordFromSql(rowValue, "message");
4121
4222
  const base = {
@@ -4133,8 +4234,8 @@ function decodeMessage(
4133
4234
  );
4134
4235
  switch (role) {
4135
4236
  case "system":
4136
- if (attachments !== undefined) {
4137
- throw new Error("System messages cannot have image attachments.");
4237
+ if (attachments !== undefined || toolContentBlocks !== undefined) {
4238
+ throw new Error("System messages cannot have media relation rows.");
4138
4239
  }
4139
4240
  return immutableRecord({
4140
4241
  ...base,
@@ -4143,6 +4244,9 @@ function decodeMessage(
4143
4244
  origin: "runtime",
4144
4245
  });
4145
4246
  case "user": {
4247
+ if (toolContentBlocks !== undefined) {
4248
+ throw new Error("User messages cannot have tool content blocks.");
4249
+ }
4146
4250
  const message = {
4147
4251
  ...base,
4148
4252
  role,
@@ -4161,8 +4265,8 @@ function decodeMessage(
4161
4265
  return immutableRecord(message);
4162
4266
  }
4163
4267
  case "assistant": {
4164
- if (attachments !== undefined) {
4165
- throw new Error("Assistant messages cannot have image attachments.");
4268
+ if (attachments !== undefined || toolContentBlocks !== undefined) {
4269
+ throw new Error("Assistant messages cannot have media relation rows.");
4166
4270
  }
4167
4271
  const content = nullableTextFromSql(row.content, "content");
4168
4272
  const reasoningPresent = numberFromSql(
@@ -4198,10 +4302,18 @@ function decodeMessage(
4198
4302
  origin: "model",
4199
4303
  });
4200
4304
  }
4201
- case "tool":
4305
+ case "tool": {
4202
4306
  if (attachments !== undefined) {
4203
4307
  throw new Error("Tool messages cannot have image attachments.");
4204
4308
  }
4309
+ if (toolContentBlocks === undefined) {
4310
+ throw new Error("Tool messages must have persisted content blocks.");
4311
+ }
4312
+ validateToolResultContent(toolContentBlocks);
4313
+ const displayText = stringFromSql(row.content, "content");
4314
+ if (toolResultDisplayText(toolContentBlocks) !== displayText) {
4315
+ throw new Error("Tool message display projection does not match its blocks.");
4316
+ }
4205
4317
  return immutableRecord({
4206
4318
  ...base,
4207
4319
  role,
@@ -4213,9 +4325,11 @@ function decodeMessage(
4213
4325
  "provider_tool_call_id",
4214
4326
  ),
4215
4327
  name: stringFromSql(row.name, "name"),
4216
- content: stringFromSql(row.content, "content"),
4328
+ content: toolContentBlocks,
4329
+ displayText,
4217
4330
  origin: enumFromSql(row.origin, ["tool", "runtime"] as const, "tool origin"),
4218
4331
  });
4332
+ }
4219
4333
  }
4220
4334
  }
4221
4335
 
@@ -4597,7 +4711,11 @@ function decodeStoredSwapOverride(rowValue: unknown): StoredContextOverrideV8 {
4597
4711
  ordinal: numberFromSql(row.ordinal, "ordinal"),
4598
4712
  rendererFormat: enumFromSql(
4599
4713
  row.renderer_format,
4600
- [SWAP_OBSERVATION_FORMAT, SKILL_ACTIVATION_RECEIPT_FORMAT] as const,
4714
+ [
4715
+ SWAP_OBSERVATION_FORMAT,
4716
+ SWAP_TOOL_IMAGE_FORMAT,
4717
+ SKILL_ACTIVATION_RECEIPT_FORMAT,
4718
+ ] as const,
4601
4719
  "context override renderer format",
4602
4720
  ),
4603
4721
  source: stringFromSql(row.source, "source") as StoredContextOverrideV8["source"],
@@ -4937,6 +5055,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
4937
5055
  raw.kind,
4938
5056
  [
4939
5057
  "read",
5058
+ "view_image",
4940
5059
  "write",
4941
5060
  "edit",
4942
5061
  "delete",
@@ -4952,6 +5071,8 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
4952
5071
  "web_fetch",
4953
5072
  "recall",
4954
5073
  "memory_search",
5074
+ "memory_get",
5075
+ "wait",
4955
5076
  "skill",
4956
5077
  "mcp",
4957
5078
  "generic",
@@ -4964,9 +5085,65 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
4964
5085
  if (kind === "skill") {
4965
5086
  return decodeStoredSkillRawResult(raw);
4966
5087
  }
5088
+ if (kind === "view_image") {
5089
+ return decodeStoredViewImageRawResult(raw);
5090
+ }
4967
5091
  return immutableCanonicalClone(raw) as ToolRawResult;
4968
5092
  }
4969
5093
 
5094
+ function decodeStoredViewImageRawResult(
5095
+ raw: Record<string, unknown>,
5096
+ ): Extract<ToolRawResult, { kind: "view_image" }> {
5097
+ assertObjectKeys(
5098
+ raw,
5099
+ ["kind", "ok", "filePath", "originalName", "asset", "error"],
5100
+ ["kind", "ok", "filePath"],
5101
+ "ViewImage raw result",
5102
+ );
5103
+ const filePath = stringFromSql(raw.filePath, "ViewImage filePath");
5104
+ if (raw.ok === false) {
5105
+ if (raw.originalName !== undefined || raw.asset !== undefined) {
5106
+ throw new Error("Failed ViewImage raw result cannot contain image metadata.");
5107
+ }
5108
+ const error = stringFromSql(raw.error, "ViewImage error");
5109
+ if (error.trim() === "") {
5110
+ throw new Error("Failed ViewImage raw result error must not be empty.");
5111
+ }
5112
+ return immutableRecord({ kind: "view_image", ok: false, filePath, error });
5113
+ }
5114
+ if (raw.ok !== true || raw.error !== undefined || filePath.trim() === "") {
5115
+ throw new Error("Successful ViewImage raw result is invalid.");
5116
+ }
5117
+ const originalName = stringFromSql(raw.originalName, "ViewImage originalName");
5118
+ validateOriginalImageName(originalName);
5119
+ const storedAsset = recordFromSql(raw.asset, "ViewImage asset");
5120
+ assertObjectKeys(
5121
+ storedAsset,
5122
+ ["assetId", "mimeType", "byteLength", "width", "height"],
5123
+ ["assetId", "mimeType", "byteLength", "width", "height"],
5124
+ "ViewImage asset",
5125
+ );
5126
+ const asset: ImageAssetRef = immutableRecord({
5127
+ assetId: parseImageAssetId(stringFromSql(storedAsset.assetId, "assetId")),
5128
+ mimeType: enumFromSql(
5129
+ storedAsset.mimeType,
5130
+ ["image/png", "image/jpeg", "image/webp"] as const,
5131
+ "ViewImage asset mimeType",
5132
+ ),
5133
+ byteLength: numberFromJson(storedAsset.byteLength, "ViewImage asset byteLength"),
5134
+ width: numberFromJson(storedAsset.width, "ViewImage asset width"),
5135
+ height: numberFromJson(storedAsset.height, "ViewImage asset height"),
5136
+ });
5137
+ validateImageAssetRef(asset);
5138
+ return immutableRecord({
5139
+ kind: "view_image",
5140
+ ok: true,
5141
+ filePath,
5142
+ originalName,
5143
+ asset,
5144
+ });
5145
+ }
5146
+
4970
5147
  function decodeStoredSkillRawResult(
4971
5148
  raw: Record<string, unknown>,
4972
5149
  ): Extract<ToolRawResult, { kind: "skill" }> {
@@ -5325,7 +5502,7 @@ function assertCommitPrefixRetirementRevisionInput(
5325
5502
  }
5326
5503
  }
5327
5504
 
5328
- function requireActiveRevisionId(meta: StoredSessionMetaV9): ContextRevisionId {
5505
+ function requireActiveRevisionId(meta: StoredSessionMetaV10): ContextRevisionId {
5329
5506
  if (meta.initializationState !== "ready" || meta.activeRevisionId === null) {
5330
5507
  throw new Error("Session has no active context revision.");
5331
5508
  }
@@ -5339,16 +5516,19 @@ function previousRevision(
5339
5516
  return revisions[revision.revisionNumber - 2];
5340
5517
  }
5341
5518
 
5342
- function decodeMeta(value: unknown, expectedSessionId: SessionId): StoredSessionMetaV9 {
5519
+ function decodeMeta(
5520
+ value: unknown,
5521
+ expectedSessionId: SessionId,
5522
+ ): StoredSessionMetaV10 {
5343
5523
  const row = recordFromSql(value, "session metadata");
5344
5524
  const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
5345
5525
  if (sessionId !== expectedSessionId) {
5346
5526
  throw new Error(`Metadata session ID ${sessionId} does not match directory.`);
5347
5527
  }
5348
5528
  const schemaVersion = numberFromSql(row.schema_version, "schema_version");
5349
- if (schemaVersion !== 9) {
5529
+ if (schemaVersion !== 10) {
5350
5530
  throw new Error(
5351
- `Session metadata schema version must be 9; received ${schemaVersion}.`,
5531
+ `Session metadata schema version must be 10; received ${schemaVersion}.`,
5352
5532
  );
5353
5533
  }
5354
5534
  const projectInstructionFile = nullableStringFromSql(
@@ -5477,7 +5657,7 @@ function compatibilityContractDifferences(
5477
5657
  "includeReasoningContent",
5478
5658
  "contextProfile",
5479
5659
  "messageProtocol",
5480
- "imageInput",
5660
+ "media",
5481
5661
  ];
5482
5662
  return fields.filter((key) => {
5483
5663
  const storedValue = record[key];
@@ -5504,14 +5684,14 @@ function decodeSessionCompatibilityContract(
5504
5684
  "includeReasoningContent",
5505
5685
  "contextProfile",
5506
5686
  "messageProtocol",
5507
- "imageInput",
5687
+ "media",
5508
5688
  ],
5509
5689
  [
5510
5690
  "modelName",
5511
5691
  "includeReasoningContent",
5512
5692
  "contextProfile",
5513
5693
  "messageProtocol",
5514
- "imageInput",
5694
+ "media",
5515
5695
  ],
5516
5696
  "session compatibility contract",
5517
5697
  );
@@ -5535,24 +5715,21 @@ function decodeSessionCompatibilityContract(
5535
5715
  ["adapter", "serializationVersion"],
5536
5716
  "session compatibility message protocol",
5537
5717
  );
5538
- const imageInput = recordFromSql(
5539
- record.imageInput,
5540
- "session compatibility image input",
5541
- );
5718
+ const media = recordFromSql(record.media, "session compatibility media");
5542
5719
  assertObjectKeys(
5543
- imageInput,
5544
- ["policyVersion", "policySha256", "inputModalities", "tokenEstimator"],
5545
- ["policyVersion", "policySha256", "inputModalities"],
5546
- "session compatibility image input",
5720
+ media,
5721
+ ["policyVersion", "policySha256", "inputModalities", "toolResultModalities"],
5722
+ ["policyVersion", "policySha256", "inputModalities", "toolResultModalities"],
5723
+ "session compatibility media",
5547
5724
  );
5548
- const inputModalities = imageInput.inputModalities;
5549
- if (!Array.isArray(inputModalities)) {
5550
- throw new Error("Session compatibility input modalities must be an array.");
5725
+ const inputModalities = decodeCompatibilityModalities(media.inputModalities, "input");
5726
+ const toolResultModalities = decodeCompatibilityModalities(
5727
+ media.toolResultModalities,
5728
+ "tool result",
5729
+ );
5730
+ if (toolResultModalities.includes("image") && !inputModalities.includes("image")) {
5731
+ throw new Error("Session compatibility image tool results require image input.");
5551
5732
  }
5552
- const tokenEstimator =
5553
- imageInput.tokenEstimator === undefined
5554
- ? undefined
5555
- : decodeTokenEstimatorCompatibility(imageInput.tokenEstimator);
5556
5733
  if (typeof record.includeReasoningContent !== "boolean") {
5557
5734
  throw new Error("Session compatibility reasoning replay flag must be boolean.");
5558
5735
  }
@@ -5582,17 +5759,12 @@ function decodeSessionCompatibilityContract(
5582
5759
  "compatibility serializationVersion",
5583
5760
  ),
5584
5761
  });
5585
- const modalities = normalizeInputModalities(
5586
- inputModalities.map((value) =>
5587
- enumFromSql(value, ["text", "image"] as const, "compatibility input modality"),
5588
- ),
5589
- );
5590
5762
  const policyVersion = stringFromSql(
5591
- imageInput.policyVersion,
5763
+ media.policyVersion,
5592
5764
  "compatibility image policyVersion",
5593
5765
  );
5594
5766
  const policySha256 = stringFromSql(
5595
- imageInput.policySha256,
5767
+ media.policySha256,
5596
5768
  "compatibility image policySha256",
5597
5769
  );
5598
5770
  if (!/^[0-9a-f]{64}$/.test(policySha256)) {
@@ -5604,49 +5776,31 @@ function decodeSessionCompatibilityContract(
5604
5776
  includeReasoningContent: record.includeReasoningContent,
5605
5777
  contextProfile: Object.freeze(context),
5606
5778
  messageProtocol: protocol,
5607
- imageInput: Object.freeze({
5779
+ media: Object.freeze({
5608
5780
  policyVersion,
5609
5781
  policySha256,
5610
- inputModalities: modalities,
5611
- ...(tokenEstimator === undefined ? {} : { tokenEstimator }),
5782
+ inputModalities,
5783
+ toolResultModalities,
5612
5784
  }),
5613
5785
  });
5614
5786
  }
5615
5787
 
5616
- function decodeTokenEstimatorCompatibility(
5788
+ function decodeCompatibilityModalities(
5617
5789
  value: unknown,
5618
- ): LegacyInputTokenEstimatorCompatibility {
5619
- const record = recordFromSql(value, "session compatibility token estimator");
5620
- assertObjectKeys(
5621
- record,
5622
- ["kind", "coverageVersion", "model", "endpoint", "timeoutMs", "maxRetries"],
5623
- ["kind", "coverageVersion", "model", "endpoint", "timeoutMs", "maxRetries"],
5624
- "session compatibility token estimator",
5625
- );
5626
- const estimator: LegacyInputTokenEstimatorCompatibility = {
5627
- kind: enumFromSql(
5628
- record.kind,
5629
- ["moonshot-estimate-token-count-v1"] as const,
5630
- "compatibility token estimator kind",
5631
- ),
5632
- coverageVersion: enumFromSql(
5633
- record.coverageVersion,
5634
- ["full-request-v1"] as const,
5635
- "compatibility token estimator coverage",
5636
- ),
5637
- model: stringFromSql(record.model, "compatibility token estimator model"),
5638
- endpoint: stringFromSql(record.endpoint, "compatibility token estimator endpoint"),
5639
- timeoutMs: numberFromJson(
5640
- record.timeoutMs,
5641
- "compatibility token estimator timeoutMs",
5642
- ),
5643
- maxRetries: zeroFromJson(
5644
- record.maxRetries,
5645
- "compatibility token estimator maxRetries",
5790
+ label: string,
5791
+ ): readonly ("text" | "image")[] {
5792
+ if (!Array.isArray(value)) {
5793
+ throw new Error(`Session compatibility ${label} modalities must be an array.`);
5794
+ }
5795
+ return normalizeInputModalities(
5796
+ value.map((modality) =>
5797
+ enumFromSql(
5798
+ modality,
5799
+ ["text", "image"] as const,
5800
+ `compatibility ${label} modality`,
5801
+ ),
5646
5802
  ),
5647
- };
5648
- validateTokenEstimatorCompatibility(estimator);
5649
- return Object.freeze(estimator);
5803
+ );
5650
5804
  }
5651
5805
 
5652
5806
  function openWritableDatabase(databasePath: string): Database {
@@ -6244,13 +6398,6 @@ function numberFromJson(value: unknown, name: string): number {
6244
6398
  return value as number;
6245
6399
  }
6246
6400
 
6247
- function zeroFromJson(value: unknown, name: string): 0 {
6248
- if (value !== 0) {
6249
- throw new Error(`${name} must be 0.`);
6250
- }
6251
- return 0;
6252
- }
6253
-
6254
6401
  function enumFromSql<const T extends readonly string[]>(
6255
6402
  value: unknown,
6256
6403
  values: T,