doer-agent 0.5.8 → 0.5.9

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.
@@ -1,5 +1,6 @@
1
1
  import { watch } from "node:fs";
2
- import { open, readdir, realpath, rm, rmdir, stat, unlink } from "node:fs/promises";
2
+ import { open, mkdir, readdir, realpath, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
3
+ import crypto from "node:crypto";
3
4
  import path from "node:path";
4
5
  import { StringCodec } from "nats";
5
6
  const sessionRpcCodec = StringCodec();
@@ -89,6 +90,84 @@ function buildInlineBlobMarker(value) {
89
90
  }
90
91
  return "[inline blob omitted]";
91
92
  }
93
+ function extensionForImageMimeType(mimeType) {
94
+ const normalized = mimeType.trim().toLowerCase();
95
+ if (normalized === "image/jpeg" || normalized === "image/jpg")
96
+ return ".jpg";
97
+ if (normalized === "image/webp")
98
+ return ".webp";
99
+ if (normalized === "image/gif")
100
+ return ".gif";
101
+ if (normalized === "image/svg+xml")
102
+ return ".svg";
103
+ if (normalized === "image/bmp")
104
+ return ".bmp";
105
+ if (normalized === "image/avif")
106
+ return ".avif";
107
+ return ".png";
108
+ }
109
+ function decodeInlineImageDataUrl(value) {
110
+ const trimmed = value.trim();
111
+ const match = /^data:([^;,]+)(?:;[^,]*)?;base64,([\s\S]+)$/i.exec(trimmed);
112
+ if (!match) {
113
+ return null;
114
+ }
115
+ const mimeType = match[1]?.trim().toLowerCase() || "";
116
+ if (!mimeType.startsWith("image/")) {
117
+ return null;
118
+ }
119
+ const base64 = match[2]?.replace(/\s/g, "") || "";
120
+ if (!base64) {
121
+ return null;
122
+ }
123
+ try {
124
+ return {
125
+ bytes: Buffer.from(base64, "base64"),
126
+ mimeType,
127
+ };
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ function sanitizeSessionPathSegment(value, fallback) {
134
+ return value.trim().replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || fallback;
135
+ }
136
+ function inferSessionIdFromSessionFilePath(filePath) {
137
+ const base = path.basename(filePath, path.extname(filePath));
138
+ const match = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$/.exec(base);
139
+ const inferred = match?.[1]?.trim() || "";
140
+ return inferred || null;
141
+ }
142
+ async function materializeInlineSessionImage(args) {
143
+ const decoded = decodeInlineImageDataUrl(args.value);
144
+ if (!decoded || decoded.bytes.byteLength === 0) {
145
+ return null;
146
+ }
147
+ const inferredSessionId = inferSessionIdFromSessionFilePath(args.filePath);
148
+ const sessionBase = (args.sessionId ? sanitizeSessionPathSegment(args.sessionId, "") : "")
149
+ || (inferredSessionId ? sanitizeSessionPathSegment(inferredSessionId, "") : "")
150
+ || sanitizeSessionPathSegment(path.basename(args.filePath, path.extname(args.filePath)), "session");
151
+ const hash = crypto.createHash("sha256").update(decoded.bytes).digest("hex").slice(0, 16);
152
+ const ext = extensionForImageMimeType(decoded.mimeType);
153
+ const name = `line-${args.lineNumber}-${hash}${ext}`;
154
+ const relPath = path.posix.join(".doer-agent", "sessions", sessionBase, "outputs", name);
155
+ const absPath = path.resolve(args.workspaceRoot, relPath);
156
+ if (!(absPath === args.workspaceRoot || absPath.startsWith(args.workspaceRoot + path.sep))) {
157
+ return null;
158
+ }
159
+ await mkdir(path.dirname(absPath), { recursive: true });
160
+ const existing = await stat(absPath).catch(() => null);
161
+ if (!existing || !existing.isFile() || existing.size !== decoded.bytes.byteLength) {
162
+ await writeFile(absPath, decoded.bytes);
163
+ }
164
+ return {
165
+ relPath,
166
+ name,
167
+ size: decoded.bytes.byteLength,
168
+ mimeType: decoded.mimeType,
169
+ };
170
+ }
92
171
  function getSessionRpcPayloadByteLength(value) {
93
172
  try {
94
173
  const serialized = typeof value === "string" ? value : JSON.stringify(value);
@@ -104,12 +183,12 @@ function buildSessionRpcTruncatedMarker(label, value) {
104
183
  ? `[${label} truncated for session RPC pagination]`
105
184
  : `[${label} truncated for session RPC pagination: ${byteLength} bytes omitted]`;
106
185
  }
107
- function sanitizeSessionRpcPayload(value) {
186
+ async function sanitizeSessionRpcPayload(value, args) {
108
187
  if (typeof value === "string") {
109
188
  return value;
110
189
  }
111
190
  if (Array.isArray(value)) {
112
- return value.map((entry) => sanitizeSessionRpcPayload(entry));
191
+ return Promise.all(value.map((entry) => sanitizeSessionRpcPayload(entry, args)));
113
192
  }
114
193
  if (!isObjectRecord(value)) {
115
194
  return value;
@@ -117,14 +196,25 @@ function sanitizeSessionRpcPayload(value) {
117
196
  const sanitized = {};
118
197
  for (const [key, entry] of Object.entries(value)) {
119
198
  if (SESSION_RPC_BLOB_KEYS.has(key) && typeof entry === "string" && isInlineBlobString(entry)) {
199
+ if (key === "image_url") {
200
+ const materialized = await materializeInlineSessionImage({ ...args, value: entry });
201
+ if (materialized) {
202
+ sanitized[key] = materialized.relPath;
203
+ sanitized.name = typeof value.name === "string" && value.name.trim() ? value.name : materialized.name;
204
+ sanitized.size = typeof value.size === "number" && Number.isFinite(value.size) ? value.size : materialized.size;
205
+ sanitized.mimeType =
206
+ typeof value.mimeType === "string" && value.mimeType.trim() ? value.mimeType : materialized.mimeType;
207
+ continue;
208
+ }
209
+ }
120
210
  sanitized[key] = buildInlineBlobMarker(entry);
121
211
  continue;
122
212
  }
123
- sanitized[key] = sanitizeSessionRpcPayload(entry);
213
+ sanitized[key] = await sanitizeSessionRpcPayload(entry, args);
124
214
  }
125
215
  return sanitized;
126
216
  }
127
- function sanitizeSessionRpcRawLine(line) {
217
+ async function sanitizeSessionRpcRawLine(line, args) {
128
218
  const trimmed = line.trim();
129
219
  if (!trimmed.startsWith("{")) {
130
220
  return line;
@@ -159,7 +249,7 @@ function sanitizeSessionRpcRawLine(line) {
159
249
  }
160
250
  return JSON.stringify({
161
251
  ...parsed,
162
- payload: sanitizeSessionRpcPayload(parsed.payload),
252
+ payload: await sanitizeSessionRpcPayload(parsed.payload, args),
163
253
  });
164
254
  }
165
255
  catch {
@@ -482,6 +572,7 @@ async function readSessionLineIndex(workspaceRoot, filePath) {
482
572
  }
483
573
  async function getAgentSessionRawRows(args) {
484
574
  const resolvedFile = resolveSessionFilePath(args.workspaceRoot, args.filePath);
575
+ const effectiveSessionId = args.sessionId || await readSessionIdFromSessionFile(resolvedFile).catch(() => null);
485
576
  const index = await readSessionLineIndex(args.workspaceRoot, resolvedFile);
486
577
  const totalLines = index.lineStartOffsets.length;
487
578
  const sinceLine = Math.max(0, Math.floor(args.sinceLine));
@@ -595,7 +686,12 @@ async function getAgentSessionRawRows(args) {
595
686
  let lineNumber = startLineIndex + 1;
596
687
  for (const line of lines) {
597
688
  if (line.trim()) {
598
- const sanitized = sanitizeSessionRpcRawLine(line);
689
+ const sanitized = await sanitizeSessionRpcRawLine(line, {
690
+ workspaceRoot: args.workspaceRoot,
691
+ filePath: resolvedFile,
692
+ lineNumber,
693
+ sessionId: effectiveSessionId,
694
+ });
599
695
  rawRows.push({
600
696
  id: lineNumber,
601
697
  raw: sanitized,
@@ -613,7 +709,7 @@ async function getAgentSessionRawRows(args) {
613
709
  }
614
710
  }
615
711
  function resolveSessionUploadsDir(workspaceRoot, sessionId) {
616
- const safeSessionId = sessionId.trim().replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "session";
712
+ const safeSessionId = sanitizeSessionPathSegment(sessionId, "session");
617
713
  return path.join(workspaceRoot, ".doer-agent", "sessions", safeSessionId);
618
714
  }
619
715
  async function deleteAgentSession(workspaceRoot, filePath, sessionId) {
@@ -735,6 +831,7 @@ async function handleSessionRpcMessage(args) {
735
831
  const result = await getAgentSessionRawRows({
736
832
  workspaceRoot: args.workspaceRoot,
737
833
  filePath: request.filePath ?? "",
834
+ sessionId: request.sessionId,
738
835
  sinceLine: request.sinceLine,
739
836
  beforeRowId: request.beforeRowId,
740
837
  pageSize: request.pageSize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",