tinker-agent 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +27 -2
  3. package/package.json +2 -1
  4. package/src/agent/context-meter.ts +2 -4
  5. package/src/agent/runtime-session.ts +9 -2
  6. package/src/agent/session-ledger.ts +12 -5
  7. package/src/agent/tool-result-content.ts +76 -0
  8. package/src/agent/types.ts +14 -2
  9. package/src/cli/config.ts +4 -0
  10. package/src/cli/model-profiles.ts +41 -2
  11. package/src/cli/public-config-contract.ts +30 -7
  12. package/src/cli/runner-dependencies.ts +5 -0
  13. package/src/cli/tui-memory.ts +1 -0
  14. package/src/cli/tui-runner.tsx +4 -0
  15. package/src/context/compiled-context-hash.ts +2 -1
  16. package/src/context/compiled-context-validator.ts +13 -4
  17. package/src/context/context-protocol-validator.ts +33 -2
  18. package/src/context/context-revision-compiler.ts +2 -1
  19. package/src/context/context-revision.ts +8 -2
  20. package/src/context/context-swap-renderer.ts +46 -12
  21. package/src/context/prefix-retirement-planner.ts +13 -9
  22. package/src/context/protocol-frame.ts +74 -7
  23. package/src/context/swap-planner.ts +19 -14
  24. package/src/events/observation-text-log.ts +1 -1
  25. package/src/events/stdout-event-printer.ts +6 -0
  26. package/src/image/image-asset-store.ts +32 -3
  27. package/src/memory/contracts.ts +63 -3
  28. package/src/memory/memory-coordinator.ts +319 -49
  29. package/src/memory/memory-extractor.ts +48 -48
  30. package/src/memory/memory-get-tool.ts +86 -0
  31. package/src/memory/memory-search-tool.ts +122 -33
  32. package/src/memory/memory-store.ts +227 -20
  33. package/src/model/fake-model-client.ts +129 -76
  34. package/src/model/model-client.ts +62 -11
  35. package/src/model/openai-chat-mapping.ts +2 -1
  36. package/src/model/openai-chat-model-client.ts +22 -10
  37. package/src/model/openai-model-utils.ts +61 -30
  38. package/src/model/openai-responses-mapping.ts +25 -1
  39. package/src/model/openai-responses-model-client.ts +27 -11
  40. package/src/model/token-estimator.ts +10 -0
  41. package/src/observation/observation-builder.ts +100 -25
  42. package/src/session/session-history-reader.ts +128 -5
  43. package/src/session/session-schema.ts +59 -9
  44. package/src/session/session-store.ts +343 -196
  45. package/src/tools/registry.ts +18 -0
  46. package/src/tools/types.ts +46 -0
  47. package/src/tools/view-image.ts +89 -0
  48. package/src/tools/wait.ts +85 -0
  49. package/src/tui/components/memory-browser.tsx +3 -0
  50. package/src/tui/components/prompt-input.tsx +48 -25
  51. package/src/tui/event-store.ts +61 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.2.0] - 2026-08-29
9
+
10
+ ### Added
11
+
12
+ - Log raw vector cosine scores in `MemorySearch` diagnostics, making hybrid
13
+ recall ranking easier to inspect and tune.
14
+
15
+ ### Changed
16
+
17
+ - Restyle the prompt-input footer status line with a muted color palette:
18
+ model name, workspace path, Git branch, and cache hit rate each get a soft
19
+ accent color, and normal-pressure context usage now renders in a cool gray
20
+ instead of plain dim. Red and yellow remain reserved for context-pressure
21
+ warnings.
22
+
23
+ ## [2.1.0] - 2026-08-29
24
+
25
+ ### Added
26
+
27
+ - Add cross-session derived memory. Completed turns are distilled into memory
28
+ records that the agent can recall in later sessions and workspaces through
29
+ `MemorySearch`, which fuses vector similarity with FTS5 keyword matching via
30
+ RRF hybrid ranking, and `MemoryGet`, which reads a full record by its id.
31
+ - Add a `toolResultModalities` model-profile setting. Image-capable profiles can
32
+ now receive `ViewImage` results as real image content in the model request
33
+ instead of text-only descriptions.
34
+ - Add a `Wait` tool that pauses the agent loop for a cancellable whole number of
35
+ seconds (1 to 3600), useful for spacing polling attempts.
36
+ - Expand a leading `~` to the user's home directory in `TINKER_MODELS` and
37
+ `TINKER_WORKSPACE` paths.
38
+
39
+ ### Changed
40
+
41
+ - Show `MemorySearch` keywords alongside the query in TUI tool summaries, so
42
+ hybrid recall behavior is visible while observing the agent.
43
+
44
+ ### Fixed
45
+
46
+ - Accept `memory_get` raw results in stored tool history, so sessions that
47
+ contain MemoryGet calls remain resumable.
48
+
8
49
  ## [2.0.0] - 2026-08-20
