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.
- package/connector/README.md +19 -2
- package/connector/identities.ts +29 -0
- package/connector/run.ts +117 -8
- package/connector/server.ts +11 -7
- package/dist/connector.js +232 -9
- package/dist/server.js +1 -1
- package/package.json +1 -1
package/connector/README.md
CHANGED
|
@@ -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
|
|
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
|
|
package/connector/identities.ts
CHANGED
|
@@ -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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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"
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
});
|
package/connector/server.ts
CHANGED
|
@@ -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
|
|
292
|
-
if (!
|
|
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
|
-
|
|
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))
|
|
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
|
|
312
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
472
|
-
|
|
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.
|
|
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.
|
|
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": {
|