agentschat-mcp 0.33.2 → 0.33.3

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.
@@ -54,6 +54,13 @@ After `auth_ok` the connector GETs `/api/channels/mine` and sends `join_channel`
54
54
  for each membership, and again on `channel_created`. The server only pushes
55
55
  DM/@ frames to sockets that have joined; auth alone is not enough.
56
56
 
57
+ **Read cursor (same as stdio MCP):** each identity persists
58
+ `last-seen-msg-ts-<botId>.json` (channel → last seen timestamp) under
59
+ `AGENTCHAT_CURSOR_DIR` or the process cwd. On every `auth_ok` it REST-backfills
60
+ messages strictly after that watermark through the same inject path as live WS
61
+ (empty cursor seeds from newest and does not replay). Live frames and backfill
62
+ share a message-id dedup so a reconnect race does not double-inject.
63
+
57
64
  **Inbound gating (all modes):** the AgentsChat WS pushes every message of every
58
65
  joined channel. The connector injects into the gateway only what is ADDRESSED to
59
66
  an identity — DMs always, group messages only when the body @mentions it (same
package/connector/run.ts CHANGED
@@ -26,7 +26,12 @@
26
26
  import WS from "ws";
27
27
  import { startConnector } from "./server.ts";
28
28
  import type { Identity } from "./identities.ts";
29
+ import { join } from "node:path";
29
30
  import { redactSecrets } from "../src/redact.ts";
31
+ import { flushCursor, loadCursor, persistCursor } from "../src/read-cursor.ts";
32
+ import { normalizeTimestampForCursor } from "../src/timestamps.ts";
33
+ import { MessageDedup, messageDedupKey } from "../src/dedup.ts";
34
+ import { planBackfill } from "./backfill.ts";
30
35
 
31
36
  const log = (m: string) => process.stderr.write(`[agentschat-connector] ${m}\n`);
32
37
 
@@ -97,6 +102,55 @@ for (const id of identities) {
97
102
  let broadcast: ((msg: any) => void) | null = null;
98
103
  const socketsByBot = new Map<string, WS>();
99
104
  const backoffByBot = new Map<string, number>();
105
+ const backfillTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
106
+ const dedup = new MessageDedup();
107
+
108
+ // Per-identity last-seen cursor (channel → timestamp). Same files/shape as
109
+ // stdio MCP (`last-seen-msg-ts-<agent>.json`): reconnect REST-replays the
110
+ // gap after this watermark; empty cursor seeds from newest and does not replay.
111
+ const CURSOR_DIR = process.env.AGENTCHAT_CURSOR_DIR || process.cwd();
112
+ const cursorFlushMs = Math.max(500, Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000));
113
+
114
+ type CursorStore = {
115
+ file: string;
116
+ map: Map<string, string>;
117
+ state: { dirty: boolean };
118
+ timer: ReturnType<typeof setTimeout> | null;
119
+ };
120
+ const cursors = new Map<string, CursorStore>();
121
+
122
+ function cursorFor(id: Identity): CursorStore {
123
+ let s = cursors.get(id.botId);
124
+ if (s) return s;
125
+ const file = join(CURSOR_DIR, `last-seen-msg-ts-${id.botId}.json`);
126
+ s = { file, map: loadCursor(file, (m) => log(m.trimEnd())), state: { dirty: false }, timer: null };
127
+ cursors.set(id.botId, s);
128
+ return s;
129
+ }
130
+
131
+ function flushOne(s: CursorStore) {
132
+ if (s.timer) { clearTimeout(s.timer); s.timer = null; }
133
+ flushCursor(s.state, () => persistCursor(s.file, s.map, (m) => log(m.trimEnd())));
134
+ }
135
+
136
+ function scheduleSave(s: CursorStore) {
137
+ s.state.dirty = true;
138
+ if (s.timer) return;
139
+ s.timer = setTimeout(() => { s.timer = null; flushOne(s); }, cursorFlushMs);
140
+ (s.timer as any).unref?.();
141
+ }
142
+
143
+ function advanceCursor(id: Identity, channelId: string | undefined, timestamp: string | undefined) {
144
+ if (typeof channelId !== "string" || typeof timestamp !== "string" || !timestamp) return;
145
+ const s = cursorFor(id);
146
+ const prev = s.map.get(channelId) || "";
147
+ const currentTs = normalizeTimestampForCursor(timestamp, "after") || timestamp;
148
+ const prevTs = normalizeTimestampForCursor(prev, "after") || prev;
149
+ if (currentTs > prevTs) {
150
+ s.map.set(channelId, timestamp);
151
+ scheduleSave(s);
152
+ }
153
+ }
100
154
 
101
155
  function joinChannel(ws: WS, id: Identity, channelId: string, name?: string) {
102
156
  try {
@@ -119,16 +173,61 @@ async function joinMemberships(ws: WS, id: Identity) {
119
173
  }
120
174
  const body = await r.json() as any;
121
175
  const channels = Array.isArray(body) ? body : (body.channels || []);
176
+ const joined: string[] = [];
122
177
  for (const ch of channels) {
123
178
  const channelId = ch?.id || ch?.channel_id;
124
179
  if (!channelId) continue;
125
180
  joinChannel(ws, id, channelId, ch?.name);
181
+ joined.push(channelId);
126
182
  }
183
+ // Same delay as MCP Task #119: let channel_created + gateway hello settle,
184
+ // then REST-replay anything after the persisted last-seen cursor.
185
+ const prev = backfillTimerByBot.get(id.botId);
186
+ if (prev) clearTimeout(prev);
187
+ const t = setTimeout(() => {
188
+ backfillTimerByBot.delete(id.botId);
189
+ if ((process as any).__shutdown) return;
190
+ void backfillIdentity(id, joined);
191
+ }, 2000);
192
+ (t as any).unref?.();
193
+ backfillTimerByBot.set(id.botId, t);
127
194
  } catch (e: any) {
128
195
  log(`join-on-auth failed: ${e?.message ?? e}`);
129
196
  }
130
197
  }
131
198
 
199
+ async function backfillIdentity(id: Identity, channelIds: string[]) {
200
+ const s = cursorFor(id);
201
+ for (const channelId of channelIds) {
202
+ try {
203
+ const after = s.map.get(channelId);
204
+ const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
205
+ const r = await fetch(`${API}/api/channels/${encodeURIComponent(channelId)}/messages${params}`, {
206
+ headers: { Authorization: `Bearer ${id.token}` },
207
+ });
208
+ if (!r.ok) continue;
209
+ const msgs = (((await r.json()) as any)?.messages ?? []) as any[];
210
+ const plan = planBackfill(after, msgs, id.agentId);
211
+ if (plan.seed) {
212
+ s.map.set(channelId, plan.seed);
213
+ scheduleSave(s);
214
+ continue;
215
+ }
216
+ if (!plan.replay.length) continue;
217
+ log(`backfill ${id.botId} ${channelId}: ${plan.replay.length} missed msg(s)`);
218
+ for (const m of plan.replay) {
219
+ const frame = { ...m, type: "message", channel_id: m.channel_id ?? channelId, __botId: id.botId, __source: "backfill" };
220
+ const key = messageDedupKey(frame);
221
+ if (key && dedup.recordOrSkip(key)) continue;
222
+ advanceCursor(id, frame.channel_id, frame.timestamp);
223
+ broadcast?.(frame);
224
+ }
225
+ } catch (e: any) {
226
+ log(`backfill failed for ${id.botId} ${channelId}: ${e?.message ?? e}`);
227
+ }
228
+ }
229
+ }
230
+
132
231
  function connectIdentity(id: Identity) {
133
232
  const ws = new WS(WS_URL);
134
233
  socketsByBot.set(id.botId, ws);
@@ -150,10 +249,16 @@ function connectIdentity(id: Identity) {
150
249
  joinChannel(ws, id, data.channel_id, data.name);
151
250
  return;
152
251
  }
153
- if (data.type === "message" && data.sender_id !== id.agentId) {
154
- // Tag which identity's socket this arrived on, so the connector routes it to
155
- // the gateway fronting that identity (and only that one).
156
- broadcast?.({ ...data, __botId: id.botId });
252
+ if (data.type === "message") {
253
+ if (!data.__source) data.__source = "live";
254
+ const key = messageDedupKey(data);
255
+ if (key && dedup.recordOrSkip(key)) return;
256
+ // Advance on every frame (including unaddressed / self) so reconnect
257
+ // backfill resumes after what we actually saw, not only what we injected.
258
+ if (data.content !== "__typing__") advanceCursor(id, data.channel_id, data.timestamp);
259
+ if (data.sender_id !== id.agentId && data.content !== "__typing__") {
260
+ broadcast?.({ ...data, __botId: id.botId });
261
+ }
157
262
  }
158
263
  });
159
264
  ws.on("close", () => {
@@ -238,6 +343,7 @@ for (const sig of ["SIGINT", "SIGTERM"] as const) {
238
343
  for (const ws of socketsByBot.values()) {
239
344
  try { ws.close(); } catch {}
240
345
  }
346
+ for (const s of cursors.values()) flushOne(s);
241
347
  connector.stop();
242
348
  process.exit(0);
243
349
  });
package/dist/connector.js CHANGED
@@ -365,11 +365,120 @@ function peekPayload(token) {
365
365
  }
366
366
  }
367
367
 
368
+ // connector/run.ts
369
+ import { join } from "node:path";
370
+
368
371
  // src/redact.ts
369
372
  function redactSecrets(text) {
370
373
  return text.replace(/ac_[A-Za-z0-9_-]{16,}/g, "ac_***REDACTED***").replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "***JWT_REDACTED***");
371
374
  }