9
50
 
10
51
  ### Changed
@@ -223,7 +264,9 @@ All notable user-facing changes to Tinker are documented here. The project follo
223
264
  - First formal npm release under the `tinker-agent` package name with the `tinker`
224
265
  executable.
225
266
 
226
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.0.0...HEAD
267
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.2.0...HEAD
268
+ [2.2.0]: https://github.com/ishowshao/tinker/releases/tag/v2.2.0
269
+ [2.1.0]: https://github.com/ishowshao/tinker/releases/tag/v2.1.0
227
270
  [2.0.0]: https://github.com/ishowshao/tinker/releases/tag/v2.0.0
228
271
  [1.11.0]: https://github.com/ishowshao/tinker/releases/tag/v1.11.0
229
272
  [1.10.1]: https://github.com/ishowshao/tinker/releases/tag/v1.10.1
package/README.md CHANGED
@@ -138,7 +138,7 @@ are required. Boolean environment values accept case-insensitive `true/false`,
138
138
  <!-- BEGIN GENERATED: PUBLIC ENVIRONMENT VARIABLES -->
139
139
  | Variable | Area | Applies | Required | Type | Default | Secret | Description |
140
140
  | --- | --- | --- | --- | --- | --- | --- | --- |
141
- | `TINKER_MODELS` | Model | All modes | No | Non-empty string | — | No | Optional model profiles JSON path. Relative paths resolve from the process cwd. |
141
+ | `TINKER_MODELS` | Model | All modes | No | Non-empty string | — | No | Optional model profiles JSON path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd. |
142
142
  | `TINKER_MODEL` | Model | Env mode | Env mode | Non-empty string | — | No | Model name used when model profiles are not configured. |
143
143
  | `TINKER_API` | Model | Env mode | No | Non-empty string | `"chat-completions"` | No | Model API adapter: "chat-completions" or "responses". |
144
144
  | `TINKER_BASE_URL` | Model | Env mode | Env mode | Non-empty string | — | No | OpenAI-compatible API root URL; do not append /chat/completions or /responses. |
@@ -148,7 +148,7 @@ are required. Boolean environment values accept case-insensitive `true/false`,
148
148
  | `TINKER_INCLUDE_REASONING_CONTENT` | Model | Env mode | No | Boolean | `false` | No | Replay provider reasoning_content in Chat Completions history; ignored by Responses. |
149
149
  | `TINKER_STREAM` | Model | Env mode | No | Boolean | `true` | No | Use streaming transport for the selected model API. |
150
150
  | `TINKER_WEBFETCH_REFINE_MODEL` | Model | Env mode | No | Non-empty string | — | No | Optional WebFetch refiner model; currently must match TINKER_MODEL. |
151
- | `TINKER_WORKSPACE` | Workspace | All modes | No | Non-empty string | Process cwd | No | Workspace path. Relative paths resolve from the process cwd. |
151
+ | `TINKER_WORKSPACE` | Workspace | All modes | No | Non-empty string | Process cwd | No | Workspace path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd. |
152
152
  | `TINKER_MAX_ITERATIONS` | Workspace | All modes | No | Positive integer | `512` | No | Maximum agent-loop iterations per turn. |
153
153
  | `EXA_API_KEY` | Tooling | All modes | No | Non-empty string | — | Yes | Enables WebSearch and the Exa WebFetch backend when set. |
