tinker-agent 1.0.65 → 1.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 (208) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +90 -9
  5. package/bin/tinker.js +39 -0
  6. package/node_modules/ansi-regex/index.d.ts +33 -0
  7. package/node_modules/ansi-regex/index.js +14 -0
  8. package/node_modules/ansi-regex/license +9 -0
  9. package/node_modules/ansi-regex/package.json +61 -0
  10. package/node_modules/ansi-regex/readme.md +66 -0
  11. package/node_modules/ansi-styles/index.d.ts +236 -0
  12. package/node_modules/ansi-styles/index.js +223 -0
  13. package/node_modules/ansi-styles/license +9 -0
  14. package/node_modules/ansi-styles/package.json +54 -0
  15. package/node_modules/ansi-styles/readme.md +173 -0
  16. package/node_modules/chalk/license +9 -0
  17. package/node_modules/chalk/package.json +83 -0
  18. package/node_modules/chalk/readme.md +297 -0
  19. package/node_modules/chalk/source/index.d.ts +325 -0
  20. package/node_modules/chalk/source/index.js +225 -0
  21. package/node_modules/chalk/source/utilities.js +33 -0
  22. package/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  23. package/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  24. package/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  25. package/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  26. package/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  27. package/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  28. package/node_modules/character-entities/index.d.ts +6 -0
  29. package/node_modules/character-entities/index.js +2132 -0
  30. package/node_modules/character-entities/license +22 -0
  31. package/node_modules/character-entities/package.json +78 -0
  32. package/node_modules/character-entities/readme.md +152 -0
  33. package/node_modules/decode-named-character-reference/index.d.ts +13 -0
  34. package/node_modules/decode-named-character-reference/index.d.ts.map +1 -0
  35. package/node_modules/decode-named-character-reference/index.dom.d.ts +6 -0
  36. package/node_modules/decode-named-character-reference/index.dom.d.ts.map +1 -0
  37. package/node_modules/decode-named-character-reference/index.dom.js +32 -0
  38. package/node_modules/decode-named-character-reference/index.js +19 -0
  39. package/node_modules/decode-named-character-reference/license +22 -0
  40. package/node_modules/decode-named-character-reference/package.json +90 -0
  41. package/node_modules/decode-named-character-reference/readme.md +136 -0
  42. package/node_modules/get-east-asian-width/index.d.ts +60 -0
  43. package/node_modules/get-east-asian-width/index.js +30 -0
  44. package/node_modules/get-east-asian-width/license +9 -0
  45. package/node_modules/get-east-asian-width/lookup-data.js +21 -0
  46. package/node_modules/get-east-asian-width/lookup.js +138 -0
  47. package/node_modules/get-east-asian-width/package.json +71 -0
  48. package/node_modules/get-east-asian-width/readme.md +65 -0
  49. package/node_modules/get-east-asian-width/utilities.js +24 -0
  50. package/node_modules/has-flag/index.d.ts +39 -0
  51. package/node_modules/has-flag/index.js +8 -0
  52. package/node_modules/has-flag/license +9 -0
  53. package/node_modules/has-flag/package.json +49 -0
  54. package/node_modules/has-flag/readme.md +74 -0
  55. package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
  56. package/node_modules/is-fullwidth-code-point/index.js +12 -0
  57. package/node_modules/is-fullwidth-code-point/license +9 -0
  58. package/node_modules/is-fullwidth-code-point/package.json +53 -0
  59. package/node_modules/is-fullwidth-code-point/readme.md +31 -0
  60. package/node_modules/markdansi/LICENSE +21 -0
  61. package/node_modules/markdansi/README.md +167 -0
  62. package/node_modules/markdansi/dist/ast.d.ts +99 -0
  63. package/node_modules/markdansi/dist/ast.js +1 -0
  64. package/node_modules/markdansi/dist/cli.d.ts +17 -0
  65. package/node_modules/markdansi/dist/cli.js +208 -0
  66. package/node_modules/markdansi/dist/hyperlink.d.ts +9 -0
  67. package/node_modules/markdansi/dist/hyperlink.js +26 -0
  68. package/node_modules/markdansi/dist/index.d.ts +10 -0
  69. package/node_modules/markdansi/dist/index.js +16 -0
  70. package/node_modules/markdansi/dist/parser.d.ts +2 -0
  71. package/node_modules/markdansi/dist/parser.js +205 -0
  72. package/node_modules/markdansi/dist/render.d.ts +9 -0
  73. package/node_modules/markdansi/dist/render.js +680 -0
  74. package/node_modules/markdansi/dist/stream.d.ts +39 -0
  75. package/node_modules/markdansi/dist/stream.js +192 -0
  76. package/node_modules/markdansi/dist/theme.d.ts +18 -0
  77. package/node_modules/markdansi/dist/theme.js +105 -0
  78. package/node_modules/markdansi/dist/types.d.ts +58 -0
  79. package/node_modules/markdansi/dist/types.js +1 -0
  80. package/node_modules/markdansi/dist/wrap.d.ts +10 -0
  81. package/node_modules/markdansi/dist/wrap.js +73 -0
  82. package/node_modules/markdansi/docs/spec.md +110 -0
  83. package/node_modules/markdansi/package.json +78 -0
  84. package/node_modules/markdansi/tsconfig.json +18 -0
  85. package/node_modules/marked/LICENSE +44 -0
  86. package/node_modules/marked/README.md +115 -0
  87. package/node_modules/marked/bin/main.js +284 -0
  88. package/node_modules/marked/bin/marked.js +16 -0
  89. package/node_modules/marked/lib/marked.d.ts +760 -0
  90. package/node_modules/marked/lib/marked.esm.js +77 -0
  91. package/node_modules/marked/lib/marked.esm.js.map +7 -0
  92. package/node_modules/marked/lib/marked.umd.js +79 -0
  93. package/node_modules/marked/lib/marked.umd.js.map +7 -0
  94. package/node_modules/marked/man/marked.1 +113 -0
  95. package/node_modules/marked/man/marked.1.md +93 -0
  96. package/node_modules/marked/package.json +103 -0
  97. package/node_modules/slice-ansi/index.d.ts +19 -0
  98. package/node_modules/slice-ansi/index.js +317 -0
  99. package/node_modules/slice-ansi/license +10 -0
  100. package/node_modules/slice-ansi/package.json +59 -0
  101. package/node_modules/slice-ansi/readme.md +55 -0
  102. package/node_modules/slice-ansi/tokenize-ansi.js +752 -0
  103. package/node_modules/string-width/index.d.ts +39 -0
  104. package/node_modules/string-width/index.js +203 -0
  105. package/node_modules/string-width/license +9 -0
  106. package/node_modules/string-width/package.json +65 -0
  107. package/node_modules/string-width/readme.md +66 -0
  108. package/node_modules/strip-ansi/index.d.ts +15 -0
  109. package/node_modules/strip-ansi/index.js +19 -0
  110. package/node_modules/strip-ansi/license +9 -0
  111. package/node_modules/strip-ansi/package.json +59 -0
  112. package/node_modules/strip-ansi/readme.md +37 -0
  113. package/node_modules/supports-color/browser.d.ts +1 -0
  114. package/node_modules/supports-color/browser.js +35 -0
  115. package/node_modules/supports-color/index.d.ts +55 -0
  116. package/node_modules/supports-color/index.js +202 -0
  117. package/node_modules/supports-color/license +9 -0
  118. package/node_modules/supports-color/package.json +64 -0
  119. package/node_modules/supports-color/readme.md +75 -0
  120. package/node_modules/supports-hyperlinks/browser.js +10 -0
  121. package/node_modules/supports-hyperlinks/index.d.ts +25 -0
  122. package/node_modules/supports-hyperlinks/index.js +163 -0
  123. package/node_modules/supports-hyperlinks/license +10 -0
  124. package/node_modules/supports-hyperlinks/package.json +47 -0
  125. package/node_modules/supports-hyperlinks/readme.md +45 -0
  126. package/package.json +41 -10
  127. package/patches/markdansi@0.3.2.patch +22 -2
  128. package/src/agent/context-builder.ts +24 -10
  129. package/src/agent/context-meter.ts +89 -5
  130. package/src/agent/loop.ts +107 -10
  131. package/src/agent/runtime-session.ts +1437 -82
  132. package/src/agent/session-ledger.ts +169 -29
  133. package/src/agent/types.ts +12 -1
  134. package/src/agent/user-prompt-projection.ts +62 -0
  135. package/src/cli/config.ts +57 -14
  136. package/src/cli/model-profiles.ts +124 -0
  137. package/src/cli/run-runner.ts +4 -1
  138. package/src/cli/tui-runner.tsx +68 -30
  139. package/src/context/compiled-context-hash.ts +1 -1
  140. package/src/context/compiled-context-validator.ts +92 -15
  141. package/src/context/context-automation-policy.ts +121 -0
  142. package/src/context/context-manager.ts +390 -11
  143. package/src/context/context-policy.ts +8 -0
  144. package/src/context/context-protocol-validator.ts +11 -1
  145. package/src/context/context-revision-compiler.ts +190 -27
  146. package/src/context/context-revision.ts +105 -19
  147. package/src/context/context-surface.ts +329 -0
  148. package/src/context/prefix-retirement-planner.ts +570 -0
  149. package/src/context/protocol-frame.ts +30 -3
  150. package/src/context/recall-retirement-contract.ts +27 -0
  151. package/src/context/swap-planner.ts +34 -10
  152. package/src/events/observation-text-log.ts +3 -3
  153. package/src/events/stdout-event-printer.ts +39 -3
  154. package/src/events/types.ts +172 -35
  155. package/src/ids/runtime-id.ts +6 -0
  156. package/src/ids/uuid-v7.ts +7 -0
  157. package/src/image/image-asset-store.ts +319 -0
  158. package/src/image/image-input-policy.ts +14 -0
  159. package/src/image/image-probe.ts +233 -0
  160. package/src/image/image-types.ts +262 -0
  161. package/src/instructions/project-instructions.ts +4 -1
  162. package/src/mcp/mcp-manager.ts +83 -19
  163. package/src/model/fake-model-client.ts +9 -1
  164. package/src/model/input-token-estimator.ts +25 -0
  165. package/src/model/model-client.ts +60 -0
  166. package/src/model/model-request-preflight.ts +1 -0
  167. package/src/model/moonshot-input-token-estimator.ts +107 -0
  168. package/src/model/openai-chat-mapping.ts +77 -1
  169. package/src/model/openai-chat-model-client.ts +389 -59
  170. package/src/model/openai-chat-stream.ts +272 -0
  171. package/src/model/prompt-prefix-hash.ts +2 -4
  172. package/src/model/token-estimator.ts +4 -0
  173. package/src/observation/observation-builder.ts +29 -0
  174. package/src/session/resume-projection.ts +94 -34
  175. package/src/session/session-catalog.ts +4 -4
  176. package/src/session/session-errors.ts +1 -1
  177. package/src/session/session-history-reader.ts +151 -4
  178. package/src/session/session-last-response-reader.ts +189 -0
  179. package/src/session/session-schema.ts +349 -35
  180. package/src/session/session-store.ts +3266 -255
  181. package/src/session/sqlite-session-ledger.ts +9 -4
  182. package/src/skills/skill-catalog.ts +182 -0
  183. package/src/skills/skill-context.ts +364 -0
  184. package/src/skills/skill-loader.ts +779 -0
  185. package/src/skills/skill-tool.ts +297 -0
  186. package/src/tools/edit.ts +51 -64
  187. package/src/tools/ensure-parent-directory.ts +6 -0
  188. package/src/tools/read.ts +2 -2
  189. package/src/tools/recall.ts +43 -40
  190. package/src/tools/registry.ts +24 -3
  191. package/src/tools/ripgrep.ts +3 -2
  192. package/src/tools/types.ts +43 -4
  193. package/src/tools/write.ts +20 -31
  194. package/src/tui/app.tsx +333 -96
  195. package/src/tui/clipboard.ts +5 -0
  196. package/src/tui/components/mcp-panel.tsx +28 -0
  197. package/src/tui/components/prompt-input.tsx +790 -257
  198. package/src/tui/components/skills-panel.tsx +31 -0
  199. package/src/tui/components/timeline.tsx +30 -1
  200. package/src/tui/event-store.ts +85 -2
  201. package/src/tui/file-mention.ts +234 -0
  202. package/src/tui/line-editor.ts +1 -0
  203. package/src/tui/project-slash-commands.ts +229 -0
  204. package/src/tui/prompt-draft.ts +510 -0
  205. package/src/tui/prompt-history.ts +294 -22
  206. package/src/tui/slash-commands.ts +46 -2
  207. package/src/tui/tui-session-controller.ts +110 -48
  208. package/src/tui/workspace-file-search.ts +94 -0
