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,7 +4,14 @@ import {
4
4
  parseMessageSource,
5
5
  type MessageSource,
6
6
  } from "../context/context-source";
7
- import { contentHash } from "../context/protocol-frame";
7
+ import { contentHash, userMessageHash } from "../context/protocol-frame";
8
+ import {
9
+ parseImageAssetId,
10
+ parseImageAttachmentId,
11
+ validateUserMessage,
12
+ type UserImageAttachment,
13
+ type UserMessage,
14
+ } from "../image/image-types";
8
15
  import type { MessageId, SessionId } from "../ids/runtime-id";
9
16
  import { SessionError, sessionReadError } from "./session-errors";
10
17
 
@@ -110,6 +117,21 @@ type RecallRow = {
110
117
  observation_sha256?: unknown;
111
118
  };
112
119
 
120
+ type RecallImageRow = {
121
+ message_id: unknown;
122
+ attachment_id: unknown;
123
+ position: unknown;
124
+ label: unknown;
125
+ range_start: unknown;
126
+ range_end: unknown;
127
+ original_name: unknown;
128
+ asset_id: unknown;
129
+ mime_type: unknown;
130
+ byte_length: unknown;
131
+ width: unknown;
132
+ height: unknown;
133
+ };
134
+
113
135
  export function createSessionHistoryReader(input: {
114
136
  database: Database;
115
137
  sessionId: SessionId;
@@ -203,11 +225,18 @@ class SqliteSessionHistoryReader implements SessionHistoryReader {
203
225
 
204
226
  let decoded: Array<RecallSearchHit & { content: string }>;
205
227
  try {
228
+ const imageAttachments = this.loadImageAttachments(
229
+ rows.map((row) => nonEmptyString(row.message_id, "message_id") as MessageId),
230
+ );
206
231
  decoded = rows.map((row) => {
207
232
  const metadata = decodeRecallRow(row);
233
+ const attachments = imageAttachments.get(metadata.messageId) ?? [];
234
+ verifyRecallContent(metadata, attachments);
235
+ const content = renderRecallContent(metadata.content, attachments);
208
236
  return {
209
237
  ...metadata,
210
- excerpt: buildExcerpt(metadata.content, input.query),
238
+ content,
239
+ excerpt: buildExcerpt(content, input.query),
211
240
  };
212
241
  });
213
242
  } catch (error) {
@@ -258,12 +287,15 @@ class SqliteSessionHistoryReader implements SessionHistoryReader {
258
287
  }
259
288
 
260
289
  let metadata: ReturnType<typeof decodeRecallRow>;
290
+ let attachments: readonly UserImageAttachment[];
261
291
  try {
262
292
  metadata = decodeRecallRow(row);
293
+ attachments =
294
+ this.loadImageAttachments([metadata.messageId]).get(metadata.messageId) ?? [];
263
295
  } catch (error) {
264
296
  throw sessionReadError("decode_recall_get", this.sessionId, error);
265
297
  }
266
- const actualHash = contentHash(metadata.content);
298
+ const actualHash = recallContentHash(metadata, attachments);
267
299
  const observationHash = row.observation_sha256;
268
300
  if (
269
301
  actualHash !== metadata.contentSha256 ||
@@ -277,7 +309,8 @@ class SqliteSessionHistoryReader implements SessionHistoryReader {
277
309
  );
278
310
  }
279
311
 
280
- const page = sliceUtf8Page(metadata.content, input.byteOffset, input.byteLimit);
312
+ const renderedContent = renderRecallContent(metadata.content, attachments);
313
+ const page = sliceUtf8Page(renderedContent, input.byteOffset, input.byteLimit);
281
314
  return Object.freeze({
282
315
  source: formatMessageSource(metadata.messageId),
283
316
  messageId: metadata.messageId,
@@ -324,6 +357,112 @@ class SqliteSessionHistoryReader implements SessionHistoryReader {
324
357
  }
325
358
  return requested ?? maximum;
326
359
  }
360
+
361
+ private loadImageAttachments(
362
+ messageIds: readonly MessageId[],
363
+ ): ReadonlyMap<MessageId, readonly UserImageAttachment[]> {
364
+ if (messageIds.length === 0) {
365
+ return new Map();
366
+ }
367
+ const unique = [...new Set(messageIds)];
368
+ let rows: RecallImageRow[];
369
+ try {
370
+ this.requireStoreOpen();
371
+ rows = this.database
372
+ .query(
373
+ `SELECT
374
+ mia.message_id, mia.attachment_id, mia.position, mia.label,
375
+ mia.range_start, mia.range_end, mia.original_name,
376
+ ia.asset_id, ia.mime_type, ia.byte_length, ia.width, ia.height
377
+ FROM message_image_attachments mia
378
+ JOIN image_assets ia ON ia.asset_id = mia.asset_id
379
+ WHERE mia.message_id IN (${unique.map(() => "?").join(", ")})
380
+ ORDER BY mia.message_id, mia.position`,
381
+ )
382
+ .all(...unique) as RecallImageRow[];
383
+ } catch (error) {
384
+ throw sessionReadError("read_recall_images", this.sessionId, error);
385
+ }
386
+
387
+ const grouped = new Map<MessageId, UserImageAttachment[]>();
388
+ for (const row of rows) {
389
+ const messageId = nonEmptyString(row.message_id, "message_id") as MessageId;
390
+ const attachments = grouped.get(messageId) ?? [];
391
+ const position = nonNegativeSafeInteger(row.position, "image position");
392
+ if (position !== attachments.length) {
393
+ throw new Error("Recall image positions are not contiguous.");
394
+ }
395
+ attachments.push(
396
+ Object.freeze({
397
+ attachmentId: parseImageAttachmentId(
398
+ nonEmptyString(row.attachment_id, "attachment_id"),
399
+ ),
400
+ assetId: parseImageAssetId(nonEmptyString(row.asset_id, "asset_id")),
401
+ label: nonEmptyString(row.label, "image label"),
402
+ range: Object.freeze({
403
+ start: nonNegativeSafeInteger(row.range_start, "image range start"),
404
+ end: safeInteger(row.range_end, "image range end"),
405
+ }),
406
+ mimeType: enumValue(
407
+ row.mime_type,
408
+ ["image/png", "image/jpeg", "image/webp"] as const,
409
+ "image MIME type",
410
+ ),
411
+ byteLength: safeInteger(row.byte_length, "image byte length"),
412
+ width: safeInteger(row.width, "image width"),
413
+ height: safeInteger(row.height, "image height"),
414
+ originalName: nonEmptyString(row.original_name, "original image name"),
415
+ }),
416
+ );
417
+ grouped.set(messageId, attachments);
418
+ }
419
+ return new Map(
420
+ [...grouped].map(([messageId, attachments]) => [
421
+ messageId,
422
+ Object.freeze(attachments),
423
+ ]),
424
+ );
425
+ }
426
+ }
427
+
428
+ export function renderRecallContent(
429
+ content: string,
430
+ attachments: readonly UserImageAttachment[],
431
+ ): string {
432
+ if (attachments.length === 0) return content;
433
+ const notes = attachments.map(
434
+ (attachment) =>
435
+ `[Historical image omitted: label=${attachment.label}, originalName=${JSON.stringify(attachment.originalName)}. Ask the user to reattach it.]`,
436
+ );
437
+ return `${content}\n\n${notes.join("\n")}`;
438
+ }
439
+
440
+ function verifyRecallContent(
441
+ metadata: ReturnType<typeof decodeRecallRow>,
442
+ attachments: readonly UserImageAttachment[],
443
+ ): void {
444
+ if (recallContentHash(metadata, attachments) !== metadata.contentSha256) {
445
+ throw new Error("Canonical Recall content failed its integrity check.");
446
+ }
447
+ }
448
+
449
+ function recallContentHash(
450
+ metadata: ReturnType<typeof decodeRecallRow>,
451
+ attachments: readonly UserImageAttachment[],
452
+ ): string {
453
+ if (metadata.role !== "user") {
454
+ if (attachments.length > 0) {
455
+ throw new Error("Non-user Recall message has image attachments.");
456
+ }
457
+ return contentHash(metadata.content);
458
+ }
459
+ const message: UserMessage = Object.freeze({
460
+ role: "user",
461
+ content: metadata.content,
462
+ ...(attachments.length === 0 ? {} : { attachments }),
463
+ });
464
+ validateUserMessage(message);
465
+ return userMessageHash(message);
327
466
  }
328
467
 
329
468
  function searchPredicates(
@@ -523,6 +662,14 @@ function safeInteger(value: unknown, name: string): number {
523
662
  return number;
524
663
  }
525
664
 
665
+ function nonNegativeSafeInteger(value: unknown, name: string): number {
666
+ const number = typeof value === "bigint" ? Number(value) : value;
667
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) {
668
+ throw new Error(`${name} must be a non-negative safe integer.`);
669
+ }
670
+ return number;
671
+ }
672
+
526
673
  function enumValue<const TValues extends readonly string[]>(
527
674
  value: unknown,
528
675
  values: TValues,
@@ -0,0 +1,189 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { realpath } from "node:fs/promises";
3
+ import { contentHash } from "../context/protocol-frame";
4
+ import type { SessionId } from "../ids/runtime-id";
5
+ import { SessionError, sessionOpenError, sessionReadError } from "./session-errors";
6
+ import { verifySessionSchema } from "./session-schema";
7
+ import { decodeStoredToolCalls, sessionDatabasePath } from "./session-store";
8
+
9
+ const OPERATION = "read_last_assistant_response";
10
+
11
+ export async function readLastAssistantResponse(input: {
12
+ workspaceRoot: string;
13
+ sessionId: SessionId;
14
+ }): Promise<string | undefined> {
15
+ const workspaceRoot = await realpath(input.workspaceRoot);
16
+ let database: Database;
17
+ try {
18
+ database = new Database(sessionDatabasePath(workspaceRoot, input.sessionId), {
19
+ readonly: true,
20
+ strict: true,
21
+ safeIntegers: true,
22
+ });
23
+ } catch (error) {
24
+ throw sessionOpenError(OPERATION, input.sessionId, error);
25
+ }
26
+
27
+ try {
28
+ return readResponse(database, {
29
+ workspaceRoot,
30
+ sessionId: input.sessionId,
31
+ });
32
+ } finally {
33
+ database.close();
34
+ }
35
+ }
36
+
37
+ function readResponse(
38
+ database: Database,
39
+ input: { workspaceRoot: string; sessionId: SessionId },
40
+ ): string | undefined {
41
+ try {
42
+ verifySessionSchema(database, input.sessionId);
43
+ const metaRows = database
44
+ .query("SELECT session_id, workspace_root FROM session_meta")
45
+ .all() as Array<Record<string, unknown>>;
46
+ if (
47
+ metaRows.length !== 1 ||
48
+ metaRows[0]?.session_id !== input.sessionId ||
49
+ metaRows[0]?.workspace_root !== input.workspaceRoot
50
+ ) {
51
+ throw integrityError(
52
+ input.sessionId,
53
+ "Session identity does not match the requested last response.",
54
+ );
55
+ }
56
+
57
+ const row = database
58
+ .query(
59
+ `SELECT
60
+ t.turn_id,
61
+ t.turn_number,
62
+ t.final_message_id,
63
+ m.message_id,
64
+ m.session_id AS message_session_id,
65
+ m.turn_id AS message_turn_id,
66
+ m.role,
67
+ m.content,
68
+ m.content_sha256,
69
+ m.tool_calls_json
70
+ FROM turns t
71
+ LEFT JOIN messages m ON m.message_id = t.final_message_id
72
+ WHERE t.session_id = ? AND t.status = 'completed'
73
+ ORDER BY t.turn_number DESC
74
+ LIMIT 1`,
75
+ )
76
+ .get(input.sessionId) as Record<string, unknown> | null;
77
+ if (row === null) {
78
+ return undefined;
79
+ }
80
+
81
+ return decodeResponse(row, input.sessionId);
82
+ } catch (error) {
83
+ throw sessionReadError(OPERATION, input.sessionId, error);
84
+ }
85
+ }
86
+
87
+ function decodeResponse(
88
+ row: Record<string, unknown>,
89
+ sessionId: SessionId,
90
+ ): string | undefined {
91
+ const turnId = requireNonEmptyString(row.turn_id, "turn ID", sessionId);
92
+ requirePositiveInteger(row.turn_number, "turn number", sessionId);
93
+ const finalMessageId = requireNonEmptyString(
94
+ row.final_message_id,
95
+ "final message ID",
96
+ sessionId,
97
+ );
98
+ if (
99
+ row.message_id !== finalMessageId ||
100
+ row.message_session_id !== sessionId ||
101
+ row.message_turn_id !== turnId
102
+ ) {
103
+ throw integrityError(
104
+ sessionId,
105
+ `Final message ${finalMessageId} does not match turn ${turnId}.`,
106
+ );
107
+ }
108
+ if (row.role !== "assistant") {
109
+ throw integrityError(
110
+ sessionId,
111
+ `Final message ${finalMessageId} is not assistant.`,
112
+ );
113
+ }
114
+
115
+ const toolCalls =
116
+ row.tool_calls_json === null
117
+ ? []
118
+ : decodeStoredToolCalls(
119
+ requireNonEmptyString(
120
+ row.tool_calls_json,
121
+ "final message tool calls",
122
+ sessionId,
123
+ ),
124
+ );
125
+ if (toolCalls.length !== 0) {
126
+ throw integrityError(
127
+ sessionId,
128
+ `Final message ${finalMessageId} still contains tool calls.`,
129
+ );
130
+ }
131
+
132
+ const content = requireNullableString(
133
+ row.content,
134
+ "final message content",
135
+ sessionId,
136
+ );
137
+ const storedHash = requireNonEmptyString(
138
+ row.content_sha256,
139
+ "final message content hash",
140
+ sessionId,
141
+ );
142
+ if (contentHash(content) !== storedHash) {
143
+ throw integrityError(
144
+ sessionId,
145
+ `Final message ${finalMessageId} content hash does not match.`,
146
+ );
147
+ }
148
+ return content === null || content.trim() === "" ? undefined : content;
149
+ }
150
+
151
+ function requireNonEmptyString(
152
+ value: unknown,
153
+ name: string,
154
+ sessionId: SessionId,
155
+ ): string {
156
+ if (typeof value !== "string" || value === "") {
157
+ throw integrityError(sessionId, `Stored ${name} must be non-empty text.`);
158
+ }
159
+ return value;
160
+ }
161
+
162
+ function requireNullableString(
163
+ value: unknown,
164
+ name: string,
165
+ sessionId: SessionId,
166
+ ): string | null {
167
+ if (value !== null && typeof value !== "string") {
168
+ throw integrityError(sessionId, `Stored ${name} must be text or null.`);
169
+ }
170
+ return value;
171
+ }
172
+
173
+ function requirePositiveInteger(
174
+ value: unknown,
175
+ name: string,
176
+ sessionId: SessionId,
177
+ ): number {
178
+ const number = typeof value === "bigint" ? Number(value) : value;
179
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 1) {
180
+ throw integrityError(sessionId, `Stored ${name} must be a positive integer.`);
181
+ }
182
+ return number;
183
+ }
184
+
185
+ function integrityError(sessionId: SessionId, message: string): SessionError {
186
+ return new SessionError("SESSION_INTEGRITY_FAILED", OPERATION, message, {
187
+ sessionId,
188
+ });
189
+ }