relay-companion 0.1.30 → 0.1.32
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 +1 -1
- package/src/materializer.js +112 -9
- package/src/relay-briefing.js +49 -0
package/package.json
CHANGED
package/src/materializer.js
CHANGED
|
@@ -108,6 +108,7 @@ async function resolveRow(id, { log = () => {} } = {}) {
|
|
|
108
108
|
// before staging; this is only a last-resort merge default.
|
|
109
109
|
senderName: firstNonEmpty(rowState.senderName, content?.sender?.name, content?.sender?.handle) || "",
|
|
110
110
|
attachments: rowState.attachments || content?.attachments || [],
|
|
111
|
+
attachmentUrls: rowState.attachmentUrls || content?.attachmentUrls || {},
|
|
111
112
|
delivery: content?.delivery || rowState.delivery || null,
|
|
112
113
|
sender: content?.sender || (rowState.senderName ? { name: rowState.senderName } : null),
|
|
113
114
|
};
|
|
@@ -213,10 +214,14 @@ export async function openRelay({ id, host = "claude", log = () => {} } = {}) {
|
|
|
213
214
|
async function materializeRowInHost({ id, row, rowState = {}, host = "claude", log = () => {} }) {
|
|
214
215
|
const cleanHost = String(host || "claude").toLowerCase();
|
|
215
216
|
const cwd = resolveCwd();
|
|
217
|
+
const rowWithAttachments = await materializeAttachmentFiles(row, { log });
|
|
218
|
+
const materializationSignature = rowMaterializationSignature(rowWithAttachments);
|
|
219
|
+
const materializationStale =
|
|
220
|
+
hasAttachments(rowWithAttachments) && rowState.attachmentMaterializationSignature !== materializationSignature;
|
|
216
221
|
// The human reads `visible`; the agent-only `operatorNote` (real ids + the tool
|
|
217
222
|
// call to make) rides a hidden channel per host (Claude: a system-meta transcript
|
|
218
223
|
// row; Codex: developerInstructions) so it never enters the visible transcript.
|
|
219
|
-
const { visible: briefing, operatorNote } = renderRelayRowSeed(
|
|
224
|
+
const { visible: briefing, operatorNote } = renderRelayRowSeed(rowWithAttachments);
|
|
220
225
|
let url = null;
|
|
221
226
|
let openedInHost = false;
|
|
222
227
|
let skipExternalOpen = false;
|
|
@@ -224,14 +229,20 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
|
|
|
224
229
|
if (cleanHost === "codex") {
|
|
225
230
|
let threadId = rowState.threadId || null;
|
|
226
231
|
let sessionPath = rowState.sessionPath || null;
|
|
232
|
+
if (materializationStale) {
|
|
233
|
+
threadId = null;
|
|
234
|
+
sessionPath = null;
|
|
235
|
+
}
|
|
227
236
|
if (!threadId || !codexRolloutExists(threadId)) {
|
|
228
|
-
const thread = await createCodexThread({ row, briefing, operatorNote, cwd });
|
|
237
|
+
const thread = await createCodexThread({ row: rowWithAttachments, briefing, operatorNote, cwd });
|
|
229
238
|
threadId = thread.id;
|
|
230
239
|
sessionPath = thread.path;
|
|
231
240
|
rememberRow(id, {
|
|
232
241
|
threadId,
|
|
233
242
|
sessionPath,
|
|
234
|
-
title:
|
|
243
|
+
title: rowWithAttachments.displayTitle,
|
|
244
|
+
attachments: rowWithAttachments.attachments || [],
|
|
245
|
+
attachmentMaterializationSignature: materializationSignature,
|
|
235
246
|
materializedSurfaces: {
|
|
236
247
|
codex: true,
|
|
237
248
|
claudeCode: Boolean(rowState.materializedSurfaces?.claudeCode || rowState.claudeNativeSession),
|
|
@@ -243,9 +254,9 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
|
|
|
243
254
|
ensureRelayCodexIndexMarker({ threadId, sessionPath, packetId: id });
|
|
244
255
|
finalizeCodexThreadState({
|
|
245
256
|
threadId,
|
|
246
|
-
title:
|
|
257
|
+
title: rowWithAttachments.displayTitle,
|
|
247
258
|
cwd,
|
|
248
|
-
preview: relayThreadPreview(
|
|
259
|
+
preview: relayThreadPreview(rowWithAttachments),
|
|
249
260
|
});
|
|
250
261
|
try {
|
|
251
262
|
setThreadPinned(threadId, true);
|
|
@@ -275,26 +286,29 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
|
|
|
275
286
|
url = `codex://threads/${encodeURIComponent(threadId)}`;
|
|
276
287
|
} else {
|
|
277
288
|
let claudeNativeSession = rowState.claudeNativeSession || null;
|
|
289
|
+
if (materializationStale) claudeNativeSession = null;
|
|
278
290
|
if (!isClaudeNativeSessionImported(claudeNativeSession)) {
|
|
279
|
-
const claude = materializeRowForClaude(
|
|
291
|
+
const claude = materializeRowForClaude(rowWithAttachments, { cwd, forceClaudeCode: true });
|
|
280
292
|
claudeNativeSession = claude.nativeSession || null;
|
|
281
293
|
const previousSurfaces = rowState.materializedSurfaces || {};
|
|
282
294
|
rememberRow(id, {
|
|
283
295
|
claudeMarkdownPaths: claude.paths,
|
|
284
296
|
claudeNativeSession,
|
|
297
|
+
attachments: rowWithAttachments.attachments || [],
|
|
298
|
+
attachmentMaterializationSignature: materializationSignature,
|
|
285
299
|
materializedSurfaces: {
|
|
286
300
|
codex: Boolean(previousSurfaces.codex || rowState.threadId),
|
|
287
301
|
claudeCode: Boolean(previousSurfaces.claudeCode || claude.nativeSession || claude.surfaces?.claudeCode),
|
|
288
302
|
claudeCowork: Boolean(previousSurfaces.claudeCowork || claude.surfaces?.claudeCowork),
|
|
289
303
|
},
|
|
290
304
|
materializedAt: new Date().toISOString(),
|
|
291
|
-
title:
|
|
305
|
+
title: rowWithAttachments.displayTitle,
|
|
292
306
|
});
|
|
293
307
|
} else {
|
|
294
308
|
claudeNativeSession = ensureClaudeDesktopImported(claudeNativeSession, {
|
|
295
|
-
title: relayRowTitle(
|
|
309
|
+
title: relayRowTitle(rowWithAttachments),
|
|
296
310
|
cwd,
|
|
297
|
-
createdAt:
|
|
311
|
+
createdAt: rowWithAttachments.createdAt,
|
|
298
312
|
});
|
|
299
313
|
rememberRow(id, { claudeNativeSession });
|
|
300
314
|
}
|
|
@@ -304,6 +318,91 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
|
|
|
304
318
|
return { id, host: cleanHost, url, openedInHost, skipExternalOpen };
|
|
305
319
|
}
|
|
306
320
|
|
|
321
|
+
async function materializeAttachmentFiles(row, { log = () => {} } = {}) {
|
|
322
|
+
const attachments = Array.isArray(row?.attachments) ? row.attachments : [];
|
|
323
|
+
if (!attachments.length) return row;
|
|
324
|
+
const attachmentUrls = row?.attachmentUrls && typeof row.attachmentUrls === "object" ? row.attachmentUrls : {};
|
|
325
|
+
const dir = path.join(storeDir(), "attachments", safeFileStem(row?.id || row?.relayId || "relay"));
|
|
326
|
+
const materialized = [];
|
|
327
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
328
|
+
if (!attachment || typeof attachment !== "object") continue;
|
|
329
|
+
const name = String(attachment.name || attachment.filename || `attachment-${index + 1}`).trim() || `attachment-${index + 1}`;
|
|
330
|
+
const url = String(attachmentUrls[attachment.id] || attachment.openUrl || "").trim();
|
|
331
|
+
const localPath = await ensureAttachmentLocalCopy({ attachment, url, dir, name, index, log });
|
|
332
|
+
materialized.push({
|
|
333
|
+
...attachment,
|
|
334
|
+
name,
|
|
335
|
+
filename: name,
|
|
336
|
+
openUrl: url || attachment.openUrl,
|
|
337
|
+
...(localPath ? { localPath } : {}),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return { ...row, attachments: materialized };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function ensureAttachmentLocalCopy({ attachment, url, dir, name, index, log }) {
|
|
344
|
+
const existing = String(attachment.localPath || "").trim();
|
|
345
|
+
if (existing && fs.existsSync(existing) && sizeMatches(existing, attachment.bytes)) return existing;
|
|
346
|
+
if (!url) return existing || "";
|
|
347
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
348
|
+
const filePath = path.join(dir, attachmentFileName(attachment, name, index));
|
|
349
|
+
if (fs.existsSync(filePath) && sizeMatches(filePath, attachment.bytes)) return filePath;
|
|
350
|
+
try {
|
|
351
|
+
const res = await fetch(url);
|
|
352
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
353
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
354
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
355
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
356
|
+
fs.renameSync(tmp, filePath);
|
|
357
|
+
return filePath;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
log(`relay attachment download failed for ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
360
|
+
return existing || "";
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function sizeMatches(filePath, expectedBytes) {
|
|
365
|
+
if (!Number.isFinite(Number(expectedBytes))) return true;
|
|
366
|
+
try {
|
|
367
|
+
return fs.statSync(filePath).size === Number(expectedBytes);
|
|
368
|
+
} catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function attachmentFileName(attachment, name, index) {
|
|
374
|
+
const prefix = safeFileStem(attachment.id || `attachment-${index + 1}`);
|
|
375
|
+
const clean = safeFilename(name);
|
|
376
|
+
return `${prefix}-${clean}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function safeFilename(value) {
|
|
380
|
+
return String(value || "attachment")
|
|
381
|
+
.replace(/[/\\?%*:|"<>]+/g, "-")
|
|
382
|
+
.replace(/\s+/g, " ")
|
|
383
|
+
.trim()
|
|
384
|
+
.slice(0, 160) || "attachment";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function rowMaterializationSignature(row) {
|
|
388
|
+
const attachments = Array.isArray(row?.attachments) ? row.attachments : [];
|
|
389
|
+
return JSON.stringify({
|
|
390
|
+
renderer: "relay-attachments-table-v2",
|
|
391
|
+
body: String(row?.bodyMarkdown || row?.briefingMarkdown || ""),
|
|
392
|
+
attachments: attachments.map((attachment) => ({
|
|
393
|
+
id: attachment.id || "",
|
|
394
|
+
name: attachment.name || attachment.filename || "",
|
|
395
|
+
bytes: attachment.bytes || 0,
|
|
396
|
+
sha256: attachment.sha256 || "",
|
|
397
|
+
localPath: attachment.localPath || "",
|
|
398
|
+
})),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function hasAttachments(row) {
|
|
403
|
+
return Array.isArray(row?.attachments) && row.attachments.length > 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
307
406
|
// `relay open --task <taskId> --host <host>` — materialize a Relay task into a
|
|
308
407
|
// visible native agent session (exactly like a staged relay row). The task object
|
|
309
408
|
// (GET /v1/tasks/:id, unwrapped) carries title/objective/state but NO messages or
|
|
@@ -434,6 +533,10 @@ function firstNonEmpty(...values) {
|
|
|
434
533
|
return "";
|
|
435
534
|
}
|
|
436
535
|
|
|
536
|
+
function safeFileStem(value) {
|
|
537
|
+
return String(value || new Date().toISOString()).replace(/[^0-9A-Za-z._-]+/g, "-").replace(/^-|-$/g, "");
|
|
538
|
+
}
|
|
539
|
+
|
|
437
540
|
function sleep(ms) {
|
|
438
541
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
439
542
|
}
|
package/src/relay-briefing.js
CHANGED
|
@@ -66,6 +66,7 @@ export function renderRelayRowSeed(row) {
|
|
|
66
66
|
seed = renderMessageSeed(row, task);
|
|
67
67
|
}
|
|
68
68
|
let visible = String(seed?.visible || "").trim();
|
|
69
|
+
visible = insertAttachmentSection(visible, row);
|
|
69
70
|
const operatorNote = String(seed?.operatorNote || "").trim();
|
|
70
71
|
if (!visible) visible = renderMinimalSeed(row);
|
|
71
72
|
return { visible, operatorNote };
|
|
@@ -243,6 +244,54 @@ function renderMinimalSeed(row) {
|
|
|
243
244
|
return joinSections([`# ${title}`, "No content was available to seed this session."]);
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
function insertAttachmentSection(visible, row) {
|
|
248
|
+
const attachments = Array.isArray(row?.attachments) ? row.attachments : [];
|
|
249
|
+
const rendered = attachments.map(renderAttachmentTableRow).filter(Boolean);
|
|
250
|
+
if (!rendered.length) return visible;
|
|
251
|
+
const section = ["## Attachments", "", "| File | Size |", "| --- | ---: |", ...rendered].join("\n");
|
|
252
|
+
const cleanVisible = String(visible || "").trim();
|
|
253
|
+
const lines = cleanVisible.split("\n");
|
|
254
|
+
if (/^#\s+/.test(lines[0] || "")) {
|
|
255
|
+
const title = lines.shift();
|
|
256
|
+
return joinSections([title, section, lines.join("\n")]);
|
|
257
|
+
}
|
|
258
|
+
return joinSections([section, cleanVisible]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderAttachmentTableRow(attachment) {
|
|
262
|
+
if (!attachment || typeof attachment !== "object") return "";
|
|
263
|
+
const name = String(attachment.name || attachment.filename || "attachment").trim() || "attachment";
|
|
264
|
+
const size = humanBytes(attachment.bytes);
|
|
265
|
+
const localPath = String(attachment.localPath || "").trim();
|
|
266
|
+
const openUrl = String(attachment.openUrl || "").trim();
|
|
267
|
+
const href = localPath || openUrl;
|
|
268
|
+
const label = escapeTableCell(name);
|
|
269
|
+
const file = href
|
|
270
|
+
? `[${escapeMarkdownLinkText(label)}](<${escapeMarkdownHref(href)}>)`
|
|
271
|
+
: label;
|
|
272
|
+
return `| ${file} | ${escapeTableCell(size || "-")} |`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function humanBytes(bytes) {
|
|
276
|
+
const n = Number(bytes);
|
|
277
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
278
|
+
if (n < 1024) return `${n} B`;
|
|
279
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
|
|
280
|
+
return `${(n / (1024 * 1024)).toFixed(n < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function escapeMarkdownLinkText(value) {
|
|
284
|
+
return String(value).replace(/[[\]\\]/g, "\\$&");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function escapeMarkdownHref(value) {
|
|
288
|
+
return String(value).replace(/[<>]/g, encodeURIComponent);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function escapeTableCell(value) {
|
|
292
|
+
return String(value).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
|
293
|
+
}
|
|
294
|
+
|
|
246
295
|
function relayKind(row) {
|
|
247
296
|
return String(row?.relayNotificationKind || row?.kind || "").trim().toLowerCase();
|
|
248
297
|
}
|