ework-daemon 0.4.63 → 0.4.64

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.63",
3
+ "version": "0.4.64",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -0,0 +1,102 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+
3
+ /**
4
+ * ework-web attachment links: `[name](/attachments/<uuid>)` in issue bodies
5
+ * and comments. The web requires auth on that route, but the daemon's Gitea
6
+ * PAT is accepted (same auth surface as cookies), so the daemon downloads
7
+ * attachments for the agent instead of teaching it to curl with tokens.
8
+ */
9
+ export const ATTACHMENT_LINK_RE = /\/attachments\/([0-9a-fA-F-]{36})/g;
10
+
11
+ export interface DownloadedAttachment {
12
+ uuid: string;
13
+ filename: string;
14
+ size: number;
15
+ /** Reason the referenced attachment was not downloaded, if any. */
16
+ skipped?: string;
17
+ }
18
+
19
+ export const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
20
+
21
+ function sanitizeFilename(raw: string, fallback: string): string {
22
+ const cleaned = raw
23
+ .replace(/[\u0000-\u001f\u007f]/g, "")
24
+ .replace(/[/\\]/g, "_")
25
+ .replace(/^\.+/, "")
26
+ .trim();
27
+ return cleaned || fallback;
28
+ }
29
+
30
+ /**
31
+ * Download every /attachments/<uuid> referenced in `content` into
32
+ * `<workdir>/attachments/` using the daemon's Gitea PAT. Best-effort:
33
+ * failures are reported per-attachment via `skipped`, never thrown.
34
+ */
35
+ export async function downloadIssueAttachments(
36
+ content: string,
37
+ baseUrl: string,
38
+ token: string,
39
+ workdir: string,
40
+ ): Promise<DownloadedAttachment[]> {
41
+ const uuids = new Set<string>();
42
+ for (const m of content.matchAll(ATTACHMENT_LINK_RE)) {
43
+ if (m[1]) uuids.add(m[1]);
44
+ }
45
+ if (uuids.size === 0) return [];
46
+ const base = baseUrl.replace(/\/+$/, "");
47
+ const out: DownloadedAttachment[] = [];
48
+ for (const uuid of uuids) {
49
+ try {
50
+ const res = await fetch(`${base}/attachments/${uuid}`, {
51
+ headers: { authorization: `token ${token}` },
52
+ signal: AbortSignal.timeout(120_000),
53
+ // Auth failures surface as 302 -> login; treat redirects as failures
54
+ // instead of following them to the login page.
55
+ redirect: "manual",
56
+ });
57
+ if (res.status >= 300 && res.status < 400) {
58
+ out.push({ uuid, filename: "", size: 0, skipped: `HTTP ${res.status} (auth)` });
59
+ continue;
60
+ }
61
+ if (!res.ok) {
62
+ out.push({ uuid, filename: "", size: 0, skipped: `HTTP ${res.status}` });
63
+ continue;
64
+ }
65
+ const lenHeader = Number(res.headers.get("content-length") ?? "0");
66
+ if (lenHeader > MAX_ATTACHMENT_BYTES) {
67
+ out.push({ uuid, filename: "", size: lenHeader, skipped: "too large" });
68
+ continue;
69
+ }
70
+ const cd = res.headers.get("content-disposition") ?? "";
71
+ const nameMatch = cd.match(/filename="([^"]*)"/);
72
+ const filename = sanitizeFilename(nameMatch?.[1] ?? "", `${uuid}.bin`);
73
+ const buf = Buffer.from(await res.arrayBuffer());
74
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
75
+ out.push({ uuid, filename, size: buf.length, skipped: "too large" });
76
+ continue;
77
+ }
78
+ const dir = `${workdir}/attachments`;
79
+ await mkdir(dir, { recursive: true });
80
+ await writeFile(`${dir}/${filename}`, buf);
81
+ out.push({ uuid, filename, size: buf.length });
82
+ } catch (e) {
83
+ out.push({
84
+ uuid,
85
+ filename: "",
86
+ size: 0,
87
+ skipped: e instanceof Error ? e.message : "download error",
88
+ });
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export function attachmentNote(atts: DownloadedAttachment[]): string {
95
+ if (atts.length === 0) return "";
96
+ const lines = atts.map((a) =>
97
+ a.skipped
98
+ ? `- ${a.filename || a.uuid}: 未能下载(${a.skipped})`
99
+ : `- attachments/${a.filename}(${(a.size / 1024).toFixed(1)} KB)`,
100
+ );
101
+ return `\n\n[system] 本条消息引用的附件已由系统代为下载到工作目录的 attachments/ 目录:\n${lines.join("\n")}\n请直接用文件工具读取分析(日志类文件建议分段/grep 查看)。`;
102
+ }
package/src/opencode.ts CHANGED
@@ -10,6 +10,7 @@ import { formatKey, parseKey } from "./trackers/types";
10
10
  import type { RuntimeBackend, RuntimeHandle } from "./runtime/types";
11
11
  import { OpencodeBackend } from "./runtime/opencode-backend";
12
12
  import { PiBackend } from "./runtime/pi-backend";
13
+ import { downloadIssueAttachments, attachmentNote } from "./attachments";
13
14
 
14
15
  // ─── Types ───
15
16
 
@@ -1278,13 +1279,33 @@ export class Engine {
1278
1279
 
1279
1280
  const childEnv = spawnEnvFor(process.env, this.hookEnvFor(issue, session, workdir), workdir);
1280
1281
 
1282
+ let spawnPrompt = msg.content;
1283
+ try {
1284
+ const atts = await downloadIssueAttachments(
1285
+ msg.content,
1286
+ this.cfg.gitea.url,
1287
+ this.cfg.gitea.token,
1288
+ workdir,
1289
+ );
1290
+ if (atts.length > 0) {
1291
+ log.info(
1292
+ `engine: attachments for ${k}: ${atts
1293
+ .map((a) => `${a.filename || a.uuid}${a.skipped ? ` (skip: ${a.skipped})` : ""}`)
1294
+ .join(", ")}`,
1295
+ );
1296
+ spawnPrompt += attachmentNote(atts);
1297
+ }
1298
+ } catch {
1299
+ // Best-effort: the agent still has the raw message without files.
1300
+ }
1301
+
1281
1302
  let exitCode: number | null = null;
1282
1303
 
1283
1304
  try {
1284
1305
  const handle = await backend.spawn(
1285
1306
  {
1286
1307
  workdir,
1287
- prompt: msg.content,
1308
+ prompt: spawnPrompt,
1288
1309
  model: model || undefined,
1289
1310
  resumeSessionId: resumeSessionId || undefined,
1290
1311
  env: childEnv,