tinker-agent 2.10.0 → 2.12.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 (52) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +44 -67
  3. package/package.json +4 -3
  4. package/src/agent/loop.ts +50 -13
  5. package/src/agent/runtime-provider-retry.ts +115 -0
  6. package/src/agent/runtime-session-contracts.ts +13 -29
  7. package/src/agent/runtime-session.ts +42 -67
  8. package/src/cli/config.ts +0 -29
  9. package/src/cli/model-profiles.ts +0 -80
  10. package/src/cli/public-config-contract.ts +1 -82
  11. package/src/cli/tui-runner.tsx +3 -41
  12. package/src/events/observation-text-log.ts +7 -0
  13. package/src/events/types.ts +8 -0
  14. package/src/image/abortable-file-open.ts +54 -0
  15. package/src/image/image-asset-store.ts +7 -1
  16. package/src/memory/contracts.ts +0 -256
  17. package/src/memory/memory-create-tool.ts +3 -5
  18. package/src/memory/memory-files.ts +145 -0
  19. package/src/memory/memory-search-output.ts +23 -0
  20. package/src/memory/memory-search-tool.ts +79 -175
  21. package/src/memory/memory-search.ts +115 -0
  22. package/src/model/fake-model-client.ts +20 -1
  23. package/src/model/openai-model-utils.ts +45 -1
  24. package/src/model/openai-responses-mapping.ts +11 -0
  25. package/src/model/openai-responses-stream.ts +14 -0
  26. package/src/observation/observation-builder.ts +5 -1
  27. package/src/session/scoped-query-database.ts +27 -0
  28. package/src/session/session-history-access.ts +4 -3
  29. package/src/session/session-store-contracts.ts +0 -23
  30. package/src/session/session-store.ts +9 -106
  31. package/src/tools/grep.ts +12 -4
  32. package/src/tools/registry.ts +15 -18
  33. package/src/tools/types.ts +12 -0
  34. package/src/tui/app.tsx +51 -7
  35. package/src/tui/components/ask-user.tsx +15 -8
  36. package/src/tui/components/prompt-input.tsx +14 -6
  37. package/src/tui/components/timeline.tsx +17 -9
  38. package/src/tui/event-store.ts +13 -1
  39. package/src/tui/file-mention.ts +29 -5
  40. package/src/tui/tui-projection-store.ts +5 -2
  41. package/src/tui/tui-session-controller.ts +8 -0
  42. package/src/tui/workspace-file-search.ts +21 -0
  43. package/src/cli/tui-memory.ts +0 -69
  44. package/src/memory/embedding-client.ts +0 -105
  45. package/src/memory/memory-coordinator.ts +0 -1274
  46. package/src/memory/memory-delete-tool.ts +0 -88
  47. package/src/memory/memory-extractor.ts +0 -252
  48. package/src/memory/memory-get-tool.ts +0 -86
  49. package/src/memory/memory-log.ts +0 -88
  50. package/src/memory/memory-store.ts +0 -1133
  51. package/src/memory/memory-update-tool.ts +0 -142
  52. package/src/memory/vector.ts +0 -153
