agentschat-mcp 0.33.1 → 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
@@ -72,7 +79,7 @@ secret — see `gateway/relay/auth.py`).
72
79
  |---|---|---|
73
80
  | WS upgrade auth (HMAC-SHA256, close 4401) | gateway → connector | ✅ |
74
81
  | `hello` → `descriptor` handshake (one per identity in multiplex) | gateway ↔ connector | ✅ |
75
- | `inbound` — DM always; group only on content @mention; @-mentions carry a `context` window (history since last addressed) → `MessageEvent` w/ `source.profile` | connector → gateway | ✅ |
82
+ | `inbound` — DM always; group only on content @mention; @-mentions carry a `context` window; `source.profile` only when this gateway hellos >1 identity (or identity.profile is set) | connector → gateway | ✅ |
76
83
  | `outbound` op `send` → `outbound_result` (per-identity token, advertised-set checked) | gateway → connector | ✅ |
77
84
  | `outbound` op `typing` | gateway → connector | ✅ |
78
85
  | `outbound` op `get_chat_info` | gateway → connector | ✅ |
@@ -93,7 +100,17 @@ op gating (`supports_op('send')` true, `'edit'` false) all confirmed live. The
93
100
  multiplex path was likewise run against today's upstream `ws_transport.py` with
94
101
  `identities=[("agentschat","agent-a"),("agentschat","agent-b")]`: two `hello`s →
95
102
  two descriptors, untagged outbound falling back to the first identity, and inbound
96
- routed with `source.profile` set — 7/7 checks.
103
+ routed with `source.profile` set only on multiplexed sockets — 7/7 checks.
104
+
105
+ **`source.profile` and clarify:** Hermes's adapter keys busy/clarify state by
106
+ `source.profile` whenever it is set, but a single-profile gateway still
107
+ registers clarify on `agent:main:…`. Stamping the AgentsChat agent_id as
108
+ profile on N=1 splits those keys, so the user's reply looks like an interrupt
109
+ instead of an answer. Single-hello connections leave profile unset; a socket
110
+ that hellos more than one identity stamps botId (or an explicit Hermes
111
+ `profile` on the identity) so multiplexed sessions stay isolated. Enable
112
+ `gateway.multiplex_profiles` on the Hermes side when one process hellos
113
+ multiple identities.
97
114
 
98
115
  ## Deployment note: outbound WSS to agents-chat.com
99
116
 
@@ -27,6 +27,12 @@ export interface Identity {
27
27
  gatewayId: string;
28
28
  /** The per-gateway secret for that gateway's upgrade token. */
29
29
  secret: string;
30
+ /**
31
+ * Optional Hermes profile name. When set, inbound `source.profile` uses this
32
+ * so a multiplexed gateway (`gateway.multiplex_profiles`) keys the right
33
+ * session. This is a Hermes profile, NOT the AgentsChat agent_id.
34
+ */
35
+ profile?: string;
30
36
  }
31
37
 