372
375
 
376
+ // src/read-cursor.ts
377
+ import { readFileSync, writeFileSync } from "fs";
378
+ function loadCursor(file, warn) {
379
+ try {
380
+ return new Map(Object.entries(JSON.parse(readFileSync(file, "utf-8"))));
381
+ } catch (e) {
382
+ if (e?.code !== "ENOENT") {
383
+ warn(`[agentchat] WARNING: could not read ${file} — resetting that state: ${e}
384
+ `);
385
+ }
386
+ return new Map;
387
+ }
388
+ }
389
+ function persistCursor(file, cursor, warn) {
390
+ try {
391
+ writeFileSync(file, JSON.stringify(Object.fromEntries(cursor)));
392
+ return true;
393
+ } catch (e) {
394
+ warn(`[agentchat] WARNING: failed to persist read cursor to ${file}: ${e}
395
+ `);
396
+ return false;
397
+ }
398
+ }
399
+ function flushCursor(state, persist) {
400
+ if (!state.dirty)
401
+ return false;
402
+ const ok = persist();
403
+ if (ok)
404
+ state.dirty = false;
405
+ return ok;
406
+ }
407
+
408
+ // src/timestamps.ts
409
+ function normalizeTimestampForCursor(ts, mode) {
410
+ if (!ts || typeof ts !== "string")
411
+ return ts;
412
+ const padChar = mode === "before" ? "9" : "0";
413
+ const withFrac = ts.match(/^(.*\.)(\d+)(Z)$/);
414
+ if (withFrac) {
415
+ const frac = withFrac[2];
416
+ if (frac.length >= 9)
417
+ return ts;
418
+ return withFrac[1] + frac + padChar.repeat(9 - frac.length) + withFrac[3];
419
+ }
420
+ const noFrac = ts.match(/^(.*\d)(Z)$/);
421
+ if (noFrac) {
422
+ return noFrac[1] + "." + padChar.repeat(9) + noFrac[2];
423
+ }
424
+ return ts;
425
+ }
426
+
427
+ // src/dedup.ts
428
+ function messageDedupKey(data) {
429
+ if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string")
430
+ return null;
431
+ return `${data.channel_id}:${data.id}`;
432
+ }
433
+
434
+ class MessageDedup {
435
+ max;
436
+ dropOnEvict;
437
+ seen = new Set;
438
+ constructor(max = 5000, dropOnEvict = 1000) {
439
+ this.max = max;
440
+ this.dropOnEvict = dropOnEvict;
441
+ }
442
+ recordOrSkip(key) {
443
+ if (this.seen.has(key))
444
+ return true;
445
+ this.seen.add(key);
446
+ if (this.seen.size > this.max) {
447
+ const arr = [...this.seen];
448
+ this.seen.clear();
449
+ for (const item of arr.slice(this.dropOnEvict))
450
+ this.seen.add(item);
451
+ }
452
+ return false;
453
+ }
454
+ get size() {
455
+ return this.seen.size;
456
+ }
457
+ }
458
+
459
+ // connector/backfill.ts
460
+ function planBackfill(after, msgs, agentId) {
461
+ const list = Array.isArray(msgs) ? msgs : [];
462
+ if (!after) {
463
+ let newest = "";
464
+ for (const m of list) {
465
+ const t = String(m?.timestamp || "");
466
+ if (t > newest)
467
+ newest = t;
468
+ }
469
+ return newest ? { seed: newest, replay: [] } : { replay: [] };
470
+ }
471
+ const afterTs = normalizeTimestampForCursor(after, "after") || after;
472
+ const replay = list.filter((m) => {
473
+ if (!m || m.sender_id === agentId || m.content === "__typing__")
474
+ return false;
475
+ const msgTs = normalizeTimestampForCursor(m.timestamp, "after");
476
+ return typeof msgTs === "string" && msgTs > afterTs;
477
+ });
478
+ replay.sort((a, b) => String(a.timestamp ?? "").localeCompare(String(b.timestamp ?? "")));
479
+ return { replay };
480
+ }
481
+
373
482
  // connector/run.ts