@@ -0,0 +1,319 @@
1
+ import { constants } from "node:fs";
2
+ import {
3
+ chmod,
4
+ link,
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ readdir,
9
+ realpath,
10
+ rm,
11
+ stat,
12
+ } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { createUuidV7 } from "../ids/uuid-v7";
15
+ import { IMAGE_INPUT_POLICY } from "./image-input-policy";
16
+ import { probeImageBytes } from "./image-probe";
17
+ import {
18
+ normalizeOriginalImageName,
19
+ parseImageAssetId,
20
+ validateImageAssetRef,
21
+ type ImageAssetId,
22
+ type ImageAssetRef,
23
+ } from "./image-types";
24
+
25
+ const STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
26
+ const STAGING_PATTERN =
27
+ /^\.staging-image-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
28
+
29
+ export type ImportedImageAsset = {
30
+ readonly asset: ImageAssetRef;
31
+ readonly originalName: string;
32
+ };
33
+
34
+ export class ImageAssetStore {
35
+ readonly workspaceRoot: string;
36
+ readonly root: string;
37
+
38
+ private constructor(
39
+ workspaceRoot: string,
40
+ root: string,
41
+ private readonly onWarning?: (message: string) => void,
42
+ ) {
43
+ this.workspaceRoot = workspaceRoot;
44
+ this.root = root;
45
+ }
46
+
47
+ static async open(input: {
48
+ workspaceRoot: string;
49
+ onWarning?: (message: string) => void;
50
+ }): Promise<ImageAssetStore> {
51
+ const workspaceRoot = await realpath(input.workspaceRoot);
52
+ const workspaceStat = await stat(workspaceRoot);
53
+ if (!workspaceStat.isDirectory()) {
54
+ throw new Error(`Workspace root is not a directory: ${workspaceRoot}.`);
55
+ }
56
+ const root = await ensureAssetRoot(workspaceRoot);
57
+ const store = new ImageAssetStore(workspaceRoot, root, input.onWarning);
58
+ await store.cleanupStagingFiles();
59
+ return store;
60
+ }
61
+
62
+ pathFor(assetId: ImageAssetId): string {
63
+ parseImageAssetId(assetId);
64
+ return path.join(this.root, assetId);
65
+ }
66
+
67
+ async importWorkspaceFile(
68
+ sourcePath: string,
69
+ options: {
70
+ signal?: AbortSignal;
71
+ accept?: (asset: ImageAssetRef) => void;
72
+ } = {},
73
+ ): Promise<ImportedImageAsset> {
74
+ throwIfAborted(options.signal);
75
+ const candidate = path.isAbsolute(sourcePath)
76
+ ? path.normalize(sourcePath)
77
+ : path.resolve(this.workspaceRoot, sourcePath);
78
+ assertContained(this.workspaceRoot, candidate, "Image source path");
79
+ const pathStat = await lstat(candidate);
80
+ if (pathStat.isSymbolicLink() || !pathStat.isFile()) {
81
+ throw new Error("Image source must be a regular non-symlink file.");
82
+ }
83
+ const canonicalSource = await realpath(candidate);
84
+ assertContained(this.workspaceRoot, canonicalSource, "Image source realpath");
85
+
86
+ const handle = await open(canonicalSource, constants.O_RDONLY | noFollowFlag());
87
+ let bytes: Buffer;
88
+ try {
89
+ const handleStat = await handle.stat();
90
+ if (
91
+ !handleStat.isFile() ||
92
+ handleStat.dev !== pathStat.dev ||
93
+ handleStat.ino !== pathStat.ino
94
+ ) {
95
+ throw new Error("Image source changed between path validation and open.");
96
+ }
97
+ if (handleStat.size < 1) {
98
+ throw new Error("Image source is empty.");
99
+ }
100
+ if (handleStat.size > IMAGE_INPUT_POLICY.maxBytesPerImage) {
101
+ throw new Error(
102
+ `Image is ${handleStat.size} bytes; maximum is ${IMAGE_INPUT_POLICY.maxBytesPerImage}.`,
103
+ );
104
+ }
105
+ bytes = await handle.readFile();
106
+ } finally {
107
+ await handle.close();
108
+ }
109
+ throwIfAborted(options.signal);
110
+
111
+ const asset = await probeImageBytes(bytes, {
112
+ fullDecode: true,
113
+ sourceName: path.basename(canonicalSource),
114
+ });
115
+ options.accept?.(asset);
116
+ throwIfAborted(options.signal);
117
+ await this.publish(asset, bytes, options.signal);
118
+ return Object.freeze({
119
+ asset,
120
+ originalName: normalizeOriginalImageName(path.basename(canonicalSource)),
121
+ });
122
+ }
123
+
124
+ async readVerified(
125
+ expected: ImageAssetRef,
126
+ options: { signal?: AbortSignal } = {},
127
+ ): Promise<Buffer> {
128
+ validateImageAssetRef(expected);
129
+ throwIfAborted(options.signal);
130
+ const assetPath = this.pathFor(expected.assetId);
131
+ const fileStat = await lstat(assetPath);
132
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
133
+ throw new Error(`Image asset ${shortAssetId(expected.assetId)} is not regular.`);
134
+ }
135
+ if (fileStat.size !== expected.byteLength) {
136
+ throw new Error(`Image asset ${shortAssetId(expected.assetId)} length changed.`);
137
+ }
138
+ const handle = await open(assetPath, constants.O_RDONLY | noFollowFlag());
139
+ let bytes: Buffer;
140
+ try {
141
+ const openedStat = await handle.stat();
142
+ if (
143
+ !openedStat.isFile() ||
144
+ openedStat.dev !== fileStat.dev ||
145
+ openedStat.ino !== fileStat.ino
146
+ ) {
147
+ throw new Error(`Image asset ${shortAssetId(expected.assetId)} changed.`);
148
+ }
149
+ bytes = await handle.readFile();
150
+ } finally {
151
+ await handle.close();
152
+ }
153
+ throwIfAborted(options.signal);
154
+ const actual = await probeImageBytes(bytes, { fullDecode: false });
155
+ if (
156
+ actual.assetId !== expected.assetId ||
157
+ actual.mimeType !== expected.mimeType ||
158
+ actual.byteLength !== expected.byteLength ||
159
+ actual.width !== expected.width ||
160
+ actual.height !== expected.height
161
+ ) {
162
+ throw new Error(
163
+ `Image asset ${shortAssetId(expected.assetId)} failed integrity validation.`,
164
+ );
165
+ }
166
+ return bytes;
167
+ }
168
+
169
+ async verify(expected: ImageAssetRef): Promise<void> {
170
+ await this.readVerified(expected);
171
+ }
172
+
173
+ private async publish(
174
+ asset: ImageAssetRef,
175
+ bytes: Buffer,
176
+ signal?: AbortSignal,
177
+ ): Promise<void> {
178
+ const target = this.pathFor(asset.assetId);
179
+ const staging = path.join(this.root, `.staging-image-${createUuidV7()}`);
180
+ let stagingExists = false;
181
+ try {
182
+ const stagingHandle = await open(staging, "wx", 0o600);
183
+ stagingExists = true;
184
+ try {
185
+ await stagingHandle.writeFile(bytes);
186
+ await stagingHandle.sync();
187
+ } finally {
188
+ await stagingHandle.close();
189
+ }
190
+ throwIfAborted(signal);
191
+ try {
192
+ await link(staging, target);
193
+ } catch (error) {
194
+ if (!hasCode(error, "EEXIST")) {
195
+ throw error;
196
+ }
197
+ await this.verify(asset);
198
+ return;
199
+ }
200
+ await chmod(target, 0o600);
201
+ await syncDirectory(this.root);
202
+ } finally {
203
+ if (stagingExists) {
204
+ await rm(staging).catch((error) => {
205
+ if (!hasCode(error, "ENOENT")) {
206
+ this.onWarning?.(
207
+ `Failed to remove image staging file: ${errorMessage(error)}`,
208
+ );
209
+ }
210
+ });
211
+ }
212
+ }
213
+ }
214
+
215
+ private async cleanupStagingFiles(): Promise<void> {
216
+ const names = await readdir(this.root);
217
+ const now = Date.now();
218
+ for (const name of names) {
219
+ if (!STAGING_PATTERN.test(name)) {
220
+ continue;
221
+ }
222
+ const stagingPath = path.join(this.root, name);
223
+ try {
224
+ const entry = await lstat(stagingPath);
225
+ const age = now - entry.mtimeMs;
226
+ if (!Number.isFinite(age) || age < 0) {
227
+ this.onWarning?.(`Image staging file has an invalid mtime: ${name}`);
228
+ continue;
229
+ }
230
+ if (!entry.isFile() || entry.isSymbolicLink() || age <= STAGING_MAX_AGE_MS) {
231
+ continue;
232
+ }
233
+ await rm(stagingPath);
234
+ } catch (error) {
235
+ if (!hasCode(error, "ENOENT")) {
236
+ this.onWarning?.(
237
+ `Image staging cleanup skipped ${name}: ${errorMessage(error)}`,
238
+ );
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ async function ensureAssetRoot(workspaceRoot: string): Promise<string> {
246
+ let current = workspaceRoot;
247
+ for (const name of [".tinker", "assets", "images"]) {
248
+ current = path.join(current, name);
249
+ try {
250
+ const entry = await lstat(current);
251
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
252
+ throw new Error(
253
+ `Image asset directory is not a regular directory: ${current}.`,
254
+ );
255
+ }
256
+ } catch (error) {
257
+ if (!hasCode(error, "ENOENT")) {
258
+ throw error;
259
+ }
260
+ await mkdir(current, { mode: 0o700 });
261
+ }
262
+ if (name !== ".tinker") {
263
+ await chmod(current, 0o700);
264
+ }
265
+ }
266
+ const canonical = await realpath(current);
267
+ assertContained(workspaceRoot, canonical, "Image asset root");
268
+ if (canonical !== current) {
269
+ throw new Error("Image asset root is not canonical.");
270
+ }
271
+ return canonical;
272
+ }
273
+
274
+ function assertContained(root: string, candidate: string, name: string): void {
275
+ const relative = path.relative(root, candidate);
276
+ if (
277
+ relative === "" ||
278
+ (!relative.startsWith(`..${path.sep}`) &&
279
+ relative !== ".." &&
280
+ !path.isAbsolute(relative))
281
+ ) {
282
+ return;
283
+ }
284
+ throw new Error(`${name} is outside the workspace.`);
285
+ }
286
+
287
+ async function syncDirectory(directoryPath: string): Promise<void> {
288
+ const handle = await open(directoryPath, constants.O_RDONLY);
289
+ try {
290
+ await handle.sync();
291
+ } finally {
292
+ await handle.close();
293
+ }
294
+ }
295
+
296
+ function noFollowFlag(): number {
297
+ return typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
298
+ }
299
+
300
+ function throwIfAborted(signal: AbortSignal | undefined): void {
301
+ signal?.throwIfAborted();
302
+ }
303
+
304
+ function hasCode(error: unknown, code: string): boolean {
305
+ return (
306
+ typeof error === "object" &&
307
+ error !== null &&
308
+ "code" in error &&
309
+ error.code === code
310
+ );
311
+ }
312
+
313
+ function shortAssetId(assetId: ImageAssetId): string {
314
+ return `${assetId.slice(0, 12)}…`;
315
+ }
316
+
317
+ function errorMessage(error: unknown): string {
318
+ return error instanceof Error ? error.message : String(error);
319
+ }
@@ -0,0 +1,14 @@
1
+ export const IMAGE_INPUT_POLICY = Object.freeze({
2
+ transport: "openai-chat-image-url-data-v1",
3
+ allowedMimeTypes: Object.freeze(["image/png", "image/jpeg", "image/webp"] as const),
4
+ maxBytesPerImage: 20 * 1024 * 1024,
5
+ maxImagesPerMessage: 8,
6
+ maxImagesPerRequest: 8,
7
+ maxLongEdge: 4096,
8
+ maxPixels: 8_847_360,
9
+ maxRequestBodyBytes: 90_000_000,
10
+ planningTokensPerImage: 2048,
11
+ retryPolicy: "none",
12
+ } as const);
13
+
14
+ export const IMAGE_INPUT_POLICY_VERSION = "image-input-policy-v1" as const;
@@ -0,0 +1,233 @@
1
+ import sharp from "sharp";
2
+ import { IMAGE_INPUT_POLICY } from "./image-input-policy";
3
+ import {
4
+ imageAssetIdForBytes,
5
+ type ImageAssetRef,
6
+ type ImageMimeType,
7
+ } from "./image-types";
8
+
9
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
10
+
11
+ export type ImageProbeOptions = {
12
+ fullDecode?: boolean;
13
+ sourceName?: string;
14
+ };
15
+
16
+ export class ImageNotRecognizedError extends Error {
17
+ readonly code = "IMAGE_NOT_RECOGNIZED" as const;
18
+ }
19
+
20
+ export class UnsupportedImageFormatError extends Error {
21
+ readonly code = "IMAGE_FORMAT_UNSUPPORTED" as const;
22
+ }
23
+
24
+ export async function probeImageBytes(
25
+ bytes: Buffer,
26
+ options: ImageProbeOptions = {},
27
+ ): Promise<ImageAssetRef> {
28
+ if (bytes.length < 1) {
29
+ throw new Error("Image file is empty.");
30
+ }
31
+ if (bytes.length > IMAGE_INPUT_POLICY.maxBytesPerImage) {
32
+ throw new Error(
33
+ `Image is ${bytes.length} bytes; maximum is ${IMAGE_INPUT_POLICY.maxBytesPerImage}.`,
34
+ );
35
+ }
36
+
37
+ const container = inspectContainer(bytes, options.sourceName);
38
+ let metadata;
39
+ try {
40
+ const instance = sharp(bytes, {
41
+ failOn: "warning",
42
+ limitInputPixels: IMAGE_INPUT_POLICY.maxPixels,
43
+ unlimited: false,
44
+ sequentialRead: true,
45
+ });
46
+ metadata = await instance.metadata();
47
+ if (options.fullDecode !== false) {
48
+ await instance.raw().toBuffer();
49
+ }
50
+ } catch (error) {
51
+ throw new Error(`Image decode failed: ${errorMessage(error)}.`, {
52
+ cause: error,
53
+ });
54
+ }
55
+
56
+ const mimeType = mimeTypeForSharpFormat(metadata.format);
57
+ if (mimeType !== container.mimeType) {
58
+ throw new Error(
59
+ `Image container and decoder disagree on format (${container.mimeType} vs ${mimeType}).`,
60
+ );
61
+ }
62
+ const width = requireDimension(metadata.width, "width");
63
+ const height = requireDimension(metadata.height, "height");
64
+ const decoderAnimated =
65
+ (metadata.pages ?? 1) > 1 || metadata.pageHeight !== undefined;
66
+ if (container.animated || decoderAnimated) {
67
+ throw new Error("Animated images are not supported.");
68
+ }
69
+ if (
70
+ width > IMAGE_INPUT_POLICY.maxLongEdge ||
71
+ height > IMAGE_INPUT_POLICY.maxLongEdge
72
+ ) {
73
+ throw new Error(
74
+ `Image dimensions ${width}x${height} exceed ${IMAGE_INPUT_POLICY.maxLongEdge}px.`,
75
+ );
76
+ }
77
+ const pixels = width * height;
78
+ if (!Number.isSafeInteger(pixels) || pixels > IMAGE_INPUT_POLICY.maxPixels) {
79
+ throw new Error(
80
+ `Image pixel count ${pixels} exceeds ${IMAGE_INPUT_POLICY.maxPixels}.`,
81
+ );
82
+ }
83
+
84
+ return Object.freeze({
85
+ assetId: imageAssetIdForBytes(bytes),
86
+ mimeType,
87
+ byteLength: bytes.length,
88
+ width,
89
+ height,
90
+ });
91
+ }
92
+
93
+ function inspectContainer(
94
+ bytes: Buffer,
95
+ sourceName?: string,
96
+ ): {
97
+ mimeType: ImageMimeType;
98
+ animated: boolean;
99
+ } {
100
+ if (bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
101
+ return { mimeType: "image/png", animated: inspectPng(bytes) };
102
+ }
103
+ if (
104
+ bytes.length >= 3 &&
105
+ bytes[0] === 0xff &&
106
+ bytes[1] === 0xd8 &&
107
+ bytes[2] === 0xff
108
+ ) {
109
+ return { mimeType: "image/jpeg", animated: false };
110
+ }
111
+ if (
112
+ bytes.length >= 12 &&
113
+ bytes.toString("ascii", 0, 4) === "RIFF" &&
114
+ bytes.toString("ascii", 8, 12) === "WEBP"
115
+ ) {
116
+ return { mimeType: "image/webp", animated: inspectWebp(bytes) };
117
+ }
118
+ const prefix = bytes.subarray(0, Math.min(bytes.length, 512));
119
+ const looksUnsupported =
120
+ prefix.subarray(0, 6).toString("ascii") === "GIF87a" ||
121
+ prefix.subarray(0, 6).toString("ascii") === "GIF89a" ||
122
+ prefix.toString("utf8").trimStart().startsWith("<svg") ||
123
+ /\.(?:apng|avif|bmp|gif|heic|heif|jpe?g|png|svg|tiff?|webp)$/i.test(
124
+ sourceName ?? "",
125
+ );
126
+ if (looksUnsupported) {
127
+ throw new UnsupportedImageFormatError(
128
+ "Image format is unsupported or the image content is invalid.",
129
+ );
130
+ }
131
+ throw new ImageNotRecognizedError("Selected file is not an image.");
132
+ }
133
+
134
+ function inspectPng(bytes: Buffer): boolean {
135
+ let offset = PNG_SIGNATURE.length;
136
+ let chunkIndex = 0;
137
+ let sawIend = false;
138
+ let animated = false;
139
+ while (offset < bytes.length) {
140
+ if (bytes.length - offset < 12) {
141
+ throw new Error("PNG container is truncated.");
142
+ }
143
+ const length = bytes.readUInt32BE(offset);
144
+ const type = bytes.toString("ascii", offset + 4, offset + 8);
145
+ const chunkEnd = offset + 12 + length;
146
+ if (!/^[A-Za-z]{4}$/.test(type) || chunkEnd > bytes.length) {
147
+ throw new Error("PNG chunk length or type is invalid.");
148
+ }
149
+ if (chunkIndex === 0 && (type !== "IHDR" || length !== 13)) {
150
+ throw new Error("PNG must begin with a valid IHDR chunk.");
151
+ }
152
+ if (type === "acTL") {
153
+ if (length !== 8) {
154
+ throw new Error("PNG acTL chunk is invalid.");
155
+ }
156
+ animated = true;
157
+ }
158
+ if (type === "IEND") {
159
+ if (length !== 0 || chunkEnd !== bytes.length) {
160
+ throw new Error("PNG IEND chunk or trailing data is invalid.");
161
+ }
162
+ sawIend = true;
163
+ break;
164
+ }
165
+ offset = chunkEnd;
166
+ chunkIndex += 1;
167
+ }
168
+ if (!sawIend) {
169
+ throw new Error("PNG container has no IEND chunk.");
170
+ }
171
+ return animated;
172
+ }
173
+
174
+ function inspectWebp(bytes: Buffer): boolean {
175
+ const declaredSize = bytes.readUInt32LE(4) + 8;
176
+ if (declaredSize !== bytes.length) {
177
+ throw new Error("WebP RIFF size does not match the file length.");
178
+ }
179
+ let offset = 12;
180
+ let animated = false;
181
+ let chunkCount = 0;
182
+ while (offset < bytes.length) {
183
+ if (bytes.length - offset < 8) {
184
+ throw new Error("WebP container is truncated.");
185
+ }
186
+ const type = bytes.toString("ascii", offset, offset + 4);
187
+ const length = bytes.readUInt32LE(offset + 4);
188
+ const dataStart = offset + 8;
189
+ const paddedEnd = dataStart + length + (length % 2);
190
+ if (!/^[ -~]{4}$/.test(type) || paddedEnd > bytes.length) {
191
+ throw new Error("WebP chunk length or type is invalid.");
192
+ }
193
+ if (type === "VP8X") {
194
+ if (length !== 10) {
195
+ throw new Error("WebP VP8X chunk is invalid.");
196
+ }
197
+ animated ||= (bytes[dataStart] & 0x02) !== 0;
198
+ }
199
+ if (type === "ANIM" || type === "ANMF") {
200
+ animated = true;
201
+ }
202
+ chunkCount += 1;
203
+ offset = paddedEnd;
204
+ }
205
+ if (offset !== bytes.length || chunkCount === 0) {
206
+ throw new Error("WebP container has an invalid chunk boundary.");
207
+ }
208
+ return animated;
209
+ }
210
+
211
+ function mimeTypeForSharpFormat(format: string | undefined): ImageMimeType {
212
+ switch (format) {
213
+ case "png":
214
+ return "image/png";
215
+ case "jpeg":
216
+ return "image/jpeg";
217
+ case "webp":
218
+ return "image/webp";
219
+ default:
220
+ throw new Error(`Unsupported decoded image format: ${String(format)}.`);
221
+ }
222
+ }
223
+
224
+ function requireDimension(value: number | undefined, name: string): number {
225
+ if (!Number.isSafeInteger(value) || value === undefined || value < 1) {
226
+ throw new Error(`Decoded image ${name} must be a positive safe integer.`);
227
+ }
228
+ return value;
229
+ }
230
+
231
+ function errorMessage(error: unknown): string {
232
+ return error instanceof Error ? error.message : String(error);
233
+ }