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,281 @@
1
+ import type { AgentMessage } from "../agent/types";
2
+ import type { ContextRevisionId } from "../ids/runtime-id";
3
+ import { CompiledContextValidator } from "./compiled-context-validator";
4
+ import {
5
+ activeOverrideManifestHash,
6
+ canonicalRenderedMessageHash,
7
+ canonicalSequenceHash,
8
+ renderedMessageHash,
9
+ } from "./compiled-context-hash";
10
+ import { ContextProtocolValidator } from "./context-protocol-validator";
11
+ import type {
12
+ CompiledContextEntry,
13
+ CompiledRevisionContext,
14
+ StoredContextSnapshotV5,
15
+ StoredInitialContextRevisionV5,
16
+ SwapOverride,
17
+ } from "./context-revision";
18
+ import {
19
+ immutableRecord,
20
+ materializeAgentMessages,
21
+ type ProtocolContextView,
22
+ } from "./protocol-frame";
23
+
24
+ export class ContextRevisionError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "ContextRevisionError";
28
+ }
29
+ }
30
+
31
+ export class ContextRevisionCompiler {
32
+ constructor(
33
+ private readonly protocolValidator = new ContextProtocolValidator(),
34
+ private readonly compiledValidator = new CompiledContextValidator(),
35
+ ) {}
36
+
37
+ compileActive(snapshot: StoredContextSnapshotV5): CompiledRevisionContext {
38
+ validateSnapshotIdentity(snapshot);
39
+ this.protocolValidator.validate(snapshot.canonical);
40
+ const overrides = overrideMap(snapshot.activeOverrides);
41
+ const compiled = compileEntries({
42
+ canonical: snapshot.canonical,
43
+ revisionId: snapshot.revision.revisionId,
44
+ overrides,
45
+ });
46
+ this.compiledValidator.validateActive(
47
+ compiled,
48
+ snapshot.canonical,
49
+ snapshot.activeOverrides,
50
+ );
51
+ if (
52
+ compiled.manifest.canonicalSequenceHash !==
53
+ canonicalSequenceHash(snapshot.canonical) ||
54
+ canonicalSequenceHash(
55
+ snapshot.canonical,
56
+ snapshot.revision.sourceThroughOrdinal,
57
+ ) !== snapshot.revision.canonicalSequenceSha256 ||
58
+ renderedMessageHash(compiled.entries, snapshot.revision.sourceThroughOrdinal) !==
59
+ snapshot.revision.renderedMessageSha256
60
+ ) {
61
+ throw new ContextRevisionError(
62
+ "Active context revision prefix hash does not match stored history.",
63
+ );
64
+ }
65
+ return compiled;
66
+ }
67
+
68
+ compileProspective(input: {
69
+ active: CompiledRevisionContext;
70
+ canonical: ProtocolContextView;
71
+ activeOverrides: readonly SwapOverride[];
72
+ addedOverrides: readonly SwapOverride[];
73
+ }): CompiledRevisionContext {
74
+ this.protocolValidator.validate(input.canonical);
75
+ if (
76
+ input.active.sessionId !== input.canonical.sessionId ||
77
+ input.active.canonicalThroughOrdinal !== input.canonical.messages.length ||
78
+ input.active.manifest.canonicalSequenceHash !==
79
+ canonicalSequenceHash(input.canonical)
80
+ ) {
81
+ throw new ContextRevisionError(
82
+ "Prospective compilation base does not match canonical history.",
83
+ );
84
+ }
85
+ this.compiledValidator.validateActive(
86
+ input.active,
87
+ input.canonical,
88
+ input.activeOverrides,
89
+ );
90
+ const candidateOverrides = [...input.activeOverrides, ...input.addedOverrides];
91
+ const overrides = overrideMap(candidateOverrides);
92
+ if (overrides.size !== candidateOverrides.length) {
93
+ throw new ContextRevisionError("Prospective overrides contain duplicate IDs.");
94
+ }
95
+ const compiled = compileEntries({
96
+ canonical: input.canonical,
97
+ revisionId: input.active.revisionId,
98
+ overrides,
99
+ });
100
+ this.compiledValidator.validateProspective(
101
+ compiled,
102
+ input.canonical,
103
+ candidateOverrides,
104
+ );
105
+ return compiled;
106
+ }
107
+ }
108
+
109
+ function compileEntries(input: {
110
+ canonical: ProtocolContextView;
111
+ revisionId: CompiledRevisionContext["revisionId"];
112
+ overrides: ReadonlyMap<string, SwapOverride>;
113
+ }): CompiledRevisionContext {
114
+ const materialized = materializeAgentMessages(input.canonical.messages);
115
+ const entries = input.canonical.messages.map((record, index) => {
116
+ const canonicalMessage = requireItem(materialized, index, "canonical message");
117
+ const override = input.overrides.get(record.messageId);
118
+ const message =
119
+ override === undefined
120
+ ? canonicalMessage
121
+ : swappedToolMessage(canonicalMessage, override);
122
+ return Object.freeze<CompiledContextEntry>({
123
+ frameId: record.frameId,
124
+ messageId: record.messageId,
125
+ ordinal: record.ordinal,
126
+ representation: override === undefined ? "canonical" : "swapped",
127
+ sourceContentSha256: record.contentSha256,
128
+ message: immutableRecord(message),
129
+ });
130
+ });
131
+ const manifest = Object.freeze({
132
+ frameCount: input.canonical.frames.length,
133
+ messageCount: entries.length,
134
+ canonicalSequenceHash: canonicalSequenceHash(input.canonical),
135
+ renderedMessageHash: renderedMessageHash(entries),
136
+ });
137
+ return Object.freeze({
138
+ sessionId: input.canonical.sessionId,
139
+ revisionId: input.revisionId,
140
+ canonicalThroughOrdinal: input.canonical.messages.length,
141
+ entries: Object.freeze(entries),
142
+ manifest,
143
+ });
144
+ }
145
+
146
+ function swappedToolMessage(
147
+ message: AgentMessage,
148
+ override: SwapOverride,
149
+ ): AgentMessage {
150
+ if (message.role !== "tool") {
151
+ throw new ContextRevisionError(
152
+ `Swap override at ordinal ${override.ordinal} does not target a tool message.`,
153
+ );
154
+ }
155
+ return {
156
+ ...message,
157
+ content: override.renderedContent,
158
+ };
159
+ }
160
+
161
+ function validateSnapshotIdentity(snapshot: StoredContextSnapshotV5): void {
162
+ if (
163
+ snapshot.meta.sessionId !== snapshot.canonical.sessionId ||
164
+ snapshot.revision.sessionId !== snapshot.canonical.sessionId ||
165
+ snapshot.meta.activeRevisionId !== snapshot.revision.revisionId
166
+ ) {
167
+ throw new ContextRevisionError(
168
+ "Active context revision identity does not match canonical history.",
169
+ );
170
+ }
171
+ if (snapshot.revision.keepFromOrdinal !== 1) {
172
+ throw new ContextRevisionError("Unsupported active context revision.");
173
+ }
174
+ const firstFrame = snapshot.canonical.frames[0];
175
+ const firstMessage = snapshot.canonical.messages[0];
176
+ if (
177
+ firstFrame?.kind !== "system" ||
178
+ firstFrame.firstOrdinal !== 1 ||
179
+ firstMessage?.role !== "system" ||
180
+ firstMessage.ordinal !== 1 ||
181
+ snapshot.canonical.messages.at(-1)?.ordinal !== snapshot.canonical.messages.length
182
+ ) {
183
+ throw new ContextRevisionError(
184
+ "Canonical context does not preserve the initial_full ordinal boundary.",
185
+ );
186
+ }
187
+ const boundaryFrame = snapshot.canonical.frames.find(
188
+ (frame) => frame.lastOrdinal === snapshot.revision.sourceThroughOrdinal,
189
+ );
190
+ if (
191
+ snapshot.revision.sourceThroughOrdinal > snapshot.canonical.messages.length ||
192
+ boundaryFrame?.state !== "closed"
193
+ ) {
194
+ throw new ContextRevisionError(
195
+ "Active context revision source boundary is not a closed frame boundary.",
196
+ );
197
+ }
198
+
199
+ if (snapshot.revision.kind === "initial_full") {
200
+ if (
201
+ snapshot.revision.revisionNumber !== 1 ||
202
+ snapshot.revision.parentRevisionId !== null ||
203
+ snapshot.revision.sourceThroughOrdinal !== 1 ||
204
+ snapshot.revision.addedOverrideCount !== 0 ||
205
+ snapshot.revision.totalOverrideCount !== 0 ||
206
+ snapshot.activeOverrides.length !== 0
207
+ ) {
208
+ throw new ContextRevisionError("Initial context revision invariant failed.");
209
+ }
210
+ } else if (
211
+ snapshot.revision.revisionNumber < 2 ||
212
+ snapshot.revision.addedOverrideCount < 1 ||
213
+ snapshot.revision.totalOverrideCount < snapshot.revision.addedOverrideCount ||
214
+ snapshot.revision.policyVersion !== "swap-only-v1" ||
215
+ snapshot.revision.rendererFormat !== "swap-observation-v1" ||
216
+ snapshot.activeOverrides.length !== snapshot.revision.totalOverrideCount
217
+ ) {
218
+ throw new ContextRevisionError("Swap context revision invariant failed.");
219
+ }
220
+
221
+ if (
222
+ activeOverrideManifestHash(snapshot.activeOverrides) !==
223
+ snapshot.revision.overrideManifestSha256
224
+ ) {
225
+ throw new ContextRevisionError(
226
+ "Active context revision override manifest does not match stored overrides.",
227
+ );
228
+ }
229
+ }
230
+
231
+ export function createInitialContextRevision(input: {
232
+ revisionId: ContextRevisionId;
233
+ canonical: ProtocolContextView;
234
+ createdAt: string;
235
+ }): StoredInitialContextRevisionV5 {
236
+ const firstFrame = input.canonical.frames[0];
237
+ const firstMessage = input.canonical.messages[0];
238
+ if (
239
+ input.canonical.messages.length !== 1 ||
240
+ input.canonical.frames.length !== 1 ||
241
+ firstFrame?.kind !== "system" ||
242
+ firstFrame.state !== "closed" ||
243
+ firstFrame.firstOrdinal !== 1 ||
244
+ firstFrame.lastOrdinal !== 1 ||
245
+ firstMessage?.role !== "system" ||
246
+ firstMessage.ordinal !== 1
247
+ ) {
248
+ throw new ContextRevisionError(
249
+ "Initial context revision requires exactly one closed system frame.",
250
+ );
251
+ }
252
+ return Object.freeze({
253
+ revisionId: input.revisionId,
254
+ sessionId: input.canonical.sessionId,
255
+ revisionNumber: 1,
256
+ parentRevisionId: null,
257
+ kind: "initial_full",
258
+ keepFromOrdinal: 1,
259
+ sourceThroughOrdinal: 1,
260
+ addedOverrideCount: 0,
261
+ totalOverrideCount: 0,
262
+ overrideManifestSha256: activeOverrideManifestHash([]),
263
+ canonicalSequenceSha256: canonicalSequenceHash(input.canonical, 1),
264
+ renderedMessageSha256: canonicalRenderedMessageHash(input.canonical, 1),
265
+ createdAt: input.createdAt,
266
+ });
267
+ }
268
+
269
+ function overrideMap(
270
+ overrides: readonly SwapOverride[],
271
+ ): ReadonlyMap<string, SwapOverride> {
272
+ return new Map(overrides.map((override) => [override.messageId, override] as const));
273
+ }
274
+
275
+ function requireItem<T>(items: readonly T[], index: number, name: string): T {
276
+ const item = items[index];
277
+ if (item === undefined) {
278
+ throw new ContextRevisionError(`Missing ${name} at index ${index}.`);
279
+ }
280
+ return item;
281
+ }
@@ -0,0 +1,111 @@
1
+ import type { AgentMessage } from "../agent/types";
2
+ import type {
3
+ ContextRevisionId,
4
+ MessageId,
5
+ ProtocolFrameId,
6
+ SessionId,
7
+ } from "../ids/runtime-id";
8
+ import type { ModelRequestInput } from "../model/model-client";
9
+ import type { MessageSource } from "./context-source";
10
+ import type { ProtocolContextView } from "./protocol-frame";
11
+
12
+ export type StoredInitialContextRevisionV5 = {
13
+ readonly revisionId: ContextRevisionId;
14
+ readonly sessionId: SessionId;
15
+ readonly revisionNumber: 1;
16
+ readonly parentRevisionId: null;
17
+ readonly kind: "initial_full";
18
+ readonly keepFromOrdinal: 1;
19
+ readonly sourceThroughOrdinal: 1;
20
+ readonly addedOverrideCount: 0;
21
+ readonly totalOverrideCount: 0;
22
+ readonly overrideManifestSha256: string;
23
+ readonly canonicalSequenceSha256: string;
24
+ readonly renderedMessageSha256: string;
25
+ readonly createdAt: string;
26
+ };
27
+
28
+ export type StoredSwapContextRevisionV5 = {
29
+ readonly revisionId: ContextRevisionId;
30
+ readonly sessionId: SessionId;
31
+ readonly revisionNumber: number;
32
+ readonly parentRevisionId: ContextRevisionId;
33
+ readonly kind: "swap_only";
34
+ readonly keepFromOrdinal: 1;
35
+ readonly sourceThroughOrdinal: number;
36
+ readonly addedOverrideCount: number;
37
+ readonly totalOverrideCount: number;
38
+ readonly overrideManifestSha256: string;
39
+ readonly canonicalSequenceSha256: string;
40
+ readonly renderedMessageSha256: string;
41
+ readonly policyVersion: "swap-only-v1";
42
+ readonly rendererFormat: "swap-observation-v1";
43
+ readonly planSha256: string;
44
+ readonly createdAt: string;
45
+ };
46
+
47
+ export type StoredContextRevisionV5 =
48
+ | StoredInitialContextRevisionV5
49
+ | StoredSwapContextRevisionV5;
50
+
51
+ export type SwapOverride = {
52
+ readonly frameId: ProtocolFrameId;
53
+ readonly messageId: MessageId;
54
+ readonly ordinal: number;
55
+ readonly source: MessageSource;
56
+ readonly originalContentSha256: string;
57
+ readonly renderedContent: string;
58
+ readonly renderedContentSha256: string;
59
+ readonly originalBytes: number;
60
+ readonly renderedBytes: number;
61
+ readonly byteSavings: number;
62
+ };
63
+
64
+ export type StoredSwapOverrideV5 = SwapOverride & {
65
+ readonly introducedRevisionId: ContextRevisionId;
66
+ readonly rendererFormat: "swap-observation-v1";
67
+ readonly createdAt: string;
68
+ };
69
+
70
+ export type StoredContextSnapshotV5 = {
71
+ readonly meta: {
72
+ readonly sessionId: SessionId;
73
+ readonly activeRevisionId: ContextRevisionId;
74
+ };
75
+ readonly revision: StoredContextRevisionV5;
76
+ readonly activeOverrides: readonly StoredSwapOverrideV5[];
77
+ readonly canonical: ProtocolContextView;
78
+ };
79
+
80
+ export type CompiledContextEntry = {
81
+ readonly frameId: ProtocolFrameId;
82
+ readonly messageId: MessageId;
83
+ readonly ordinal: number;
84
+ readonly representation: "canonical" | "swapped";
85
+ readonly sourceContentSha256: string;
86
+ readonly message: AgentMessage;
87
+ };
88
+
89
+ export type CompiledContextManifest = {
90
+ readonly frameCount: number;
91
+ readonly messageCount: number;
92
+ readonly canonicalSequenceHash: string;
93
+ readonly renderedMessageHash: string;
94
+ };
95
+
96
+ export type CompiledRevisionContext = {
97
+ readonly sessionId: SessionId;
98
+ readonly revisionId: ContextRevisionId;
99
+ readonly canonicalThroughOrdinal: number;
100
+ readonly entries: readonly CompiledContextEntry[];
101
+ readonly manifest: CompiledContextManifest;
102
+ };
103
+
104
+ export type BuiltContextRequest = {
105
+ readonly canonical: ProtocolContextView;
106
+ readonly revision: StoredContextRevisionV5;
107
+ readonly activeOverrides: readonly SwapOverride[];
108
+ readonly compiled: CompiledRevisionContext;
109
+ readonly request: ModelRequestInput;
110
+ readonly candidateUserPromptIncluded: boolean;
111
+ };
@@ -0,0 +1,30 @@
1
+ import { parseMessageId, type MessageId } from "../ids/runtime-id";
2
+
3
+ export type MessageSource = `ctx://message/${string}`;
4
+
5
+ const MESSAGE_SOURCE_PREFIX = "ctx://message/";
6
+
7
+ export class MessageSourceParseError extends Error {
8
+ readonly code = "RECALL_SOURCE_INVALID" as const;
9
+
10
+ constructor(source: string, options?: ErrorOptions) {
11
+ super(`Invalid message source: ${JSON.stringify(source)}.`, options);
12
+ this.name = "MessageSourceParseError";
13
+ }
14
+ }
15
+
16
+ export function formatMessageSource(messageId: MessageId): MessageSource {
17
+ const canonicalId = parseMessageId(messageId);
18
+ return `${MESSAGE_SOURCE_PREFIX}${canonicalId}`;
19
+ }
20
+
21
+ export function parseMessageSource(source: string): MessageId {
22
+ if (!source.startsWith(MESSAGE_SOURCE_PREFIX)) {
23
+ throw new MessageSourceParseError(source);
24
+ }
25
+ try {
26
+ return parseMessageId(source.slice(MESSAGE_SOURCE_PREFIX.length));
27
+ } catch (error) {
28
+ throw new MessageSourceParseError(source, { cause: error });
29
+ }
30
+ }
@@ -0,0 +1,272 @@
1
+ import { contentHash } from "./protocol-frame";
2
+ import { formatMessageSource } from "./context-source";
3
+ import type { CanonicalMessageRecord, ToolResultRecord } from "./protocol-frame";
4
+ import type { ToolRawResult, ToolRawResultKind } from "../tools/types";
5
+ import { sha256, stableJsonStringify } from "../model/model-request-preflight";
6
+ import type { SwapOverride } from "./context-revision";
7
+
8
+ const MAX_METADATA_BYTES = 1_024;
9
+ const MAX_SCALAR_BYTES = 256;
10
+
11
+ export const SWAP_OBSERVATION_FORMAT = "swap-observation-v1" as const;
12
+
13
+ export const SWAPPABLE_RAW_KINDS = Object.freeze([
14
+ "read",
15
+ "glob",
16
+ "grep",
17
+ "bash",
18
+ "task_output",
19
+ "web_search",
20
+ "web_fetch",
21
+ "mcp",
22
+ ] as const satisfies readonly ToolRawResultKind[]);
23
+
24
+ export type SwappableRawKind = (typeof SWAPPABLE_RAW_KINDS)[number];
25
+
26
+ export class SwapRenderUnsupportedError extends Error {
27
+ constructor(
28
+ readonly code: string,
29
+ message: string,
30
+ ) {
31
+ super(message);
32
+ this.name = "SwapRenderUnsupportedError";
33
+ }
34
+ }
35
+
36
+ export class ContextSwapRenderer {
37
+ render(input: {
38
+ message: Extract<CanonicalMessageRecord, { role: "tool" }>;
39
+ result: ToolResultRecord;
40
+ }): SwapOverride {
41
+ const { message, result } = input;
42
+ if (
43
+ result.toolMessageId !== message.messageId ||
44
+ result.frameId !== message.frameId ||
45
+ result.toolCallId !== message.toolCallId ||
46
+ result.observationSha256 !== message.contentSha256
47
+ ) {
48
+ throw new SwapRenderUnsupportedError(
49
+ "source_hash_mismatch",
50
+ "Canonical tool result does not match its message.",
51
+ );
52
+ }
53
+ if (result.completion.kind !== "returned") {
54
+ throw new SwapRenderUnsupportedError(
55
+ "synthetic_completion",
56
+ "Synthetic tool completions cannot be swapped.",
57
+ );
58
+ }
59
+ const raw = result.completion.raw;
60
+ if (!isSwappableRawResult(raw)) {
61
+ throw new SwapRenderUnsupportedError(
62
+ "raw_kind_not_allowlisted",
63
+ "Tool result kind is not eligible for swap rendering.",
64
+ );
65
+ }
66
+ assertHistoricalRawIsStable(raw);
67
+
68
+ const source = formatMessageSource(message.messageId);
69
+ const metadata = renderMetadata(raw);
70
+ const renderedContent = [
71
+ "[Tinker historical tool observation swapped]",
72
+ `source=${source}`,
73
+ `contentSha256=${message.contentSha256}`,
74
+ `tool=${stableJsonStringify(compactExternalString(message.name))}`,
75
+ `metadata=${metadata}`,
76
+ "historical=Use Recall get with source to recover the original observation.",
77
+ `current=${currentGuidance(raw.kind)}`,
78
+ ].join("\n");
79
+ const originalBytes = utf8Bytes(message.content);
80
+ const renderedBytes = utf8Bytes(renderedContent);
81
+ if (renderedBytes >= originalBytes) {
82
+ throw new SwapRenderUnsupportedError(
83
+ "placeholder_not_smaller",
84
+ "Rendered placeholder is not smaller than its canonical observation.",
85
+ );
86
+ }
87
+ return Object.freeze({
88
+ frameId: message.frameId,
89
+ messageId: message.messageId,
90
+ ordinal: message.ordinal,
91
+ source,
92
+ originalContentSha256: message.contentSha256,
93
+ renderedContent,
94
+ renderedContentSha256: contentHash(renderedContent),
95
+ originalBytes,
96
+ renderedBytes,
97
+ byteSavings: originalBytes - renderedBytes,
98
+ });
99
+ }
100
+ }
101
+
102
+ export function isSwappableRawResult(
103
+ raw: ToolRawResult,
104
+ ): raw is Extract<ToolRawResult, { kind: SwappableRawKind }> {
105
+ return (SWAPPABLE_RAW_KINDS as readonly string[]).includes(raw.kind);
106
+ }
107
+
108
+ function assertHistoricalRawIsStable(
109
+ raw: Extract<ToolRawResult, { kind: SwappableRawKind }>,
110
+ ): void {
111
+ if (raw.kind === "bash" && raw.status === "running") {
112
+ throw new SwapRenderUnsupportedError(
113
+ "running_task",
114
+ "Running Bash results cannot be rendered as historical placeholders.",
115
+ );
116
+ }
117
+ if (
118
+ raw.kind === "task_output" &&
119
+ (raw.status === "running" || raw.status === "stopping")
120
+ ) {
121
+ throw new SwapRenderUnsupportedError(
122
+ "running_task",
123
+ "Running task output cannot be rendered as a historical placeholder.",
124
+ );
125
+ }
126
+ }
127
+
128
+ function renderMetadata(
129
+ raw: Extract<ToolRawResult, { kind: SwappableRawKind }>,
130
+ ): string {
131
+ const entries: readonly (readonly [string, unknown])[] = metadataEntries(raw);
132
+ const metadata: Record<string, unknown> = {};
133
+ for (const [key, value] of entries) {
134
+ if (value === undefined) {
135
+ continue;
136
+ }
137
+ const next = {
138
+ ...metadata,
139
+ [key]: typeof value === "string" ? compactExternalString(value) : value,
140
+ };
141
+ if (utf8Bytes(stableJsonStringify(next)) <= MAX_METADATA_BYTES) {
142
+ Object.assign(metadata, { [key]: next[key] });
143
+ }
144
+ }
145
+ const rendered = stableJsonStringify(metadata);
146
+ if (utf8Bytes(rendered) > MAX_METADATA_BYTES) {
147
+ throw new SwapRenderUnsupportedError(
148
+ "metadata_too_large",
149
+ "Swap placeholder metadata exceeded its byte limit.",
150
+ );
151
+ }
152
+ return rendered;
153
+ }
154
+
155
+ function metadataEntries(
156
+ raw: Extract<ToolRawResult, { kind: SwappableRawKind }>,
157
+ ): readonly (readonly [string, unknown])[] {
158
+ switch (raw.kind) {
159
+ case "read":
160
+ return [
161
+ ["filePath", raw.filePath],
162
+ ["startLine", raw.startLine],
163
+ ["endLine", raw.endLine],
164
+ ["sha256", raw.sha256],
165
+ ["sizeBytes", raw.sizeBytes],
166
+ ];
167
+ case "glob":
168
+ return [
169
+ ["pattern", raw.pattern],
170
+ ["searchPath", raw.searchPath],
171
+ ["matchCount", raw.matchCount],
172
+ ];
173
+ case "grep":
174
+ return [
175
+ ["pattern", raw.pattern],
176
+ ["searchPath", raw.searchPath],
177
+ ["mode", raw.mode],
178
+ ["numMatches", raw.numMatches],
179
+ ["truncated", raw.truncated],
180
+ ];
181
+ case "bash":
182
+ return [
183
+ ["status", raw.status],
184
+ ["exitCode", raw.exitCode],
185
+ ["outputFilePath", raw.outputFilePath],
186
+ ["outputBytes", raw.outputBytes],
187
+ ["command", raw.command],
188
+ ];
189
+ case "task_output":
190
+ return [
191
+ ["taskId", raw.taskId],
192
+ ["status", raw.status],
193
+ ["outputFilePath", raw.outputFilePath],
194
+ ["outputBytes", raw.outputBytes],
195
+ ];
196
+ case "web_search":
197
+ return [
198
+ ["query", raw.query],
199
+ ["resultCount", raw.resultCount],
200
+ ["requestId", raw.requestId],
201
+ ];
202
+ case "web_fetch":
203
+ return [
204
+ ["url", raw.url],
205
+ ["finalUrl", raw.finalUrl],
206
+ ["title", raw.title],
207
+ ["httpStatusCode", raw.httpStatusCode],
208
+ ];
209
+ case "mcp":
210
+ return [
211
+ ["serverName", raw.serverName],
212
+ ["serverToolName", raw.serverToolName],
213
+ ["isError", raw.isError],
214
+ ["contentBlockCount", raw.contentBlockCount],
215
+ ];
216
+ }
217
+ }
218
+
219
+ function currentGuidance(kind: SwappableRawKind): string {
220
+ switch (kind) {
221
+ case "read":
222
+ return "Use Read to inspect the current file state before relying on historical content.";
223
+ case "glob":
224
+ return "Use Glob to inspect the current workspace matches.";
225
+ case "grep":
226
+ return "Use Grep to inspect current workspace matches.";
227
+ case "bash":
228
+ return "Inspect current state before deciding whether a historical command should be rerun.";
229
+ case "task_output":
230
+ return "Use TaskOutput to inspect the task's current recorded output and status.";
231
+ case "web_search":
232
+ return "Use WebSearch when current search results are required.";
233
+ case "web_fetch":
234
+ return "Use WebFetch when the current page content is required.";
235
+ case "mcp":
236
+ return "Call the MCP tool again only when current external state is required.";
237
+ }
238
+ }
239
+
240
+ function compactExternalString(
241
+ value: string,
242
+ ):
243
+ | string
244
+ | { readonly prefix: string; readonly byteLength: number; readonly sha256: string } {
245
+ const byteLength = utf8Bytes(value);
246
+ if (byteLength <= MAX_SCALAR_BYTES) {
247
+ return value;
248
+ }
249
+ return Object.freeze({
250
+ prefix: utf8Prefix(value, MAX_SCALAR_BYTES),
251
+ byteLength,
252
+ sha256: sha256(value),
253
+ });
254
+ }
255
+
256
+ function utf8Prefix(value: string, maximumBytes: number): string {
257
+ let prefix = "";
258
+ let bytes = 0;
259
+ for (const character of value) {
260
+ const characterBytes = utf8Bytes(character);
261
+ if (bytes + characterBytes > maximumBytes) {
262
+ break;
263
+ }
264
+ prefix += character;
265
+ bytes += characterBytes;
266
+ }
267
+ return prefix;
268
+ }
269
+
270
+ function utf8Bytes(value: string): number {
271
+ return Buffer.byteLength(value, "utf8");
272
+ }