tinker-agent 1.8.0 → 1.10.1

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 (54) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +65 -6
  3. package/package.json +1 -1
  4. package/src/agent/loop.ts +13 -0
  5. package/src/agent/runtime-session.ts +165 -0
  6. package/src/agent/session-ledger.ts +20 -3
  7. package/src/cli/config.ts +11 -2
  8. package/src/cli/model-profiles.ts +58 -0
  9. package/src/cli/public-config-contract.ts +73 -7
  10. package/src/cli/run-runner.ts +4 -1
  11. package/src/cli/runner-dependencies.ts +35 -10
  12. package/src/cli/tui-memory.ts +4 -0
  13. package/src/cli/tui-runner.tsx +8 -1
  14. package/src/context/context-automation-policy.ts +22 -21
  15. package/src/context/context-manager.ts +91 -15
  16. package/src/context/context-policy.ts +0 -2
  17. package/src/context/context-swap-renderer.ts +1 -1
  18. package/src/context/prefix-retirement-planner.ts +58 -8
  19. package/src/context/recall-retirement-contract.ts +5 -4
  20. package/src/context/swap-planner.ts +33 -27
  21. package/src/events/stdout-event-printer.ts +17 -0
  22. package/src/model/fake-model-client.ts +26 -16
  23. package/src/model/model-api.ts +12 -0
  24. package/src/model/model-client.ts +9 -1
  25. package/src/model/moonshot-input-token-estimator.ts +5 -1
  26. package/src/model/openai-chat-mapping.ts +2 -24
  27. package/src/model/openai-chat-model-client.ts +18 -294
  28. package/src/model/openai-image-mapping.ts +20 -0
  29. package/src/model/openai-model-utils.ts +304 -0
  30. package/src/model/openai-responses-mapping.ts +532 -0
  31. package/src/model/openai-responses-model-client.ts +295 -0
  32. package/src/model/openai-responses-stream.ts +96 -0
  33. package/src/model/openai-responses-token-estimator.ts +155 -0
  34. package/src/model/reasoning-effort.ts +60 -0
  35. package/src/observation/observation-builder.ts +7 -0
  36. package/src/session/session-catalog.ts +2 -2
  37. package/src/session/session-history-reader.ts +6 -1
  38. package/src/session/session-schema.ts +268 -4
  39. package/src/session/session-store.ts +106 -26
  40. package/src/skills/skill-context.ts +2 -2
  41. package/src/tools/bounded-output-preview.ts +276 -0
  42. package/src/tools/recall.ts +67 -36
  43. package/src/tools/registry.ts +9 -2
  44. package/src/tools/task-output-snapshot.ts +6 -22
  45. package/src/tools/task-output.ts +23 -27
  46. package/src/tools/types.ts +19 -0
  47. package/src/tools/update-plan.ts +166 -0
  48. package/src/tui/app.tsx +82 -5
  49. package/src/tui/components/plan-view.tsx +43 -0
  50. package/src/tui/components/prompt-input.tsx +9 -1
  51. package/src/tui/components/timeline.tsx +7 -0
  52. package/src/tui/event-store.ts +28 -2
  53. package/src/tui/slash-commands.ts +20 -0
  54. package/src/tui/tui-session-controller.ts +7 -0
