tinker-agent 1.0.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +173 -0
  2. package/package.json +78 -0
  3. package/patches/markdansi@0.3.2.patch +37 -0
  4. package/src/agent/context-builder.ts +43 -0
  5. package/src/agent/context-meter.ts +310 -0
  6. package/src/agent/loop.ts +525 -0
  7. package/src/agent/runtime-session.ts +1212 -0
  8. package/src/agent/session-ledger.ts +828 -0
  9. package/src/agent/turn-cancellation.ts +44 -0
  10. package/src/agent/types.ts +77 -0
  11. package/src/cli/config.ts +283 -0
  12. package/src/cli/index.ts +29 -0
  13. package/src/cli/model-profiles.ts +289 -0
  14. package/src/cli/run-runner.ts +107 -0
  15. package/src/cli/tui-runner.tsx +290 -0
  16. package/src/context/compiled-context-hash.ts +138 -0
  17. package/src/context/compiled-context-validator.ts +209 -0
  18. package/src/context/context-manager.ts +362 -0
  19. package/src/context/context-policy.ts +8 -0
  20. package/src/context/context-protocol-validator.ts +463 -0
  21. package/src/context/context-revision-compiler.ts +281 -0
  22. package/src/context/context-revision.ts +111 -0
  23. package/src/context/context-source.ts +30 -0
  24. package/src/context/context-swap-renderer.ts +272 -0
  25. package/src/context/protocol-frame.ts +240 -0
  26. package/src/context/swap-planner.ts +725 -0
  27. package/src/events/append-private-file.ts +16 -0
  28. package/src/events/bash-result-detail.ts +70 -0
  29. package/src/events/composite-event-sink.ts +82 -0
  30. package/src/events/event-sink.ts +16 -0
  31. package/src/events/jsonl-event-log.ts +13 -0
  32. package/src/events/observation-text-log.ts +195 -0
  33. package/src/events/stdout-event-printer.ts +396 -0
  34. package/src/events/types.ts +263 -0
  35. package/src/ids/runtime-id.ts +68 -0
  36. package/src/ids/uuid-v7.ts +5 -0
  37. package/src/instructions/project-instructions.ts +242 -0
  38. package/src/mcp/mcp-config.ts +144 -0
  39. package/src/mcp/mcp-manager.ts +216 -0
  40. package/src/mcp/mcp-tool-executor.ts +178 -0
  41. package/src/model/committed-prefix-auditor.ts +68 -0
  42. package/src/model/fake-model-client.ts +280 -0
  43. package/src/model/model-client.ts +64 -0
  44. package/src/model/model-context-profile.ts +134 -0
  45. package/src/model/model-request-preflight.ts +120 -0
  46. package/src/model/openai-chat-mapping.ts +444 -0
  47. package/src/model/openai-chat-model-client.ts +190 -0
  48. package/src/model/prompt-prefix-hash.ts +47 -0
  49. package/src/model/token-estimator.ts +148 -0
  50. package/src/observation/observation-builder.ts +481 -0
  51. package/src/session/resume-projection.ts +616 -0
  52. package/src/session/session-catalog.ts +270 -0
  53. package/src/session/session-errors.ts +121 -0
  54. package/src/session/session-history-reader.ts +535 -0
  55. package/src/session/session-lock.ts +291 -0
  56. package/src/session/session-schema.ts +741 -0
  57. package/src/session/session-store.ts +3067 -0
  58. package/src/session/sqlite-session-ledger.ts +153 -0
  59. package/src/tools/bash-task.ts +617 -0
  60. package/src/tools/bash.ts +450 -0
  61. package/src/tools/cwd-state.ts +22 -0
  62. package/src/tools/edit.ts +428 -0
  63. package/src/tools/file-diff.ts +116 -0
  64. package/src/tools/glob.ts +202 -0
  65. package/src/tools/grep.ts +550 -0
  66. package/src/tools/hash.ts +9 -0
  67. package/src/tools/path-safety.ts +33 -0
  68. package/src/tools/read.ts +319 -0
  69. package/src/tools/recall.ts +400 -0
  70. package/src/tools/registry.ts +213 -0
  71. package/src/tools/ripgrep.ts +220 -0
  72. package/src/tools/task-list.ts +59 -0
  73. package/src/tools/task-output-snapshot.ts +47 -0
  74. package/src/tools/task-output-tool.ts +62 -0
  75. package/src/tools/task-output.ts +159 -0
  76. package/src/tools/task-stop.ts +59 -0
  77. package/src/tools/task-tool-args.ts +29 -0
  78. package/src/tools/types.ts +330 -0
  79. package/src/tools/web-fetch/backend.ts +27 -0
  80. package/src/tools/web-fetch/browser-backend.ts +126 -0
  81. package/src/tools/web-fetch/exa-backend.ts +172 -0
  82. package/src/tools/web-fetch/index.ts +298 -0
  83. package/src/tools/web-fetch/local-backend.ts +267 -0
  84. package/src/tools/web-fetch/refiner.ts +78 -0
  85. package/src/tools/web-fetch/route.ts +95 -0
  86. package/src/tools/web-search.ts +300 -0
  87. package/src/tools/write.ts +244 -0
  88. package/src/tui/app.tsx +497 -0
  89. package/src/tui/components/assistant-markdown.tsx +47 -0
  90. package/src/tui/components/background-tasks.tsx +92 -0
  91. package/src/tui/components/bash-result-view.tsx +47 -0
  92. package/src/tui/components/context-status.tsx +127 -0
  93. package/src/tui/components/diff-view.tsx +151 -0
  94. package/src/tui/components/file-viewer.tsx +212 -0
  95. package/src/tui/components/footer.tsx +60 -0
  96. package/src/tui/components/header.tsx +21 -0
  97. package/src/tui/components/model-picker.tsx +142 -0
  98. package/src/tui/components/prompt-input.tsx +432 -0
  99. package/src/tui/components/resume-session-picker.tsx +273 -0
  100. package/src/tui/components/timeline.tsx +121 -0
  101. package/src/tui/context-format.ts +24 -0
  102. package/src/tui/event-store.ts +865 -0
  103. package/src/tui/git-branch.ts +23 -0
  104. package/src/tui/line-editor.ts +157 -0
  105. package/src/tui/prompt-history.ts +94 -0
  106. package/src/tui/slash-commands.ts +126 -0
  107. package/src/tui/tui-projection-policy.ts +35 -0
  108. package/src/tui/tui-projection-store.ts +123 -0
  109. package/src/tui/tui-session-controller.ts +170 -0
  110. package/src/tui/view-file.ts +122 -0