154
154
  | `TINKER_MCP_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `60000` | No | MCP tool-call timeout in milliseconds. |
@@ -194,6 +194,7 @@ Profile fields:
194
194
  | `includeReasoningContent` | No | JSON boolean | `false` | No | Replay provider reasoning_content in Chat Completions history; ignored by Responses. |
195
195
  | `stream` | No | JSON boolean | `true` | No | Use streaming transport for the selected model API. |
196
196
  | `inputModalities` | No | Normalized modality array | `["text"]` | No | Accepted model input modalities; normalizes to ["text"] or ["text", "image"]. |
197
+ | `toolResultModalities` | No | Normalized modality array | `["text"]` | No | Accepted tool-result modalities; normalizes to ["text"] or ["text", "image"]. |
197
198
 
198
199
  `reasoning` fields:
199
200
 
@@ -212,6 +213,7 @@ Text-only profile example:
212
213
  "profiles": {
213
214
  "text": {
214
215
  "model": "example-text-model",
216
+ "api": "chat-completions",
215
217
  "apiBase": "https://api.example.com/v1",
216
218
  "apiKey": "your-model-api-key",
217
219
  "contextWindowTokens": 128000,
@@ -228,6 +230,9 @@ Text-only profile example:
228
230
  "stream": true,
229
231
  "inputModalities": [
230
232
  "text"
233
+ ],
234
+ "toolResultModalities": [
235
+ "text"
231
236
  ]
232
237
  }
233
238
  }
@@ -242,6 +247,7 @@ Image-capable profile example:
242
247
  "profiles": {
243
248
  "image": {
244
249
  "model": "example-vision-model",
250
+ "api": "responses",
245
251
  "apiBase": "https://api.example.com/v1",
246
252
  "apiKey": "your-model-api-key",
247
253
  "contextWindowTokens": 128000,
@@ -259,6 +265,10 @@ Image-capable profile example:
259
265
  "inputModalities": [
260
266
  "text",
261
267
  "image"
268
+ ],
269
+ "toolResultModalities": [
270
+ "text",
271
+ "image"
262
272
  ]
263
273
  }
264
274
  }
@@ -293,6 +303,7 @@ Atomic-memory profile example:
293
303
  "profiles": {
294
304
  "text": {
295
305
  "model": "example-text-model",
306
+ "api": "chat-completions",
296
307
  "apiBase": "https://api.example.com/v1",
297
308
  "apiKey": "your-model-api-key",
298
309
  "contextWindowTokens": 128000,
@@ -309,6 +320,9 @@ Atomic-memory profile example:
309
320
  "stream": true,
310
321
  "inputModalities": [
311
322
  "text"
323
+ ],
324
+ "toolResultModalities": [
325
+ "text"
312
326
  ]
313
327
  }
314
328
  },
@@ -366,6 +380,17 @@ independent token-estimator request. See the
366
380
  [`multimodal image input design`](docs/multimodal-image-input-design.md) for the
367
381
  complete fixed policy and persistence contract.
368
382
 
383
+ `ViewImage(file_path)` is registered only when the selected profile declares both
384
+ `inputModalities: ["text", "image"]` and
385
+ `toolResultModalities: ["text", "image"]`. The first implementation supports
386
+ image tool results through the Responses adapter; Chat Completions remains
387
+ text-only for tool results. Relative paths stay inside the workspace, absolute
388
+ paths may explicitly select an external local file, and symbolic links are
389
+ rejected. Canonical history stores content-addressed image references rather than
390
+ Base64, while stdout, TUI, Recall, and logs show deterministic text summaries.
391
+ See the [`ViewImage tool design`](docs/view-image-tool-design.md) for the complete
392
+ capability, persistence, provider, and compaction contract.
393
+
369
394
  ### Built-in Slash Commands
370
395
 
371
396
  <!-- BEGIN GENERATED: BUILT-IN SLASH COMMANDS -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -50,6 +50,7 @@
50
50
  "bench:long-session": "bun scripts/bench-long-session-memory.ts",
51
51
  "bench:i3-provider-smoke": "bun scripts/bench-i3-provider-smoke.ts",
52
52
  "bench:k3-image-live": "bun scripts/k3-image-live-smoke.ts",
53
+ "bench:view-image-live": "bun scripts/view-image-live-smoke.ts",
53
54
  "bench:i4-active-recall": "bun scripts/bench-i4-active-recall.ts",
54
55
  "qualify:i4-active-recall": "bun scripts/qualify-i4-active-recall.ts",
55
56
  "release:verify": "bun scripts/verify-release-package.ts",
@@ -13,6 +13,7 @@ import {
13
13
  import { lastPromptPrefixHash, promptPrefixHashes } from "../model/prompt-prefix-hash";
14
14
  import {
15
15
  estimatePromptSegments,
16
+ guardedContextTokens,
16
17
  RollingTokenCalibration,
17
18
  type RawContextBreakdown,
18
19
  } from "../model/token-estimator";
@@ -296,10 +297,7 @@ function guardedEstimate(
296
297
  breakdown: RawContextBreakdown,
297
298
  correctionFactor: number,
298
299
  ): number {
299
- return (
300
- Math.ceil(breakdown.textAndProtocolTokens * correctionFactor) +
301
- breakdown.imageTokens
302
- );
300
+ return guardedContextTokens(breakdown, correctionFactor);
303
301
  }
304
302
 
305
303
  function assertMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
@@ -308,6 +308,7 @@ type CommonRuntimeSessionInput = {
308
308
  webFetchRefiner?: Refiner;
309
309
  toolingConfig?: PublicToolingConfig;
310
310
  memorySearch?: ToolExecutor;
311
+ memoryGet?: ToolExecutor;
311
312
  completedTurnHook?: CompletedTurnHook;
312
313
  enableTurnUndo?: boolean;
313
314
  bashGuard?: {
@@ -580,7 +581,8 @@ class DefaultRuntimeSession implements RuntimeSession {
580
581
  includeReasoningContent: input.includeReasoningContent,
581
582
  contextProfile: input.contextProfile,
582
583
  messageProtocol: input.modelClient.messageProtocol,
583
- inputModalities: input.modelClient.inputModalities ?? ["text"],
584
+ inputModalities: input.modelClient.inputModalities,
585
+ toolResultModalities: input.modelClient.toolResultModalities,
584
586
  });
585
587
  if (input.selection.mode === "resume") {
586
588
  store.assertSessionCompatibility(compatibility);
@@ -654,6 +656,10 @@ class DefaultRuntimeSession implements RuntimeSession {
654
656
  workspaceRoot: input.workspaceRoot,
655
657
  runtimeSession: session.context,
656
658
  historyReader: store.historyReader(),
659
+ imageAssetStore: assetStore,
660
+ supportsViewImage:
661
+ input.modelClient.inputModalities.includes("image") &&
662
+ input.modelClient.toolResultModalities.includes("image"),
657
663
  ...(input.enableTurnUndo === true ? { enableTurnUndo: true } : {}),
658
664
  webFetchRefiner: input.webFetchRefiner,
659
665
  toolingConfig: input.toolingConfig,
@@ -665,6 +671,7 @@ class DefaultRuntimeSession implements RuntimeSession {
665
671
  ...(input.memorySearch === undefined
666
672
  ? {}
667
673
  : { memorySearch: input.memorySearch }),
674
+ ...(input.memoryGet === undefined ? {} : { memoryGet: input.memoryGet }),
668
675
  ...(session.skillCatalog.skills.size === 0
669
676
  ? {}
670
677
  : {
@@ -1397,7 +1404,7 @@ class DefaultRuntimeSession implements RuntimeSession {
1397
1404
  messageId: message.messageId,
1398
1405
  frameId: message.frameId,
1399
1406
  ordinal: message.ordinal,
1400
- content: message.content,
1407
+ content: message.displayText,
1401
1408
  contentSha256: message.contentSha256,
1402
1409
  },
1403
1410
  name: activation.name,
@@ -26,12 +26,15 @@ import {
26
26
  } from "../context/context-protocol-validator";
27
27
  import {
28
28
  CURRENT_TOOL_OBSERVATION_FORMAT,
29
+ canonicalToolResultContentHash,
29
30
  contentHash,
31
+ displayTextForCompletion,
30
32
  immutableCanonicalClone,
31
33
  immutableRecord,
32
34
  observationForCompletion,
33
35
  rawResultHash,
34
36
  userMessageHash,
37
+ validateReturnedToolObservation,
35
38
  type CanonicalMessageRecord,
36
39
  type ProtocolContextView,
37
40
  type ProtocolFrame,
@@ -583,6 +586,7 @@ export class InMemorySessionLedger implements SessionLedger {
583
586
  assertSameToolCall(expectedCall, completionInput.call);
584
587
  validateCompletionInput(completionInput);
585
588
  const content = observationForCompletion(completionInput);
589
+ const displayText = displayTextForCompletion(completionInput);
586
590
  const createdAt = this.clock();
587
591
  const messageId = this.input.idFactory.createMessageId();
588
592
  const message = immutableRecord<CanonicalMessageRecord>({
@@ -590,7 +594,7 @@ export class InMemorySessionLedger implements SessionLedger {
590
594
  sessionId: this.input.sessionId,
591
595
  frameId: frameBefore.frameId,
592
596
  ordinal: this.view.messages.length + messages.length + 1,
593
- contentSha256: contentHash(content),
597
+ contentSha256: canonicalToolResultContentHash(content),
594
598
  createdAt,
595
599
  role: "tool",
596
600
  turnId: pending.turn.turnId,
@@ -599,6 +603,7 @@ export class InMemorySessionLedger implements SessionLedger {
599
603
  providerToolCallId: expectedCall.providerToolCallId,
600
604
  name: expectedCall.name,
601
605
  content,
606
+ displayText,
602
607
  origin: completionInput.kind === "returned" ? "tool" : "runtime",
603
608
  });
604
609
  const completion = canonicalCompletion(completionInput);
@@ -608,7 +613,7 @@ export class InMemorySessionLedger implements SessionLedger {
608
613
  toolCallId: expectedCall.toolCallId,
609
614
  toolMessageId: messageId,
610
615
  completion,
611
- observationSha256: contentHash(content),
616
+ observationSha256: canonicalToolResultContentHash(content),
612
617
  createdAt,
613
618
  });
614
619
  messages.push(message);
@@ -1006,9 +1011,11 @@ function canonicalCompletion(input: ToolCompletionInput): ToolCompletion {
1006
1011
 
1007
1012
  function validateCompletionInput(input: ToolCompletionInput): void {
1008
1013
  if (input.kind === "returned") {
1009
- if (typeof input.observation !== "string") {
1010
- throw new Error("Returned tool completion observation must be a string.");
1011
- }
1014
+ validateReturnedToolObservation({
1015
+ toolName: input.call.name,
1016
+ raw: input.raw,
1017
+ content: input.observation,
1018
+ });
1012
1019
  immutableCanonicalClone(input.raw);
1013
1020
  return;
1014
1021
  }
@@ -0,0 +1,76 @@
1
+ import { sha256, stableJsonStringify } from "../model/model-request-preflight";
2
+ import { validateImageAssetRef } from "../image/image-types";
3
+ import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
4
+ import type { ToolResultContent } from "./types";
5
+
6
+ export function canonicalToolResultContentHash(
7
+ content: readonly ToolResultContent[],
8
+ ): string {
9
+ validateToolResultContent(content);
10
+ return sha256(stableJsonStringify(content));
11
+ }
12
+
13
+ export function validateToolResultContent(content: readonly ToolResultContent[]): void {
14
+ if (content.length === 0) {
15
+ throw new Error("Tool result content must be a non-empty array.");
16
+ }
17
+ let previousWasText = false;
18
+ let imageCount = 0;
19
+ for (const block of content) {
20
+ switch (block.type) {
21
+ case "text":
22
+ if (block.text.trim() === "") {
23
+ throw new Error("Tool result text blocks must not be empty.");
24
+ }
25
+ if (previousWasText) {
26
+ throw new Error("Consecutive tool result text blocks must be merged.");
27
+ }
28
+ previousWasText = true;
29
+ break;
30
+ case "image":
31
+ validateImageAssetRef(block.asset);
32
+ imageCount += 1;
33
+ previousWasText = false;
34
+ break;
35
+ }
36
+ }
37
+ if (imageCount > IMAGE_INPUT_POLICY.maxImagesPerMessage) {
38
+ throw new Error("Tool result content exceeds the per-message image limit.");
39
+ }
40
+ }
41
+
42
+ export function textToolResultContent(text: string): readonly ToolResultContent[] {
43
+ const content = Object.freeze([Object.freeze({ type: "text" as const, text })]);
44
+ validateToolResultContent(content);
45
+ return content;
46
+ }
47
+
48
+ export function toolResultDisplayText(content: readonly ToolResultContent[]): string {
49
+ validateToolResultContent(content);
50
+ return content
51
+ .map((block) =>
52
+ block.type === "text"
53
+ ? block.text
54
+ : `[Image: ${block.asset.mimeType}, ${block.asset.width}x${block.asset.height}, ${block.asset.byteLength} bytes, asset=${shortAssetId(block.asset.assetId)}]`,
55
+ )
56
+ .join("\n");
57
+ }
58
+
59
+ export function toolResultText(content: readonly ToolResultContent[]): string {
60
+ validateToolResultContent(content);
61
+ if (content.some((block) => block.type === "image")) {
62
+ throw new Error("Text-only tool result mapping received an image block.");
63
+ }
64
+ return content
65
+ .map((block) => {
66
+ if (block.type !== "text") {
67
+ throw new Error("Text-only tool result mapping received an image block.");
68
+ }
69
+ return block.text;
70
+ })
71
+ .join("\n");
72
+ }
73
+
74
+ function shortAssetId(assetId: string): string {
75
+ return `${assetId.slice(0, 12)}…`;
76
+ }
@@ -1,5 +1,5 @@
1
1
  import type { IterationId, SessionId, ToolCallId, TurnId } from "../ids/runtime-id";
2
- import type { UserMessage } from "../image/image-types";
2
+ import type { ImageAssetRef, UserMessage } from "../image/image-types";
3
3
 
4
4
  export type {
5
5
  CodePointRange,
@@ -45,12 +45,24 @@ export type AssistantMessage = {
45
45
  toolCalls?: readonly ToolCall[];
46
46
  };
47
47
 
48
+ export type ToolResultTextContent = {
49
+ readonly type: "text";
50
+ readonly text: string;
51
+ };
52
+
53
+ export type ToolResultImageContent = {
54
+ readonly type: "image";
55
+ readonly asset: ImageAssetRef;
56
+ };
57
+
58
+ export type ToolResultContent = ToolResultTextContent | ToolResultImageContent;
59
+
48
60
  export type ToolMessage = {
49
61
  role: "tool";
50
62
  toolCallId: ToolCallId;
51
63
  providerToolCallId: string;
52
64
  name: string;
53
- content: string;
65
+ content: readonly ToolResultContent[];
54
66
  };
55
67
 
56
68
  export type AgentMessage =
package/src/cli/config.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  persistDefaultProfile,
13
13
  profileToContextProfile,
14
14
  type ModelInputModality,
15
+ type ToolResultModality,
15
16
  type ModelProfile,
16
17
  type ModelProfiles,
17
18
  unknownProfileError,
@@ -39,6 +40,7 @@ export type RunnerConfig = {
39
40
  readonly contextBudget: ModelContextBudget;
40
41
  readonly profileName?: string;
41
42
  readonly inputModalities: readonly ModelInputModality[];
43
+ readonly toolResultModalities: readonly ToolResultModality[];
42
44
  readonly bashGuardMode: "guard" | "yolo";
43
45
  readonly bashGuardSource: "default" | "environment" | "cli";
44
46
  };
@@ -180,6 +182,7 @@ function runnerConfigTemplateFromProfile(
180
182
  contextBudget: deriveModelContextBudget(contextProfile),
181
183
  profileName: profile.name,
182
184
  inputModalities: profile.inputModalities,
185
+ toolResultModalities: profile.toolResultModalities,
183
186
  bashGuardMode: environment.bashGuardMode,
184
187
  bashGuardSource: environment.bashGuardSource,
185
188
  });
@@ -205,6 +208,7 @@ function runnerConfigTemplateFromEnvironment(
205
208
  contextProfile,
206
209
  contextBudget: deriveModelContextBudget(contextProfile),
207
210
  inputModalities: Object.freeze(["text"] as const),
211
+ toolResultModalities: Object.freeze(["text"] as const),
208
212
  bashGuardMode: environment.bashGuardMode,
209
213
  bashGuardSource: environment.bashGuardSource,
210
214
  });
@@ -13,6 +13,7 @@ import {
13
13
  } from "./public-config-contract";
14
14
  import type { MemoryEmbeddingConfig } from "../memory/contracts";
15
15
  import type { ReasoningEffortConfig } from "../model/reasoning-effort";
16
+ import type { ModelInputModality, ToolResultModality } from "../model/model-client";
16
17
 
17
18
  export type ModelProfile = {
18
19
  readonly name: string;
@@ -26,9 +27,9 @@ export type ModelProfile = {
26
27
  readonly includeReasoningContent: boolean;
27
28
  readonly stream: boolean;
28
29
  readonly inputModalities: readonly ModelInputModality[];
30
+ readonly toolResultModalities: readonly ToolResultModality[];
29
31
  };
30
-
31
- export type ModelInputModality = "text" | "image";
32
+ export type { ModelInputModality, ToolResultModality } from "../model/model-client";
32
33
 
33
34
  export type ModelProfiles = {
34
35
  readonly defaultProfile: string;
@@ -272,6 +273,15 @@ function parseProfile(
272
273
  value.inputModalities,
273
274
  `${where}: "inputModalities"`,
274
275
  );
276
+ const toolResultModalities = parseToolResultModalities(
277
+ value.toolResultModalities,
278
+ `${where}: "toolResultModalities"`,
279
+ );
280
+ if (toolResultModalities.includes("image") && !inputModalities.includes("image")) {
281
+ throw new Error(
282
+ `${where}: "toolResultModalities" cannot include "image" unless "inputModalities" also includes "image".`,
283
+ );
284
+ }
275
285
  createModelContextProfile({
276
286
  contextWindowTokens,
277
287
  maxSupportedOutputTokens,
@@ -289,9 +299,38 @@ function parseProfile(
289
299
  includeReasoningContent,
290
300
  stream,
291
301
  inputModalities,
302
+ toolResultModalities,
292
303
  });
293
304
  }
294
305
 
306
+ function parseToolResultModalities(
307
+ value: unknown,
308
+ name: string,
309
+ ): readonly ToolResultModality[] {
310
+ if (value === undefined) {
311
+ return modelProfileField("toolResultModalities").defaultValue;
312
+ }
313
+ if (!Array.isArray(value) || value.length === 0) {
314
+ throw new Error(`${name} must be a non-empty array.`);
315
+ }
316
+ const modalities = value.map((rawEntry): ToolResultModality => {
317
+ const entry: unknown = rawEntry;
318
+ if (entry !== "text" && entry !== "image") {
319
+ throw new Error(`${name} contains an unsupported modality.`);
320
+ }
321
+ return entry;
322
+ });
323
+ if (new Set(modalities).size !== modalities.length) {
324
+ throw new Error(`${name} must not contain duplicates.`);
325
+ }
326
+ if (!modalities.includes("text")) {
327
+ throw new Error(`${name} must include "text".`);
328
+ }
329
+ return Object.freeze(
330
+ modalities.includes("image") ? (["text", "image"] as const) : (["text"] as const),
331
+ );
332
+ }
333
+
295
334
  function parseReasoning(value: unknown, where: string): ReasoningEffortConfig {
296
335
  if (!isRecord(value)) {
297
336
  throw new Error(`${where} must be an object.`);
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
3
  import { rgPath } from "@vscode/ripgrep";
3
4
  import { parseModelApi, type ModelApi } from "../model/model-api";
@@ -29,7 +30,7 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
29
30
  secret: false,
30
31
  section: "model",
31
32
  description:
32
- "Optional model profiles JSON path. Relative paths resolve from the process cwd.",
33
+ "Optional model profiles JSON path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd.",
33
34
  }),
34
35
  publicField({
35
36
  name: "TINKER_MODEL",
@@ -126,7 +127,8 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
126
127
  defaultSource: "process-cwd",
127
128
  secret: false,
128
129
  section: "workspace",
129
- description: "Workspace path. Relative paths resolve from the process cwd.",
130
+ description:
131
+ "Workspace path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd.",
130
132
  }),
131
133
  publicField({
132
134
  name: "TINKER_MAX_ITERATIONS",
@@ -242,7 +244,11 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
242
244
 
243
245
  export type ModelProfileField = {
244
246
  readonly name: string;
245
- readonly valueKind: PublicConfigValueKind | "input-modalities" | "reasoning";
247
+ readonly valueKind:
248
+ | PublicConfigValueKind
249
+ | "input-modalities"
250
+ | "tool-result-modalities"
251
+ | "reasoning";
246
252
  readonly required: boolean;
247
253
  readonly defaultValue?: string | number | boolean | readonly string[];
248
254
  readonly secret: boolean;
@@ -333,6 +339,15 @@ export const MODEL_PROFILE_FIELDS = Object.freeze([
333
339
  description:
334
340
  'Accepted model input modalities; normalizes to ["text"] or ["text", "image"].',
335
341
  }),
342
+ profileField({
343
+ name: "toolResultModalities",
344
+ valueKind: "tool-result-modalities",
345
+ required: false,
346
+ defaultValue: Object.freeze(["text"] as const),
347
+ secret: false,
348
+ description:
349
+ 'Accepted tool-result modalities; normalizes to ["text"] or ["text", "image"].',
350
+ }),
336
351
  ]);
337
352
 
338
353
  export type ModelReasoningField = {
@@ -523,7 +538,7 @@ export function parsePublicEnvironment(
523
538
  values[field.name] = parseEnvironmentField(field, env[field.name], mode, cwd);
524
539
  }
525
540
 
526
- const workspaceRoot = path.resolve(
541
+ const workspaceRoot = resolveUserPath(
527
542
  cwd,
528
543
  requiredStringValue(values, "TINKER_WORKSPACE"),
529
544
  );
@@ -572,9 +587,7 @@ export function parsePublicEnvironment(
572
587
  return Object.freeze({
573
588
  ...common,
574
589
  mode,
575
- modelsPath: path.isAbsolute(configuredModelsPath)
576
- ? configuredModelsPath
577
- : path.resolve(cwd, configuredModelsPath),
590
+ modelsPath: resolveUserPath(cwd, configuredModelsPath),
578
591
  });
579
592
  }
580
593
 
@@ -673,6 +686,16 @@ function optionalRawString(value: string | undefined): string | undefined {
673
686
  return normalized === undefined || normalized === "" ? undefined : normalized;
674
687
  }
675
688
 
689
+ function resolveUserPath(cwd: string, value: string): string {
690
+ let expanded = value;
691
+ if (value === "~") {
692
+ expanded = os.homedir();
693
+ } else if (value.startsWith("~/") || value.startsWith(`~${path.sep}`)) {
694
+ expanded = path.join(os.homedir(), value.slice(2));
695
+ }
696
+ return path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
697
+ }
698
+
676
699
  function optionalStringValue(
677
700
  values: Record<PublicConfigFieldName, ParsedPrimitive>,
678
701
  name: PublicConfigFieldName,
@@ -66,6 +66,8 @@ export function createModelClient(
66
66
  | "apiKey"
67
67
  | "apiBase"
68
68
  | "inputModalities"
69
+ | "toolResultModalities"
70
+ | "profileName"
69
71
  >,
70
72
  env: NodeJS.ProcessEnv = process.env,
71
73
  reasoningEffort?: ReasoningEffortController,
@@ -78,6 +80,7 @@ export function createModelClient(
78
80
  model: config.modelName,
79
81
  contextBudget: config.contextBudget,
80
82
  inputModalities: config.inputModalities,
83
+ toolResultModalities: config.toolResultModalities,
81
84
  ...(activeReasoningEffort === undefined
82
85
  ? {}
83
86
  : { reasoningEffort: activeReasoningEffort }),
@@ -95,6 +98,8 @@ export function createModelClient(
95
98
  stream: config.stream,
96
99
  contextBudget: config.contextBudget,
97
100
  inputModalities: config.inputModalities,
101
+ toolResultModalities: config.toolResultModalities,
102
+ profileName: config.profileName,
98
103
  ...(activeReasoningEffort === undefined
99
104
  ? {}
100
105
  : { reasoningEffort: activeReasoningEffort }),
@@ -47,6 +47,7 @@ export async function initializeTuiMemory(input: {
47
47
  stream: profile.stream,
48
48
  contextBudget: memoryConfig.contextBudget,
49
49
  inputModalities: profile.inputModalities,
50
+ toolResultModalities: profile.toolResultModalities,
50
51
  },
51
52
  input.env,
52
53
  );
@@ -129,6 +129,10 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
129
129
  workspaceRoot,
130
130
  sessionId,
131
131
  }),
132
+ memoryGet: memoryCoordinator.createGetToolExecutor({
133
+ workspaceRoot,
134
+ sessionId,
135
+ }),
132
136
  completedTurnHook: memoryCoordinator,
133
137
  }),
134
138
  };
@@ -1,4 +1,5 @@
1
1
  import { sha256, stableJsonStringify } from "../model/model-request-preflight";
2
+ import { toolResultText } from "../agent/tool-result-content";
2
3
  import type { CompiledContextEntry, SwapOverride } from "./context-revision";
3
4
  import {
4
5
  contentHash,
@@ -132,7 +133,7 @@ function renderedMessageDescriptor(entry: CompiledContextEntry): unknown {
132
133
  contentSha256:
133
134
  entry.representation === "canonical"
134
135
  ? entry.sourceContentSha256
135
- : contentHash(message.content),
136
+ : contentHash(toolResultText(message.content)),
136
137
  };
137
138
  }
138
139
  }