374
483
  var log = (m) => process.stderr.write(`[agentschat-connector] ${m}
375
484
  `);
@@ -429,6 +538,49 @@ for (const id of identities) {
429
538
  var broadcast = null;
430
539
  var socketsByBot = new Map;
431
540
  var backoffByBot = new Map;
541
+ var backfillTimerByBot = new Map;
542
+ var dedup = new MessageDedup;
543
+ var CURSOR_DIR = process.env.AGENTCHAT_CURSOR_DIR || process.cwd();
544
+ var cursorFlushMs = Math.max(500, Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000));
545
+ var cursors = new Map;
546
+ function cursorFor(id) {
547
+ let s = cursors.get(id.botId);
548
+ if (s)
549
+ return s;
550
+ const file = join(CURSOR_DIR, `last-seen-msg-ts-${id.botId}.json`);
551
+ s = { file, map: loadCursor(file, (m) => log(m.trimEnd())), state: { dirty: false }, timer: null };
552
+ cursors.set(id.botId, s);
553
+ return s;
554
+ }
555
+ function flushOne(s) {
556
+ if (s.timer) {
557
+ clearTimeout(s.timer);
558
+ s.timer = null;
559
+ }
560
+ flushCursor(s.state, () => persistCursor(s.file, s.map, (m) => log(m.trimEnd())));
561
+ }
562
+ function scheduleSave(s) {
563
+ s.state.dirty = true;
564
+ if (s.timer)
565
+ return;
566
+ s.timer = setTimeout(() => {
567
+ s.timer = null;
568
+ flushOne(s);
569
+ }, cursorFlushMs);
570
+ s.timer.unref?.();
571
+ }
572
+ function advanceCursor(id, channelId, timestamp) {
573
+ if (typeof channelId !== "string" || typeof timestamp !== "string" || !timestamp)
574
+ return;
575
+ const s = cursorFor(id);
576
+ const prev = s.map.get(channelId) || "";
577
+ const currentTs = normalizeTimestampForCursor(timestamp, "after") || timestamp;
578
+ const prevTs = normalizeTimestampForCursor(prev, "after") || prev;
579
+ if (currentTs > prevTs) {
580
+ s.map.set(channelId, timestamp);
581
+ scheduleSave(s);
582
+ }
583
+ }
432
584
  function joinChannel(ws, id, channelId, name) {
433
585
  try {
434
586
  ws.send(JSON.stringify({ type: "join_channel", channel_id: channelId, agent_id: id.agentId }));
@@ -446,16 +598,63 @@ async function joinMemberships(ws, id) {
446
598
  }
447
599
  const body = await r.json();
448
600
  const channels = Array.isArray(body) ? body : body.channels || [];
601
+ const joined = [];
449
602
  for (const ch of channels) {
450
603
  const channelId = ch?.id || ch?.channel_id;
451
604
  if (!channelId)
452
605
  continue;
453
606
  joinChannel(ws, id, channelId, ch?.name);
607
+ joined.push(channelId);
454
608
  }
609
+ const prev = backfillTimerByBot.get(id.botId);
610
+ if (prev)
611
+ clearTimeout(prev);
612
+ const t = setTimeout(() => {
613
+ backfillTimerByBot.delete(id.botId);
614
+ if (process.__shutdown)
615
+ return;
616
+ backfillIdentity(id, joined);
617
+ }, 2000);
618
+ t.unref?.();
619
+ backfillTimerByBot.set(id.botId, t);
455
620
  } catch (e) {
456
621
  log(`join-on-auth failed: ${e?.message ?? e}`);
457
622
  }
458
623
  }
