@downcity/agent 1.1.104 → 1.1.106

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 (35) hide show
  1. package/bin/config/DowncitySchema.d.ts.map +1 -1
  2. package/bin/config/DowncitySchema.js +0 -69
  3. package/bin/config/DowncitySchema.js.map +1 -1
  4. package/bin/config/Paths.d.ts +1 -1
  5. package/bin/config/Paths.js +1 -1
  6. package/bin/executor/composer/system/default/assets/plugin.prompt.d.ts +1 -1
  7. package/bin/executor/composer/system/default/assets/plugin.prompt.d.ts.map +1 -1
  8. package/bin/executor/composer/system/default/assets/plugin.prompt.js +1 -1
  9. package/bin/executor/composer/system/default/assets/plugin.prompt.js.map +1 -1
  10. package/bin/executor/core-engine/CoreEngineMessageState.d.ts +8 -0
  11. package/bin/executor/core-engine/CoreEngineMessageState.d.ts.map +1 -1
  12. package/bin/executor/core-engine/CoreEngineMessageState.js +9 -3
  13. package/bin/executor/core-engine/CoreEngineMessageState.js.map +1 -1
  14. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  15. package/bin/executor/core-engine/CoreEngineRunner.js +1 -0
  16. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  17. package/bin/executor/messages/SessionAttachmentMapper.d.ts +4 -3
  18. package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
  19. package/bin/executor/messages/SessionAttachmentMapper.js +24 -7
  20. package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
  21. package/bin/executor/messages/SessionMessageCodec.d.ts +1 -1
  22. package/bin/executor/messages/SessionMessageCodec.d.ts.map +1 -1
  23. package/bin/executor/messages/SessionMessageCodec.js +3 -3
  24. package/bin/executor/messages/SessionMessageCodec.js.map +1 -1
  25. package/package.json +2 -2
  26. package/scripts/assistant-file-resource.test.mjs +107 -18
  27. package/src/config/DowncitySchema.ts +0 -69
  28. package/src/config/Paths.ts +1 -1
  29. package/src/executor/composer/system/default/assets/plugin.prompt.ts +1 -1
  30. package/src/executor/composer/system/default/assets/plugin.prompt.ts.txt +1 -1
  31. package/src/executor/core-engine/CoreEngineMessageState.ts +26 -2
  32. package/src/executor/core-engine/CoreEngineRunner.ts +1 -0
  33. package/src/executor/messages/SessionAttachmentMapper.ts +29 -6
  34. package/src/executor/messages/SessionMessageCodec.ts +6 -2
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -1,9 +1,9 @@
1
1
  /**
2
- * @file 验证 assistant file part 会把 data URL 资源落盘为 file:// URL。
2
+ * @file 验证 assistant file part 会把资源落盘为 resources:// URL。
3
3
  *
4
4
  * 关键点(中文)
5
5
  * - 历史消息不应长期保存图片 base64。
6
- * - 送模型前可以从 file:// 临时 hydrate 回 data URL。
6
+ * - 送模型前可以从 resources:// 临时 hydrate 回 data URL。
7
7
  */
8
8
 
9
9
  import test from "node:test";
@@ -11,11 +11,16 @@ import assert from "node:assert/strict";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import fs from "node:fs/promises";
14
- import { fileURLToPath } from "node:url";
14
+ import http from "node:http";
15
+ import { pathToFileURL } from "node:url";
15
16
 
16
17
  import { materializeAssistantFileParts } from "../bin/executor/messages/AssistantFileResource.js";
17
18
  import { hydrateFileUrlPartsForModel } from "../bin/executor/messages/SessionAttachmentMapper.js";
18
19
 
