@pingagent/sdk 0.1.8 → 0.1.10
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/bin/pingagent.js +854 -3
- package/dist/chunk-2Y6YRKTO.js +3100 -0
- package/dist/chunk-3OEFISNL.js +2433 -0
- package/dist/chunk-5Z6HZWDA.js +2603 -0
- package/dist/chunk-BSDY6AKB.js +2918 -0
- package/dist/chunk-PFABO4C7.js +2961 -0
- package/dist/chunk-QK2GMSWC.js +2959 -0
- package/dist/chunk-RMIRCSQ6.js +3042 -0
- package/dist/chunk-TCYDOFRQ.js +2085 -0
- package/dist/chunk-V7HHUQT6.js +1962 -0
- package/dist/index.d.ts +439 -5
- package/dist/index.js +65 -3
- package/dist/web-server.js +1323 -16
- package/package.json +11 -3
- package/__tests__/cli.test.ts +0 -225
- package/__tests__/identity.test.ts +0 -47
- package/__tests__/store.test.ts +0 -332
- package/src/a2a-adapter.ts +0 -159
- package/src/auth.ts +0 -50
- package/src/client.ts +0 -582
- package/src/contacts.ts +0 -210
- package/src/history.ts +0 -269
- package/src/identity.ts +0 -86
- package/src/index.ts +0 -25
- package/src/paths.ts +0 -52
- package/src/store.ts +0 -62
- package/src/transport.ts +0 -141
- package/src/web-server.ts +0 -1148
- package/src/ws-subscription.ts +0 -428
- package/tsconfig.json +0 -8
|
@@ -0,0 +1,1962 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { SCHEMA_TASK, SCHEMA_CONTACT_REQUEST, SCHEMA_RESULT, SCHEMA_TEXT } from "@pingagent/schemas";
|
|
3
|
+
import {
|
|
4
|
+
buildUnsignedEnvelope,
|
|
5
|
+
signEnvelope
|
|
6
|
+
} from "@pingagent/protocol";
|
|
7
|
+
|
|
8
|
+
// src/transport.ts
|
|
9
|
+
import { ErrorCode } from "@pingagent/schemas";
|
|
10
|
+
var HttpTransport = class {
|
|
11
|
+
serverUrl;
|
|
12
|
+
accessToken;
|
|
13
|
+
maxRetries;
|
|
14
|
+
onTokenRefreshed;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.serverUrl = opts.serverUrl.replace(/\/$/, "");
|
|
17
|
+
this.accessToken = opts.accessToken;
|
|
18
|
+
this.maxRetries = opts.maxRetries ?? 3;
|
|
19
|
+
this.onTokenRefreshed = opts.onTokenRefreshed;
|
|
20
|
+
}
|
|
21
|
+
setToken(token) {
|
|
22
|
+
this.accessToken = token;
|
|
23
|
+
}
|
|
24
|
+
/** Fetch a URL with Bearer auth (e.g. artifact upload/download). */
|
|
25
|
+
async fetchWithAuth(url, options = {}) {
|
|
26
|
+
const headers = { Authorization: `Bearer ${this.accessToken}` };
|
|
27
|
+
if (options.contentType) headers["Content-Type"] = options.contentType;
|
|
28
|
+
const res = await fetch(url, {
|
|
29
|
+
method: options.method ?? "GET",
|
|
30
|
+
headers,
|
|
31
|
+
body: options.body ?? void 0
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
throw new Error(`Request failed (${res.status}): ${text.slice(0, 200)}`);
|
|
36
|
+
}
|
|
37
|
+
return res.arrayBuffer();
|
|
38
|
+
}
|
|
39
|
+
async request(method, path4, body, skipAuth = false) {
|
|
40
|
+
const url = `${this.serverUrl}${path4}`;
|
|
41
|
+
const headers = {
|
|
42
|
+
"Content-Type": "application/json"
|
|
43
|
+
};
|
|
44
|
+
if (!skipAuth) {
|
|
45
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
46
|
+
}
|
|
47
|
+
let lastError = null;
|
|
48
|
+
const delays = [1e3, 2e3, 4e3];
|
|
49
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(url, {
|
|
52
|
+
method,
|
|
53
|
+
headers,
|
|
54
|
+
body: body ? JSON.stringify(body) : void 0
|
|
55
|
+
});
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
let data;
|
|
58
|
+
try {
|
|
59
|
+
data = text ? JSON.parse(text) : {};
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`Server returned non-JSON (${res.status}): ${text.slice(0, 200)}`);
|
|
62
|
+
}
|
|
63
|
+
if (res.status === 401 && data.error?.code === ErrorCode.TOKEN_EXPIRED && attempt === 0) {
|
|
64
|
+
const refreshed = await this.refreshToken();
|
|
65
|
+
if (refreshed) {
|
|
66
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (res.status === 429 && data.error?.retry_after_ms) {
|
|
71
|
+
await sleep(data.error.retry_after_ms);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (res.status >= 500 && attempt < this.maxRetries) {
|
|
75
|
+
await sleep(delays[attempt] ?? 4e3);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
return data;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
lastError = e;
|
|
81
|
+
if (attempt < this.maxRetries) {
|
|
82
|
+
await sleep(delays[attempt] ?? 4e3);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw lastError ?? new Error("Request failed after retries");
|
|
87
|
+
}
|
|
88
|
+
async refreshToken() {
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(`${this.serverUrl}/v1/auth/refresh`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify({ access_token: this.accessToken })
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) return false;
|
|
96
|
+
const text = await res.text();
|
|
97
|
+
let data;
|
|
98
|
+
try {
|
|
99
|
+
data = text ? JSON.parse(text) : {};
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (!data.ok || !data.data) return false;
|
|
104
|
+
this.accessToken = data.data.access_token;
|
|
105
|
+
const expiresAt = Date.now() + data.data.expires_ms;
|
|
106
|
+
this.onTokenRefreshed?.(data.data.access_token, expiresAt);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
function sleep(ms) {
|
|
114
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/contacts.ts
|
|
118
|
+
function rowToContact(row) {
|
|
119
|
+
return {
|
|
120
|
+
did: row.did,
|
|
121
|
+
alias: row.alias ?? void 0,
|
|
122
|
+
display_name: row.display_name ?? void 0,
|
|
123
|
+
notes: row.notes ?? void 0,
|
|
124
|
+
conversation_id: row.conversation_id ?? void 0,
|
|
125
|
+
trusted: row.trusted === 1,
|
|
126
|
+
added_at: row.added_at,
|
|
127
|
+
last_message_at: row.last_message_at ?? void 0,
|
|
128
|
+
tags: row.tags ? JSON.parse(row.tags) : void 0
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
var ContactManager = class {
|
|
132
|
+
store;
|
|
133
|
+
constructor(store) {
|
|
134
|
+
this.store = store;
|
|
135
|
+
}
|
|
136
|
+
add(contact) {
|
|
137
|
+
const db = this.store.getDb();
|
|
138
|
+
const now = contact.added_at ?? Date.now();
|
|
139
|
+
db.prepare(`
|
|
140
|
+
INSERT INTO contacts (did, alias, display_name, notes, conversation_id, trusted, added_at, last_message_at, tags)
|
|
141
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
142
|
+
ON CONFLICT(did) DO UPDATE SET
|
|
143
|
+
alias = COALESCE(excluded.alias, contacts.alias),
|
|
144
|
+
display_name = COALESCE(excluded.display_name, contacts.display_name),
|
|
145
|
+
notes = COALESCE(excluded.notes, contacts.notes),
|
|
146
|
+
conversation_id = COALESCE(excluded.conversation_id, contacts.conversation_id),
|
|
147
|
+
trusted = excluded.trusted,
|
|
148
|
+
last_message_at = COALESCE(excluded.last_message_at, contacts.last_message_at),
|
|
149
|
+
tags = COALESCE(excluded.tags, contacts.tags)
|
|
150
|
+
`).run(
|
|
151
|
+
contact.did,
|
|
152
|
+
contact.alias ?? null,
|
|
153
|
+
contact.display_name ?? null,
|
|
154
|
+
contact.notes ?? null,
|
|
155
|
+
contact.conversation_id ?? null,
|
|
156
|
+
contact.trusted ? 1 : 0,
|
|
157
|
+
now,
|
|
158
|
+
contact.last_message_at ?? null,
|
|
159
|
+
contact.tags ? JSON.stringify(contact.tags) : null
|
|
160
|
+
);
|
|
161
|
+
return { ...contact, added_at: now };
|
|
162
|
+
}
|
|
163
|
+
remove(did) {
|
|
164
|
+
const result = this.store.getDb().prepare("DELETE FROM contacts WHERE did = ?").run(did);
|
|
165
|
+
return result.changes > 0;
|
|
166
|
+
}
|
|
167
|
+
get(did) {
|
|
168
|
+
const row = this.store.getDb().prepare("SELECT * FROM contacts WHERE did = ?").get(did);
|
|
169
|
+
return row ? rowToContact(row) : null;
|
|
170
|
+
}
|
|
171
|
+
update(did, updates) {
|
|
172
|
+
const existing = this.get(did);
|
|
173
|
+
if (!existing) return null;
|
|
174
|
+
const fields = [];
|
|
175
|
+
const values = [];
|
|
176
|
+
if (updates.alias !== void 0) {
|
|
177
|
+
fields.push("alias = ?");
|
|
178
|
+
values.push(updates.alias);
|
|
179
|
+
}
|
|
180
|
+
if (updates.display_name !== void 0) {
|
|
181
|
+
fields.push("display_name = ?");
|
|
182
|
+
values.push(updates.display_name);
|
|
183
|
+
}
|
|
184
|
+
if (updates.notes !== void 0) {
|
|
185
|
+
fields.push("notes = ?");
|
|
186
|
+
values.push(updates.notes);
|
|
187
|
+
}
|
|
188
|
+
if (updates.conversation_id !== void 0) {
|
|
189
|
+
fields.push("conversation_id = ?");
|
|
190
|
+
values.push(updates.conversation_id);
|
|
191
|
+
}
|
|
192
|
+
if (updates.trusted !== void 0) {
|
|
193
|
+
fields.push("trusted = ?");
|
|
194
|
+
values.push(updates.trusted ? 1 : 0);
|
|
195
|
+
}
|
|
196
|
+
if (updates.last_message_at !== void 0) {
|
|
197
|
+
fields.push("last_message_at = ?");
|
|
198
|
+
values.push(updates.last_message_at);
|
|
199
|
+
}
|
|
200
|
+
if (updates.tags !== void 0) {
|
|
201
|
+
fields.push("tags = ?");
|
|
202
|
+
values.push(JSON.stringify(updates.tags));
|
|
203
|
+
}
|
|
204
|
+
if (fields.length === 0) return existing;
|
|
205
|
+
values.push(did);
|
|
206
|
+
this.store.getDb().prepare(`UPDATE contacts SET ${fields.join(", ")} WHERE did = ?`).run(...values);
|
|
207
|
+
return this.get(did);
|
|
208
|
+
}
|
|
209
|
+
list(opts) {
|
|
210
|
+
const conditions = [];
|
|
211
|
+
const params = [];
|
|
212
|
+
if (opts?.trusted !== void 0) {
|
|
213
|
+
conditions.push("trusted = ?");
|
|
214
|
+
params.push(opts.trusted ? 1 : 0);
|
|
215
|
+
}
|
|
216
|
+
if (opts?.tag) {
|
|
217
|
+
conditions.push("tags LIKE ?");
|
|
218
|
+
params.push(`%"${opts.tag}"%`);
|
|
219
|
+
}
|
|
220
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
221
|
+
const limit = opts?.limit ? `LIMIT ${opts.limit}` : "";
|
|
222
|
+
const offset = opts?.offset ? `OFFSET ${opts.offset}` : "";
|
|
223
|
+
const rows = this.store.getDb().prepare(`SELECT * FROM contacts ${where} ORDER BY added_at DESC ${limit} ${offset}`).all(...params);
|
|
224
|
+
return rows.map(rowToContact);
|
|
225
|
+
}
|
|
226
|
+
search(query) {
|
|
227
|
+
const pattern = `%${query}%`;
|
|
228
|
+
const rows = this.store.getDb().prepare(`
|
|
229
|
+
SELECT * FROM contacts
|
|
230
|
+
WHERE did LIKE ? OR alias LIKE ? OR display_name LIKE ? OR notes LIKE ?
|
|
231
|
+
ORDER BY added_at DESC
|
|
232
|
+
`).all(pattern, pattern, pattern, pattern);
|
|
233
|
+
return rows.map(rowToContact);
|
|
234
|
+
}
|
|
235
|
+
export(format = "json") {
|
|
236
|
+
const contacts = this.list();
|
|
237
|
+
if (format === "csv") {
|
|
238
|
+
const header = "did,alias,display_name,notes,conversation_id,trusted,added_at,last_message_at,tags";
|
|
239
|
+
const rows = contacts.map(
|
|
240
|
+
(c) => [
|
|
241
|
+
c.did,
|
|
242
|
+
c.alias ?? "",
|
|
243
|
+
c.display_name ?? "",
|
|
244
|
+
c.notes ?? "",
|
|
245
|
+
c.conversation_id ?? "",
|
|
246
|
+
c.trusted,
|
|
247
|
+
c.added_at,
|
|
248
|
+
c.last_message_at ?? "",
|
|
249
|
+
c.tags ? JSON.stringify(c.tags) : ""
|
|
250
|
+
].map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")
|
|
251
|
+
);
|
|
252
|
+
return [header, ...rows].join("\n");
|
|
253
|
+
}
|
|
254
|
+
return JSON.stringify(contacts, null, 2);
|
|
255
|
+
}
|
|
256
|
+
import(data, format = "json") {
|
|
257
|
+
let imported = 0;
|
|
258
|
+
let skipped = 0;
|
|
259
|
+
if (format === "json") {
|
|
260
|
+
const contacts = JSON.parse(data);
|
|
261
|
+
for (const c of contacts) {
|
|
262
|
+
try {
|
|
263
|
+
this.add(c);
|
|
264
|
+
imported++;
|
|
265
|
+
} catch {
|
|
266
|
+
skipped++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
const lines = data.split("\n").filter((l) => l.trim());
|
|
271
|
+
for (let i = 1; i < lines.length; i++) {
|
|
272
|
+
try {
|
|
273
|
+
const cols = parseCsvLine(lines[i]);
|
|
274
|
+
this.add({
|
|
275
|
+
did: cols[0],
|
|
276
|
+
alias: cols[1] || void 0,
|
|
277
|
+
display_name: cols[2] || void 0,
|
|
278
|
+
notes: cols[3] || void 0,
|
|
279
|
+
conversation_id: cols[4] || void 0,
|
|
280
|
+
trusted: cols[5] === "1" || cols[5] === "true",
|
|
281
|
+
added_at: parseInt(cols[6]) || Date.now(),
|
|
282
|
+
last_message_at: cols[7] ? parseInt(cols[7]) : void 0,
|
|
283
|
+
tags: cols[8] ? JSON.parse(cols[8]) : void 0
|
|
284
|
+
});
|
|
285
|
+
imported++;
|
|
286
|
+
} catch {
|
|
287
|
+
skipped++;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { imported, skipped };
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
function parseCsvLine(line) {
|
|
295
|
+
const result = [];
|
|
296
|
+
let current = "";
|
|
297
|
+
let inQuotes = false;
|
|
298
|
+
for (let i = 0; i < line.length; i++) {
|
|
299
|
+
const ch = line[i];
|
|
300
|
+
if (inQuotes) {
|
|
301
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
302
|
+
current += '"';
|
|
303
|
+
i++;
|
|
304
|
+
} else if (ch === '"') {
|
|
305
|
+
inQuotes = false;
|
|
306
|
+
} else {
|
|
307
|
+
current += ch;
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
if (ch === '"') {
|
|
311
|
+
inQuotes = true;
|
|
312
|
+
} else if (ch === ",") {
|
|
313
|
+
result.push(current);
|
|
314
|
+
current = "";
|
|
315
|
+
} else {
|
|
316
|
+
current += ch;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
result.push(current);
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/history.ts
|
|
325
|
+
function rowToMessage(row) {
|
|
326
|
+
return {
|
|
327
|
+
conversation_id: row.conversation_id,
|
|
328
|
+
message_id: row.message_id,
|
|
329
|
+
seq: row.seq ?? void 0,
|
|
330
|
+
sender_did: row.sender_did,
|
|
331
|
+
schema: row.schema,
|
|
332
|
+
payload: JSON.parse(row.payload),
|
|
333
|
+
ts_ms: row.ts_ms,
|
|
334
|
+
direction: row.direction
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
var HistoryManager = class {
|
|
338
|
+
store;
|
|
339
|
+
constructor(store) {
|
|
340
|
+
this.store = store;
|
|
341
|
+
}
|
|
342
|
+
save(messages) {
|
|
343
|
+
const db = this.store.getDb();
|
|
344
|
+
const stmt = db.prepare(`
|
|
345
|
+
INSERT INTO messages (conversation_id, message_id, seq, sender_did, schema, payload, ts_ms, direction)
|
|
346
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
347
|
+
ON CONFLICT(message_id) DO UPDATE SET
|
|
348
|
+
seq = COALESCE(excluded.seq, messages.seq),
|
|
349
|
+
payload = excluded.payload,
|
|
350
|
+
ts_ms = excluded.ts_ms
|
|
351
|
+
`);
|
|
352
|
+
let count = 0;
|
|
353
|
+
const tx = db.transaction(() => {
|
|
354
|
+
for (const msg of messages) {
|
|
355
|
+
stmt.run(
|
|
356
|
+
msg.conversation_id,
|
|
357
|
+
msg.message_id,
|
|
358
|
+
msg.seq ?? null,
|
|
359
|
+
msg.sender_did,
|
|
360
|
+
msg.schema,
|
|
361
|
+
JSON.stringify(msg.payload),
|
|
362
|
+
msg.ts_ms,
|
|
363
|
+
msg.direction
|
|
364
|
+
);
|
|
365
|
+
count++;
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
tx();
|
|
369
|
+
return count;
|
|
370
|
+
}
|
|
371
|
+
list(conversationId, opts) {
|
|
372
|
+
const conditions = ["conversation_id = ?"];
|
|
373
|
+
const params = [conversationId];
|
|
374
|
+
if (opts?.beforeSeq !== void 0) {
|
|
375
|
+
conditions.push("seq < ?");
|
|
376
|
+
params.push(opts.beforeSeq);
|
|
377
|
+
}
|
|
378
|
+
if (opts?.afterSeq !== void 0) {
|
|
379
|
+
conditions.push("seq > ?");
|
|
380
|
+
params.push(opts.afterSeq);
|
|
381
|
+
}
|
|
382
|
+
if (opts?.beforeTsMs !== void 0) {
|
|
383
|
+
conditions.push("ts_ms < ?");
|
|
384
|
+
params.push(opts.beforeTsMs);
|
|
385
|
+
}
|
|
386
|
+
if (opts?.afterTsMs !== void 0) {
|
|
387
|
+
conditions.push("ts_ms > ?");
|
|
388
|
+
params.push(opts.afterTsMs);
|
|
389
|
+
}
|
|
390
|
+
const limit = opts?.limit ?? 100;
|
|
391
|
+
const rows = this.store.getDb().prepare(`SELECT * FROM messages WHERE ${conditions.join(" AND ")} ORDER BY COALESCE(seq, ts_ms) ASC LIMIT ?`).all(...params, limit);
|
|
392
|
+
return rows.map(rowToMessage);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* List the most recent N messages *before* a seq (paging older history).
|
|
396
|
+
* Only compares rows where seq is not null.
|
|
397
|
+
*/
|
|
398
|
+
listBeforeSeq(conversationId, beforeSeq, limit) {
|
|
399
|
+
const rows = this.store.getDb().prepare(
|
|
400
|
+
`SELECT * FROM messages
|
|
401
|
+
WHERE conversation_id = ? AND seq IS NOT NULL AND seq < ?
|
|
402
|
+
ORDER BY seq DESC
|
|
403
|
+
LIMIT ?`
|
|
404
|
+
).all(conversationId, beforeSeq, limit);
|
|
405
|
+
return rows.map(rowToMessage).reverse();
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* List the most recent N messages *before* a timestamp (paging older history).
|
|
409
|
+
*/
|
|
410
|
+
listBeforeTs(conversationId, beforeTsMs, limit) {
|
|
411
|
+
const rows = this.store.getDb().prepare(
|
|
412
|
+
`SELECT * FROM messages
|
|
413
|
+
WHERE conversation_id = ? AND ts_ms < ?
|
|
414
|
+
ORDER BY ts_ms DESC
|
|
415
|
+
LIMIT ?`
|
|
416
|
+
).all(conversationId, beforeTsMs, limit);
|
|
417
|
+
return rows.map(rowToMessage).reverse();
|
|
418
|
+
}
|
|
419
|
+
/** Returns the N most recent messages (chronological order, oldest to newest of those N). */
|
|
420
|
+
listRecent(conversationId, limit) {
|
|
421
|
+
const rows = this.store.getDb().prepare(
|
|
422
|
+
`SELECT * FROM messages WHERE conversation_id = ? ORDER BY COALESCE(seq, ts_ms) DESC LIMIT ?`
|
|
423
|
+
).all(conversationId, limit);
|
|
424
|
+
return rows.map(rowToMessage).reverse();
|
|
425
|
+
}
|
|
426
|
+
search(query, opts) {
|
|
427
|
+
const conditions = ["payload LIKE ?"];
|
|
428
|
+
const params = [`%${query}%`];
|
|
429
|
+
if (opts?.conversationId) {
|
|
430
|
+
conditions.push("conversation_id = ?");
|
|
431
|
+
params.push(opts.conversationId);
|
|
432
|
+
}
|
|
433
|
+
const limit = opts?.limit ?? 50;
|
|
434
|
+
const rows = this.store.getDb().prepare(`SELECT * FROM messages WHERE ${conditions.join(" AND ")} ORDER BY ts_ms DESC LIMIT ?`).all(...params, limit);
|
|
435
|
+
return rows.map(rowToMessage);
|
|
436
|
+
}
|
|
437
|
+
delete(conversationId) {
|
|
438
|
+
const result = this.store.getDb().prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversationId);
|
|
439
|
+
this.store.getDb().prepare("DELETE FROM sync_state WHERE conversation_id = ?").run(conversationId);
|
|
440
|
+
return result.changes;
|
|
441
|
+
}
|
|
442
|
+
listConversations() {
|
|
443
|
+
const rows = this.store.getDb().prepare(`
|
|
444
|
+
SELECT conversation_id, COUNT(*) as message_count, MAX(ts_ms) as last_message_at
|
|
445
|
+
FROM messages
|
|
446
|
+
GROUP BY conversation_id
|
|
447
|
+
ORDER BY last_message_at DESC
|
|
448
|
+
`).all();
|
|
449
|
+
return rows;
|
|
450
|
+
}
|
|
451
|
+
getLastSyncedSeq(conversationId) {
|
|
452
|
+
const row = this.store.getDb().prepare("SELECT last_synced_seq FROM sync_state WHERE conversation_id = ?").get(conversationId);
|
|
453
|
+
return row?.last_synced_seq ?? 0;
|
|
454
|
+
}
|
|
455
|
+
setLastSyncedSeq(conversationId, seq) {
|
|
456
|
+
this.store.getDb().prepare(`
|
|
457
|
+
INSERT INTO sync_state (conversation_id, last_synced_seq, last_synced_at)
|
|
458
|
+
VALUES (?, ?, ?)
|
|
459
|
+
ON CONFLICT(conversation_id) DO UPDATE SET last_synced_seq = excluded.last_synced_seq, last_synced_at = excluded.last_synced_at
|
|
460
|
+
`).run(conversationId, seq, Date.now());
|
|
461
|
+
}
|
|
462
|
+
export(opts) {
|
|
463
|
+
const format = opts?.format ?? "json";
|
|
464
|
+
let messages;
|
|
465
|
+
if (opts?.conversationId) {
|
|
466
|
+
messages = this.list(opts.conversationId, { limit: 1e5 });
|
|
467
|
+
} else {
|
|
468
|
+
const rows = this.store.getDb().prepare("SELECT * FROM messages ORDER BY ts_ms ASC").all();
|
|
469
|
+
messages = rows.map(rowToMessage);
|
|
470
|
+
}
|
|
471
|
+
if (format === "csv") {
|
|
472
|
+
const header = "conversation_id,message_id,seq,sender_did,schema,payload,ts_ms,direction";
|
|
473
|
+
const lines = messages.map(
|
|
474
|
+
(m) => [
|
|
475
|
+
m.conversation_id,
|
|
476
|
+
m.message_id,
|
|
477
|
+
m.seq ?? "",
|
|
478
|
+
m.sender_did,
|
|
479
|
+
m.schema,
|
|
480
|
+
JSON.stringify(m.payload),
|
|
481
|
+
m.ts_ms,
|
|
482
|
+
m.direction
|
|
483
|
+
].map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")
|
|
484
|
+
);
|
|
485
|
+
return [header, ...lines].join("\n");
|
|
486
|
+
}
|
|
487
|
+
return JSON.stringify(messages, null, 2);
|
|
488
|
+
}
|
|
489
|
+
async syncFromServer(client, conversationId, opts) {
|
|
490
|
+
const sinceSeq = opts?.full ? 0 : this.getLastSyncedSeq(conversationId);
|
|
491
|
+
let totalSynced = 0;
|
|
492
|
+
let currentSeq = sinceSeq;
|
|
493
|
+
while (true) {
|
|
494
|
+
const res = await client.fetchInbox(conversationId, {
|
|
495
|
+
sinceSeq: currentSeq,
|
|
496
|
+
limit: 50
|
|
497
|
+
});
|
|
498
|
+
if (!res.ok || !res.data || res.data.messages.length === 0) break;
|
|
499
|
+
const messages = res.data.messages.map((msg) => ({
|
|
500
|
+
conversation_id: conversationId,
|
|
501
|
+
message_id: msg.message_id,
|
|
502
|
+
seq: msg.seq,
|
|
503
|
+
sender_did: msg.sender_did,
|
|
504
|
+
schema: msg.schema,
|
|
505
|
+
payload: msg.payload,
|
|
506
|
+
ts_ms: msg.ts_ms,
|
|
507
|
+
direction: "received"
|
|
508
|
+
}));
|
|
509
|
+
this.save(messages);
|
|
510
|
+
totalSynced += messages.length;
|
|
511
|
+
currentSeq = res.data.next_since_seq;
|
|
512
|
+
this.setLastSyncedSeq(conversationId, currentSeq);
|
|
513
|
+
if (!res.data.has_more) break;
|
|
514
|
+
}
|
|
515
|
+
return { synced: totalSynced };
|
|
516
|
+
}
|
|
517
|
+
async listMergedRecent(client, conversationId, opts) {
|
|
518
|
+
const limit = opts?.limit ?? 50;
|
|
519
|
+
const box = opts?.box ?? "ready";
|
|
520
|
+
const local = this.listRecent(conversationId, limit * 2);
|
|
521
|
+
const remoteRes = await client.fetchInbox(conversationId, { sinceSeq: 0, limit, box: box === "all" ? "ready" : box });
|
|
522
|
+
const remote = remoteRes.ok && remoteRes.data ? remoteRes.data.messages.map((msg) => ({
|
|
523
|
+
conversation_id: conversationId,
|
|
524
|
+
message_id: msg.message_id,
|
|
525
|
+
seq: msg.seq,
|
|
526
|
+
sender_did: msg.sender_did,
|
|
527
|
+
schema: msg.schema,
|
|
528
|
+
payload: msg.payload,
|
|
529
|
+
ts_ms: msg.ts_ms,
|
|
530
|
+
direction: msg.sender_did === client.getDid() ? "sent" : "received"
|
|
531
|
+
})) : [];
|
|
532
|
+
const byId = /* @__PURE__ */ new Map();
|
|
533
|
+
for (const msg of [...local, ...remote]) byId.set(msg.message_id, msg);
|
|
534
|
+
return Array.from(byId.values()).sort((a, b) => (a.seq ?? a.ts_ms) - (b.seq ?? b.ts_ms)).slice(-limit);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// src/session.ts
|
|
539
|
+
function rowToSession(row) {
|
|
540
|
+
return {
|
|
541
|
+
session_key: row.session_key,
|
|
542
|
+
remote_did: row.remote_did ?? void 0,
|
|
543
|
+
conversation_id: row.conversation_id ?? void 0,
|
|
544
|
+
trust_state: row.trust_state || "stranger",
|
|
545
|
+
last_message_preview: row.last_message_preview ?? void 0,
|
|
546
|
+
last_remote_activity_at: row.last_remote_activity_at ?? void 0,
|
|
547
|
+
last_read_seq: row.last_read_seq ?? 0,
|
|
548
|
+
unread_count: row.unread_count ?? 0,
|
|
549
|
+
active: row.active === 1,
|
|
550
|
+
updated_at: row.updated_at ?? 0
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
function previewFromPayload(payload) {
|
|
554
|
+
if (payload == null) return "";
|
|
555
|
+
if (typeof payload === "string") return payload.slice(0, 280);
|
|
556
|
+
if (typeof payload !== "object") return String(payload).slice(0, 280);
|
|
557
|
+
const value = payload;
|
|
558
|
+
const preferred = value.text ?? value.title ?? value.summary ?? value.message ?? value.description ?? value.error_message;
|
|
559
|
+
if (typeof preferred === "string") return preferred.slice(0, 280);
|
|
560
|
+
return JSON.stringify(payload).slice(0, 280);
|
|
561
|
+
}
|
|
562
|
+
function buildSessionKey(opts) {
|
|
563
|
+
const type = opts.conversationType ?? "";
|
|
564
|
+
if (type === "channel" || type === "group" || opts.conversationId.startsWith("c_grp_")) {
|
|
565
|
+
return `hook:im:conv:${opts.conversationId}`;
|
|
566
|
+
}
|
|
567
|
+
const remote = (opts.remoteDid ?? "").trim();
|
|
568
|
+
if (!remote) {
|
|
569
|
+
return `hook:im:conv:${opts.conversationId}`;
|
|
570
|
+
}
|
|
571
|
+
const local = (opts.localDid ?? "").trim();
|
|
572
|
+
if (!local) {
|
|
573
|
+
return `hook:im:peer:${remote}:conv:${opts.conversationId}`;
|
|
574
|
+
}
|
|
575
|
+
return `hook:im:did:${local}:peer:${remote}`;
|
|
576
|
+
}
|
|
577
|
+
var SessionManager = class {
|
|
578
|
+
constructor(store) {
|
|
579
|
+
this.store = store;
|
|
580
|
+
}
|
|
581
|
+
upsert(state) {
|
|
582
|
+
const now = state.updated_at ?? Date.now();
|
|
583
|
+
const existing = this.get(state.session_key);
|
|
584
|
+
const next = {
|
|
585
|
+
session_key: state.session_key,
|
|
586
|
+
remote_did: state.remote_did ?? existing?.remote_did,
|
|
587
|
+
conversation_id: state.conversation_id ?? existing?.conversation_id,
|
|
588
|
+
trust_state: state.trust_state ?? existing?.trust_state ?? "stranger",
|
|
589
|
+
last_message_preview: state.last_message_preview ?? existing?.last_message_preview,
|
|
590
|
+
last_remote_activity_at: state.last_remote_activity_at ?? existing?.last_remote_activity_at,
|
|
591
|
+
last_read_seq: state.last_read_seq ?? existing?.last_read_seq ?? 0,
|
|
592
|
+
unread_count: state.unread_count ?? existing?.unread_count ?? 0,
|
|
593
|
+
active: state.active ?? existing?.active ?? false,
|
|
594
|
+
updated_at: now
|
|
595
|
+
};
|
|
596
|
+
if (next.active) {
|
|
597
|
+
this.store.getDb().prepare("UPDATE session_state SET active = 0 WHERE session_key != ?").run(next.session_key);
|
|
598
|
+
}
|
|
599
|
+
this.store.getDb().prepare(`
|
|
600
|
+
INSERT INTO session_state (
|
|
601
|
+
session_key, remote_did, conversation_id, trust_state, last_message_preview,
|
|
602
|
+
last_remote_activity_at, last_read_seq, unread_count, active, updated_at
|
|
603
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
604
|
+
ON CONFLICT(session_key) DO UPDATE SET
|
|
605
|
+
remote_did = COALESCE(excluded.remote_did, session_state.remote_did),
|
|
606
|
+
conversation_id = COALESCE(excluded.conversation_id, session_state.conversation_id),
|
|
607
|
+
trust_state = excluded.trust_state,
|
|
608
|
+
last_message_preview = COALESCE(excluded.last_message_preview, session_state.last_message_preview),
|
|
609
|
+
last_remote_activity_at = COALESCE(excluded.last_remote_activity_at, session_state.last_remote_activity_at),
|
|
610
|
+
last_read_seq = excluded.last_read_seq,
|
|
611
|
+
unread_count = excluded.unread_count,
|
|
612
|
+
active = excluded.active,
|
|
613
|
+
updated_at = excluded.updated_at
|
|
614
|
+
`).run(
|
|
615
|
+
next.session_key,
|
|
616
|
+
next.remote_did ?? null,
|
|
617
|
+
next.conversation_id ?? null,
|
|
618
|
+
next.trust_state,
|
|
619
|
+
next.last_message_preview ?? null,
|
|
620
|
+
next.last_remote_activity_at ?? null,
|
|
621
|
+
next.last_read_seq,
|
|
622
|
+
next.unread_count,
|
|
623
|
+
next.active ? 1 : 0,
|
|
624
|
+
next.updated_at
|
|
625
|
+
);
|
|
626
|
+
return next;
|
|
627
|
+
}
|
|
628
|
+
upsertFromMessage(input) {
|
|
629
|
+
const sessionKey = input.session_key ?? buildSessionKey({
|
|
630
|
+
localDid: "",
|
|
631
|
+
remoteDid: input.remote_did ?? input.sender_did ?? "",
|
|
632
|
+
conversationId: input.conversation_id
|
|
633
|
+
});
|
|
634
|
+
const existing = this.get(sessionKey);
|
|
635
|
+
const preview = previewFromPayload(input.payload);
|
|
636
|
+
const isRemote = input.sender_is_self !== true;
|
|
637
|
+
const seq = input.seq ?? 0;
|
|
638
|
+
const lastReadSeq = existing?.last_read_seq ?? 0;
|
|
639
|
+
const unreadCount = isRemote && seq > lastReadSeq ? Math.max(existing?.unread_count ?? 0, seq - lastReadSeq) : existing?.unread_count ?? 0;
|
|
640
|
+
const activity = isRemote ? input.ts_ms ?? Date.now() : existing?.last_remote_activity_at;
|
|
641
|
+
return this.upsert({
|
|
642
|
+
session_key: sessionKey,
|
|
643
|
+
remote_did: input.remote_did ?? existing?.remote_did ?? (isRemote ? input.sender_did : void 0),
|
|
644
|
+
conversation_id: input.conversation_id,
|
|
645
|
+
trust_state: input.trust_state ?? existing?.trust_state ?? "stranger",
|
|
646
|
+
last_message_preview: preview || existing?.last_message_preview,
|
|
647
|
+
last_remote_activity_at: activity,
|
|
648
|
+
last_read_seq: existing?.last_read_seq ?? 0,
|
|
649
|
+
unread_count: unreadCount,
|
|
650
|
+
active: existing?.active ?? false,
|
|
651
|
+
updated_at: input.ts_ms ?? Date.now()
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
get(sessionKey) {
|
|
655
|
+
const row = this.store.getDb().prepare("SELECT * FROM session_state WHERE session_key = ?").get(sessionKey);
|
|
656
|
+
return row ? rowToSession(row) : null;
|
|
657
|
+
}
|
|
658
|
+
getByConversationId(conversationId) {
|
|
659
|
+
const row = this.store.getDb().prepare("SELECT * FROM session_state WHERE conversation_id = ? ORDER BY updated_at DESC LIMIT 1").get(conversationId);
|
|
660
|
+
return row ? rowToSession(row) : null;
|
|
661
|
+
}
|
|
662
|
+
getActiveSession() {
|
|
663
|
+
const row = this.store.getDb().prepare("SELECT * FROM session_state WHERE active = 1 ORDER BY updated_at DESC LIMIT 1").get();
|
|
664
|
+
return row ? rowToSession(row) : null;
|
|
665
|
+
}
|
|
666
|
+
listRecentSessions(limit = 20) {
|
|
667
|
+
const rows = this.store.getDb().prepare("SELECT * FROM session_state ORDER BY COALESCE(last_remote_activity_at, updated_at) DESC, updated_at DESC LIMIT ?").all(limit);
|
|
668
|
+
return rows.map(rowToSession);
|
|
669
|
+
}
|
|
670
|
+
focusSession(sessionKey) {
|
|
671
|
+
const session = this.get(sessionKey);
|
|
672
|
+
if (!session) return null;
|
|
673
|
+
this.store.getDb().prepare("UPDATE session_state SET active = CASE WHEN session_key = ? THEN 1 ELSE 0 END").run(sessionKey);
|
|
674
|
+
return this.get(sessionKey);
|
|
675
|
+
}
|
|
676
|
+
markRead(sessionKey, seq) {
|
|
677
|
+
const session = this.get(sessionKey);
|
|
678
|
+
if (!session) return null;
|
|
679
|
+
const lastReadSeq = Math.max(session.last_read_seq, seq ?? session.last_read_seq);
|
|
680
|
+
return this.upsert({
|
|
681
|
+
session_key: sessionKey,
|
|
682
|
+
last_read_seq: lastReadSeq,
|
|
683
|
+
unread_count: 0,
|
|
684
|
+
active: session.active
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
nextUnread() {
|
|
688
|
+
const row = this.store.getDb().prepare("SELECT * FROM session_state WHERE unread_count > 0 ORDER BY last_remote_activity_at DESC, updated_at DESC LIMIT 1").get();
|
|
689
|
+
return row ? rowToSession(row) : null;
|
|
690
|
+
}
|
|
691
|
+
resolveReplyTarget(sessionKey) {
|
|
692
|
+
const session = sessionKey ? this.get(sessionKey) : this.getActiveSession();
|
|
693
|
+
if (!session) return null;
|
|
694
|
+
return {
|
|
695
|
+
session_key: session.session_key,
|
|
696
|
+
remote_did: session.remote_did,
|
|
697
|
+
conversation_id: session.conversation_id,
|
|
698
|
+
trust_state: session.trust_state
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
// src/task-threads.ts
|
|
704
|
+
function rowToThread(row) {
|
|
705
|
+
return {
|
|
706
|
+
task_id: row.task_id,
|
|
707
|
+
session_key: row.session_key,
|
|
708
|
+
conversation_id: row.conversation_id,
|
|
709
|
+
title: row.title ?? void 0,
|
|
710
|
+
status: row.status,
|
|
711
|
+
started_at: row.started_at ?? void 0,
|
|
712
|
+
updated_at: row.updated_at,
|
|
713
|
+
result_summary: row.result_summary ?? void 0,
|
|
714
|
+
error_code: row.error_code ?? void 0,
|
|
715
|
+
error_message: row.error_message ?? void 0
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
var TaskThreadManager = class {
|
|
719
|
+
constructor(store) {
|
|
720
|
+
this.store = store;
|
|
721
|
+
}
|
|
722
|
+
upsert(thread) {
|
|
723
|
+
const updatedAt = thread.updated_at ?? Date.now();
|
|
724
|
+
const existing = this.get(thread.task_id);
|
|
725
|
+
this.store.getDb().prepare(`
|
|
726
|
+
INSERT INTO task_threads (
|
|
727
|
+
task_id, session_key, conversation_id, title, status, started_at, updated_at, result_summary, error_code, error_message
|
|
728
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
729
|
+
ON CONFLICT(task_id) DO UPDATE SET
|
|
730
|
+
session_key = excluded.session_key,
|
|
731
|
+
conversation_id = excluded.conversation_id,
|
|
732
|
+
title = COALESCE(excluded.title, task_threads.title),
|
|
733
|
+
status = excluded.status,
|
|
734
|
+
started_at = COALESCE(excluded.started_at, task_threads.started_at),
|
|
735
|
+
updated_at = excluded.updated_at,
|
|
736
|
+
result_summary = COALESCE(excluded.result_summary, task_threads.result_summary),
|
|
737
|
+
error_code = COALESCE(excluded.error_code, task_threads.error_code),
|
|
738
|
+
error_message = COALESCE(excluded.error_message, task_threads.error_message)
|
|
739
|
+
`).run(
|
|
740
|
+
thread.task_id,
|
|
741
|
+
thread.session_key,
|
|
742
|
+
thread.conversation_id,
|
|
743
|
+
thread.title ?? null,
|
|
744
|
+
thread.status,
|
|
745
|
+
thread.started_at ?? existing?.started_at ?? updatedAt,
|
|
746
|
+
updatedAt,
|
|
747
|
+
thread.result_summary ?? null,
|
|
748
|
+
thread.error_code ?? null,
|
|
749
|
+
thread.error_message ?? null
|
|
750
|
+
);
|
|
751
|
+
return this.get(thread.task_id);
|
|
752
|
+
}
|
|
753
|
+
get(taskId) {
|
|
754
|
+
const row = this.store.getDb().prepare("SELECT * FROM task_threads WHERE task_id = ?").get(taskId);
|
|
755
|
+
return row ? rowToThread(row) : null;
|
|
756
|
+
}
|
|
757
|
+
listBySession(sessionKey, limit = 20) {
|
|
758
|
+
const rows = this.store.getDb().prepare("SELECT * FROM task_threads WHERE session_key = ? ORDER BY updated_at DESC LIMIT ?").all(sessionKey, limit);
|
|
759
|
+
return rows.map(rowToThread);
|
|
760
|
+
}
|
|
761
|
+
listByConversation(conversationId, limit = 20) {
|
|
762
|
+
const rows = this.store.getDb().prepare("SELECT * FROM task_threads WHERE conversation_id = ? ORDER BY updated_at DESC LIMIT ?").all(conversationId, limit);
|
|
763
|
+
return rows.map(rowToThread);
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// src/client.ts
|
|
768
|
+
var PingAgentClient = class {
|
|
769
|
+
constructor(opts) {
|
|
770
|
+
this.opts = opts;
|
|
771
|
+
this.identity = opts.identity;
|
|
772
|
+
this.transport = new HttpTransport({
|
|
773
|
+
serverUrl: opts.serverUrl,
|
|
774
|
+
accessToken: opts.accessToken,
|
|
775
|
+
onTokenRefreshed: opts.onTokenRefreshed
|
|
776
|
+
});
|
|
777
|
+
if (opts.store) {
|
|
778
|
+
this.contactManager = new ContactManager(opts.store);
|
|
779
|
+
this.historyManager = new HistoryManager(opts.store);
|
|
780
|
+
this.sessionManager = new SessionManager(opts.store);
|
|
781
|
+
this.taskThreadManager = new TaskThreadManager(opts.store);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
transport;
|
|
785
|
+
identity;
|
|
786
|
+
contactManager;
|
|
787
|
+
historyManager;
|
|
788
|
+
sessionManager;
|
|
789
|
+
taskThreadManager;
|
|
790
|
+
getContactManager() {
|
|
791
|
+
return this.contactManager;
|
|
792
|
+
}
|
|
793
|
+
getHistoryManager() {
|
|
794
|
+
return this.historyManager;
|
|
795
|
+
}
|
|
796
|
+
getSessionManager() {
|
|
797
|
+
return this.sessionManager;
|
|
798
|
+
}
|
|
799
|
+
getTaskThreadManager() {
|
|
800
|
+
return this.taskThreadManager;
|
|
801
|
+
}
|
|
802
|
+
/** Update the in-memory access token (e.g. after proactive refresh from disk). */
|
|
803
|
+
setAccessToken(token) {
|
|
804
|
+
this.transport.setToken(token);
|
|
805
|
+
}
|
|
806
|
+
async register(developerToken) {
|
|
807
|
+
let binary = "";
|
|
808
|
+
for (const b of this.identity.publicKey) binary += String.fromCharCode(b);
|
|
809
|
+
const publicKeyBase64 = btoa(binary);
|
|
810
|
+
return this.transport.request("POST", "/v1/agent/register", {
|
|
811
|
+
device_id: this.identity.deviceId,
|
|
812
|
+
public_key: publicKeyBase64,
|
|
813
|
+
developer_token: developerToken
|
|
814
|
+
}, true);
|
|
815
|
+
}
|
|
816
|
+
async openConversation(targetDid) {
|
|
817
|
+
return this.transport.request("POST", "/v1/conversations/open", {
|
|
818
|
+
targets: [targetDid]
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
async sendMessage(conversationId, schema, payload) {
|
|
822
|
+
const ttlMs = schema === SCHEMA_TEXT ? 6048e5 : void 0;
|
|
823
|
+
const unsigned = buildUnsignedEnvelope({
|
|
824
|
+
type: "message",
|
|
825
|
+
conversationId,
|
|
826
|
+
senderDid: this.identity.did,
|
|
827
|
+
senderDeviceId: this.identity.deviceId,
|
|
828
|
+
schema,
|
|
829
|
+
payload,
|
|
830
|
+
ttlMs
|
|
831
|
+
});
|
|
832
|
+
const signed = signEnvelope(unsigned, this.identity.privateKey);
|
|
833
|
+
const res = await this.transport.request("POST", "/v1/messages/send", signed);
|
|
834
|
+
if (res.ok && this.historyManager) {
|
|
835
|
+
this.historyManager.save([{
|
|
836
|
+
conversation_id: conversationId,
|
|
837
|
+
message_id: signed.message_id,
|
|
838
|
+
seq: res.data?.seq,
|
|
839
|
+
sender_did: this.identity.did,
|
|
840
|
+
schema,
|
|
841
|
+
payload,
|
|
842
|
+
ts_ms: signed.ts_ms,
|
|
843
|
+
direction: "sent"
|
|
844
|
+
}]);
|
|
845
|
+
this.sessionManager?.upsertFromMessage({
|
|
846
|
+
session_key: this.sessionManager.getByConversationId(conversationId)?.session_key,
|
|
847
|
+
remote_did: this.sessionManager.getByConversationId(conversationId)?.remote_did,
|
|
848
|
+
conversation_id: conversationId,
|
|
849
|
+
trust_state: this.sessionManager.getByConversationId(conversationId)?.trust_state ?? "trusted",
|
|
850
|
+
sender_did: this.identity.did,
|
|
851
|
+
sender_is_self: true,
|
|
852
|
+
schema,
|
|
853
|
+
payload,
|
|
854
|
+
seq: res.data?.seq,
|
|
855
|
+
ts_ms: signed.ts_ms
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
return res;
|
|
859
|
+
}
|
|
860
|
+
async sendTask(conversationId, task) {
|
|
861
|
+
return this.sendMessage(conversationId, SCHEMA_TASK, {
|
|
862
|
+
...task,
|
|
863
|
+
idempotency_key: task.task_id,
|
|
864
|
+
expected_output_schema: SCHEMA_RESULT
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
async sendContactRequest(conversationId, message) {
|
|
868
|
+
const { v7: uuidv7 } = await import("uuid");
|
|
869
|
+
return this.sendMessage(conversationId, SCHEMA_CONTACT_REQUEST, {
|
|
870
|
+
request_id: `r_${uuidv7()}`,
|
|
871
|
+
from_did: this.identity.did,
|
|
872
|
+
to_did: "",
|
|
873
|
+
capabilities: ["task", "result", "files"],
|
|
874
|
+
message,
|
|
875
|
+
expires_ms: 864e5
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
async fetchInbox(conversationId, opts) {
|
|
879
|
+
const params = new URLSearchParams({
|
|
880
|
+
conversation_id: conversationId,
|
|
881
|
+
since_seq: String(opts?.sinceSeq ?? 0),
|
|
882
|
+
limit: String(opts?.limit ?? 50),
|
|
883
|
+
box: opts?.box ?? "ready"
|
|
884
|
+
});
|
|
885
|
+
const res = await this.transport.request("GET", `/v1/inbox/fetch?${params}`);
|
|
886
|
+
if (res.ok && res.data && this.historyManager) {
|
|
887
|
+
const msgs = res.data.messages.map((msg) => ({
|
|
888
|
+
conversation_id: conversationId,
|
|
889
|
+
message_id: msg.message_id,
|
|
890
|
+
seq: msg.seq,
|
|
891
|
+
sender_did: msg.sender_did,
|
|
892
|
+
schema: msg.schema,
|
|
893
|
+
payload: msg.payload,
|
|
894
|
+
ts_ms: msg.ts_ms,
|
|
895
|
+
direction: msg.sender_did === this.identity.did ? "sent" : "received"
|
|
896
|
+
}));
|
|
897
|
+
if (msgs.length > 0) {
|
|
898
|
+
this.historyManager.save(msgs);
|
|
899
|
+
const maxSeq = Math.max(...msgs.map((m) => m.seq ?? 0));
|
|
900
|
+
if (maxSeq > 0) this.historyManager.setLastSyncedSeq(conversationId, maxSeq);
|
|
901
|
+
}
|
|
902
|
+
for (const msg of res.data.messages) {
|
|
903
|
+
const existingSession = this.sessionManager?.getByConversationId(conversationId);
|
|
904
|
+
this.sessionManager?.upsertFromMessage({
|
|
905
|
+
session_key: existingSession?.session_key,
|
|
906
|
+
remote_did: existingSession?.remote_did ?? (msg.sender_did !== this.identity.did ? msg.sender_did : void 0),
|
|
907
|
+
conversation_id: conversationId,
|
|
908
|
+
trust_state: existingSession?.trust_state ?? (opts?.box === "strangers" ? "pending" : "trusted"),
|
|
909
|
+
sender_did: msg.sender_did,
|
|
910
|
+
sender_is_self: msg.sender_did === this.identity.did,
|
|
911
|
+
schema: msg.schema,
|
|
912
|
+
payload: msg.payload,
|
|
913
|
+
seq: msg.seq,
|
|
914
|
+
ts_ms: msg.ts_ms
|
|
915
|
+
});
|
|
916
|
+
if (msg.schema === SCHEMA_RESULT && msg.payload?.task_id) {
|
|
917
|
+
const session = this.sessionManager?.getByConversationId(conversationId);
|
|
918
|
+
this.taskThreadManager?.upsert({
|
|
919
|
+
task_id: msg.payload.task_id,
|
|
920
|
+
session_key: session?.session_key ?? buildSessionKey({ localDid: this.identity.did, conversationId, remoteDid: session?.remote_did }),
|
|
921
|
+
conversation_id: conversationId,
|
|
922
|
+
status: msg.payload.status === "ok" ? "processed" : "failed",
|
|
923
|
+
updated_at: msg.ts_ms ?? Date.now(),
|
|
924
|
+
result_summary: msg.payload?.output?.summary,
|
|
925
|
+
error_code: msg.payload?.error?.code,
|
|
926
|
+
error_message: msg.payload?.error?.message
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return res;
|
|
932
|
+
}
|
|
933
|
+
async ack(conversationId, forMessageId, status, opts) {
|
|
934
|
+
return this.transport.request("POST", "/v1/receipts/ack", {
|
|
935
|
+
conversation_id: conversationId,
|
|
936
|
+
for_message_id: forMessageId,
|
|
937
|
+
for_task_id: opts?.forTaskId,
|
|
938
|
+
status,
|
|
939
|
+
reason: opts?.reason,
|
|
940
|
+
detail: opts?.detail
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
async approveContact(conversationId) {
|
|
944
|
+
const res = await this.transport.request("POST", "/v1/control/approve_contact", {
|
|
945
|
+
conversation_id: conversationId
|
|
946
|
+
});
|
|
947
|
+
if (res.ok && this.contactManager) {
|
|
948
|
+
const pendingMessages = this.historyManager?.list(conversationId, { limit: 1 });
|
|
949
|
+
const senderDid = pendingMessages?.[0]?.sender_did;
|
|
950
|
+
if (senderDid && senderDid !== this.identity.did) {
|
|
951
|
+
this.contactManager.add({
|
|
952
|
+
did: senderDid,
|
|
953
|
+
conversation_id: res.data?.dm_conversation_id ?? conversationId,
|
|
954
|
+
trusted: true
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (res.ok) {
|
|
959
|
+
const existing = this.sessionManager?.getByConversationId(conversationId);
|
|
960
|
+
if (existing) {
|
|
961
|
+
this.sessionManager?.upsert({
|
|
962
|
+
session_key: existing.session_key,
|
|
963
|
+
conversation_id: res.data?.dm_conversation_id ?? conversationId,
|
|
964
|
+
trust_state: "trusted",
|
|
965
|
+
remote_did: existing.remote_did,
|
|
966
|
+
active: existing.active
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return res;
|
|
971
|
+
}
|
|
972
|
+
async revokeConversation(conversationId) {
|
|
973
|
+
return this.transport.request("POST", "/v1/control/revoke_conversation", {
|
|
974
|
+
conversation_id: conversationId
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
async cancelTask(conversationId, taskId) {
|
|
978
|
+
const res = await this.transport.request("POST", "/v1/control/cancel_task", {
|
|
979
|
+
conversation_id: conversationId,
|
|
980
|
+
task_id: taskId
|
|
981
|
+
});
|
|
982
|
+
if (res.ok && res.data) {
|
|
983
|
+
const session = this.sessionManager?.getByConversationId(conversationId);
|
|
984
|
+
this.taskThreadManager?.upsert({
|
|
985
|
+
task_id: taskId,
|
|
986
|
+
session_key: session?.session_key ?? buildSessionKey({ localDid: this.identity.did, conversationId, remoteDid: session?.remote_did }),
|
|
987
|
+
conversation_id: conversationId,
|
|
988
|
+
status: normalizeTaskThreadStatus(res.data.task_state),
|
|
989
|
+
updated_at: Date.now()
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
return res;
|
|
993
|
+
}
|
|
994
|
+
async blockSender(conversationId, senderDid) {
|
|
995
|
+
return this.transport.request("POST", "/v1/control/block_sender", {
|
|
996
|
+
conversation_id: conversationId,
|
|
997
|
+
sender_did: senderDid
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
async acquireLease(conversationId) {
|
|
1001
|
+
return this.transport.request("POST", "/v1/lease/acquire", {
|
|
1002
|
+
conversation_id: conversationId
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
async renewLease(conversationId) {
|
|
1006
|
+
return this.transport.request("POST", "/v1/lease/renew", {
|
|
1007
|
+
conversation_id: conversationId
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
async releaseLease(conversationId) {
|
|
1011
|
+
return this.transport.request("POST", "/v1/lease/release", {
|
|
1012
|
+
conversation_id: conversationId
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
async uploadArtifact(content) {
|
|
1016
|
+
const presignRes = await this.transport.request("POST", "/v1/artifacts/presign_upload", {
|
|
1017
|
+
size: content.length,
|
|
1018
|
+
content_type: "application/octet-stream"
|
|
1019
|
+
});
|
|
1020
|
+
if (!presignRes.ok || !presignRes.data) throw new Error("Failed to presign upload");
|
|
1021
|
+
const { upload_url, artifact_ref } = presignRes.data;
|
|
1022
|
+
await this.transport.fetchWithAuth(upload_url, {
|
|
1023
|
+
method: "PUT",
|
|
1024
|
+
body: content,
|
|
1025
|
+
contentType: "application/octet-stream"
|
|
1026
|
+
});
|
|
1027
|
+
const { createHash } = await import("crypto");
|
|
1028
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
1029
|
+
return { artifact_ref, sha256: hash, size: content.length };
|
|
1030
|
+
}
|
|
1031
|
+
async downloadArtifact(artifactRef, expectedSha256, expectedSize) {
|
|
1032
|
+
const presignRes = await this.transport.request("POST", "/v1/artifacts/presign_download", {
|
|
1033
|
+
artifact_ref: artifactRef
|
|
1034
|
+
});
|
|
1035
|
+
if (!presignRes.ok || !presignRes.data) throw new Error("Failed to presign download");
|
|
1036
|
+
const buffer = Buffer.from(await this.transport.fetchWithAuth(presignRes.data.download_url));
|
|
1037
|
+
if (buffer.length !== expectedSize) {
|
|
1038
|
+
throw new Error(`Size mismatch: expected ${expectedSize}, got ${buffer.length}`);
|
|
1039
|
+
}
|
|
1040
|
+
const { createHash } = await import("crypto");
|
|
1041
|
+
const hash = createHash("sha256").update(buffer).digest("hex");
|
|
1042
|
+
if (hash !== expectedSha256) {
|
|
1043
|
+
throw new Error(`SHA256 mismatch: expected ${expectedSha256}, got ${hash}`);
|
|
1044
|
+
}
|
|
1045
|
+
return buffer;
|
|
1046
|
+
}
|
|
1047
|
+
async getSubscription() {
|
|
1048
|
+
return this.transport.request("GET", "/v1/subscription");
|
|
1049
|
+
}
|
|
1050
|
+
async resolveAlias(alias) {
|
|
1051
|
+
return this.transport.request("GET", `/v1/directory/resolve?alias=${encodeURIComponent(alias)}`);
|
|
1052
|
+
}
|
|
1053
|
+
async registerAlias(alias) {
|
|
1054
|
+
return this.transport.request("POST", "/v1/directory/alias", { alias });
|
|
1055
|
+
}
|
|
1056
|
+
async listConversations(opts) {
|
|
1057
|
+
const params = new URLSearchParams();
|
|
1058
|
+
if (opts?.type) params.set("type", opts.type);
|
|
1059
|
+
const qs = params.toString();
|
|
1060
|
+
const res = await this.transport.request("GET", `/v1/conversations/list${qs ? "?" + qs : ""}`);
|
|
1061
|
+
if (res.ok && res.data && this.sessionManager) {
|
|
1062
|
+
for (const conv of res.data.conversations) {
|
|
1063
|
+
const trustState = conv.trusted ? "trusted" : conv.type === "pending_dm" ? "pending" : "stranger";
|
|
1064
|
+
this.sessionManager.upsert({
|
|
1065
|
+
session_key: buildSessionKey({
|
|
1066
|
+
localDid: this.identity.did,
|
|
1067
|
+
remoteDid: conv.target_did,
|
|
1068
|
+
conversationId: conv.conversation_id,
|
|
1069
|
+
conversationType: conv.type
|
|
1070
|
+
}),
|
|
1071
|
+
remote_did: conv.target_did,
|
|
1072
|
+
conversation_id: conv.conversation_id,
|
|
1073
|
+
trust_state: trustState,
|
|
1074
|
+
last_remote_activity_at: conv.last_activity_at ?? void 0,
|
|
1075
|
+
updated_at: conv.last_activity_at ?? conv.created_at
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return res;
|
|
1080
|
+
}
|
|
1081
|
+
async getTaskStatus(conversationId, taskId) {
|
|
1082
|
+
const params = new URLSearchParams({ conversation_id: conversationId, task_id: taskId });
|
|
1083
|
+
const res = await this.transport.request("GET", `/v1/tasks/status?${params.toString()}`);
|
|
1084
|
+
if (res.ok && res.data) {
|
|
1085
|
+
const session = this.sessionManager?.getByConversationId(conversationId);
|
|
1086
|
+
this.taskThreadManager?.upsert({
|
|
1087
|
+
task_id: taskId,
|
|
1088
|
+
session_key: session?.session_key ?? buildSessionKey({ localDid: this.identity.did, conversationId, remoteDid: session?.remote_did }),
|
|
1089
|
+
conversation_id: conversationId,
|
|
1090
|
+
status: res.data.status,
|
|
1091
|
+
updated_at: res.data.last_update_ts_ms,
|
|
1092
|
+
result_summary: res.data.result_summary,
|
|
1093
|
+
error_code: res.data.error?.code,
|
|
1094
|
+
error_message: res.data.error?.message ?? res.data.reason
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
return res;
|
|
1098
|
+
}
|
|
1099
|
+
async listTaskThreads(conversationId) {
|
|
1100
|
+
const params = new URLSearchParams({ conversation_id: conversationId });
|
|
1101
|
+
const res = await this.transport.request("GET", `/v1/tasks/list?${params.toString()}`);
|
|
1102
|
+
if (res.ok && res.data) {
|
|
1103
|
+
const session = this.sessionManager?.getByConversationId(conversationId);
|
|
1104
|
+
for (const task of res.data.tasks) {
|
|
1105
|
+
this.taskThreadManager?.upsert({
|
|
1106
|
+
task_id: task.task_id,
|
|
1107
|
+
session_key: session?.session_key ?? buildSessionKey({ localDid: this.identity.did, conversationId, remoteDid: session?.remote_did }),
|
|
1108
|
+
conversation_id: conversationId,
|
|
1109
|
+
title: task.title,
|
|
1110
|
+
status: task.status,
|
|
1111
|
+
updated_at: task.last_update_ts_ms,
|
|
1112
|
+
result_summary: task.result_summary,
|
|
1113
|
+
error_code: task.error?.code,
|
|
1114
|
+
error_message: task.error?.message ?? task.reason
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return res;
|
|
1119
|
+
}
|
|
1120
|
+
async getProfile() {
|
|
1121
|
+
return this.transport.request("GET", "/v1/directory/profile");
|
|
1122
|
+
}
|
|
1123
|
+
async updateProfile(profile) {
|
|
1124
|
+
return this.transport.request("POST", "/v1/directory/profile", profile);
|
|
1125
|
+
}
|
|
1126
|
+
async enableDiscovery() {
|
|
1127
|
+
return this.transport.request("POST", "/v1/directory/enable_discovery");
|
|
1128
|
+
}
|
|
1129
|
+
async disableDiscovery() {
|
|
1130
|
+
return this.transport.request("POST", "/v1/directory/disable_discovery");
|
|
1131
|
+
}
|
|
1132
|
+
async browseDirectory(opts) {
|
|
1133
|
+
const params = new URLSearchParams();
|
|
1134
|
+
if (opts?.tag) params.set("tag", opts.tag);
|
|
1135
|
+
if (opts?.query) params.set("q", opts.query);
|
|
1136
|
+
if (opts?.limit != null) params.set("limit", String(opts.limit));
|
|
1137
|
+
if (opts?.offset != null) params.set("offset", String(opts.offset));
|
|
1138
|
+
if (opts?.sort) params.set("sort", opts.sort);
|
|
1139
|
+
const qs = params.toString();
|
|
1140
|
+
return this.transport.request("GET", `/v1/directory/browse${qs ? "?" + qs : ""}`);
|
|
1141
|
+
}
|
|
1142
|
+
async publishPost(opts) {
|
|
1143
|
+
return this.transport.request("POST", "/v1/feed/publish", { text: opts.text, artifact_ref: opts.artifact_ref });
|
|
1144
|
+
}
|
|
1145
|
+
async listFeedPublic(opts) {
|
|
1146
|
+
const params = new URLSearchParams();
|
|
1147
|
+
if (opts?.limit != null) params.set("limit", String(opts.limit));
|
|
1148
|
+
if (opts?.since != null) params.set("since", String(opts.since));
|
|
1149
|
+
const qs = params.toString();
|
|
1150
|
+
return this.transport.request("GET", `/v1/feed/public${qs ? "?" + qs : ""}`, void 0, true);
|
|
1151
|
+
}
|
|
1152
|
+
async listFeedByDid(did, opts) {
|
|
1153
|
+
const params = new URLSearchParams({ did });
|
|
1154
|
+
if (opts?.limit != null) params.set("limit", String(opts.limit));
|
|
1155
|
+
return this.transport.request("GET", `/v1/feed/by_did?${params.toString()}`, void 0, true);
|
|
1156
|
+
}
|
|
1157
|
+
async createChannel(opts) {
|
|
1158
|
+
return this.transport.request("POST", "/v1/channels/create", {
|
|
1159
|
+
name: opts.name,
|
|
1160
|
+
alias: opts.alias,
|
|
1161
|
+
description: opts.description,
|
|
1162
|
+
join_policy: "open",
|
|
1163
|
+
discoverable: opts.discoverable
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
async updateChannel(opts) {
|
|
1167
|
+
const alias = opts.alias.replace(/^@ch\//, "");
|
|
1168
|
+
return this.transport.request("PATCH", "/v1/channels/update", {
|
|
1169
|
+
alias,
|
|
1170
|
+
name: opts.name,
|
|
1171
|
+
description: opts.description,
|
|
1172
|
+
discoverable: opts.discoverable
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
async deleteChannel(alias) {
|
|
1176
|
+
const a = alias.replace(/^@ch\//, "");
|
|
1177
|
+
return this.transport.request("DELETE", "/v1/channels/delete", { alias: a });
|
|
1178
|
+
}
|
|
1179
|
+
async discoverChannels(opts) {
|
|
1180
|
+
const params = new URLSearchParams();
|
|
1181
|
+
if (opts?.limit != null) params.set("limit", String(opts.limit));
|
|
1182
|
+
if (opts?.query) params.set("q", opts.query);
|
|
1183
|
+
const qs = params.toString();
|
|
1184
|
+
return this.transport.request("GET", `/v1/channels/discover${qs ? "?" + qs : ""}`, void 0, true);
|
|
1185
|
+
}
|
|
1186
|
+
async joinChannel(alias) {
|
|
1187
|
+
return this.transport.request("POST", "/v1/channels/join", { alias });
|
|
1188
|
+
}
|
|
1189
|
+
getDid() {
|
|
1190
|
+
return this.identity.did;
|
|
1191
|
+
}
|
|
1192
|
+
// === Billing: device linking ===
|
|
1193
|
+
async createBillingLinkCode() {
|
|
1194
|
+
return this.transport.request("POST", "/v1/billing/link-code");
|
|
1195
|
+
}
|
|
1196
|
+
async redeemBillingLink(code) {
|
|
1197
|
+
return this.transport.request("POST", "/v1/billing/link", { code });
|
|
1198
|
+
}
|
|
1199
|
+
async unlinkBillingDevice(did) {
|
|
1200
|
+
return this.transport.request("POST", "/v1/billing/unlink", { did });
|
|
1201
|
+
}
|
|
1202
|
+
async getLinkedDevices() {
|
|
1203
|
+
return this.transport.request("GET", "/v1/billing/linked-devices");
|
|
1204
|
+
}
|
|
1205
|
+
// P0: High-level send-task-and-wait
|
|
1206
|
+
async sendTaskAndWait(targetDid, task, opts) {
|
|
1207
|
+
const { v7: uuidv7 } = await import("uuid");
|
|
1208
|
+
const timeout = opts?.timeoutMs ?? 12e4;
|
|
1209
|
+
const pollInterval = opts?.pollIntervalMs ?? 2e3;
|
|
1210
|
+
const taskId = `t_${uuidv7()}`;
|
|
1211
|
+
const startTime = Date.now();
|
|
1212
|
+
const openRes = await this.openConversation(targetDid);
|
|
1213
|
+
if (!openRes.ok || !openRes.data) {
|
|
1214
|
+
return { status: "error", task_id: taskId, error: { code: "E_OPEN_FAILED", message: "Failed to open conversation" }, elapsed_ms: Date.now() - startTime };
|
|
1215
|
+
}
|
|
1216
|
+
let conversationId = openRes.data.conversation_id;
|
|
1217
|
+
if (!openRes.data.trusted) {
|
|
1218
|
+
await this.sendContactRequest(conversationId, task.title);
|
|
1219
|
+
const approvalDeadline = startTime + timeout;
|
|
1220
|
+
while (Date.now() < approvalDeadline) {
|
|
1221
|
+
await sleep2(pollInterval);
|
|
1222
|
+
const reopen = await this.openConversation(targetDid);
|
|
1223
|
+
if (reopen.ok && reopen.data?.trusted) {
|
|
1224
|
+
conversationId = reopen.data.conversation_id;
|
|
1225
|
+
break;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if (Date.now() >= approvalDeadline) {
|
|
1229
|
+
return { status: "error", task_id: taskId, error: { code: "E_NOT_APPROVED", message: "Contact request not approved within timeout" }, elapsed_ms: Date.now() - startTime };
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const sendRes = await this.sendTask(conversationId, { task_id: taskId, ...task });
|
|
1233
|
+
if (!sendRes.ok) {
|
|
1234
|
+
return { status: "error", task_id: taskId, error: { code: sendRes.error?.code ?? "E_SEND_FAILED", message: sendRes.error?.message ?? "Send failed" }, elapsed_ms: Date.now() - startTime };
|
|
1235
|
+
}
|
|
1236
|
+
const session = this.sessionManager?.getByConversationId(conversationId);
|
|
1237
|
+
this.taskThreadManager?.upsert({
|
|
1238
|
+
task_id: taskId,
|
|
1239
|
+
session_key: session?.session_key ?? buildSessionKey({ localDid: this.identity.did, conversationId, remoteDid: targetDid }),
|
|
1240
|
+
conversation_id: conversationId,
|
|
1241
|
+
title: task.title,
|
|
1242
|
+
status: "queued",
|
|
1243
|
+
started_at: Date.now(),
|
|
1244
|
+
updated_at: Date.now()
|
|
1245
|
+
});
|
|
1246
|
+
const sinceSeq = sendRes.data?.seq ?? 0;
|
|
1247
|
+
const deadline = startTime + timeout;
|
|
1248
|
+
while (Date.now() < deadline) {
|
|
1249
|
+
await sleep2(pollInterval);
|
|
1250
|
+
const statusRes = await this.getTaskStatus(conversationId, taskId);
|
|
1251
|
+
if (statusRes.ok && statusRes.data) {
|
|
1252
|
+
if (statusRes.data.status === "failed" || statusRes.data.status === "cancelled") {
|
|
1253
|
+
return {
|
|
1254
|
+
status: "error",
|
|
1255
|
+
task_id: taskId,
|
|
1256
|
+
error: {
|
|
1257
|
+
code: statusRes.data.error?.code ?? (statusRes.data.status === "cancelled" ? "E_CANCELLED" : "E_TASK_FAILED"),
|
|
1258
|
+
message: statusRes.data.error?.message ?? statusRes.data.reason ?? `Task ${statusRes.data.status}`
|
|
1259
|
+
},
|
|
1260
|
+
elapsed_ms: Date.now() - startTime
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
const fetchRes = await this.fetchInbox(conversationId, { sinceSeq });
|
|
1265
|
+
if (!fetchRes.ok || !fetchRes.data) continue;
|
|
1266
|
+
for (const msg of fetchRes.data.messages) {
|
|
1267
|
+
if (msg.schema === SCHEMA_RESULT && msg.payload?.task_id === taskId) {
|
|
1268
|
+
return {
|
|
1269
|
+
status: msg.payload.status === "ok" ? "ok" : "error",
|
|
1270
|
+
task_id: taskId,
|
|
1271
|
+
result: msg.payload.output,
|
|
1272
|
+
error: msg.payload.error,
|
|
1273
|
+
elapsed_ms: Date.now() - startTime
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return { status: "error", task_id: taskId, error: { code: "E_TIMEOUT", message: `No result within ${timeout}ms` }, elapsed_ms: timeout };
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
function normalizeTaskThreadStatus(state) {
|
|
1282
|
+
if (state === "completed") return "processed";
|
|
1283
|
+
if (state === "cancel_requested") return "running";
|
|
1284
|
+
if (state === "cancelled") return "cancelled";
|
|
1285
|
+
if (state === "failed") return "failed";
|
|
1286
|
+
if (state === "running") return "running";
|
|
1287
|
+
return "queued";
|
|
1288
|
+
}
|
|
1289
|
+
function sleep2(ms) {
|
|
1290
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// src/identity.ts
|
|
1294
|
+
import * as fs from "fs";
|
|
1295
|
+
import * as path2 from "path";
|
|
1296
|
+
import { generateIdentity as genId } from "@pingagent/protocol";
|
|
1297
|
+
|
|
1298
|
+
// src/paths.ts
|
|
1299
|
+
import * as path from "path";
|
|
1300
|
+
import * as os from "os";
|
|
1301
|
+
var DEFAULT_ROOT_DIR = path.join(os.homedir(), ".pingagent");
|
|
1302
|
+
function getRootDir() {
|
|
1303
|
+
return process.env.PINGAGENT_ROOT_DIR || DEFAULT_ROOT_DIR;
|
|
1304
|
+
}
|
|
1305
|
+
function getProfile() {
|
|
1306
|
+
const p = process.env.PINGAGENT_PROFILE;
|
|
1307
|
+
if (!p) return void 0;
|
|
1308
|
+
return p.trim() || void 0;
|
|
1309
|
+
}
|
|
1310
|
+
function getIdentityPath(explicitPath) {
|
|
1311
|
+
const envPath = process.env.PINGAGENT_IDENTITY_PATH?.trim();
|
|
1312
|
+
if (explicitPath) return explicitPath;
|
|
1313
|
+
if (envPath) return path.resolve(envPath.replace(/^~(?=\/|$)/, os.homedir()));
|
|
1314
|
+
const root = getRootDir();
|
|
1315
|
+
const profile = getProfile();
|
|
1316
|
+
if (!profile) {
|
|
1317
|
+
return path.join(root, "identity.json");
|
|
1318
|
+
}
|
|
1319
|
+
return path.join(root, "profiles", profile, "identity.json");
|
|
1320
|
+
}
|
|
1321
|
+
function getStorePath(explicitPath) {
|
|
1322
|
+
const envPath = process.env.PINGAGENT_STORE_PATH?.trim();
|
|
1323
|
+
if (explicitPath) return explicitPath;
|
|
1324
|
+
if (envPath) return path.resolve(envPath.replace(/^~(?=\/|$)/, os.homedir()));
|
|
1325
|
+
const root = getRootDir();
|
|
1326
|
+
const profile = getProfile();
|
|
1327
|
+
if (!profile) {
|
|
1328
|
+
return path.join(root, "store.db");
|
|
1329
|
+
}
|
|
1330
|
+
return path.join(root, "profiles", profile, "store.db");
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// src/identity.ts
|
|
1334
|
+
function toBase64(bytes) {
|
|
1335
|
+
let binary = "";
|
|
1336
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
1337
|
+
return btoa(binary);
|
|
1338
|
+
}
|
|
1339
|
+
function fromBase64(b64) {
|
|
1340
|
+
const binary = atob(b64);
|
|
1341
|
+
const bytes = new Uint8Array(binary.length);
|
|
1342
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
1343
|
+
return bytes;
|
|
1344
|
+
}
|
|
1345
|
+
function generateIdentity() {
|
|
1346
|
+
return genId();
|
|
1347
|
+
}
|
|
1348
|
+
function identityExists(identityPath) {
|
|
1349
|
+
return fs.existsSync(getIdentityPath(identityPath));
|
|
1350
|
+
}
|
|
1351
|
+
function loadIdentity(identityPath) {
|
|
1352
|
+
const p = getIdentityPath(identityPath);
|
|
1353
|
+
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
1354
|
+
return {
|
|
1355
|
+
publicKey: fromBase64(data.public_key),
|
|
1356
|
+
privateKey: fromBase64(data.private_key),
|
|
1357
|
+
did: data.did,
|
|
1358
|
+
deviceId: data.device_id,
|
|
1359
|
+
serverUrl: data.server_url,
|
|
1360
|
+
accessToken: data.access_token,
|
|
1361
|
+
tokenExpiresAt: data.token_expires_at,
|
|
1362
|
+
mode: data.mode
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
function saveIdentity(identity, opts, identityPath) {
|
|
1366
|
+
const p = getIdentityPath(identityPath);
|
|
1367
|
+
const dir = path2.dirname(p);
|
|
1368
|
+
if (!fs.existsSync(dir)) {
|
|
1369
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
1370
|
+
}
|
|
1371
|
+
const stored = {
|
|
1372
|
+
public_key: toBase64(identity.publicKey),
|
|
1373
|
+
private_key: toBase64(identity.privateKey),
|
|
1374
|
+
did: identity.did,
|
|
1375
|
+
device_id: identity.deviceId,
|
|
1376
|
+
server_url: opts?.serverUrl,
|
|
1377
|
+
access_token: opts?.accessToken,
|
|
1378
|
+
token_expires_at: opts?.tokenExpiresAt,
|
|
1379
|
+
mode: opts?.mode,
|
|
1380
|
+
alias: opts?.alias
|
|
1381
|
+
};
|
|
1382
|
+
fs.writeFileSync(p, JSON.stringify(stored, null, 2), { mode: 384 });
|
|
1383
|
+
}
|
|
1384
|
+
function updateStoredToken(accessToken, expiresAt, identityPath) {
|
|
1385
|
+
const p = getIdentityPath(identityPath);
|
|
1386
|
+
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
1387
|
+
data.access_token = accessToken;
|
|
1388
|
+
data.token_expires_at = expiresAt;
|
|
1389
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2), { mode: 384 });
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// src/auth.ts
|
|
1393
|
+
var REFRESH_GRACE_MS = 5 * 60 * 1e3;
|
|
1394
|
+
async function ensureTokenValid(identityPath, serverUrl) {
|
|
1395
|
+
if (!identityExists(identityPath)) return false;
|
|
1396
|
+
const id = loadIdentity(identityPath);
|
|
1397
|
+
const token = id.accessToken;
|
|
1398
|
+
const expiresAt = id.tokenExpiresAt;
|
|
1399
|
+
if (!token || expiresAt == null) return false;
|
|
1400
|
+
const now = Date.now();
|
|
1401
|
+
if (expiresAt > now + REFRESH_GRACE_MS) return false;
|
|
1402
|
+
const baseUrl = (serverUrl ?? id.serverUrl ?? "https://pingagent.chat").replace(/\/$/, "");
|
|
1403
|
+
try {
|
|
1404
|
+
const res = await fetch(`${baseUrl}/v1/auth/refresh`, {
|
|
1405
|
+
method: "POST",
|
|
1406
|
+
headers: { "Content-Type": "application/json" },
|
|
1407
|
+
body: JSON.stringify({ access_token: token })
|
|
1408
|
+
});
|
|
1409
|
+
const text = await res.text();
|
|
1410
|
+
let data;
|
|
1411
|
+
try {
|
|
1412
|
+
data = text ? JSON.parse(text) : {};
|
|
1413
|
+
} catch {
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
if (!res.ok || !data.ok || !data.data?.access_token || data.data.expires_ms == null) return false;
|
|
1417
|
+
const newExpiresAt = now + data.data.expires_ms;
|
|
1418
|
+
updateStoredToken(data.data.access_token, newExpiresAt, identityPath);
|
|
1419
|
+
return true;
|
|
1420
|
+
} catch {
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// src/store.ts
|
|
1426
|
+
import Database from "better-sqlite3";
|
|
1427
|
+
import * as fs2 from "fs";
|
|
1428
|
+
import * as path3 from "path";
|
|
1429
|
+
var SCHEMA_SQL = `
|
|
1430
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
1431
|
+
did TEXT PRIMARY KEY,
|
|
1432
|
+
alias TEXT,
|
|
1433
|
+
display_name TEXT,
|
|
1434
|
+
notes TEXT,
|
|
1435
|
+
conversation_id TEXT,
|
|
1436
|
+
trusted INTEGER NOT NULL DEFAULT 0,
|
|
1437
|
+
added_at INTEGER NOT NULL,
|
|
1438
|
+
last_message_at INTEGER,
|
|
1439
|
+
tags TEXT
|
|
1440
|
+
);
|
|
1441
|
+
CREATE INDEX IF NOT EXISTS idx_contacts_alias ON contacts(alias);
|
|
1442
|
+
|
|
1443
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
1444
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1445
|
+
conversation_id TEXT NOT NULL,
|
|
1446
|
+
message_id TEXT NOT NULL UNIQUE,
|
|
1447
|
+
seq INTEGER,
|
|
1448
|
+
sender_did TEXT NOT NULL,
|
|
1449
|
+
schema TEXT NOT NULL,
|
|
1450
|
+
payload TEXT NOT NULL,
|
|
1451
|
+
ts_ms INTEGER NOT NULL,
|
|
1452
|
+
direction TEXT NOT NULL CHECK(direction IN ('sent', 'received'))
|
|
1453
|
+
);
|
|
1454
|
+
CREATE INDEX IF NOT EXISTS idx_messages_conv_seq ON messages(conversation_id, seq);
|
|
1455
|
+
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts_ms);
|
|
1456
|
+
|
|
1457
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
1458
|
+
conversation_id TEXT PRIMARY KEY,
|
|
1459
|
+
last_synced_seq INTEGER NOT NULL DEFAULT 0,
|
|
1460
|
+
last_synced_at INTEGER
|
|
1461
|
+
);
|
|
1462
|
+
|
|
1463
|
+
CREATE TABLE IF NOT EXISTS session_state (
|
|
1464
|
+
session_key TEXT PRIMARY KEY,
|
|
1465
|
+
remote_did TEXT,
|
|
1466
|
+
conversation_id TEXT,
|
|
1467
|
+
trust_state TEXT NOT NULL DEFAULT 'stranger',
|
|
1468
|
+
last_message_preview TEXT,
|
|
1469
|
+
last_remote_activity_at INTEGER,
|
|
1470
|
+
last_read_seq INTEGER NOT NULL DEFAULT 0,
|
|
1471
|
+
unread_count INTEGER NOT NULL DEFAULT 0,
|
|
1472
|
+
active INTEGER NOT NULL DEFAULT 0,
|
|
1473
|
+
updated_at INTEGER NOT NULL
|
|
1474
|
+
);
|
|
1475
|
+
CREATE INDEX IF NOT EXISTS idx_session_state_updated ON session_state(updated_at DESC);
|
|
1476
|
+
CREATE INDEX IF NOT EXISTS idx_session_state_conversation ON session_state(conversation_id);
|
|
1477
|
+
CREATE INDEX IF NOT EXISTS idx_session_state_remote_did ON session_state(remote_did);
|
|
1478
|
+
|
|
1479
|
+
CREATE TABLE IF NOT EXISTS task_threads (
|
|
1480
|
+
task_id TEXT PRIMARY KEY,
|
|
1481
|
+
session_key TEXT NOT NULL,
|
|
1482
|
+
conversation_id TEXT NOT NULL,
|
|
1483
|
+
title TEXT,
|
|
1484
|
+
status TEXT NOT NULL,
|
|
1485
|
+
started_at INTEGER,
|
|
1486
|
+
updated_at INTEGER NOT NULL,
|
|
1487
|
+
result_summary TEXT,
|
|
1488
|
+
error_code TEXT,
|
|
1489
|
+
error_message TEXT
|
|
1490
|
+
);
|
|
1491
|
+
CREATE INDEX IF NOT EXISTS idx_task_threads_session ON task_threads(session_key, updated_at DESC);
|
|
1492
|
+
CREATE INDEX IF NOT EXISTS idx_task_threads_conversation ON task_threads(conversation_id, updated_at DESC);
|
|
1493
|
+
`;
|
|
1494
|
+
var LocalStore = class {
|
|
1495
|
+
db;
|
|
1496
|
+
constructor(dbPath) {
|
|
1497
|
+
const p = getStorePath(dbPath);
|
|
1498
|
+
const dir = path3.dirname(p);
|
|
1499
|
+
if (!fs2.existsSync(dir)) {
|
|
1500
|
+
fs2.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
1501
|
+
}
|
|
1502
|
+
this.db = new Database(p);
|
|
1503
|
+
this.db.pragma("journal_mode = WAL");
|
|
1504
|
+
this.db.exec(SCHEMA_SQL);
|
|
1505
|
+
this.applyMigrations();
|
|
1506
|
+
}
|
|
1507
|
+
getDb() {
|
|
1508
|
+
return this.db;
|
|
1509
|
+
}
|
|
1510
|
+
close() {
|
|
1511
|
+
this.db.close();
|
|
1512
|
+
}
|
|
1513
|
+
applyMigrations() {
|
|
1514
|
+
const alterStatements = [
|
|
1515
|
+
`ALTER TABLE session_state ADD COLUMN remote_did TEXT`,
|
|
1516
|
+
`ALTER TABLE session_state ADD COLUMN conversation_id TEXT`,
|
|
1517
|
+
`ALTER TABLE session_state ADD COLUMN trust_state TEXT NOT NULL DEFAULT 'stranger'`,
|
|
1518
|
+
`ALTER TABLE session_state ADD COLUMN last_message_preview TEXT`,
|
|
1519
|
+
`ALTER TABLE session_state ADD COLUMN last_remote_activity_at INTEGER`,
|
|
1520
|
+
`ALTER TABLE session_state ADD COLUMN last_read_seq INTEGER NOT NULL DEFAULT 0`,
|
|
1521
|
+
`ALTER TABLE session_state ADD COLUMN unread_count INTEGER NOT NULL DEFAULT 0`,
|
|
1522
|
+
`ALTER TABLE session_state ADD COLUMN active INTEGER NOT NULL DEFAULT 0`,
|
|
1523
|
+
`ALTER TABLE session_state ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0`,
|
|
1524
|
+
`ALTER TABLE task_threads ADD COLUMN title TEXT`,
|
|
1525
|
+
`ALTER TABLE task_threads ADD COLUMN result_summary TEXT`,
|
|
1526
|
+
`ALTER TABLE task_threads ADD COLUMN error_code TEXT`,
|
|
1527
|
+
`ALTER TABLE task_threads ADD COLUMN error_message TEXT`
|
|
1528
|
+
];
|
|
1529
|
+
for (const sql of alterStatements) {
|
|
1530
|
+
try {
|
|
1531
|
+
this.db.exec(sql);
|
|
1532
|
+
} catch {
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
// src/a2a-adapter.ts
|
|
1539
|
+
import {
|
|
1540
|
+
A2AClient
|
|
1541
|
+
} from "@pingagent/a2a";
|
|
1542
|
+
var A2AAdapter = class {
|
|
1543
|
+
client;
|
|
1544
|
+
cachedCard = null;
|
|
1545
|
+
constructor(opts) {
|
|
1546
|
+
this.client = new A2AClient({
|
|
1547
|
+
agentUrl: opts.agentUrl,
|
|
1548
|
+
authToken: opts.authToken ? `Bearer ${opts.authToken}` : void 0,
|
|
1549
|
+
timeoutMs: opts.timeoutMs
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
async getAgentCard() {
|
|
1553
|
+
if (!this.cachedCard) {
|
|
1554
|
+
this.cachedCard = await this.client.fetchAgentCard();
|
|
1555
|
+
}
|
|
1556
|
+
return this.cachedCard;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Send a task to the external A2A agent and optionally wait for completion.
|
|
1560
|
+
*/
|
|
1561
|
+
async sendTask(opts) {
|
|
1562
|
+
const parts = [];
|
|
1563
|
+
parts.push({ kind: "text", text: opts.title });
|
|
1564
|
+
if (opts.description) {
|
|
1565
|
+
parts.push({ kind: "text", text: opts.description });
|
|
1566
|
+
}
|
|
1567
|
+
if (opts.input) {
|
|
1568
|
+
parts.push({
|
|
1569
|
+
kind: "data",
|
|
1570
|
+
data: typeof opts.input === "object" ? opts.input : { value: opts.input }
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1574
|
+
const message = {
|
|
1575
|
+
kind: "message",
|
|
1576
|
+
messageId,
|
|
1577
|
+
role: "user",
|
|
1578
|
+
parts
|
|
1579
|
+
};
|
|
1580
|
+
if (opts.wait) {
|
|
1581
|
+
const task = await this.client.sendAndWait(
|
|
1582
|
+
{ message, configuration: { blocking: true } },
|
|
1583
|
+
{ maxPollMs: opts.timeoutMs ?? 12e4 }
|
|
1584
|
+
);
|
|
1585
|
+
return this.convertTask(task);
|
|
1586
|
+
}
|
|
1587
|
+
const result = await this.client.sendMessage({ message });
|
|
1588
|
+
if (result.kind === "task") {
|
|
1589
|
+
return this.convertTask(result);
|
|
1590
|
+
}
|
|
1591
|
+
return {
|
|
1592
|
+
taskId: result.messageId,
|
|
1593
|
+
state: "completed",
|
|
1594
|
+
summary: this.extractText(result.parts)
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
async getTaskStatus(taskId) {
|
|
1598
|
+
const task = await this.client.getTask(taskId);
|
|
1599
|
+
return this.convertTask(task);
|
|
1600
|
+
}
|
|
1601
|
+
async cancelTask(taskId) {
|
|
1602
|
+
const task = await this.client.cancelTask(taskId);
|
|
1603
|
+
return this.convertTask(task);
|
|
1604
|
+
}
|
|
1605
|
+
async sendText(text, opts) {
|
|
1606
|
+
const result = await this.client.sendText(text, { blocking: opts?.blocking });
|
|
1607
|
+
if (result.kind === "task") {
|
|
1608
|
+
return this.convertTask(result);
|
|
1609
|
+
}
|
|
1610
|
+
return {
|
|
1611
|
+
taskId: result.messageId,
|
|
1612
|
+
state: "completed",
|
|
1613
|
+
summary: this.extractText(result.parts)
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
convertTask(task) {
|
|
1617
|
+
const summary = task.artifacts?.flatMap((a) => a.parts).filter((p) => p.kind === "text").map((p) => p.text).join("\n");
|
|
1618
|
+
const output = task.artifacts?.flatMap((a) => a.parts).filter((p) => p.kind === "data").map((p) => p.data);
|
|
1619
|
+
return {
|
|
1620
|
+
taskId: task.id,
|
|
1621
|
+
contextId: task.contextId,
|
|
1622
|
+
state: task.status.state,
|
|
1623
|
+
summary: summary || void 0,
|
|
1624
|
+
output: output && output.length > 0 ? output.length === 1 ? output[0] : output : void 0,
|
|
1625
|
+
timestamp: task.status.timestamp
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
extractText(parts) {
|
|
1629
|
+
return parts.filter((p) => p.kind === "text").map((p) => p.text).join("\n") || void 0;
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
// src/ws-subscription.ts
|
|
1634
|
+
import WebSocket from "ws";
|
|
1635
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
1636
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
1637
|
+
var RECONNECT_JITTER = 0.2;
|
|
1638
|
+
var LIST_CONVERSATIONS_INTERVAL_MS = 6e4;
|
|
1639
|
+
var DEFAULT_HEARTBEAT = {
|
|
1640
|
+
enable: true,
|
|
1641
|
+
idleThresholdMs: 1e4,
|
|
1642
|
+
pingIntervalMs: 15e3,
|
|
1643
|
+
pongTimeoutMs: 1e4,
|
|
1644
|
+
maxMissedPongs: 2,
|
|
1645
|
+
tickMs: 5e3,
|
|
1646
|
+
jitter: 0.2
|
|
1647
|
+
};
|
|
1648
|
+
var WsSubscription = class {
|
|
1649
|
+
opts;
|
|
1650
|
+
connections = /* @__PURE__ */ new Map();
|
|
1651
|
+
reconnectTimers = /* @__PURE__ */ new Map();
|
|
1652
|
+
reconnectAttempts = /* @__PURE__ */ new Map();
|
|
1653
|
+
listInterval = null;
|
|
1654
|
+
stopped = false;
|
|
1655
|
+
/** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
|
|
1656
|
+
stoppedConversations = /* @__PURE__ */ new Set();
|
|
1657
|
+
lastParseErrorAtByConv = /* @__PURE__ */ new Map();
|
|
1658
|
+
lastFrameAtByConv = /* @__PURE__ */ new Map();
|
|
1659
|
+
heartbeatTimer = null;
|
|
1660
|
+
heartbeatStates = /* @__PURE__ */ new Map();
|
|
1661
|
+
constructor(opts) {
|
|
1662
|
+
this.opts = opts;
|
|
1663
|
+
}
|
|
1664
|
+
start() {
|
|
1665
|
+
this.stopped = false;
|
|
1666
|
+
this.connectAll();
|
|
1667
|
+
this.listInterval = setInterval(() => this.syncConnections(), LIST_CONVERSATIONS_INTERVAL_MS);
|
|
1668
|
+
const hb = this.opts.heartbeat ?? {};
|
|
1669
|
+
if (hb.enable ?? DEFAULT_HEARTBEAT.enable) {
|
|
1670
|
+
this.heartbeatTimer = setInterval(() => this.heartbeatTick(), hb.tickMs ?? DEFAULT_HEARTBEAT.tickMs);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
stop() {
|
|
1674
|
+
this.stopped = true;
|
|
1675
|
+
if (this.listInterval) {
|
|
1676
|
+
clearInterval(this.listInterval);
|
|
1677
|
+
this.listInterval = null;
|
|
1678
|
+
}
|
|
1679
|
+
if (this.heartbeatTimer) {
|
|
1680
|
+
clearInterval(this.heartbeatTimer);
|
|
1681
|
+
this.heartbeatTimer = null;
|
|
1682
|
+
}
|
|
1683
|
+
for (const timer of this.reconnectTimers.values()) {
|
|
1684
|
+
clearTimeout(timer);
|
|
1685
|
+
}
|
|
1686
|
+
this.reconnectTimers.clear();
|
|
1687
|
+
for (const [convId, ws] of this.connections) {
|
|
1688
|
+
ws.removeAllListeners();
|
|
1689
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
1690
|
+
ws.close();
|
|
1691
|
+
}
|
|
1692
|
+
this.connections.delete(convId);
|
|
1693
|
+
}
|
|
1694
|
+
this.reconnectAttempts.clear();
|
|
1695
|
+
this.stoppedConversations.clear();
|
|
1696
|
+
this.lastParseErrorAtByConv.clear();
|
|
1697
|
+
this.lastFrameAtByConv.clear();
|
|
1698
|
+
for (const state of this.heartbeatStates.values()) {
|
|
1699
|
+
if (state.pongTimeout) clearTimeout(state.pongTimeout);
|
|
1700
|
+
}
|
|
1701
|
+
this.heartbeatStates.clear();
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Stop a single conversation's WebSocket and do not reconnect.
|
|
1705
|
+
* Used when the conversation is revoked or the client no longer wants to subscribe.
|
|
1706
|
+
*/
|
|
1707
|
+
stopConversation(conversationId) {
|
|
1708
|
+
this.stoppedConversations.add(conversationId);
|
|
1709
|
+
const timer = this.reconnectTimers.get(conversationId);
|
|
1710
|
+
if (timer) {
|
|
1711
|
+
clearTimeout(timer);
|
|
1712
|
+
this.reconnectTimers.delete(conversationId);
|
|
1713
|
+
}
|
|
1714
|
+
const hbState = this.heartbeatStates.get(conversationId);
|
|
1715
|
+
if (hbState?.pongTimeout) clearTimeout(hbState.pongTimeout);
|
|
1716
|
+
this.heartbeatStates.delete(conversationId);
|
|
1717
|
+
const ws = this.connections.get(conversationId);
|
|
1718
|
+
if (ws) {
|
|
1719
|
+
ws.removeAllListeners();
|
|
1720
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
1721
|
+
ws.close();
|
|
1722
|
+
}
|
|
1723
|
+
this.connections.delete(conversationId);
|
|
1724
|
+
}
|
|
1725
|
+
this.lastParseErrorAtByConv.delete(conversationId);
|
|
1726
|
+
this.lastFrameAtByConv.delete(conversationId);
|
|
1727
|
+
}
|
|
1728
|
+
wsUrl(conversationId) {
|
|
1729
|
+
const base = this.opts.serverUrl.replace(/^http/, "ws").replace(/\/$/, "");
|
|
1730
|
+
return `${base}/v1/ws?conversation_id=${encodeURIComponent(conversationId)}`;
|
|
1731
|
+
}
|
|
1732
|
+
async connectAsync(conversationId) {
|
|
1733
|
+
if (this.stopped || this.stoppedConversations.has(conversationId) || this.connections.has(conversationId)) return;
|
|
1734
|
+
const token = await Promise.resolve(this.opts.getAccessToken());
|
|
1735
|
+
const url = this.wsUrl(conversationId);
|
|
1736
|
+
const ws = new WebSocket(url, {
|
|
1737
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1738
|
+
});
|
|
1739
|
+
this.connections.set(conversationId, ws);
|
|
1740
|
+
const hb = this.opts.heartbeat ?? {};
|
|
1741
|
+
const hbEnabled = hb.enable ?? DEFAULT_HEARTBEAT.enable;
|
|
1742
|
+
if (hbEnabled) {
|
|
1743
|
+
const now = Date.now();
|
|
1744
|
+
this.heartbeatStates.set(conversationId, {
|
|
1745
|
+
lastRxAt: now,
|
|
1746
|
+
lastPingAt: 0,
|
|
1747
|
+
nextPingAt: now + (hb.idleThresholdMs ?? DEFAULT_HEARTBEAT.idleThresholdMs),
|
|
1748
|
+
missedPongs: 0,
|
|
1749
|
+
pongTimeout: null
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
ws.on("open", () => {
|
|
1753
|
+
this.reconnectAttempts.set(conversationId, 0);
|
|
1754
|
+
this.opts.onOpen?.(conversationId);
|
|
1755
|
+
});
|
|
1756
|
+
ws.on("message", (data) => {
|
|
1757
|
+
try {
|
|
1758
|
+
const state = this.heartbeatStates.get(conversationId);
|
|
1759
|
+
if (state) {
|
|
1760
|
+
state.lastRxAt = Date.now();
|
|
1761
|
+
state.missedPongs = 0;
|
|
1762
|
+
if (state.pongTimeout) {
|
|
1763
|
+
clearTimeout(state.pongTimeout);
|
|
1764
|
+
state.pongTimeout = null;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
const rawText = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString() : Array.isArray(data) ? Buffer.concat(data).toString() : data instanceof ArrayBuffer ? Buffer.from(new Uint8Array(data)).toString() : (
|
|
1768
|
+
// Fallback: try best-effort stringification
|
|
1769
|
+
String(data)
|
|
1770
|
+
);
|
|
1771
|
+
this.lastFrameAtByConv.set(conversationId, Date.now());
|
|
1772
|
+
const msg = JSON.parse(rawText);
|
|
1773
|
+
if (msg.type === "ws_connected") {
|
|
1774
|
+
this.opts.onDebug?.({
|
|
1775
|
+
event: "ws_connected",
|
|
1776
|
+
conversationId,
|
|
1777
|
+
detail: {
|
|
1778
|
+
your_did: msg.your_did,
|
|
1779
|
+
server_ts_ms: msg.server_ts_ms,
|
|
1780
|
+
conversation_id: msg.conversation_id
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
} else if (msg.type === "ws_message" && msg.envelope) {
|
|
1784
|
+
const env = msg.envelope;
|
|
1785
|
+
const ignoreSelf = this.opts.ignoreSelfMessages ?? true;
|
|
1786
|
+
if (!ignoreSelf || env.sender_did !== this.opts.myDid) {
|
|
1787
|
+
this.opts.onMessage(env, conversationId);
|
|
1788
|
+
} else {
|
|
1789
|
+
this.opts.onDebug?.({
|
|
1790
|
+
event: "ws_message_ignored_self",
|
|
1791
|
+
conversationId,
|
|
1792
|
+
detail: { sender_did: env.sender_did, message_id: env.message_id, seq: env.seq }
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
} else if (msg.type === "ws_control" && msg.control) {
|
|
1796
|
+
this.opts.onControl?.(msg.control, conversationId);
|
|
1797
|
+
this.opts.onDebug?.({ event: "ws_control", conversationId, detail: msg.control });
|
|
1798
|
+
} else if (msg.type === "ws_receipt" && msg.receipt) {
|
|
1799
|
+
this.opts.onDebug?.({ event: "ws_receipt", conversationId, detail: msg.receipt });
|
|
1800
|
+
} else if (msg?.type) {
|
|
1801
|
+
this.opts.onDebug?.({ event: "ws_unknown_type", conversationId, detail: { type: msg.type } });
|
|
1802
|
+
}
|
|
1803
|
+
} catch (e) {
|
|
1804
|
+
const now = Date.now();
|
|
1805
|
+
const last = this.lastParseErrorAtByConv.get(conversationId) ?? 0;
|
|
1806
|
+
if (now - last > 3e4) {
|
|
1807
|
+
this.lastParseErrorAtByConv.set(conversationId, now);
|
|
1808
|
+
this.opts.onDebug?.({
|
|
1809
|
+
event: "ws_parse_error",
|
|
1810
|
+
conversationId,
|
|
1811
|
+
detail: {
|
|
1812
|
+
message: e?.message ?? String(e),
|
|
1813
|
+
last_frame_at_ms: this.lastFrameAtByConv.get(conversationId) ?? null
|
|
1814
|
+
}
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
ws.on("pong", () => {
|
|
1820
|
+
const state = this.heartbeatStates.get(conversationId);
|
|
1821
|
+
if (!state) return;
|
|
1822
|
+
state.lastRxAt = Date.now();
|
|
1823
|
+
state.missedPongs = 0;
|
|
1824
|
+
if (state.pongTimeout) {
|
|
1825
|
+
clearTimeout(state.pongTimeout);
|
|
1826
|
+
state.pongTimeout = null;
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
ws.on("close", () => {
|
|
1830
|
+
this.connections.delete(conversationId);
|
|
1831
|
+
const state = this.heartbeatStates.get(conversationId);
|
|
1832
|
+
if (state?.pongTimeout) clearTimeout(state.pongTimeout);
|
|
1833
|
+
this.heartbeatStates.delete(conversationId);
|
|
1834
|
+
if (!this.stopped && !this.stoppedConversations.has(conversationId)) {
|
|
1835
|
+
this.scheduleReconnect(conversationId);
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
ws.on("error", (err) => {
|
|
1839
|
+
this.opts.onError?.(err);
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
heartbeatTick() {
|
|
1843
|
+
const hb = this.opts.heartbeat ?? {};
|
|
1844
|
+
const hbEnabled = hb.enable ?? DEFAULT_HEARTBEAT.enable;
|
|
1845
|
+
if (!hbEnabled) return;
|
|
1846
|
+
const idleThresholdMs = hb.idleThresholdMs ?? DEFAULT_HEARTBEAT.idleThresholdMs;
|
|
1847
|
+
const pingIntervalMs = hb.pingIntervalMs ?? DEFAULT_HEARTBEAT.pingIntervalMs;
|
|
1848
|
+
const pongTimeoutMs = hb.pongTimeoutMs ?? DEFAULT_HEARTBEAT.pongTimeoutMs;
|
|
1849
|
+
const maxMissedPongs = hb.maxMissedPongs ?? DEFAULT_HEARTBEAT.maxMissedPongs;
|
|
1850
|
+
const jitter = hb.jitter ?? DEFAULT_HEARTBEAT.jitter;
|
|
1851
|
+
const now = Date.now();
|
|
1852
|
+
for (const [conversationId, ws] of this.connections) {
|
|
1853
|
+
if (ws.readyState !== WebSocket.OPEN) continue;
|
|
1854
|
+
const state = this.heartbeatStates.get(conversationId);
|
|
1855
|
+
if (!state) continue;
|
|
1856
|
+
if (state.pongTimeout) continue;
|
|
1857
|
+
const idleFor = now - state.lastRxAt;
|
|
1858
|
+
if (idleFor < idleThresholdMs) continue;
|
|
1859
|
+
if (now < state.nextPingAt) continue;
|
|
1860
|
+
if (state.lastPingAt !== 0 && now - state.lastPingAt < pingIntervalMs * 0.5) {
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
try {
|
|
1864
|
+
ws.send(JSON.stringify({ type: "hb" }));
|
|
1865
|
+
} catch {
|
|
1866
|
+
}
|
|
1867
|
+
ws.ping();
|
|
1868
|
+
state.lastPingAt = now;
|
|
1869
|
+
const jitterFactor = 1 + (Math.random() - 0.5) * 2 * jitter;
|
|
1870
|
+
state.nextPingAt = now + Math.max(1e3, pingIntervalMs * jitterFactor);
|
|
1871
|
+
state.pongTimeout = setTimeout(() => {
|
|
1872
|
+
state.missedPongs += 1;
|
|
1873
|
+
state.pongTimeout = null;
|
|
1874
|
+
if (state.missedPongs >= maxMissedPongs) {
|
|
1875
|
+
try {
|
|
1876
|
+
ws.close(1001, "pong timeout");
|
|
1877
|
+
} catch {
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}, pongTimeoutMs);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
scheduleReconnect(conversationId) {
|
|
1884
|
+
if (this.reconnectTimers.has(conversationId)) return;
|
|
1885
|
+
const attempt = this.reconnectAttempts.get(conversationId) ?? 0;
|
|
1886
|
+
this.reconnectAttempts.set(conversationId, attempt + 1);
|
|
1887
|
+
const tryConnect = () => {
|
|
1888
|
+
this.reconnectTimers.delete(conversationId);
|
|
1889
|
+
if (this.stopped) return;
|
|
1890
|
+
void this.connectAsync(conversationId);
|
|
1891
|
+
};
|
|
1892
|
+
const delay = Math.min(
|
|
1893
|
+
RECONNECT_BASE_MS * Math.pow(2, attempt) + (Math.random() - 0.5) * RECONNECT_JITTER * RECONNECT_BASE_MS,
|
|
1894
|
+
RECONNECT_MAX_MS
|
|
1895
|
+
);
|
|
1896
|
+
const timer = setTimeout(tryConnect, delay);
|
|
1897
|
+
this.reconnectTimers.set(conversationId, timer);
|
|
1898
|
+
}
|
|
1899
|
+
isSubscribableConversationType(type) {
|
|
1900
|
+
return type === "dm" || type === "pending_dm" || type === "channel" || type === "group";
|
|
1901
|
+
}
|
|
1902
|
+
async connectAll() {
|
|
1903
|
+
try {
|
|
1904
|
+
const convos = await this.opts.listConversations();
|
|
1905
|
+
const subscribable = convos.filter((c) => this.isSubscribableConversationType(c.type));
|
|
1906
|
+
for (const c of subscribable) {
|
|
1907
|
+
void this.connectAsync(c.conversation_id);
|
|
1908
|
+
}
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
async syncConnections() {
|
|
1914
|
+
if (this.stopped) return;
|
|
1915
|
+
try {
|
|
1916
|
+
const convos = await this.opts.listConversations();
|
|
1917
|
+
const subscribableIds = new Set(
|
|
1918
|
+
convos.filter((c) => this.isSubscribableConversationType(c.type)).map((c) => c.conversation_id)
|
|
1919
|
+
);
|
|
1920
|
+
for (const convId of subscribableIds) {
|
|
1921
|
+
if (!this.stoppedConversations.has(convId) && !this.connections.has(convId)) {
|
|
1922
|
+
void this.connectAsync(convId);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
for (const convId of this.connections.keys()) {
|
|
1926
|
+
if (!subscribableIds.has(convId) || this.stoppedConversations.has(convId)) {
|
|
1927
|
+
const ws = this.connections.get(convId);
|
|
1928
|
+
if (ws) {
|
|
1929
|
+
ws.removeAllListeners();
|
|
1930
|
+
ws.close();
|
|
1931
|
+
this.connections.delete(convId);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
} catch {
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
export {
|
|
1941
|
+
HttpTransport,
|
|
1942
|
+
ContactManager,
|
|
1943
|
+
HistoryManager,
|
|
1944
|
+
previewFromPayload,
|
|
1945
|
+
buildSessionKey,
|
|
1946
|
+
SessionManager,
|
|
1947
|
+
TaskThreadManager,
|
|
1948
|
+
PingAgentClient,
|
|
1949
|
+
getRootDir,
|
|
1950
|
+
getProfile,
|
|
1951
|
+
getIdentityPath,
|
|
1952
|
+
getStorePath,
|
|
1953
|
+
generateIdentity,
|
|
1954
|
+
identityExists,
|
|
1955
|
+
loadIdentity,
|
|
1956
|
+
saveIdentity,
|
|
1957
|
+
updateStoredToken,
|
|
1958
|
+
ensureTokenValid,
|
|
1959
|
+
LocalStore,
|
|
1960
|
+
A2AAdapter,
|
|
1961
|
+
WsSubscription
|
|
1962
|
+
};
|