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,319 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { Buffer } from "node:buffer";
3
+ import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
4
+ import { sha256Bytes } from "./hash";
5
+ import { resolveWorkspacePath } from "./path-safety";
6
+ import { defineToolExecutor } from "./types";
7
+ import type {
8
+ ReadFileRawResult,
9
+ ReadSnapshotStore,
10
+ ToolExecutionContext,
11
+ ToolExecutor,
12
+ } from "./types";
13
+
14
+ type ReadArgs = {
15
+ file_path: string;
16
+ offset?: number;
17
+ limit?: number;
18
+ };
19
+
20
+ export type ReadToolOptions = {
21
+ workspaceRoot: string;
22
+ snapshots: ReadSnapshotStore;
23
+ maxContentBytes?: number;
24
+ };
25
+
26
+ export const DEFAULT_MAX_READ_CONTENT_BYTES = 256 * 1024;
27
+
28
+ export function createReadToolExecutor(options: ReadToolOptions): ToolExecutor {
29
+ const maxContentBytes = options.maxContentBytes ?? DEFAULT_MAX_READ_CONTENT_BYTES;
30
+
31
+ return defineToolExecutor("read", {
32
+ definition: {
33
+ name: "Read",
34
+ description:
35
+ "Read a file from the local workspace. Use offset and limit for line ranges.",
36
+ parameters: {
37
+ type: "object",
38
+ additionalProperties: false,
39
+ properties: {
40
+ file_path: {
41
+ type: "string",
42
+ description: "Workspace-relative path or absolute path.",
43
+ },
44
+ offset: {
45
+ type: "integer",
46
+ minimum: 1,
47
+ description: "1-based line number to start reading from.",
48
+ },
49
+ limit: {
50
+ type: "integer",
51
+ minimum: 1,
52
+ description: "Maximum number of lines to read.",
53
+ },
54
+ },
55
+ required: ["file_path"],
56
+ },
57
+ },
58
+ async execute(
59
+ args,
60
+ _call,
61
+ context: ToolExecutionContext,
62
+ ): Promise<ReadFileRawResult> {
63
+ throwIfTurnCancelled(context.signal);
64
+ const parsed = parseReadArgs(args);
65
+
66
+ if (!parsed.ok) {
67
+ return {
68
+ ok: false,
69
+ filePath: "",
70
+ error: parsed.error,
71
+ };
72
+ }
73
+
74
+ const input = parsed.value;
75
+ let absolutePath: string;
76
+
77
+ try {
78
+ absolutePath = resolveWorkspacePath(options.workspaceRoot, input.file_path);
79
+ } catch (error) {
80
+ if (context.signal.aborted) {
81
+ throw cancellationError(context.signal, error);
82
+ }
83
+
84
+ return {
85
+ ok: false,
86
+ filePath: input.file_path,
87
+ error: errorMessage(error),
88
+ };
89
+ }
90
+
91
+ try {
92
+ const info = await stat(absolutePath);
93
+ throwIfTurnCancelled(context.signal);
94
+
95
+ if (!info.isFile()) {
96
+ return {
97
+ ok: false,
98
+ filePath: input.file_path,
99
+ absolutePath,
100
+ error: "Path is not a file.",
101
+ };
102
+ }
103
+
104
+ const bytes = await readFile(absolutePath);
105
+ throwIfTurnCancelled(context.signal);
106
+ const currentInfo = await stat(absolutePath);
107
+ throwIfTurnCancelled(context.signal);
108
+
109
+ if (currentInfo.mtimeMs > info.mtimeMs) {
110
+ return {
111
+ ok: false,
112
+ filePath: input.file_path,
113
+ absolutePath,
114
+ error: "File changed while it was being read. Read it again.",
115
+ };
116
+ }
117
+
118
+ const sha256 = sha256Bytes(bytes);
119
+ const text = bytes.toString("utf8");
120
+ const lines = splitLines(text);
121
+ const offset = input.offset ?? 1;
122
+
123
+ if (lines.length === 0) {
124
+ if (input.offset !== undefined || input.limit !== undefined) {
125
+ return {
126
+ ok: false,
127
+ filePath: input.file_path,
128
+ absolutePath,
129
+ error: "File is empty. Omit offset and limit to read the empty file.",
130
+ };
131
+ }
132
+
133
+ options.snapshots.set(absolutePath, {
134
+ sha256,
135
+ mtimeMs: currentInfo.mtimeMs,
136
+ source: "read",
137
+ });
138
+
139
+ return {
140
+ ok: true,
141
+ filePath: input.file_path,
142
+ absolutePath,
143
+ content: "",
144
+ contentBytes: 0,
145
+ sizeBytes: currentInfo.size,
146
+ totalLines: 0,
147
+ sha256,
148
+ };
149
+ }
150
+
151
+ if (offset > lines.length) {
152
+ return {
153
+ ok: false,
154
+ filePath: input.file_path,
155
+ absolutePath,
156
+ error: `Read.offset ${offset} exceeds the file's ${lines.length} lines.`,
157
+ };
158
+ }
159
+
160
+ const startIndex = offset - 1;
161
+ const selectedLines = lines.slice(
162
+ startIndex,
163
+ input.limit === undefined ? undefined : startIndex + input.limit,
164
+ );
165
+ const selectedText = selectedLines.join("\n");
166
+ const contentBytes = Buffer.byteLength(selectedText, "utf8");
167
+ const endLine = offset + selectedLines.length - 1;
168
+
169
+ if (contentBytes > maxContentBytes) {
170
+ return {
171
+ ok: false,
172
+ filePath: input.file_path,
173
+ absolutePath,
174
+ sizeBytes: currentInfo.size,
175
+ totalLines: lines.length,
176
+ startLine: offset,
177
+ endLine,
178
+ error: readSizeError({
179
+ contentBytes,
180
+ endLine,
181
+ explicitLimit: input.limit !== undefined,
182
+ maxContentBytes,
183
+ selectedLineCount: selectedLines.length,
184
+ startLine: offset,
185
+ }),
186
+ };
187
+ }
188
+
189
+ options.snapshots.set(absolutePath, {
190
+ sha256,
191
+ mtimeMs: currentInfo.mtimeMs,
192
+ source: "read",
193
+ });
194
+
195
+ return {
196
+ ok: true,
197
+ filePath: input.file_path,
198
+ absolutePath,
199
+ content: selectedText,
200
+ contentBytes,
201
+ sizeBytes: currentInfo.size,
202
+ totalLines: lines.length,
203
+ startLine: offset,
204
+ endLine,
205
+ sha256,
206
+ };
207
+ } catch (error) {
208
+ if (context.signal.aborted) {
209
+ throw cancellationError(context.signal, error);
210
+ }
211
+
212
+ return {
213
+ ok: false,
214
+ filePath: input.file_path,
215
+ absolutePath,
216
+ error: errorMessage(error),
217
+ };
218
+ }
219
+ },
220
+ });
221
+ }
222
+
223
+ function parseReadArgs(
224
+ args: unknown,
225
+ ): { ok: true; value: ReadArgs } | { ok: false; error: string } {
226
+ if (!isRecord(args)) {
227
+ return { ok: false, error: "Read arguments must be an object." };
228
+ }
229
+
230
+ if (typeof args.file_path !== "string") {
231
+ return { ok: false, error: "Read.file_path must be a string." };
232
+ }
233
+
234
+ const offset = parseOptionalPositiveInteger(args.offset, "Read.offset");
235
+ if (!offset.ok) {
236
+ return offset;
237
+ }
238
+
239
+ const limit = parseOptionalPositiveInteger(args.limit, "Read.limit");
240
+ if (!limit.ok) {
241
+ return limit;
242
+ }
243
+
244
+ return {
245
+ ok: true,
246
+ value: {
247
+ file_path: args.file_path,
248
+ offset: offset.value,
249
+ limit: limit.value,
250
+ },
251
+ };
252
+ }
253
+
254
+ function parseOptionalPositiveInteger(
255
+ value: unknown,
256
+ name: string,
257
+ ): { ok: true; value?: number } | { ok: false; error: string } {
258
+ if (value === undefined) {
259
+ return { ok: true };
260
+ }
261
+
262
+ if (!Number.isInteger(value) || typeof value !== "number" || value < 1) {
263
+ return { ok: false, error: `${name} must be a positive integer.` };
264
+ }
265
+
266
+ return { ok: true, value };
267
+ }
268
+
269
+ function splitLines(text: string): string[] {
270
+ if (text.length === 0) {
271
+ return [];
272
+ }
273
+
274
+ const lines = text.split(/\r\n|\n|\r/);
275
+ if (text.endsWith("\n") || text.endsWith("\r")) {
276
+ lines.pop();
277
+ }
278
+
279
+ return lines;
280
+ }
281
+
282
+ function readSizeError(input: {
283
+ contentBytes: number;
284
+ endLine: number;
285
+ explicitLimit: boolean;
286
+ maxContentBytes: number;
287
+ selectedLineCount: number;
288
+ startLine: number;
289
+ }): string {
290
+ if (input.selectedLineCount === 1) {
291
+ return (
292
+ `Line ${input.startLine} is ${input.contentBytes} bytes and exceeds the ` +
293
+ `${input.maxContentBytes}-byte Read limit. This line cannot be read with ` +
294
+ "line-based pagination."
295
+ );
296
+ }
297
+
298
+ if (input.explicitLimit) {
299
+ return (
300
+ `Requested lines ${input.startLine}-${input.endLine} contain ` +
301
+ `${input.contentBytes} bytes and exceed the ${input.maxContentBytes}-byte ` +
302
+ "Read limit. Reduce limit to request a smaller line range."
303
+ );
304
+ }
305
+
306
+ return (
307
+ `File content (${input.contentBytes} bytes) exceeds maximum allowed size ` +
308
+ `(${input.maxContentBytes} bytes). Use offset and limit parameters to read ` +
309
+ "specific portions of the file."
310
+ );
311
+ }
312
+
313
+ function isRecord(value: unknown): value is Record<string, unknown> {
314
+ return typeof value === "object" && value !== null && !Array.isArray(value);
315
+ }
316
+
317
+ function errorMessage(error: unknown): string {
318
+ return error instanceof Error ? error.message : String(error);
319
+ }
@@ -0,0 +1,400 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
3
+ import {
4
+ MessageSourceParseError,
5
+ parseMessageSource,
6
+ type MessageSource,
7
+ } from "../context/context-source";
8
+ import { SessionError } from "../session/session-errors";
9
+ import {
10
+ RecallHistoryError,
11
+ type RecallRole,
12
+ type RecallSearchFilters,
13
+ type SessionHistoryReader,
14
+ } from "../session/session-history-reader";
15
+ import {
16
+ defineToolExecutor,
17
+ ToolExecutionFatalError,
18
+ type RecallRawResult,
19
+ type RecallToolErrorCode,
20
+ type ToolExecutionContext,
21
+ type ToolExecutor,
22
+ } from "./types";
23
+
24
+ type RecallSearchArgs = {
25
+ mode: "search";
26
+ query: string;
27
+ roles?: RecallRole[];
28
+ toolNames?: string[];
29
+ turnFrom?: number;
30
+ turnTo?: number;
31
+ limit: number;
32
+ offset: number;
33
+ snapshotThroughOrdinal?: number;
34
+ };
35
+
36
+ type RecallGetArgs = {
37
+ mode: "get";
38
+ source: MessageSource;
39
+ byteOffset: number;
40
+ byteLimit: number;
41
+ };
42
+
43
+ type ParsedRecallArgs = RecallSearchArgs | RecallGetArgs;
44
+
45
+ type ParseResult =
46
+ | { ok: true; value: ParsedRecallArgs }
47
+ | {
48
+ ok: false;
49
+ mode: "search" | "get";
50
+ errorCode: "RECALL_ARGS_INVALID" | "RECALL_SOURCE_INVALID";
51
+ error: string;
52
+ };
53
+
54
+ export function createRecallToolExecutor(options: {
55
+ historyReader: SessionHistoryReader;
56
+ }): ToolExecutor {
57
+ return defineToolExecutor("recall", {
58
+ definition: {
59
+ name: "Recall",
60
+ description:
61
+ "Search or retrieve immutable model-visible history from the current session. Results are historical snapshots and may differ from the current workspace. Use search to find a source, get to retrieve exact content, and Read/Grep for current files.",
62
+ parameters: {
63
+ type: "object",
64
+ additionalProperties: false,
65
+ properties: {
66
+ mode: { type: "string", enum: ["search", "get"] },
67
+ query: { type: "string", maxLength: 1024 },
68
+ roles: {
69
+ type: "array",
70
+ minItems: 1,
71
+ uniqueItems: true,
72
+ items: { type: "string", enum: ["user", "assistant", "tool"] },
73
+ },
74
+ tool_names: {
75
+ type: "array",
76
+ minItems: 1,
77
+ maxItems: 16,
78
+ uniqueItems: true,
79
+ items: { type: "string", minLength: 1 },
80
+ },
81
+ turn_from: { type: "integer", minimum: 1 },
82
+ turn_to: { type: "integer", minimum: 1 },
83
+ limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
84
+ offset: { type: "integer", minimum: 0, default: 0 },
85
+ snapshot_through_ordinal: { type: "integer", minimum: 1 },
86
+ source: { type: "string" },
87
+ byte_offset: { type: "integer", minimum: 0, default: 0 },
88
+ byte_limit: {
89
+ type: "integer",
90
+ minimum: 256,
91
+ maximum: 20_000,
92
+ default: 12_000,
93
+ },
94
+ },
95
+ required: ["mode"],
96
+ },
97
+ },
98
+ async execute(
99
+ args,
100
+ _call,
101
+ context: ToolExecutionContext,
102
+ ): Promise<RecallRawResult> {
103
+ throwIfTurnCancelled(context.signal);
104
+ const parsed = parseRecallArgs(args);
105
+ if (!parsed.ok) {
106
+ return recallFailure(parsed.mode, parsed.errorCode, parsed.error);
107
+ }
108
+
109
+ try {
110
+ if (parsed.value.mode === "get") {
111
+ const page = options.historyReader.get({
112
+ source: parsed.value.source,
113
+ byteOffset: parsed.value.byteOffset,
114
+ byteLimit: parsed.value.byteLimit,
115
+ });
116
+ throwIfTurnCancelled(context.signal);
117
+ return { ok: true, mode: "get", historical: true, page };
118
+ }
119
+
120
+ const input = parsed.value;
121
+ const page = options.historyReader.search({
122
+ query: input.query,
123
+ ...(input.roles === undefined ? {} : { roles: input.roles }),
124
+ ...(input.toolNames === undefined ? {} : { toolNames: input.toolNames }),
125
+ ...(input.turnFrom === undefined ? {} : { turnFrom: input.turnFrom }),
126
+ ...(input.turnTo === undefined ? {} : { turnTo: input.turnTo }),
127
+ limit: input.limit,
128
+ offset: input.offset,
129
+ ...(input.snapshotThroughOrdinal === undefined
130
+ ? {}
131
+ : { snapshotThroughOrdinal: input.snapshotThroughOrdinal }),
132
+ });
133
+ throwIfTurnCancelled(context.signal);
134
+ const filters: RecallSearchFilters = Object.freeze({
135
+ ...(input.roles === undefined
136
+ ? {}
137
+ : { roles: Object.freeze([...input.roles]) }),
138
+ ...(input.toolNames === undefined
139
+ ? {}
140
+ : { toolNames: Object.freeze([...input.toolNames]) }),
141
+ ...(input.turnFrom === undefined ? {} : { turnFrom: input.turnFrom }),
142
+ ...(input.turnTo === undefined ? {} : { turnTo: input.turnTo }),
143
+ });
144
+ return {
145
+ ok: true,
146
+ mode: "search",
147
+ historical: true,
148
+ query: input.query,
149
+ filters,
150
+ page,
151
+ };
152
+ } catch (error) {
153
+ if (error instanceof RecallHistoryError) {
154
+ return recallFailure(parsed.value.mode, error.code, error.message);
155
+ }
156
+ if (error instanceof SessionError) {
157
+ throw new ToolExecutionFatalError("Recall required history storage failed.", {
158
+ cause: error,
159
+ });
160
+ }
161
+ throw error;
162
+ }
163
+ },
164
+ });
165
+ }
166
+
167
+ function parseRecallArgs(args: unknown): ParseResult {
168
+ if (!isRecord(args)) {
169
+ return argsFailure("search", "Recall arguments must be an object.");
170
+ }
171
+ const mode = args.mode === "get" ? "get" : "search";
172
+ if (args.mode !== "search" && args.mode !== "get") {
173
+ return argsFailure(mode, 'Recall.mode must be "search" or "get".');
174
+ }
175
+ return args.mode === "search" ? parseSearchArgs(args) : parseGetArgs(args);
176
+ }
177
+
178
+ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
179
+ const allowed = new Set([
180
+ "mode",
181
+ "query",
182
+ "roles",
183
+ "tool_names",
184
+ "turn_from",
185
+ "turn_to",
186
+ "limit",
187
+ "offset",
188
+ "snapshot_through_ordinal",
189
+ ]);
190
+ const unexpected = Object.keys(args).find((key) => !allowed.has(key));
191
+ if (unexpected !== undefined) {
192
+ return argsFailure(
193
+ "search",
194
+ `Recall search received unexpected field: ${unexpected}.`,
195
+ );
196
+ }
197
+ if (
198
+ typeof args.query !== "string" ||
199
+ args.query.trim() === "" ||
200
+ Buffer.byteLength(args.query, "utf8") > 1024
201
+ ) {
202
+ return argsFailure(
203
+ "search",
204
+ "Recall search query must be non-empty and at most 1024 UTF-8 bytes.",
205
+ );
206
+ }
207
+
208
+ const roles = parseRoles(args.roles);
209
+ if (!roles.ok) {
210
+ return argsFailure("search", roles.error);
211
+ }
212
+ const toolNames = parseToolNames(args.tool_names);
213
+ if (!toolNames.ok) {
214
+ return argsFailure("search", toolNames.error);
215
+ }
216
+ if (
217
+ toolNames.value !== undefined &&
218
+ roles.value !== undefined &&
219
+ (roles.value.length !== 1 || roles.value[0] !== "tool")
220
+ ) {
221
+ return argsFailure(
222
+ "search",
223
+ "Recall.tool_names requires roles to be omitted or contain only tool.",
224
+ );
225
+ }
226
+
227
+ const turnFrom = optionalInteger(args.turn_from, "Recall.turn_from", 1);
228
+ if (!turnFrom.ok) {
229
+ return argsFailure("search", turnFrom.error);
230
+ }
231
+ const turnTo = optionalInteger(args.turn_to, "Recall.turn_to", 1);
232
+ if (!turnTo.ok) {
233
+ return argsFailure("search", turnTo.error);
234
+ }
235
+ if (
236
+ turnFrom.value !== undefined &&
237
+ turnTo.value !== undefined &&
238
+ turnFrom.value > turnTo.value
239
+ ) {
240
+ return argsFailure("search", "Recall.turn_from must not exceed turn_to.");
241
+ }
242
+ const limit = optionalInteger(args.limit, "Recall.limit", 1, 20);
243
+ if (!limit.ok) {
244
+ return argsFailure("search", limit.error);
245
+ }
246
+ const offset = optionalInteger(args.offset, "Recall.offset", 0);
247
+ if (!offset.ok) {
248
+ return argsFailure("search", offset.error);
249
+ }
250
+ const snapshot = optionalInteger(
251
+ args.snapshot_through_ordinal,
252
+ "Recall.snapshot_through_ordinal",
253
+ 1,
254
+ );
255
+ if (!snapshot.ok) {
256
+ return argsFailure("search", snapshot.error);
257
+ }
258
+
259
+ return {
260
+ ok: true,
261
+ value: {
262
+ mode: "search",
263
+ query: args.query,
264
+ ...(roles.value === undefined ? {} : { roles: roles.value }),
265
+ ...(toolNames.value === undefined ? {} : { toolNames: toolNames.value }),
266
+ ...(turnFrom.value === undefined ? {} : { turnFrom: turnFrom.value }),
267
+ ...(turnTo.value === undefined ? {} : { turnTo: turnTo.value }),
268
+ limit: limit.value ?? 10,
269
+ offset: offset.value ?? 0,
270
+ ...(snapshot.value === undefined
271
+ ? {}
272
+ : { snapshotThroughOrdinal: snapshot.value }),
273
+ },
274
+ };
275
+ }
276
+
277
+ function parseGetArgs(args: Record<string, unknown>): ParseResult {
278
+ const allowed = new Set(["mode", "source", "byte_offset", "byte_limit"]);
279
+ const unexpected = Object.keys(args).find((key) => !allowed.has(key));
280
+ if (unexpected !== undefined) {
281
+ return argsFailure("get", `Recall get received unexpected field: ${unexpected}.`);
282
+ }
283
+ if (typeof args.source !== "string") {
284
+ return sourceFailure("Recall.source must be a string.");
285
+ }
286
+ let source: MessageSource;
287
+ try {
288
+ parseMessageSource(args.source);
289
+ source = args.source as MessageSource;
290
+ } catch (error) {
291
+ if (error instanceof MessageSourceParseError) {
292
+ return sourceFailure(error.message);
293
+ }
294
+ throw error;
295
+ }
296
+ const byteOffset = optionalInteger(args.byte_offset, "Recall.byte_offset", 0);
297
+ if (!byteOffset.ok) {
298
+ return argsFailure("get", byteOffset.error);
299
+ }
300
+ const byteLimit = optionalInteger(args.byte_limit, "Recall.byte_limit", 256, 20_000);
301
+ if (!byteLimit.ok) {
302
+ return argsFailure("get", byteLimit.error);
303
+ }
304
+ return {
305
+ ok: true,
306
+ value: {
307
+ mode: "get",
308
+ source,
309
+ byteOffset: byteOffset.value ?? 0,
310
+ byteLimit: byteLimit.value ?? 12_000,
311
+ },
312
+ };
313
+ }
314
+
315
+ function parseRoles(
316
+ value: unknown,
317
+ ): { ok: true; value?: RecallRole[] } | { ok: false; error: string } {
318
+ if (value === undefined) {
319
+ return { ok: true };
320
+ }
321
+ if (
322
+ !Array.isArray(value) ||
323
+ value.length === 0 ||
324
+ value.some(
325
+ (entry) => entry !== "user" && entry !== "assistant" && entry !== "tool",
326
+ ) ||
327
+ new Set(value).size !== value.length
328
+ ) {
329
+ return {
330
+ ok: false,
331
+ error:
332
+ "Recall.roles must be a non-empty, unique list of user, assistant, or tool.",
333
+ };
334
+ }
335
+ return { ok: true, value: value as RecallRole[] };
336
+ }
337
+
338
+ function parseToolNames(
339
+ value: unknown,
340
+ ): { ok: true; value?: string[] } | { ok: false; error: string } {
341
+ if (value === undefined) {
342
+ return { ok: true };
343
+ }
344
+ if (
345
+ !Array.isArray(value) ||
346
+ value.length === 0 ||
347
+ value.length > 16 ||
348
+ value.some((entry) => typeof entry !== "string" || entry.trim() === "") ||
349
+ new Set(value).size !== value.length
350
+ ) {
351
+ return {
352
+ ok: false,
353
+ error: "Recall.tool_names must contain 1 to 16 unique, non-empty tool names.",
354
+ };
355
+ }
356
+ return { ok: true, value: value as string[] };
357
+ }
358
+
359
+ function optionalInteger(
360
+ value: unknown,
361
+ name: string,
362
+ minimum: number,
363
+ maximum = Number.MAX_SAFE_INTEGER,
364
+ ): { ok: true; value?: number } | { ok: false; error: string } {
365
+ if (value === undefined) {
366
+ return { ok: true };
367
+ }
368
+ if (
369
+ typeof value !== "number" ||
370
+ !Number.isSafeInteger(value) ||
371
+ value < minimum ||
372
+ value > maximum
373
+ ) {
374
+ return {
375
+ ok: false,
376
+ error: `${name} must be a safe integer from ${minimum} to ${maximum}.`,
377
+ };
378
+ }
379
+ return { ok: true, value };
380
+ }
381
+
382
+ function recallFailure(
383
+ mode: "search" | "get",
384
+ errorCode: RecallToolErrorCode,
385
+ error: string,
386
+ ): RecallRawResult {
387
+ return { ok: false, mode, errorCode, error };
388
+ }
389
+
390
+ function argsFailure(mode: "search" | "get", error: string): ParseResult {
391
+ return { ok: false, mode, errorCode: "RECALL_ARGS_INVALID", error };
392
+ }
393
+
394
+ function sourceFailure(error: string): ParseResult {
395
+ return { ok: false, mode: "get", errorCode: "RECALL_SOURCE_INVALID", error };
396
+ }
397
+
398
+ function isRecord(value: unknown): value is Record<string, unknown> {
399
+ return typeof value === "object" && value !== null && !Array.isArray(value);
400
+ }