pingerchips-js 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/chat.js +585 -0
  2. package/index.js +835 -43
  3. package/package.json +3 -2
  4. package/spaces.js +414 -0
package/chat.js ADDED
@@ -0,0 +1,585 @@
1
+ import { Pingerchips } from "./index.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // PingerchipsChat — entry point for the chat Session API
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * Usage (client):
9
+ * const chat = new PingerchipsChat(appKey, { authEndpoint: "/auth/chat" });
10
+ * const session = await chat.connect(threadId, { clientId: "user-123" });
11
+ * const run = await session.view.send("Hello");
12
+ * run.toJSON(); // → { threadId, runId } — POST this to your agent endpoint
13
+ *
14
+ * Usage (agent — prefer AgentSession from pingerchips-js-server):
15
+ * const chat = new PingerchipsChat(appKey);
16
+ * const session = await chat.connectAgent(threadId, { secret, agentId });
17
+ */
18
+ export class PingerchipsChat {
19
+ constructor(appKey, options = {}) {
20
+ if (!appKey) throw new Error("appKey required");
21
+ this.appKey = appKey;
22
+ this._options = options;
23
+ this._realtime =
24
+ options.realtime ||
25
+ new Pingerchips(appKey, {
26
+ endpoint: options.endpoint,
27
+ params: options.params,
28
+ serializer: options.serializer,
29
+ messageFormat: options.messageFormat,
30
+ reconnectAfterMs: options.reconnectAfterMs,
31
+ rejoinAfterMs: options.rejoinAfterMs,
32
+ });
33
+ if (options.autoConnect === false) this._realtime.disconnect();
34
+ this._sessions = {};
35
+ }
36
+
37
+ /**
38
+ * Connect as a human client. Fetches signed auth token from authEndpoint.
39
+ * @param {string} threadId
40
+ * @param {{ clientId: string, afterLogId?: number }} options
41
+ * @returns {Promise<ChatSession>}
42
+ */
43
+ async connect(threadId, { clientId, afterLogId } = {}) {
44
+ if (!clientId) throw new Error("clientId required");
45
+
46
+ const socketId = this._realtime.getSocketIdAsync
47
+ ? await this._realtime.getSocketIdAsync()
48
+ : this._realtime.socketId;
49
+ if (!socketId) throw new Error("Socket ID not available");
50
+
51
+ const joinParams = { client_id: clientId };
52
+ if (afterLogId != null) joinParams.after_log_id = afterLogId;
53
+
54
+ if (this._options.authEndpoint || this._options.authCallback) {
55
+ joinParams.auth = await this._resolveAuthToken(threadId, clientId, socketId);
56
+ }
57
+
58
+ return this._joinSession(threadId, joinParams);
59
+ }
60
+
61
+ /**
62
+ * Connect as an agent. Server-side only — never call from browser.
63
+ * @param {string} threadId
64
+ * @param {{ secret: string, agentId: string, afterLogId?: number }} options
65
+ * @returns {Promise<ChatSession>}
66
+ */
67
+ async connectAgent(threadId, { secret, agentId, afterLogId } = {}) {
68
+ if (!secret) throw new Error("secret required");
69
+ if (!agentId) throw new Error("agentId required");
70
+
71
+ const joinParams = { secret, agent_id: agentId };
72
+ if (afterLogId != null) joinParams.after_log_id = afterLogId;
73
+
74
+ return this._joinSession(threadId, joinParams);
75
+ }
76
+
77
+ async _resolveAuthToken(threadId, clientId, socketId) {
78
+ const params = {
79
+ socket_id: socketId,
80
+ thread_id: threadId,
81
+ client_id: clientId,
82
+ };
83
+
84
+ if (this._options.authCallback) {
85
+ const result = await invokeAuthCallback(this._options.authCallback, params);
86
+ return authToken(result);
87
+ }
88
+
89
+ const doFetch = this._options.fetch ?? globalThis.fetch;
90
+ const response = await doFetch(this._options.authEndpoint, {
91
+ method: "POST",
92
+ headers: {
93
+ "Content-Type": "application/json",
94
+ ...this._options.authHeaders,
95
+ },
96
+ body: JSON.stringify({
97
+ ...(this._options.authParams ?? {}),
98
+ ...params,
99
+ }),
100
+ });
101
+
102
+ if (!response.ok) {
103
+ const err = await response.json().catch(() => ({ error: "Auth failed" }));
104
+ throw new Error(err.error || "Auth failed");
105
+ }
106
+
107
+ return authToken(await response.json());
108
+ }
109
+
110
+ _joinSession(threadId, joinParams) {
111
+ const topic = `chat:v1:app:${this.appKey}:thread:${threadId}`;
112
+ const channel = this._realtime.socket.channel(topic, joinParams);
113
+
114
+ return new Promise((resolve, reject) => {
115
+ channel
116
+ .join()
117
+ .receive("ok", () => {
118
+ this._sessions[threadId]?.close();
119
+ const session = new ChatSession(channel, threadId, this.appKey, () => {
120
+ if (this._sessions[threadId] === session) delete this._sessions[threadId];
121
+ });
122
+ this._sessions[threadId] = session;
123
+ resolve(session);
124
+ })
125
+ .receive("error", (resp) =>
126
+ reject(
127
+ new Error(
128
+ `Failed to join chat thread ${threadId}: ${JSON.stringify(resp)}`,
129
+ ),
130
+ ),
131
+ );
132
+ });
133
+ }
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // ChatSession
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export class ChatSession {
141
+ constructor(channel, threadId, appKey, onClose) {
142
+ this.threadId = threadId;
143
+ this.appKey = appKey;
144
+ this.view = new View(channel, threadId);
145
+ this._channel = channel;
146
+ this._onClose = onClose;
147
+ this._closed = false;
148
+ }
149
+
150
+ cancel(runId) {
151
+ return this.push("cancel", { runId });
152
+ }
153
+
154
+ push(event, payload) {
155
+ return new Promise((resolve, reject) => {
156
+ this._channel
157
+ .push(event, payload)
158
+ .receive("ok", resolve)
159
+ .receive("error", (e) =>
160
+ reject(new Error(e.reason || `${event} failed`)),
161
+ );
162
+ });
163
+ }
164
+
165
+ close() {
166
+ if (this._closed) return;
167
+ this._closed = true;
168
+ this.view.close();
169
+ this._channel.leave();
170
+ this._onClose?.();
171
+ }
172
+
173
+ on(event, callback) {
174
+ this.view.on(event, callback);
175
+ return this;
176
+ }
177
+
178
+ off(event, callback) {
179
+ this.view.off(event, callback);
180
+ return this;
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // View
186
+ // ---------------------------------------------------------------------------
187
+
188
+ export class View {
189
+ constructor(channel, threadId) {
190
+ this._channel = channel;
191
+ this._threadId = threadId;
192
+ this._messages = [];
193
+ this._runs = {};
194
+ this._streams = {};
195
+ this._hasOlder = false;
196
+ this._logId = 0;
197
+ this._handlers = {};
198
+ this._siblingIndex = {};
199
+
200
+ this._channelRefs = [
201
+ ["snapshot", channel.on("snapshot", (p) => this._onSnapshot(p))],
202
+ ["change", channel.on("change", (p) => this._onChange(p))],
203
+ ["batch", channel.on("batch", (p) => this._onBatch(p))],
204
+ ["run:start", channel.on("run:start", (p) => this._onRunStart(p))],
205
+ ["run:end", channel.on("run:end", (p) => this._onRunEnd(p))],
206
+ ["run:suspend", channel.on("run:suspend", (p) => this._onRunSuspend(p))],
207
+ ["run:resume", channel.on("run:resume", (p) => this._onRunResume(p))],
208
+ ["tree:delta", channel.on("tree:delta", (p) => this._onTreeDelta(p))],
209
+ ];
210
+ }
211
+
212
+ // ── Read ──────────────────────────────────────────────────────────────────
213
+
214
+ getMessages() {
215
+ return this._buildBranch();
216
+ }
217
+ getChannelMessages() {
218
+ return [...this._messages];
219
+ }
220
+ get logId() {
221
+ return this._logId;
222
+ }
223
+ runs() {
224
+ return Object.values(this._runs);
225
+ }
226
+ hasOlder() {
227
+ return this._hasOlder;
228
+ }
229
+ getStream(runId) {
230
+ return this._streams[runId] ?? "";
231
+ }
232
+
233
+ // ── Write ─────────────────────────────────────────────────────────────────
234
+
235
+ send(text, { parentId, messageId = crypto.randomUUID(), runId = crypto.randomUUID(), role = "user" } = {}) {
236
+ const payload = { messageId, runId, text, role };
237
+ if (parentId != null) payload.parentId = parentId;
238
+
239
+ return new Promise((resolve, reject) => {
240
+ this._channel
241
+ .push("send", payload)
242
+ .receive("ok", (ack) =>
243
+ resolve(
244
+ new ActiveRun(runId, this._threadId, this._channel, {
245
+ messageId,
246
+ serial: ack?.serial,
247
+ status: ack?.status,
248
+ }),
249
+ ),
250
+ )
251
+ .receive("error", (e) => reject(new Error(e.reason || "send failed")));
252
+ });
253
+ }
254
+
255
+ regenerate(messageId, { runId = crypto.randomUUID(), parentId } = {}) {
256
+ const payload = { messageId, runId };
257
+ if (parentId != null) payload.parentId = parentId;
258
+
259
+ return new Promise((resolve, reject) => {
260
+ this._channel
261
+ .push("regenerate", payload)
262
+ .receive("ok", (ack) =>
263
+ resolve(
264
+ new ActiveRun(runId, this._threadId, this._channel, {
265
+ regeneratesMessageId: messageId,
266
+ serial: ack?.serial,
267
+ }),
268
+ ),
269
+ )
270
+ .receive("error", (e) =>
271
+ reject(new Error(e.reason || "regenerate failed")),
272
+ );
273
+ });
274
+ }
275
+
276
+ edit(messageId, content, { newMessageId = crypto.randomUUID(), runId = crypto.randomUUID() } = {}) {
277
+
278
+ return new Promise((resolve, reject) => {
279
+ this._channel
280
+ .push("edit", { messageId, text: content, newMessageId, runId })
281
+ .receive("ok", (ack) =>
282
+ resolve(
283
+ new ActiveRun(runId, this._threadId, this._channel, {
284
+ messageId: newMessageId,
285
+ forkOf: messageId,
286
+ serial: ack?.serial,
287
+ status: ack?.status,
288
+ }),
289
+ ),
290
+ )
291
+ .receive("error", (e) => reject(new Error(e.reason || "edit failed")));
292
+ });
293
+ }
294
+
295
+ loadOlder(limit = 50) {
296
+ const oldest = this._messages[0];
297
+ if (!oldest || !this._hasOlder) return Promise.resolve([]);
298
+
299
+ return new Promise((resolve, reject) => {
300
+ this._channel
301
+ .push("load_older", { beforeMessageId: oldest.messageId, limit })
302
+ .receive("ok", ({ messages, has_older }) => {
303
+ this._messages = [...messages, ...this._messages];
304
+ this._hasOlder = has_older;
305
+ this._emit("update");
306
+ resolve(messages);
307
+ })
308
+ .receive("error", (e) =>
309
+ reject(new Error(e.reason || "load_older failed")),
310
+ );
311
+ });
312
+ }
313
+
314
+ selectSibling(parentId, messageId) {
315
+ this._siblingIndex[parentId] = messageId;
316
+ this._emit("update");
317
+ }
318
+
319
+ approveTool(runId, toolCallId) {
320
+ return this._pushToolApproval(runId, toolCallId, true);
321
+ }
322
+
323
+ rejectTool(runId, toolCallId) {
324
+ return this._pushToolApproval(runId, toolCallId, false);
325
+ }
326
+
327
+ _pushToolApproval(runId, toolCallId, approved) {
328
+ return new Promise((resolve, reject) => {
329
+ this._channel
330
+ .push("tool_approval", { runId, toolCallId, approved })
331
+ .receive("ok", resolve)
332
+ .receive("error", (e) =>
333
+ reject(new Error(e.reason || "tool_approval failed")),
334
+ );
335
+ });
336
+ }
337
+
338
+ on(event, callback) {
339
+ (this._handlers[event] ??= []).push(callback);
340
+ return this;
341
+ }
342
+
343
+ off(event, callback) {
344
+ if (!callback) {
345
+ delete this._handlers[event];
346
+ } else {
347
+ this._handlers[event] = (this._handlers[event] || []).filter(
348
+ (h) => h !== callback,
349
+ );
350
+ }
351
+ return this;
352
+ }
353
+
354
+ // ── Server event handlers ─────────────────────────────────────────────────
355
+
356
+ _onSnapshot({ state, log_id, has_older }) {
357
+ this._messages = state.messages ?? [];
358
+ this._runs = state.runs ?? {};
359
+ this._hasOlder = has_older ?? false;
360
+ this._logId = log_id ?? 0;
361
+ this._streams = {};
362
+
363
+ for (const [key, value] of Object.entries(state)) {
364
+ if (key.startsWith("stream:")) this._streams[key.slice(7)] = value;
365
+ }
366
+
367
+ this._emit("update");
368
+ }
369
+
370
+ _onChange({ key, value, log_id }) {
371
+ if (log_id != null) this._logId = log_id;
372
+
373
+ if (key === "messages") {
374
+ this._messages = value ?? [];
375
+ this._emit("update");
376
+ } else if (key.startsWith("stream:")) {
377
+ const runId = key.slice(7);
378
+ if (value === null) delete this._streams[runId];
379
+ else this._streams[runId] = value;
380
+ this._emit("update");
381
+ } else if (key.startsWith("run:")) {
382
+ const runId = key.slice(4);
383
+ if (value === null) delete this._runs[runId];
384
+ else this._runs[runId] = value;
385
+ this._emit("run", this._runs[runId] ?? { runId, status: null });
386
+ }
387
+ }
388
+
389
+ _onBatch({ changes, log_id }) {
390
+ if (log_id != null) this._logId = log_id;
391
+
392
+ const changedRuns = [];
393
+ for (const { key, value } of changes) {
394
+ if (key === "messages") {
395
+ this._messages = value ?? [];
396
+ } else if (key.startsWith("stream:")) {
397
+ const runId = key.slice(7);
398
+ if (value === null) delete this._streams[runId];
399
+ else this._streams[runId] = value;
400
+ } else if (key.startsWith("run:")) {
401
+ const runId = key.slice(4);
402
+ if (value === null) delete this._runs[runId];
403
+ else this._runs[runId] = value;
404
+ changedRuns.push(this._runs[runId] ?? { runId, status: null });
405
+ }
406
+ }
407
+
408
+ for (const run of changedRuns) this._emit("run", run);
409
+ this._emit("update");
410
+ }
411
+
412
+ _onRunStart({ runId, ownerClientId, startedAt }) {
413
+ this._runs[runId] = {
414
+ runId,
415
+ ownerClientId,
416
+ status: "active",
417
+ startedAt,
418
+ endedAt: null,
419
+ suspendedTool: null,
420
+ };
421
+ this._emit("run", this._runs[runId]);
422
+ this._emit("run:start", this._runs[runId]);
423
+ }
424
+
425
+ _onRunEnd({ runId, reason, endedAt }) {
426
+ if (this._runs[runId])
427
+ this._runs[runId] = { ...this._runs[runId], status: reason, endedAt };
428
+ delete this._streams[runId];
429
+ this._emit("run", this._runs[runId] ?? { runId, status: reason });
430
+ this._emit("run:end", { runId, reason, endedAt });
431
+ }
432
+
433
+ _onRunSuspend({ runId, toolCallId, toolName, args }) {
434
+ if (this._runs[runId]) {
435
+ this._runs[runId] = {
436
+ ...this._runs[runId],
437
+ status: "suspended",
438
+ suspendedTool: { toolCallId, toolName, args },
439
+ };
440
+ }
441
+ this._emit("run", this._runs[runId]);
442
+ this._emit("run:suspended", { runId, toolCallId, toolName, args });
443
+ this._emit("run:suspend", { runId, toolCallId, toolName, args });
444
+ }
445
+
446
+ _onRunResume({ runId }) {
447
+ if (this._runs[runId]) {
448
+ this._runs[runId] = {
449
+ ...this._runs[runId],
450
+ status: "active",
451
+ suspendedTool: null,
452
+ };
453
+ }
454
+ this._emit("run", this._runs[runId]);
455
+ this._emit("run:resume", { runId });
456
+ }
457
+
458
+ _onTreeDelta({ node }) {
459
+ if (!node || !node.id) return;
460
+ const idx = this._messages.findIndex((m) => m.messageId === node.id);
461
+ // Normalise the server node shape back to the wire message shape the
462
+ // branch resolver expects.
463
+ const msg = {
464
+ messageId: node.id,
465
+ parentId: node.parent_id ?? null,
466
+ forkOf: node.fork_of ?? null,
467
+ msgRegenerate: node.msg_regenerate ?? null,
468
+ runId: node.run_id ?? null,
469
+ ownerClientId: node.owner_client_id ?? null,
470
+ role: node.role,
471
+ status: node.status,
472
+ content: node.content,
473
+ createdAt: node.created_at,
474
+ };
475
+ if (idx === -1) this._messages = [...this._messages, msg];
476
+ else this._messages = this._messages.map((m, i) => (i === idx ? msg : m));
477
+ this._emit("update");
478
+ }
479
+
480
+ // ── Branch resolution ─────────────────────────────────────────────────────
481
+
482
+ _buildBranch() {
483
+ const byId = new Map(this._messages.map((m) => [m.messageId, m]));
484
+ const children = new Map();
485
+
486
+ for (const { messageId, parentId } of this._messages) {
487
+ const pid = parentId ?? "__root__";
488
+ (children.get(pid) ?? children.set(pid, []).get(pid)).push(messageId);
489
+ }
490
+
491
+ const branch = [];
492
+ let cursor = "__root__";
493
+
494
+ while (children.has(cursor)) {
495
+ const siblings = children.get(cursor);
496
+ const selected = this._siblingIndex[cursor];
497
+ const next =
498
+ selected && siblings.includes(selected)
499
+ ? selected
500
+ : siblings[siblings.length - 1];
501
+
502
+ const msg = byId.get(next);
503
+ if (!msg) break;
504
+ branch.push(msg);
505
+ cursor = msg.messageId;
506
+ }
507
+
508
+ return branch;
509
+ }
510
+
511
+ _emit(event, payload) {
512
+ for (const h of this._handlers[event] || []) h(payload);
513
+ }
514
+
515
+ close() {
516
+ for (const [event, ref] of this._channelRefs) this._channel.off(event, ref);
517
+ this._channelRefs = [];
518
+ this._handlers = {};
519
+ }
520
+ }
521
+
522
+ function authToken(value) {
523
+ const token = typeof value === "string" ? value : value?.auth;
524
+ if (!token || typeof token !== "string") {
525
+ throw new Error("Chat auth must return a JWT string or { auth: jwt }");
526
+ }
527
+ return token;
528
+ }
529
+
530
+ function invokeAuthCallback(callback, params) {
531
+ return new Promise((resolve, reject) => {
532
+ let settled = false;
533
+ const done = (error, value) => {
534
+ if (settled) return;
535
+ settled = true;
536
+ error ? reject(error) : resolve(value);
537
+ };
538
+
539
+ try {
540
+ const result = callback(params, done);
541
+ if (result?.then) result.then((value) => done(null, value), done);
542
+ else if (result !== undefined) done(null, result);
543
+ } catch (error) {
544
+ done(error);
545
+ }
546
+ });
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // ActiveRun
551
+ // ---------------------------------------------------------------------------
552
+
553
+ export class ActiveRun {
554
+ constructor(runId, threadId, channel, meta = {}) {
555
+ this.runId = runId;
556
+ this._threadId = threadId;
557
+ this._channel = channel;
558
+ // Publish-ack metadata (ADR-0011): the codec-message-id the client owns
559
+ // for this send/edit, the fork/regenerate anchor, and the channel serial.
560
+ this.messageId = meta.messageId ?? null;
561
+ this.forkOf = meta.forkOf ?? null;
562
+ this.regeneratesMessageId = meta.regeneratesMessageId ?? null;
563
+ this.serial = meta.serial ?? null;
564
+ this.status = meta.status ?? "ok";
565
+ }
566
+
567
+ cancel() {
568
+ return new Promise((resolve, reject) => {
569
+ this._channel
570
+ .push("cancel", { runId: this.runId })
571
+ .receive("ok", resolve)
572
+ .receive("error", (e) =>
573
+ reject(new Error(e.reason || "cancel failed")),
574
+ );
575
+ });
576
+ }
577
+
578
+ toJSON() {
579
+ return { threadId: this._threadId, runId: this.runId };
580
+ }
581
+
582
+ toInvocation() {
583
+ return { toJSON: () => this.toJSON() };
584
+ }
585
+ }