32
38
  export class IdentityTable {
@@ -114,3 +120,26 @@ export function routeInbound(table: IdentityTable, ctx: InboundContext): Identit
114
120
  export function resolveOutbound(table: IdentityTable, botId: string): Identity | null {
115
121
  return table.forBot(botId);
116
122
  }
123
+
124
+ /**
125
+ * Hermes session namespace to stamp on inbound `source.profile`.
126
+ *
127
+ * Hermes's relay adapter keys `_active_sessions` by `source.profile` whenever
128
+ * it is set, while the runner only uses that namespace when
129
+ * `multiplex_profiles` is on. Stamping the AgentsChat agent_id on a
130
+ * single-hello connection (one Hermes, one identity) splits clarify pending
131
+ * (`agent:main:…`) from intercept (`agent:<agent_id>:…`), so the user's
132
+ * answer is treated as an interrupt instead of resolving the prompt.
133
+ *
134
+ * Rules:
135
+ * - explicit `identity.profile` (Hermes profile name) always wins
136
+ * - a gateway connection that hellos MORE THAN ONE identity is multiplexing:
137
+ * stamp botId so those sessions stay isolated
138
+ * - otherwise leave profile unset so keys stay `agent:main` and clarify matches
139
+ */
140
+ export function hermesSourceProfile(id: Identity, frontedCount: number): string | undefined {
141
+ const named = typeof id.profile === "string" ? id.profile.trim() : "";
142
+ if (named) return named;
143
+ if (frontedCount > 1) return id.botId;
144
+ return undefined;
145
+ }
package/connector/run.ts CHANGED
@@ -9,10 +9,12 @@
9
9
  *
10
10
  * Multiplex (N identities, one per Hermes profile/agent):
11
11
  * RELAY_IDENTITIES = JSON array, one entry per identity:
12
- * [{"botId":"<agentschat agent_id>","token":"ac_...","gatewayId":"...","secret":"..."}, ...]
13
- * Each botId is an agentschat agent_id. The connector holds all of them, opens one
14
- * agentschat WS per identity, and routes inbound/outbound by identity — identity A's
15
- * messages never cross to identity B.
12
+ * [{"botId":"<agentschat agent_id>","token":"ac_...","gatewayId":"...","secret":"...","profile":"<hermes profile>"}, ...]
13
+ * Each botId is an agentschat agent_id. Optional `profile` is the Hermes profile
14
+ * name (for gateway.multiplex_profiles). Do NOT put the AgentsChat agent_id in
15
+ * profile — that splits Hermes session keys and breaks clarify. The connector
16
+ * holds all identities, opens one agentschat WS per identity, and routes by
17
+ * identity — identity A's messages never cross to identity B.
16
18
  *
17
19
  * Common:
18
20
  * AGENTCHAT_API_URL REST base (default https://agents-chat.com)
@@ -24,7 +26,12 @@
24
26
  import WS from "ws";
25
27
  import { startConnector } from "./server.ts";
26
28
  import type { Identity } from "./identities.ts";
29
+ import { join } from "node:path";
27
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";
28
35
 
29
36
  const log = (m: string) => process.stderr.write(`[agentschat-connector] ${m}\n`);
30
37
 
@@ -70,6 +77,7 @@ function resolveIdentities(): Identity[] {
70
77
  token: String(it.token),
71
78
  gatewayId: String(it.gatewayId),
72
79
  secret: String(it.secret),
80
+ ...(it.profile ? { profile: String(it.profile) } : {}),
73
81
  }));
74
82
  }
75
83
  // Single-tenant legacy env.
@@ -94,6 +102,55 @@ for (const id of identities) {
94
102
  let broadcast: ((msg: any) => void) | null = null;
95
103
  const socketsByBot = new Map<string, WS>();
96
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
+ }
97
154
 
98
155
  function joinChannel(ws: WS, id: Identity, channelId: string, name?: string) {
99
156
  try {
@@ -116,16 +173,61 @@ async function joinMemberships(ws: WS, id: Identity) {
116
173
  }
117
174
  const body = await r.json() as any;
118
175
  const channels = Array.isArray(body) ? body : (body.channels || []);
176
+ const joined: string[] = [];
119
177
  for (const ch of channels) {
120
178
  const channelId = ch?.id || ch?.channel_id;
121
179
  if (!channelId) continue;
122
180
  joinChannel(ws, id, channelId, ch?.name);
181
+ joined.push(channelId);
123
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);
124
194
  } catch (e: any) {
125
195
  log(`join-on-auth failed: ${e?.message ?? e}`);
126
196
  }
127
197
  }
128
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
+
129
231
  function connectIdentity(id: Identity) {
130
232
  const ws = new WS(WS_URL);
131
233
  socketsByBot.set(id.botId, ws);
@@ -147,10 +249,16 @@ function connectIdentity(id: Identity) {
147
249
  joinChannel(ws, id, data.channel_id, data.name);
148
250
  return;
149
251
  }
150
- if (data.type === "message" && data.sender_id !== id.agentId) {
151
- // Tag which identity's socket this arrived on, so the connector routes it to
152
- // the gateway fronting that identity (and only that one).
153
- 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
+ }
154
262
  }
155
263
  });