20
+ function resource_path_from_url(project_root, url) {
21
+ return path.join(project_root, String(url || "").replace(/^resources:\/\//, ""));
22
+ }
23
+
19
24
  test("materializeAssistantFileParts stores data URL images under .downcity/resources", async () => {
20
25
  const project_root = await fs.mkdtemp(
21
26
  path.join(os.tmpdir(), "downcity-agent-assistant-resource-"),
@@ -39,10 +44,10 @@ test("materializeAssistantFileParts stores data URL images under .downcity/resou
39
44
  assert.equal(parts[0].type, "file");
40
45
  assert.equal(parts[0].mediaType, "image/png");
41
46
  assert.equal(parts[0].filename, "image-1.png");
42
- assert.match(parts[0].url, /^file:\/\//);
47
+ assert.match(parts[0].url, /^resources:\/\/\.downcity\/resources\//);
43
48
  assert.equal(parts[0].url.includes("base64"), false);
44
49
 
45
- const resource_path = fileURLToPath(parts[0].url);
50
+ const resource_path = resource_path_from_url(project_root, parts[0].url);
46
51
  assert.equal(
47
52
  path.dirname(resource_path),
48
53
  path.join(project_root, ".downcity", "resources"),
@@ -50,7 +55,49 @@ test("materializeAssistantFileParts stores data URL images under .downcity/resou
50
55
  assert.deepEqual(await fs.readFile(resource_path), bytes);
51
56
  });
52
57
 
53
- test("hydrateFileUrlPartsForModel converts persisted file URLs back to data URLs in memory", async () => {
58
+ test("materializeAssistantFileParts downloads remote file URLs into resources", async () => {
59
+ const project_root = await fs.mkdtemp(
60
+ path.join(os.tmpdir(), "downcity-agent-assistant-remote-resource-"),
61
+ );
62
+ const bytes = Buffer.from("remote-png-bytes-for-test", "utf8");
63
+ const server = http.createServer((_, response) => {
64
+ response.writeHead(200, { "content-type": "image/png" });
65
+ response.end(bytes);
66
+ });
67
+
68
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
69
+ try {
70
+ const address = server.address();
71
+ assert.equal(typeof address, "object");
72
+ assert.ok(address);
73
+ const remote_url = `http://127.0.0.1:${address.port}/image.png`;
74
+
75
+ const parts = await materializeAssistantFileParts({
76
+ projectRoot: project_root,
77
+ parts: [
78
+ {
79
+ type: "file",
80
+ mediaType: "image/png",
81
+ filename: "remote.png",
82
+ url: remote_url,
83
+ },
84
+ ],
85
+ });
86
+
87
+ assert.match(parts[0].url, /^resources:\/\/\.downcity\/resources\//);
88
+ assert.equal(parts[0].url.startsWith("http://"), false);
89
+ assert.deepEqual(
90
+ await fs.readFile(resource_path_from_url(project_root, parts[0].url)),
91
+ bytes,
92
+ );
93
+ } finally {
94
+ await new Promise((resolve, reject) =>
95
+ server.close((error) => (error ? reject(error) : resolve())),
96
+ );
97
+ }
98
+ });
99
+
100
+ test("hydrateFileUrlPartsForModel converts resources URLs back to data URLs in memory", async () => {
54
101
  const project_root = await fs.mkdtemp(
55
102
  path.join(os.tmpdir(), "downcity-agent-assistant-hydrate-"),
56
103
  );
@@ -67,18 +114,21 @@ test("hydrateFileUrlPartsForModel converts persisted file URLs back to data URLs
67
114
  ],
68
115
  });
69
116
 
70
- const messages = await hydrateFileUrlPartsForModel([
71
- {
72
- id: "a:test:1",
73
- role: "assistant",
74
- metadata: {
75
- v: 1,
76
- ts: Date.now(),
77
- sessionId: "session_test",
117
+ const messages = await hydrateFileUrlPartsForModel(
118
+ [
119
+ {
120
+ id: "a:test:1",
121
+ role: "assistant",
122
+ metadata: {
123
+ v: 1,
124
+ ts: Date.now(),
125
+ sessionId: "session_test",
126
+ },
127
+ parts: materialized,
78
128
  },
79
- parts: materialized,
80
- },
81
- ]);
129
+ ],
130
+ project_root,
131
+ );
82
132
 
83
133
  const hydrated_part = messages[0]?.parts[0];
84
134
  assert.equal(hydrated_part?.type, "file");
@@ -87,5 +137,44 @@ test("hydrateFileUrlPartsForModel converts persisted file URLs back to data URLs
87
137
  hydrated_part?.url,
88
138
  `data:image/png;base64,${bytes.toString("base64")}`,
89
139
  );
90
- assert.match(materialized[0].url, /^file:\/\//);
140
+ assert.match(materialized[0].url, /^resources:\/\/\.downcity\/resources\//);
141
+ });
142
+
143
+ test("hydrateFileUrlPartsForModel keeps old file URLs compatible", async () => {
144
+ const project_root = await fs.mkdtemp(
145
+ path.join(os.tmpdir(), "downcity-agent-assistant-file-url-"),
146
+ );
147
+ const bytes = Buffer.from("legacy-file-url-bytes", "utf8");
148
+ const file_path = path.join(project_root, "legacy.png");
149
+ await fs.writeFile(file_path, bytes);
150
+
151
+ const messages = await hydrateFileUrlPartsForModel(
152
+ [
153
+ {
154
+ id: "a:test:legacy",
155
+ role: "assistant",
156
+ metadata: {
157
+ v: 1,
158
+ ts: Date.now(),
159
+ sessionId: "session_test",
160
+ },
161
+ parts: [
162
+ {
163
+ type: "file",
164
+ mediaType: "image/png",
165
+ filename: "legacy.png",
166
+ url: pathToFileURL(file_path).href,
167
+ },
168
+ ],
169
+ },
170
+ ],
171
+ project_root,
172
+ );
173
+
174
+ const hydrated_part = messages[0]?.parts[0];
175
+ assert.equal(hydrated_part?.type, "file");
176
+ assert.equal(
177
+ hydrated_part?.url,
178
+ `data:image/png;base64,${bytes.toString("base64")}`,
179
+ );
91
180
  });
@@ -141,75 +141,6 @@ export const DOWNCITY_JSON_SCHEMA: JsonObject = {
141
141
  },
142
142
  },
143
143
  },
144
- asr: {
145
- type: "object",
146
- additionalProperties: true,
147
- properties: {
148
- injectPrompt: { type: "boolean" },
149
- augmentMessage: { type: "boolean" },
150
- provider: { type: "string", enum: ["local", "command"] },
151
- modelId: {
152
- type: "string",
153
- enum: [
154
- "SenseVoiceSmall",
155
- "paraformer-zh-streaming",
156
- "whisper-large-v3-turbo",
157
- ],
158
- },
159
- modelsDir: { type: "string" },
160
- pythonBin: { type: "string" },
161
- command: { type: "string" },
162
- language: { type: "string" },
163
- timeoutMs: { type: "integer", minimum: 1000, maximum: 600000 },
164
- strategy: {
165
- type: "string",
166
- enum: ["auto", "funasr", "transformers-whisper", "command"],
167
- },
168
- installedModels: {
169
- type: "array",
170
- items: {
171
- type: "string",
172
- enum: [
173
- "SenseVoiceSmall",
174
- "paraformer-zh-streaming",
175
- "whisper-large-v3-turbo",
176
- ],
177
- },
178
- },
179
- },
180
- },
181
- tts: {
182
- type: "object",
183
- additionalProperties: true,
184
- properties: {
185
- provider: {
186
- type: "string",
187
- enum: ["local"],
188
- },
189
- modelId: {
190
- type: "string",
191
- enum: ["qwen3-tts-0.6b", "kokoro-82m", "qwen3-tts-1.7b"],
192
- },
193
- modelsDir: { type: "string" },
194
- pythonBin: { type: "string" },
195
- language: { type: "string" },
196
- voice: { type: "string" },
197
- format: {
198
- type: "string",
199
- enum: ["wav", "flac"],
200
- },
201
- speed: { type: "number", minimum: 0.5, maximum: 2 },
202
- outputDir: { type: "string" },
203
- timeoutMs: { type: "integer", minimum: 5000, maximum: 900000 },
204
- installedModels: {
205
- type: "array",
206
- items: {
207
- type: "string",
208
- enum: ["qwen3-tts-0.6b", "kokoro-82m", "qwen3-tts-1.7b"],
209
- },
210
- },
211
- },
212
- },
213
144
  },
214
145
  },
215
146
  llm: {
@@ -336,7 +336,7 @@ export function getDowncityPublicDirPath(cwd: string): string {
336
336
  *
337
337
  * 关键点(中文)
338
338
  * - 该目录用于存放会话历史引用的二进制资源,例如图片生成结果。
339
- * - `messages.jsonl` 只保存 `file://` 绝对 URL,避免把大段 base64 长期写入历史。
339
+ * - `messages.jsonl` 只保存 `resources://` 相对 URL,避免暴露本机绝对路径或长期保存 base64
340
340
  */
341
341
  export function getDowncityResourcesDirPath(cwd: string): string {
342
342
  return path.join(getDowncityDirPath(cwd), "resources");
@@ -4,6 +4,6 @@
4
4
  */
5
5
 
6
6
  // Source: src/executor/composer/system/default/assets/plugin.prompt.ts.txt
7
- const TEXT_MODULE_CONTENT = "# Plugin State\n\n你正在一个基于 plugin 的执行环境中工作。\n\n## Plugin 调用规则\n\n- 当你需要使用 plugin 能力时,优先通过可用的 tool 调用 plugin action。\n- 若当前工具集中存在 `plugin_call`,使用 `plugin_call({ plugin, action, payload })` 触发对应 plugin action。\n- `plugin_call.plugin` 是 plugin 名称,例如 `skill`、`task`、`memory`、`contact`。\n- `plugin_call.action` 是 action 名称,例如 `list`、`lookup`、`create`、`run`。\n- `plugin_call.payload` 是结构化 JSON payload;没有参数时传 `{}`。\n- ActionSchedule 是 Agent 内部用于延迟执行 plugin action 的能力,不是独立 plugin。\n\n## 可用 plugin 概览\n\n- 当前内建托管 plugin:`shell` / `chat` / `task` / `memory` / `contact`。\n- 当前内建本地 plugin:`auth` / `skill` / `asr` / `tts`。\n\n具体 plugin 能力以该 plugin 的 action 和 system 提示为准。\n";
7
+ const TEXT_MODULE_CONTENT = "# Plugin State\n\n你正在一个基于 plugin 的执行环境中工作。\n\n## Plugin 调用规则\n\n- 当你需要使用 plugin 能力时,优先通过可用的 tool 调用 plugin action。\n- 若当前工具集中存在 `plugin_call`,使用 `plugin_call({ plugin, action, payload })` 触发对应 plugin action。\n- `plugin_call.plugin` 是 plugin 名称,例如 `skill`、`task`、`memory`、`contact`。\n- `plugin_call.action` 是 action 名称,例如 `list`、`lookup`、`create`、`run`。\n- `plugin_call.payload` 是结构化 JSON payload;没有参数时传 `{}`。\n- ActionSchedule 是 Agent 内部用于延迟执行 plugin action 的能力,不是独立 plugin。\n\n## 可用 plugin 概览\n\n- 当前内建托管 plugin:`shell` / `chat` / `task` / `memory` / `contact`。\n- 当前内建本地 plugin:`auth` / `skill`。\n\n具体 plugin 能力以该 plugin 的 action 和 system 提示为准。\n";
8
8
 
9
9
  export default TEXT_MODULE_CONTENT;
@@ -14,6 +14,6 @@
14
14
  ## 可用 plugin 概览
15
15
 
16
16
  - 当前内建托管 plugin:`shell` / `chat` / `task` / `memory` / `contact`。
17
- - 当前内建本地 plugin:`auth` / `skill` / `asr` / `tts`。
17
+ - 当前内建本地 plugin:`auth` / `skill`。
18
18
 
19
19
  具体 plugin 能力以该 plugin 的 action 和 system 提示为准。
@@ -33,6 +33,11 @@ export class CoreEngineMessageState {
33
33
  */
34
34
  private readonly tools: Record<string, Tool>;
35
35
 
36
+ /**
37
+ * 当前项目根目录,用于解析历史中的 `resources://` file part。
38
+ */
39
+ private readonly projectRoot?: string;
40
+
36
41
  private constructor(params: {
37
42
  /**
38
43
  * 当前运行时 session 语义消息。
@@ -46,10 +51,15 @@ export class CoreEngineMessageState {
46
51
  * 当前轮可用工具集合。
47
52
  */
48
53
  tools: Record<string, Tool>;
54
+ /**
55
+ * 当前项目根目录。
56
+ */
57
+ projectRoot?: string;
49
58
  }) {
50
59
  this.sessionMessages = params.sessionMessages;
51
60
  this.currentModelMessages = params.modelMessages;
52
61
  this.tools = params.tools;
62
+ this.projectRoot = params.projectRoot;
53
63
  }
54
64
 
55
65
  /**
@@ -64,14 +74,23 @@ export class CoreEngineMessageState {
64
74
  * 当前轮可用工具集合。
65
75
  */
66
76
  tools: Record<string, Tool>;
77
+ /**
78
+ * 当前项目根目录。
79
+ */
80
+ projectRoot?: string;
67
81
  }): Promise<CoreEngineMessageState> {
68
82
  const sessionMessages = Array.isArray(params.messages)
69
83
  ? [...params.messages]
70
84
  : [];
71
85
  return new CoreEngineMessageState({
72
86
  sessionMessages,
73
- modelMessages: await toModelMessages(sessionMessages, params.tools),
87
+ modelMessages: await toModelMessages(
88
+ sessionMessages,
89
+ params.tools,
90
+ params.projectRoot,
91
+ ),
74
92
  tools: params.tools,
93
+ projectRoot: params.projectRoot,
75
94
  });
76
95
  }
77
96
 
@@ -119,7 +138,11 @@ export class CoreEngineMessageState {
119
138
  messages: SessionMessageV1[],
120
139
  ): Promise<ModelMessage[]> {
121
140
  this.sessionMessages = [...this.sessionMessages, ...messages];
122
- const modelMessages = await toModelMessages(messages, this.tools);
141
+ const modelMessages = await toModelMessages(
142
+ messages,
143
+ this.tools,
144
+ this.projectRoot,
145
+ );
123
146
  if (modelMessages.length > 0) {
124
147
  this.currentModelMessages = [...this.currentModelMessages, ...modelMessages];
125
148
  return modelMessages;
@@ -127,6 +150,7 @@ export class CoreEngineMessageState {
127
150
  this.currentModelMessages = await toModelMessages(
128
151
  this.sessionMessages,
129
152
  this.tools,
153
+ this.projectRoot,
130
154
  );
131
155
  return [];
132
156
  }
@@ -154,6 +154,7 @@ export class CoreEngineRunner {
154
154
  const message_state = await CoreEngineMessageState.create({
155
155
  messages: input.execute_input.messages,
156
156
  tools,
157
+ projectRoot: input.run_context.projectRoot,
157
158
  });
158
159
 
159
160
  const append_merged_user_messages = (messages: SessionMessageV1[]) =>
@@ -5,6 +5,7 @@
5
5
  * - 兼容 Telegram / Feishu / TUI 等统一的 `<file>` 协议入口。
6
6
  * - 仅在本轮执行的内存消息上追加 file parts,不修改持久化历史。
7
7
  * - 当前只为图片与 PDF 注入 file part,保持多模态模型可直接消费。
8
+ * - 历史中的 `resources://` 与旧版 `file://` 会在喂给模型前临时 hydrate。
8
9
  */
9
10
 
10
11
  import fs from "fs-extra";
@@ -62,11 +63,32 @@ function buildDataUrl(mediaType: string, buffer: Buffer): string {
62
63
  return `data:${safeType};base64,${base64}`;
63
64
  }
64
65
 
65
- async function hydrateFileUrlPart(part: FileUIPart): Promise<FileUIPart> {
66
+ function resolveResourcesUrlPath(
67
+ projectRoot: string | undefined,
68
+ rawUrl: string,
69
+ ): string | null {
70
+ const prefix = "resources://";
71
+ const raw = String(rawUrl || "").trim();
72
+ if (!raw.startsWith(prefix)) return null;
73
+ const relative = raw.slice(prefix.length).replace(/^\/+/, "");
74
+ if (!relative) return null;
75
+
76
+ const root = path.resolve(String(projectRoot || "").trim() || process.cwd());
77
+ const absPath = path.resolve(root, relative);
78
+ const rel = path.relative(root, absPath);
79
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null;
80
+ return absPath;
81
+ }
82
+
83
+ async function hydrateFileUrlPart(
84
+ part: FileUIPart,
85
+ projectRoot?: string,
86
+ ): Promise<FileUIPart> {
66
87
  const url = String(part.url || "").trim();
67
- if (!url.startsWith("file://")) return part;
88
+ const resourcesPath = resolveResourcesUrlPath(projectRoot, url);
89
+ if (!url.startsWith("file://") && !resourcesPath) return part;
68
90
  try {
69
- const absPath = fileURLToPath(url);
91
+ const absPath = resourcesPath || fileURLToPath(url);
70
92
  const buffer = await fs.readFile(absPath);
71
93
  const mediaType =
72
94
  String(part.mediaType || "").trim() ||
@@ -83,14 +105,15 @@ async function hydrateFileUrlPart(part: FileUIPart): Promise<FileUIPart> {
83
105
  }
84
106
 
85
107
  /**
86
- * 将历史中的 `file://` file part 临时转换为模型可消费的 data URL。
108
+ * 将历史中的资源 file part 临时转换为模型可消费的 data URL。
87
109
  *
88
110
  * 关键点(中文)
89
111
  * - 该函数只修改本轮内存消息,不回写历史。
90
- * - 持久化层继续保留轻量 `file://` 绝对 URL,避免 JSONL 存储 base64。
112
+ * - 新历史保留 `resources://` 相对 URL,旧历史的 `file://` 仍继续兼容。
91
113
  */
92
114
  export async function hydrateFileUrlPartsForModel(
93
115
  messages: SessionMessageV1[],
116
+ projectRoot?: string,
94
117
  ): Promise<SessionMessageV1[]> {
95
118
  if (!Array.isArray(messages) || messages.length === 0) return messages;
96
119
 
@@ -109,7 +132,7 @@ export async function hydrateFileUrlPartsForModel(
109
132
  nextParts.push(part);
110
133
  continue;
111
134
  }
112
- const nextPart = await hydrateFileUrlPart(part as FileUIPart);
135
+ const nextPart = await hydrateFileUrlPart(part as FileUIPart, projectRoot);
113
136
  if (nextPart !== part) changed = true;
114
137
  nextParts.push(nextPart as SessionMessageV1["parts"][number]);
115
138
  }
@@ -72,6 +72,7 @@ export function pickMergedUserMessages(
72
72
  export async function toModelMessages(
73
73
  messages: SessionMessageV1[],
74
74
  tools: Record<string, Tool>,
75
+ projectRoot?: string,
75
76
  ): Promise<ModelMessage[]> {
76
77
  // 空输入快速返回,避免调用转换器的额外开销。
77
78
  if (!Array.isArray(messages) || messages.length === 0) return [];
@@ -79,8 +80,11 @@ export async function toModelMessages(
79
80
  // 第一步(中文):在 user 消息上注入 file parts(多模态附件)。
80
81
  const enrichedMessages = await injectFilePartsFromAttachments(messages);
81
82
 
82
- // 第二步(中文):把历史里的 file:// 资源在内存中 hydrate 成模型可消费的 data URL。
83
- const hydratedMessages = await hydrateFileUrlPartsForModel(enrichedMessages);
83
+ // 第二步(中文):把历史里的资源 URL 在内存中 hydrate 成模型可消费的 data URL。
84
+ const hydratedMessages = await hydrateFileUrlPartsForModel(
85
+ enrichedMessages,
86
+ projectRoot,
87
+ );
84
88
 
85
89
  // 第三步(中文):转换前先剔除 UI 层 id 字段,仅保留模型需要的数据结构。
86
90
  const input = hydratedMessages.map((message) => {