@@ -0,0 +1,276 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ export const MAX_PREVIEW_LINES = 200;
4
+ export const PREVIEW_EDGE_LINES = 100;
5
+ export const MAX_PREVIEW_BYTES = 32 * 1024;
6
+ export const MAX_PREVIEW_LINE_BYTES = 8 * 1024;
7
+
8
+ export type BoundedOutputPreview = {
9
+ preview: string;
10
+ truncated: boolean;
11
+ omittedLines?: number;
12
+ };
13
+
14
+ export type OutputPreviewSource =
15
+ | {
16
+ outputLines: number;
17
+ lines: readonly string[];
18
+ }
19
+ | {
20
+ outputLines: number;
21
+ firstLines: readonly string[];
22
+ lastLines: readonly string[];
23
+ };
24
+
25
+ type LineWindow = {
26
+ leadingLines: readonly string[];
27
+ trailingLines: readonly string[];
28
+ omittedLines: number;
29
+ };
30
+
31
+ type BoundedLine = {
32
+ text: string;
33
+ truncated: boolean;
34
+ };
35
+
36
+ export function buildBoundedOutputPreview(
37
+ source: OutputPreviewSource,
38
+ ): BoundedOutputPreview {
39
+ const window = selectLineWindow(source);
40
+ const leadingLines = window.leadingLines.map(boundLine);
41
+ const trailingLines = window.trailingLines.map(boundLine);
42
+ const lineContentTruncated = [...leadingLines, ...trailingLines].some(
43
+ (line) => line.truncated,
44
+ );
45
+ const lineWindowPreview = renderLineWindow({
46
+ leadingLines: leadingLines.map((line) => line.text),
47
+ trailingLines: trailingLines.map((line) => line.text),
48
+ outputLines: source.outputLines,
49
+ omittedLines: window.omittedLines,
50
+ });
51
+
52
+ if (utf8Bytes(lineWindowPreview) <= MAX_PREVIEW_BYTES) {
53
+ return {
54
+ preview: lineWindowPreview,
55
+ truncated: lineContentTruncated || window.omittedLines > 0,
56
+ ...(window.omittedLines > 0 ? { omittedLines: window.omittedLines } : {}),
57
+ };
58
+ }
59
+
60
+ const candidates = [...leadingLines, ...trailingLines].map((line) => line.text);
61
+ const totalWindow = renderTotalByteWindow(candidates);
62
+ const omittedLines = source.outputLines - totalWindow.retainedLines;
63
+
64
+ if (utf8Bytes(totalWindow.preview) > MAX_PREVIEW_BYTES) {
65
+ throw new Error("Bounded output preview exceeded its UTF-8 byte limit.");
66
+ }
67
+ if (omittedLines < 1) {
68
+ throw new Error("Bounded output preview byte truncation omitted no lines.");
69
+ }
70
+
71
+ return {
72
+ preview: totalWindow.preview,
73
+ truncated: true,
74
+ omittedLines,
75
+ };
76
+ }
77
+
78
+ export function takeUtf8Prefix(text: string, maxBytes: number): string {
79
+ if (maxBytes <= 0) {
80
+ return "";
81
+ }
82
+
83
+ let end = 0;
84
+ let bytes = 0;
85
+ for (const character of text) {
86
+ const characterBytes = utf8Bytes(character);
87
+ if (bytes + characterBytes > maxBytes) {
88
+ break;
89
+ }
90
+ bytes += characterBytes;
91
+ end += character.length;
92
+ }
93
+ return text.slice(0, end);
94
+ }
95
+
96
+ export function takeUtf8Suffix(text: string, maxBytes: number): string {
97
+ if (maxBytes <= 0) {
98
+ return "";
99
+ }
100
+
101
+ let start = text.length;
102
+ let bytes = 0;
103
+ while (start > 0) {
104
+ let characterStart = start - 1;
105
+ const lastCodeUnit = text.charCodeAt(characterStart);
106
+ if (
107
+ isLowSurrogate(lastCodeUnit) &&
108
+ characterStart > 0 &&
109
+ isHighSurrogate(text.charCodeAt(characterStart - 1))
110
+ ) {
111
+ characterStart -= 1;
112
+ }
113
+
114
+ const character = text.slice(characterStart, start);
115
+ const characterBytes = utf8Bytes(character);
116
+ if (bytes + characterBytes > maxBytes) {
117
+ break;
118
+ }
119
+ bytes += characterBytes;
120
+ start = characterStart;
121
+ }
122
+ return text.slice(start);
123
+ }
124
+
125
+ function selectLineWindow(source: OutputPreviewSource): LineWindow {
126
+ if ("lines" in source) {
127
+ if (source.lines.length !== source.outputLines) {
128
+ throw new Error("Complete output preview source has inconsistent line counts.");
129
+ }
130
+ if (source.outputLines <= MAX_PREVIEW_LINES) {
131
+ return {
132
+ leadingLines: source.lines,
133
+ trailingLines: [],
134
+ omittedLines: 0,
135
+ };
136
+ }
137
+
138
+ return {
139
+ leadingLines: source.lines.slice(0, PREVIEW_EDGE_LINES),
140
+ trailingLines: source.lines.slice(-PREVIEW_EDGE_LINES),
141
+ omittedLines: source.outputLines - MAX_PREVIEW_LINES,
142
+ };
143
+ }
144
+
145
+ if (
146
+ source.outputLines <= MAX_PREVIEW_LINES ||
147
+ source.firstLines.length !== PREVIEW_EDGE_LINES ||
148
+ source.lastLines.length !== PREVIEW_EDGE_LINES
149
+ ) {
150
+ throw new Error("Windowed output preview source has inconsistent line counts.");
151
+ }
152
+
153
+ return {
154
+ leadingLines: source.firstLines,
155
+ trailingLines: source.lastLines,
156
+ omittedLines: source.outputLines - MAX_PREVIEW_LINES,
157
+ };
158
+ }
159
+
160
+ function boundLine(text: string): BoundedLine {
161
+ const originalBytes = utf8Bytes(text);
162
+ if (originalBytes <= MAX_PREVIEW_LINE_BYTES) {
163
+ return { text, truncated: false };
164
+ }
165
+
166
+ let omittedBytes = originalBytes;
167
+ while (true) {
168
+ const marker = lineByteOmissionMarker(omittedBytes);
169
+ const remainingBytes = MAX_PREVIEW_LINE_BYTES - utf8Bytes(marker);
170
+ const prefix = takeUtf8Prefix(text, Math.floor(remainingBytes / 2));
171
+ const suffix = takeUtf8Suffix(
172
+ text,
173
+ remainingBytes - Math.floor(remainingBytes / 2),
174
+ );
175
+ const nextOmittedBytes = originalBytes - utf8Bytes(prefix) - utf8Bytes(suffix);
176
+
177
+ if (nextOmittedBytes !== omittedBytes) {
178
+ omittedBytes = nextOmittedBytes;
179
+ continue;
180
+ }
181
+
182
+ const bounded = `${prefix}${marker}${suffix}`;
183
+ if (utf8Bytes(bounded) > MAX_PREVIEW_LINE_BYTES) {
184
+ throw new Error("Bounded output line exceeded its UTF-8 byte limit.");
185
+ }
186
+ return { text: bounded, truncated: true };
187
+ }
188
+ }
189
+
190
+ function renderLineWindow(input: {
191
+ leadingLines: readonly string[];
192
+ trailingLines: readonly string[];
193
+ outputLines: number;
194
+ omittedLines: number;
195
+ }): string {
196
+ if (input.omittedLines === 0) {
197
+ return input.leadingLines.join("\n");
198
+ }
199
+
200
+ const omittedStartLine = input.leadingLines.length + 1;
201
+ const omittedEndLine = input.outputLines - input.trailingLines.length;
202
+ return [
203
+ ...input.leadingLines,
204
+ `... output omitted: lines ${omittedStartLine}-${omittedEndLine} (${input.omittedLines} ${input.omittedLines === 1 ? "line" : "lines"}). Full output is available at outputFilePath.`,
205
+ ...input.trailingLines,
206
+ ].join("\n");
207
+ }
208
+
209
+ function renderTotalByteWindow(lines: readonly string[]): {
210
+ preview: string;
211
+ retainedLines: number;
212
+ } {
213
+ const marker = `... output omitted to fit the ${MAX_PREVIEW_BYTES}-byte preview limit. Full output is available at outputFilePath.`;
214
+ const contentBudget = MAX_PREVIEW_BYTES - utf8Bytes(marker) - 2;
215
+ const leadingBudget = Math.floor(contentBudget / 2);
216
+ const trailingBudget = contentBudget - leadingBudget;
217
+ const leadingCount = countLeadingLinesWithin(lines, leadingBudget);
218
+ const trailingCount = countTrailingLinesWithin(lines, trailingBudget, leadingCount);
219
+
220
+ return {
221
+ preview: [
222
+ ...lines.slice(0, leadingCount),
223
+ marker,
224
+ ...lines.slice(lines.length - trailingCount),
225
+ ].join("\n"),
226
+ retainedLines: leadingCount + trailingCount,
227
+ };
228
+ }
229
+
230
+ function countLeadingLinesWithin(lines: readonly string[], maxBytes: number): number {
231
+ let count = 0;
232
+ let bytes = 0;
233
+ for (const line of lines) {
234
+ const nextBytes = utf8Bytes(line) + (count === 0 ? 0 : 1);
235
+ if (bytes + nextBytes > maxBytes) {
236
+ break;
237
+ }
238
+ bytes += nextBytes;
239
+ count += 1;
240
+ }
241
+ return count;
242
+ }
243
+
244
+ function countTrailingLinesWithin(
245
+ lines: readonly string[],
246
+ maxBytes: number,
247
+ leadingCount: number,
248
+ ): number {
249
+ let count = 0;
250
+ let bytes = 0;
251
+ for (let index = lines.length - 1; index >= leadingCount; index -= 1) {
252
+ const nextBytes = utf8Bytes(lines[index] ?? "") + (count === 0 ? 0 : 1);
253
+ if (bytes + nextBytes > maxBytes) {
254
+ break;
255
+ }
256
+ bytes += nextBytes;
257
+ count += 1;
258
+ }
259
+ return count;
260
+ }
261
+
262
+ function lineByteOmissionMarker(omittedBytes: number): string {
263
+ return `... ${omittedBytes} UTF-8 bytes omitted from this line ...`;
264
+ }
265
+
266
+ function isHighSurrogate(codeUnit: number): boolean {
267
+ return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
268
+ }
269
+
270
+ function isLowSurrogate(codeUnit: number): boolean {
271
+ return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
272
+ }
273
+
274
+ function utf8Bytes(text: string): number {
275
+ return Buffer.byteLength(text, "utf8");
276
+ }
@@ -22,15 +22,14 @@ import {
22
22
  type ToolExecutor,
23
23
  } from "./types";