@@ -1,189 +1,93 @@
1
- import { throwIfTurnCancelled } from "../agent/turn-cancellation";
2
- import {
3
- defineToolExecutor,
4
- type MemorySearchRawResult,
5
- type ToolDefinition,
6
- type ToolExecutor,
7
- } from "../tools/types";
8
- import {
9
- MAX_MEMORY_KEYWORD_BYTES,
10
- MAX_MEMORY_KEYWORDS,
11
- MAX_MEMORY_QUERY_BYTES,
12
- MEMORY_SEARCH_TOOL_NAME,
13
- } from "./contracts";
14
-
15
- export const MEMORY_SEARCH_TOOL_DEFINITION: ToolDefinition = Object.freeze({
16
- name: MEMORY_SEARCH_TOOL_NAME,
17
- description:
18
- "Search memories retained from historical conversation turns across sessions and workspaces. Use this proactively when prior user preferences, project decisions, environment facts, or verified solutions may help. Put exact-match terms in `keywords`, put a concise semantic description in `query`, and provide both for hybrid recall. Each result is a historical record containing a one-line index text, a possibly truncated detailed summary, a `memoryId`, and a `sourceSessionId`. Results may be stale or incorrect, so verify current workspace facts with current tools. To read the full stored record for a result, call `MemoryGet` with its `memoryId`.",
19
- parameters: {
20
- type: "object",
21
- additionalProperties: false,
22
- properties: {
23
- query: {
24
- type: "string",
25
- minLength: 1,
26
- maxLength: MAX_MEMORY_QUERY_BYTES,
27
- description:
28
- "A concise semantic description of the fact, preference, decision, or solution to recall. Drives vector similarity search.",
29
- },
30
- keywords: {
31
- type: "array",
32
- maxItems: MAX_MEMORY_KEYWORDS,
33
- items: {
34
- type: "string",
35
- minLength: 1,
36
- maxLength: MAX_MEMORY_KEYWORD_BYTES,
37
- },
38
- description:
39
- "Exact terms to match literally, such as identifiers, error strings, paths, or project names. Drives keyword search. Keywords shorter than 3 characters cannot be matched.",
40
- },
41
- },
42
- required: [],
43
- },
44
- });
1
+ import { defineToolExecutor, type ToolExecutor } from "../tools/types";
2
+ import { boundedMemoryError } from "./contracts";
3
+ import { ensureMemoryDirectory } from "./memory-files";
4
+ import { searchMemoryFiles, type MemorySearchInput } from "./memory-search";
45
5
 
