wtagent 0.4.0 → 1.0.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 (34) hide show
  1. package/README.md +161 -7
  2. package/package.json +5 -4
  3. package/src/artifacts/artifact-store.js +154 -0
  4. package/src/audio/music-artifact.js +66 -0
  5. package/src/audio/native-music-receiver.js +91 -0
  6. package/src/browser/base-web-adapter.js +354 -50
  7. package/src/browser/chatgpt-dom.js +179 -0
  8. package/src/browser/chatgpt-web-adapter.js +89 -125
  9. package/src/browser/fake-web-model-adapter.js +16 -1
  10. package/src/browser/gemini-web-adapter.js +61 -20
  11. package/src/browser/grok-web-adapter.js +35 -3
  12. package/src/browser/rendered-text.js +11 -0
  13. package/src/cli/i18n.js +40 -10
  14. package/src/cli/main.js +32 -3
  15. package/src/cli/prompt-input.js +68 -0
  16. package/src/image/browser-image-driver.js +238 -0
  17. package/src/image/browser-image-session-pool.js +96 -0
  18. package/src/image/image-generation-service.js +101 -0
  19. package/src/image/image-provider-driver.js +22 -0
  20. package/src/image/native-image-receiver.js +66 -0
  21. package/src/image/provider-registry.js +23 -0
  22. package/src/image/providers/chatgpt-image-driver.js +211 -0
  23. package/src/image/providers/gemini-image-driver.js +100 -0
  24. package/src/image/providers/grok-image-driver.js +193 -0
  25. package/src/policy/policy-engine.js +10 -5
  26. package/src/protocol/envelope.js +74 -0
  27. package/src/protocol/markers.js +16 -0
  28. package/src/protocol/prompt-builder.js +12 -9
  29. package/src/protocol/xml-protocol.js +36 -18
  30. package/src/runtime/agent-runtime.js +345 -65
  31. package/src/session/agent-session.js +73 -2
  32. package/src/tools/image-tools.js +92 -0
  33. package/src/tools/registry.js +13 -1
  34. package/docs/technical-design.md +0 -873
package/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # WTAgent
2
2
 
3
- Use your web AI account as a local CLI agent.
3
+ Turn ChatGPT Web (including Pro) into Codex.
4
+ Turn Claude Web into Claude Code.
4
5
 
5
- WTAgent connects ChatGPT, Claude, DeepSeek, Gemini, Grok, Kimi, or GLM Web to your local project: the model reasons in the browser while WTAgent reads and writes local files and runs commands on your machine.
6
+ Also DeepSeek, Gemini, Grok, Kimi, GLM.
7
+
8
+ No API key. No MCP. No tunnel. No plugin.
9
+
10
+ https://xixian42.github.io/wtagent/
6
11
 
