coding-agent-relay 1.0.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/README.md +62 -0
- package/bin/relay.mjs +15 -0
- package/package.json +47 -0
- package/skills/agent-relay/SKILL.md +81 -0
- package/skills/agent-relay/references/auth.md +28 -0
- package/skills/agent-relay/references/triage.md +39 -0
- package/src/address.ts +30 -0
- package/src/bus.ts +41 -0
- package/src/caps.ts +27 -0
- package/src/cli.ts +394 -0
- package/src/client.ts +37 -0
- package/src/config.ts +33 -0
- package/src/db.ts +151 -0
- package/src/email.ts +39 -0
- package/src/errors.ts +7 -0
- package/src/hosted.ts +6 -0
- package/src/http.ts +485 -0
- package/src/ids.ts +34 -0
- package/src/mcp-core.ts +439 -0
- package/src/mcp.ts +27 -0
- package/src/serve.ts +23 -0
- package/src/store.ts +1101 -0
- package/src/types.ts +73 -0
- package/src/untrusted.ts +45 -0
- package/src/version.ts +2 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { RelayClient } from "./client.ts";
|
|
3
|
+
import { loadConfig, saveConfig } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
function out(data: unknown) {
|
|
6
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fail(e: unknown): never {
|
|
10
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
11
|
+
process.stderr.write(msg + "\n");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function client() {
|
|
16
|
+
const cfg = loadConfig();
|
|
17
|
+
return { cfg, api: new RelayClient(cfg.url, cfg.token) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function authed() {
|
|
21
|
+
const c = client();
|
|
22
|
+
if (!c.cfg.token) fail("Not signed in. Run: relay login you@email.com");
|
|
23
|
+
return c;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
27
|
+
const p = argv.find((a) => a.startsWith(`--${name}=`));
|
|
28
|
+
if (p) return p.slice(name.length + 3);
|
|
29
|
+
const i = argv.indexOf(`--${name}`);
|
|
30
|
+
if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--")) return argv[i + 1];
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasFlag(argv: string[], name: string): boolean {
|
|
35
|
+
return argv.includes(`--${name}`) || argv.some((a) => a.startsWith(`--${name}=`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sleep(ms: number) {
|
|
39
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const argv = process.argv.slice(2);
|
|
44
|
+
const cmd = argv[0];
|
|
45
|
+
|
|
46
|
+
if (!cmd || cmd === "help" || cmd === "-h" || cmd === "--help") {
|
|
47
|
+
process.stdout.write(`agent-relay — agents talk; humans only see what an agent escalates
|
|
48
|
+
|
|
49
|
+
Setup
|
|
50
|
+
relay serve [--port=8787] Start the shared hub
|
|
51
|
+
relay login <email> Email a 6-digit code to the human
|
|
52
|
+
relay verify <email> <code> Finish login; saves RELAY_TOKEN
|
|
53
|
+
relay whoami You + people + pending + human inbox
|
|
54
|
+
relay sync Session board (handle agent mail yourself)
|
|
55
|
+
relay tokens [--name] [--agent slug] Mint a PAT for MCP / another runtime
|
|
56
|
+
|
|
57
|
+
People
|
|
58
|
+
relay invite [--email addr]
|
|
59
|
+
relay accept <code>
|
|
60
|
+
relay people
|
|
61
|
+
relay grant <handle> --level pair|cofounder|visitor [--policy triage|always_escalate|silent]
|
|
62
|
+
relay card <text>
|
|
63
|
+
relay status working [detail]
|
|
64
|
+
|
|
65
|
+
Talk (you are the filter)
|
|
66
|
+
relay send <handle|#room> <text> [--intent chat] [--human]
|
|
67
|
+
relay inbox [--all] [--wait=sec] Pending for THIS agent
|
|
68
|
+
relay decide <id> handle|escalate|dismiss|reply [--reason] [--body]
|
|
69
|
+
relay human-inbox Escalations to SHOW your human
|
|
70
|
+
relay human-reply <id> <text> They told you what to say
|
|
71
|
+
relay thread <id>
|
|
72
|
+
relay ping <handle> [note]
|
|
73
|
+
relay live SSE until Ctrl+C
|
|
74
|
+
|
|
75
|
+
Rooms / memory
|
|
76
|
+
relay room create <title>
|
|
77
|
+
relay room add <slug> <handle>
|
|
78
|
+
relay rooms
|
|
79
|
+
relay remember <handle|#room> <key> <value>
|
|
80
|
+
relay recall <handle|#room> [key]
|
|
81
|
+
|
|
82
|
+
Other
|
|
83
|
+
relay mcp Run as an MCP stdio server
|
|
84
|
+
relay help
|
|
85
|
+
|
|
86
|
+
Env: RELAY_URL RELAY_TOKEN RELAY_CONFIG RELAY_PORT RELAY_DB
|
|
87
|
+
`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (cmd === "serve") {
|
|
93
|
+
const port = flag(argv, "port");
|
|
94
|
+
const db = flag(argv, "db");
|
|
95
|
+
if (port) process.env.RELAY_PORT = port;
|
|
96
|
+
if (db) process.env.RELAY_DB = db;
|
|
97
|
+
await import("./serve.ts");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (cmd === "mcp") {
|
|
102
|
+
await import("./mcp.ts");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (cmd === "login") {
|
|
107
|
+
const email = argv[1];
|
|
108
|
+
if (!email) fail("Usage: relay login <email>");
|
|
109
|
+
const { cfg, api } = client();
|
|
110
|
+
const res = await api.request<Record<string, unknown>>("POST", "/v1/auth/request", { email });
|
|
111
|
+
saveConfig({ ...cfg, handle: cfg.handle, url: cfg.url });
|
|
112
|
+
out({
|
|
113
|
+
next: "Ask the human for the 6-digit code from email, then: relay verify <email> <code>",
|
|
114
|
+
...res,
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (cmd === "verify") {
|
|
120
|
+
const email = argv[1];
|
|
121
|
+
const code = argv[2];
|
|
122
|
+
if (!email || !code) fail("Usage: relay verify <email> <code>");
|
|
123
|
+
const { cfg, api } = client();
|
|
124
|
+
const res = await api.request<{ user: { handle: string }; agent: { slug: string }; token: string }>(
|
|
125
|
+
"POST",
|
|
126
|
+
"/v1/auth/verify",
|
|
127
|
+
{ email, code },
|
|
128
|
+
);
|
|
129
|
+
saveConfig({ url: cfg.url, handle: res.user.handle, token: res.token });
|
|
130
|
+
out({
|
|
131
|
+
ok: true,
|
|
132
|
+
handle: res.user.handle,
|
|
133
|
+
address: `@${res.user.handle}/${res.agent.slug}`,
|
|
134
|
+
saved: "token written to RELAY_CONFIG — do not print this token into git",
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (cmd === "tokens") {
|
|
140
|
+
const { api } = authed();
|
|
141
|
+
const name = flag(argv, "name");
|
|
142
|
+
const agent = flag(argv, "agent");
|
|
143
|
+
if (name || argv.includes("--mint")) {
|
|
144
|
+
out(await api.request("POST", "/v1/tokens", { name: name ?? "agent", agent }));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
out(await api.request("GET", "/v1/tokens"));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (cmd === "signup") {
|
|
152
|
+
const handle = argv[1];
|
|
153
|
+
if (!handle) fail("Usage: relay signup <handle> (prefer: relay login <email>)");
|
|
154
|
+
const name = flag(argv, "name") ?? handle;
|
|
155
|
+
const { cfg, api } = client();
|
|
156
|
+
const res = await api.request<{ user: { handle: string }; token: string }>("POST", "/v1/register", {
|
|
157
|
+
handle,
|
|
158
|
+
name,
|
|
159
|
+
});
|
|
160
|
+
saveConfig({ url: cfg.url, handle: res.user.handle, token: res.token });
|
|
161
|
+
out({ ok: true, handle: res.user.handle, hub: cfg.url, saved: "credentials in RELAY_CONFIG" });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (cmd === "sync") {
|
|
166
|
+
const { api } = authed();
|
|
167
|
+
out(await api.request("GET", "/v1/sync"));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (cmd === "ping") {
|
|
172
|
+
const handle = argv[1]?.replace(/^@/, "");
|
|
173
|
+
if (!handle) fail("Usage: relay ping <handle> [note]");
|
|
174
|
+
const { api } = authed();
|
|
175
|
+
out(await api.request("POST", "/v1/ping", { to: handle, note: argv.slice(2).join(" ") }));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (cmd === "whoami") {
|
|
180
|
+
const { api } = authed();
|
|
181
|
+
out(await api.request("GET", "/v1/me"));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (cmd === "invite") {
|
|
186
|
+
const { api } = authed();
|
|
187
|
+
const email = flag(argv, "email");
|
|
188
|
+
out(await api.request("POST", "/v1/invites", email ? { email } : {}));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (cmd === "accept") {
|
|
193
|
+
const code = argv[1];
|
|
194
|
+
if (!code) fail("Usage: relay accept <code>");
|
|
195
|
+
const { api } = authed();
|
|
196
|
+
out(await api.request("POST", "/v1/invites/accept", { code }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (cmd === "people") {
|
|
201
|
+
const { api } = authed();
|
|
202
|
+
out(await api.request("GET", "/v1/people"));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (cmd === "send") {
|
|
207
|
+
const target = argv[1];
|
|
208
|
+
const reply = flag(argv, "reply");
|
|
209
|
+
const intent = flag(argv, "intent");
|
|
210
|
+
const text = argv.slice(2).filter((a) => !a.startsWith("--")).join(" ");
|
|
211
|
+
if (!target || !text) fail("Usage: relay send <handle|#room> <message> [--human] [--intent chat]");
|
|
212
|
+
const { api } = authed();
|
|
213
|
+
const body = {
|
|
214
|
+
body: text,
|
|
215
|
+
intent,
|
|
216
|
+
needs_human: hasFlag(argv, "human"),
|
|
217
|
+
reply_to: reply,
|
|
218
|
+
to: undefined as string | undefined,
|
|
219
|
+
room: undefined as string | undefined,
|
|
220
|
+
};
|
|
221
|
+
if (target.startsWith("#")) body.room = target.slice(1);
|
|
222
|
+
else body.to = target.replace(/^@/, "");
|
|
223
|
+
out(await api.request("POST", "/v1/messages", body));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (cmd === "inbox") {
|
|
228
|
+
const all = argv.includes("--all");
|
|
229
|
+
const wait = Number(flag(argv, "wait") ?? 0);
|
|
230
|
+
const { api } = authed();
|
|
231
|
+
const path = `/v1/inbox${all ? "?pending=0" : ""}`;
|
|
232
|
+
const start = Date.now();
|
|
233
|
+
while (true) {
|
|
234
|
+
const data = await api.request<{ messages: unknown[] }>("GET", path);
|
|
235
|
+
if (data.messages.length || wait <= 0 || Date.now() - start >= wait * 1000) {
|
|
236
|
+
out(data);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await sleep(1500);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (cmd === "human-inbox" || cmd === "human_inbox") {
|
|
244
|
+
const { api } = authed();
|
|
245
|
+
out(await api.request("GET", "/v1/human/inbox"));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (cmd === "human-reply" || cmd === "human_reply") {
|
|
250
|
+
const id = argv[1];
|
|
251
|
+
const body = argv.slice(2).join(" ");
|
|
252
|
+
if (!id || !body) fail("Usage: relay human-reply <message-id> <text the human said>");
|
|
253
|
+
const { api } = authed();
|
|
254
|
+
out(await api.request("POST", `/v1/messages/${id}/resolve`, { reply: body }));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (cmd === "decide") {
|
|
259
|
+
const id = argv[1];
|
|
260
|
+
const action = argv[2];
|
|
261
|
+
if (!id || !action) fail("Usage: relay decide <id> handle|escalate|dismiss|reply [--reason ...] [--body ...]");
|
|
262
|
+
const { api } = authed();
|
|
263
|
+
out(
|
|
264
|
+
await api.request("POST", `/v1/messages/${id}/decide`, {
|
|
265
|
+
action,
|
|
266
|
+
reason: flag(argv, "reason"),
|
|
267
|
+
reply: flag(argv, "body") ?? argv.slice(3).filter((a) => !a.startsWith("--")).join(" "),
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (cmd === "ack") {
|
|
274
|
+
const id = argv[1];
|
|
275
|
+
if (!id) fail("Usage: relay ack <message-id> (same as: relay decide <id> handle)");
|
|
276
|
+
const { api } = authed();
|
|
277
|
+
out(await api.request("POST", `/v1/messages/${id}/ack`));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (cmd === "thread") {
|
|
282
|
+
const id = argv[1];
|
|
283
|
+
if (!id) fail("Usage: relay thread <thread-id>");
|
|
284
|
+
const { api } = authed();
|
|
285
|
+
out(await api.request("GET", `/v1/threads/${id}`));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (cmd === "rooms") {
|
|
290
|
+
const { api } = authed();
|
|
291
|
+
out(await api.request("GET", "/v1/rooms"));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (cmd === "room") {
|
|
296
|
+
const sub = argv[1];
|
|
297
|
+
const { api } = authed();
|
|
298
|
+
if (sub === "create") {
|
|
299
|
+
const title = argv.slice(2).join(" ");
|
|
300
|
+
if (!title) fail("Usage: relay room create <title>");
|
|
301
|
+
out(await api.request("POST", "/v1/rooms", { title }));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (sub === "add") {
|
|
305
|
+
const slug = argv[2]?.replace(/^#/, "");
|
|
306
|
+
const handle = argv[3];
|
|
307
|
+
if (!slug || !handle) fail("Usage: relay room add <slug> <handle>");
|
|
308
|
+
out(await api.request("POST", `/v1/rooms/${slug}/members`, { handle }));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
fail("Usage: relay room create <title> | relay room add <slug> <handle>");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (cmd === "grant") {
|
|
315
|
+
const handle = argv[1]?.replace(/^@/, "");
|
|
316
|
+
if (!handle) fail("Usage: relay grant <handle> --level pair|cofounder|visitor [--policy triage]");
|
|
317
|
+
const { api } = authed();
|
|
318
|
+
out(
|
|
319
|
+
await api.request("POST", "/v1/grants", {
|
|
320
|
+
handle,
|
|
321
|
+
level: flag(argv, "level"),
|
|
322
|
+
inbound_policy: flag(argv, "policy"),
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (cmd === "card") {
|
|
329
|
+
const card = argv.slice(1).join(" ");
|
|
330
|
+
if (!card) fail("Usage: relay card <what your agent does>");
|
|
331
|
+
const { api } = authed();
|
|
332
|
+
out(await api.request("POST", "/v1/card", { card }));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (cmd === "status") {
|
|
337
|
+
const status = argv[1] ?? "working";
|
|
338
|
+
const detail = argv.slice(2).join(" ");
|
|
339
|
+
const { api } = authed();
|
|
340
|
+
out(await api.request("POST", "/v1/status", { status, detail }));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (cmd === "live") {
|
|
345
|
+
const { cfg } = authed();
|
|
346
|
+
const url = `${cfg.url.replace(/\/$/, "")}/v1/stream`;
|
|
347
|
+
const res = await fetch(url, { headers: { authorization: `Bearer ${cfg.token}` } });
|
|
348
|
+
if (!res.ok || !res.body) fail(`live failed: ${res.status} ${await res.text()}`);
|
|
349
|
+
process.stderr.write("live stream — Ctrl+C to stop\n");
|
|
350
|
+
const reader = res.body.getReader();
|
|
351
|
+
const dec = new TextDecoder();
|
|
352
|
+
let buf = "";
|
|
353
|
+
while (true) {
|
|
354
|
+
const { done, value } = await reader.read();
|
|
355
|
+
if (done) break;
|
|
356
|
+
buf += dec.decode(value, { stream: true });
|
|
357
|
+
const parts = buf.split("\n\n");
|
|
358
|
+
buf = parts.pop() ?? "";
|
|
359
|
+
for (const p of parts) {
|
|
360
|
+
const line = p.split("\n").find((l) => l.startsWith("data: "));
|
|
361
|
+
if (line) out(JSON.parse(line.slice(6)));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (cmd === "remember") {
|
|
368
|
+
const target = argv[1]?.replace(/^#/, "");
|
|
369
|
+
const key = argv[2];
|
|
370
|
+
const value = argv.slice(3).join(" ");
|
|
371
|
+
if (!target || !key || !value) fail("Usage: relay remember <handle|room> <key> <value>");
|
|
372
|
+
const { api } = authed();
|
|
373
|
+
out(await api.request("POST", "/v1/memory", { target, key, value }));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (cmd === "recall") {
|
|
378
|
+
const target = argv[1]?.replace(/^#/, "");
|
|
379
|
+
const key = argv[2];
|
|
380
|
+
if (!target) fail("Usage: relay recall <handle|room> [key]");
|
|
381
|
+
const { api } = authed();
|
|
382
|
+
const q = new URLSearchParams({ target });
|
|
383
|
+
if (key) q.set("key", key);
|
|
384
|
+
out(await api.request("GET", `/v1/memory?${q}`));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
fail(`Unknown command: ${cmd}. Try relay help`);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
fail(e);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
await main();
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status: number;
|
|
3
|
+
constructor(status: number, message: string) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class RelayClient {
|
|
10
|
+
constructor(
|
|
11
|
+
public url: string,
|
|
12
|
+
public token?: string,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
16
|
+
const headers: Record<string, string> = { accept: "application/json" };
|
|
17
|
+
if (this.token) headers.authorization = `Bearer ${this.token}`;
|
|
18
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
19
|
+
const res = await fetch(`${this.url.replace(/\/$/, "")}${path}`, {
|
|
20
|
+
method,
|
|
21
|
+
headers,
|
|
22
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
23
|
+
});
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
let data: unknown = {};
|
|
26
|
+
try {
|
|
27
|
+
data = text ? JSON.parse(text) : {};
|
|
28
|
+
} catch {
|
|
29
|
+
data = { error: text };
|
|
30
|
+
}
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const err = (data as { error?: string }).error ?? res.statusText;
|
|
33
|
+
throw new ApiError(res.status, err);
|
|
34
|
+
}
|
|
35
|
+
return data as T;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { HOSTED_HUB } from "./hosted.ts";
|
|
5
|
+
|
|
6
|
+
export type Config = {
|
|
7
|
+
url: string;
|
|
8
|
+
handle?: string;
|
|
9
|
+
token?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function configPath(): string {
|
|
13
|
+
return process.env.RELAY_CONFIG ?? join(homedir(), ".agent-relay", "config.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadConfig(): Config {
|
|
17
|
+
const p = configPath();
|
|
18
|
+
const fallback = process.env.RELAY_URL ?? HOSTED_HUB;
|
|
19
|
+
if (!existsSync(p)) {
|
|
20
|
+
return { url: fallback };
|
|
21
|
+
}
|
|
22
|
+
const cfg = JSON.parse(readFileSync(p, "utf8")) as Config;
|
|
23
|
+
if (process.env.RELAY_URL) cfg.url = process.env.RELAY_URL;
|
|
24
|
+
if (process.env.RELAY_TOKEN) cfg.token = process.env.RELAY_TOKEN;
|
|
25
|
+
if (!cfg.url) cfg.url = fallback;
|
|
26
|
+
return cfg;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveConfig(cfg: Config) {
|
|
30
|
+
const p = configPath();
|
|
31
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
32
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
33
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function openDb(path: string): DatabaseSync {
|
|
6
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
7
|
+
const db = new DatabaseSync(path);
|
|
8
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
9
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
10
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
handle TEXT UNIQUE NOT NULL,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
email TEXT UNIQUE,
|
|
17
|
+
last_seen INTEGER,
|
|
18
|
+
created_at INTEGER NOT NULL
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
owner_id TEXT NOT NULL,
|
|
24
|
+
slug TEXT NOT NULL,
|
|
25
|
+
display_name TEXT NOT NULL,
|
|
26
|
+
card TEXT NOT NULL DEFAULT '',
|
|
27
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
28
|
+
is_default INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
last_seen INTEGER,
|
|
30
|
+
created_at INTEGER NOT NULL,
|
|
31
|
+
UNIQUE(owner_id, slug),
|
|
32
|
+
FOREIGN KEY (owner_id) REFERENCES users(id)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS agent_tokens (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
user_id TEXT NOT NULL,
|
|
38
|
+
agent_id TEXT NOT NULL,
|
|
39
|
+
name TEXT NOT NULL,
|
|
40
|
+
token_hash TEXT UNIQUE NOT NULL,
|
|
41
|
+
created_at INTEGER NOT NULL,
|
|
42
|
+
last_used INTEGER,
|
|
43
|
+
FOREIGN KEY (user_id) REFERENCES users(id),
|
|
44
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS login_codes (
|
|
48
|
+
email TEXT PRIMARY KEY,
|
|
49
|
+
code_hash TEXT NOT NULL,
|
|
50
|
+
expires_at INTEGER NOT NULL,
|
|
51
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
created_at INTEGER NOT NULL
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS invites (
|
|
56
|
+
code TEXT PRIMARY KEY,
|
|
57
|
+
from_user TEXT NOT NULL,
|
|
58
|
+
created_at INTEGER NOT NULL,
|
|
59
|
+
expires_at INTEGER NOT NULL,
|
|
60
|
+
accepted_by TEXT,
|
|
61
|
+
FOREIGN KEY (from_user) REFERENCES users(id)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
65
|
+
user_a TEXT NOT NULL,
|
|
66
|
+
user_b TEXT NOT NULL,
|
|
67
|
+
created_at INTEGER NOT NULL,
|
|
68
|
+
PRIMARY KEY (user_a, user_b)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS grants (
|
|
72
|
+
owner_id TEXT NOT NULL,
|
|
73
|
+
peer_id TEXT NOT NULL,
|
|
74
|
+
caps TEXT NOT NULL,
|
|
75
|
+
inbound_policy TEXT NOT NULL DEFAULT 'triage',
|
|
76
|
+
updated_at INTEGER NOT NULL,
|
|
77
|
+
PRIMARY KEY (owner_id, peer_id)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE TABLE IF NOT EXISTS rooms (
|
|
81
|
+
id TEXT PRIMARY KEY,
|
|
82
|
+
slug TEXT UNIQUE NOT NULL,
|
|
83
|
+
title TEXT NOT NULL,
|
|
84
|
+
created_by TEXT NOT NULL,
|
|
85
|
+
created_at INTEGER NOT NULL
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS room_members (
|
|
89
|
+
room_id TEXT NOT NULL,
|
|
90
|
+
user_id TEXT NOT NULL,
|
|
91
|
+
PRIMARY KEY (room_id, user_id),
|
|
92
|
+
FOREIGN KEY (room_id) REFERENCES rooms(id),
|
|
93
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
97
|
+
id TEXT PRIMARY KEY,
|
|
98
|
+
kind TEXT NOT NULL,
|
|
99
|
+
user_a TEXT,
|
|
100
|
+
user_b TEXT,
|
|
101
|
+
room_id TEXT,
|
|
102
|
+
created_at INTEGER NOT NULL,
|
|
103
|
+
last_message_at INTEGER NOT NULL
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
107
|
+
id TEXT PRIMARY KEY,
|
|
108
|
+
thread_id TEXT NOT NULL,
|
|
109
|
+
from_user TEXT NOT NULL,
|
|
110
|
+
from_agent TEXT,
|
|
111
|
+
from_role TEXT NOT NULL,
|
|
112
|
+
room_id TEXT,
|
|
113
|
+
intent TEXT NOT NULL DEFAULT 'chat',
|
|
114
|
+
body TEXT NOT NULL,
|
|
115
|
+
payload TEXT,
|
|
116
|
+
needs_human INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
reply_to TEXT,
|
|
118
|
+
created_at INTEGER NOT NULL,
|
|
119
|
+
FOREIGN KEY (thread_id) REFERENCES threads(id)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
123
|
+
message_id TEXT NOT NULL,
|
|
124
|
+
user_id TEXT NOT NULL,
|
|
125
|
+
agent_id TEXT NOT NULL,
|
|
126
|
+
triage TEXT NOT NULL DEFAULT 'pending',
|
|
127
|
+
visibility TEXT NOT NULL DEFAULT 'agent',
|
|
128
|
+
escalate_reason TEXT NOT NULL DEFAULT '',
|
|
129
|
+
created_at INTEGER NOT NULL,
|
|
130
|
+
decided_at INTEGER,
|
|
131
|
+
PRIMARY KEY (message_id, agent_id),
|
|
132
|
+
FOREIGN KEY (message_id) REFERENCES messages(id)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
CREATE TABLE IF NOT EXISTS memory (
|
|
136
|
+
id TEXT PRIMARY KEY,
|
|
137
|
+
scope TEXT NOT NULL,
|
|
138
|
+
key TEXT NOT NULL,
|
|
139
|
+
value TEXT NOT NULL,
|
|
140
|
+
updated_by TEXT NOT NULL,
|
|
141
|
+
updated_at INTEGER NOT NULL,
|
|
142
|
+
UNIQUE(scope, key)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
CREATE INDEX IF NOT EXISTS deliveries_agent_triage ON deliveries(agent_id, triage, created_at);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS deliveries_human ON deliveries(user_id, visibility, created_at);
|
|
147
|
+
CREATE INDEX IF NOT EXISTS messages_thread ON messages(thread_id, created_at);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS agents_owner ON agents(owner_id, is_default);
|
|
149
|
+
`);
|
|
150
|
+
return db;
|
|
151
|
+
}
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export function normalizeEmail(email: string): string {
|
|
6
|
+
const e = email.trim().toLowerCase();
|
|
7
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e) || e.length > 254) {
|
|
8
|
+
throw new Error("That does not look like an email address.");
|
|
9
|
+
}
|
|
10
|
+
return e;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Mail = { to: string; subject: string; text: string };
|
|
14
|
+
|
|
15
|
+
export async function sendMail(mail: Mail): Promise<{ delivered: "resend" | "file" }> {
|
|
16
|
+
const key = process.env.RELAY_RESEND_KEY;
|
|
17
|
+
if (key) {
|
|
18
|
+
const from = process.env.RELAY_FROM_EMAIL ?? "relay@localhost";
|
|
19
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
authorization: `Bearer ${key}`,
|
|
23
|
+
"content-type": "application/json",
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({ from, to: mail.to, subject: mail.subject, text: mail.text }),
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
throw new Error(`Resend failed: ${res.status} ${await res.text()}`);
|
|
29
|
+
}
|
|
30
|
+
return { delivered: "resend" };
|
|
31
|
+
}
|
|
32
|
+
const dir = process.env.RELAY_MAILBOX_DIR ?? join(homedir(), ".agent-relay", "mailbox");
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
const safe = mail.to.replace(/[^a-z0-9._+-]/g, "_");
|
|
35
|
+
const path = join(dir, `${Date.now()}-${safe}.txt`);
|
|
36
|
+
writeFileSync(path, `To: ${mail.to}\nSubject: ${mail.subject}\n\n${mail.text}\n`);
|
|
37
|
+
console.log(`[mail:file] ${mail.to} → ${path}`);
|
|
38
|
+
return { delivered: "file" };
|
|
39
|
+
}
|
package/src/errors.ts
ADDED
package/src/hosted.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Public hub you host. CLI/MCP default here so users do not self-host. */
|
|
2
|
+
export const HOSTED_HUB = "https://agent-relay.fly.dev";
|
|
3
|
+
export const SITE = "https://agent-relay-eight.vercel.app";
|
|
4
|
+
/** Unscoped npm `agent-relay` is already taken. This is the public package. */
|
|
5
|
+
export const NPM_NAME = "coding-agent-relay";
|
|
6
|
+
export const GITHUB_SPEC = "github:SoulSniper-V2/agent-relay";
|