vessels 0.8.0 → 0.9.0
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/dist/index.js +46 -1
- package/package.json +1 -1
- package/template/agent/README.md +19 -3
- package/template/agent/_env.example +4 -0
- package/template/agent/package.json +1 -1
- package/template/agent/src/agent.ts +41 -32
- package/template/agent/src/inbound.ts +125 -0
- package/template/agent/src/index.ts +25 -4
- package/template/agent/src/protocol.ts +24 -8
- package/template/agent/src/store.ts +62 -54
package/dist/index.js
CHANGED
|
@@ -4314,6 +4314,13 @@ var SupersededInteractionSchema = external_exports.object({
|
|
|
4314
4314
|
interaction_type: InteractionTypeSchema,
|
|
4315
4315
|
prompt: external_exports.string().nullable()
|
|
4316
4316
|
});
|
|
4317
|
+
var WebhookEventAttachmentSchema = external_exports.object({
|
|
4318
|
+
type: external_exports.enum(["image", "file"]),
|
|
4319
|
+
filename: external_exports.string().nullish(),
|
|
4320
|
+
fileId: external_exports.string().optional(),
|
|
4321
|
+
downloadUrl: external_exports.string().optional(),
|
|
4322
|
+
url: external_exports.string().optional()
|
|
4323
|
+
});
|
|
4317
4324
|
var WebhookUserMessagePayloadSchema = external_exports.object({
|
|
4318
4325
|
event: external_exports.literal("message.user"),
|
|
4319
4326
|
vessel_id: external_exports.string(),
|
|
@@ -4324,6 +4331,7 @@ var WebhookUserMessagePayloadSchema = external_exports.object({
|
|
|
4324
4331
|
content: external_exports.string(),
|
|
4325
4332
|
vessel: WebhookVesselSchema,
|
|
4326
4333
|
context: external_exports.array(WebhookContextMessageSchema),
|
|
4334
|
+
attachments: external_exports.array(WebhookEventAttachmentSchema).optional(),
|
|
4327
4335
|
superseded_interaction: SupersededInteractionSchema.optional()
|
|
4328
4336
|
})
|
|
4329
4337
|
});
|
|
@@ -4338,7 +4346,8 @@ var WebhookVesselCreatedPayloadSchema = external_exports.object({
|
|
|
4338
4346
|
message_id: external_exports.string(),
|
|
4339
4347
|
content: external_exports.string().nullable(),
|
|
4340
4348
|
created_at: external_exports.string()
|
|
4341
|
-
})
|
|
4349
|
+
}),
|
|
4350
|
+
attachments: external_exports.array(WebhookEventAttachmentSchema).optional()
|
|
4342
4351
|
})
|
|
4343
4352
|
});
|
|
4344
4353
|
var WebhookMessageCancelledPayloadSchema = external_exports.object({
|
|
@@ -4671,6 +4680,37 @@ async function cmdPush(args) {
|
|
|
4671
4680
|
}
|
|
4672
4681
|
console.log(`Message sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
|
|
4673
4682
|
}
|
|
4683
|
+
var FEEDBACK_TYPES = ["bug", "feature", "other"];
|
|
4684
|
+
async function cmdFeedback(args) {
|
|
4685
|
+
const flags = parseFlags(args);
|
|
4686
|
+
const positionals = [];
|
|
4687
|
+
for (let i = 0; i < args.length; i++) {
|
|
4688
|
+
if (args[i].startsWith("--")) {
|
|
4689
|
+
i++;
|
|
4690
|
+
continue;
|
|
4691
|
+
}
|
|
4692
|
+
positionals.push(args[i]);
|
|
4693
|
+
}
|
|
4694
|
+
let message = (flags.message || positionals.join(" ")).trim();
|
|
4695
|
+
if (!message) message = (await prompt("Your feedback (bug or feature request): ")).trim();
|
|
4696
|
+
if (!message) {
|
|
4697
|
+
console.error("Nothing to submit.");
|
|
4698
|
+
process.exit(1);
|
|
4699
|
+
}
|
|
4700
|
+
const type = (flags.type || "other").toLowerCase();
|
|
4701
|
+
if (!FEEDBACK_TYPES.includes(type)) {
|
|
4702
|
+
console.error(`Type must be one of: ${FEEDBACK_TYPES.join(", ")}`);
|
|
4703
|
+
process.exit(1);
|
|
4704
|
+
}
|
|
4705
|
+
const data = await api("/api/v1/feedback", {
|
|
4706
|
+
method: "POST",
|
|
4707
|
+
body: JSON.stringify({ type, message })
|
|
4708
|
+
});
|
|
4709
|
+
const label = type === "other" ? "Feedback" : type === "bug" ? "Bug report" : "Feature request";
|
|
4710
|
+
console.log(`
|
|
4711
|
+
${label} submitted \u2014 thank you!`);
|
|
4712
|
+
console.log(` id ${data.id}`);
|
|
4713
|
+
}
|
|
4674
4714
|
function readStdin() {
|
|
4675
4715
|
return new Promise((resolve2) => {
|
|
4676
4716
|
const chunks = [];
|
|
@@ -4976,6 +5016,10 @@ Commands:
|
|
|
4976
5016
|
vessels types disable
|
|
4977
5017
|
Manage vessel types and the user-initiated-vessels feature flag.
|
|
4978
5018
|
|
|
5019
|
+
vessels feedback <message> [--type bug|feature|other]
|
|
5020
|
+
Send a bug report or feature request to the Vessels team. Requires login.
|
|
5021
|
+
Message can be positional or --message; --type defaults to "other".
|
|
5022
|
+
|
|
4979
5023
|
vessels push --vessel <id> --message <text> --key <api_key>
|
|
4980
5024
|
(--key can be omitted if VESSELS_API_KEY is set)
|
|
4981
5025
|
|
|
@@ -5059,6 +5103,7 @@ Run: vessels help`);
|
|
|
5059
5103
|
Run: vessels help`);
|
|
5060
5104
|
process.exit(1);
|
|
5061
5105
|
}
|
|
5106
|
+
if (cmd === "feedback") return cmdFeedback([sub, ...rest].filter(Boolean));
|
|
5062
5107
|
if (cmd === "push") return cmdPush([sub, ...rest].filter(Boolean));
|
|
5063
5108
|
if (cmd === "message") return cmdMessage([sub, ...rest].filter(Boolean));
|
|
5064
5109
|
if (cmd === "validate") return cmdValidate([sub, ...rest].filter(Boolean));
|
package/package.json
CHANGED
package/template/agent/README.md
CHANGED
|
@@ -92,9 +92,25 @@ chat **bubbles** and full-width **surfaces**; all four **interactions** (`approv
|
|
|
92
92
|
**metadata** that rides back to you for routing); the live **working card** with a ticking **plan**,
|
|
93
93
|
auto-narrated **steps**, and **token streaming**; **pinned cards**, **labels**, **attachments**
|
|
94
94
|
(images/files you host), **preview links**, **suggested replies**, vessel **naming/renaming**, and
|
|
95
|
-
user-initiated vessel **types
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
user-initiated vessel **types**; plus **inbound files** the human sends you — downloaded, stored on
|
|
96
|
+
your infra, resolved, and (for images) read by the model via **vision** (see below). Idempotency
|
|
97
|
+
keys, the per-vessel lock, **one card per turn**, and the resolve-before-ask discipline are handled
|
|
98
|
+
for you. (It deliberately keeps the notification rule too: the working card stays silent, only your
|
|
99
|
+
reply and outcome buzz the human's phone.)
|
|
100
|
+
|
|
101
|
+
## Receiving files (inbound)
|
|
102
|
+
|
|
103
|
+
Vessels is **not a file store** — when the human sends a photo/document, it relays the bytes
|
|
104
|
+
transiently and hands you a signed, short-lived `downloadUrl`. The engine does the full handshake in
|
|
105
|
+
`src/inbound.ts`: **download** it → **store** it on your infra (`store.putInboundFile`) → **resolve**
|
|
106
|
+
the permanent link back (`vessels.resolveInboundFile`) so the human sees your hosted copy and Vessels
|
|
107
|
+
drops its copy → for images, hand the bytes to the model as a **vision** block so your agent actually
|
|
108
|
+
sees them.
|
|
109
|
+
|
|
110
|
+
The zero-infra default stores files in the same `MemoryStore`/`PostgresStore` and serves them from the
|
|
111
|
+
agent's own `GET /files/:id` route, so the resolved link points back at you. Set **`PUBLIC_URL`** to
|
|
112
|
+
your externally-reachable base (your tunnel in dev) so that link is fetchable. In production, point
|
|
113
|
+
`putInboundFile` at object storage (S3/R2/GCS) and return a CDN URL — nothing else changes.
|
|
98
114
|
|
|
99
115
|
A few Vessels features live on **your backend**, not inside a turn — call them on the `vessels`
|
|
100
116
|
SDK directly:
|
|
@@ -11,6 +11,10 @@ ANTHROPIC_API_KEY=sk-ant-...
|
|
|
11
11
|
# ANTHROPIC_MODEL=claude-sonnet-4-6
|
|
12
12
|
# Port the webhook server listens on (default: 3000).
|
|
13
13
|
# PORT=3000
|
|
14
|
+
# Your agent's externally-reachable base URL — where stored INBOUND files are served from
|
|
15
|
+
# (the human's app fetches the link you resolve). In local dev this is your tunnel, the same
|
|
16
|
+
# host as your webhook. Defaults to http://localhost:<PORT> (only reachable on this machine).
|
|
17
|
+
# PUBLIC_URL=https://your-tunnel.example.com
|
|
14
18
|
# Override the Vessels base URL (default: https://vessels.app).
|
|
15
19
|
# VESSELS_BASE_URL=https://vessels.app
|
|
16
20
|
# Set DEBUG=1 to log the turn flow (model calls, tools, pushes).
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* You usually don't edit this file. The pieces worth knowing are flagged inline.
|
|
17
17
|
*/
|
|
18
18
|
import Anthropic from '@anthropic-ai/sdk';
|
|
19
|
-
import type { MessageParam, Tool, ToolUseBlock, TextBlock, ThinkingBlock, ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages';
|
|
19
|
+
import type { MessageParam, Tool, ToolUseBlock, TextBlock, ThinkingBlock, ToolResultBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages';
|
|
20
20
|
import { Vessels } from 'vessels-sdk';
|
|
21
|
-
import type { PushOptions, AgentActivityType, AgentTodoStatus } from 'vessels-sdk';
|
|
21
|
+
import type { PushOptions, AgentActivityType, AgentTodoStatus, InboundAttachment } from 'vessels-sdk';
|
|
22
22
|
import { ROLE } from './role.js';
|
|
23
23
|
import { VESSELS_PROTOCOL, NAME_RULE } from './protocol.js';
|
|
24
24
|
import { BACKEND_TOOLS } from './tools.js';
|
|
@@ -35,7 +35,8 @@ import {
|
|
|
35
35
|
cleanLabels,
|
|
36
36
|
cleanAttachments,
|
|
37
37
|
} from './vessels-tools.js';
|
|
38
|
-
import type { AgentStore
|
|
38
|
+
import type { AgentStore } from './store.js';
|
|
39
|
+
import { handleInboundFiles, attachImagesToLastUserMessage } from './inbound.js';
|
|
39
40
|
|
|
40
41
|
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
|
|
41
42
|
const MAX_TURN_STEPS = 12; // tool hops within a single turn before we force an ending
|
|
@@ -100,6 +101,7 @@ export interface RunTurnOpts {
|
|
|
100
101
|
openingMessage?: string; // text for the working card (proactive triggers pass the headline)
|
|
101
102
|
vesselTitle?: string; // set on the first push (creates the vessel) for agent-initiated triggers
|
|
102
103
|
nameVessel?: boolean; // freshly-opened vessel with a placeholder title — agent names it (vessel.created)
|
|
104
|
+
attachments?: InboundAttachment[]; // files the human sent — download → store on your infra → resolve → view (inbound.ts)
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/** A best-effort push that never throws — a failed push must not crash the turn. */
|
|
@@ -123,7 +125,7 @@ async function safePatch(vessels: Vessels, messageId: string, patch: Record<stri
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
126
|
-
const { vessels, store, vessel, humanInput, idempotencyKeyBase, openingMessage, vesselTitle, nameVessel } = opts;
|
|
128
|
+
const { vessels, store, vessel, humanInput, idempotencyKeyBase, openingMessage, vesselTitle, nameVessel, attachments } = opts;
|
|
127
129
|
log('turn_start', { vessel, humanInput: humanInput.slice(0, 120) });
|
|
128
130
|
|
|
129
131
|
// Per-vessel mutex: serialise turns on this vessel. A blocked turn must NOT be dropped —
|
|
@@ -156,10 +158,27 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
156
158
|
// Recover state AFTER the lock: if we waited for a prior turn, its work is now persisted,
|
|
157
159
|
// so we thread on top of it instead of clobbering it.
|
|
158
160
|
const messages: MessageParam[] = await store.loadState(vessel);
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
161
|
+
|
|
162
|
+
// Inbound files (human → agent). Before the model runs, do the handshake for any attachments
|
|
163
|
+
// on this event: download each from the Vessels relay, store it on YOUR infra, resolve the
|
|
164
|
+
// permanent link back (the human now sees it), and collect viewable images as vision blocks.
|
|
165
|
+
// The text note rides the persisted history; the image bytes ride ONLY this live model call.
|
|
166
|
+
let effectiveHumanInput = humanInput;
|
|
167
|
+
let inboundImages: ImageBlockParam[] = [];
|
|
168
|
+
if (attachments?.length) {
|
|
169
|
+
try {
|
|
170
|
+
const inbound = await handleInboundFiles({ vessels, store, attachments });
|
|
171
|
+
inboundImages = inbound.imageBlocks;
|
|
172
|
+
if (inbound.note) effectiveHumanInput = humanInput ? `${humanInput}\n\n${inbound.note}` : inbound.note;
|
|
173
|
+
} catch (e) {
|
|
174
|
+
log('inbound files failed', e instanceof Error ? e.message : e);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
appendHumanTurn(messages, effectiveHumanInput);
|
|
179
|
+
// Attach the downloaded image bytes to the current user turn so the model can SEE them
|
|
180
|
+
// (vision). Only on this live call — the persisted history stays text-only.
|
|
181
|
+
attachImagesToLastUserMessage(messages, inboundImages);
|
|
163
182
|
|
|
164
183
|
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, timeout: 45_000, maxRetries: 1 });
|
|
165
184
|
const systemPrompt = `${ROLE}\n\n${VESSELS_PROTOCOL}${nameVessel ? NAME_RULE : ''}`;
|
|
@@ -191,19 +210,15 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
191
210
|
activityId = r.messageId ?? null;
|
|
192
211
|
};
|
|
193
212
|
|
|
194
|
-
// Authoritative todo list for
|
|
213
|
+
// Authoritative todo list for THIS turn's working card; we PATCH the full list each update.
|
|
214
|
+
// ONE CARD PER TURN: always starts empty — the model declares the turn's plan with plan().
|
|
215
|
+
// We never re-attach to a prior turn's card. A card is anchored at one point in the stream;
|
|
216
|
+
// mutating it on a later turn renders its new steps ABOVE messages that already followed it
|
|
217
|
+
// (chronological inversion) and mis-files them under a stale task. So every turn opens its
|
|
218
|
+
// own card at the bottom and seals it on end. Continuity lives in the recovered history.
|
|
195
219
|
type Todo = { label: string; status: AgentTodoStatus };
|
|
196
220
|
let todos: Todo[] = [];
|
|
197
221
|
|
|
198
|
-
// RESUME a paused plan: re-attach to the SAME working card and its plan instead of
|
|
199
|
-
// opening a fresh one, so the operator sees ONE continuous card across the whole
|
|
200
|
-
// multi-step flow. The first work patch flips it from awaiting_input back to working.
|
|
201
|
-
if (resume?.activityId) {
|
|
202
|
-
activityId = resume.activityId;
|
|
203
|
-
cardOpened = true;
|
|
204
|
-
if (Array.isArray(resume.todos)) todos = resume.todos.map((t) => ({ label: String(t.label), status: t.status }));
|
|
205
|
-
log('resume', { activityId, todos: todos.length });
|
|
206
|
-
}
|
|
207
222
|
const patchActivity = async (body: Record<string, unknown>) => {
|
|
208
223
|
if (activityId) await safePatch(vessels, activityId, { agentActivity: body });
|
|
209
224
|
};
|
|
@@ -273,9 +288,6 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
273
288
|
const pushes: PendingPush[] = [];
|
|
274
289
|
|
|
275
290
|
let ended = false;
|
|
276
|
-
// A mid-plan checkpoint (request_* with keepWorking) ends the MODEL loop but does NOT
|
|
277
|
-
// seal the card: the plan pauses on the human and resumes on their reply (see finally).
|
|
278
|
-
let pauseForInput = false;
|
|
279
291
|
const recordEnding = (name: string, input: Record<string, unknown>) => {
|
|
280
292
|
const msg = String(input.message ?? '');
|
|
281
293
|
const endPin = sanitizeCard(input.pinCard);
|
|
@@ -286,7 +298,9 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
286
298
|
pushes.push({ message: msg || 'All done.', pinCard: endPin, labels: endLabels });
|
|
287
299
|
} else {
|
|
288
300
|
const interaction = buildInteraction(name, input);
|
|
289
|
-
|
|
301
|
+
// keepWorking is purely semantic — it tells the model the task isn't finished, so the
|
|
302
|
+
// NEXT turn continues it in a fresh card. It does NOT keep this card alive: every turn
|
|
303
|
+
// seals its own card on end (one card per turn).
|
|
290
304
|
pushes.push({
|
|
291
305
|
message: msg || String(input.prompt ?? 'Please respond.'),
|
|
292
306
|
kind: 'surface',
|
|
@@ -390,7 +404,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
390
404
|
if (tu.name === 'plan') {
|
|
391
405
|
const labels = Array.isArray(input.todos) ? (input.todos as unknown[]).map(String) : [];
|
|
392
406
|
// Merge by label, preserving the status of tasks already in flight — matters on
|
|
393
|
-
// a
|
|
407
|
+
// a same-turn re-plan (a tweaked plan keeps the steps it already ticked).
|
|
394
408
|
todos = labels.map((label) => {
|
|
395
409
|
const prev = todos.find((t) => t.label.toLowerCase() === label.toLowerCase());
|
|
396
410
|
return { label, status: prev?.status ?? ('pending' as AgentTodoStatus) };
|
|
@@ -494,19 +508,14 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
494
508
|
log('turn error', err);
|
|
495
509
|
pushes.push({ message: 'I hit a snag and had to stop early.' });
|
|
496
510
|
} finally {
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
// the card
|
|
511
|
+
// ALWAYS seal this turn's working card (one card per turn) — even when the turn ends on
|
|
512
|
+
// a keepWorking checkpoint, the card resolves here as a faithful record of THIS turn's
|
|
513
|
+
// work; the next turn opens its own fresh card. Never orphans, even on an error.
|
|
500
514
|
try {
|
|
501
515
|
await stopStream();
|
|
502
516
|
if (activityId) {
|
|
503
|
-
await safePatch(vessels, activityId, {
|
|
504
|
-
agentActivity: pauseForInput ? { status: 'awaiting_input' } : null,
|
|
505
|
-
tokenStream: null,
|
|
506
|
-
});
|
|
517
|
+
await safePatch(vessels, activityId, { agentActivity: null, tokenStream: null });
|
|
507
518
|
}
|
|
508
|
-
// Persist the paused-plan handle (or clear a prior one now the plan resumed/ended).
|
|
509
|
-
await store.saveResume(vessel, pauseForInput && activityId ? { activityId, todos } : null);
|
|
510
519
|
} catch (e) {
|
|
511
520
|
log('seal failed', e);
|
|
512
521
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INBOUND FILES — the human → agent handshake.
|
|
3
|
+
*
|
|
4
|
+
* When the human attaches a photo/document, Vessels holds the bytes TRANSIENTLY and hands
|
|
5
|
+
* you a signed, short-lived `downloadUrl` on the `message.user` / `vessel.created` event
|
|
6
|
+
* (Vessels is not a file store). You must fetch it, store it on YOUR own infra, and hand
|
|
7
|
+
* back a permanent link — then Vessels swaps in your link and drops its copy. Per file:
|
|
8
|
+
*
|
|
9
|
+
* 1. download — fetch the signed downloadUrl (plain HTTP)
|
|
10
|
+
* 2. store — `store.putInboundFile(...)` → a permanent URL on YOUR infra (store.ts)
|
|
11
|
+
* 3. resolve — `vessels.resolveInboundFile(fileId, url)` so the human sees your copy
|
|
12
|
+
* 4. view — for supported images, hand the bytes to the model as a vision block so
|
|
13
|
+
* your agent can ACTUALLY SEE the image
|
|
14
|
+
*
|
|
15
|
+
* You usually don't edit this file — point `store.putInboundFile` at S3/R2/GCS and you're done.
|
|
16
|
+
*/
|
|
17
|
+
import type { ImageBlockParam, MessageParam, ContentBlockParam } from '@anthropic-ai/sdk/resources/messages';
|
|
18
|
+
import type { Vessels, InboundAttachment } from 'vessels-sdk';
|
|
19
|
+
import type { AgentStore } from './store.js';
|
|
20
|
+
|
|
21
|
+
// Anthropic vision accepts these as base64 image blocks. Other image kinds and all
|
|
22
|
+
// non-images are still stored + resolved, just not shown to the model inline.
|
|
23
|
+
const VISION_MIME = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
24
|
+
const MAX_VISION_BYTES = 4 * 1024 * 1024; // ~Anthropic's practical per-image base64 ceiling
|
|
25
|
+
|
|
26
|
+
function extToMime(filename?: string | null): string | undefined {
|
|
27
|
+
const ext = (filename ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
|
28
|
+
switch (ext) {
|
|
29
|
+
case 'jpg':
|
|
30
|
+
case 'jpeg': return 'image/jpeg';
|
|
31
|
+
case 'png': return 'image/png';
|
|
32
|
+
case 'gif': return 'image/gif';
|
|
33
|
+
case 'webp': return 'image/webp';
|
|
34
|
+
case 'pdf': return 'application/pdf';
|
|
35
|
+
default: return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InboundResult {
|
|
40
|
+
/** Image blocks to append to the model's current user turn (vision). */
|
|
41
|
+
imageBlocks: ImageBlockParam[];
|
|
42
|
+
/** A text note describing what arrived + where it landed, appended to the user turn. */
|
|
43
|
+
note: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run the full inbound handshake for every attachment on this event, in parallel. Returns
|
|
48
|
+
* the image blocks to show the model and a note for the user turn. Best-effort per file —
|
|
49
|
+
* one file's failure never sinks the others (or the turn).
|
|
50
|
+
*/
|
|
51
|
+
export async function handleInboundFiles(opts: {
|
|
52
|
+
vessels: Vessels;
|
|
53
|
+
store: AgentStore;
|
|
54
|
+
attachments: InboundAttachment[];
|
|
55
|
+
}): Promise<InboundResult> {
|
|
56
|
+
const pending = opts.attachments.filter((a) => a.fileId && a.downloadUrl);
|
|
57
|
+
if (!pending.length) return { imageBlocks: [], note: '' };
|
|
58
|
+
|
|
59
|
+
const imageBlocks: ImageBlockParam[] = [];
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
|
|
62
|
+
const results = await Promise.all(
|
|
63
|
+
pending.map(async (att) => {
|
|
64
|
+
try {
|
|
65
|
+
// 1 — download from the Vessels relay (signed, short-lived URL).
|
|
66
|
+
const res = await fetch(att.downloadUrl!);
|
|
67
|
+
if (!res.ok) throw new Error(`download ${res.status}`);
|
|
68
|
+
const headerMime = res.headers.get('content-type')?.split(';')[0]?.trim();
|
|
69
|
+
const mime = headerMime && headerMime !== 'application/octet-stream'
|
|
70
|
+
? headerMime
|
|
71
|
+
: extToMime(att.filename) ?? (att.type === 'image' ? 'image/jpeg' : 'application/octet-stream');
|
|
72
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
73
|
+
|
|
74
|
+
// 2 — store on YOUR infra, 3 — resolve the permanent link back to Vessels.
|
|
75
|
+
const url = await opts.store.putInboundFile({ fileId: att.fileId, bytes, contentType: mime, filename: att.filename ?? undefined });
|
|
76
|
+
await opts.vessels.resolveInboundFile(att.fileId, url);
|
|
77
|
+
|
|
78
|
+
// 4 — supported, in-budget image → vision block.
|
|
79
|
+
const viewable = att.type === 'image' && VISION_MIME.has(mime) && bytes.byteLength <= MAX_VISION_BYTES;
|
|
80
|
+
if (viewable) {
|
|
81
|
+
imageBlocks.push({
|
|
82
|
+
type: 'image',
|
|
83
|
+
source: { type: 'base64', media_type: mime as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', data: Buffer.from(bytes).toString('base64') },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { att, url, viewable, ok: true as const };
|
|
87
|
+
} catch {
|
|
88
|
+
return { att, ok: false as const };
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
for (const r of results) {
|
|
94
|
+
const label = `${r.att.filename ?? r.att.fileId} (${r.att.type})`;
|
|
95
|
+
if (!r.ok) lines.push(`- ${label} → could not be retrieved (the relay link may have expired)`);
|
|
96
|
+
else if (r.viewable) lines.push(`- ${label} → stored at ${r.url}; the image is attached to this message for you to view`);
|
|
97
|
+
else lines.push(`- ${label} → stored at ${r.url}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const one = pending.length === 1;
|
|
101
|
+
const note =
|
|
102
|
+
`[The operator attached ${one ? 'a file' : `${pending.length} files`}. Your backend downloaded ${one ? 'it' : 'them'} from ` +
|
|
103
|
+
`Vessels, stored ${one ? 'it' : 'them'} on your own storage, and confirmed the permanent link(s) back (the operator now ` +
|
|
104
|
+
`sees your hosted file(s)):\n${lines.join('\n')}\n` +
|
|
105
|
+
(imageBlocks.length
|
|
106
|
+
? `The image${imageBlocks.length === 1 ? ' is' : 's are'} included for you to read directly — act on what you actually see, don't guess.`
|
|
107
|
+
: `No previewable image was included; reference the stored file(s) by name.`);
|
|
108
|
+
|
|
109
|
+
return { imageBlocks, note };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Append image blocks to the most recent user message, converting its text to a block array. */
|
|
113
|
+
export function attachImagesToLastUserMessage(messages: MessageParam[], imageBlocks: ImageBlockParam[]): void {
|
|
114
|
+
if (!imageBlocks.length) return;
|
|
115
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
116
|
+
if (messages[i].role !== 'user') continue;
|
|
117
|
+
const existing = messages[i].content;
|
|
118
|
+
const textBlocks: ContentBlockParam[] =
|
|
119
|
+
typeof existing === 'string'
|
|
120
|
+
? existing ? [{ type: 'text', text: existing }] : []
|
|
121
|
+
: (existing as ContentBlockParam[]);
|
|
122
|
+
messages[i] = { role: 'user', content: [...textBlocks, ...imageBlocks] };
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -52,6 +52,21 @@ function runInBackground(work: Promise<unknown>): void {
|
|
|
52
52
|
|
|
53
53
|
const server = http.createServer(async (req, res) => {
|
|
54
54
|
if (req.method === 'GET') {
|
|
55
|
+
// Serve inbound files we stored on our own infra. After the human sends a photo/doc, the
|
|
56
|
+
// engine downloads it from Vessels, stores it here, and resolves a permanent link pointing
|
|
57
|
+
// back at this route (see inbound.ts + store.ts). The app fetches THIS url to render it.
|
|
58
|
+
// In production you'd store in object storage and serve from a CDN instead.
|
|
59
|
+
const fileMatch = req.url && /^\/files\/([^/?]+)/.exec(req.url);
|
|
60
|
+
if (fileMatch) {
|
|
61
|
+
const file = await store.getInboundFile(decodeURIComponent(fileMatch[1])).catch(() => null);
|
|
62
|
+
if (!file) {
|
|
63
|
+
res.writeHead(404).end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
res.writeHead(200, { 'Content-Type': file.contentType, 'Content-Length': String(file.bytes.byteLength) });
|
|
67
|
+
res.end(Buffer.from(file.bytes));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
55
70
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
56
71
|
res.end('Vessels agent is alive. POST webhook events here.');
|
|
57
72
|
return;
|
|
@@ -89,18 +104,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
89
104
|
vessel,
|
|
90
105
|
humanInput: `${typeNote}${first || '(the operator opened a new vessel)'}`,
|
|
91
106
|
nameVessel: true,
|
|
107
|
+
// Files attached to the first message ride the vessel.created event too.
|
|
108
|
+
attachments: event.attachments,
|
|
92
109
|
idempotencyKeyBase: `vc:${event.message.id}`,
|
|
93
110
|
});
|
|
94
111
|
} else if (event.type === 'message.user') {
|
|
95
112
|
const content = (event.message.content ?? '').trim();
|
|
96
|
-
|
|
113
|
+
// Files the human attached (signed downloadUrls) — the engine does the download → store →
|
|
114
|
+
// resolve handshake before the model runs (inbound.ts). A message can be attachments-only.
|
|
115
|
+
const attachments = event.attachments;
|
|
116
|
+
if (content || attachments?.length) {
|
|
97
117
|
// If this message expired a live interaction, tell the agent so it reacts to what they
|
|
98
118
|
// actually said instead of waiting on the now-dead card.
|
|
99
119
|
const sup = event.supersededInteraction;
|
|
100
|
-
const
|
|
120
|
+
const base = content || '(the operator sent the attached file(s) with no message)';
|
|
121
|
+
const humanInput = sup && content
|
|
101
122
|
? `[The operator did not answer your ${sup.interactionType}${sup.prompt ? ` "${sup.prompt}"` : ''} — that card expired because they sent a message instead. Respond to what they actually said; re-offer or adjust only if it still makes sense.]\n${content}`
|
|
102
|
-
:
|
|
103
|
-
work = runTurn({ vessels, store, vessel, humanInput, idempotencyKeyBase: `mu:${event.message.id}` });
|
|
123
|
+
: base;
|
|
124
|
+
work = runTurn({ vessels, store, vessel, humanInput, attachments, idempotencyKeyBase: `mu:${event.message.id}` });
|
|
104
125
|
}
|
|
105
126
|
} else if (event.type === 'interaction.response') {
|
|
106
127
|
const originInteraction = event.originMessage?.interaction ?? null;
|
|
@@ -91,12 +91,12 @@ Tools:
|
|
|
91
91
|
- request_questions — SEVERAL questions at once, answered together (a short form)
|
|
92
92
|
- finish — wrap up; no further human action needed
|
|
93
93
|
|
|
94
|
-
MID-PLAN CHECKPOINTS — keepWorking: when a multi-step
|
|
95
|
-
PART-WAY THROUGH (e.g.
|
|
96
|
-
keepWorking:true.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
MID-PLAN CHECKPOINTS — keepWorking: when a multi-step task needs the operator's input
|
|
95
|
+
PART-WAY THROUGH (e.g. Draft → Get approval → Send), raise the request_* with
|
|
96
|
+
keepWorking:true. It signals the task ISN'T finished — this turn's working card seals as a
|
|
97
|
+
clean record of what you just did, and when they answer you continue the remaining steps in
|
|
98
|
+
a FRESH card next turn (each turn is its own card, in order). Omit keepWorking on the FINAL
|
|
99
|
+
question/decision, where nothing follows.
|
|
100
100
|
|
|
101
101
|
0. quick_reply(message, done?) — ALWAYS your first action (see the lead-with-a-reply rule
|
|
102
102
|
above): one conversational line, pushed instantly. done:true → it's the whole answer and
|
|
@@ -120,9 +120,20 @@ Flow:
|
|
|
120
120
|
- ONE closing line per turn. The finishing tool's message IS the wrap-up — do NOT also send a
|
|
121
121
|
near-duplicate finish/send_update saying the same thing.
|
|
122
122
|
|
|
123
|
+
FILES the operator sends you (images, PDFs, docs):
|
|
124
|
+
- Your backend has ALREADY handled them before you read this turn: it downloaded each from
|
|
125
|
+
Vessels, stored it on YOUR storage, and confirmed the permanent link back — the operator
|
|
126
|
+
already sees your hosted copy. A bracketed note tells you what arrived and where it landed.
|
|
127
|
+
So NEVER say you "can't receive files" or ask them to re-send — the file is in hand.
|
|
128
|
+
- IMAGES are attached to this turn for you to actually SEE — read what's genuinely there and
|
|
129
|
+
act on it (a receipt → the amount; a form → the fields). Reference real details you observe;
|
|
130
|
+
do NOT invent contents. Non-image files are stored but not shown inline — work from the
|
|
131
|
+
filename and the operator's words.
|
|
132
|
+
|
|
123
133
|
More you can attach (use when they genuinely help — don't decorate):
|
|
124
|
-
- ATTACHMENTS: images render inline, files as a download link. Pass {type, url,
|
|
125
|
-
send_update or show_document — only URLs you already host (e.g. one a backend
|
|
134
|
+
- ATTACHMENTS (outbound): images render inline, files as a download link. Pass {type, url,
|
|
135
|
+
filename?} on send_update or show_document — only URLs you already host (e.g. one a backend
|
|
136
|
+
tool returned, or a stored inbound file).
|
|
126
137
|
- PREVIEW LINK: a single tappable link card under a message (previewUrl) — a draft/dashboard to
|
|
127
138
|
open. Presentation only, no response. Pair it with a request_* when they should look THEN decide.
|
|
128
139
|
- INTERACTION METADATA: attach metadata to any request_* and it rides back to you verbatim in the
|
|
@@ -136,6 +147,11 @@ Be efficient — every assistant turn is a slow round-trip, so do MORE per turn:
|
|
|
136
147
|
- You MUST end with an ending tool (request_* or finish). When you reach the task that needs the
|
|
137
148
|
human, call its work tools AND the request_* tool in the SAME response — do not tick that task
|
|
138
149
|
and stop. If you trail off without an ending tool the turn dies as a bare "Done."
|
|
150
|
+
- Once you've called plan(), a real DECISION you need from the operator goes through a STRUCTURED
|
|
151
|
+
interaction, not prose: a clean either/or is request_choice; a single value is request_text —
|
|
152
|
+
with keepWorking:true when steps remain after the answer. Do NOT write the question as a plain
|
|
153
|
+
message and stop; that strands an unworked plan and denies the operator the action bar. (A bare
|
|
154
|
+
clarifying question with NO plan can still be a quick_reply done:true.)
|
|
139
155
|
- Never repeat a tool call with identical arguments — reuse the result you already have.
|
|
140
156
|
- In task:"…" use the EXACT task label from your plan() — never invent a new name.`;
|
|
141
157
|
|
|
@@ -1,47 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE STORE SEAM — your agent's runtime state, on YOUR infrastructure.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three things live here, and all are facets of "the agent owns its own runtime":
|
|
5
5
|
* 1. Conversation state — the real Anthropic message history per vessel (including
|
|
6
6
|
* tool_use / tool_result blocks). This is your agent's memory. Vessels is NOT
|
|
7
7
|
* your memory; it only shows the human what happened.
|
|
8
8
|
* 2. A per-vessel lock — "don't run two turns for one vessel at once." That's a
|
|
9
9
|
* property of YOUR deployment, so it lives here too, never in Vessels.
|
|
10
|
+
* 3. Inbound files — when a human sends a photo/document, Vessels relays it
|
|
11
|
+
* TRANSIENTLY and you must store it on your OWN infra, then hand back a permanent
|
|
12
|
+
* link (see inbound.ts). The bytes live here; the link points back at your agent.
|
|
10
13
|
*
|
|
11
14
|
* Durability is an UPGRADE, not a prerequisite:
|
|
12
15
|
* • MemoryStore (default) — zero infra. Correct for a single long-lived process.
|
|
13
|
-
* State
|
|
14
|
-
* • PostgresStore — set DATABASE_URL and you get durable state
|
|
15
|
-
* lock.
|
|
16
|
-
* to run. Horizontally scaled? This is the lock that keeps turns serialised.
|
|
16
|
+
* State + files live in RAM and reset on restart; the lock is an in-process mutex.
|
|
17
|
+
* • PostgresStore — set DATABASE_URL and you get durable state, files, and a
|
|
18
|
+
* cross-process lock. Self-provisions (CREATE TABLE IF NOT EXISTS on init).
|
|
17
19
|
*
|
|
18
|
-
* Swap in Redis/Dynamo/your-DB by implementing the same `AgentStore` interface.
|
|
20
|
+
* Swap in Redis/Dynamo/S3/your-DB by implementing the same `AgentStore` interface.
|
|
21
|
+
* In production you'd typically store inbound files in object storage (S3/R2/GCS) and
|
|
22
|
+
* return a CDN URL from `putInboundFile` — here we serve them off the agent itself.
|
|
19
23
|
*/
|
|
20
24
|
import type { MessageParam } from '@anthropic-ai/sdk/resources/messages';
|
|
21
|
-
import type { AgentTodoStatus } from 'vessels-sdk';
|
|
22
25
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
/** A stored inbound file, served back by the agent's own `GET /files/:id` route. */
|
|
27
|
+
export interface StoredFile {
|
|
28
|
+
bytes: Uint8Array;
|
|
29
|
+
contentType: string;
|
|
30
|
+
filename?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The agent's externally-reachable base URL — where resolved inbound files are served
|
|
34
|
+
* from. In local dev this is your tunnel (same host as your webhook). */
|
|
35
|
+
export function publicBaseUrl(): string {
|
|
36
|
+
return (process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3000}`).replace(/\/$/, '');
|
|
37
|
+
}
|
|
31
38
|
|
|
32
39
|
export interface AgentStore {
|
|
33
40
|
/** The agent's conversation history for this vessel (empty array if new). */
|
|
34
41
|
loadState(vessel: string): Promise<MessageParam[]>;
|
|
35
42
|
/** Persist the full conversation history for this vessel. */
|
|
36
43
|
saveState(vessel: string, messages: MessageParam[]): Promise<void>;
|
|
37
|
-
/** The paused-plan handle for this vessel, or null when no plan is paused. */
|
|
38
|
-
loadResume(vessel: string): Promise<ResumeMarker | null>;
|
|
39
|
-
/** Save the paused-plan handle (or null to clear it once the plan resumes/ends). */
|
|
40
|
-
saveResume(vessel: string, marker: ResumeMarker | null): Promise<void>;
|
|
41
44
|
/** Try to take the per-vessel lock. Returns false if someone else holds it. TTL bounds a crash. */
|
|
42
45
|
acquireLock(vessel: string, ttlSeconds: number): Promise<boolean>;
|
|
43
46
|
/** Release the per-vessel lock. */
|
|
44
47
|
releaseLock(vessel: string): Promise<void>;
|
|
48
|
+
/** Store an inbound file on YOUR infra; returns a permanent, publicly-fetchable URL.
|
|
49
|
+
* (Swap the body for S3/R2/GCS in production and return the CDN URL.) */
|
|
50
|
+
putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string>;
|
|
51
|
+
/** Read a stored inbound file back — the `GET /files/:id` route serves it to the app. */
|
|
52
|
+
getInboundFile(fileId: string): Promise<StoredFile | null>;
|
|
45
53
|
/** Optional one-time setup (e.g. create tables). Called once at boot. */
|
|
46
54
|
init?(): Promise<void>;
|
|
47
55
|
}
|
|
@@ -50,8 +58,8 @@ export interface AgentStore {
|
|
|
50
58
|
|
|
51
59
|
export class MemoryStore implements AgentStore {
|
|
52
60
|
private state = new Map<string, MessageParam[]>();
|
|
53
|
-
private resume = new Map<string, ResumeMarker>();
|
|
54
61
|
private locks = new Map<string, number>(); // vessel → expiry (ms epoch)
|
|
62
|
+
private files = new Map<string, StoredFile>();
|
|
55
63
|
|
|
56
64
|
async loadState(vessel: string): Promise<MessageParam[]> {
|
|
57
65
|
return this.state.get(vessel) ?? [];
|
|
@@ -61,15 +69,6 @@ export class MemoryStore implements AgentStore {
|
|
|
61
69
|
this.state.set(vessel, messages);
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
async loadResume(vessel: string): Promise<ResumeMarker | null> {
|
|
65
|
-
return this.resume.get(vessel) ?? null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async saveResume(vessel: string, marker: ResumeMarker | null): Promise<void> {
|
|
69
|
-
if (marker) this.resume.set(vessel, marker);
|
|
70
|
-
else this.resume.delete(vessel);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
72
|
async acquireLock(vessel: string, ttlSeconds: number): Promise<boolean> {
|
|
74
73
|
const now = Date.now();
|
|
75
74
|
const until = this.locks.get(vessel);
|
|
@@ -81,6 +80,15 @@ export class MemoryStore implements AgentStore {
|
|
|
81
80
|
async releaseLock(vessel: string): Promise<void> {
|
|
82
81
|
this.locks.delete(vessel);
|
|
83
82
|
}
|
|
83
|
+
|
|
84
|
+
async putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string> {
|
|
85
|
+
this.files.set(file.fileId, { bytes: file.bytes, contentType: file.contentType, filename: file.filename });
|
|
86
|
+
return `${publicBaseUrl()}/files/${file.fileId}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getInboundFile(fileId: string): Promise<StoredFile | null> {
|
|
90
|
+
return this.files.get(fileId) ?? null;
|
|
91
|
+
}
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
// ─── PostgresStore — durable, self-provisioning ─────────────────────────────────
|
|
@@ -103,10 +111,12 @@ export class PostgresStore implements AgentStore {
|
|
|
103
111
|
vessel TEXT PRIMARY KEY,
|
|
104
112
|
expires_at TIMESTAMPTZ NOT NULL
|
|
105
113
|
);
|
|
106
|
-
CREATE TABLE IF NOT EXISTS
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
CREATE TABLE IF NOT EXISTS agent_files (
|
|
115
|
+
file_id TEXT PRIMARY KEY,
|
|
116
|
+
bytes BYTEA NOT NULL,
|
|
117
|
+
content_type TEXT NOT NULL,
|
|
118
|
+
filename TEXT,
|
|
119
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
110
120
|
);
|
|
111
121
|
`);
|
|
112
122
|
}
|
|
@@ -133,27 +143,6 @@ export class PostgresStore implements AgentStore {
|
|
|
133
143
|
);
|
|
134
144
|
}
|
|
135
145
|
|
|
136
|
-
async loadResume(vessel: string): Promise<ResumeMarker | null> {
|
|
137
|
-
const { rows } = await this.db.query<{ marker: ResumeMarker }>(
|
|
138
|
-
'SELECT marker FROM agent_resume WHERE vessel = $1',
|
|
139
|
-
[vessel]
|
|
140
|
-
);
|
|
141
|
-
return rows[0]?.marker ?? null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async saveResume(vessel: string, marker: ResumeMarker | null): Promise<void> {
|
|
145
|
-
if (!marker) {
|
|
146
|
-
await this.db.query('DELETE FROM agent_resume WHERE vessel = $1', [vessel]);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
await this.db.query(
|
|
150
|
-
`INSERT INTO agent_resume (vessel, marker, updated_at)
|
|
151
|
-
VALUES ($1, $2, now())
|
|
152
|
-
ON CONFLICT (vessel) DO UPDATE SET marker = EXCLUDED.marker, updated_at = now()`,
|
|
153
|
-
[vessel, JSON.stringify(marker)]
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
146
|
async acquireLock(vessel: string, ttlSeconds: number): Promise<boolean> {
|
|
158
147
|
// Atomic: take the row if free, OR steal it if the prior holder's TTL has lapsed.
|
|
159
148
|
const { rows } = await this.db.query(
|
|
@@ -171,6 +160,25 @@ export class PostgresStore implements AgentStore {
|
|
|
171
160
|
async releaseLock(vessel: string): Promise<void> {
|
|
172
161
|
await this.db.query('DELETE FROM agent_locks WHERE vessel = $1', [vessel]);
|
|
173
162
|
}
|
|
163
|
+
|
|
164
|
+
async putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string> {
|
|
165
|
+
await this.db.query(
|
|
166
|
+
`INSERT INTO agent_files (file_id, bytes, content_type, filename)
|
|
167
|
+
VALUES ($1, $2, $3, $4)
|
|
168
|
+
ON CONFLICT (file_id) DO UPDATE SET bytes = EXCLUDED.bytes, content_type = EXCLUDED.content_type, filename = EXCLUDED.filename`,
|
|
169
|
+
[file.fileId, Buffer.from(file.bytes), file.contentType, file.filename ?? null]
|
|
170
|
+
);
|
|
171
|
+
return `${publicBaseUrl()}/files/${file.fileId}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async getInboundFile(fileId: string): Promise<StoredFile | null> {
|
|
175
|
+
const { rows } = await this.db.query<{ bytes: Buffer; content_type: string; filename: string | null }>(
|
|
176
|
+
'SELECT bytes, content_type, filename FROM agent_files WHERE file_id = $1',
|
|
177
|
+
[fileId]
|
|
178
|
+
);
|
|
179
|
+
const r = rows[0];
|
|
180
|
+
return r ? { bytes: new Uint8Array(r.bytes), contentType: r.content_type, filename: r.filename ?? undefined } : null;
|
|
181
|
+
}
|
|
174
182
|
}
|
|
175
183
|
|
|
176
184
|
/**
|