24
24
 
25
- export const RECALL_TOOL_DEFINITION: ToolDefinition = Object.freeze({
26
- name: "Recall",
25
+ export const RECALL_SEARCH_TOOL_DEFINITION: ToolDefinition = Object.freeze({
26
+ name: "RecallSearch",
27
27
  description:
28
- "Search or retrieve immutable model-visible history from the current session. Search matches literal substrings: use a short distinctive anchor such as a path, symbol, project, command fragment, or error text, not a whole natural-language question. 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.",
28
+ "Search immutable model-visible history from the current session. Matches literal substrings: use a short distinctive anchor such as a path, symbol, project, command fragment, or error text, not a whole natural-language question. Results are historical snapshots and may differ from the current workspace. Use RecallGet with a returned source to retrieve exact content, and Read/Grep for current files.",
29
29
  parameters: {
30
30
  type: "object",
31
31
  additionalProperties: false,
32
32
  properties: {
33
- mode: { type: "string", enum: ["search", "get"] },
34
33
  query: { type: "string", maxLength: 1024 },
35
34
  roles: {
36
35
  type: "array",
@@ -50,6 +49,19 @@ export const RECALL_TOOL_DEFINITION: ToolDefinition = Object.freeze({
50
49
  limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
51
50
  offset: { type: "integer", minimum: 0, default: 0 },
52
51
  snapshot_through_ordinal: { type: "integer", minimum: 1 },
52
+ },
53
+ required: ["query"],
54
+ },
55
+ });
56
+
57
+ export const RECALL_GET_TOOL_DEFINITION: ToolDefinition = Object.freeze({
58
+ name: "RecallGet",
59
+ description:
60
+ "Retrieve exact immutable model-visible historical content from the current session using a ctx://message/<UUID> source returned by RecallSearch. Results are historical snapshots and may differ from the current workspace; use Read/Grep for current files.",
61
+ parameters: {
62
+ type: "object",
63
+ additionalProperties: false,
64
+ properties: {
53
65
  source: { type: "string" },
54
66
  byte_offset: { type: "integer", minimum: 0, default: 0 },
55
67
  byte_limit: {
@@ -59,10 +71,15 @@ export const RECALL_TOOL_DEFINITION: ToolDefinition = Object.freeze({
59
71
  default: 12_000,
60
72
  },
61
73
  },
62
- required: ["mode"],
74
+ required: ["source"],
63
75
  },
64
76
  });
65
77
 
78
+ export const RECALL_TOOL_DEFINITIONS: readonly ToolDefinition[] = Object.freeze([
79
+ RECALL_SEARCH_TOOL_DEFINITION,
80
+ RECALL_GET_TOOL_DEFINITION,
81
+ ]);
82
+
66
83
  type RecallSearchArgs = {
67
84
  mode: "search";
68
85
  query: string;
@@ -93,18 +110,32 @@ type ParseResult =
93
110
  error: string;
94
111
  };
95
112
 
96
- export function createRecallToolExecutor(options: {
113
+ export function createRecallSearchToolExecutor(options: {
114
+ historyReader: SessionHistoryReader;
115
+ }): ToolExecutor {
116
+ return createRecallToolExecutor("search", options);
117
+ }
118
+
119
+ export function createRecallGetToolExecutor(options: {
97
120
  historyReader: SessionHistoryReader;
98
121
  }): ToolExecutor {
122
+ return createRecallToolExecutor("get", options);
123
+ }
124
+
125
+ function createRecallToolExecutor(
126
+ mode: "search" | "get",
127
+ options: { historyReader: SessionHistoryReader },
128
+ ): ToolExecutor {
99
129
  return defineToolExecutor("recall", {
100
- definition: RECALL_TOOL_DEFINITION,
130
+ definition:
131
+ mode === "search" ? RECALL_SEARCH_TOOL_DEFINITION : RECALL_GET_TOOL_DEFINITION,
101
132
  async execute(
102
133
  args,
103
134
  _call,
104
135
  context: ToolExecutionContext,
105
136
  ): Promise<RecallRawResult> {
106
137
  throwIfTurnCancelled(context.signal);
107
- const parsed = parseRecallArgs(args);
138
+ const parsed = mode === "search" ? parseSearchArgs(args) : parseGetArgs(args);
108
139
  if (!parsed.ok) {
109
140
  return recallFailure(parsed.mode, parsed.errorCode, parsed.error);
110
141
  }
@@ -167,20 +198,11 @@ export function createRecallToolExecutor(options: {
167
198
  });
168
199
  }
169
200
 
170
- function parseRecallArgs(args: unknown): ParseResult {
201
+ function parseSearchArgs(args: unknown): ParseResult {
171
202
  if (!isRecord(args)) {
172
- return argsFailure("search", "Recall arguments must be an object.");
173
- }
174
- const mode = args.mode === "get" ? "get" : "search";
175
- if (args.mode !== "search" && args.mode !== "get") {
176
- return argsFailure(mode, 'Recall.mode must be "search" or "get".');
203
+ return argsFailure("search", "RecallSearch arguments must be an object.");
177
204
  }
178
- return args.mode === "search" ? parseSearchArgs(args) : parseGetArgs(args);
179
- }
180
-
181
- function parseSearchArgs(args: Record<string, unknown>): ParseResult {
182
205
  const allowed = new Set([
183
- "mode",
184
206
  "query",
185
207
  "roles",
186
208
  "tool_names",
@@ -194,7 +216,7 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
194
216
  if (unexpected !== undefined) {
195
217
  return argsFailure(
196
218
  "search",
197
- `Recall search received unexpected field: ${unexpected}.`,
219
+ `RecallSearch received unexpected field: ${unexpected}.`,
198
220
  );
199
221
  }
200
222
  if (
@@ -204,7 +226,7 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
204
226
  ) {
205
227
  return argsFailure(
206
228
  "search",
207
- "Recall search query must be non-empty and at most 1024 UTF-8 bytes.",
229
+ "RecallSearch query must be non-empty and at most 1024 UTF-8 bytes.",
208
230
  );
209
231
  }
210
232
 
@@ -223,15 +245,15 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
223
245
  ) {
224
246
  return argsFailure(
225
247
  "search",
226
- "Recall.tool_names requires roles to be omitted or contain only tool.",
248
+ "RecallSearch.tool_names requires roles to be omitted or contain only tool.",
227
249
  );
228
250
  }
229
251
 
230
- const turnFrom = optionalInteger(args.turn_from, "Recall.turn_from", 1);
252
+ const turnFrom = optionalInteger(args.turn_from, "RecallSearch.turn_from", 1);
231
253
  if (!turnFrom.ok) {
232
254
  return argsFailure("search", turnFrom.error);
233
255
  }
234
- const turnTo = optionalInteger(args.turn_to, "Recall.turn_to", 1);
256
+ const turnTo = optionalInteger(args.turn_to, "RecallSearch.turn_to", 1);
235
257
  if (!turnTo.ok) {
236
258
  return argsFailure("search", turnTo.error);
237
259
  }
@@ -240,19 +262,19 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
240
262
  turnTo.value !== undefined &&
241
263
  turnFrom.value > turnTo.value
242
264
  ) {
243
- return argsFailure("search", "Recall.turn_from must not exceed turn_to.");
265
+ return argsFailure("search", "RecallSearch.turn_from must not exceed turn_to.");
244
266
  }
245
- const limit = optionalInteger(args.limit, "Recall.limit", 1, 20);
267
+ const limit = optionalInteger(args.limit, "RecallSearch.limit", 1, 20);
246
268
  if (!limit.ok) {
247
269
  return argsFailure("search", limit.error);
248
270
  }
249
- const offset = optionalInteger(args.offset, "Recall.offset", 0);
271
+ const offset = optionalInteger(args.offset, "RecallSearch.offset", 0);
250
272
  if (!offset.ok) {
251
273
  return argsFailure("search", offset.error);
252
274
  }
253
275
  const snapshot = optionalInteger(
254
276
  args.snapshot_through_ordinal,
255
- "Recall.snapshot_through_ordinal",
277
+ "RecallSearch.snapshot_through_ordinal",
256
278
  1,
257
279
  );
258
280
  if (!snapshot.ok) {
@@ -277,14 +299,17 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
277
299
  };
278
300
  }
279
301
 
280
- function parseGetArgs(args: Record<string, unknown>): ParseResult {
281
- const allowed = new Set(["mode", "source", "byte_offset", "byte_limit"]);
302
+ function parseGetArgs(args: unknown): ParseResult {
303
+ if (!isRecord(args)) {
304
+ return argsFailure("get", "RecallGet arguments must be an object.");
305
+ }
306
+ const allowed = new Set(["source", "byte_offset", "byte_limit"]);
282
307
  const unexpected = Object.keys(args).find((key) => !allowed.has(key));
283
308
  if (unexpected !== undefined) {
284
- return argsFailure("get", `Recall get received unexpected field: ${unexpected}.`);
309
+ return argsFailure("get", `RecallGet received unexpected field: ${unexpected}.`);
285
310
  }
286
311
  if (typeof args.source !== "string") {
287
- return sourceFailure("Recall.source must be a string.");
312
+ return sourceFailure("RecallGet.source must be a string.");
288
313
  }
289
314
  let source: MessageSource;
290
315
  try {
@@ -296,11 +321,16 @@ function parseGetArgs(args: Record<string, unknown>): ParseResult {
296
321
  }
297
322
  throw error;
298
323
  }
299
- const byteOffset = optionalInteger(args.byte_offset, "Recall.byte_offset", 0);
324
+ const byteOffset = optionalInteger(args.byte_offset, "RecallGet.byte_offset", 0);
300
325
  if (!byteOffset.ok) {
301
326
  return argsFailure("get", byteOffset.error);
302
327
  }
303
- const byteLimit = optionalInteger(args.byte_limit, "Recall.byte_limit", 256, 20_000);
328
+ const byteLimit = optionalInteger(
329
+ args.byte_limit,
330
+ "RecallGet.byte_limit",
331
+ 256,
332
+ 20_000,
333
+ );
304
334
  if (!byteLimit.ok) {
305
335
  return argsFailure("get", byteLimit.error);
306
336
  }
@@ -332,7 +362,7 @@ function parseRoles(
332
362
  return {
333
363
  ok: false,
334
364
  error:
335
- "Recall.roles must be a non-empty, unique list of user, assistant, or tool.",
365
+ "RecallSearch.roles must be a non-empty, unique list of user, assistant, or tool.",
336
366
  };
337
367
  }
338
368
  return { ok: true, value: value as RecallRole[] };
@@ -353,7 +383,8 @@ function parseToolNames(
353
383
  ) {
354
384
  return {
355
385
  ok: false,
356
- error: "Recall.tool_names must contain 1 to 16 unique, non-empty tool names.",
386
+ error:
387
+ "RecallSearch.tool_names must contain 1 to 16 unique, non-empty tool names.",
357
388
  };
358
389
  }
359
390
  return { ok: true, value: value as string[] };
@@ -6,11 +6,12 @@ import { createEditToolExecutor } from "./edit";
6
6
  import { createGlobToolExecutor } from "./glob";
7
7
  import { createGrepToolExecutor } from "./grep";
8
8
  import { createReadToolExecutor } from "./read";
9
- import { createRecallToolExecutor } from "./recall";
9
+ import { createRecallGetToolExecutor, createRecallSearchToolExecutor } from "./recall";
10
10
  import { createTaskListToolExecutor } from "./task-list";
11
11
  import { createTaskInputToolExecutor } from "./task-input";
12
12
  import { createTaskOutputToolExecutor } from "./task-output-tool";
13
13
  import { createTaskStopToolExecutor } from "./task-stop";
14
+ import { createUpdatePlanToolExecutor } from "./update-plan";
14
15
  import { createWebFetchToolExecutor } from "./web-fetch";
15
16
  import type { Refiner } from "./web-fetch/refiner";
16
17
  import { createWebSearchToolExecutor } from "./web-search";
@@ -205,7 +206,12 @@ export function createDefaultTooling(options: {
205
206
  maxContentBytes: options.maxReadContentBytes,
206
207
  }),
207
208
  );
208
- registry.register(createRecallToolExecutor({ historyReader: options.historyReader }));
209
+ registry.register(
210
+ createRecallSearchToolExecutor({ historyReader: options.historyReader }),
211
+ );
212
+ registry.register(
213
+ createRecallGetToolExecutor({ historyReader: options.historyReader }),
214
+ );
209
215
  if (options.memorySearch !== undefined) {
210
216
  registry.register(options.memorySearch);
211
217
  }
@@ -255,6 +261,7 @@ export function createDefaultTooling(options: {
255
261
  maxTimeoutMs: toolingConfig.bashMaxTimeoutMs,
256
262
  }),
257
263
  );
264
+ registry.register(createUpdatePlanToolExecutor());
258
265
  registry.register(createTaskListToolExecutor({ taskManager }));
259
266
  registry.register(createTaskOutputToolExecutor({ taskManager }));
260
267
  registry.register(createTaskInputToolExecutor({ taskManager }));
@@ -1,35 +1,19 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import { buildBoundedOutputPreview } from "./bounded-output-preview";
2
3
  import type { TaskOutputSnapshot } from "./task-output";
3
4
 
4
- const maxPreviewLines = 200;
5
- const previewEdgeLines = 100;
6
-
7
5
  export function buildOutputSnapshotFromText(bytes: Buffer): TaskOutputSnapshot {
8
6
  const text = bytes.toString("utf8");
9
7
  const lines = splitLines(text);
8
+ const bounded = buildBoundedOutputPreview({
9
+ outputLines: lines.length,
10
+ lines,
11
+ });
10
12
 
11
- if (lines.length <= maxPreviewLines) {
12
- return {
13
- outputBytes: bytes.byteLength,
14
- outputLines: lines.length,
15
- preview: lines.join("\n"),
16
- truncated: false,
17
- };
18
- }
19
-
20
- const omittedLines = lines.length - maxPreviewLines;
21
- const omittedStartLine = previewEdgeLines + 1;
22
- const omittedEndLine = lines.length - previewEdgeLines;
23
13
  return {
24
14
  outputBytes: bytes.byteLength,
25
15
  outputLines: lines.length,
26
- preview: [
27
- ...lines.slice(0, previewEdgeLines),
28
- `... output omitted: lines ${omittedStartLine}-${omittedEndLine} (${omittedLines} ${omittedLines === 1 ? "line" : "lines"}). Full output is available at outputFilePath.`,
29
- ...lines.slice(-previewEdgeLines),
30
- ].join("\n"),
31
- truncated: true,
32
- omittedLines,
16
+ ...bounded,
33
17
  };
34
18
  }
35
19
 
@@ -2,6 +2,12 @@ import { mkdir } from "node:fs/promises";
2
2
  import { createWriteStream, type WriteStream } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
+ import {
6
+ buildBoundedOutputPreview,
7
+ MAX_PREVIEW_LINES,
8
+ PREVIEW_EDGE_LINES,
9
+ type OutputPreviewSource,
10
+ } from "./bounded-output-preview";
5
11
 
6
12
  export type TaskOutputSnapshot = {
7
13
  outputBytes: number;
@@ -11,9 +17,6 @@ export type TaskOutputSnapshot = {
11
17
  omittedLines?: number;
12
18
  };
13
19
 
14
- const maxPreviewLines = 200;
15
- const previewEdgeLines = 100;
16
-
17
20
  export class TaskOutput {
18
21
  private readonly decoder = new StringDecoder("utf8");
19
22
  private readonly stream: WriteStream;
@@ -68,30 +71,23 @@ export class TaskOutput {
68
71
 
69
72
  snapshot(): TaskOutputSnapshot {
70
73
  const outputLines = this.outputLines + (this.pendingLine === "" ? 0 : 1);
71
- const lines = this.previewLines();
72
-
73
- if (outputLines <= maxPreviewLines) {
74
- return {
75
- outputBytes: this.outputBytes,
76
- outputLines,
77
- preview: lines.join("\n"),
78
- truncated: false,
79
- };
80
- }
74
+ const source: OutputPreviewSource =
75
+ this.fullPreviewLines === undefined
76
+ ? {
77
+ outputLines,
78
+ firstLines: this.firstLines,
79
+ lastLines: this.lastPreviewLines(),
80
+ }
81
+ : {
82
+ outputLines,
83
+ lines: this.previewLines(),
84
+ };
85
+ const bounded = buildBoundedOutputPreview(source);
81
86
 
82
- const omittedLines = outputLines - maxPreviewLines;
83
- const omittedStartLine = previewEdgeLines + 1;
84
- const omittedEndLine = outputLines - previewEdgeLines;
85
87
  return {
86
88
  outputBytes: this.outputBytes,
87
89
  outputLines,
88
- preview: [
89
- ...this.firstLines,
90
- `... output omitted: lines ${omittedStartLine}-${omittedEndLine} (${omittedLines} ${omittedLines === 1 ? "line" : "lines"}). Full output is available at outputFilePath.`,
91
- ...this.lastPreviewLines(),
92
- ].join("\n"),
93
- truncated: true,
94
- omittedLines,
90
+ ...bounded,
95
91
  };
96
92
  }
97
93
 
@@ -113,18 +109,18 @@ export class TaskOutput {
113
109
  private pushLine(line: string): void {
114
110
  this.outputLines += 1;
115
111
 
116
- if (this.firstLines.length < previewEdgeLines) {
112
+ if (this.firstLines.length < PREVIEW_EDGE_LINES) {
117
113
  this.firstLines.push(line);
118
114
  }
119
115
 
120
116
  this.lastLines.push(line);
121
- if (this.lastLines.length > previewEdgeLines) {
117
+ if (this.lastLines.length > PREVIEW_EDGE_LINES) {
122
118
  this.lastLines.shift();
123
119
  }
124
120
 
125
121
  if (this.fullPreviewLines !== undefined) {
126
122
  this.fullPreviewLines.push(line);
127
- if (this.fullPreviewLines.length > maxPreviewLines) {
123
+ if (this.fullPreviewLines.length > MAX_PREVIEW_LINES) {
128
124
  this.fullPreviewLines = undefined;
129
125
  }
130
126
  }
@@ -150,7 +146,7 @@ export class TaskOutput {
150
146
  lines.push(this.pendingLine);
151
147
  }
152
148
 
153
- return lines.slice(-previewEdgeLines);
149
+ return lines.slice(-PREVIEW_EDGE_LINES);
154
150
  }
155
151
  }
156
152