doer-agent 0.5.7 → 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 {
@@ -279,33 +369,57 @@ function extractLastSessionMessage(candidateLines) {
279
369
  }
280
370
  return null;
281
371
  }
282
- async function readLastSessionMessage(fileHandle, fileSize) {
283
- const chunkBytes = 16_384;
284
- const maxScanBytes = 131_072;
285
- if (fileSize <= 0) {
286
- return null;
372
+ function linePrefixContainsSessionMessageCandidate(prefix) {
373
+ return (/"type"\s*:\s*"event_msg"/.test(prefix) &&
374
+ /"type"\s*:\s*"(agent_message|user_message)"/.test(prefix));
375
+ }
376
+ async function readLineSpan(fileHandle, start, end) {
377
+ const readSize = Math.max(0, end - start);
378
+ if (readSize <= 0) {
379
+ return "";
287
380
  }
288
- let position = fileSize;
289
- let scanned = 0;
290
- let carry = "";
291
- while (position > 0 && scanned < maxScanBytes) {
292
- const readSize = Math.min(chunkBytes, position, maxScanBytes - scanned);
293
- position -= readSize;
294
- scanned += readSize;
295
- const buffer = Buffer.alloc(readSize);
296
- const { bytesRead } = await fileHandle.read(buffer, 0, readSize, position);
381
+ const buffer = Buffer.alloc(readSize);
382
+ let totalBytesRead = 0;
383
+ while (totalBytesRead < readSize) {
384
+ const { bytesRead } = await fileHandle.read(buffer, totalBytesRead, readSize - totalBytesRead, start + totalBytesRead);
297
385
  if (bytesRead <= 0) {
298
386
  break;
299
387
  }
300
- const merged = buffer.toString("utf8", 0, bytesRead) + carry;
301
- const lines = merged.split(/\r?\n/);
302
- carry = lines.shift() || "";
303
- const found = extractLastSessionMessage(lines.reverse());
304
- if (found) {
305
- return found;
388
+ totalBytesRead += bytesRead;
389
+ }
390
+ return buffer.toString("utf8", 0, totalBytesRead).trim();
391
+ }
392
+ async function readLastSessionMessage(workspaceRoot, filePath) {
393
+ const index = await readSessionLineIndex(workspaceRoot, filePath);
394
+ const totalLines = index.lineStartOffsets.length;
395
+ if (totalLines <= 0 || index.size <= 0) {
396
+ return null;
397
+ }
398
+ const resolvedFile = resolveSessionFilePath(workspaceRoot, filePath);
399
+ const fileHandle = await open(resolvedFile, "r");
400
+ try {
401
+ for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex -= 1) {
402
+ const start = index.lineStartOffsets[lineIndex] ?? index.size;
403
+ const end = lineIndex + 1 < totalLines ? (index.lineStartOffsets[lineIndex + 1] ?? index.size) : index.size;
404
+ const spanBytes = Math.max(0, end - start);
405
+ if (spanBytes <= 0) {
406
+ continue;
407
+ }
408
+ const prefixBytes = Math.min(spanBytes, 1024);
409
+ const prefix = await readLineSpan(fileHandle, start, start + prefixBytes);
410
+ if (!linePrefixContainsSessionMessageCandidate(prefix)) {
411
+ continue;
412
+ }
413
+ const found = extractLastSessionMessage([await readLineSpan(fileHandle, start, end)]);
414
+ if (found) {
415
+ return found;
416
+ }
306
417
  }
418
+ return null;
419
+ }
420
+ finally {
421
+ await fileHandle.close().catch(() => undefined);
307
422
  }
308
- return extractLastSessionMessage([carry]);
309
423
  }
310
424
  function normalizeSessionMeta(rawMeta, filePath, mtimeMs) {
311
425
  const baseName = path.basename(filePath, path.extname(filePath));
@@ -321,13 +435,13 @@ function normalizeSessionMeta(rawMeta, filePath, mtimeMs) {
321
435
  filePath,
322
436
  };
323
437
  }
324
- async function readSessionSummary(filePath, mtimeMs) {
438
+ async function readSessionSummary(workspaceRoot, filePath, mtimeMs) {
325
439
  let fileHandle = null;
326
440
  try {
327
441
  fileHandle = await open(filePath, "r");
328
442
  const entryStat = await fileHandle.stat();
329
443
  const firstLine = await readFirstLine(fileHandle, entryStat.size);
330
- const tailSummary = await readLastSessionMessage(fileHandle, entryStat.size);
444
+ const tailSummary = await readLastSessionMessage(workspaceRoot, filePath);
331
445
  let normalized = normalizeSessionMeta({}, filePath, mtimeMs);
332
446
  if (firstLine) {
333
447
  try {
@@ -363,6 +477,7 @@ async function readSessionSummary(filePath, mtimeMs) {
363
477
  }
364
478
  }
365
479
  async function listAgentSessions(workspaceRoot) {
480
+ const maxSessionSummaries = 10;
366
481
  const sessionsRoot = getSessionsRootPath(workspaceRoot);
367
482
  let sessionsRootStat;
368
483
  try {
@@ -376,8 +491,23 @@ async function listAgentSessions(workspaceRoot) {
376
491
  }
377
492
  const files = await collectSessionJsonlFiles(workspaceRoot);
378
493
  files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
379
- const sessions = await Promise.all(files.slice(0, 10).map((file) => readSessionSummary(file.filePath, file.mtimeMs)));
380
- return sessions.sort((a, b) => toSortableTimestampMs(b.updatedAt) - toSortableTimestampMs(a.updatedAt) || b.filePath.localeCompare(a.filePath));
494
+ const sessions = [];
495
+ for (let index = 0; index < files.length; index += 1) {
496
+ const file = files[index];
497
+ sessions.push(await readSessionSummary(workspaceRoot, file.filePath, file.mtimeMs));
498
+ sessions.sort((a, b) => toSortableTimestampMs(b.updatedAt) - toSortableTimestampMs(a.updatedAt) || b.filePath.localeCompare(a.filePath));
499
+ if (sessions.length > maxSessionSummaries) {
500
+ sessions.length = maxSessionSummaries;
501
+ }
502
+ const nextFile = files[index + 1] ?? null;
503
+ const oldestSelectedSession = sessions[maxSessionSummaries - 1] ?? null;
504
+ if (nextFile &&
505
+ oldestSelectedSession &&
506
+ toSortableTimestampMs(oldestSelectedSession.updatedAt) >= nextFile.mtimeMs) {
507
+ break;
508
+ }
509
+ }
510
+ return sessions;
381
511
  }
382
512
  async function readSessionLineIndex(workspaceRoot, filePath) {
383
513
  const resolvedFile = resolveSessionFilePath(workspaceRoot, filePath);
@@ -442,6 +572,7 @@ async function readSessionLineIndex(workspaceRoot, filePath) {
442
572
  }
443
573
  async function getAgentSessionRawRows(args) {
444
574
  const resolvedFile = resolveSessionFilePath(args.workspaceRoot, args.filePath);
575
+ const effectiveSessionId = args.sessionId || await readSessionIdFromSessionFile(resolvedFile).catch(() => null);
445
576
  const index = await readSessionLineIndex(args.workspaceRoot, resolvedFile);
446
577
  const totalLines = index.lineStartOffsets.length;
447
578
  const sinceLine = Math.max(0, Math.floor(args.sinceLine));
@@ -555,7 +686,12 @@ async function getAgentSessionRawRows(args) {
555
686
  let lineNumber = startLineIndex + 1;
556
687
  for (const line of lines) {
557
688
  if (line.trim()) {
558
- const sanitized = sanitizeSessionRpcRawLine(line);
689
+ const sanitized = await sanitizeSessionRpcRawLine(line, {
690
+ workspaceRoot: args.workspaceRoot,
691
+ filePath: resolvedFile,
692
+ lineNumber,
693
+ sessionId: effectiveSessionId,
694
+ });
559
695
  rawRows.push({
560
696
  id: lineNumber,
561
697
  raw: sanitized,
@@ -573,7 +709,7 @@ async function getAgentSessionRawRows(args) {
573
709
  }
574
710
  }
575
711
  function resolveSessionUploadsDir(workspaceRoot, sessionId) {
576
- const safeSessionId = sessionId.trim().replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "session";
712
+ const safeSessionId = sanitizeSessionPathSegment(sessionId, "session");
577
713
  return path.join(workspaceRoot, ".doer-agent", "sessions", safeSessionId);
578
714
  }
579
715
  async function deleteAgentSession(workspaceRoot, filePath, sessionId) {
@@ -695,6 +831,7 @@ async function handleSessionRpcMessage(args) {
695
831
  const result = await getAgentSessionRawRows({
696
832
  workspaceRoot: args.workspaceRoot,
697
833
  filePath: request.filePath ?? "",
834
+ sessionId: request.sessionId,
698
835
  sinceLine: request.sinceLine,
699
836
  beforeRowId: request.beforeRowId,
700
837
  pageSize: request.pageSize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",