@cleocode/core 2026.4.44 → 2026.4.45
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/conduit/index.js +749 -0
- package/dist/conduit/index.js.map +7 -0
- package/dist/index.js +20 -9
- package/dist/index.js.map +2 -2
- package/dist/internal.js +110674 -0
- package/dist/internal.js.map +7 -0
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/store/migration-manager.ts +41 -9
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// packages/core/src/conduit/conduit-client.ts
|
|
2
|
+
var ConduitClient = class {
|
|
3
|
+
transport;
|
|
4
|
+
credential;
|
|
5
|
+
state = "disconnected";
|
|
6
|
+
/** Create a ConduitClient backed by the given transport and credential. */
|
|
7
|
+
constructor(transport, credential) {
|
|
8
|
+
this.transport = transport;
|
|
9
|
+
this.credential = credential;
|
|
10
|
+
}
|
|
11
|
+
/** The agent ID from the bound credential. */
|
|
12
|
+
get agentId() {
|
|
13
|
+
return this.credential.agentId;
|
|
14
|
+
}
|
|
15
|
+
/** Current connection state (disconnected → connecting → connected | error). */
|
|
16
|
+
getState() {
|
|
17
|
+
return this.state;
|
|
18
|
+
}
|
|
19
|
+
/** Connect the underlying transport using the bound credential. */
|
|
20
|
+
async connect() {
|
|
21
|
+
this.state = "connecting";
|
|
22
|
+
try {
|
|
23
|
+
await this.transport.connect({
|
|
24
|
+
agentId: this.credential.agentId,
|
|
25
|
+
apiKey: this.credential.apiKey,
|
|
26
|
+
apiBaseUrl: this.credential.apiBaseUrl,
|
|
27
|
+
...this.credential.transportConfig
|
|
28
|
+
});
|
|
29
|
+
this.state = "connected";
|
|
30
|
+
} catch (err) {
|
|
31
|
+
this.state = "error";
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Send a message to another agent, optionally within a thread. */
|
|
36
|
+
async send(to, content, options) {
|
|
37
|
+
const result = await this.transport.push(to, content, {
|
|
38
|
+
conversationId: options?.threadId
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
messageId: result.messageId,
|
|
42
|
+
deliveredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** One-shot poll for new messages. Delegates to the transport's poll method. */
|
|
46
|
+
async poll(options) {
|
|
47
|
+
return this.transport.poll(options);
|
|
48
|
+
}
|
|
49
|
+
/** Subscribe to incoming messages. Uses real-time transport when available, else polls. */
|
|
50
|
+
onMessage(handler) {
|
|
51
|
+
if (this.transport.subscribe) {
|
|
52
|
+
return this.transport.subscribe(handler);
|
|
53
|
+
}
|
|
54
|
+
const interval = setInterval(async () => {
|
|
55
|
+
const messages = await this.transport.poll();
|
|
56
|
+
for (const msg of messages) handler(msg);
|
|
57
|
+
if (messages.length > 0) {
|
|
58
|
+
await this.transport.ack(messages.map((m) => m.id));
|
|
59
|
+
}
|
|
60
|
+
}, this.credential.transportConfig.pollIntervalMs ?? 5e3);
|
|
61
|
+
return () => clearInterval(interval);
|
|
62
|
+
}
|
|
63
|
+
/** Send an empty heartbeat to maintain presence on the relay. */
|
|
64
|
+
async heartbeat() {
|
|
65
|
+
await this.transport.push(this.credential.agentId, "", {});
|
|
66
|
+
}
|
|
67
|
+
/** Check whether a remote agent is currently online via the cloud API. */
|
|
68
|
+
async isOnline(agentId) {
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(`${this.credential.apiBaseUrl}/agents/${agentId}`, {
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `Bearer ${this.credential.apiKey}`,
|
|
73
|
+
"X-Agent-Id": this.credential.agentId
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) return false;
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
return data.data?.agent?.status === "online";
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Disconnect the transport and reset state to disconnected. */
|
|
84
|
+
async disconnect() {
|
|
85
|
+
await this.transport.disconnect();
|
|
86
|
+
this.state = "disconnected";
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// packages/core/src/conduit/http-transport.ts
|
|
91
|
+
var HttpTransport = class {
|
|
92
|
+
name = "http";
|
|
93
|
+
state = null;
|
|
94
|
+
/** Connect to the SignalDock API, probing primary/fallback health when both are configured. */
|
|
95
|
+
async connect(config) {
|
|
96
|
+
const primaryUrl = config.apiBaseUrl;
|
|
97
|
+
const fallbackUrl = config.apiBaseUrlFallback ?? null;
|
|
98
|
+
let activeUrl = primaryUrl;
|
|
99
|
+
if (fallbackUrl) {
|
|
100
|
+
const [primaryResult, fallbackResult] = await Promise.allSettled([
|
|
101
|
+
fetch(`${primaryUrl}/health`, { method: "GET", signal: AbortSignal.timeout(5e3) }),
|
|
102
|
+
fetch(`${fallbackUrl}/health`, { method: "GET", signal: AbortSignal.timeout(5e3) })
|
|
103
|
+
]);
|
|
104
|
+
const primaryOk = primaryResult.status === "fulfilled" && primaryResult.value.ok;
|
|
105
|
+
const fallbackOk = fallbackResult.status === "fulfilled" && fallbackResult.value.ok;
|
|
106
|
+
if (!primaryOk && fallbackOk) {
|
|
107
|
+
activeUrl = fallbackUrl;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
this.state = {
|
|
111
|
+
agentId: config.agentId,
|
|
112
|
+
apiKey: config.apiKey,
|
|
113
|
+
primaryUrl,
|
|
114
|
+
fallbackUrl,
|
|
115
|
+
activeUrl,
|
|
116
|
+
connected: true
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Disconnect and clear connection state. */
|
|
120
|
+
async disconnect() {
|
|
121
|
+
this.state = null;
|
|
122
|
+
}
|
|
123
|
+
/** Send a message to an agent (direct or within a conversation thread). */
|
|
124
|
+
async push(to, content, options) {
|
|
125
|
+
this.ensureConnected();
|
|
126
|
+
const body = { content };
|
|
127
|
+
let path;
|
|
128
|
+
if (options?.conversationId) {
|
|
129
|
+
path = `/conversations/${options.conversationId}/messages`;
|
|
130
|
+
if (options.replyTo) {
|
|
131
|
+
body["replyTo"] = options.replyTo;
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
path = "/messages";
|
|
135
|
+
body["toAgentId"] = to;
|
|
136
|
+
}
|
|
137
|
+
const response = await this.fetchWithFallback(path, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: this.headers(),
|
|
140
|
+
body: JSON.stringify(body)
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const text = await response.text().catch(() => "");
|
|
144
|
+
throw new Error(`HttpTransport push failed: ${response.status} ${text}`);
|
|
145
|
+
}
|
|
146
|
+
const data = await response.json();
|
|
147
|
+
const messageId = data.data?.message?.id ?? data.data?.id ?? "unknown";
|
|
148
|
+
return { messageId };
|
|
149
|
+
}
|
|
150
|
+
/** Poll for new messages for this agent. Returns empty array on HTTP error. */
|
|
151
|
+
async poll(options) {
|
|
152
|
+
this.ensureConnected();
|
|
153
|
+
const params = new URLSearchParams();
|
|
154
|
+
if (options?.limit) params.set("limit", String(options.limit));
|
|
155
|
+
if (options?.since) params.set("since", options.since);
|
|
156
|
+
const response = await this.fetchWithFallback(`/messages/peek?${params}`, {
|
|
157
|
+
method: "GET",
|
|
158
|
+
headers: this.headers()
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) return [];
|
|
161
|
+
const data = await response.json();
|
|
162
|
+
return (data.data?.messages ?? []).map((m) => ({
|
|
163
|
+
id: m.id,
|
|
164
|
+
from: m.fromAgentId ?? "unknown",
|
|
165
|
+
content: m.content ?? "",
|
|
166
|
+
threadId: m.conversationId,
|
|
167
|
+
timestamp: m.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
/** Acknowledge messages by ID so they are not returned by future polls. */
|
|
171
|
+
async ack(messageIds) {
|
|
172
|
+
this.ensureConnected();
|
|
173
|
+
await this.fetchWithFallback("/messages/ack", {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: this.headers(),
|
|
176
|
+
body: JSON.stringify({ messageIds })
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Fetch with automatic failover. Tries activeUrl first.
|
|
181
|
+
* If it fails and a fallback exists, retries on the other URL
|
|
182
|
+
* and swaps activeUrl for subsequent calls.
|
|
183
|
+
*/
|
|
184
|
+
async fetchWithFallback(path, init) {
|
|
185
|
+
const timeout = AbortSignal.timeout(1e4);
|
|
186
|
+
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
|
187
|
+
const url = `${this.state.activeUrl}${path}`;
|
|
188
|
+
try {
|
|
189
|
+
return await fetch(url, { ...init, signal });
|
|
190
|
+
} catch (primaryErr) {
|
|
191
|
+
const otherUrl = this.state.activeUrl === this.state.primaryUrl ? this.state.fallbackUrl : this.state.primaryUrl;
|
|
192
|
+
if (!otherUrl) throw primaryErr;
|
|
193
|
+
try {
|
|
194
|
+
const fallbackSignal = init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(1e4)]) : AbortSignal.timeout(1e4);
|
|
195
|
+
const fallbackResponse = await fetch(`${otherUrl}${path}`, {
|
|
196
|
+
...init,
|
|
197
|
+
signal: fallbackSignal
|
|
198
|
+
});
|
|
199
|
+
this.state.activeUrl = otherUrl;
|
|
200
|
+
return fallbackResponse;
|
|
201
|
+
} catch {
|
|
202
|
+
throw primaryErr;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
headers() {
|
|
207
|
+
return {
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
Authorization: `Bearer ${this.state.apiKey}`,
|
|
210
|
+
"X-Agent-Id": this.state.agentId
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
ensureConnected() {
|
|
214
|
+
if (!this.state?.connected) {
|
|
215
|
+
throw new Error("HttpTransport not connected. Call connect() first.");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// packages/core/src/conduit/local-transport.ts
|
|
221
|
+
import { randomUUID } from "node:crypto";
|
|
222
|
+
import { existsSync } from "node:fs";
|
|
223
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
224
|
+
|
|
225
|
+
// packages/core/src/store/conduit-sqlite.ts
|
|
226
|
+
import { createRequire } from "node:module";
|
|
227
|
+
import { dirname, join } from "node:path";
|
|
228
|
+
var _require = createRequire(import.meta.url);
|
|
229
|
+
var { DatabaseSync } = _require("node:sqlite");
|
|
230
|
+
var CONDUIT_DB_FILENAME = "conduit.db";
|
|
231
|
+
function getConduitDbPath(projectRoot) {
|
|
232
|
+
return join(projectRoot, ".cleo", CONDUIT_DB_FILENAME);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// packages/core/src/conduit/local-transport.ts
|
|
236
|
+
var _require2 = createRequire2(import.meta.url);
|
|
237
|
+
var { DatabaseSync: DatabaseSyncClass } = _require2("node:sqlite");
|
|
238
|
+
var LocalTransport = class {
|
|
239
|
+
name = "local";
|
|
240
|
+
state = null;
|
|
241
|
+
/**
|
|
242
|
+
* Connect to conduit.db for in-process messaging.
|
|
243
|
+
*
|
|
244
|
+
* Opens the database, sets WAL mode pragmas, and verifies
|
|
245
|
+
* the messages table exists. Throws if conduit.db is missing
|
|
246
|
+
* or uninitialized (run `cleo init` first).
|
|
247
|
+
*
|
|
248
|
+
* @task T356
|
|
249
|
+
* @epic T310
|
|
250
|
+
*/
|
|
251
|
+
async connect(config) {
|
|
252
|
+
const dbPath = getConduitDbPath(process.cwd());
|
|
253
|
+
if (!existsSync(dbPath)) {
|
|
254
|
+
throw new Error(`LocalTransport: conduit.db not found at ${dbPath}. Run: cleo init`);
|
|
255
|
+
}
|
|
256
|
+
const db = new DatabaseSyncClass(dbPath);
|
|
257
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
258
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
259
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
260
|
+
const hasMessages = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'").get();
|
|
261
|
+
if (!hasMessages) {
|
|
262
|
+
db.close();
|
|
263
|
+
throw new Error(
|
|
264
|
+
"LocalTransport: conduit.db exists but messages table missing \u2014 run cleo init or allow auto-migration (T358)"
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
this.state = {
|
|
268
|
+
agentId: config.agentId,
|
|
269
|
+
db,
|
|
270
|
+
dbPath,
|
|
271
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
272
|
+
pollTimer: null
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** Close the database connection and stop any subscriber polling. */
|
|
276
|
+
async disconnect() {
|
|
277
|
+
if (!this.state) return;
|
|
278
|
+
if (this.state.pollTimer) {
|
|
279
|
+
clearInterval(this.state.pollTimer);
|
|
280
|
+
}
|
|
281
|
+
this.state.subscribers.clear();
|
|
282
|
+
this.state.db.close();
|
|
283
|
+
this.state = null;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Store a message in conduit.db.
|
|
287
|
+
*
|
|
288
|
+
* Inserts into the messages table with status 'pending'.
|
|
289
|
+
* For conversation messages, also links via conversation_participants
|
|
290
|
+
* if not already present.
|
|
291
|
+
*/
|
|
292
|
+
async push(to, content, options) {
|
|
293
|
+
this.ensureConnected();
|
|
294
|
+
const { db, agentId } = this.state;
|
|
295
|
+
const messageId = randomUUID();
|
|
296
|
+
const nowUnix = Math.floor(Date.now() / 1e3);
|
|
297
|
+
if (options?.conversationId) {
|
|
298
|
+
db.prepare(
|
|
299
|
+
`INSERT INTO messages (id, conversation_id, from_agent_id, to_agent_id, content, content_type, status, created_at)
|
|
300
|
+
VALUES (?, ?, ?, ?, ?, 'text', 'pending', ?)`
|
|
301
|
+
).run(messageId, options.conversationId, agentId, to, content, nowUnix);
|
|
302
|
+
} else {
|
|
303
|
+
const convId = this.ensureDmConversation(agentId, to);
|
|
304
|
+
db.prepare(
|
|
305
|
+
`INSERT INTO messages (id, conversation_id, from_agent_id, to_agent_id, content, content_type, status, created_at)
|
|
306
|
+
VALUES (?, ?, ?, ?, ?, 'text', 'pending', ?)`
|
|
307
|
+
).run(messageId, convId, agentId, to, content, nowUnix);
|
|
308
|
+
}
|
|
309
|
+
this.notifySubscribers({
|
|
310
|
+
id: messageId,
|
|
311
|
+
from: agentId,
|
|
312
|
+
content,
|
|
313
|
+
threadId: options?.conversationId,
|
|
314
|
+
timestamp: new Date(nowUnix * 1e3).toISOString()
|
|
315
|
+
});
|
|
316
|
+
return { messageId };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Poll for messages addressed to this agent.
|
|
320
|
+
*
|
|
321
|
+
* Returns messages with status 'pending' where to_agent_id matches
|
|
322
|
+
* the connected agent. Messages are returned oldest-first.
|
|
323
|
+
*/
|
|
324
|
+
async poll(options) {
|
|
325
|
+
this.ensureConnected();
|
|
326
|
+
const { db, agentId } = this.state;
|
|
327
|
+
const limit = options?.limit ?? 50;
|
|
328
|
+
let query;
|
|
329
|
+
let params;
|
|
330
|
+
if (options?.since) {
|
|
331
|
+
query = `SELECT id, from_agent_id, content, conversation_id, created_at
|
|
332
|
+
FROM messages
|
|
333
|
+
WHERE to_agent_id = ? AND status = 'pending' AND created_at > ?
|
|
334
|
+
ORDER BY created_at ASC
|
|
335
|
+
LIMIT ?`;
|
|
336
|
+
params = [agentId, options.since, limit];
|
|
337
|
+
} else {
|
|
338
|
+
query = `SELECT id, from_agent_id, content, conversation_id, created_at
|
|
339
|
+
FROM messages
|
|
340
|
+
WHERE to_agent_id = ? AND status = 'pending'
|
|
341
|
+
ORDER BY created_at ASC
|
|
342
|
+
LIMIT ?`;
|
|
343
|
+
params = [agentId, limit];
|
|
344
|
+
}
|
|
345
|
+
const rows = db.prepare(query).all(...params);
|
|
346
|
+
return rows.map((r) => ({
|
|
347
|
+
id: r.id,
|
|
348
|
+
from: r.from_agent_id,
|
|
349
|
+
content: r.content,
|
|
350
|
+
threadId: r.conversation_id ?? void 0,
|
|
351
|
+
timestamp: new Date(r.created_at * 1e3).toISOString()
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Acknowledge messages by marking them as 'delivered'.
|
|
356
|
+
*
|
|
357
|
+
* Updates the status and delivered_at timestamp for each message ID.
|
|
358
|
+
*/
|
|
359
|
+
async ack(messageIds) {
|
|
360
|
+
this.ensureConnected();
|
|
361
|
+
if (messageIds.length === 0) return;
|
|
362
|
+
const { db } = this.state;
|
|
363
|
+
const nowUnix = Math.floor(Date.now() / 1e3);
|
|
364
|
+
const placeholders = messageIds.map(() => "?").join(", ");
|
|
365
|
+
db.prepare(
|
|
366
|
+
`UPDATE messages SET status = 'delivered', delivered_at = ? WHERE id IN (${placeholders})`
|
|
367
|
+
).run(nowUnix, ...messageIds);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Subscribe to real-time local messages.
|
|
371
|
+
*
|
|
372
|
+
* Since this is in-process, subscribers are notified synchronously
|
|
373
|
+
* when push() is called. Additionally, a polling interval checks
|
|
374
|
+
* for messages inserted by other processes (e.g., Rust CLI).
|
|
375
|
+
*
|
|
376
|
+
* @returns Unsubscribe function.
|
|
377
|
+
*/
|
|
378
|
+
subscribe(handler) {
|
|
379
|
+
this.ensureConnected();
|
|
380
|
+
this.state.subscribers.add(handler);
|
|
381
|
+
if (!this.state.pollTimer && this.state.subscribers.size === 1) {
|
|
382
|
+
this.state.pollTimer = setInterval(() => {
|
|
383
|
+
void this.pollAndNotify();
|
|
384
|
+
}, 1e3);
|
|
385
|
+
}
|
|
386
|
+
return () => {
|
|
387
|
+
this.state?.subscribers.delete(handler);
|
|
388
|
+
if (this.state?.subscribers.size === 0 && this.state.pollTimer) {
|
|
389
|
+
clearInterval(this.state.pollTimer);
|
|
390
|
+
this.state.pollTimer = null;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Check whether conduit.db is available for local transport.
|
|
396
|
+
*
|
|
397
|
+
* Used by factory.ts to decide whether to use LocalTransport.
|
|
398
|
+
*
|
|
399
|
+
* @task T356
|
|
400
|
+
* @epic T310
|
|
401
|
+
* @param cwd - Optional working directory override (defaults to process.cwd()).
|
|
402
|
+
* @returns `true` if conduit.db exists at the expected path.
|
|
403
|
+
*/
|
|
404
|
+
static isAvailable(cwd) {
|
|
405
|
+
const dbPath = getConduitDbPath(cwd ?? process.cwd());
|
|
406
|
+
return existsSync(dbPath);
|
|
407
|
+
}
|
|
408
|
+
/** Poll for new messages and notify subscribers (cross-process sync). */
|
|
409
|
+
async pollAndNotify() {
|
|
410
|
+
if (!this.state || this.state.subscribers.size === 0) return;
|
|
411
|
+
const messages = await this.poll({ limit: 20 });
|
|
412
|
+
for (const msg of messages) {
|
|
413
|
+
this.notifySubscribers(msg);
|
|
414
|
+
}
|
|
415
|
+
if (messages.length > 0) {
|
|
416
|
+
await this.ack(messages.map((m) => m.id));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** Notify all active subscribers of a new message. */
|
|
420
|
+
notifySubscribers(message) {
|
|
421
|
+
if (!this.state) return;
|
|
422
|
+
for (const handler of this.state.subscribers) {
|
|
423
|
+
try {
|
|
424
|
+
handler(message);
|
|
425
|
+
} catch {
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Ensure a DM conversation exists between two agents.
|
|
431
|
+
*
|
|
432
|
+
* Conversations store participants as a comma-separated TEXT field.
|
|
433
|
+
* We search for existing private conversations containing both agents.
|
|
434
|
+
*
|
|
435
|
+
* @returns The conversation ID.
|
|
436
|
+
*/
|
|
437
|
+
ensureDmConversation(fromAgentId, toAgentId) {
|
|
438
|
+
const { db } = this.state;
|
|
439
|
+
const sortedParticipants = [fromAgentId, toAgentId].sort().join(",");
|
|
440
|
+
const existing = db.prepare(
|
|
441
|
+
`SELECT id FROM conversations
|
|
442
|
+
WHERE visibility = 'private' AND participants = ?
|
|
443
|
+
LIMIT 1`
|
|
444
|
+
).get(sortedParticipants);
|
|
445
|
+
if (existing) return existing.id;
|
|
446
|
+
const convId = randomUUID();
|
|
447
|
+
const nowUnix = Math.floor(Date.now() / 1e3);
|
|
448
|
+
db.prepare(
|
|
449
|
+
`INSERT INTO conversations (id, participants, visibility, message_count, created_at, updated_at)
|
|
450
|
+
VALUES (?, ?, 'private', 0, ?, ?)`
|
|
451
|
+
).run(convId, sortedParticipants, nowUnix, nowUnix);
|
|
452
|
+
return convId;
|
|
453
|
+
}
|
|
454
|
+
/** Throw if not connected. */
|
|
455
|
+
ensureConnected() {
|
|
456
|
+
if (!this.state) {
|
|
457
|
+
throw new Error("LocalTransport not connected. Call connect() first.");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// packages/core/src/conduit/sse-transport.ts
|
|
463
|
+
var MAX_RECONNECT_ATTEMPTS = 3;
|
|
464
|
+
var MAX_RECONNECT_DELAY_MS = 3e4;
|
|
465
|
+
var SseTransport = class {
|
|
466
|
+
name = "sse";
|
|
467
|
+
state = null;
|
|
468
|
+
/**
|
|
469
|
+
* Connect to the SSE endpoint for real-time message delivery.
|
|
470
|
+
*
|
|
471
|
+
* If SSE connection fails, falls back to HTTP polling mode.
|
|
472
|
+
* Auth is conveyed via query parameter (SSE doesn't support custom headers).
|
|
473
|
+
*/
|
|
474
|
+
async connect(config) {
|
|
475
|
+
if (this.state?.connected) {
|
|
476
|
+
throw new Error("SseTransport already connected. Disconnect first.");
|
|
477
|
+
}
|
|
478
|
+
const sseEndpoint = config.sseEndpoint;
|
|
479
|
+
if (!sseEndpoint && !config.apiBaseUrl) {
|
|
480
|
+
throw new Error("SseTransport requires sseEndpoint or apiBaseUrl in config.");
|
|
481
|
+
}
|
|
482
|
+
const endpoint = sseEndpoint ?? `${config.apiBaseUrl}/messages/stream`;
|
|
483
|
+
this.state = {
|
|
484
|
+
agentId: config.agentId,
|
|
485
|
+
apiKey: config.apiKey,
|
|
486
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
487
|
+
sseEndpoint: endpoint,
|
|
488
|
+
eventSource: null,
|
|
489
|
+
mode: "sse",
|
|
490
|
+
messageBuffer: [],
|
|
491
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
492
|
+
reconnectAttempts: 0,
|
|
493
|
+
reconnectTimer: null,
|
|
494
|
+
connected: false
|
|
495
|
+
};
|
|
496
|
+
try {
|
|
497
|
+
await this.connectSse();
|
|
498
|
+
} catch {
|
|
499
|
+
this.state.mode = "http-fallback";
|
|
500
|
+
this.state.connected = true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** Disconnect the transport, closing SSE and clearing all state. */
|
|
504
|
+
async disconnect() {
|
|
505
|
+
if (!this.state) return;
|
|
506
|
+
if (this.state.eventSource) {
|
|
507
|
+
this.state.eventSource.close();
|
|
508
|
+
}
|
|
509
|
+
if (this.state.reconnectTimer) {
|
|
510
|
+
clearTimeout(this.state.reconnectTimer);
|
|
511
|
+
}
|
|
512
|
+
this.state.messageBuffer = [];
|
|
513
|
+
this.state.connected = false;
|
|
514
|
+
this.state = null;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Send a message via HTTP POST.
|
|
518
|
+
*
|
|
519
|
+
* SSE is receive-only — all sends go through HTTP regardless of SSE state.
|
|
520
|
+
*/
|
|
521
|
+
async push(to, content, options) {
|
|
522
|
+
this.ensureConnected();
|
|
523
|
+
const body = { content, toAgentId: to };
|
|
524
|
+
let path = "/messages";
|
|
525
|
+
if (options?.conversationId) {
|
|
526
|
+
path = `/conversations/${options.conversationId}/messages`;
|
|
527
|
+
if (options.replyTo) {
|
|
528
|
+
body["replyTo"] = options.replyTo;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const response = await this.httpFetch(path, {
|
|
532
|
+
method: "POST",
|
|
533
|
+
body: JSON.stringify(body)
|
|
534
|
+
});
|
|
535
|
+
if (!response.ok) {
|
|
536
|
+
const text = await response.text().catch(() => "");
|
|
537
|
+
throw new Error(`SseTransport push failed: ${response.status} ${text}`);
|
|
538
|
+
}
|
|
539
|
+
const data = await response.json();
|
|
540
|
+
return { messageId: data.data?.message?.id ?? data.data?.id ?? "unknown" };
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Poll for messages.
|
|
544
|
+
*
|
|
545
|
+
* In SSE mode: drains the internal message buffer (no HTTP request).
|
|
546
|
+
* In HTTP fallback mode: fetches via GET /messages/peek.
|
|
547
|
+
*/
|
|
548
|
+
async poll(options) {
|
|
549
|
+
this.ensureConnected();
|
|
550
|
+
if (this.state.mode === "sse" && this.state.eventSource) {
|
|
551
|
+
let messages = this.state.messageBuffer.splice(0);
|
|
552
|
+
if (options?.since) {
|
|
553
|
+
messages = messages.filter((m) => m.timestamp > options.since);
|
|
554
|
+
}
|
|
555
|
+
if (options?.limit && messages.length > options.limit) {
|
|
556
|
+
const excess = messages.splice(options.limit);
|
|
557
|
+
this.state.messageBuffer.unshift(...excess);
|
|
558
|
+
}
|
|
559
|
+
return messages;
|
|
560
|
+
}
|
|
561
|
+
return this.httpPoll(options);
|
|
562
|
+
}
|
|
563
|
+
/** Acknowledge messages via HTTP POST. */
|
|
564
|
+
async ack(messageIds) {
|
|
565
|
+
this.ensureConnected();
|
|
566
|
+
if (messageIds.length === 0) return;
|
|
567
|
+
await this.httpFetch("/messages/ack", {
|
|
568
|
+
method: "POST",
|
|
569
|
+
body: JSON.stringify({ messageIds })
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Subscribe to real-time messages.
|
|
574
|
+
*
|
|
575
|
+
* In SSE mode, messages are pushed to the handler as they arrive.
|
|
576
|
+
* In HTTP fallback mode, polls on an interval.
|
|
577
|
+
*/
|
|
578
|
+
subscribe(handler) {
|
|
579
|
+
this.ensureConnected();
|
|
580
|
+
this.state.subscribers.add(handler);
|
|
581
|
+
const interval = this.state.mode === "http-fallback" ? setInterval(async () => {
|
|
582
|
+
if (this.state?.mode === "http-fallback") {
|
|
583
|
+
const messages = await this.httpPoll({ limit: 20 });
|
|
584
|
+
for (const msg of messages) handler(msg);
|
|
585
|
+
if (messages.length > 0) {
|
|
586
|
+
await this.ack(messages.map((m) => m.id));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}, 5e3) : null;
|
|
590
|
+
return () => {
|
|
591
|
+
if (interval) clearInterval(interval);
|
|
592
|
+
if (this.state) {
|
|
593
|
+
this.state.subscribers.delete(handler);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
// --------------------------------------------------------------------------
|
|
598
|
+
// SSE connection management
|
|
599
|
+
// --------------------------------------------------------------------------
|
|
600
|
+
/** Establish SSE connection. Resolves when open, rejects on error. */
|
|
601
|
+
connectSse() {
|
|
602
|
+
return new Promise((resolve, reject) => {
|
|
603
|
+
if (!this.state) {
|
|
604
|
+
reject(new Error("No state"));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const url = `${this.state.sseEndpoint}?token=${encodeURIComponent(this.state.apiKey)}&agent_id=${encodeURIComponent(this.state.agentId)}`;
|
|
608
|
+
const es = new EventSource(url);
|
|
609
|
+
this.state.eventSource = es;
|
|
610
|
+
const timeout = setTimeout(() => {
|
|
611
|
+
es.close();
|
|
612
|
+
reject(new Error("SSE connection timeout"));
|
|
613
|
+
}, 1e4);
|
|
614
|
+
es.addEventListener("open", () => {
|
|
615
|
+
clearTimeout(timeout);
|
|
616
|
+
this.state.connected = true;
|
|
617
|
+
this.state.reconnectAttempts = 0;
|
|
618
|
+
resolve();
|
|
619
|
+
});
|
|
620
|
+
es.addEventListener("message", (event) => {
|
|
621
|
+
this.handleSseMessage(event);
|
|
622
|
+
});
|
|
623
|
+
es.addEventListener("error", () => {
|
|
624
|
+
clearTimeout(timeout);
|
|
625
|
+
if (!this.state.connected) {
|
|
626
|
+
es.close();
|
|
627
|
+
reject(new Error("SSE connection failed"));
|
|
628
|
+
} else {
|
|
629
|
+
this.handleSseDisconnect();
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/** Handle an incoming SSE message event. */
|
|
635
|
+
handleSseMessage(event) {
|
|
636
|
+
if (!this.state) return;
|
|
637
|
+
try {
|
|
638
|
+
const data = JSON.parse(event.data);
|
|
639
|
+
if (data.type === "heartbeat" || data.type === "ping") return;
|
|
640
|
+
const from = data.from_agent_id ?? data.from ?? "unknown";
|
|
641
|
+
if (from === this.state.agentId) return;
|
|
642
|
+
const message = {
|
|
643
|
+
id: data.id ?? `sse-${Date.now()}`,
|
|
644
|
+
from,
|
|
645
|
+
content: data.content ?? "",
|
|
646
|
+
threadId: data.conversation_id ?? data.threadId,
|
|
647
|
+
timestamp: data.created_at ?? data.timestamp ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
648
|
+
};
|
|
649
|
+
this.state.messageBuffer.push(message);
|
|
650
|
+
for (const h of this.state.subscribers) {
|
|
651
|
+
try {
|
|
652
|
+
h(message);
|
|
653
|
+
} catch {
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
} catch {
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/** Handle SSE connection drop — attempt reconnect with backoff. */
|
|
660
|
+
handleSseDisconnect() {
|
|
661
|
+
if (!this.state) return;
|
|
662
|
+
this.state.eventSource?.close();
|
|
663
|
+
this.state.eventSource = null;
|
|
664
|
+
this.state.reconnectAttempts++;
|
|
665
|
+
if (this.state.reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
|
666
|
+
this.state.mode = "http-fallback";
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const delay = Math.min(1e3 * 2 ** (this.state.reconnectAttempts - 1), MAX_RECONNECT_DELAY_MS);
|
|
670
|
+
this.state.reconnectTimer = setTimeout(() => {
|
|
671
|
+
void this.connectSse().catch(() => {
|
|
672
|
+
this.handleSseDisconnect();
|
|
673
|
+
});
|
|
674
|
+
}, delay);
|
|
675
|
+
}
|
|
676
|
+
// --------------------------------------------------------------------------
|
|
677
|
+
// HTTP helpers
|
|
678
|
+
// --------------------------------------------------------------------------
|
|
679
|
+
/** HTTP poll for messages (used in fallback mode). */
|
|
680
|
+
async httpPoll(options) {
|
|
681
|
+
const params = new URLSearchParams();
|
|
682
|
+
params.set("mentioned", this.state.agentId);
|
|
683
|
+
if (options?.limit) params.set("limit", String(options.limit));
|
|
684
|
+
if (options?.since) params.set("since", options.since);
|
|
685
|
+
const response = await this.httpFetch(`/messages/peek?${params}`, { method: "GET" });
|
|
686
|
+
if (!response.ok) return [];
|
|
687
|
+
const data = await response.json();
|
|
688
|
+
return (data.data?.messages ?? []).map((m) => ({
|
|
689
|
+
id: m.id,
|
|
690
|
+
from: m.fromAgentId ?? "unknown",
|
|
691
|
+
content: m.content ?? "",
|
|
692
|
+
threadId: m.conversationId,
|
|
693
|
+
timestamp: m.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
/** Make an authenticated HTTP request to the API. */
|
|
697
|
+
async httpFetch(path, init) {
|
|
698
|
+
const url = `${this.state.apiBaseUrl}${path}`;
|
|
699
|
+
return fetch(url, {
|
|
700
|
+
...init,
|
|
701
|
+
headers: {
|
|
702
|
+
"Content-Type": "application/json",
|
|
703
|
+
Authorization: `Bearer ${this.state.apiKey}`,
|
|
704
|
+
"X-Agent-Id": this.state.agentId,
|
|
705
|
+
...init.headers
|
|
706
|
+
},
|
|
707
|
+
signal: init.signal ?? AbortSignal.timeout(1e4)
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
/** Throw if not connected. */
|
|
711
|
+
ensureConnected() {
|
|
712
|
+
if (!this.state?.connected) {
|
|
713
|
+
throw new Error("SseTransport not connected. Call connect() first.");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// packages/core/src/conduit/factory.ts
|
|
719
|
+
function resolveTransport(credential) {
|
|
720
|
+
if (LocalTransport.isAvailable()) {
|
|
721
|
+
return new LocalTransport();
|
|
722
|
+
}
|
|
723
|
+
const isCloudBacked = credential.apiBaseUrl && credential.apiBaseUrl !== "local" && credential.apiBaseUrl.startsWith("http");
|
|
724
|
+
if (isCloudBacked && credential.transportConfig.sseEndpoint) {
|
|
725
|
+
return new SseTransport();
|
|
726
|
+
}
|
|
727
|
+
return new HttpTransport();
|
|
728
|
+
}
|
|
729
|
+
async function createConduit(registry, agentId) {
|
|
730
|
+
const credential = agentId ? await registry.get(agentId) : await registry.getActive();
|
|
731
|
+
if (!credential) {
|
|
732
|
+
throw new Error(
|
|
733
|
+
"No agent credential found. Run: cleo agent register --id <id> --api-key <key>"
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
const transport = resolveTransport(credential);
|
|
737
|
+
const conduit = new ConduitClient(transport, credential);
|
|
738
|
+
await conduit.connect();
|
|
739
|
+
return conduit;
|
|
740
|
+
}
|
|
741
|
+
export {
|
|
742
|
+
ConduitClient,
|
|
743
|
+
HttpTransport,
|
|
744
|
+
LocalTransport,
|
|
745
|
+
SseTransport,
|
|
746
|
+
createConduit,
|
|
747
|
+
resolveTransport
|
|
748
|
+
};
|
|
749
|
+
//# sourceMappingURL=index.js.map
|