7
12
  [中文](#中文)
8
13
 
@@ -24,7 +29,7 @@ cd wtagent-demo
24
29
  wtagent
25
30
  ```
26
31
 
27
- ChatGPT asks you to choose `Pro` or `Current`; other providers keep or silently select their configured default. Then type a task:
32
+ After login, interactive runs give you five seconds to choose a model in the browser. Press Enter to continue immediately, or wait to keep the current selection. Then type a task:
28
33
 
29
34
  ```text
30
35
  $ wtagent
@@ -33,13 +38,86 @@ you › Create hello.js that writes "Hello from WTAgent" to hello.txt. Run it wi
33
38
 
34
39
  WTAgent creates the files in the current directory, runs the script locally, and checks its output. You can continue chatting in the same terminal after the task finishes.
35
40
 
36
- Choose a provider with `--model` (ChatGPT is the default). Supported values are `chatgpt`, `claude`, `deepseek`, `gemini`, `kimi`, and `glm`:
41
+ Choose a provider with `--model` (ChatGPT is the default). Supported values are `chatgpt`, `claude`, `deepseek`, `gemini`, `grok`, `kimi`, and `glm`:
37
42
 
38
43
  ```bash
39
44
  wtagent --model claude "inspect this project and summarize its architecture"
40
45
  wtagent --model gemini "inspect this project and summarize its architecture"
41
46
  ```
42
47
 
48
+ ### Generate and save images
49
+
50
+ Use ChatGPT, Gemini, or Grok to generate images in the current conversation. No
51
+ extra image flag is needed; describe the image in your task:
52
+
53
+ ```bash
54
+ wtagent --model chatgpt --once "Generate a square illustration of a blue robot watering a plant. Save it locally."
55
+ wtagent --model gemini --once "Generate a 16:9 watercolor city skyline and save the image locally."
56
+ wtagent --model grok --once "Generate a minimalist landscape illustration and save it locally."
57
+ ```
58
+
59
+ WTAgent waits for the completed image reply, downloads its images, checks their
60
+ format and dimensions, and records SHA-256 hashes. Supported file formats are
61
+ PNG, JPEG, and WebP. Files are saved under the current project's
62
+ `artifacts/native-images/<turn-id>/`, for example:
63
+
64
+ ```text
65
+ artifacts/native-images/<turn-id>/image-1.png
66
+ artifacts/native-images/<turn-id>/image-2.webp
67
+ ```
68
+
69
+ The extension follows the downloaded file's actual format. Multiple images from
70
+ one reply are saved separately. WTAgent passes the verified local paths back to
71
+ the model, which can finish the task or continue with file operations such as
72
+ organizing assets and building a page that uses them.
73
+
74
+ In an interactive conversation, attach a local reference image with `@path`:
75
+
76
+ ```text
77
+ you › @assets/reference.png Create a watercolor illustration based on this reference and save it locally.
78
+ ```
79
+
80
+ Reference handling, image size, aspect ratio, and image availability depend on
81
+ the website and selected model. WTAgent uses your logged-in account and its
82
+ quota. A text-only explanation or refusal is returned as text; it is not reported
83
+ as a saved image.
84
+
85
+ If downloading or returning the saved paths is interrupted, resume the recorded
86
+ session without adding a new instruction:
87
+
88
+ ```bash
89
+ wtagent resume <session-id>
90
+ ```
91
+
92
+ Recovery reuses the recorded reply when it can verify the conversation. It does
93
+ not automatically repeat an image-generation request whose submission is
94
+ uncertain. Partially downloaded replies may need to download their files again.
95
+
96
+ ### Generate music with Gemini
97
+
98
+ ```bash
99
+ wtagent --model gemini --type music --once "Create peaceful instrumental piano music, no vocals"
100
+ ```
101
+
102
+ `--type music` selects Gemini's native Music tool. Tracks are saved under
103
+ `artifacts/music/<turn-id>/`. If Gemini returns an MP4 with audio and a cover
104
+ video, WTAgent preserves that original; when `ffmpeg` is on PATH, it also extracts
105
+ an `.m4a` audio file without re-encoding. Without FFmpeg, the original MP4 remains
106
+ usable. Duration follows the provider's output.
107
+
108
+ Download interruptions can be resumed with `wtagent resume <session-id>`. A bare
109
+ resume of a completed music session returns its saved result without generating
110
+ again. The default `--type agent` retains ordinary agent behavior, including
111
+ native image output.
112
+
113
+ ### Conversation recovery and diagnostics
114
+
115
+ The ChatGPT adapter supports legacy and modern message layouts, temporary
116
+ conversation URLs, and virtualized history. When a send cannot be verified,
117
+ WTAgent preserves the session for recovery. Structural failure reports identify
118
+ the recognized page layout, missing identity fields, and send stage; the CLI
119
+ shows the report path for startup, send-confirmation, and recovery failures.
120
+
43
121
  Claude keeps the model selected by claude.ai in its browser profile; WTAgent does not override it.
44
122
  Gemini likewise keeps the model selected in its browser profile.
45
123
 
@@ -57,7 +135,14 @@ Multiline paste and `↑` / `↓` input history are supported. Press `Ctrl+C` or
57
135
 
58
136
  ## 中文
59
137
 
60
- WTAgent 将 ChatGPT、Claude、DeepSeek、Gemini、Grok、Kimi 或 GLM 网页聊天连接到本地项目:模型在浏览器中思考,WTAgent 在你的电脑上读写本地文件并运行命令。
138
+ 把 ChatGPT 网页版(含 Pro)变成 Codex。
139
+ 把 Claude 网页版变成 Claude Code。
140
+
141
+ 同时支持 DeepSeek、Gemini、Grok、Kimi、GLM。
142
+
143
+ 无需 API Key,无需 MCP,无需隧道,无需插件。
144
+
145
+ https://xixian42.github.io/wtagent/
61
146
 
62
147
  ### 快速开始
63
148
 
@@ -77,7 +162,7 @@ cd wtagent-demo
77
162
  wtagent
78
163
  ```
79
164
 
80
- ChatGPT 会让你选择 `Pro` 或 `Current`;其他 provider 会保持或自动选择配置的默认模式。然后直接输入任务:
165
+ 登录后,交互模式会留出 5 秒供你在浏览器里选择模型。按 Enter 可立即继续,等待倒计时结束则沿用当前选择。然后输入任务:
81
166
 
82
167
  ```text
83
168
  $ wtagent
@@ -86,13 +171,62 @@ you › 创建 hello.js,将“Hello from WTAgent”写入 hello.txt。用 Node
86
171
 
87
172
  WTAgent 会在当前目录创建文件、运行本地脚本并检查输出。任务完成后,可以继续在同一个终端中对话。
88
173
 
89
- 通过 `--model` 选择 provider(默认是 ChatGPT)。可选值包括 `chatgpt`、`claude`、`deepseek`、`gemini`、`kimi` 和 `glm`:
174
+ 通过 `--model` 选择 provider(默认是 ChatGPT)。可选值包括 `chatgpt`、`claude`、`deepseek`、`gemini`、`grok`、`kimi` 和 `glm`:
90
175
 
91
176
  ```bash
92
177
  wtagent --model claude "分析这个项目并总结架构"
93
178
  wtagent --model gemini "分析这个项目并总结架构"
94
179
  ```
95
180
 
181
+ ### 生成图片并保存到本地
182
+
183
+ 使用 ChatGPT、Gemini 或 Grok,在当前对话中直接生成图片。无需额外的生图参数,在任务里描述要画的内容即可:
184
+
185
+ ```bash
186
+ wtagent --model chatgpt --once "生成一张蓝色小机器人给盆栽浇水的方形插画,保存到本地"
187
+ wtagent --model gemini --once "生成一张 16:9 的水彩城市天际线,保存到本地"
188
+ wtagent --model grok --once "生成一张极简风格的山水插画,保存到本地"
189
+ ```
190
+
191
+ WTAgent 等待图片回复完成后下载文件,检查格式和尺寸,并记录 SHA-256 哈希。支持 PNG、JPEG 和 WebP。文件保存在当前项目的 `artifacts/native-images/<turn-id>/` 目录,例如:
192
+
193
+ ```text
194
+ artifacts/native-images/<turn-id>/image-1.png
195
+ artifacts/native-images/<turn-id>/image-2.webp
196
+ ```
197
+
198
+ 扩展名以下载文件的实际格式为准。一条回复包含多张图片时,会分别保存。WTAgent 把经过验证的本地路径交回模型,模型可以结束任务,也可以继续整理素材、制作引用这些图片的页面等。
199
+
200
+ 在交互对话中,可以通过 `@路径` 附加本地参考图:
201
+
202
+ ```text
203
+ 你 › @assets/reference.png 参考这张图,生成水彩风格的插画并保存到本地。
204
+ ```
205
+
206
+ 是否支持参考图、图片尺寸、宽高比和图片生成,取决于网站及当前选择的模型。WTAgent 使用你已登录的账号和额度。若网站只返回文字说明或拒绝生成,会如实返回文字,不会声称已经保存图片。
207
+
208
+ 下载或回传文件路径时中断,可以不带新指令恢复原会话:
209
+
210
+ ```bash
211
+ wtagent resume <session-id>
212
+ ```
213
+
214
+ 恢复时会核对会话并复用已记录的回复;无法确认是否已提交的生图请求不会自动重发。只下载了一部分图片的回复,恢复时可能需要重新下载文件。
215
+
216
+ ### 使用 Gemini 生成音乐
217
+
218
+ ```bash
219
+ wtagent --model gemini --type music --once "生成一段舒缓钢琴纯音乐,不要人声"
220
+ ```
221
+
222
+ `--type music` 会启用 Gemini 原生 Music 工具,文件保存到 `artifacts/music/<turn-id>/`。如果 Gemini 返回带封面画面和音轨的 MP4,WTAgent 会保留原文件;本机 PATH 中有 `ffmpeg` 时,还会在不重新编码的情况下提取 `.m4a` 纯音频。未安装 FFmpeg 时仍可使用原始 MP4,时长以网站实际生成结果为准。
223
+
224
+ 下载中断后,可用 `wtagent resume <session-id>` 继续。已完成的音乐会话不带新指令恢复时,会返回已保存的结果,不再生成一次。默认 `--type agent` 保持普通智能体行为,也支持直接接收图片。
225
+
226
+ ### 会话恢复与兼容诊断
227
+
228
+ ChatGPT 适配器支持新旧消息结构、临时会话地址和只保留部分消息节点的长对话。无法确认发送结果时,会保留会话供恢复。结构化诊断会记录识别到的页面结构、缺失的消息身份字段和发送阶段;启动、发送确认或恢复失败时,终端会显示诊断文件路径。
229
+
96
230
  Claude 会沿用 claude.ai 在该浏览器 Profile 中选择的模型,WTAgent 不会自动切换模型。
97
231
  Gemini 同样沿用其浏览器 Profile 当前选择的模型。
98
232
 
@@ -108,6 +242,26 @@ wtagent "创建 hello.js,运行并验证输出"
108
242
 
109
243
  支持多行粘贴和 `↑` / `↓` 输入历史。使用 `Ctrl+C` 或 `Ctrl+D` 退出。
110
244
 
245
+ ## Development and regression tests / 开发与回归测试
246
+
247
+ Run from a source checkout / 在源码目录运行:
248
+
249
+ ```bash
250
+ npm ci
251
+ npm test
252
+ npm run check
253
+ npm run test:media
254
+ npm run test:chatgpt-dom
255
+ ```
256
+
257
+ `test:media` covers image downloads, native multi-image replies, interrupted
258
+ recovery, music handling, and provider DOM behavior. The browser tests require
259
+ Chrome/Chromium (or `WTAGENT_CHROME_PATH`), use isolated test pages, and do not
260
+ require a web account or consume generation quota. They do not replace live
261
+ account testing when a provider changes its service.
262
+
263
+ `test:media` 覆盖图片下载、多图回复、中断恢复、音乐处理和各网站的 DOM 行为。浏览器测试需要 Chrome/Chromium,也可通过 `WTAGENT_CHROME_PATH` 指定路径;测试使用隔离页面,无需账号,也不消耗生成额度。网站服务变化后的真实账号验证仍需单独进行。
264
+
111
265
  ## License
112
266
 
113
267
  [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wtagent",
3
- "version": "0.4.0",
4
- "description": "Turn your own web AI session into a local tool-using agent.",
3
+ "version": "1.0.0",
4
+ "description": "Turn ChatGPT Web (including Pro) into Codex. Turn Claude Web into Claude Code. Also DeepSeek, Gemini, Grok, Kimi, GLM. No API key, no MCP, no tunnel, no plugin.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/XiXian42/wtagent.git"
@@ -25,7 +25,6 @@
25
25
  "files": [
26
26
  "src",
27
27
  "README.md",
28
- "docs/technical-design.md",
29
28
  "LICENSE"
30
29
  ],
31
30
  "scripts": {
@@ -38,7 +37,9 @@
38
37
  "login": "node src/cli/main.js login",
39
38
  "prepack": "npm run check && npm test",
40
39
  "release": "npm publish --tag latest",
41
- "release:alpha": "npm publish --tag alpha"
40
+ "release:alpha": "npm publish --tag alpha",
41
+ "test:chatgpt-dom": "node scripts/test-chatgpt-dom.js",
42
+ "test:media": "node scripts/test-media.js"
42
43
  },
43
44
  "engines": {
44
45
  "node": "^20.17.0 || ^22.13.0 || >=23.5.0"
@@ -0,0 +1,154 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { replaceFileAtomic } from "../shared/atomic-write.js";
5
+ import { inspectMusicBuffer } from "../audio/music-artifact.js";
6
+ import {
7
+ resolveCanonicalWriteTarget,
8
+ resolveToolPath,
9
+ } from "../policy/path-guard.js";
10
+
11
+ const SIGNATURES = Object.freeze([
12
+ { mimeType: "image/png", extension: ".png", matches: (b) => b.length >= 24 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
13
+ { mimeType: "image/jpeg", extension: ".jpg", matches: (b) => b.length >= 4 && b[0] === 0xff && b[1] === 0xd8 && b.at(-2) === 0xff && b.at(-1) === 0xd9 },
14
+ { mimeType: "image/webp", extension: ".webp", matches: (b) => b.length >= 16 && b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP" },
15
+ ]);
16
+
17
+ function pngDimensions(buffer) {
18
+ return buffer.length >= 24
19
+ ? { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
20
+ : {};
21
+ }
22
+
23
+ function jpegDimensions(buffer) {
24
+ let offset = 2;
25
+ while (offset + 9 < buffer.length) {
26
+ if (buffer[offset] !== 0xff) {
27
+ offset += 1;
28
+ continue;
29
+ }
30
+ const marker = buffer[offset + 1];
31
+ offset += 2;
32
+ if (marker === 0xd8 || marker === 0xd9 || marker === 0x01) continue;
33
+ if (offset + 2 > buffer.length) break;
34
+ const length = buffer.readUInt16BE(offset);
35
+ if (length < 2 || offset + length > buffer.length) break;
36
+ if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
37
+ return {
38
+ height: buffer.readUInt16BE(offset + 3),
39
+ width: buffer.readUInt16BE(offset + 5),
40
+ };
41
+ }
42
+ offset += length;
43
+ }
44
+ return {};
45
+ }
46
+
47
+ function webpDimensions(buffer) {
48
+ const kind = buffer.toString("ascii", 12, 16);
49
+ if (kind === "VP8X" && buffer.length >= 30) {
50
+ return {
51
+ width: 1 + buffer.readUIntLE(24, 3),
52
+ height: 1 + buffer.readUIntLE(27, 3),
53
+ };
54
+ }
55
+ if (kind === "VP8L" && buffer.length >= 25) {
56
+ const bits = buffer.readUInt32LE(21);
57
+ return {
58
+ width: (bits & 0x3fff) + 1,
59
+ height: ((bits >>> 14) & 0x3fff) + 1,
60
+ };
61
+ }
62
+ if (kind === "VP8 " && buffer.length >= 30) {
63
+ return {
64
+ width: buffer.readUInt16LE(26) & 0x3fff,
65
+ height: buffer.readUInt16LE(28) & 0x3fff,
66
+ };
67
+ }
68
+ return {};
69
+ }
70
+
71
+ export function inspectImageBuffer(buffer) {
72
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
73
+ throw new Error("The provider returned an empty image artifact.");
74
+ }
75
+ const format = SIGNATURES.find((entry) => entry.matches(buffer));
76
+ if (!format) {
77
+ throw new Error("The downloaded artifact is not a supported PNG, JPEG, or WebP image.");
78
+ }
79
+ const dimensions = format.mimeType === "image/png"
80
+ ? pngDimensions(buffer)
81
+ : format.mimeType === "image/jpeg"
82
+ ? jpegDimensions(buffer)
83
+ : webpDimensions(buffer);
84
+ if (!Number.isSafeInteger(dimensions.width) || !Number.isSafeInteger(dimensions.height)
85
+ || dimensions.width <= 0 || dimensions.height <= 0) {
86
+ throw new Error(`Could not read dimensions from ${format.mimeType} artifact.`);
87
+ }
88
+ return {
89
+ ...format,
90
+ ...dimensions,
91
+ size: buffer.length,
92
+ sha256: createHash("sha256").update(buffer).digest("hex"),
93
+ };
94
+ }
95
+
96
+ export class ArtifactStore {
97
+ constructor({ projectRoot, allowOutside = false }) {
98
+ this.projectRoot = path.resolve(projectRoot);
99
+ this.allowOutside = allowOutside;
100
+ }
101
+
102
+ async resolveOutputPath(rawPath) {
103
+ const info = await resolveToolPath(this.projectRoot, rawPath);
104
+ if (!info.inside && !this.allowOutside) {
105
+ throw new Error(`Path is outside project root: ${info.path}`);
106
+ }
107
+ await fs.mkdir(path.dirname(info.path), { recursive: true });
108
+ return await resolveCanonicalWriteTarget(this.projectRoot, info.path, {
109
+ allowOutside: this.allowOutside,
110
+ });
111
+ }
112
+
113
+ async saveImage(sourcePath, outputPath, provenance = {}) {
114
+ return await this.saveValidatedMedia(sourcePath, outputPath, provenance, inspectImageBuffer, "image");
115
+ }
116
+
117
+ async saveMusic(sourcePath, outputPath, provenance = {}) {
118
+ return await this.saveValidatedMedia(sourcePath, outputPath, provenance, inspectMusicBuffer, "music");
119
+ }
120
+
121
+ async saveValidatedMedia(sourcePath, outputPath, provenance, inspect, type) {
122
+ const target = await this.resolveOutputPath(outputPath);
123
+ const buffer = await fs.readFile(sourcePath);
124
+ const inspected = inspect(buffer);
125
+ const requestedExtension = path.extname(target).toLowerCase();
126
+ const acceptedExtensions = inspected.mimeType === "image/jpeg"
127
+ ? new Set([".jpg", ".jpeg"])
128
+ : new Set([inspected.extension]);
129
+ if (requestedExtension && !acceptedExtensions.has(requestedExtension)) {
130
+ throw new Error(
131
+ `Output extension ${requestedExtension} does not match downloaded ${inspected.mimeType}.`,
132
+ );
133
+ }
134
+ const temporary = `${target}.wtagent-${process.pid}-${randomUUID()}.tmp`;
135
+ try {
136
+ await fs.copyFile(sourcePath, temporary);
137
+ await replaceFileAtomic(temporary, target);
138
+ } finally {
139
+ await fs.rm(temporary, { force: true }).catch(() => null);
140
+ }
141
+ return {
142
+ id: `artifact_${inspected.sha256.slice(0, 20)}`,
143
+ type,
144
+ localPath: target,
145
+ mimeType: inspected.mimeType,
146
+ width: inspected.width,
147
+ height: inspected.height,
148
+ ...(type === "music" ? { durationSeconds: inspected.durationSeconds, hasVideo: inspected.hasVideo } : {}),
149
+ size: inspected.size,
150
+ sha256: inspected.sha256,
151
+ provenance,
152
+ };
153
+ }
154
+ }
@@ -0,0 +1,66 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ function boxes(buffer, start = 0, end = buffer.length) {
4
+ const result = [];
5
+ for (let offset = start; offset < end;) {
6
+ if (offset + 8 > end) throw new Error("Truncated music container.");
7
+ let size = buffer.readUInt32BE(offset);
8
+ let header = 8;
9
+ if (size === 1) {
10
+ if (offset + 16 > end) throw new Error("Truncated music container.");
11
+ size = Number(buffer.readBigUInt64BE(offset + 8));
12
+ header = 16;
13
+ } else if (size === 0) size = end - offset;
14
+ if (!Number.isSafeInteger(size) || size < header || offset + size > end) {
15
+ throw new Error("Invalid music container size.");
16
+ }
17
+ result.push({ type: buffer.toString("ascii", offset + 4, offset + 8), start: offset + header, end: offset + size });
18
+ offset += size;
19
+ }
20
+ return result;
21
+ }
22
+
23
+ // Gemini's Music player currently serves an MP4 containing cover video and AAC.
24
+ // Check the actual audio track, not the URL suffix or the provider's caption.
25
+ export function inspectMusicBuffer(buffer) {
26
+ const top = boxes(buffer);
27
+ const moov = top.find((box) => box.type === "moov");
28
+ if (!top.some((box) => box.type === "ftyp") || !moov
29
+ || !top.some((box) => box.type === "mdat" && box.end > box.start)) {
30
+ throw new Error("The downloaded music is not a complete MP4/M4A file.");
31
+ }
32
+ const tracks = boxes(buffer, moov.start, moov.end).filter((box) => box.type === "trak");
33
+ const handlers = [];
34
+ let durationSeconds = null;
35
+ for (const track of tracks) {
36
+ const mdia = boxes(buffer, track.start, track.end).find((box) => box.type === "mdia");
37
+ if (!mdia) continue;
38
+ const children = boxes(buffer, mdia.start, mdia.end);
39
+ const hdlr = children.find((box) => box.type === "hdlr");
40
+ if (!hdlr || hdlr.start + 12 > hdlr.end) continue;
41
+ const handler = buffer.toString("ascii", hdlr.start + 8, hdlr.start + 12);
42
+ handlers.push(handler);
43
+ if (handler !== "soun") continue;
44
+ const mdhd = children.find((box) => box.type === "mdhd");
45
+ if (!mdhd) continue;
46
+ const version = buffer[mdhd.start];
47
+ const timeOffset = mdhd.start + (version === 1 ? 20 : 12);
48
+ if (![0, 1].includes(version) || timeOffset + (version === 1 ? 12 : 8) > mdhd.end) continue;
49
+ const scale = buffer.readUInt32BE(timeOffset);
50
+ const duration = version === 1
51
+ ? Number(buffer.readBigUInt64BE(timeOffset + 4)) : buffer.readUInt32BE(timeOffset + 4);
52
+ if (scale > 0 && duration > 0) durationSeconds = duration / scale;
53
+ }
54
+ if (!handlers.includes("soun") || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
55
+ throw new Error("The downloaded music file has no valid audio track.");
56
+ }
57
+ const hasVideo = handlers.includes("vide");
58
+ return {
59
+ mimeType: hasVideo ? "video/mp4" : "audio/mp4",
60
+ extension: hasVideo ? ".mp4" : ".m4a",
61
+ durationSeconds,
62
+ hasVideo,
63
+ size: buffer.length,
64
+ sha256: createHash("sha256").update(buffer).digest("hex"),
65
+ };
66
+ }
@@ -0,0 +1,91 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { createHash } from "node:crypto";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { ArtifactStore } from "../artifacts/artifact-store.js";
8
+ import { inspectMusicBuffer } from "./music-artifact.js";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ export const MUSIC_SYSTEM_REMINDER = "Use Gemini's native Music tool to create the requested playable music. "
12
+ + "Return the generated track directly, with an optional short description. Do not use XML, code, or local tools. "
13
+ + "If music cannot be generated, explain the limitation plainly. WTAgent downloads the generated file automatically.";
14
+
15
+ export function validateGenerationType(type, provider) {
16
+ const value = type ?? "agent";
17
+ if (!["agent", "music"].includes(value)) throw new Error(`Unknown --type ${value}. Use agent or music.`);
18
+ if (value === "music" && provider !== "gemini") throw new Error("--type music requires --model gemini.");
19
+ return value;
20
+ }
21
+
22
+ export class NativeMusicReceiver {
23
+ constructor({ adapter, extractAudio = extractAudioTrack }) {
24
+ this.adapter = adapter;
25
+ this.extractAudio = extractAudio;
26
+ }
27
+
28
+ async prepare() {
29
+ await this.adapter.prepareMusicGeneration();
30
+ }
31
+
32
+ async read(message) {
33
+ return await message.locator("video, audio").evaluateAll((elements) => ({
34
+ pending: elements.some((element) => !element.currentSrc && !element.src),
35
+ music: elements.filter((element) => element.isConnected && (element.currentSrc || element.src))
36
+ .map((element) => ({ source: element.currentSrc || element.src })),
37
+ }));
38
+ }
39
+
40
+ async save(tracks, { projectRoot, handoffId, assistantMessageId }) {
41
+ const store = new ArtifactStore({ projectRoot });
42
+ const directory = path.join("artifacts", "music", createHash("sha256").update(handoffId).digest("hex").slice(0, 24));
43
+ const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), "wtagent-music-"));
44
+ const artifacts = [];
45
+ try {
46
+ for (const [index, track] of tracks.entries()) {
47
+ const url = new URL(track.source);
48
+ if (url.protocol !== "https:" || !(url.hostname === "contribution.usercontent.google.com"
49
+ || url.hostname.endsWith(".googleusercontent.com"))) {
50
+ throw new Error("The music source is not a supported Gemini media URL.");
51
+ }
52
+ const response = await this.adapter.page.request.get(track.source, { timeout: 60_000 });
53
+ if (!response.ok()) throw new Error(`Music download failed with HTTP ${response.status()}.`);
54
+ const body = await response.body();
55
+ const inspected = inspectMusicBuffer(body);
56
+ const source = path.join(temporaryDir, `track-${index + 1}${inspected.extension}`);
57
+ await fs.writeFile(source, body);
58
+ const provenance = { provider: "gemini", assistantMessageId, source: track.source };
59
+ const original = await store.saveMusic(source, path.join(directory, path.basename(source)), provenance);
60
+ artifacts.push(original);
61
+ if (inspected.hasVideo) {
62
+ const audioPath = path.join(temporaryDir, `track-${index + 1}.m4a`);
63
+ const extraction = await this.extractAudio(source, audioPath);
64
+ if (extraction.ok) {
65
+ artifacts.push(await store.saveMusic(audioPath, path.join(directory, path.basename(audioPath)), {
66
+ ...provenance, extractedFrom: original.localPath, lossless: true,
67
+ }));
68
+ } else {
69
+ original.audioExtractionNote = extraction.message;
70
+ }
71
+ }
72
+ }
73
+ return artifacts;
74
+ } finally {
75
+ await fs.rm(temporaryDir, { recursive: true, force: true });
76
+ }
77
+ }
78
+ }
79
+
80
+ async function extractAudioTrack(source, output) {
81
+ try {
82
+ await execFileAsync("ffmpeg", ["-nostdin", "-v", "error", "-n", "-i", source, "-map", "0:a:0", "-vn", "-c:a", "copy", output], {
83
+ timeout: 60_000, maxBuffer: 1024 * 1024, windowsHide: true,
84
+ });
85
+ return { ok: true };
86
+ } catch (error) {
87
+ return { ok: false, message: error.code === "ENOENT"
88
+ ? "FFmpeg is not installed; the original MP4 with its audio track was saved."
89
+ : "Audio extraction failed; the original MP4 with its audio track was saved." };
90
+ }
91
+ }