@@ -0,0 +1,616 @@
1
+ import path from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+ import { Database } from "bun:sqlite";
4
+ import type { ToolCall } from "../agent/types";
5
+ import type { SessionId } from "../ids/runtime-id";
6
+ import {
7
+ assertMatchingContextBudget,
8
+ createModelContextProfile,
9
+ type ModelContextBudget,
10
+ type ModelContextProfile,
11
+ } from "../model/model-context-profile";
12
+ import {
13
+ completedModelRequestText,
14
+ toolCallStartedProjection,
15
+ toolRawResultProjection,
16
+ type TimelineItem,
17
+ type TuiProjectionState,
18
+ type TuiTurnProjection,
19
+ } from "../tui/event-store";
20
+ import {
21
+ defaultTuiProjectionPolicy,
22
+ type TuiProjectionPolicy,
23
+ validateTuiProjectionPolicy,
24
+ } from "../tui/tui-projection-policy";
25
+ import { SessionError } from "./session-errors";
26
+ import { verifySessionSchema } from "./session-schema";
27
+ import { decodeStoredToolCalls, decodeStoredToolRawResult } from "./session-store";
28
+
29
+ export class ResumeProjectionReader {
30
+ static async read(input: {
31
+ workspaceRoot: string;
32
+ sessionId: SessionId;
33
+ modelName: string;
34
+ policy?: TuiProjectionPolicy;
35
+ }): Promise<TuiProjectionState> {
36
+ const policy = validateTuiProjectionPolicy(
37
+ input.policy ?? defaultTuiProjectionPolicy,
38
+ );
39
+ const workspaceRoot = await realpath(input.workspaceRoot);
40
+ const databasePath = path.join(
41
+ workspaceRoot,
42
+ ".tinker",
43
+ "sessions",
44
+ input.sessionId,
45
+ "session.sqlite",
46
+ );
47
+ const database = new Database(databasePath, {
48
+ readonly: true,
49
+ strict: true,
50
+ safeIntegers: true,
51
+ });
52
+ try {
53
+ verifySessionSchema(database, input.sessionId);
54
+ const meta = database.query("SELECT * FROM session_meta").get() as Record<
55
+ string,
56
+ unknown
57
+ > | null;
58
+ if (
59
+ meta === null ||
60
+ meta.session_id !== input.sessionId ||
61
+ meta.workspace_root !== workspaceRoot ||
62
+ meta.model_name !== input.modelName
63
+ ) {
64
+ throw new SessionError(
65
+ "SESSION_INTEGRITY_FAILED",
66
+ "read_resume_projection",
67
+ "Projection metadata does not match the requested session.",
68
+ { sessionId: input.sessionId },
69
+ );
70
+ }
71
+ const totalTurns = count(
72
+ database.query("SELECT COUNT(*) AS count FROM turns").get(),
73
+ );
74
+ const turns = database
75
+ .query(`SELECT * FROM turns ORDER BY turn_number DESC LIMIT ?`)
76
+ .all(policy.recentTurnLimit)
77
+ .reverse() as Array<Record<string, unknown>>;
78
+ const recentTurns = turns.map((turn) => projectTurn(database, turn, policy));
79
+ const last = recentTurns.at(-1);
80
+ const terminal = terminalProjection(last);
81
+ const context = decodeContextContract(meta.runtime_contract_json);
82
+ return {
83
+ status: terminal.status,
84
+ sessionId: input.sessionId,
85
+ modelName: input.modelName,
86
+ workspaceRoot,
87
+ contextProfile: context.profile,
88
+ contextBudget: context.budget,
89
+ ...(last?.workedForMs === undefined ? {} : { workedForMs: last.workedForMs }),
90
+ recentTurns,
91
+ notices: [],
92
+ backgroundTasks: [],
93
+ omittedTurnCount: Math.max(0, totalTurns - recentTurns.length),
94
+ ...(terminal.finalText === undefined ? {} : { finalText: terminal.finalText }),
95
+ ...(terminal.error === undefined ? {} : { error: terminal.error }),
96
+ };
97
+ } finally {
98
+ database.close();
99
+ }
100
+ }
101
+ }
102
+
103
+ function decodeContextContract(value: unknown): {
104
+ profile: ModelContextProfile;
105
+ budget: ModelContextBudget;
106
+ } {
107
+ if (typeof value !== "string") {
108
+ throw new Error("Session runtime contract is missing.");
109
+ }
110
+ const parsed = JSON.parse(value) as unknown;
111
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
112
+ throw new Error("Session runtime contract must be an object.");
113
+ }
114
+ const record = parsed as Record<string, unknown>;
115
+ const profileRecord = objectValue(record.contextProfile, "contextProfile");
116
+ const budgetRecord = objectValue(record.contextBudget, "contextBudget");
117
+ const profile = createModelContextProfile({
118
+ contextWindowTokens: positiveInteger(
119
+ profileRecord.contextWindowTokens,
120
+ "contextWindowTokens",
121
+ ),
122
+ maxSupportedOutputTokens: positiveInteger(
123
+ profileRecord.maxSupportedOutputTokens,
124
+ "maxSupportedOutputTokens",
125
+ ),
126
+ });
127
+ const budget: ModelContextBudget = {
128
+ contextWindowTokens: positiveInteger(
129
+ budgetRecord.contextWindowTokens,
130
+ "budget.contextWindowTokens",
131
+ ),
132
+ maxSupportedOutputTokens: positiveInteger(
133
+ budgetRecord.maxSupportedOutputTokens,
134
+ "budget.maxSupportedOutputTokens",
135
+ ),
136
+ requestMaxOutputTokens: positiveInteger(
137
+ budgetRecord.requestMaxOutputTokens,
138
+ "requestMaxOutputTokens",
139
+ ),
140
+ inputBudgetTokens: positiveInteger(
141
+ budgetRecord.inputBudgetTokens,
142
+ "inputBudgetTokens",
143
+ ),
144
+ triggerRatio: 0.8,
145
+ triggerTokens: positiveInteger(budgetRecord.triggerTokens, "triggerTokens"),
146
+ };
147
+ if (budgetRecord.triggerRatio !== 0.8) {
148
+ throw new Error("Session context triggerRatio must be 0.8.");
149
+ }
150
+ assertMatchingContextBudget(profile, budget);
151
+ return { profile, budget };
152
+ }
153
+
154
+ function projectTurn(
155
+ database: Database,
156
+ row: Record<string, unknown>,
157
+ policy: TuiProjectionPolicy,
158
+ ): TuiTurnProjection {
159
+ const turnId = requireString(row.turn_id, "turn_id");
160
+ const turnNumber = safeNumber(row.turn_number, "turn_number");
161
+ const status = enumValue(
162
+ row.status,
163
+ ["completed", "failed", "cancelled", "interrupted"] as const,
164
+ "turn status",
165
+ );
166
+ const startedAt = timestamp(row.started_at, "started_at");
167
+ const finishedAt = timestamp(row.finished_at, "finished_at");
168
+ const messages = database
169
+ .query("SELECT * FROM messages WHERE turn_id = ? ORDER BY ordinal")
170
+ .all(turnId) as Array<Record<string, unknown>>;
171
+ const promptRows = messages.filter((message) => message.role === "user");
172
+ if (promptRows.length !== 1) {
173
+ throw new Error(
174
+ `Turn ${turnId} must contain exactly one user message; found ${promptRows.length}.`,
175
+ );
176
+ }
177
+ const prompt = promptRows[0];
178
+ if (prompt === undefined) {
179
+ throw new Error(`Turn ${turnId} user message disappeared.`);
180
+ }
181
+ const allItems: TimelineItem[] = [
182
+ {
183
+ id: `resume-${requireString(prompt.message_id, "message_id")}`,
184
+ label: "prompt",
185
+ text: boundedText(requireString(prompt.content, "user content")),
186
+ status: "text",
187
+ },
188
+ ];
189
+ const assistantsByIteration = new Map<string, Record<string, unknown>>();
190
+ for (const message of messages) {
191
+ const role = requireString(message.role, "message role");
192
+ if (role !== "assistant") {
193
+ continue;
194
+ }
195
+ const iterationId = requireString(message.iteration_id, "iteration_id");
196
+ if (assistantsByIteration.has(iterationId)) {
197
+ throw new Error(`Iteration ${iterationId} has multiple assistant messages.`);
198
+ }
199
+ assistantsByIteration.set(iterationId, message);
200
+ }
201
+
202
+ const toolResultsByCall = new Map<string, Record<string, unknown>>();
203
+ const resultRows = database
204
+ .query(
205
+ `SELECT tool_results.*,
206
+ messages.tool_call_id AS message_tool_call_id,
207
+ messages.iteration_id AS message_iteration_id,
208
+ messages.name AS message_tool_name
209
+ FROM tool_results
210
+ JOIN messages ON messages.message_id = tool_results.tool_message_id
211
+ WHERE messages.turn_id = ?`,
212
+ )
213
+ .all(turnId) as Array<Record<string, unknown>>;
214
+ for (const result of resultRows) {
215
+ const toolCallId = requireString(result.tool_call_id, "tool result call ID");
216
+ if (
217
+ requireString(result.message_tool_call_id, "tool message call ID") !== toolCallId
218
+ ) {
219
+ throw new Error(`Tool result ${toolCallId} does not match its tool message.`);
220
+ }
221
+ if (toolResultsByCall.has(toolCallId)) {
222
+ throw new Error(`Tool call ${toolCallId} has multiple stored results.`);
223
+ }
224
+ toolResultsByCall.set(toolCallId, result);
225
+ }
226
+
227
+ const iterations = database
228
+ .query("SELECT * FROM iterations WHERE turn_id = ? ORDER BY iteration_number")
229
+ .all(turnId) as Array<Record<string, unknown>>;
230
+ for (const iteration of iterations) {
231
+ allItems.push(
232
+ ...projectIteration(
233
+ iteration,
234
+ turnId,
235
+ turnNumber,
236
+ assistantsByIteration,
237
+ toolResultsByCall,
238
+ ),
239
+ );
240
+ }
241
+ if (assistantsByIteration.size > 0) {
242
+ throw new Error(
243
+ `Turn ${turnId} has assistant messages without matching iterations.`,
244
+ );
245
+ }
246
+ if (toolResultsByCall.size > 0) {
247
+ throw new Error(`Turn ${turnId} has tool results without matching calls.`);
248
+ }
249
+
250
+ if (status === "failed" || status === "interrupted") {
251
+ const detail = terminalDetail(row.terminal_detail_json, status);
252
+ allItems.push({
253
+ id: `resume-${turnId}-terminal`,
254
+ label: status === "interrupted" ? "interrupted" : "error",
255
+ text: detail,
256
+ status: "failed",
257
+ });
258
+ }
259
+
260
+ const limited = limitItems(allItems, policy.itemLimitPerTurn);
261
+ return {
262
+ turnId,
263
+ turnNumber,
264
+ status,
265
+ startedAt,
266
+ workedForMs: Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)),
267
+ items: limited.items,
268
+ omittedItemCount: limited.omitted,
269
+ };
270
+ }
271
+
272
+ function projectIteration(
273
+ row: Record<string, unknown>,
274
+ turnId: string,
275
+ turnNumber: number,
276
+ assistantsByIteration: Map<string, Record<string, unknown>>,
277
+ toolResultsByCall: Map<string, Record<string, unknown>>,
278
+ ): TimelineItem[] {
279
+ const iterationId = requireString(row.iteration_id, "iteration_id");
280
+ const iterationNumber = safeNumber(row.iteration_number, "iteration_number");
281
+ const outcome = enumValue(
282
+ row.outcome,
283
+ ["open", "continue", "completed", "failed", "cancelled", "interrupted"] as const,
284
+ "iteration outcome",
285
+ );
286
+ const assistant = assistantsByIteration.get(iterationId);
287
+ if (assistant === undefined) {
288
+ return [projectUnansweredIteration(iterationId, iterationNumber, outcome)];
289
+ }
290
+ assistantsByIteration.delete(iterationId);
291
+
292
+ const assistantMessageId = requireString(assistant.message_id, "message_id");
293
+ const content = nullableText(assistant.content, "assistant content") ?? "";
294
+ const toolCalls =
295
+ assistant.tool_calls_json === null
296
+ ? []
297
+ : decodeStoredToolCalls(
298
+ requireString(assistant.tool_calls_json, "tool_calls_json"),
299
+ );
300
+ const items: TimelineItem[] = [
301
+ {
302
+ id: `model-${iterationId}`,
303
+ ref: `model-request-${iterationId}`,
304
+ text: completedModelRequestText(iterationNumber, toolCalls.length),
305
+ status: "ok",
306
+ },
307
+ ];
308
+
309
+ if (toolCalls.length === 0) {
310
+ if (content.trim() !== "") {
311
+ items.push({
312
+ id: `resume-final-${assistantMessageId}`,
313
+ label: "assistant",
314
+ text: boundedText(content),
315
+ status: "text",
316
+ });
317
+ }
318
+ return items;
319
+ }
320
+
321
+ if (content.trim() !== "") {
322
+ items.push({
323
+ id: `resume-progress-${assistantMessageId}`,
324
+ label: "assistant",
325
+ text: boundedText(content.trim()),
326
+ status: "text",
327
+ });
328
+ }
329
+ for (const call of toolCalls) {
330
+ assertToolCallIdentity(call, turnId, turnNumber, iterationId, iterationNumber);
331
+ const result = toolResultsByCall.get(call.toolCallId);
332
+ if (result === undefined) {
333
+ throw new Error(`Tool call ${call.toolCallId} is missing its stored result.`);
334
+ }
335
+ toolResultsByCall.delete(call.toolCallId);
336
+ items.push(projectToolCall(call, result));
337
+ }
338
+ return items;
339
+ }
340
+
341
+ function projectUnansweredIteration(
342
+ iterationId: string,
343
+ iterationNumber: number,
344
+ outcome: "open" | "continue" | "completed" | "failed" | "cancelled" | "interrupted",
345
+ ): TimelineItem {
346
+ const base = {
347
+ id: `model-${iterationId}`,
348
+ ref: `model-request-${iterationId}`,
349
+ };
350
+ if (outcome === "failed") {
351
+ return {
352
+ ...base,
353
+ text: `model iteration ${iterationNumber} -> failed`,
354
+ status: "failed",
355
+ };
356
+ }
357
+ if (outcome === "cancelled") {
358
+ return {
359
+ ...base,
360
+ text: `model iteration ${iterationNumber} -> cancelled`,
361
+ status: "cancelled",
362
+ };
363
+ }
364
+ if (outcome === "interrupted") {
365
+ return {
366
+ ...base,
367
+ text: `model iteration ${iterationNumber} -> interrupted`,
368
+ status: "cancelled",
369
+ };
370
+ }
371
+ throw new Error(
372
+ `Iteration ${iterationId} has outcome ${outcome} without an assistant message.`,
373
+ );
374
+ }
375
+
376
+ function projectToolCall(
377
+ call: ToolCall,
378
+ result: Record<string, unknown>,
379
+ ): TimelineItem {
380
+ if (
381
+ requireString(result.message_iteration_id, "tool message iteration ID") !==
382
+ call.iterationId
383
+ ) {
384
+ throw new Error(`Tool result ${call.toolCallId} belongs to another iteration.`);
385
+ }
386
+ if (requireString(result.message_tool_name, "tool message name") !== call.name) {
387
+ throw new Error(`Tool result ${call.toolCallId} has a mismatched tool name.`);
388
+ }
389
+
390
+ const base = {
391
+ id: `tool-${call.toolCallId}`,
392
+ ref: `tool-call-${call.toolCallId}`,
393
+ };
394
+ const completionKind = enumValue(
395
+ result.completion_kind,
396
+ ["returned", "synthetic"] as const,
397
+ "tool completion kind",
398
+ );
399
+ if (completionKind === "returned") {
400
+ const raw = decodeStoredToolRawResult(
401
+ parseStoredJson(requireString(result.raw_json, "tool raw JSON"), "tool raw JSON"),
402
+ );
403
+ return {
404
+ ...base,
405
+ status: raw.ok ? "ok" : "failed",
406
+ ...toolRawResultProjection(call, raw),
407
+ };
408
+ }
409
+
410
+ const started = toolCallStartedProjection(call);
411
+ const reason = enumValue(
412
+ result.synthetic_reason,
413
+ [
414
+ "cancelled_active",
415
+ "skipped_after_cancel",
416
+ "failed_active",
417
+ "skipped_after_failure",
418
+ "interrupted_active",
419
+ "skipped_after_interruption",
420
+ ] as const,
421
+ "synthetic tool result reason",
422
+ );
423
+ const detail = nullableText(result.synthetic_detail, "synthetic detail");
424
+ switch (reason) {
425
+ case "cancelled_active":
426
+ return {
427
+ ...base,
428
+ ...started,
429
+ text: `${started.text} -> cancelled`,
430
+ status: "cancelled",
431
+ };
432
+ case "skipped_after_cancel":
433
+ return {
434
+ ...base,
435
+ ...started,
436
+ text: `${started.text} -> skipped after cancellation`,
437
+ status: "cancelled",
438
+ };
439
+ case "failed_active":
440
+ return {
441
+ ...base,
442
+ ...started,
443
+ text: `${started.text} -> failed${detail === null ? "" : `: ${boundedText(detail)}`}`,
444
+ status: "failed",
445
+ };
446
+ case "skipped_after_failure":
447
+ return {
448
+ ...base,
449
+ ...started,
450
+ text: `${started.text} -> skipped after earlier tool failure`,
451
+ status: "cancelled",
452
+ };
453
+ case "interrupted_active":
454
+ return {
455
+ ...base,
456
+ ...started,
457
+ text: `${started.text} -> interrupted`,
458
+ status: "cancelled",
459
+ };
460
+ case "skipped_after_interruption":
461
+ return {
462
+ ...base,
463
+ ...started,
464
+ text: `${started.text} -> skipped after interruption`,
465
+ status: "cancelled",
466
+ };
467
+ }
468
+ }
469
+
470
+ function assertToolCallIdentity(
471
+ call: ToolCall,
472
+ turnId: string,
473
+ turnNumber: number,
474
+ iterationId: string,
475
+ iterationNumber: number,
476
+ ): void {
477
+ if (
478
+ call.turnId !== turnId ||
479
+ call.turnNumber !== turnNumber ||
480
+ call.iterationId !== iterationId ||
481
+ call.iterationNumber !== iterationNumber
482
+ ) {
483
+ throw new Error(`Tool call ${call.toolCallId} has mismatched stored identity.`);
484
+ }
485
+ }
486
+
487
+ function limitItems(
488
+ items: TimelineItem[],
489
+ limit: number,
490
+ ): { items: TimelineItem[]; omitted: number } {
491
+ if (items.length <= limit) {
492
+ return { items, omitted: 0 };
493
+ }
494
+ const prompt = items.find((item) => item.label === "prompt");
495
+ if (limit === 1 && prompt !== undefined) {
496
+ return { items: [prompt], omitted: items.length - 1 };
497
+ }
498
+ const tailLimit = prompt === undefined ? limit : limit - 1;
499
+ const tail = items.slice(-tailLimit);
500
+ const kept = prompt === undefined || tail.includes(prompt) ? tail : [prompt, ...tail];
501
+ return { items: kept, omitted: items.length - kept.length };
502
+ }
503
+
504
+ function terminalProjection(last: TuiTurnProjection | undefined): {
505
+ status: TuiProjectionState["status"];
506
+ finalText?: string;
507
+ error?: string;
508
+ } {
509
+ if (last === undefined) {
510
+ return { status: "idle" };
511
+ }
512
+ if (last.status === "completed") {
513
+ const finalText = [...last.items]
514
+ .reverse()
515
+ .find((item) => item.label === "assistant")?.text;
516
+ return { status: "done", ...(finalText === undefined ? {} : { finalText }) };
517
+ }
518
+ if (last.status === "cancelled") {
519
+ return { status: "cancelled" };
520
+ }
521
+ const error = last.items.at(-1)?.text;
522
+ return { status: "failed", ...(error === undefined ? {} : { error }) };
523
+ }
524
+
525
+ function terminalDetail(value: unknown, status: string): string {
526
+ if (typeof value !== "string") {
527
+ return status === "interrupted" ? "Turn was interrupted." : "Turn failed.";
528
+ }
529
+ try {
530
+ const parsed = JSON.parse(value) as { error?: unknown };
531
+ return typeof parsed.error === "string"
532
+ ? boundedText(parsed.error)
533
+ : status === "interrupted"
534
+ ? "Turn was interrupted when the previous process stopped."
535
+ : "Turn failed.";
536
+ } catch {
537
+ return status === "interrupted" ? "Turn was interrupted." : "Turn failed.";
538
+ }
539
+ }
540
+
541
+ function count(value: unknown): number {
542
+ if (typeof value !== "object" || value === null) {
543
+ throw new Error("Count query returned no row.");
544
+ }
545
+ return safeNumber((value as { count?: unknown }).count, "count");
546
+ }
547
+
548
+ function safeNumber(value: unknown, name: string): number {
549
+ const number = typeof value === "bigint" ? Number(value) : value;
550
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) {
551
+ throw new Error(`${name} must be a safe non-negative integer.`);
552
+ }
553
+ return number;
554
+ }
555
+
556
+ function requireString(value: unknown, name: string): string {
557
+ if (typeof value !== "string" || value === "") {
558
+ throw new Error(`${name} must be a non-empty string.`);
559
+ }
560
+ return value;
561
+ }
562
+
563
+ function nullableText(value: unknown, name: string): string | null {
564
+ if (value === null) {
565
+ return null;
566
+ }
567
+ if (typeof value !== "string") {
568
+ throw new Error(`${name} must be text or null.`);
569
+ }
570
+ return value;
571
+ }
572
+
573
+ function parseStoredJson(value: string, name: string): unknown {
574
+ try {
575
+ return JSON.parse(value) as unknown;
576
+ } catch (error) {
577
+ throw new Error(`${name} must be valid JSON.`, { cause: error });
578
+ }
579
+ }
580
+
581
+ function objectValue(value: unknown, name: string): Record<string, unknown> {
582
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
583
+ throw new Error(`${name} must be an object.`);
584
+ }
585
+ return value as Record<string, unknown>;
586
+ }
587
+
588
+ function positiveInteger(value: unknown, name: string): number {
589
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
590
+ throw new Error(`${name} must be a positive integer.`);
591
+ }
592
+ return value as number;
593
+ }
594
+
595
+ function timestamp(value: unknown, name: string): string {
596
+ const result = requireString(value, name);
597
+ if (Number.isNaN(Date.parse(result))) {
598
+ throw new Error(`${name} must be a timestamp.`);
599
+ }
600
+ return result;
601
+ }
602
+
603
+ function enumValue<const T extends readonly string[]>(
604
+ value: unknown,
605
+ values: T,
606
+ name: string,
607
+ ): T[number] {
608
+ if (typeof value !== "string" || !values.includes(value)) {
609
+ throw new Error(`${name} has unsupported value ${JSON.stringify(value)}.`);
610
+ }
611
+ return value;
612
+ }
613
+
614
+ function boundedText(value: string): string {
615
+ return value.length <= 4_000 ? value : `${value.slice(0, 4_000)}\n…`;
616
+ }