624
+ async function backfillIdentity(id, channelIds) {
625
+ const s = cursorFor(id);
626
+ for (const channelId of channelIds) {
627
+ try {
628
+ const after = s.map.get(channelId);
629
+ const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
630
+ const r = await fetch(`${API}/api/channels/${encodeURIComponent(channelId)}/messages${params}`, {
631
+ headers: { Authorization: `Bearer ${id.token}` }
632
+ });
633
+ if (!r.ok)
634
+ continue;
635
+ const msgs = (await r.json())?.messages ?? [];
636
+ const plan = planBackfill(after, msgs, id.agentId);
637
+ if (plan.seed) {
638
+ s.map.set(channelId, plan.seed);
639
+ scheduleSave(s);
640
+ continue;
641
+ }
642
+ if (!plan.replay.length)
643
+ continue;
644
+ log(`backfill ${id.botId} ${channelId}: ${plan.replay.length} missed msg(s)`);
645
+ for (const m of plan.replay) {
646
+ const frame = { ...m, type: "message", channel_id: m.channel_id ?? channelId, __botId: id.botId, __source: "backfill" };
647
+ const key = messageDedupKey(frame);
648
+ if (key && dedup.recordOrSkip(key))
649
+ continue;
650
+ advanceCursor(id, frame.channel_id, frame.timestamp);
651
+ broadcast?.(frame);
652
+ }
653
+ } catch (e) {
654
+ log(`backfill failed for ${id.botId} ${channelId}: ${e?.message ?? e}`);
655
+ }
656
+ }
657
+ }
459
658
  function connectIdentity(id) {
460
659
  const ws = new WS(WS_URL);
461
660
  socketsByBot.set(id.botId, ws);
@@ -481,8 +680,17 @@ function connectIdentity(id) {
481
680
  joinChannel(ws, id, data.channel_id, data.name);
482
681
  return;
483
682
  }
484
- if (data.type === "message" && data.sender_id !== id.agentId) {
485
- broadcast?.({ ...data, __botId: id.botId });
683
+ if (data.type === "message") {
684
+ if (!data.__source)
685
+ data.__source = "live";
686
+ const key = messageDedupKey(data);
687
+ if (key && dedup.recordOrSkip(key))
688
+ return;
689
+ if (data.content !== "__typing__")
690
+ advanceCursor(id, data.channel_id, data.timestamp);
691
+ if (data.sender_id !== id.agentId && data.content !== "__typing__") {
692
+ broadcast?.({ ...data, __botId: id.botId });
693
+ }
486
694
  }
487
695
  });
488
696
  ws.on("close", () => {
@@ -565,6 +773,8 @@ for (const sig of ["SIGINT", "SIGTERM"]) {
565
773
  ws.close();
566
774
  } catch {}
567
775
  }
776
+ for (const s of cursors.values())
777
+ flushOne(s);
568
778
  connector.stop();
569
779
  process.exit(0);
570
780
  });
package/dist/server.js CHANGED
@@ -361,7 +361,7 @@ async function fireGrokWake(msg, cfg) {
361
361
  var package_default = {
362
362
  name: "agentschat-mcp",
363
363
  mcpName: "io.github.swswordholy-tech/agentschat-mcp",
364
- version: "0.33.2",
364
+ version: "0.33.3",
365
365
  description: "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
366
366
  type: "module",
367
367
  bin: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.33.2",
4
+ "version": "0.33.3",
5
5
  "description": "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
6
6
  "type": "module",
7
7
  "bin": {