46
6
  export function createMemorySearchToolExecutor(options: {
47
- readonly search: (
48
- query: string | null,
49
- keywords: readonly string[],
50
- signal: AbortSignal,
51
- ) => Promise<MemorySearchRawResult>;
52
- readonly recordInvalidCall: (input: {
53
- readonly queryBytes: number;
54
- readonly keywordCount: number;
55
- }) => Promise<void>;
7
+ homeRoot?: string;
56
8
  }): ToolExecutor {
57
9
  return defineToolExecutor("memory_search", {
58
- definition: MEMORY_SEARCH_TOOL_DEFINITION,
59
- async execute(args, _call, context): Promise<MemorySearchRawResult> {
60
- throwIfTurnCancelled(context.signal);
61
- const parsed = parseMemorySearchArgs(args);
62
- if (!parsed.ok) {
63
- await options.recordInvalidCall({
64
- queryBytes: parsed.queryBytes,
65
- keywordCount: parsed.keywordCount,
66
- });
67
- return { ok: false, error: parsed.error };
10
+ definition: {
11
+ name: "MemorySearch",
12
+ description:
13
+ "Search historical session records and saved notes across workspaces when earlier decisions, preferences, or work may help. Matches any keyword literally, ignoring case, within individual lines. Returns passages grouped by file with line numbers; > marks matching lines and … separates passages. Use Read with offset and limit to expand a passage. Continue using nextOffset with the same keywords and context.",
14
+ parameters: {
15
+ type: "object",
16
+ additionalProperties: false,
17
+ properties: {
18
+ keywords: {
19
+ type: "array",
20
+ minItems: 1,
21
+ items: { type: "string", minLength: 1 },
22
+ description:
23
+ "Literal keywords; any one can match. No regular expressions or multiline keywords. A line matching multiple keywords counts once.",
24
+ },
25
+ context: {
26
+ type: "integer",
27
+ minimum: 0,
28
+ description:
29
+ "Lines before and after each matching line. Defaults to 3. Overlapping passages are merged.",
30
+ },
31
+ limit: {
32
+ type: "integer",
33
+ minimum: 1,
34
+ description:
35
+ "Maximum selected matching lines, excluding context. Defaults to 20.",
36
+ },
37
+ offset: {
38
+ type: "integer",
39
+ minimum: 0,
40
+ description:
41
+ "Matching lines to skip. Defaults to 0. Use nextOffset to continue; file changes between searches can affect pagination.",
42
+ },
43
+ },
44
+ required: ["keywords"],
45
+ },
46
+ },
47
+ async execute(args, _call, context) {
48
+ context.signal.throwIfAborted();
49
+ try {
50
+ const input = parseArgs(args);
51
+ const directory = await ensureMemoryDirectory(options.homeRoot);
52
+ return await searchMemoryFiles(directory, input, context.signal);
53
+ } catch (error) {
54
+ context.signal.throwIfAborted();
55
+ return { ok: false, error: boundedMemoryError(error) };
68
56
  }
69
- const result = await options.search(
70
- parsed.query,
71
- parsed.keywords,
72
- context.signal,
73
- );
74
- throwIfTurnCancelled(context.signal);
75
- return result;
76
57
  },
77
58
  });
78
59
  }
79
60
 
80
- type ParsedMemorySearchArgs =
81
- | {
82
- readonly ok: true;
83
- readonly query: string | null;
84
- readonly keywords: readonly string[];
85
- }
86
- | {
87
- readonly ok: false;
88
- readonly queryBytes: number;
89
- readonly keywordCount: number;
90
- readonly error: string;
91
- };
92
-
93
- function invalidMemorySearchArgs(
94
- args: Record<string, unknown>,
95
- error: string,
96
- queryBytes?: number,
97
- ): ParsedMemorySearchArgs {
98
- return {
99
- ok: false,
100
- queryBytes:
101
- queryBytes ??
102
- (typeof args.query === "string" ? Buffer.byteLength(args.query, "utf8") : 0),
103
- keywordCount: Array.isArray(args.keywords) ? args.keywords.length : 0,
104
- error,
105
- };
106
- }
107
-
108
- function parseMemorySearchArgs(args: unknown): ParsedMemorySearchArgs {
109
- if (!isRecord(args)) {
110
- return {
111
- ok: false,
112
- queryBytes: 0,
113
- keywordCount: 0,
114
- error:
115
- "MemorySearch arguments must be an object containing only query and keywords.",
116
- };
117
- }
118
- const unexpected = Object.keys(args).find(
119
- (key) => key !== "query" && key !== "keywords",
120
- );
121
- if (unexpected !== undefined) {
122
- return invalidMemorySearchArgs(
123
- args,
124
- `MemorySearch received unexpected field: ${unexpected}.`,
61
+ function parseArgs(args: unknown): MemorySearchInput {
62
+ if (args === null || typeof args !== "object" || Array.isArray(args))
63
+ throw new Error(
64
+ "MemorySearch requires keywords, with optional context, limit and offset.",
125
65
  );
66
+ const value = args as Record<string, unknown>;
67
+ for (const name of Object.keys(value)) {
68
+ if (!["keywords", "context", "limit", "offset"].includes(name))
69
+ throw new Error(`MemorySearch received unexpected field: ${name}.`);
126
70
  }
127
-
128
- let query: string | null = null;
129
- if (args.query !== undefined) {
130
- if (typeof args.query !== "string") {
131
- return invalidMemorySearchArgs(args, "MemorySearch.query must be a string.");
132
- }
133
- const trimmed = args.query.trim();
134
- const bytes = Buffer.byteLength(trimmed, "utf8");
135
- if (bytes < 1 || bytes > MAX_MEMORY_QUERY_BYTES) {
136
- return invalidMemorySearchArgs(
137
- args,
138
- `MemorySearch.query must be 1 to ${MAX_MEMORY_QUERY_BYTES} UTF-8 bytes after trimming.`,
139
- bytes,
71
+ if (!Array.isArray(value.keywords) || value.keywords.length === 0)
72
+ throw new Error("MemorySearch.keywords must be a non-empty array of strings.");
73
+ const keywords = value.keywords.map((word: unknown) => {
74
+ if (typeof word !== "string" || word.trim() === "" || /[\r\n]/.test(word))
75
+ throw new Error("MemorySearch keywords must be non-empty single-line strings.");
76
+ return word.trim();
77
+ });
78
+ const integers = { context: 3, limit: 20, offset: 0 };
79
+ for (const name of ["context", "limit", "offset"] as const) {
80
+ const number = value[name];
81
+ if (number === undefined) continue;
82
+ if (
83
+ typeof number !== "number" ||
84
+ !Number.isSafeInteger(number) ||
85
+ number < (name === "limit" ? 1 : 0)
86
+ )
87
+ throw new Error(
88
+ `MemorySearch.${name} must be a safe integer >= ${name === "limit" ? 1 : 0}.`,
140
89
  );
141
- }
142
- query = trimmed;
90
+ integers[name] = number;
143
91
  }
144
-
145
- const keywords: string[] = [];
146
- if (args.keywords !== undefined) {
147
- if (!Array.isArray(args.keywords)) {
148
- return invalidMemorySearchArgs(
149
- args,
150
- "MemorySearch.keywords must be an array of strings.",
151
- );
152
- }
153
- if (args.keywords.length > MAX_MEMORY_KEYWORDS) {
154
- return invalidMemorySearchArgs(
155
- args,
156
- `MemorySearch.keywords may contain at most ${MAX_MEMORY_KEYWORDS} entries.`,
157
- );
158
- }
159
- for (const entry of args.keywords) {
160
- if (typeof entry !== "string") {
161
- return invalidMemorySearchArgs(
162
- args,
163
- "Every MemorySearch keyword must be a string.",
164
- );
165
- }
166
- const trimmed = entry.trim();
167
- const bytes = Buffer.byteLength(trimmed, "utf8");
168
- if (bytes < 1 || bytes > MAX_MEMORY_KEYWORD_BYTES) {
169
- return invalidMemorySearchArgs(
170
- args,
171
- `Every MemorySearch keyword must be 1 to ${MAX_MEMORY_KEYWORD_BYTES} UTF-8 bytes after trimming.`,
172
- );
173
- }
174
- keywords.push(trimmed);
175
- }
176
- }
177
-
178
- if (query === null && keywords.length === 0) {
179
- return invalidMemorySearchArgs(
180
- args,
181
- "MemorySearch requires a non-empty query or at least one keyword.",
182
- );
183
- }
184
- return { ok: true, query, keywords: Object.freeze(keywords) };
185
- }
186
-
187
- function isRecord(value: unknown): value is Record<string, unknown> {
188
- return typeof value === "object" && value !== null && !Array.isArray(value);
92
+ return { keywords, ...integers };
189
93
  }
@@ -0,0 +1,115 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import type { MemoryTextSearchResult, MemorySearchLine } from "../tools/types";
6
+
7
+ export type MemorySearchInput = {
8
+ keywords: string[];
9
+ context: number;
10
+ limit: number;
11
+ offset: number;
12
+ };
13
+
14
+ export async function searchMemoryFiles(
15
+ directory: string,
16
+ input: MemorySearchInput,
17
+ signal: AbortSignal,
18
+ ): Promise<MemoryTextSearchResult> {
19
+ const paths = await markdownFiles(directory, signal);
20
+ const keywords = input.keywords.map((word) => word.toLowerCase());
21
+ const files: MemoryTextSearchResult["files"][number][] = [];
22
+ let seen = 0;
23
+ let returnedResults = 0;
24
+ let hasMore = false;
25
+ for (const filePath of paths) {
26
+ signal.throwIfAborted();
27
+ const stream = createReadStream(filePath, { encoding: "utf8", signal });
28
+ const reader = createInterface({ input: stream, crlfDelay: Infinity });
29
+ const before: MemorySearchLine[] = [];
30
+ const selected = new Map<number, MemorySearchLine>();
31
+ let lineNumber = 0;
32
+ let through = 0;
33
+ try {
34
+ for await (const text of reader) {
35
+ signal.throwIfAborted();
36
+ lineNumber++;
37
+ const folded = text.toLowerCase();
38
+ let matchAt = -1;
39
+ for (const keyword of keywords) {
40
+ const position = folded.indexOf(keyword);
41
+ if (position >= 0 && (matchAt < 0 || position < matchAt)) matchAt = position;
42
+ }
43
+ const match = matchAt >= 0;
44
+ const line = { lineNumber, match, text: excerpt(text, matchAt) };
45
+ if (match) {
46
+ if (seen >= input.offset && returnedResults < input.limit) {
47
+ returnedResults++;
48
+ for (const previous of before) selected.set(previous.lineNumber, previous);
49
+ through = lineNumber + input.context;
50
+ } else if (returnedResults === input.limit) {
51
+ hasMore = true;
52
+ }
53
+ seen++;
54
+ }
55
+ if (lineNumber <= through) selected.set(lineNumber, line);
56
+ if (hasMore && lineNumber >= through) break;
57
+ if (input.context > 0) {
58
+ before.push(line);
59
+ if (before.length > input.context) before.shift();
60
+ }
61
+ }
62
+ } finally {
63
+ reader.close();
64
+ stream.destroy();
65
+ }
66
+ if (selected.size > 0)
67
+ files.push({
68
+ filePath,
69
+ lines: [...selected.values()].sort((a, b) => a.lineNumber - b.lineNumber),
70
+ });
71
+ if (hasMore) break;
72
+ }
73
+ signal.throwIfAborted();
74
+ return {
75
+ ok: true,
76
+ format: "text",
77
+ files,
78
+ returnedResults,
79
+ hasMore,
80
+ ...(hasMore ? { nextOffset: input.offset + returnedResults } : {}),
81
+ };
82
+ }
83
+
84
+ async function markdownFiles(
85
+ directory: string,
86
+ signal: AbortSignal,
87
+ ): Promise<string[]> {
88
+ signal.throwIfAborted();
89
+ const files: string[] = [];
90
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
91
+ signal.throwIfAborted();
92
+ const filePath = path.join(directory, entry.name);
93
+ if (entry.isDirectory()) files.push(...(await markdownFiles(filePath, signal)));
94
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
95
+ files.push(filePath);
96
+ }
97
+ return files.sort();
98
+ }
99
+
100
+ /** Keep up to 500 original code points around the earliest literal match. */
101
+ function excerpt(text: string, foldedMatchOffset: number): string {
102
+ const points = [...text];
103
+ if (points.length <= 500) return text;
104
+ let matchPoint = 0;
105
+ let foldedOffset = 0;
106
+ if (foldedMatchOffset >= 0) {
107
+ while (matchPoint < points.length && foldedOffset < foldedMatchOffset) {
108
+ foldedOffset += points[matchPoint].toLowerCase().length;
109
+ matchPoint++;
110
+ }
111
+ }
112
+ const start = Math.max(0, matchPoint - 100);
113
+ const end = Math.min(points.length, start + 500);
114
+ return `${start > 0 ? `[... ${start} code points omitted ...]` : ""}${points.slice(start, end).join("")}${end < points.length ? `[... ${points.length - end} code points omitted ...]` : ""}`;
115
+ }
@@ -25,7 +25,7 @@ import type {
25
25
  PreparedModelRequest,
26
26
  PreparedPromptSegment,
27
27
  } from "./model-client";
28
- import { validateModelModalities } from "./model-client";
28
+ import { ProviderResponseError, validateModelModalities } from "./model-client";
29
29
  import { sha256, stableJsonStringify } from "./model-request-preflight";
30
30
  import { estimatePromptSegments } from "./token-estimator";
31
31
 
@@ -212,6 +212,25 @@ export class FakeModelClient implements ModelClient {
212
212
  );
213
213
  }
214
214
 
215
+ if (this.mode === "pty-provider-retry") {
216
+ if (lastUserMessage(input.messages) === "PTY_RETRY_NEXT") {
217
+ return textOutput(prepared, "PTY_RETRY_NEXT_DONE");
218
+ }
219
+ if (this.steps <= 4) {
220
+ options.onTextDelta?.(
221
+ `## Attempt ${this.steps}\nPartial response\n\n## Unfinished\nDRAFT_ONLY`,
222
+ );
223
+ throw new ProviderResponseError(
224
+ "reasoning_only_assistant",
225
+ "PTY_PROVIDER_FAILURE",
226
+ {
227
+ provider: prepared.provider,
228
+ model: prepared.model,
229
+ },
230
+ );
231
+ }
232
+ return textOutput(prepared, "PTY_RETRY_DONE");
233
+ }
215
234
  if (this.mode === "write-notes") {
216
235
  return this.writeNotes(input, prepared, options);
217
236
  }
@@ -358,12 +358,56 @@ function providerErrorCode(error: unknown): ProviderResponseErrorCode {
358
358
  if (status === 500 || status === 502 || status === 503 || status === 504) {
359
359
  return "provider_unavailable";
360
360
  }
361
- if (status === undefined && error instanceof OpenAI.APIConnectionError) {
361
+ // Explicit HTTP failures take precedence over payload or transport hints.
362
+ if (status !== undefined) return "provider_request_error";
363
+ const code = errorField(error, "code");
364
+ if (code === "server_error") return "provider_unavailable";
365
+ if (code === "rate_limit_exceeded") return "provider_rate_limited";
366
+ if (error instanceof OpenAI.APIConnectionError || isTransportFailure(error)) {
362
367
  return "provider_unavailable";
363
368
  }
364
369
  return "provider_request_error";
365
370
  }
366
371
 
372
+ const TRANSIENT_TRANSPORT_CODES = new Set([
373
+ "ECONNRESET",
374
+ "EPIPE",
375
+ "ETIMEDOUT",
376
+ "ERR_STREAM_PREMATURE_CLOSE",
377
+ "UND_ERR_SOCKET",
378
+ "UND_ERR_CONNECT_TIMEOUT",
379
+ "UND_ERR_HEADERS_TIMEOUT",
380
+ "UND_ERR_BODY_TIMEOUT",
381
+ ]);
382
+
383
+ function isTransportFailure(error: unknown): boolean {
384
+ const seen = new Set<unknown>();
385
+ // Fetch may wrap a socket error in TypeError.cause after headers arrive.
386
+ for (let depth = 0; error !== undefined && depth < 8; depth += 1) {
387
+ if (seen.has(error)) return false;
388
+ seen.add(error);
389
+ if (
390
+ errorField(error, "name") === "AbortError" ||
391
+ error instanceof OpenAI.APIUserAbortError ||
392
+ providerErrorStatus(error) !== undefined
393
+ ) {
394
+ return false;
395
+ }
396
+ const code = errorField(error, "code");
397
+ if (code !== undefined) {
398
+ return typeof code === "string" && TRANSIENT_TRANSPORT_CODES.has(code);
399
+ }
400
+ error = errorField(error, "cause");
401
+ }
402
+ return false;
403
+ }
404
+
405
+ function errorField(error: unknown, key: string): unknown {
406
+ return typeof error === "object" && error !== null
407
+ ? (error as Record<string, unknown>)[key]
408
+ : undefined;
409
+ }
410
+
367
411
  function providerErrorStatus(error: unknown): number | undefined {
368
412
  if (typeof error !== "object" || error === null || !("status" in error)) {
369
413
  return undefined;
@@ -28,6 +28,7 @@ import {
28
28
  type ProviderResponseDiagnostics,
29
29
  } from "./model-client";
30
30
  import { imageAssetUrlMarker } from "./openai-image-mapping";
31
+ import { sanitizedProviderError } from "./openai-model-utils";
31
32
 
32
33
  export type OpenAIResponsesMappingOptions = {
33
34
  materializedImages?: ReadonlyMap<ImageAssetId, string>;
@@ -160,6 +161,16 @@ export function fromOpenAIResponse(
160
161
  ): ModelRequestOutput {
161
162
  const root = requireRecord(response, "response", options);
162
163
  const status = requireString(root.status, "status", options);
164
+ if (status === "failed") {
165
+ const error = requireRecord(root.error, "error", options);
166
+ const code = requireString(error.code, "error.code", options);
167
+ const message = requireString(error.message, "error.message", options);
168
+ throw sanitizedProviderError(
169
+ Object.assign(new Error(message), { code }),
170
+ options.provider,
171
+ options.model,
172
+ );
173
+ }
163
174
  if (status !== "completed" && status !== "incomplete") {
164
175
  throw providerResponseError(
165
176
  options,
@@ -2,6 +2,7 @@ import {
2
2
  ProviderResponseError,
3
3
  type ProviderResponseDiagnostics,
4
4
  } from "./model-client";
5
+ import { sanitizedProviderError } from "./openai-model-utils";
5
6
 
6
7
  export class OpenAIResponsesStreamAccumulator {
7
8
  private eventCount = 0;
@@ -20,6 +21,19 @@ export class OpenAIResponsesStreamAccumulator {
20
21
  const type = requireString(record.type, `${path}.type`, this.options);
21
22
  this.eventCount += 1;
22
23
 
24
+ if (type === "error") {
25
+ const message = requireString(record.message, `${path}.message`, this.options);
26
+ const code =
27
+ record.code === null
28
+ ? null
29
+ : requireString(record.code, `${path}.code`, this.options);
30
+ throw sanitizedProviderError(
31
+ Object.assign(new Error(message), { code }),
32
+ this.options.provider,
33
+ this.options.model,
34
+ );
35
+ }
36
+
23
37
  if (type === "response.output_text.delta") {
24
38
  return requireString(record.delta, `${path}.delta`, this.options);
25
39
  }
@@ -1,3 +1,4 @@
1
+ import { renderMemoryTextSearch } from "../memory/memory-search-output";
1
2
  import type { ToolCall } from "../agent/types";
2
3
  import type { ToolResultContent } from "../agent/types";
3
4
  import {
@@ -410,6 +411,7 @@ function renderMemorySearchObservation(raw: MemorySearchRawResult): string {
410
411
  if (!raw.ok) {
411
412
  return `MemorySearch unavailable: ${raw.error}`;
412
413
  }
414
+ if ("format" in raw) return renderMemoryTextSearch(raw);
413
415
  const degradedNote =
414
416
  raw.degraded === "vector"
415
417
  ? " vector search unavailable; keyword results only."
@@ -467,7 +469,9 @@ function renderMemoryCreateObservation(
467
469
  }
468
470
  const text = memoryMutationText(call);
469
471
  const result = `MemoryCreate ${raw.status} memory=${raw.memoryId} created_at=${raw.createdAt}.`;
470
- return text === undefined ? result : `${result}\ntext: ${text}`;
472
+ const location =
473
+ raw.filePath === undefined ? result : `${result}\nPath: ${raw.filePath}`;
474
+ return text === undefined ? location : `${location}\ntext: ${text}`;
471
475
  }
472
476
 
473
477
  function renderMemoryUpdateObservation(
@@ -0,0 +1,27 @@
1
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
2
+
3
+ /**
4
+ * Own every query statement until a short-lived connection closes. Bun only
5
+ * caches its first 20 queries; later statements otherwise survive close() until
6
+ * GC, leaving a zombie SQLite connection behind. Do not use this for resident
7
+ * stores: retaining every query is intentionally bounded by the operation.
8
+ */
9
+ export class ScopedQueryDatabase extends Database {
10
+ private readonly queries = new Set<{ finalize(): void }>();
11
+
12
+ override query<Result, Params extends SQLQueryBindings | SQLQueryBindings[]>(
13
+ sql: string,
14
+ ) {
15
+ const statement = super.query<Result, Params>(sql);
16
+ this.queries.add(statement);
17
+ return statement;
18
+ }
19
+
20
+ override close(): void {
21
+ for (const statement of this.queries) statement.finalize();
22
+ this.queries.clear();
23
+ // Also finalizes Bun's transaction statements and rejects any other live
24
+ // resources instead of silently deferring the underlying connection close.
25
+ super.close(true);
26
+ }
27
+ }
@@ -1,4 +1,5 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "bun:sqlite";
2
+ import { ScopedQueryDatabase } from "./scoped-query-database";
2
3
  import { lstat, readdir, realpath } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { throwIfTurnCancelled } from "../agent/turn-cancellation";
@@ -67,7 +68,7 @@ export function createSessionHistoryAccess(input: {
67
68
  throwIfTurnCancelled(signal);
68
69
  await validateHistoryFiles(location.databasePath, sessionId);
69
70
  throwIfTurnCancelled(signal);
70
- const database = new Database(location.databasePath, {
71
+ const database = new ScopedQueryDatabase(location.databasePath, {
71
72
  readonly: true,
72
73
  strict: true,
73
74
  safeIntegers: true,
@@ -100,7 +101,7 @@ export function createSessionHistoryAccess(input: {
100
101
  throwIfTurnCancelled(signal);
101
102
  return result;
102
103
  } finally {
103
- database.close(true);
104
+ database.close();
104
105
  }
105
106
  } catch (error) {
106
107
  throwIfTurnCancelled(signal);
@@ -28,29 +28,6 @@ export type SessionMediaCompatibility = {
28
28
  readonly toolResultModalities: readonly ("text" | "image")[];
29
29
  };
30
30
 
31
- export type CompletedTurnMessageSnapshot =
32
- | {
33
- readonly ordinal: number;
34
- readonly role: "user";
35
- readonly content: string;
36
- }
37
- | {
38
- readonly ordinal: number;
39
- readonly role: "assistant";
40
- readonly content: string | null;
41
- readonly reasoningContent?: string | null;
42
- }
43
- | {
44
- readonly ordinal: number;
45
- readonly role: "tool";
46
- readonly name: string;
47
- readonly content: string;
48
- };
49
-
50
- export type CompletedTurnSnapshot = {
51
- readonly messages: readonly CompletedTurnMessageSnapshot[];
52
- };
53
-
54
31
  export type SessionCompatibilityContract = {
55
32
  modelName: string;
56
33
  profileName?: string;