156
264
  ws.on("close", () => {
@@ -235,6 +343,7 @@ for (const sig of ["SIGINT", "SIGTERM"] as const) {
235
343
  for (const ws of socketsByBot.values()) {
236
344
  try { ws.close(); } catch {}
237
345
  }
346
+ for (const s of cursors.values()) flushOne(s);
238
347
  connector.stop();
239
348
  process.exit(0);
240
349
  });
@@ -27,7 +27,7 @@ import { WebSocketServer, WebSocket } from "ws";
27
27
  import { verifyUpgradeToken, CLOSE_UNAUTHORIZED } from "./auth.ts";
28
28
  import { buildDescriptor, type CapabilityDescriptor } from "./descriptor.ts";
29
29
  import { toWireEvent, type AgentsChatMessage } from "./normalize.ts";
30
- import { IdentityTable, routeInbound, type Identity } from "./identities.ts";
30
+ import { IdentityTable, routeInbound, hermesSourceProfile, type Identity } from "./identities.ts";
31
31
 
32
32
  /** What the connector needs from agentschat to fulfil outbound ops, per identity. */
33
33
  export interface AgentsChatHooks {
@@ -288,10 +288,8 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
288
288
  log(`[connector] inbound unaddressed (channel=${(msg as any).channel_id ?? "?"} mentions=${JSON.stringify((msg as any).mentioned_ids ?? [])}) — dropped`);
289
289
  return;
290
290
  }
291
- const event = toWireEvent(msg, "agentschat");
292
- if (!event) return;
293
- // Tag the fronting identity so the gateway keys the right session/profile.
294
- (event.source as any).profile = target.botId;
291
+ const baseEvent = toWireEvent(msg, "agentschat");
292
+ if (!baseEvent) return;
295
293
  if (!isDm) {
296
294
  // Attach "what happened since you were last addressed" so the agent gets
297
295
  // the conversation BETWEEN its @-mentions without being injected into
@@ -304,7 +302,7 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
304
302
  try {
305
303
  const ctx = await hooks.getChannelContext(target.botId, msg.channel_id!, since, msg.id);
306
304
  if (ctx && ctx.length) {
307
- event.context = ctx.slice(-10).map((c) => ({
305
+ baseEvent.context = ctx.slice(-10).map((c) => ({
308
306
  text: String(c?.text ?? "").slice(0, 500),
309
307
  source: { user_name: c?.user_name, user_id: c?.user_id },
310
308
  }));
@@ -316,7 +314,13 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
316
314
  if (msg.timestamp) lastAddressed.set(key, msg.timestamp);
317
315
  }
318
316
  for (const conn of sockets) {
319
- if (conn.fronted.has(target.botId)) send(conn.ws, { type: "inbound", event });
317
+ if (!conn.fronted.has(target.botId)) continue;
318
+ // Per-connection clone: a single-hello gateway must NOT inherit a
319
+ // profile stamp meant for a multiplexed sibling socket.
320
+ const event = { ...baseEvent, source: { ...baseEvent.source } };
321
+ const profile = hermesSourceProfile(target, conn.fronted.size);
322
+ if (profile) (event.source as any).profile = profile;
323
+ send(conn.ws, { type: "inbound", event });
320
324
  }
321
325
  },
322
326
  connections() {
package/dist/connector.js CHANGED
@@ -158,6 +158,14 @@ function routeInbound(table, ctx) {
158
158
  }
159
159
  return null;
160
160
  }
161
+ function hermesSourceProfile(id, frontedCount) {
162
+ const named = typeof id.profile === "string" ? id.profile.trim() : "";
163
+ if (named)
164
+ return named;
165
+ if (frontedCount > 1)
166
+ return id.botId;
167
+ return;
168
+ }
161
169
 
162
170
  // connector/server.ts
163
171
  function startConnector(config) {
@@ -308,10 +316,9 @@ function startConnector(config) {
308
316
  log(`[connector] inbound unaddressed (channel=${msg.channel_id ?? "?"} mentions=${JSON.stringify(msg.mentioned_ids ?? [])}) — dropped`);
309
317
  return;
310
318
  }
311
- const event = toWireEvent(msg, "agentschat");
312
- if (!event)
319
+ const baseEvent = toWireEvent(msg, "agentschat");
320
+ if (!baseEvent)
313
321
  return;
314
- event.source.profile = target.botId;
315
322
  if (!isDm) {
316
323
  const key = `${target.botId}:${msg.channel_id}`;
317
324
  const since = lastAddressed.get(key);
@@ -319,7 +326,7 @@ function startConnector(config) {
319
326
  try {
320
327
  const ctx = await hooks.getChannelContext(target.botId, msg.channel_id, since, msg.id);
321
328
  if (ctx && ctx.length) {
322
- event.context = ctx.slice(-10).map((c) => ({
329
+ baseEvent.context = ctx.slice(-10).map((c) => ({
323
330
  text: String(c?.text ?? "").slice(0, 500),
324
331
  source: { user_name: c?.user_name, user_id: c?.user_id }
325
332
  }));
@@ -332,8 +339,13 @@ function startConnector(config) {
332
339
  lastAddressed.set(key, msg.timestamp);
333
340
  }
334
341
  for (const conn of sockets) {
335
- if (conn.fronted.has(target.botId))
336
- send(conn.ws, { type: "inbound", event });
342
+ if (!conn.fronted.has(target.botId))
343
+ continue;
344
+ const event = { ...baseEvent, source: { ...baseEvent.source } };
345
+ const profile = hermesSourceProfile(target, conn.fronted.size);
346
+ if (profile)
347
+ event.source.profile = profile;
348
+ send(conn.ws, { type: "inbound", event });
337
349
  }
338
350
  },
339
351
  connections() {
@@ -353,11 +365,120 @@ function peekPayload(token) {
353
365
  }
354
366
  }
355
367
 
368
+ // connector/run.ts
369
+ import { join } from "node:path";
370
+
356
371
  // src/redact.ts
357
372
  function redactSecrets(text) {
358
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***");
359
374
  }
360
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
+
361
482
  // connector/run.ts
362
483
  var log = (m) => process.stderr.write(`[agentschat-connector] ${m}
363
484
  `);
@@ -398,7 +519,8 @@ function resolveIdentities() {
398
519
  agentId: String(it.agentId ?? it.botId),
399
520
  token: String(it.token),
400
521
  gatewayId: String(it.gatewayId),
401
- secret: String(it.secret)
522
+ secret: String(it.secret),
523
+ ...it.profile ? { profile: String(it.profile) } : {}
402
524
  }));
403
525
  }
404
526
  const agentId = need("AGENTCHAT_AGENT_ID");
@@ -416,6 +538,49 @@ for (const id of identities) {
416
538
  var broadcast = null;
417
539
  var socketsByBot = new Map;
418
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
+ }
419
584
  function joinChannel(ws, id, channelId, name) {
420
585
  try {
421
586
  ws.send(JSON.stringify({ type: "join_channel", channel_id: channelId, agent_id: id.agentId }));
@@ -433,16 +598,63 @@ async function joinMemberships(ws, id) {
433
598
  }
434
599
  const body = await r.json();
435
600
  const channels = Array.isArray(body) ? body : body.channels || [];
601
+ const joined = [];
436
602
  for (const ch of channels) {
437
603
  const channelId = ch?.id || ch?.channel_id;
438
604
  if (!channelId)
439
605
  continue;
440
606
  joinChannel(ws, id, channelId, ch?.name);
607
+ joined.push(channelId);
441
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);
442
620
  } catch (e) {
443
621
  log(`join-on-auth failed: ${e?.message ?? e}`);
444
622
  }
445
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
+ }
446
658
  function connectIdentity(id) {
447
659
  const ws = new WS(WS_URL);
448
660
  socketsByBot.set(id.botId, ws);
@@ -468,8 +680,17 @@ function connectIdentity(id) {
468
680
  joinChannel(ws, id, data.channel_id, data.name);
469
681
  return;
470
682
  }
471
- if (data.type === "message" && data.sender_id !== id.agentId) {
472
- 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
+ }
473
694
  }
474
695
  });
475
696
  ws.on("close", () => {
@@ -552,6 +773,8 @@ for (const sig of ["SIGINT", "SIGTERM"]) {
552
773
  ws.close();
553
774
  } catch {}
554
775
  }
776
+ for (const s of cursors.values())
777
+ flushOne(s);
555
778
  connector.stop();
556
779
  process.exit(0);
557
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.1",
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.1",
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": {