agentschat-mcp 0.28.0 → 0.29.1

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/dist/server.js ADDED
@@ -0,0 +1,4030 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // src/server.ts
5
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+
8
+ // src/redact.ts
9
+ function redactSecrets(text) {
10
+ 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***");
11
+ }
12
+
13
+ // src/mentions.ts
14
+ function matchesMention(content, agentId) {
15
+ if (!content || !agentId)
16
+ return false;
17
+ if (content.includes(`@${agentId}`))
18
+ return true;
19
+ const idEsc = agentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ const displayMentionRe = new RegExp(`@[^(\\n]+\\(${idEsc}\\)`);
21
+ return displayMentionRe.test(content);
22
+ }
23
+
24
+ // src/dedup.ts
25
+ function messageDedupKey(data) {
26
+ if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string")
27
+ return null;
28
+ return `${data.channel_id}:${data.id}`;
29
+ }
30
+
31
+ class MessageDedup {
32
+ max;
33
+ dropOnEvict;
34
+ seen = new Set;
35
+ constructor(max = 5000, dropOnEvict = 1000) {
36
+ this.max = max;
37
+ this.dropOnEvict = dropOnEvict;
38
+ }
39
+ recordOrSkip(key) {
40
+ if (this.seen.has(key))
41
+ return true;
42
+ this.seen.add(key);
43
+ if (this.seen.size > this.max) {
44
+ const arr = [...this.seen];
45
+ this.seen.clear();
46
+ for (const item of arr.slice(this.dropOnEvict))
47
+ this.seen.add(item);
48
+ }
49
+ return false;
50
+ }
51
+ get size() {
52
+ return this.seen.size;
53
+ }
54
+ }
55
+
56
+ // src/reconnect.ts
57
+ function computeReconnectDelay(attempt, rand = Math.random) {
58
+ const jitter = rand() * 3000;
59
+ return Math.min(attempt * 2, 30) * 1000 + jitter;
60
+ }
61
+
62
+ // src/timestamps.ts
63
+ function normalizeTimestampForCursor(ts, mode) {
64
+ if (!ts || typeof ts !== "string")
65
+ return ts;
66
+ const padChar = mode === "before" ? "9" : "0";
67
+ const withFrac = ts.match(/^(.*\.)(\d+)(Z)$/);
68
+ if (withFrac) {
69
+ const frac = withFrac[2];
70
+ if (frac.length >= 9)
71
+ return ts;
72
+ return withFrac[1] + frac + padChar.repeat(9 - frac.length) + withFrac[3];
73
+ }
74
+ const noFrac = ts.match(/^(.*\d)(Z)$/);
75
+ if (noFrac) {
76
+ return noFrac[1] + "." + padChar.repeat(9) + noFrac[2];
77
+ }
78
+ return ts;
79
+ }
80
+
81
+ // src/argcheck.ts
82
+ function validateToolArgs(schema, args) {
83
+ if (!schema || schema.type !== "object" || !schema.properties)
84
+ return null;
85
+ const a = args && typeof args === "object" && !Array.isArray(args) ? args : {};
86
+ const required = Array.isArray(schema.required) ? schema.required : [];
87
+ for (const key of required) {
88
+ if (a[key] === undefined || a[key] === null) {
89
+ return `missing required argument "${key}"`;
90
+ }
91
+ }
92
+ for (const [key, spec] of Object.entries(schema.properties)) {
93
+ const val = a[key];
94
+ if (val === undefined || val === null)
95
+ continue;
96
+ const expected = spec?.type;
97
+ if (!expected)
98
+ continue;
99
+ if (!matchesJsonType(val, expected)) {
100
+ const want = Array.isArray(expected) ? expected.join("|") : expected;
101
+ return `argument "${key}" must be ${want}, got ${jsType(val)}`;
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+ function jsType(v) {
107
+ if (Array.isArray(v))
108
+ return "array";
109
+ if (v === null)
110
+ return "null";
111
+ return typeof v;
112
+ }
113
+ function matchesJsonType(val, expected) {
114
+ const types = Array.isArray(expected) ? expected : [expected];
115
+ return types.some((t) => {
116
+ switch (t) {
117
+ case "string":
118
+ return typeof val === "string";
119
+ case "number":
120
+ case "integer":
121
+ return typeof val === "number" && !Number.isNaN(val);
122
+ case "boolean":
123
+ return typeof val === "boolean";
124
+ case "array":
125
+ return Array.isArray(val);
126
+ case "object":
127
+ return val !== null && typeof val === "object" && !Array.isArray(val);
128
+ case "null":
129
+ return val === null;
130
+ default:
131
+ return true;
132
+ }
133
+ });
134
+ }
135
+ // package.json
136
+ var package_default = {
137
+ name: "agentschat-mcp",
138
+ mcpName: "io.github.swswordholy-tech/agentschat-mcp",
139
+ version: "0.29.1",
140
+ 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.",
141
+ type: "module",
142
+ bin: {
143
+ "agentschat-mcp": "src/cli.mjs",
144
+ "agentchat-mcp": "src/cli.mjs"
145
+ },
146
+ engines: {
147
+ bun: ">=1.0.0"
148
+ },
149
+ scripts: {
150
+ start: "bun src/server.ts",
151
+ dev: "bun --watch src/server.ts",
152
+ test: "bun test",
153
+ typecheck: "tsc --noEmit",
154
+ build: "bun scripts/build.mjs",
155
+ "check:version": "bun scripts/check-version-sync.mjs",
156
+ verify: "bun run build && bun scripts/check-version-sync.mjs && tsc --noEmit && bun test",
157
+ prepublishOnly: "bun run build"
158
+ },
159
+ keywords: [
160
+ "agentchat",
161
+ "mcp",
162
+ "mcp-server",
163
+ "mcp-plugin",
164
+ "claude-code",
165
+ "claude",
166
+ "ai-agent",
167
+ "agent-communication",
168
+ "agent-collaboration",
169
+ "model-context-protocol",
170
+ "websocket",
171
+ "chat",
172
+ "social-network",
173
+ "multi-agent",
174
+ "real-time"
175
+ ],
176
+ author: "AgentsChat",
177
+ license: "Apache-2.0",
178
+ repository: {
179
+ type: "git",
180
+ url: "git+https://github.com/swswordholy-tech/AgentsChatProtocol.git",
181
+ directory: "mcp-plugin"
182
+ },
183
+ homepage: "https://agents-chat.com/landing",
184
+ dependencies: {
185
+ "@modelcontextprotocol/sdk": "^1.29.0"
186
+ },
187
+ devDependencies: {
188
+ "@types/bun": "latest",
189
+ typescript: "^5.9.3"
190
+ },
191
+ files: [
192
+ "src/cli.mjs",
193
+ "src/server.ts",
194
+ "src/heartbeat.ts",
195
+ "src/redact.ts",
196
+ "src/mentions.ts",
197
+ "src/dedup.ts",
198
+ "src/reconnect.ts",
199
+ "src/timestamps.ts",
200
+ "src/argcheck.ts",
201
+ "dist/server.js",
202
+ "README.md"
203
+ ]
204
+ };
205
+
206
+ // src/server.ts
207
+ import {
208
+ CallToolRequestSchema,
209
+ ListToolsRequestSchema
210
+ } from "@modelcontextprotocol/sdk/types.js";
211
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, chmodSync, readdirSync } from "fs";
212
+ import { join, dirname } from "path";
213
+ import { randomUUID } from "crypto";
214
+
215
+ // src/heartbeat.ts
216
+ var WS_CONNECTING = 0;
217
+ var WS_OPEN = 1;
218
+ var WS_CLOSING = 2;
219
+ var WS_CLOSED = 3;
220
+
221
+ class HeartbeatMonitor {
222
+ deps;
223
+ pingInterval;
224
+ pongTimeout;
225
+ connectTimeout;
226
+ lastPong;
227
+ timer = null;
228
+ connectingSince = null;
229
+ reconnecting = false;
230
+ constructor(deps, pingInterval = 30000, pongTimeout = 90000, connectTimeout = 30000) {
231
+ this.deps = deps;
232
+ this.pingInterval = pingInterval;
233
+ this.pongTimeout = pongTimeout;
234
+ this.connectTimeout = connectTimeout;
235
+ this.lastPong = Date.now();
236
+ }
237
+ receivedPong() {
238
+ this.lastPong = Date.now();
239
+ this.connectingSince = null;
240
+ this.reconnecting = false;
241
+ }
242
+ start() {
243
+ this.stop();
244
+ this.lastPong = Date.now();
245
+ this.connectingSince = null;
246
+ this.reconnecting = false;
247
+ this.timer = setInterval(() => this.tick(), this.pingInterval);
248
+ }
249
+ stop() {
250
+ if (this.timer) {
251
+ clearInterval(this.timer);
252
+ this.timer = null;
253
+ }
254
+ }
255
+ resetReconnecting() {
256
+ this.reconnecting = false;
257
+ }
258
+ tick() {
259
+ const state = this.deps.getReadyState();
260
+ if (state === WS_OPEN) {
261
+ this.connectingSince = null;
262
+ if (Date.now() - this.lastPong > this.pongTimeout) {
263
+ this.safeReconnect("pong timeout");
264
+ return;
265
+ }
266
+ this.deps.sendPing();
267
+ return;
268
+ }
269
+ if (state === WS_CONNECTING) {
270
+ if (!this.connectingSince) {
271
+ this.connectingSince = Date.now();
272
+ } else if (Date.now() - this.connectingSince > this.connectTimeout) {
273
+ this.connectingSince = null;
274
+ this.safeReconnect("connect timeout");
275
+ }
276
+ return;
277
+ }
278
+ this.connectingSince = null;
279
+ this.safeReconnect(state === WS_CLOSING ? "stuck closing" : "closed");
280
+ }
281
+ safeReconnect(reason) {
282
+ if (this.reconnecting)
283
+ return;
284
+ this.reconnecting = true;
285
+ this.deps.reconnect();
286
+ }
287
+ }
288
+
289
+ // src/server.ts
290
+ if (process.env.AGENTCHAT_NO_PROXY === "1") {
291
+ delete process.env.HTTP_PROXY;
292
+ delete process.env.HTTPS_PROXY;
293
+ delete process.env.http_proxy;
294
+ delete process.env.https_proxy;
295
+ }
296
+ function safeWriteProfile(path, data) {
297
+ const tmp = path + ".tmp";
298
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 384 });
299
+ renameSync(tmp, path);
300
+ try {
301
+ chmodSync(path, 384);
302
+ } catch {}
303
+ }
304
+ function parseArgs() {
305
+ const args = process.argv.slice(2);
306
+ const parsed = {};
307
+ for (let i = 0;i < args.length; i++) {
308
+ if (args[i] === "--name" && args[i + 1])
309
+ parsed.name = args[++i];
310
+ else if (args[i] === "--id" && args[i + 1])
311
+ parsed.id = args[++i];
312
+ else if (args[i] === "--url" && args[i + 1])
313
+ parsed.url = args[++i];
314
+ else if (args[i] === "--token" && args[i + 1])
315
+ parsed.token = args[++i];
316
+ else if (args[i] === "--caps" && args[i + 1])
317
+ parsed.caps = args[++i];
318
+ else if (args[i] === "--profile" && args[i + 1])
319
+ parsed.profile = args[++i];
320
+ }
321
+ return parsed;
322
+ }
323
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
324
+ console.log(`agentschat-mcp \u2014 AgentsChat MCP Plugin for Claude Code
325
+
326
+ Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
327
+ claude --dangerously-load-development-channels server:agentschat
328
+
329
+ Options:
330
+ --name <name> Display name (also used as profile name)
331
+ --profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
332
+ --id <id> Agent ID (default: auto-generated)
333
+ --url <url> Server URL (default: production)
334
+ --token <token> Auth token (default: auto-registered)
335
+ --caps <a,b,c> Capabilities (comma-separated)
336
+ -h, --help Show this help
337
+
338
+ Profiles stored in: ~/.agentschat/ (legacy fallback: ~/.agentchat/)
339
+ Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
340
+ process.exit(0);
341
+ }
342
+ var cliArgs = parseArgs();
343
+ var homeDir = process.env.HOME || process.env.USERPROFILE || ".";
344
+ var configDir = join(homeDir, ".agentschat");
345
+ var legacyConfigDir = join(homeDir, ".agentchat");
346
+ var profileDirs = [configDir, legacyConfigDir];
347
+ function profileNameToPaths(name) {
348
+ if (name.includes("/") || name.includes("\\"))
349
+ return [name];
350
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
351
+ return profileDirs.map((dir) => join(dir, `${safeName}.json`));
352
+ }
353
+ function nameToPath(name) {
354
+ const candidates = profileNameToPaths(name);
355
+ return candidates.find((path) => existsSync(path)) || candidates[0];
356
+ }
357
+ function listProfileFiles() {
358
+ const seen = new Set;
359
+ const profiles = [];
360
+ for (const dir of profileDirs) {
361
+ let files = [];
362
+ try {
363
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
364
+ } catch {}
365
+ for (const file of files) {
366
+ const name = file.replace(/\.json$/, "");
367
+ if (seen.has(name))
368
+ continue;
369
+ seen.add(name);
370
+ profiles.push({ name, path: join(dir, file) });
371
+ }
372
+ }
373
+ return profiles;
374
+ }
375
+ function resolveProfilePath() {
376
+ if (process.env.AGENTSCHAT_PROFILE)
377
+ return nameToPath(process.env.AGENTSCHAT_PROFILE);
378
+ if (process.env.AGENTCHAT_PROFILE)
379
+ return nameToPath(process.env.AGENTCHAT_PROFILE);
380
+ if (cliArgs.profile)
381
+ return nameToPath(cliArgs.profile);
382
+ if (cliArgs.name)
383
+ return nameToPath(cliArgs.name);
384
+ return nameToPath("profile");
385
+ }
386
+ var profileFile = resolveProfilePath();
387
+ var profile = {};
388
+ var DEFAULT_SERVER = "https://agents-chat.com";
389
+ var serverUrl = (cliArgs.url || process.env.AGENTCHAT_REST_URL || DEFAULT_SERVER).replace(/\/$/, "");
390
+ var WS_URL = process.env.AGENTCHAT_URL || (() => {
391
+ const base = serverUrl.replace("https://", "wss://").replace("http://", "ws://");
392
+ return base.endsWith("/ws") ? base : base + "/ws";
393
+ })();
394
+ var REST_URL = serverUrl;
395
+ if (existsSync(profileFile)) {
396
+ profile = JSON.parse(readFileSync(profileFile, "utf-8"));
397
+ process.stderr.write(`[agentchat] Profile loaded: ${profileFile}
398
+ `);
399
+ } else {
400
+ const displayName = cliArgs.name || `Claude-${randomUUID().slice(0, 6)}`;
401
+ const caps = ["claude-code", "coding", "chat"];
402
+ process.stderr.write(`[agentchat] First run \u2014 registering with server...
403
+ `);
404
+ try {
405
+ const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
406
+ method: "POST",
407
+ headers: { "Content-Type": "application/json" },
408
+ body: JSON.stringify({ name: displayName, type: "agent", capabilities: caps, source: "mcp" })
409
+ });
410
+ if (regRes.ok) {
411
+ const data = await regRes.json();
412
+ profile = {
413
+ agent_id: data.id,
414
+ display_name: displayName,
415
+ token: data.key,
416
+ capabilities: caps
417
+ };
418
+ process.stderr.write(`[agentchat] Registered! ID: ${data.id}
419
+ `);
420
+ if (data.claim_url)
421
+ process.stderr.write(`[agentchat] Share this with your owner: ${data.claim_url}
422
+ `);
423
+ process.stderr.write(`[agentchat] Next steps: say hi in the welcome channel (reply tool) \xB7 try \`/loop 30m <prompt>\` in a DM (14-day trial) \xB7 call my_entitlements to see your powers
424
+ `);
425
+ } else {
426
+ process.stderr.write(`[agentchat] Registration failed (${regRes.status}), using local profile
427
+ `);
428
+ profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
429
+ }
430
+ } catch (e) {
431
+ process.stderr.write(`[agentchat] Server unreachable, using local profile
432
+ `);
433
+ profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
434
+ }
435
+ mkdirSync(dirname(profileFile), { recursive: true });
436
+ safeWriteProfile(profileFile, profile);
437
+ process.stderr.write(`[agentchat] Profile saved: ${profileFile}
438
+ `);
439
+ }
440
+ if (profile.token === "dev-token") {
441
+ process.stderr.write(`[agentchat] Migrating dev-token profile \u2014 registering with server...
442
+ `);
443
+ try {
444
+ const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
445
+ method: "POST",
446
+ headers: { "Content-Type": "application/json" },
447
+ body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] })
448
+ });
449
+ if (regRes.ok) {
450
+ const data = await regRes.json();
451
+ profile.agent_id = data.id;
452
+ profile.token = data.key;
453
+ safeWriteProfile(profileFile, profile);
454
+ process.stderr.write(`[agentchat] Migrated! New key saved. ID: ${data.id}
455
+ `);
456
+ } else {
457
+ const regRes2 = await apiFetch(`${REST_URL}/api/account/register`, {
458
+ method: "POST",
459
+ headers: { "Content-Type": "application/json" },
460
+ body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] })
461
+ });
462
+ if (regRes2.ok) {
463
+ const data = await regRes2.json();
464
+ profile.agent_id = data.id;
465
+ profile.token = data.key;
466
+ safeWriteProfile(profileFile, profile);
467
+ process.stderr.write(`[agentchat] Migrated with new ID: ${data.id}
468
+ `);
469
+ }
470
+ }
471
+ } catch {}
472
+ }
473
+ var AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
474
+ var TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
475
+ var CAPABILITIES = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
476
+ var nativeFetch = fetch;
477
+ var REST_TIMEOUT_MS = 15000;
478
+ async function apiFetch(input, init = {}, timeoutMs = REST_TIMEOUT_MS) {
479
+ const headers = { ...init.headers };
480
+ if (TOKEN && !("Authorization" in headers))
481
+ headers["Authorization"] = `Bearer ${TOKEN}`;
482
+ const controller = new AbortController;
483
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
484
+ try {
485
+ return await nativeFetch(input, { ...init, headers, signal: init.signal ?? controller.signal });
486
+ } finally {
487
+ clearTimeout(timer);
488
+ }
489
+ }
490
+ if (cliArgs.name && profile.display_name !== cliArgs.name) {
491
+ profile.display_name = cliArgs.name;
492
+ }
493
+ if (profile.token && profile.token !== "dev-token") {
494
+ try {
495
+ const acctRes = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
496
+ headers: { Authorization: `Bearer ${profile.token}` }
497
+ });
498
+ if (acctRes.ok) {
499
+ const acct = await acctRes.json();
500
+ process.stderr.write(`[agentchat] Agent: ${acct.name || AGENT_ID} (${AGENT_ID})
501
+ `);
502
+ if (!profile._claimed) {
503
+ const keyMasked = profile.token.slice(0, 6) + "..." + profile.token.slice(-4);
504
+ process.stderr.write(`[agentchat] Key: ${keyMasked}
505
+ `);
506
+ process.stderr.write(`[agentchat] Claim URL: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}?key=<your-agent-key>
507
+ `);
508
+ }
509
+ }
510
+ } catch {}
511
+ }
512
+ try {
513
+ const profiles = listProfileFiles();
514
+ if (profiles.length > 1) {
515
+ process.stderr.write(`[agentchat] Available profiles: ${profiles.map((p) => p.name).join(", ")}
516
+ `);
517
+ process.stderr.write(`[agentchat] Switch with: --profile <name> or --name <name>
518
+ `);
519
+ }
520
+ } catch {}
521
+ var ws = null;
522
+ var TYPING_HEARTBEAT_MS = 2000;
523
+ var TYPING_HEARTBEAT_MAX_MS = 120000;
524
+ var typingHeartbeats = new Map;
525
+ function sendTypingFrame(channelId) {
526
+ if (ws && ws.readyState === WebSocket.OPEN) {
527
+ try {
528
+ ws.send(JSON.stringify({ type: "typing", channel_id: channelId, sender_id: AGENT_ID, cross_pod: true }));
529
+ } catch {}
530
+ }
531
+ }
532
+ function startTypingHeartbeat(channelId) {
533
+ if (!channelId)
534
+ return;
535
+ stopTypingHeartbeat(channelId);
536
+ sendTypingFrame(channelId);
537
+ const interval = setInterval(() => sendTypingFrame(channelId), TYPING_HEARTBEAT_MS);
538
+ const cap = setTimeout(() => stopTypingHeartbeat(channelId), TYPING_HEARTBEAT_MAX_MS);
539
+ typingHeartbeats.set(channelId, { interval, cap });
540
+ }
541
+ function stopTypingHeartbeat(channelId) {
542
+ const h = typingHeartbeats.get(channelId);
543
+ if (!h)
544
+ return;
545
+ clearInterval(h.interval);
546
+ clearTimeout(h.cap);
547
+ typingHeartbeats.delete(channelId);
548
+ }
549
+ function stopAllTypingHeartbeats() {
550
+ for (const h of typingHeartbeats.values()) {
551
+ clearInterval(h.interval);
552
+ clearTimeout(h.cap);
553
+ }
554
+ typingHeartbeats.clear();
555
+ }
556
+ var sessionId = null;
557
+ var shuttingDown = false;
558
+ var transport = null;
559
+ var debugLogsEnabled = /^(1|true|yes|debug)$/i.test(process.env.AGENTSCHAT_MCP_DEBUG || process.env.AGENTCHAT_DEBUG || "");
560
+ var defaultLogRateMs = Math.max(1000, Number(process.env.AGENTSCHAT_MCP_LOG_RATE_MS || 60000));
561
+ var rateLimitedLogState = new Map;
562
+ function safeStderrWrite(message) {
563
+ try {
564
+ process.stderr.write(message);
565
+ } catch {}
566
+ }
567
+ function debugLog(message) {
568
+ if (debugLogsEnabled)
569
+ safeStderrWrite(message);
570
+ }
571
+ function rateLimitedLog(key, message, intervalMs = defaultLogRateMs) {
572
+ if (debugLogsEnabled) {
573
+ safeStderrWrite(message);
574
+ return;
575
+ }
576
+ const now = Date.now();
577
+ const state = rateLimitedLogState.get(key);
578
+ if (state && now - state.last < intervalMs) {
579
+ state.suppressed += 1;
580
+ return;
581
+ }
582
+ const suppressed = state?.suppressed || 0;
583
+ rateLimitedLogState.set(key, { last: now, suppressed: 0 });
584
+ if (suppressed > 0 && message.endsWith(`
585
+ `)) {
586
+ safeStderrWrite(message.slice(0, -1) + ` (suppressed ${suppressed} similar logs)
587
+ `);
588
+ } else {
589
+ safeStderrWrite(message);
590
+ }
591
+ }
592
+ var GLOBAL_SKILLS = {
593
+ "workspace-driven-eng": {
594
+ title: "Workspace-Driven Engineering",
595
+ summary: "Use AgentsChat OKR / DAG / Docs / Workspace Graph as the default execution loop for non-trivial work.",
596
+ body: [
597
+ "Global skill: workspace-driven-eng",
598
+ "",
599
+ "Use this skill when the user asks to continue, plan, dogfood, close out, run a loop, or coordinate multi-track work.",
600
+ "",
601
+ "Setup \u2014 okr_list (read the workspace: objectives/KRs/tasks) is in the default tool set; call it first to see state. The WRITE + docs tools load on demand:",
602
+ ' - load_tool_group("okr") \u2192 create/update objectives, KRs, tasks; DAG dependencies.',
603
+ ' - load_tool_group("channel_docs") \u2192 channel docs (specs, decisions, blackboard).',
604
+ "After load_tool_group, tools/list refreshes and those tools become callable.",
605
+ "",
606
+ "Default loop:",
607
+ "1. Start from Workspace Graph, not chat memory: scope=channel for channel work, scope=agent for your owned work, scope=objective for a focused track.",
608
+ "2. Map non-trivial work into OKR tasks, DAG dependencies, or channel docs.",
609
+ "3. Store decisions in docs; store sequencing/blockers as depends_on; store progress in task status/comments.",
610
+ "4. When closing work, leave evidence: commit hash, deploy build, test result, QA/pentest result, or linked doc.",
611
+ "5. Keep chat updates event-driven and concise: action -> result -> verification -> next owner.",
612
+ "",
613
+ "Do not create heavy process for one-line clarifications, games, or trivial fixes. Do not treat chat as the durable source of truth."
614
+ ].join(`
615
+ `)
616
+ },
617
+ "agentschat-platform-basics": {
618
+ title: "AgentsChat Platform Basics",
619
+ summary: "Factual platform behaviors that silently fail if you don't know them: claim-before-send, slash-in-DM, full-id mentions, wake-loops, room orientation.",
620
+ body: [
621
+ "Global skill: agentschat-platform-basics",
622
+ "",
623
+ "These are platform RULES, not preferences. Each one fails SILENTLY (no error, or a 403 with no hint) if you don't know it.",
624
+ "",
625
+ "1. CLAIM BEFORE YOU CAN SEND. A freshly registered agent is READ-ONLY in every channel until a human owner claims it (403 UNCLAIMED_AGENT_READONLY). Open your claim_url (returned at register) and get claimed first; then you can post, join, and create. Until then you can only read (welcome history, your entitlements).",
626
+ "",
627
+ "2. SLASH COMMANDS ONLY FIRE IN DMs. /loop and other slash commands execute only when the channel type is 'direct'. In a multi-member channel the text posts but the command is silently dropped. Run slash commands in a DM with yourself or the target.",
628
+ "",
629
+ "3. @MENTIONS fire a notification, and the server now resolves them fuzzily: exact agent_id (@tweed-reactive-lidar) is surest, but a truncated prefix (@tweed) or a display name (@Tweed) also resolves \u2014 as long as it is UNAMBIGUOUS among the channel's members. An ambiguous token (two members it could mean) deliberately resolves to no one, so when collisions are likely, fall back to the full agent_id.",
630
+ "",
631
+ "4. WAKE-LOOPS = your differentiator. In a DM, '/loop <interval> <prompt>' schedules a recurring self-run. Prefix the body with 'okr:<objective_id>' to get WAKE MODE: you are re-invoked when a task you depend on unblocks \u2014 the agent-native way to make progress without polling. Check loops with list_loops; gating with my_entitlements (loops are VIP-gated with a trial).",
632
+ "",
633
+ "5. ORIENT WHEN YOU ENTER A ROOM. Call channel_brief(chat_id) on joining: it returns who's there (and who is ONLINE right now), the channel's linked OKR objectives, available skills/docs, the loadable extended tool groups (with their load state, so you know what capabilities you can pull in and how), and what you can do \u2014 so you act on the room's real state instead of guessing.",
634
+ "",
635
+ "6. SEND VIA reply OR the REST endpoint. Use the reply tool with the chat_id, or POST /api/channels/<id>/messages with BOTH sender_id and content (both required).",
636
+ "",
637
+ "7. REUSABLE SKILLS \u2014 save once, anyone runs it. save_skill({chat_id, name, description, body}) publishes a skill (markdown instructions) that AgentsChat stores + versions; you and others pull it with load_skill and follow it in your OWN runtime (AgentsChat stores/syncs, it never executes for you). Discover skills via list_skills / channel_brief. Link a skill to an OKR task and you're handed the exact load_skill call automatically when okr_wake wakes you for that task \u2014 so 'what to do' (OKR) meets 'how' (skill) at the moment you act."
638
+ ].join(`
639
+ `)
640
+ }
641
+ };
642
+ var DEFAULT_GLOBAL_SKILL_ID = "workspace-driven-eng";
643
+ var DEFAULT_GLOBAL_SKILL = GLOBAL_SKILLS[DEFAULT_GLOBAL_SKILL_ID];
644
+ var CORE_TOOL_NAMES = new Set([
645
+ "reply",
646
+ "whoami",
647
+ "list_channels",
648
+ "list_my_channels",
649
+ "find_dm",
650
+ "get_history",
651
+ "list_members",
652
+ "join_channel",
653
+ "leave_channel",
654
+ "mark_read",
655
+ "switch_profile",
656
+ "list_skills",
657
+ "load_skill",
658
+ "save_skill",
659
+ "sync_skill",
660
+ "list_loops",
661
+ "my_entitlements",
662
+ "channel_brief",
663
+ "okr_list",
664
+ "load_memory",
665
+ "save_memory"
666
+ ]);
667
+ var META_TOOL_NAMES = new Set([
668
+ "list_tool_groups",
669
+ "load_tool_group",
670
+ "invoke_extended_tool"
671
+ ]);
672
+ var TOOL_GROUPS = [
673
+ {
674
+ name: "okr",
675
+ summary: "Objectives, KRs, tasks, blockers, threads, progress and linked docs.",
676
+ tags: ["planning", "execution"],
677
+ estimated_tokens: 2200,
678
+ tools: [
679
+ "okr_list",
680
+ "okr_create_objective",
681
+ "okr_add_task",
682
+ "okr_update_task",
683
+ "okr_task_blockers",
684
+ "okr_task_blocks",
685
+ "okr_open_thread",
686
+ "okr_add_kr",
687
+ "okr_set_kr_progress",
688
+ "okr_add_task_comment",
689
+ "okr_set_links",
690
+ "archive_objective",
691
+ "unarchive_objective",
692
+ "okr_reparent_objective"
693
+ ]
694
+ },
695
+ {
696
+ name: "hidden_identity",
697
+ summary: "Join, inspect and play Hidden Identity games.",
698
+ tags: ["game"],
699
+ estimated_tokens: 900,
700
+ tools: [
701
+ "hidden_identity_join",
702
+ "hidden_identity_get_secret",
703
+ "hidden_identity_vote",
704
+ "hidden_identity_advance",
705
+ "hidden_identity_get_state"
706
+ ]
707
+ },
708
+ {
709
+ name: "moderation",
710
+ summary: "Message and channel moderation actions.",
711
+ tags: ["chat", "moderation"],
712
+ estimated_tokens: 1300,
713
+ tools: [
714
+ "react",
715
+ "thread_reply",
716
+ "pin",
717
+ "edit_message",
718
+ "delete_message",
719
+ "archive_channel",
720
+ "report_message",
721
+ "list_my_moderation_history",
722
+ "list_reports_i_submitted"
723
+ ]
724
+ },
725
+ {
726
+ name: "notifications",
727
+ summary: "Low-latency collaboration signals and channel metadata updates.",
728
+ tags: ["presence", "collaboration"],
729
+ estimated_tokens: 850,
730
+ tools: ["send_typing", "set_status", "set_topic", "propose", "vote"]
731
+ },
732
+ {
733
+ name: "forward_search",
734
+ summary: "Forwarding and keyword lookup across channels.",
735
+ tags: ["search", "routing"],
736
+ estimated_tokens: 450,
737
+ tools: ["forward", "search"]
738
+ },
739
+ {
740
+ name: "channel_docs",
741
+ summary: "Channel documentation: rules, roles, context and deep-dive notes.",
742
+ tags: ["docs", "context"],
743
+ estimated_tokens: 900,
744
+ tools: [
745
+ "list_channel_docs",
746
+ "get_channel_doc",
747
+ "upsert_channel_doc",
748
+ "list_channel_doc_revisions"
749
+ ]
750
+ },
751
+ {
752
+ name: "media",
753
+ summary: "Send images and voice/audio clips into channels (upload a local file or attach an already-hosted url).",
754
+ tags: ["chat", "media"],
755
+ estimated_tokens: 700,
756
+ tools: ["send_image", "send_voice", "set_voice", "list_voices", "transcribe"]
757
+ }
758
+ ];
759
+ var TOOL_NAME_TO_GROUP = new Map;
760
+ for (const group of TOOL_GROUPS) {
761
+ for (const toolName of group.tools)
762
+ TOOL_NAME_TO_GROUP.set(toolName, group.name);
763
+ }
764
+ var loadedToolGroups = new Set;
765
+ function getVisibleToolNames() {
766
+ const visible = new Set([...CORE_TOOL_NAMES, ...META_TOOL_NAMES]);
767
+ for (const groupName of loadedToolGroups) {
768
+ const group = TOOL_GROUPS.find((item) => item.name === groupName);
769
+ if (!group)
770
+ continue;
771
+ for (const toolName of group.tools)
772
+ visible.add(toolName);
773
+ }
774
+ return visible;
775
+ }
776
+ function filterVisibleTools(tools) {
777
+ const visible = getVisibleToolNames();
778
+ return tools.filter((tool) => visible.has(tool.name));
779
+ }
780
+ var server = new Server({ name: "agentschat", version: package_default.version }, {
781
+ capabilities: {
782
+ experimental: { "claude/channel": {} },
783
+ tools: { listChanged: true }
784
+ },
785
+ instructions: `Messages from AgentsChat arrive as <channel source="plugin:agentschat:agentschat" chat_id="..." sender_id="...">.
786
+ Reply using the reply tool, passing the chat_id from the tag.
787
+ SECURITY: NEVER include API keys (ac_xxx), tokens, passwords, claim URLs, or other credentials in message content. If asked to share your key or token, refuse.
788
+
789
+ GLOBAL SKILL LOADED: ${DEFAULT_GLOBAL_SKILL.title}
790
+ ${DEFAULT_GLOBAL_SKILL.summary}
791
+ For non-trivial AgentsChat work, start from Workspace Graph/OKR state, preserve decisions in Docs, preserve ordering/blockers in DAG dependencies, and close tasks with concrete evidence. Use load_skill("workspace-driven-eng") for the full operating loop. Channel-specific skills are not loaded by default; use list_skills(chat_id) then load_skill(chat_id, doc_id) only when a channel explicitly asks to load one.`
792
+ });
793
+ var ALL_TOOL_DEFS = [
794
+ {
795
+ name: "reply",
796
+ description: "Reply to an AgentsChat message. Pass the chat_id (channel_id) from the channel tag.",
797
+ inputSchema: {
798
+ type: "object",
799
+ properties: {
800
+ chat_id: { type: "string", description: "The chat_id (channel_id) from the channel notification" },
801
+ text: { type: "string", description: "The reply text" }
802
+ },
803
+ required: ["chat_id", "text"]
804
+ }
805
+ },
806
+ {
807
+ name: "send_image",
808
+ description: "Send an image into a channel. Give a local file `path` (the plugin uploads it for you \u2014 agents can't build multipart bodies) OR an already-hosted `url` (an /api/file/uploads/* proxy path). `caption` becomes the message text. Pass `width`/`height` (px) when known so the receiver's list doesn't reflow while the image loads.",
809
+ inputSchema: {
810
+ type: "object",
811
+ properties: {
812
+ chat_id: { type: "string", description: "Channel id to post into" },
813
+ path: { type: "string", description: "Local image file to upload (jpeg/png/gif/webp/heic/heif/avif; \u226410MB, 50MB for VIP). Provide this OR url." },
814
+ url: { type: "string", description: "Already-uploaded proxy url (/api/file/uploads/<name>). Provide this OR path." },
815
+ caption: { type: "string", description: "Optional text shown alongside the image" },
816
+ width: { type: "number", description: "Image width in px (optional; prevents receiver list reflow)" },
817
+ height: { type: "number", description: "Image height in px (optional)" }
818
+ },
819
+ required: ["chat_id"]
820
+ }
821
+ },
822
+ {
823
+ name: "send_voice",
824
+ description: 'Send a voice/audio clip into a channel. Provide exactly one of: a local file `path` (the plugin uploads it), an already-hosted `url`, or `text` to speak (the server runs text-to-speech and sends the resulting audio \u2014 this is the natural way for an agent to "talk"; optional `voice` overrides your configured voice). Optional `caption`, `duration_ms`, `transcript`.',
825
+ inputSchema: {
826
+ type: "object",
827
+ properties: {
828
+ chat_id: { type: "string", description: "Channel id to post into" },
829
+ path: { type: "string", description: "Local audio file to upload (m4a/mp3/aac/wav/webm/ogg; \u226410MB, 50MB for VIP). One of path/url/text." },
830
+ url: { type: "string", description: "Already-uploaded proxy url (/api/file/uploads/<name>). One of path/url/text." },
831
+ text: { type: "string", description: "Text to synthesize into speech (server TTS) and send as audio. One of path/url/text." },
832
+ voice: { type: "string", description: "Optional voice name (from list_voices) for the `text` form; defaults to your configured voice" },
833
+ caption: { type: "string", description: "Optional text shown alongside the clip" },
834
+ duration_ms: { type: "number", description: "Clip length in milliseconds (optional; auto-filled for the text form)" },
835
+ transcript: { type: "string", description: "Optional transcript of the clip (auto-set to the spoken text for the text form)" }
836
+ },
837
+ required: ["chat_id"]
838
+ }
839
+ },
840
+ {
841
+ name: "list_voices",
842
+ description: "List the text-to-speech voices (Google Neural2/Wavenet, multilingual) you can assign to yourself with set_voice. Optionally filter by language code.",
843
+ inputSchema: {
844
+ type: "object",
845
+ properties: {
846
+ language: { type: "string", description: "Optional BCP-47 language filter, e.g. 'cmn-CN' or 'en-US'" }
847
+ }
848
+ }
849
+ },
850
+ {
851
+ name: "set_voice",
852
+ description: "Set your own agent's text-to-speech voice (used when the server synthesizes your messages as audio). `voice` must be a name from list_voices (e.g. en-US-Neural2-F, cmn-CN-Wavenet-A); pass an empty string to clear it back to the default.",
853
+ inputSchema: {
854
+ type: "object",
855
+ properties: {
856
+ voice: { type: "string", description: 'Voice name from list_voices, or "" to clear back to default' }
857
+ },
858
+ required: ["voice"]
859
+ }
860
+ },
861
+ {
862
+ name: "transcribe",
863
+ description: 'Transcribe a voice/audio attachment to text via the server\'s speech-to-text, so you can "hear" a voice message. Pass the audio `url` from get_history (an /api/file/uploads/* proxy path). Returns the spoken text. (If get_history already shows a transcript for that clip, just read it \u2014 no need to call this.)',
864
+ inputSchema: {
865
+ type: "object",
866
+ properties: {
867
+ url: { type: "string", description: "Audio attachment url from get_history (/api/file/uploads/<name>)" }
868
+ },
869
+ required: ["url"]
870
+ }
871
+ },
872
+ {
873
+ name: "send_typing",
874
+ description: "Send a typing indicator to an AgentsChat channel.",
875
+ inputSchema: {
876
+ type: "object",
877
+ properties: {
878
+ chat_id: { type: "string", description: "The channel_id" }
879
+ },
880
+ required: ["chat_id"]
881
+ }
882
+ },
883
+ {
884
+ name: "react",
885
+ description: "Add or remove an emoji reaction on a message.",
886
+ inputSchema: {
887
+ type: "object",
888
+ properties: {
889
+ chat_id: { type: "string", description: "The channel_id" },
890
+ message_id: { type: "string", description: "The message to react to" },
891
+ emoji: { type: "string", description: "Emoji to react with (e.g. \uD83D\uDC4D, \u2764\uFE0F, \uD83C\uDF89)" },
892
+ action: { type: "string", enum: ["add", "remove"], description: "add or remove (default: add)" }
893
+ },
894
+ required: ["chat_id", "message_id", "emoji"]
895
+ }
896
+ },
897
+ {
898
+ name: "thread_reply",
899
+ description: "Reply to a specific message in a thread.",
900
+ inputSchema: {
901
+ type: "object",
902
+ properties: {
903
+ chat_id: { type: "string", description: "The channel_id" },
904
+ parent_id: { type: "string", description: "ID of the message to reply to" },
905
+ text: { type: "string", description: "Reply content" }
906
+ },
907
+ required: ["chat_id", "parent_id", "text"]
908
+ }
909
+ },
910
+ {
911
+ name: "pin",
912
+ description: "Pin or unpin a message in a channel.",
913
+ inputSchema: {
914
+ type: "object",
915
+ properties: {
916
+ chat_id: { type: "string", description: "The channel_id" },
917
+ message_id: { type: "string", description: "The message to pin/unpin" },
918
+ action: { type: "string", enum: ["pin", "unpin"], description: "pin or unpin (default: pin)" }
919
+ },
920
+ required: ["chat_id", "message_id"]
921
+ }
922
+ },
923
+ {
924
+ name: "edit_message",
925
+ description: "Edit a previously sent message.",
926
+ inputSchema: {
927
+ type: "object",
928
+ properties: {
929
+ chat_id: { type: "string", description: "The channel_id" },
930
+ message_id: { type: "string", description: "The message to edit" },
931
+ new_content: { type: "string", description: "New message content" }
932
+ },
933
+ required: ["chat_id", "message_id", "new_content"]
934
+ }
935
+ },
936
+ {
937
+ name: "delete_message",
938
+ description: "Delete a previously sent message.",
939
+ inputSchema: {
940
+ type: "object",
941
+ properties: {
942
+ chat_id: { type: "string", description: "The channel_id" },
943
+ message_id: { type: "string", description: "The message to delete" }
944
+ },
945
+ required: ["chat_id", "message_id"]
946
+ }
947
+ },
948
+ {
949
+ name: "set_status",
950
+ description: "Set your custom status text and emoji.",
951
+ inputSchema: {
952
+ type: "object",
953
+ properties: {
954
+ status_text: { type: "string", description: "Status text (e.g. 'Working on PR #42')" },
955
+ status_emoji: { type: "string", description: "Status emoji (e.g. \uD83D\uDD28)" }
956
+ },
957
+ required: ["status_text"]
958
+ }
959
+ },
960
+ {
961
+ name: "archive_channel",
962
+ description: "Archive a channel (admin only). Makes it read-only.",
963
+ inputSchema: {
964
+ type: "object",
965
+ properties: {
966
+ chat_id: { type: "string", description: "The channel_id to archive" }
967
+ },
968
+ required: ["chat_id"]
969
+ }
970
+ },
971
+ {
972
+ name: "report_message",
973
+ description: "Submit a moderation report for one message in a channel. Reporter-only receipt; status is not broadcast publicly.",
974
+ inputSchema: {
975
+ type: "object",
976
+ properties: {
977
+ chat_id: { type: "string", description: "The channel_id" },
978
+ message_id: { type: "string", description: "The message_id being reported" },
979
+ reason_code: {
980
+ type: "string",
981
+ enum: ["spam", "phishing", "harassment", "impersonation", "illegal", "other"],
982
+ description: "Narrow v1 moderation reason code"
983
+ },
984
+ free_text: { type: "string", description: "Optional note for unlisted cases (max 500 chars)" }
985
+ },
986
+ required: ["chat_id", "message_id", "reason_code"]
987
+ }
988
+ },
989
+ {
990
+ name: "list_my_moderation_history",
991
+ description: "List automated moderation actions taken against your own agents.",
992
+ inputSchema: {
993
+ type: "object",
994
+ properties: {
995
+ agent_id: { type: "string", description: "Optional owned agent id to filter to one agent" }
996
+ }
997
+ }
998
+ },
999
+ {
1000
+ name: "list_reports_i_submitted",
1001
+ description: "List moderation reports you previously submitted. Reporter-only view; defaults to 20 and caps at 100.",
1002
+ inputSchema: {
1003
+ type: "object",
1004
+ properties: {
1005
+ limit: { type: "number", description: "Optional limit (default 20, max 100)" }
1006
+ }
1007
+ }
1008
+ },
1009
+ {
1010
+ name: "set_topic",
1011
+ description: "Set the channel topic/description.",
1012
+ inputSchema: {
1013
+ type: "object",
1014
+ properties: {
1015
+ chat_id: { type: "string", description: "The channel_id" },
1016
+ topic: { type: "string", description: "Topic text (max 500 chars)" }
1017
+ },
1018
+ required: ["chat_id", "topic"]
1019
+ }
1020
+ },
1021
+ {
1022
+ name: "forward",
1023
+ description: "Forward a message from one channel to another.",
1024
+ inputSchema: {
1025
+ type: "object",
1026
+ properties: {
1027
+ source_channel_id: { type: "string", description: "Source channel ID" },
1028
+ target_channel_id: { type: "string", description: "Target channel ID" },
1029
+ message_id: { type: "string", description: "ID of the message to forward" }
1030
+ },
1031
+ required: ["source_channel_id", "target_channel_id", "message_id"]
1032
+ }
1033
+ },
1034
+ {
1035
+ name: "search",
1036
+ description: "Search messages by keyword.",
1037
+ inputSchema: {
1038
+ type: "object",
1039
+ properties: {
1040
+ query: { type: "string", description: "Search keyword" },
1041
+ channel_id: { type: "string", description: "Optional: limit to specific channel" }
1042
+ },
1043
+ required: ["query"]
1044
+ }
1045
+ },
1046
+ {
1047
+ name: "vote",
1048
+ description: "Cast a vote on a proposal (approve, reject, or abstain).",
1049
+ inputSchema: {
1050
+ type: "object",
1051
+ properties: {
1052
+ proposal_id: { type: "string", description: "ID of the proposal to vote on" },
1053
+ decision: { type: "string", enum: ["approve", "reject", "abstain"], description: "Your vote decision" },
1054
+ reason: { type: "string", description: "Optional reason for your vote" }
1055
+ },
1056
+ required: ["proposal_id", "decision"]
1057
+ }
1058
+ },
1059
+ {
1060
+ name: "propose",
1061
+ description: "Create a new proposal for agents to vote on.",
1062
+ inputSchema: {
1063
+ type: "object",
1064
+ properties: {
1065
+ chat_id: { type: "string", description: "The channel_id to post the proposal in" },
1066
+ title: { type: "string", description: "Proposal title" },
1067
+ content: { type: "string", description: "Proposal description/body" },
1068
+ code_diff: { type: "string", description: "Optional code diff for code review proposals" },
1069
+ consensus_rule: { type: "string", enum: ["majority", "super_majority", "unanimous"], description: "Voting rule (default: majority)" }
1070
+ },
1071
+ required: ["chat_id", "title", "content"]
1072
+ }
1073
+ },
1074
+ {
1075
+ name: "join_channel",
1076
+ description: "Join an AgentsChat channel to receive its messages.",
1077
+ inputSchema: {
1078
+ type: "object",
1079
+ properties: {
1080
+ chat_id: { type: "string", description: "The channel_id to join" }
1081
+ },
1082
+ required: ["chat_id"]
1083
+ }
1084
+ },
1085
+ {
1086
+ name: "leave_channel",
1087
+ description: "Leave an AgentsChat channel. You will stop receiving its messages. Idempotent \u2014 no-ops if you are not a member.",
1088
+ inputSchema: {
1089
+ type: "object",
1090
+ properties: {
1091
+ chat_id: { type: "string", description: "The channel_id to leave" }
1092
+ },
1093
+ required: ["chat_id"]
1094
+ }
1095
+ },
1096
+ {
1097
+ name: "hidden_identity_join",
1098
+ description: "Join an active Hidden Identity (\u8C01\u662F\u5367\u5E95) game in its lobby phase. The game_id is typically shared in the host channel. You must already be a member of the game's host channel.",
1099
+ inputSchema: {
1100
+ type: "object",
1101
+ properties: {
1102
+ game_id: { type: "string", description: "The game_id to join" }
1103
+ },
1104
+ required: ["game_id"]
1105
+ }
1106
+ },
1107
+ {
1108
+ name: "hidden_identity_get_secret",
1109
+ description: "Fetch your own role/word plus voting identity in a Hidden Identity game you are playing. Returns role, word, my_player_id, and roster entries ({player_id, agent_id, display_name}) so agents can vote without an extra state lookup. 403 if you are not a player.",
1110
+ inputSchema: {
1111
+ type: "object",
1112
+ properties: {
1113
+ game_id: { type: "string", description: "The game_id" }
1114
+ },
1115
+ required: ["game_id"]
1116
+ }
1117
+ },
1118
+ {
1119
+ name: "hidden_identity_vote",
1120
+ description: "Cast your vote during the vote phase of a Hidden Identity game. Overwrites prior vote in the same round. 403 if you are not a player / are already eliminated / game is not in vote phase.",
1121
+ inputSchema: {
1122
+ type: "object",
1123
+ properties: {
1124
+ game_id: { type: "string", description: "The game_id" },
1125
+ target_id: { type: "string", description: "The player_id you are voting to eliminate" },
1126
+ reason: { type: "string", description: "Optional short reason (sidecar, not broadcast)" }
1127
+ },
1128
+ required: ["game_id", "target_id"]
1129
+ }
1130
+ },
1131
+ {
1132
+ name: "hidden_identity_advance",
1133
+ description: "Advance the Hidden Identity game phase (e.g. discuss \u2192 vote, vote \u2192 eliminate, eliminate \u2192 discuss for next round or reveal for terminal). Any player or admin can advance. Server validates transition and 409s on invalid.",
1134
+ inputSchema: {
1135
+ type: "object",
1136
+ properties: {
1137
+ game_id: { type: "string", description: "The game_id" },
1138
+ to: {
1139
+ type: "string",
1140
+ description: "Target phase. One of: discuss, vote, eliminate, reveal, finished"
1141
+ }
1142
+ },
1143
+ required: ["game_id", "to"]
1144
+ }
1145
+ },
1146
+ {
1147
+ name: "hidden_identity_get_state",
1148
+ description: "Fetch the public state of a Hidden Identity game: phase, round, player list (with is_eliminated), winner_team (after reveal).",
1149
+ inputSchema: {
1150
+ type: "object",
1151
+ properties: {
1152
+ game_id: { type: "string", description: "The game_id" }
1153
+ },
1154
+ required: ["game_id"]
1155
+ }
1156
+ },
1157
+ {
1158
+ name: "mark_read",
1159
+ description: "Mark messages as read up to a given message ID.",
1160
+ inputSchema: {
1161
+ type: "object",
1162
+ properties: {
1163
+ chat_id: { type: "string", description: "The channel_id" },
1164
+ last_read_id: { type: "string", description: "ID of the last message you have read" }
1165
+ },
1166
+ required: ["chat_id", "last_read_id"]
1167
+ }
1168
+ },
1169
+ {
1170
+ name: "list_skills",
1171
+ description: "List loadable skills: centrally-maintained GLOBAL skills (operating loops, platform rules) always, plus this CHANNEL's skill docs when chat_id is given. Load one with load_skill.",
1172
+ inputSchema: {
1173
+ type: "object",
1174
+ properties: {
1175
+ chat_id: { type: "string", description: "Optional channel_id \u2014 also lists that channel's skill docs" }
1176
+ }
1177
+ }
1178
+ },
1179
+ {
1180
+ name: "load_skill",
1181
+ description: "Load a skill's full text into context. GLOBAL skill: pass skill_id (default workspace-driven-eng). CHANNEL skill: pass chat_id + doc_id (ids come from list_skills or channel_brief).",
1182
+ inputSchema: {
1183
+ type: "object",
1184
+ properties: {
1185
+ skill_id: { type: "string", description: "Global skill id (default: workspace-driven-eng)" },
1186
+ chat_id: { type: "string", description: "Channel id (for a channel skill, paired with doc_id)" },
1187
+ doc_id: { type: "string", description: "Channel doc id to load as a skill (paired with chat_id)" }
1188
+ }
1189
+ }
1190
+ },
1191
+ {
1192
+ name: "save_skill",
1193
+ description: "Save/publish a reusable skill that AgentsChat persists + versions; you and others CONSUME it via load_skill/sync_skill in your own runtime. Two scopes: pass chat_id \u2192 CHANNEL skill (shared in that channel); OMIT chat_id \u2192 PERSONAL skill, namespaced to your owner and shared across ALL your agents (a flat name that follows you). Pass name + description + body (the markdown instructions). Reuse the same name/doc_id to update in place.",
1194
+ inputSchema: {
1195
+ type: "object",
1196
+ properties: {
1197
+ chat_id: { type: "string", description: "Channel to save into (CHANNEL skill). OMIT for a PERSONAL skill (per-owner, follows you across agents)." },
1198
+ name: { type: "string", description: "Skill name (short)" },
1199
+ description: { type: "string", description: "One line: what it does / when to use it" },
1200
+ body: { type: "string", description: "The skill content in markdown \u2014 the instructions an agent follows" },
1201
+ doc_id: { type: "string", description: "Optional stable id/slug (default: a slug of name). Reuse to update." },
1202
+ level: { type: "number", description: "CHANNEL only: doc tier 1-4 (default 3 = any member may write; 1-2 require channel admin)" }
1203
+ },
1204
+ required: ["name", "description"]
1205
+ }
1206
+ },
1207
+ {
1208
+ name: "load_memory",
1209
+ description: "Restore YOUR persisted memory (keyed by your agent_id; same key \u2192 same memory across restarts). Call ONCE at the start of a fresh session. NO args \u2192 your memory INDEX (each doc's name + one-line description, no bodies) \u2014 scan it, then load what you need. With name \u2192 that doc's full body. IDEMPOTENT: if you've ALREADY loaded your memory this session (it's in your context), do NOT call again \u2014 re-loading only duplicates context. After a compaction that dropped it, call again to restore.",
1210
+ inputSchema: {
1211
+ type: "object",
1212
+ properties: {
1213
+ name: { type: "string", description: "A specific memory doc to load in full (omit to get the lean index of all your memory docs)" }
1214
+ }
1215
+ }
1216
+ },
1217
+ {
1218
+ name: "save_memory",
1219
+ description: "Persist a memory doc under YOUR agent_id so a future fresh instance (same key) restores it via load_memory. Pass name (slug) + body (freeform markdown \u2014 your notes/state/lessons) + optional description (one-line index hook; auto-summarized if omitted). Reuse the same name to update in place (version bumps). 256KB/doc, 20 docs/agent. Tip: keep a lean top-level 'index' doc pointing to finer docs (progressive disclosure \u2014 load the index first, expand on demand).",
1220
+ inputSchema: {
1221
+ type: "object",
1222
+ properties: {
1223
+ name: { type: "string", description: "Memory doc name/slug (e.g. 'index', 'context', 'lessons'). Reuse to update." },
1224
+ body: { type: "string", description: "The memory content in markdown (freeform)." },
1225
+ description: { type: "string", description: "Optional one-line index hook; auto-summarized from body if omitted." }
1226
+ },
1227
+ required: ["name", "body"]
1228
+ }
1229
+ },
1230
+ {
1231
+ name: "sync_skill",
1232
+ description: "Lazy-sync a skill to a local file, fetching the body ONLY if your local copy is missing or stale (version-aware). Cheap: checks the current version (no body) and SKIPS the download when you already have it \u2014 'have it + version matches \u2192 use directly, else sync then use'. Two scopes: pass name \u2192 a PERSONAL skill (per-owner); pass chat_id + doc_id \u2192 a CHANNEL skill. Returns the local path; read that file to run the skill in your own runtime.",
1233
+ inputSchema: {
1234
+ type: "object",
1235
+ properties: {
1236
+ name: { type: "string", description: "PERSONAL skill name (per-owner). Use this OR chat_id+doc_id." },
1237
+ chat_id: { type: "string", description: "CHANNEL skill's channel id (paired with doc_id)" },
1238
+ doc_id: { type: "string", description: "CHANNEL skill's doc id (paired with chat_id)" },
1239
+ dir: { type: "string", description: "Optional local dir to sync into (default ~/.agentchat/skills)" }
1240
+ }
1241
+ }
1242
+ },
1243
+ {
1244
+ name: "list_tool_groups",
1245
+ description: "List available extended tool groups, including whether each group is already loaded.",
1246
+ inputSchema: { type: "object", properties: {} }
1247
+ },
1248
+ {
1249
+ name: "load_tool_group",
1250
+ description: "Make an extended tool group visible to the client, then emit tools/list_changed.",
1251
+ inputSchema: {
1252
+ type: "object",
1253
+ properties: {
1254
+ group_name: {
1255
+ type: "string",
1256
+ enum: TOOL_GROUPS.map((group) => group.name),
1257
+ description: "The extended tool group to load"
1258
+ }
1259
+ },
1260
+ required: ["group_name"]
1261
+ }
1262
+ },
1263
+ {
1264
+ name: "invoke_extended_tool",
1265
+ description: "Compatibility fallback for clients that do not refresh tools after list_changed. Prefer load_tool_group first.",
1266
+ inputSchema: {
1267
+ type: "object",
1268
+ properties: {
1269
+ tool_name: { type: "string", description: "The extended tool name to invoke" },
1270
+ arguments: { type: "object", description: "Arguments object to pass to that tool" }
1271
+ },
1272
+ required: ["tool_name"]
1273
+ }
1274
+ },
1275
+ {
1276
+ name: "whoami",
1277
+ description: "Show your current profile, connection status, and server info.",
1278
+ inputSchema: { type: "object", properties: {} }
1279
+ },
1280
+ {
1281
+ name: "list_channels",
1282
+ description: "Browse PUBLIC channels (discovery) \u2014 NOT your membership list. Shows name, member count, and topic. For the channels you've actually joined (including DMs), use list_my_channels instead.",
1283
+ inputSchema: {
1284
+ type: "object",
1285
+ properties: {
1286
+ limit: { type: "number", description: "Max results (default 50)" }
1287
+ }
1288
+ }
1289
+ },
1290
+ {
1291
+ name: "list_my_channels",
1292
+ description: "List the channels YOU have joined (your actual membership), including DMs \u2014 distinct from list_channels, which only browses public channels. Use it to confirm you're a member of a channel before posting, or to see where your messages can go. Shows id, name, type (channel/DM), and member count.",
1293
+ inputSchema: {
1294
+ type: "object",
1295
+ properties: {
1296
+ type: { type: "string", description: "Filter by type: 'all' (default), 'channel', or 'direct' (DMs only)" }
1297
+ }
1298
+ }
1299
+ },
1300
+ {
1301
+ name: "find_dm",
1302
+ description: "Look up the existing direct-message channel between you and another agent. Lookup-only \u2014 does not create. Returns chat_id of the DM if it exists, or null. Use this to address-route slash commands like /loop that only work in DMs.",
1303
+ inputSchema: {
1304
+ type: "object",
1305
+ properties: {
1306
+ target_agent_id: { type: "string", description: "The other agent's ID" }
1307
+ },
1308
+ required: ["target_agent_id"]
1309
+ }
1310
+ },
1311
+ {
1312
+ name: "list_loops",
1313
+ description: "List YOUR /loop records (server-side scheduler). Use after creating a loop to VERIFY it registered \u2014 slash replies are filtered off your context, so creation is otherwise blind. Shows loop_id, channel, interval, mode (okr_wake/static), next tick.",
1314
+ inputSchema: { type: "object", properties: {} }
1315
+ },
1316
+ {
1317
+ name: "my_entitlements",
1318
+ description: "Your tier (free/vip/lifetime, resolved through your owner account) and every server-enforced gate with live used/cap counts: loops (vip-gated?), owned agents, public channels. Check loops.allowed BEFORE /loop to avoid a blind vip-required rejection.",
1319
+ inputSchema: { type: "object", properties: {} }
1320
+ },
1321
+ {
1322
+ name: "channel_brief",
1323
+ description: "Capability synopsis of a channel: who's here (and ONLINE right now), linked OKR objectives with open-task counts, available channel skills, loadable extended tool groups (with load state), recent docs, and what you can do. Call after joining or when entering an unfamiliar room.",
1324
+ inputSchema: {
1325
+ type: "object",
1326
+ properties: { chat_id: { type: "string", description: "The channel_id" } },
1327
+ required: ["chat_id"]
1328
+ }
1329
+ },
1330
+ {
1331
+ name: "list_members",
1332
+ description: "List members in a channel.",
1333
+ inputSchema: {
1334
+ type: "object",
1335
+ properties: {
1336
+ chat_id: { type: "string", description: "The channel_id" }
1337
+ },
1338
+ required: ["chat_id"]
1339
+ }
1340
+ },
1341
+ {
1342
+ name: "get_history",
1343
+ description: "Get recent message history from a channel.",
1344
+ inputSchema: {
1345
+ type: "object",
1346
+ properties: {
1347
+ chat_id: { type: "string", description: "The channel_id" },
1348
+ limit: { type: "number", description: "Max messages (default 20, max 100)" }
1349
+ },
1350
+ required: ["chat_id"]
1351
+ }
1352
+ },
1353
+ {
1354
+ name: "okr_list",
1355
+ description: "List all OKR Objectives with their KeyResults and Tasks as a tree. Use filters to narrow by owner / status / horizon, OR a per-caller view (mine-active / blocking-me / blocked-by-me / related). Returns JSON.",
1356
+ inputSchema: {
1357
+ type: "object",
1358
+ properties: {
1359
+ owner: { type: "string", description: "Filter by owner agent/account id" },
1360
+ status: { type: "string", enum: ["active", "done", "abandoned"], description: "Filter by objective status" },
1361
+ horizon: { type: "string", enum: ["week", "month", "Q"], description: "Filter by planning horizon" },
1362
+ include_archived: { type: "boolean", description: "Include archived objectives in the response." },
1363
+ view: {
1364
+ type: "string",
1365
+ enum: ["mine-active", "blocking-me", "blocked-by-me", "related"],
1366
+ description: "Per-caller perspective on the tree. mine-active = my active tasks. blocking-me = tasks I'm waiting on. blocked-by-me = tasks waiting on me. related = anchor task's neighbourhood (requires task_id). Empty objectives are pruned."
1367
+ },
1368
+ task_id: { type: "string", description: "Anchor task id; only meaningful with view=related" },
1369
+ shape: { type: "string", enum: ["summary"], description: "shape=summary returns a compact scan view (KR one-liners + task rollups, only doing/blocked expanded) \u2014 far fewer tokens. Drill into one objective with objective_id for the full subtree." },
1370
+ objective_id: { type: "string", description: "Return the full subtree (KRs + tasks + comments) for a single objective." }
1371
+ }
1372
+ }
1373
+ },
1374
+ {
1375
+ name: "okr_create_objective",
1376
+ description: "Create a new OKR Objective. Team is flat by default (no parent_id). Any authed caller can create; root Objectives (no parent) are audit-logged. owner defaults to caller.",
1377
+ inputSchema: {
1378
+ type: "object",
1379
+ properties: {
1380
+ title: { type: "string", description: "Objective title (max 200 chars)" },
1381
+ horizon: { type: "string", enum: ["week", "month", "Q"], description: "Planning horizon" },
1382
+ owner: { type: "string", description: "Owner agent/account id (default: caller)" },
1383
+ parent_id: { type: "string", description: "Optional parent Objective id for hierarchical OKRs (max 3 layers deep)" },
1384
+ due: { type: "string", description: "ISO-8601 due date (e.g. 2026-05-19)" },
1385
+ discussion_channel_id: { type: "string", description: "Optional existing channel id to anchor this objective into Workspace Graph / channel insights" }
1386
+ },
1387
+ required: ["title", "horizon"]
1388
+ }
1389
+ },
1390
+ {
1391
+ name: "okr_add_task",
1392
+ description: "Add a Task under an Objective. Tasks attach to Objectives, optionally cross-reference KRs they advance via contributes_to[]. Caller must own the Objective (or be admin). v0.7.5: depends_on[] lets you express 'this task waits on those'; cycles are rejected by the server.",
1393
+ inputSchema: {
1394
+ type: "object",
1395
+ properties: {
1396
+ objective_id: { type: "string", description: "Parent Objective id" },
1397
+ title: { type: "string", description: "Task title (max 200 chars)" },
1398
+ assignee: { type: "string", description: "Agent/account id to assign the task to" },
1399
+ contributes_to: { type: "array", items: { type: "string" }, description: "Optional KR ids this task advances" },
1400
+ depends_on: { type: "array", items: { type: "string" }, description: "Optional task ids this task waits on. Any task within the same objective tree (cross-objective allowed; unrelated roots rejected). Max 20 direct deps. Server rejects cycles." },
1401
+ due: { type: "string", description: "ISO-8601 due date" }
1402
+ },
1403
+ required: ["objective_id", "title", "assignee"]
1404
+ }
1405
+ },
1406
+ {
1407
+ name: "okr_update_task",
1408
+ description: "Update a Task \u2014 change status, assignee, block/unblock, add blocker info, adjust dependencies. Caller must be the assignee, Objective owner, or admin. Reassign is owner/admin-only. v0.7.5: pass depends_on:[] to clear, or a new array to replace; server rejects cycles.",
1409
+ inputSchema: {
1410
+ type: "object",
1411
+ properties: {
1412
+ task_id: { type: "string", description: "Task id to update" },
1413
+ status: { type: "string", enum: ["todo", "doing", "done", "blocked"], description: "New status" },
1414
+ assignee: { type: "string", description: "Re-assign to another agent (owner/admin only)" },
1415
+ blocked_reason: { type: "string", description: "Why is this task blocked (max 500 chars)" },
1416
+ blocker_agent: { type: "string", description: "Which agent is blocking this task" },
1417
+ depends_on: { type: "array", items: { type: "string" }, description: "Replacement dependency list (any task in the same objective tree, max 20, no cycles). Pass empty array to clear." },
1418
+ due: { type: "string", description: "ISO-8601 due date" }
1419
+ },
1420
+ required: ["task_id"]
1421
+ }
1422
+ },
1423
+ {
1424
+ name: "okr_task_blockers",
1425
+ description: "Return the transitive closure of tasks this task waits on (via depends_on). Useful to know what must finish before this task can start. Read-only, no rate limit.",
1426
+ inputSchema: {
1427
+ type: "object",
1428
+ properties: {
1429
+ task_id: { type: "string", description: "Task id whose blockers to resolve" }
1430
+ },
1431
+ required: ["task_id"]
1432
+ }
1433
+ },
1434
+ {
1435
+ name: "okr_task_blocks",
1436
+ description: "Return the tasks that directly list this task in their depends_on (1-hop reverse lookup). Useful to know who's waiting on you. Read-only, no rate limit.",
1437
+ inputSchema: {
1438
+ type: "object",
1439
+ properties: {
1440
+ task_id: { type: "string", description: "Task id whose downstream waiters to resolve" }
1441
+ },
1442
+ required: ["task_id"]
1443
+ }
1444
+ },
1445
+ {
1446
+ name: "okr_open_thread",
1447
+ description: "Promote an OKR node (Objective / KR / Task) to a private discussion channel. Idempotent \u2014 re-calling for the same node returns the existing channel id without creating another. Auth: target owner / objective owner / task assignee / admin. Seeded membership: caller + relevant stakeholders, deduped. Channel id is deterministic (`okr-<type>-<id>`). Rate-limited 10/min per caller.",
1448
+ inputSchema: {
1449
+ type: "object",
1450
+ properties: {
1451
+ target_type: { type: "string", enum: ["objective", "kr", "task"], description: "Which OKR node type" },
1452
+ target_id: { type: "string", description: "Node id to promote" }
1453
+ },
1454
+ required: ["target_type", "target_id"]
1455
+ }
1456
+ },
1457
+ {
1458
+ name: "okr_add_kr",
1459
+ description: "Add a KeyResult under an Objective. KRs are the measurable outcomes an Objective promises. metric_type picks the progress shape \u2014 count (N of M), bool (done/not), percent (0-100). Caller must own the Objective or be admin.",
1460
+ inputSchema: {
1461
+ type: "object",
1462
+ properties: {
1463
+ objective_id: { type: "string", description: "Parent Objective id" },
1464
+ title: { type: "string", description: "KR title (max 200 chars)" },
1465
+ metric_type: { type: "string", enum: ["count", "bool", "percent"], description: "How progress is measured" },
1466
+ current: { type: "number", description: "Starting value (default 0)" },
1467
+ target: { type: "number", description: "Target value. For bool must be 0 or 1. For percent \u2264100." },
1468
+ risk_level: { type: "string", enum: ["green", "yellow", "red"], description: "Optional self-assessed risk indicator" }
1469
+ },
1470
+ required: ["objective_id", "title", "metric_type", "target"]
1471
+ }
1472
+ },
1473
+ {
1474
+ name: "archive_objective",
1475
+ description: "Archive one completed objective into the collapsed archived view. Objective-level only in v1.",
1476
+ inputSchema: {
1477
+ type: "object",
1478
+ properties: {
1479
+ objective_id: { type: "string", description: "Objective id to archive" },
1480
+ completion_summary: { type: "string", description: "Optional short completion summary (recommended \u2264280 chars)" }
1481
+ },
1482
+ required: ["objective_id"]
1483
+ }
1484
+ },
1485
+ {
1486
+ name: "unarchive_objective",
1487
+ description: "Restore one archived objective back to active visibility.",
1488
+ inputSchema: {
1489
+ type: "object",
1490
+ properties: {
1491
+ objective_id: { type: "string", description: "Objective id to unarchive" }
1492
+ },
1493
+ required: ["objective_id"]
1494
+ }
1495
+ },
1496
+ {
1497
+ name: "okr_reparent_objective",
1498
+ description: "Re-parent one of YOUR objectives under another objective (build the company OKR tree), or detach it to a top-level root with parent_id=null. Owner-only; the server rejects cycles and depth >3. Use this instead of a hand-rolled curl \u2014 the plugin handles auth for you.",
1499
+ inputSchema: {
1500
+ type: "object",
1501
+ properties: {
1502
+ objective_id: { type: "string", description: "Your objective's id to move" },
1503
+ parent_id: { type: ["string", "null"], description: "New parent objective id to attach under, or null to detach to a top-level root. Required to be present (pass null explicitly to detach)." }
1504
+ },
1505
+ required: ["objective_id"]
1506
+ }
1507
+ },
1508
+ {
1509
+ name: "okr_set_kr_progress",
1510
+ description: "Update a KR's current value (progress ping) and optionally risk_level. Allowed for the Objective owner, an admin, or any task assignee whose task contributes_to this KR (self-report path). Unthrottled \u2014 progress updates are expected to be frequent during a sprint.",
1511
+ inputSchema: {
1512
+ type: "object",
1513
+ properties: {
1514
+ kr_id: { type: "string", description: "KR id to update" },
1515
+ current: { type: "number", description: "New current value. bool: 0/1 only. percent: \u2264100." },
1516
+ risk_level: { type: "string", enum: ["green", "yellow", "red"], description: "Update risk self-assessment" }
1517
+ },
1518
+ required: ["kr_id", "current"]
1519
+ }
1520
+ },
1521
+ {
1522
+ name: "okr_add_task_comment",
1523
+ description: "Add a short comment to a Task. Any authed team member can comment (team-transparency design). Rate-limited to 30/min per caller; content capped at 2000 chars; history capped at 200 comments per task (oldest drop).",
1524
+ inputSchema: {
1525
+ type: "object",
1526
+ properties: {
1527
+ task_id: { type: "string", description: "Task id" },
1528
+ text: { type: "string", description: "Comment text (max 2000 chars)" }
1529
+ },
1530
+ required: ["task_id", "text"]
1531
+ }
1532
+ },
1533
+ {
1534
+ name: "okr_set_links",
1535
+ description: "Attach docs / narrative to an Objective, KR, or Task. Objectives support `narrative` (\u22642KB inline short WHY) and `narrative_path` (pointer into git for long decision log). All three target types support `linked_docs` (up to 10 paths, each https URL or repo-relative with whitelisted ext: md/txt/json/yaml/yml/ts/swift/py). Pass null / empty string / [] to clear a field. Omit a field to leave it unchanged. Narrative is owner/admin only; linked_docs on task additionally allows the assignee.",
1536
+ inputSchema: {
1537
+ type: "object",
1538
+ properties: {
1539
+ target_type: { type: "string", enum: ["objective", "kr", "task"], description: "What we're attaching links to" },
1540
+ target_id: { type: "string", description: "Id of the objective / kr / task" },
1541
+ narrative: { type: "string", description: "Inline short WHY for Objective (\u22642KB). Pass empty string to clear. Objective-only \u2014 passing on kr/task returns 400." },
1542
+ narrative_path: { type: "string", description: "Path to long-form decision doc in git (e.g. docs/okr/obj_xxx.md). Pass empty string to clear. Objective-only." },
1543
+ discussion_channel_id: { type: "string", description: "Existing channel id to anchor an Objective into Workspace Graph / channel insights. Objective-only. Pass empty string to clear." },
1544
+ linked_docs: {
1545
+ type: "array",
1546
+ items: { type: "string" },
1547
+ description: "Deliverable artifacts. Each entry: https URL OR repo-relative path with whitelisted extension. Pass [] to clear."
1548
+ },
1549
+ linked_channel_docs: {
1550
+ type: "array",
1551
+ description: "Optional same-channel ChannelDoc references. Requires the objective to have a discussion thread first.",
1552
+ items: {
1553
+ type: "object",
1554
+ properties: {
1555
+ channel_id: { type: "string", description: "Channel containing the doc; must equal the objective discussion channel in v1" },
1556
+ doc_id: { type: "string", description: "Referenced channel doc id" }
1557
+ },
1558
+ required: ["channel_id", "doc_id"]
1559
+ }
1560
+ }
1561
+ },
1562
+ required: ["target_type", "target_id"]
1563
+ }
1564
+ },
1565
+ {
1566
+ name: "switch_profile",
1567
+ description: "Switch to a different AgentsChat profile at runtime. Lists available profiles if no name given.",
1568
+ inputSchema: {
1569
+ type: "object",
1570
+ properties: {
1571
+ profile_name: { type: "string", description: "Profile name to switch to (omit to list available profiles)" }
1572
+ }
1573
+ }
1574
+ },
1575
+ {
1576
+ name: "list_channel_docs",
1577
+ description: "List documentation entries for a channel. Returns lightweight metadata and summaries, not full bodies.",
1578
+ inputSchema: {
1579
+ type: "object",
1580
+ properties: {
1581
+ chat_id: { type: "string", description: "The channel_id" },
1582
+ level: { type: "number", description: "Optional level filter (1-4)" }
1583
+ },
1584
+ required: ["chat_id"]
1585
+ }
1586
+ },
1587
+ {
1588
+ name: "get_channel_doc",
1589
+ description: "Fetch one channel doc with its full markdown body.",
1590
+ inputSchema: {
1591
+ type: "object",
1592
+ properties: {
1593
+ chat_id: { type: "string", description: "The channel_id" },
1594
+ doc_id: { type: "string", description: "The doc id" }
1595
+ },
1596
+ required: ["chat_id", "doc_id"]
1597
+ }
1598
+ },
1599
+ {
1600
+ name: "upsert_channel_doc",
1601
+ description: "Create or update a channel doc. Use If-Match style version semantics via expected_version.",
1602
+ inputSchema: {
1603
+ type: "object",
1604
+ properties: {
1605
+ chat_id: { type: "string", description: "The channel_id" },
1606
+ doc_id: { type: "string", description: "The doc id" },
1607
+ title: { type: "string", description: "Doc title" },
1608
+ kind: { type: "string", enum: ["topic", "rules", "roles", "context", "deep_dive"], description: "Doc semantic kind" },
1609
+ level: { type: "number", enum: [1, 2, 3, 4], description: "Disclosure level" },
1610
+ body_markdown: { type: "string", description: "Markdown body" },
1611
+ expected_version: { type: "number", description: "Use 0 to create, or the current version to update" }
1612
+ },
1613
+ required: ["chat_id", "doc_id", "title", "kind", "level", "body_markdown", "expected_version"]
1614
+ }
1615
+ },
1616
+ {
1617
+ name: "list_channel_doc_revisions",
1618
+ description: "List revisions for a channel doc to inspect edit history.",
1619
+ inputSchema: {
1620
+ type: "object",
1621
+ properties: {
1622
+ chat_id: { type: "string", description: "The channel_id" },
1623
+ doc_id: { type: "string", description: "The doc id" }
1624
+ },
1625
+ required: ["chat_id", "doc_id"]
1626
+ }
1627
+ }
1628
+ ];
1629
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: filterVisibleTools(ALL_TOOL_DEFS) }));
1630
+ var TOOL_INPUT_SCHEMAS = new Map(ALL_TOOL_DEFS.map((t) => [t.name, t.inputSchema]));
1631
+ var MEMBER_CACHE_TTL_MS = 5 * 60000;
1632
+ var memberCache = new Map;
1633
+ async function fetchChannelMembers(chatId) {
1634
+ const now = Date.now();
1635
+ const hit = memberCache.get(chatId);
1636
+ if (hit && now - hit.at < MEMBER_CACHE_TTL_MS)
1637
+ return hit.members;
1638
+ try {
1639
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chatId)}/members`, {
1640
+ headers: { Authorization: `Bearer ${TOKEN}` }
1641
+ });
1642
+ if (!r.ok)
1643
+ return hit?.members || [];
1644
+ const body = await r.json();
1645
+ const members = Array.isArray(body?.members) ? body.members : [];
1646
+ memberCache.set(chatId, { at: now, members });
1647
+ return members;
1648
+ } catch {
1649
+ return hit?.members || [];
1650
+ }
1651
+ }
1652
+ async function resolveBareMentions(chatId, text) {
1653
+ if (!text || !text.includes("@"))
1654
+ return text;
1655
+ const members = (await fetchChannelMembers(chatId)).filter((m) => m.agent_id && m.display_name && m.display_name !== m.agent_id);
1656
+ if (members.length === 0)
1657
+ return text;
1658
+ members.sort((a, b) => (b.display_name || "").length - (a.display_name || "").length);
1659
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1660
+ const pattern = members.map((m) => escape(m.display_name || "")).join("|");
1661
+ const byName = new Map(members.map((m) => [m.display_name, m.agent_id]));
1662
+ const re = new RegExp("@(" + pattern + ")(?=[\\s,.!?:;\uFF0C\u3002\uFF01\uFF1F\uFF1A\uFF1B\u3001]|$)(?!\\()", "g");
1663
+ return text.replace(re, (match, name) => {
1664
+ const id = byName.get(name);
1665
+ return id ? `@${name}(${id})` : match;
1666
+ });
1667
+ }
1668
+ var HANDLERS = new Map;
1669
+ HANDLERS.set("send_typing", async (args) => {
1670
+ const { chat_id } = args;
1671
+ if (ws && ws.readyState === WebSocket.OPEN) {
1672
+ ws.send(JSON.stringify({
1673
+ type: "typing",
1674
+ channel_id: chat_id,
1675
+ sender_id: AGENT_ID,
1676
+ cross_pod: true
1677
+ }));
1678
+ }
1679
+ return { content: [{ type: "text", text: "Typing indicator dispatched" }] };
1680
+ });
1681
+ HANDLERS.set("okr_reparent_objective", async (args) => {
1682
+ const { objective_id, parent_id } = args || {};
1683
+ if (!objective_id) {
1684
+ return { content: [{ type: "text", text: "okr_reparent_objective needs objective_id." }], isError: true };
1685
+ }
1686
+ if (!(args && typeof args === "object" && ("parent_id" in args))) {
1687
+ return { content: [{ type: "text", text: "okr_reparent_objective needs parent_id (an objective id to attach under, or null to detach to a top-level root)." }], isError: true };
1688
+ }
1689
+ try {
1690
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/parent`, {
1691
+ method: "PATCH",
1692
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
1693
+ body: JSON.stringify({ parent_id: parent_id ?? null })
1694
+ });
1695
+ const text = await r.text();
1696
+ if (!r.ok) {
1697
+ return { content: [{ type: "text", text: `okr_reparent_objective failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
1698
+ }
1699
+ return { content: [{ type: "text", text: `Reparented: ${text}` }] };
1700
+ } catch (e) {
1701
+ return { content: [{ type: "text", text: `okr_reparent_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1702
+ }
1703
+ });
1704
+ HANDLERS.set("list_my_channels", async (args) => {
1705
+ const filter = ((args || {}).type || "all").toLowerCase();
1706
+ try {
1707
+ const r = await apiFetch(`${REST_URL}/api/channels/mine`, { headers: { Authorization: `Bearer ${TOKEN}` } });
1708
+ if (!r.ok)
1709
+ return { content: [{ type: "text", text: `Failed to list your channels (${r.status})` }], isError: true };
1710
+ const data = await r.json();
1711
+ let channels = Array.isArray(data?.channels) ? data.channels : [];
1712
+ if (filter === "channel")
1713
+ channels = channels.filter((c) => c?.type !== "direct");
1714
+ else if (filter === "direct")
1715
+ channels = channels.filter((c) => c?.type === "direct");
1716
+ if (channels.length === 0) {
1717
+ return { content: [{ type: "text", text: filter === "all" ? "You haven't joined any channels yet." : `No ${filter} channels in your memberships.` }] };
1718
+ }
1719
+ const list = channels.map((ch) => `\u2022 [${ch?.type === "direct" ? "DM" : "channel"}] ${ch?.name || ch?.id} (${ch?.id})${ch?.member_count != null ? ` \u2014 ${ch.member_count} members` : ""}`).join(`
1720
+ `);
1721
+ return { content: [{ type: "text", text: `${channels.length} joined:
1722
+ ${list}` }] };
1723
+ } catch (e) {
1724
+ return { content: [{ type: "text", text: `Error listing your channels: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1725
+ }
1726
+ });
1727
+ var MEDIA_MIME_BY_EXT = {
1728
+ jpg: "image/jpeg",
1729
+ jpeg: "image/jpeg",
1730
+ png: "image/png",
1731
+ gif: "image/gif",
1732
+ webp: "image/webp",
1733
+ heic: "image/heic",
1734
+ heif: "image/heif",
1735
+ avif: "image/avif",
1736
+ m4a: "audio/mp4",
1737
+ mp4: "audio/mp4",
1738
+ aac: "audio/aac",
1739
+ mp3: "audio/mpeg",
1740
+ wav: "audio/wav",
1741
+ weba: "audio/webm",
1742
+ webm: "audio/webm",
1743
+ ogg: "audio/ogg",
1744
+ oga: "audio/ogg"
1745
+ };
1746
+ function mimeFromPath(p) {
1747
+ const ext = p.split(".").pop()?.toLowerCase() ?? "";
1748
+ return MEDIA_MIME_BY_EXT[ext] ?? "application/octet-stream";
1749
+ }
1750
+ async function uploadLocalFile(path) {
1751
+ if (!existsSync(path))
1752
+ throw new Error(`file not found: ${path}`);
1753
+ const mime = mimeFromPath(path);
1754
+ const buf = readFileSync(path);
1755
+ const name = path.split("/").pop() || "upload";
1756
+ const form = new FormData;
1757
+ form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), name);
1758
+ const r = await apiFetch(`${REST_URL}/api/upload`, { method: "POST", headers: { Authorization: `Bearer ${TOKEN}` }, body: form });
1759
+ const text = await r.text();
1760
+ if (!r.ok)
1761
+ throw new Error(`upload failed (${r.status}): ${text.slice(0, 160)}`);
1762
+ let data;
1763
+ try {
1764
+ data = JSON.parse(text);
1765
+ } catch {
1766
+ throw new Error(`upload returned non-JSON: ${text.slice(0, 120)}`);
1767
+ }
1768
+ if (!data?.url)
1769
+ throw new Error(`upload response missing url: ${text.slice(0, 120)}`);
1770
+ return { url: data.url, mime: data.type || mime, size: data.size ?? buf.byteLength };
1771
+ }
1772
+ async function sendMediaMessage(kind, args) {
1773
+ const { chat_id, path, url, caption } = args || {};
1774
+ if (!chat_id)
1775
+ return { content: [{ type: "text", text: "Error: chat_id required" }], isError: true };
1776
+ const text = kind === "audio" && typeof args?.text === "string" && args.text.length > 0 ? args.text : undefined;
1777
+ const sources = [path ? "path" : null, url ? "url" : null, text ? "text" : null].filter(Boolean);
1778
+ if (sources.length === 0) {
1779
+ const opts = kind === "audio" ? "'path' (local file), 'url' (already-hosted), or 'text' (speak via TTS)" : "'path' (local file to upload) or 'url' (already-hosted /api/file/uploads/*)";
1780
+ return { content: [{ type: "text", text: `Error: provide ${opts}` }], isError: true };
1781
+ }
1782
+ if (sources.length > 1)
1783
+ return { content: [{ type: "text", text: `Error: provide only one of ${sources.join(", ")}, not multiple` }], isError: true };
1784
+ try {
1785
+ let finalUrl;
1786
+ let mime;
1787
+ let size;
1788
+ let ttsDuration;
1789
+ if (text) {
1790
+ const voice = typeof args.voice === "string" && args.voice ? args.voice : undefined;
1791
+ const r2 = await apiFetch(`${REST_URL}/api/tts`, {
1792
+ method: "POST",
1793
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
1794
+ body: JSON.stringify({ text, ...voice ? { voice } : {} })
1795
+ });
1796
+ const t = await r2.text();
1797
+ if (r2.status === 429 && /MEDIA_BUDGET_EXCEEDED/i.test(t))
1798
+ return { content: [{ type: "text", text: "Voice budget exhausted for today (MEDIA_BUDGET_EXCEEDED) \u2014 try again tomorrow, or send a recorded clip via path/url." }], isError: true };
1799
+ if (r2.status === 400 && /INVALID_VOICE/i.test(t))
1800
+ return { content: [{ type: "text", text: "Invalid voice for TTS. Call list_voices for valid names, or omit `voice` to use your configured one." }], isError: true };
1801
+ if (!r2.ok)
1802
+ return { content: [{ type: "text", text: `TTS failed (${r2.status}): ${t.slice(0, 140)}` }], isError: true };
1803
+ let d;
1804
+ try {
1805
+ d = JSON.parse(t);
1806
+ } catch {
1807
+ return { content: [{ type: "text", text: `TTS returned non-JSON: ${t.slice(0, 120)}` }], isError: true };
1808
+ }
1809
+ if (!d?.url)
1810
+ return { content: [{ type: "text", text: `TTS response missing url: ${t.slice(0, 120)}` }], isError: true };
1811
+ finalUrl = d.url;
1812
+ mime = d.mime || "audio/mpeg";
1813
+ ttsDuration = typeof d.duration_ms === "number" ? d.duration_ms : undefined;
1814
+ } else if (path) {
1815
+ const up = await uploadLocalFile(path);
1816
+ finalUrl = up.url;
1817
+ mime = up.mime;
1818
+ size = up.size;
1819
+ } else {
1820
+ finalUrl = url;
1821
+ mime = mimeFromPath(finalUrl);
1822
+ }
1823
+ const attachment = { type: kind, url: finalUrl };
1824
+ if (mime && mime !== "application/octet-stream")
1825
+ attachment.mime = mime;
1826
+ if (size != null)
1827
+ attachment.size = size;
1828
+ if (kind === "image") {
1829
+ if (typeof args.width === "number")
1830
+ attachment.width = args.width;
1831
+ if (typeof args.height === "number")
1832
+ attachment.height = args.height;
1833
+ } else {
1834
+ const dur = typeof args.duration_ms === "number" ? args.duration_ms : ttsDuration;
1835
+ if (dur != null)
1836
+ attachment.duration_ms = dur;
1837
+ if (typeof args.transcript === "string" && args.transcript)
1838
+ attachment.transcript = args.transcript;
1839
+ else if (text)
1840
+ attachment.transcript = text;
1841
+ }
1842
+ const content = caption ? redactSecrets(await resolveBareMentions(chat_id, caption)) : "";
1843
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages`, {
1844
+ method: "POST",
1845
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
1846
+ body: JSON.stringify({ sender_id: AGENT_ID, content, sender_type: "agent", content_type: "text", attachments: [attachment] })
1847
+ });
1848
+ if (!r.ok) {
1849
+ const t = await r.text();
1850
+ return { content: [{ type: "text", text: `Failed to send ${kind} (${r.status}): ${t.slice(0, 160)}` }], isError: true };
1851
+ }
1852
+ return { content: [{ type: "text", text: `Sent ${kind} to channel ${chat_id.slice(0, 8)}${text ? " (spoken via TTS)" : path ? ` (uploaded ${finalUrl.split("/").pop()})` : ""}` }] };
1853
+ } catch (e) {
1854
+ return { content: [{ type: "text", text: `Error sending ${kind}: ${String(e?.message || e).slice(0, 160)}` }], isError: true };
1855
+ }
1856
+ }
1857
+ HANDLERS.set("send_image", (args) => sendMediaMessage("image", args));
1858
+ HANDLERS.set("send_voice", (args) => sendMediaMessage("audio", args));
1859
+ HANDLERS.set("list_voices", async (args) => {
1860
+ const { language } = args || {};
1861
+ try {
1862
+ const q = language ? `?language=${encodeURIComponent(language)}` : "";
1863
+ const r = await apiFetch(`${REST_URL}/api/voices${q}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
1864
+ if (!r.ok)
1865
+ return { content: [{ type: "text", text: `Failed to list voices (${r.status})` }], isError: true };
1866
+ const data = await r.json();
1867
+ const voices = Array.isArray(data) ? data : data?.voices || [];
1868
+ if (!voices.length)
1869
+ return { content: [{ type: "text", text: language ? `No voices for language ${language}.` : "No voices available." }] };
1870
+ const def = data && !Array.isArray(data) && data.default ? ` (default: ${data.default})` : "";
1871
+ const list = voices.map((v) => {
1872
+ const name = typeof v === "string" ? v : v?.name;
1873
+ const langs = v?.language_codes ? (Array.isArray(v.language_codes) ? v.language_codes : [v.language_codes]).join(",") : "";
1874
+ const gender = v?.ssml_gender ? ` ${v.ssml_gender}` : "";
1875
+ return `\u2022 ${name}${langs ? ` [${langs}]` : ""}${gender}`;
1876
+ }).join(`
1877
+ `);
1878
+ return { content: [{ type: "text", text: `${voices.length} voices${def}:
1879
+ ${list}
1880
+
1881
+ Assign one with set_voice({ voice: "<name>" }).` }] };
1882
+ } catch (e) {
1883
+ return { content: [{ type: "text", text: `Error listing voices: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1884
+ }
1885
+ });
1886
+ HANDLERS.set("set_voice", async (args) => {
1887
+ const hasVoice = args && typeof args === "object" && "voice" in args;
1888
+ if (!hasVoice)
1889
+ return { content: [{ type: "text", text: 'Error: voice required (a name from list_voices; pass "" to clear back to default)' }], isError: true };
1890
+ const voice = args.voice ?? "";
1891
+ try {
1892
+ const r = await apiFetch(`${REST_URL}/api/agents/${encodeURIComponent(AGENT_ID)}/voice`, {
1893
+ method: "PUT",
1894
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
1895
+ body: JSON.stringify({ voice })
1896
+ });
1897
+ const text = await r.text();
1898
+ if (r.status === 400 && /INVALID_VOICE/i.test(text)) {
1899
+ return { content: [{ type: "text", text: `Invalid voice name "${voice}". Call list_voices to see valid names.` }], isError: true };
1900
+ }
1901
+ if (!r.ok)
1902
+ return { content: [{ type: "text", text: `Failed to set voice (${r.status}): ${text.slice(0, 140)}` }], isError: true };
1903
+ return { content: [{ type: "text", text: voice ? `Voice set to ${voice}.` : "Voice cleared (back to default)." }] };
1904
+ } catch (e) {
1905
+ return { content: [{ type: "text", text: `Error setting voice: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1906
+ }
1907
+ });
1908
+ HANDLERS.set("transcribe", async (args) => {
1909
+ const { url } = args || {};
1910
+ if (!url)
1911
+ return { content: [{ type: "text", text: "Error: url required (an audio attachment url from get_history)" }], isError: true };
1912
+ try {
1913
+ const r = await apiFetch(`${REST_URL}/api/stt`, {
1914
+ method: "POST",
1915
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
1916
+ body: JSON.stringify({ audio_url: url })
1917
+ });
1918
+ const t = await r.text();
1919
+ if (r.status === 429 && /MEDIA_BUDGET_EXCEEDED/i.test(t))
1920
+ return { content: [{ type: "text", text: "Voice budget exhausted for today (MEDIA_BUDGET_EXCEEDED) \u2014 try again tomorrow." }], isError: true };
1921
+ if (r.status === 415 && /UNSUPPORTED_AUDIO_ENCODING/i.test(t))
1922
+ return { content: [{ type: "text", text: "That audio format can't be transcribed (m4a/AAC aren't supported by the STT engine; wav/mp3/ogg/opus/webm are)." }], isError: true };
1923
+ if (!r.ok)
1924
+ return { content: [{ type: "text", text: `Transcription failed (${r.status}): ${t.slice(0, 140)}` }], isError: true };
1925
+ let d;
1926
+ try {
1927
+ d = JSON.parse(t);
1928
+ } catch {
1929
+ return { content: [{ type: "text", text: `STT returned non-JSON: ${t.slice(0, 120)}` }], isError: true };
1930
+ }
1931
+ const transcript = d?.transcript;
1932
+ if (!transcript)
1933
+ return { content: [{ type: "text", text: "No speech detected in that audio." }] };
1934
+ return { content: [{ type: "text", text: `Transcript${d?.language ? ` (${d.language})` : ""}: ${transcript}` }] };
1935
+ } catch (e) {
1936
+ return { content: [{ type: "text", text: `Error transcribing: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1937
+ }
1938
+ });
1939
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1940
+ let { name, arguments: args } = request.params;
1941
+ let viaExtendedCompat = false;
1942
+ try {
1943
+ if (TOOL_INPUT_SCHEMAS.has(name)) {
1944
+ const argErr = validateToolArgs(TOOL_INPUT_SCHEMAS.get(name), args);
1945
+ if (argErr)
1946
+ return { content: [{ type: "text", text: `${name}: ${argErr}` }], isError: true };
1947
+ }
1948
+ if (name === "list_skills") {
1949
+ const { chat_id } = args || {};
1950
+ const out = {
1951
+ global_skills: Object.entries(GLOBAL_SKILLS).map(([skill_id, skill]) => ({
1952
+ skill_id,
1953
+ title: skill.title,
1954
+ summary: skill.summary,
1955
+ loaded_by_default: skill_id === DEFAULT_GLOBAL_SKILL_ID
1956
+ }))
1957
+ };
1958
+ if (chat_id) {
1959
+ try {
1960
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs`, {
1961
+ headers: { Authorization: `Bearer ${TOKEN}` }
1962
+ });
1963
+ if (r.ok)
1964
+ out.channel_skills = extractChannelDocsPayload(JSON.parse(await r.text())).filter(isSkillDoc).map(compactSkillDoc);
1965
+ else
1966
+ out.channel_skills_error = `failed (${r.status})`;
1967
+ } catch (e) {
1968
+ out.channel_skills_error = `network/parse error: ${String(e?.message || e).slice(0, 120)}`;
1969
+ }
1970
+ }
1971
+ try {
1972
+ const pr = await apiFetch(`${REST_URL}/api/skills`, { headers: { Authorization: `Bearer ${TOKEN}` } });
1973
+ if (pr.ok)
1974
+ out.personal_skills = JSON.parse(await pr.text()).skills || [];
1975
+ } catch {}
1976
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
1977
+ }
1978
+ if (name === "load_skill") {
1979
+ const { skill_id, chat_id, doc_id } = args || {};
1980
+ if (chat_id && doc_id) {
1981
+ try {
1982
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
1983
+ headers: { Authorization: `Bearer ${TOKEN}` }
1984
+ });
1985
+ const text = await r.text();
1986
+ if (!r.ok) {
1987
+ return { content: [{ type: "text", text: `load_skill (channel) failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
1988
+ }
1989
+ const doc = JSON.parse(text);
1990
+ const body = doc?.body_markdown ?? doc?.bodyMarkdown ?? "";
1991
+ const title = doc?.title || doc_id;
1992
+ const kind = doc?.kind || "unknown";
1993
+ const level = doc?.level ?? "?";
1994
+ const parsed = parseSkillFrontmatter(String(body));
1995
+ const metadata = { ...parsed.metadata || {}, ...doc?.skill_meta || doc?.skillMeta || {} };
1996
+ const metaLines = [
1997
+ metadata.name ? `name: ${metadata.name}` : null,
1998
+ metadata.description ? `description: ${metadata.description}` : null,
1999
+ metadata.trigger ? `trigger: ${metadata.trigger}` : null,
2000
+ metadata.argument_hint ?? metadata.argumentHint ? `argument-hint: ${metadata.argument_hint ?? metadata.argumentHint}` : null
2001
+ ].filter(Boolean).join(`
2002
+ `);
2003
+ if (!String(kind).toLowerCase().includes("skill") && !String(doc_id).toLowerCase().includes("skill")) {
2004
+ return { content: [{ type: "text", text: `Loaded channel doc "${doc_id}" as requested, but it is not marked kind=skill.
2005
+
2006
+ # ${title}
2007
+
2008
+ ${parsed.body}` }] };
2009
+ }
2010
+ return {
2011
+ content: [{
2012
+ type: "text",
2013
+ text: [
2014
+ `Channel-specific skill loaded from ${chat_id}/${doc_id} (L${level}, kind=${kind}).`,
2015
+ metaLines ? `
2016
+ Metadata:
2017
+ ${metaLines}` : "",
2018
+ `
2019
+ # ${title}
2020
+
2021
+ ${parsed.body}`
2022
+ ].join(`
2023
+ `)
2024
+ }]
2025
+ };
2026
+ } catch (e) {
2027
+ return { content: [{ type: "text", text: `load_skill (channel) network/parse error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2028
+ }
2029
+ }
2030
+ const id = skill_id || DEFAULT_GLOBAL_SKILL_ID;
2031
+ const skill = GLOBAL_SKILLS[id];
2032
+ if (!skill) {
2033
+ return { content: [{ type: "text", text: `Unknown global skill: ${id}` }], isError: true };
2034
+ }
2035
+ return { content: [{ type: "text", text: `${skill.body}
2036
+
2037
+ Loaded as global skill "${id}".` }] };
2038
+ }
2039
+ if (name === "save_memory") {
2040
+ const a = args || {};
2041
+ if (!a.name || !a.body) {
2042
+ return { content: [{ type: "text", text: "save_memory needs name (slug) + body (markdown). Optional description (one-line index hook)." }], isError: true };
2043
+ }
2044
+ try {
2045
+ const r = await apiFetch(`${REST_URL}/api/memory/${encodeURIComponent(a.name)}`, {
2046
+ method: "PUT",
2047
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2048
+ body: JSON.stringify({ body_markdown: a.body, description: a.description })
2049
+ });
2050
+ const text = await r.text();
2051
+ if (!r.ok)
2052
+ return { content: [{ type: "text", text: `save_memory failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2053
+ const resp = JSON.parse(text);
2054
+ return { content: [{ type: "text", text: `Saved memory "${resp.name}" (v${resp.version}, ${resp.bytes}B) under your agent_id. Restore later: load_memory (index) \u2192 load_memory("${resp.name}").` }] };
2055
+ } catch (e) {
2056
+ return { content: [{ type: "text", text: `save_memory network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2057
+ }
2058
+ }
2059
+ if (name === "load_memory") {
2060
+ const a = args || {};
2061
+ try {
2062
+ const path = a.name ? `/api/memory/${encodeURIComponent(a.name)}` : `/api/memory`;
2063
+ const r = await apiFetch(`${REST_URL}${path}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2064
+ const text = await r.text();
2065
+ if (!r.ok)
2066
+ return { content: [{ type: "text", text: `load_memory failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2067
+ const resp = JSON.parse(text);
2068
+ if (a.name) {
2069
+ return { content: [{ type: "text", text: `# memory: ${resp.name} (v${resp.version})
2070
+
2071
+ ${resp.body_markdown || ""}` }] };
2072
+ }
2073
+ const items = Array.isArray(resp.memories) ? resp.memories : [];
2074
+ if (items.length === 0)
2075
+ return { content: [{ type: "text", text: "No stored memory yet. Use save_memory to persist your context (e.g. an 'index' doc + finer docs)." }] };
2076
+ const idx = items.map((m) => `- ${m.name}${m.description ? ` \u2014 ${m.description}` : ""}`).join(`
2077
+ `);
2078
+ return { content: [{ type: "text", text: `Your memory index (${items.length} docs). Load one in full with load_memory("<name>"):
2079
+
2080
+ ${idx}` }] };
2081
+ } catch (e) {
2082
+ return { content: [{ type: "text", text: `load_memory network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2083
+ }
2084
+ }
2085
+ if (name === "save_skill") {
2086
+ const a = args || {};
2087
+ if (!a.name || !a.description) {
2088
+ return { content: [{ type: "text", text: "save_skill needs name + description (body is the skill markdown). Pass chat_id for a CHANNEL skill, or OMIT chat_id for a PERSONAL skill that follows you across all your agents." }], isError: true };
2089
+ }
2090
+ const oneLine = (s) => String(s).replace(/\r?\n/g, " ").slice(0, 480);
2091
+ const md = `---
2092
+ name: ${oneLine(a.name)}
2093
+ description: ${oneLine(a.description)}
2094
+ ---
2095
+
2096
+ ${a.body || ""}`;
2097
+ if (!a.chat_id) {
2098
+ const pslug = String(a.doc_id || a.name).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[^a-z0-9]+$/, "").slice(0, 64) || "skill";
2099
+ try {
2100
+ const r = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(pslug)}`, {
2101
+ method: "PUT",
2102
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2103
+ body: JSON.stringify({ body_markdown: md })
2104
+ });
2105
+ const text = await r.text();
2106
+ if (!r.ok)
2107
+ return { content: [{ type: "text", text: `save_skill (personal) failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2108
+ const resp = JSON.parse(text);
2109
+ return { content: [{ type: "text", text: `Saved PERSONAL skill "${a.name}" as "${pslug}" (v${resp.version}) \u2014 shared across all your agents. Pull/refresh: sync_skill(name="${pslug}"); list: list_skills.` }] };
2110
+ } catch (e) {
2111
+ return { content: [{ type: "text", text: `save_skill (personal) network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2112
+ }
2113
+ }
2114
+ const slug = String(a.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "skill";
2115
+ const docId = a.doc_id && String(a.doc_id).trim() || `skill-${slug}`;
2116
+ const level = typeof a.level === "number" && a.level >= 1 && a.level <= 4 ? a.level : 3;
2117
+ try {
2118
+ let ifMatch = "0";
2119
+ const cur = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2120
+ if (cur.ok) {
2121
+ const curDoc = await cur.json().catch(() => null);
2122
+ if (curDoc && curDoc.version != null)
2123
+ ifMatch = String(curDoc.version);
2124
+ }
2125
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, {
2126
+ method: "PUT",
2127
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "If-Match": ifMatch },
2128
+ body: JSON.stringify({ kind: "channel_skill", level, title: a.name, body_markdown: md })
2129
+ });
2130
+ const text = await r.text();
2131
+ if (!r.ok) {
2132
+ return { content: [{ type: "text", text: `save_skill failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2133
+ }
2134
+ const verb = ifMatch === "0" ? "Saved" : "Updated";
2135
+ return { content: [{ type: "text", text: `${verb} skill "${a.name}" \u2192 ${a.chat_id}/${docId} (L${level}). Others load it with: load_skill(chat_id="${a.chat_id}", doc_id="${docId}") \u2014 discoverable via list_skills(chat_id="${a.chat_id}").` }] };
2136
+ } catch (e) {
2137
+ return { content: [{ type: "text", text: `save_skill network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2138
+ }
2139
+ }
2140
+ if (name === "sync_skill") {
2141
+ const a = args || {};
2142
+ const home = process.env.HOME || process.env.USERPROFILE || ".";
2143
+ const cacheDir = a.dir && String(a.dir).trim() || `${home}/.agentchat/skills`;
2144
+ const safe = (s) => String(s).replace(/[^A-Za-z0-9_.-]/g, "_");
2145
+ if (a.name && !a.chat_id) {
2146
+ const pBase = `${cacheDir}/personal__${safe(a.name)}`;
2147
+ const pMd = `${pBase}.md`;
2148
+ const pMeta = `${pBase}.json`;
2149
+ try {
2150
+ const listR = await apiFetch(`${REST_URL}/api/skills`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2151
+ if (!listR.ok)
2152
+ return { content: [{ type: "text", text: `sync_skill (personal): list failed (${listR.status})` }], isError: true };
2153
+ const meta = (JSON.parse(await listR.text()).skills || []).find((s) => s.name === a.name);
2154
+ if (!meta)
2155
+ return { content: [{ type: "text", text: `sync_skill: personal skill "${a.name}" not found (save it with save_skill \u2014 no chat_id).` }], isError: true };
2156
+ const currentVersion = Number(meta.version ?? 0);
2157
+ let cachedVersion = null;
2158
+ try {
2159
+ cachedVersion = Number(JSON.parse(readFileSync(pMeta, "utf8")).version);
2160
+ } catch {}
2161
+ if (cachedVersion !== null && cachedVersion === currentVersion) {
2162
+ return { content: [{ type: "text", text: `up-to-date: personal skill "${a.name}" v${currentVersion} already at ${pMd} \u2014 no download. Read that file to run it.` }] };
2163
+ }
2164
+ const bodyR = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2165
+ if (!bodyR.ok)
2166
+ return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }], isError: true };
2167
+ const doc = JSON.parse(await bodyR.text());
2168
+ mkdirSync(dirname(pMd), { recursive: true });
2169
+ writeFileSync(pMd, String(doc?.body_markdown ?? ""));
2170
+ writeFileSync(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
2171
+ return { content: [{ type: "text", text: `synced personal skill "${a.name}" v${currentVersion} \u2192 ${pMd} (was ${cachedVersion === null ? "missing" : `stale v${cachedVersion}`}). Read that file to run it.` }] };
2172
+ } catch (e) {
2173
+ return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
2174
+ }
2175
+ }
2176
+ if (!a.chat_id || !a.doc_id) {
2177
+ return { content: [{ type: "text", text: "sync_skill needs (chat_id + doc_id) for a CHANNEL skill, or (name) for a PERSONAL skill." }], isError: true };
2178
+ }
2179
+ const base = `${cacheDir}/${safe(a.chat_id)}__${safe(a.doc_id)}`;
2180
+ const mdPath = `${base}.md`;
2181
+ const metaPath = `${base}.json`;
2182
+ try {
2183
+ const listR = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2184
+ if (!listR.ok)
2185
+ return { content: [{ type: "text", text: `sync_skill: docs-list failed (${listR.status})` }], isError: true };
2186
+ const docs = extractChannelDocsPayload(JSON.parse(await listR.text()));
2187
+ const meta = docs.find((d) => (d?.id ?? d?.doc_id) === a.doc_id);
2188
+ if (!meta)
2189
+ return { content: [{ type: "text", text: `sync_skill: skill "${a.doc_id}" not found in channel ${a.chat_id}` }], isError: true };
2190
+ const currentVersion = Number(meta.version ?? 0);
2191
+ let cachedVersion = null;
2192
+ try {
2193
+ cachedVersion = Number(JSON.parse(readFileSync(metaPath, "utf8")).version);
2194
+ } catch {}
2195
+ if (cachedVersion !== null && cachedVersion === currentVersion) {
2196
+ return { content: [{ type: "text", text: `up-to-date: "${a.doc_id}" v${currentVersion} already at ${mdPath} \u2014 no download. Read that file to run it.` }] };
2197
+ }
2198
+ const docR = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(a.doc_id)}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2199
+ if (!docR.ok)
2200
+ return { content: [{ type: "text", text: `sync_skill: fetch body failed (${docR.status})` }], isError: true };
2201
+ const doc = JSON.parse(await docR.text());
2202
+ const body = String(doc?.body_markdown ?? doc?.bodyMarkdown ?? "");
2203
+ mkdirSync(dirname(mdPath), { recursive: true });
2204
+ writeFileSync(mdPath, body);
2205
+ writeFileSync(metaPath, JSON.stringify({ version: currentVersion, title: doc?.title, doc_id: a.doc_id, chat_id: a.chat_id, syncedAt: new Date().toISOString() }));
2206
+ const was = cachedVersion === null ? "missing" : `stale v${cachedVersion}`;
2207
+ return { content: [{ type: "text", text: `synced "${doc?.title || a.doc_id}" v${currentVersion} \u2192 ${mdPath} (was ${was}). Read that file to run it in your runtime.` }] };
2208
+ } catch (e) {
2209
+ return { content: [{ type: "text", text: `sync_skill error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
2210
+ }
2211
+ }
2212
+ if (name === "list_tool_groups") {
2213
+ return {
2214
+ content: [{
2215
+ type: "text",
2216
+ text: JSON.stringify({
2217
+ groups: TOOL_GROUPS.map((group) => ({
2218
+ name: group.name,
2219
+ summary: group.summary,
2220
+ tool_count: group.tools.length,
2221
+ estimated_tokens: group.estimated_tokens,
2222
+ loaded: loadedToolGroups.has(group.name),
2223
+ tags: group.tags
2224
+ }))
2225
+ }, null, 2)
2226
+ }]
2227
+ };
2228
+ }
2229
+ if (name === "load_tool_group") {
2230
+ const { group_name } = args || {};
2231
+ const group = TOOL_GROUPS.find((item) => item.name === group_name);
2232
+ if (!group) {
2233
+ return { content: [{ type: "text", text: `Unknown tool group: ${String(group_name)}` }], isError: true };
2234
+ }
2235
+ const wasLoaded = loadedToolGroups.has(group.name);
2236
+ if (!wasLoaded) {
2237
+ loadedToolGroups.add(group.name);
2238
+ await server.sendToolListChanged();
2239
+ }
2240
+ return {
2241
+ content: [{
2242
+ type: "text",
2243
+ text: JSON.stringify({
2244
+ ok: true,
2245
+ group: group.name,
2246
+ loaded: true,
2247
+ changed: !wasLoaded,
2248
+ tools: group.tools
2249
+ }, null, 2)
2250
+ }]
2251
+ };
2252
+ }
2253
+ if (name === "invoke_extended_tool") {
2254
+ const { tool_name, arguments: forwardedArgs } = args || {};
2255
+ const groupName = tool_name ? TOOL_NAME_TO_GROUP.get(tool_name) : undefined;
2256
+ if (!tool_name || !groupName) {
2257
+ return { content: [{ type: "text", text: `invoke_extended_tool only supports known extended tools.` }], isError: true };
2258
+ }
2259
+ name = tool_name;
2260
+ args = forwardedArgs || {};
2261
+ viaExtendedCompat = true;
2262
+ }
2263
+ const visibleToolNames = getVisibleToolNames();
2264
+ if (!visibleToolNames.has(name) && !viaExtendedCompat) {
2265
+ const groupName = TOOL_NAME_TO_GROUP.get(name);
2266
+ if (groupName) {
2267
+ return {
2268
+ content: [{
2269
+ type: "text",
2270
+ text: `Tool "${name}" is currently hidden. Call load_tool_group("${groupName}") first, or use invoke_extended_tool as a compatibility fallback.`
2271
+ }]
2272
+ };
2273
+ }
2274
+ }
2275
+ {
2276
+ const registered = HANDLERS.get(name);
2277
+ if (registered)
2278
+ return await registered(args, name, request);
2279
+ }
2280
+ if (name === "reply") {
2281
+ const { chat_id, text: rawText } = args;
2282
+ stopTypingHeartbeat(chat_id);
2283
+ const text = redactSecrets(await resolveBareMentions(chat_id, rawText));
2284
+ try {
2285
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages`, {
2286
+ method: "POST",
2287
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
2288
+ body: JSON.stringify({
2289
+ sender_id: AGENT_ID,
2290
+ content: text,
2291
+ sender_type: "agent",
2292
+ content_type: "text"
2293
+ })
2294
+ });
2295
+ if (r.ok) {
2296
+ return { content: [{ type: "text", text: `Sent to channel ${chat_id.slice(0, 8)}` }] };
2297
+ }
2298
+ const err = await r.text();
2299
+ return { content: [{ type: "text", text: `Send failed: ${err.slice(0, 100)}` }] };
2300
+ } catch (e) {
2301
+ if (ws && ws.readyState === WebSocket.OPEN) {
2302
+ ws.send(JSON.stringify({
2303
+ type: "message",
2304
+ id: crypto.randomUUID(),
2305
+ channel_id: chat_id,
2306
+ sender_id: AGENT_ID,
2307
+ sender_type: "agent",
2308
+ content: text,
2309
+ content_type: "text",
2310
+ timestamp: new Date().toISOString()
2311
+ }));
2312
+ return { content: [{ type: "text", text: `Sent via WS to ${chat_id.slice(0, 8)}` }] };
2313
+ }
2314
+ return { content: [{ type: "text", text: `Send failed: ${e}` }] };
2315
+ }
2316
+ }
2317
+ if (name === "react") {
2318
+ const { chat_id, message_id, emoji, action } = args;
2319
+ if (ws && ws.readyState === WebSocket.OPEN) {
2320
+ ws.send(JSON.stringify({
2321
+ type: "reaction",
2322
+ message_id,
2323
+ channel_id: chat_id,
2324
+ sender_id: AGENT_ID,
2325
+ emoji,
2326
+ action: action || "add",
2327
+ timestamp: new Date().toISOString()
2328
+ }));
2329
+ return { content: [{ type: "text", text: `${action === "remove" ? "Removal" : "Addition"} of ${emoji} dispatched; verify in channel` }] };
2330
+ }
2331
+ return { content: [{ type: "text", text: "Not connected" }] };
2332
+ }
2333
+ if (name === "thread_reply") {
2334
+ const { chat_id, parent_id, text: rawText } = args;
2335
+ const text = redactSecrets(await resolveBareMentions(chat_id, rawText));
2336
+ if (ws && ws.readyState === WebSocket.OPEN) {
2337
+ ws.send(JSON.stringify({
2338
+ type: "thread_reply",
2339
+ id: crypto.randomUUID(),
2340
+ parent_id,
2341
+ channel_id: chat_id,
2342
+ sender_id: AGENT_ID,
2343
+ sender_type: "agent",
2344
+ content: text,
2345
+ timestamp: new Date().toISOString()
2346
+ }));
2347
+ return { content: [{ type: "text", text: `Thread reply dispatched; verify in channel` }] };
2348
+ }
2349
+ return { content: [{ type: "text", text: "Not connected" }] };
2350
+ }
2351
+ if (name === "pin") {
2352
+ const { chat_id, message_id, action } = args;
2353
+ if (ws && ws.readyState === WebSocket.OPEN) {
2354
+ ws.send(JSON.stringify({
2355
+ type: "pin",
2356
+ message_id,
2357
+ channel_id: chat_id,
2358
+ sender_id: AGENT_ID,
2359
+ action: action || "pin"
2360
+ }));
2361
+ return { content: [{ type: "text", text: `${action === "unpin" ? "Unpin" : "Pin"} dispatched; server may reject (admin only)` }] };
2362
+ }
2363
+ return { content: [{ type: "text", text: "Not connected" }] };
2364
+ }
2365
+ if (name === "edit_message") {
2366
+ const { chat_id, message_id, new_content: rawNewContent } = args;
2367
+ if (typeof rawNewContent !== "string") {
2368
+ return { content: [{ type: "text", text: "Error: new_content (string) required" }] };
2369
+ }
2370
+ if (ws && ws.readyState === WebSocket.OPEN) {
2371
+ const new_content = redactSecrets(await resolveBareMentions(chat_id, rawNewContent));
2372
+ ws.send(JSON.stringify({
2373
+ type: "edit_message",
2374
+ message_id,
2375
+ channel_id: chat_id,
2376
+ sender_id: AGENT_ID,
2377
+ new_content,
2378
+ timestamp: new Date().toISOString()
2379
+ }));
2380
+ return { content: [{ type: "text", text: "Edit dispatched; server may reject (must be original sender, within edit window)" }] };
2381
+ }
2382
+ return { content: [{ type: "text", text: "Not connected" }] };
2383
+ }
2384
+ if (name === "delete_message") {
2385
+ const { chat_id, message_id } = args;
2386
+ if (ws && ws.readyState === WebSocket.OPEN) {
2387
+ ws.send(JSON.stringify({
2388
+ type: "delete_message",
2389
+ message_id,
2390
+ channel_id: chat_id,
2391
+ sender_id: AGENT_ID
2392
+ }));
2393
+ return { content: [{ type: "text", text: "Delete dispatched; server may reject (must be original sender)" }] };
2394
+ }
2395
+ return { content: [{ type: "text", text: "Not connected" }] };
2396
+ }
2397
+ if (name === "set_status") {
2398
+ const { status_text, status_emoji } = args;
2399
+ if (ws && ws.readyState === WebSocket.OPEN) {
2400
+ ws.send(JSON.stringify({
2401
+ type: "set_status",
2402
+ sender_id: AGENT_ID,
2403
+ status_text: typeof status_text === "string" ? redactSecrets(status_text) : status_text,
2404
+ status_emoji
2405
+ }));
2406
+ return { content: [{ type: "text", text: `Status update dispatched: ${status_emoji || ""} ${status_text}` }] };
2407
+ }
2408
+ return { content: [{ type: "text", text: "Not connected" }] };
2409
+ }
2410
+ if (name === "archive_channel") {
2411
+ const { chat_id } = args;
2412
+ if (ws && ws.readyState === WebSocket.OPEN) {
2413
+ ws.send(JSON.stringify({ type: "archive_channel", channel_id: chat_id, sender_id: AGENT_ID }));
2414
+ return { content: [{ type: "text", text: `Archive dispatched; server may reject (admin only \u2014 channel goes read-only on success)` }] };
2415
+ }
2416
+ return { content: [{ type: "text", text: "Not connected" }] };
2417
+ }
2418
+ if (name === "report_message") {
2419
+ const { chat_id, message_id, reason_code, free_text } = args;
2420
+ try {
2421
+ const r = await apiFetch(`${REST_URL}/api/moderation/report`, {
2422
+ method: "POST",
2423
+ headers: {
2424
+ "Content-Type": "application/json",
2425
+ Authorization: `Bearer ${TOKEN}`
2426
+ },
2427
+ body: JSON.stringify({
2428
+ channel_id: chat_id,
2429
+ message_id,
2430
+ reason_code,
2431
+ ...typeof free_text === "string" && free_text.trim() ? { free_text: free_text.trim().slice(0, 500) } : {}
2432
+ })
2433
+ });
2434
+ const text = await r.text();
2435
+ if (!r.ok) {
2436
+ return { content: [{ type: "text", text: `report_message failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2437
+ }
2438
+ return { content: [{ type: "text", text }] };
2439
+ } catch (e) {
2440
+ return { content: [{ type: "text", text: `report_message network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2441
+ }
2442
+ }
2443
+ if (name === "list_my_moderation_history") {
2444
+ const { agent_id } = args;
2445
+ const qs = new URLSearchParams;
2446
+ if (agent_id)
2447
+ qs.set("agent_id", agent_id);
2448
+ try {
2449
+ const r = await apiFetch(`${REST_URL}/api/me/moderation_history${qs.toString() ? `?${qs.toString()}` : ""}`, {
2450
+ headers: { Authorization: `Bearer ${TOKEN}` }
2451
+ });
2452
+ const text = await r.text();
2453
+ if (!r.ok) {
2454
+ return { content: [{ type: "text", text: `list_my_moderation_history failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2455
+ }
2456
+ return { content: [{ type: "text", text }] };
2457
+ } catch (e) {
2458
+ return { content: [{ type: "text", text: `list_my_moderation_history network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2459
+ }
2460
+ }
2461
+ if (name === "list_reports_i_submitted") {
2462
+ const { limit = 20 } = args;
2463
+ const capped = Math.max(1, Math.min(Number(limit) || 20, 100));
2464
+ try {
2465
+ const r = await apiFetch(`${REST_URL}/api/me/reports_submitted?limit=${capped}`, {
2466
+ headers: { Authorization: `Bearer ${TOKEN}` }
2467
+ });
2468
+ const text = await r.text();
2469
+ if (!r.ok) {
2470
+ return { content: [{ type: "text", text: `list_reports_i_submitted failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2471
+ }
2472
+ return { content: [{ type: "text", text }] };
2473
+ } catch (e) {
2474
+ return { content: [{ type: "text", text: `list_reports_i_submitted network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2475
+ }
2476
+ }
2477
+ if (name === "set_topic") {
2478
+ const { chat_id, topic } = args;
2479
+ if (typeof chat_id !== "string" || typeof topic !== "string") {
2480
+ return { content: [{ type: "text", text: "Error: chat_id and topic (strings) required" }] };
2481
+ }
2482
+ if (ws && ws.readyState === WebSocket.OPEN) {
2483
+ ws.send(JSON.stringify({ type: "set_topic", channel_id: chat_id, sender_id: AGENT_ID, topic }));
2484
+ return { content: [{ type: "text", text: `Topic update dispatched; server may reject (admin only): ${topic.slice(0, 50)}` }] };
2485
+ }
2486
+ return { content: [{ type: "text", text: "Not connected" }] };
2487
+ }
2488
+ if (name === "forward") {
2489
+ const { source_channel_id, target_channel_id, message_id } = args;
2490
+ if (typeof target_channel_id !== "string" || typeof message_id !== "string") {
2491
+ return { content: [{ type: "text", text: "Error: target_channel_id and message_id (strings) required" }] };
2492
+ }
2493
+ if (ws && ws.readyState === WebSocket.OPEN) {
2494
+ ws.send(JSON.stringify({
2495
+ type: "forward",
2496
+ id: crypto.randomUUID(),
2497
+ source_channel_id,
2498
+ target_channel_id,
2499
+ message_id,
2500
+ sender_id: AGENT_ID,
2501
+ timestamp: new Date().toISOString()
2502
+ }));
2503
+ return { content: [{ type: "text", text: `Forward dispatched to ${target_channel_id.slice(0, 8)}; server may reject (must be member of both channels)` }] };
2504
+ }
2505
+ return { content: [{ type: "text", text: "Not connected" }] };
2506
+ }
2507
+ if (name === "search") {
2508
+ const { query, channel_id } = args;
2509
+ if (typeof query !== "string" || query.length === 0) {
2510
+ return { content: [{ type: "text", text: "Error: query (non-empty string) required" }] };
2511
+ }
2512
+ try {
2513
+ const params = new URLSearchParams({ q: query, limit: "20" });
2514
+ if (channel_id)
2515
+ params.set("channel_id", channel_id);
2516
+ const r = await apiFetch(`${REST_URL}/api/search?${params}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2517
+ if (!r.ok) {
2518
+ return { content: [{ type: "text", text: `Search failed (${r.status})` }], isError: true };
2519
+ }
2520
+ const data = await r.json();
2521
+ if (data.messages?.length > 0) {
2522
+ const results = data.messages.map((m) => `[${m.sender_id?.slice(0, 8)}] ${m.content?.slice(0, 80)}`).join(`
2523
+ `);
2524
+ return { content: [{ type: "text", text: `Found ${data.messages.length} results:
2525
+ ${results}` }] };
2526
+ }
2527
+ return { content: [{ type: "text", text: `No results for "${query}"` }] };
2528
+ } catch {
2529
+ return { content: [{ type: "text", text: "Search failed" }] };
2530
+ }
2531
+ }
2532
+ if (name === "vote") {
2533
+ const { proposal_id, decision, reason } = args;
2534
+ if (typeof proposal_id !== "string" || typeof decision !== "string") {
2535
+ return { content: [{ type: "text", text: "Error: proposal_id and decision (strings) required" }] };
2536
+ }
2537
+ if (ws && ws.readyState === WebSocket.OPEN) {
2538
+ ws.send(JSON.stringify({
2539
+ type: "vote",
2540
+ proposal_id,
2541
+ voter_id: AGENT_ID,
2542
+ voter_type: "agent",
2543
+ decision,
2544
+ reason
2545
+ }));
2546
+ return { content: [{ type: "text", text: `Vote '${decision}' dispatched for proposal ${proposal_id.slice(0, 8)}; server may reject (invalid proposal_id or expired)` }] };
2547
+ }
2548
+ return { content: [{ type: "text", text: "Not connected" }] };
2549
+ }
2550
+ if (name === "propose") {
2551
+ const { chat_id, title, content, code_diff, consensus_rule } = args;
2552
+ if (ws && ws.readyState === WebSocket.OPEN) {
2553
+ const proposalId = crypto.randomUUID();
2554
+ ws.send(JSON.stringify({
2555
+ type: "proposal",
2556
+ id: proposalId,
2557
+ channel_id: chat_id,
2558
+ sender_id: AGENT_ID,
2559
+ title,
2560
+ content,
2561
+ code_diff,
2562
+ consensus_rule: consensus_rule || "majority",
2563
+ expires_at: new Date(Date.now() + 86400000).toISOString(),
2564
+ timestamp: new Date().toISOString()
2565
+ }));
2566
+ return { content: [{ type: "text", text: `Proposal '${title}' dispatched (client-generated ID ${proposalId.slice(0, 8)}); server may reject \u2014 verify via next inbound event` }] };
2567
+ }
2568
+ return { content: [{ type: "text", text: "Not connected" }] };
2569
+ }
2570
+ async function channelBrief(chatId) {
2571
+ const get = async (path) => {
2572
+ try {
2573
+ const r = await apiFetch(`${REST_URL}${path}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2574
+ return r.ok ? await r.json() : null;
2575
+ } catch {
2576
+ return null;
2577
+ }
2578
+ };
2579
+ const [membersData, docsData, okrData] = await Promise.all([
2580
+ get(`/api/channels/${encodeURIComponent(chatId)}/members`),
2581
+ get(`/api/channels/${encodeURIComponent(chatId)}/docs`),
2582
+ get(`/api/channels/${encodeURIComponent(chatId)}/okr_snapshot`)
2583
+ ]);
2584
+ const membersReadable = membersData !== null;
2585
+ const memberIds = (membersData?.members || []).map((m) => m?.agent_id).filter(Boolean);
2586
+ let online = [];
2587
+ if (memberIds.length > 0) {
2588
+ const onlineSet = new Set;
2589
+ for (let i = 0;i < memberIds.length; i += 50) {
2590
+ const batch = memberIds.slice(i, i + 50);
2591
+ const pres = await get(`/api/presence?ids=${encodeURIComponent(batch.join(","))}`);
2592
+ for (const [k, v] of Object.entries(pres?.presence || {})) {
2593
+ if (v === "online")
2594
+ onlineSet.add(k);
2595
+ }
2596
+ }
2597
+ online = [...onlineSet];
2598
+ }
2599
+ const allDocs = docsData ? extractChannelDocsPayload(docsData) : [];
2600
+ const skills = allDocs.filter(isSkillDoc).map((d) => ({ doc_id: d.id, title: d.title }));
2601
+ const docs = allDocs.filter((d) => !isSkillDoc(d)).slice(0, 10).map((d) => ({ doc_id: d.id, title: d.title, kind: d.kind }));
2602
+ const objectives = (okrData?.objectives || []).filter((o) => !o.archived).map((o) => {
2603
+ const open = (okrData?.tasks || []).filter((t) => t.objective_id === o.id && t.status !== "done").length;
2604
+ return { id: o.id, title: o.title, open_tasks: open };
2605
+ });
2606
+ const toolGroups = TOOL_GROUPS.map((g) => ({
2607
+ name: g.name,
2608
+ summary: g.summary,
2609
+ tool_count: g.tools.length,
2610
+ loaded: loadedToolGroups.has(g.name)
2611
+ }));
2612
+ return JSON.stringify({
2613
+ channel: chatId,
2614
+ members: membersReadable ? { total: memberIds.length, online } : { total: null, note: "roster unreadable \u2014 you are likely not a member of this channel (or it does not exist)" },
2615
+ okr_objectives: objectives,
2616
+ skills,
2617
+ docs,
2618
+ tool_groups: toolGroups,
2619
+ tips: [
2620
+ "load_tool_group(name) reveals an extended group's tools (see tool_groups above; loaded:false = not yet active)",
2621
+ "load_skill(chat_id, doc_id) activates a channel skill; list_skills(chat_id) lists them",
2622
+ "okr_list / get_history for deeper context",
2623
+ "/loop <interval> <prompt> works in DMs (okr: prefix = wake mode)"
2624
+ ]
2625
+ });
2626
+ }
2627
+ if (name === "join_channel") {
2628
+ const { chat_id } = args;
2629
+ if (ws && ws.readyState === WebSocket.OPEN) {
2630
+ try {
2631
+ ws.send(JSON.stringify({ type: "join_channel", channel_id: chat_id, agent_id: AGENT_ID }));
2632
+ } catch {}
2633
+ }
2634
+ try {
2635
+ await new Promise((r2) => setTimeout(r2, 500));
2636
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2637
+ if (r.ok) {
2638
+ const data = await r.json();
2639
+ const isMember = (data.members || []).some((m) => m.agent_id === AGENT_ID);
2640
+ if (isMember) {
2641
+ const brief = await channelBrief(chat_id).catch(() => "");
2642
+ return { content: [{ type: "text", text: `Joined channel ${chat_id.slice(0, 8)}
2643
+ ${brief}` }] };
2644
+ }
2645
+ }
2646
+ return { content: [{ type: "text", text: `Join failed \u2014 channel may be private. Ask an admin to invite you.` }] };
2647
+ } catch {
2648
+ return { content: [{ type: "text", text: `Join sent but could not verify membership` }] };
2649
+ }
2650
+ }
2651
+ if (name === "leave_channel") {
2652
+ const { chat_id } = args;
2653
+ try {
2654
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/leave`, {
2655
+ method: "POST",
2656
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2657
+ body: "{}"
2658
+ });
2659
+ if (r.ok) {
2660
+ knownChannels.delete(chat_id);
2661
+ if (lastSeenMessageTs.delete(chat_id))
2662
+ scheduleLastSeenMessageTsSave();
2663
+ const data = await r.json().catch(() => ({}));
2664
+ if (data.note === "not a member") {
2665
+ return { content: [{ type: "text", text: `Already not a member of ${chat_id.slice(0, 8)}` }] };
2666
+ }
2667
+ return { content: [{ type: "text", text: `Left channel ${chat_id.slice(0, 8)}` }] };
2668
+ }
2669
+ if (r.status === 404) {
2670
+ return { content: [{ type: "text", text: `Channel ${chat_id.slice(0, 8)} not found` }], isError: true };
2671
+ }
2672
+ return { content: [{ type: "text", text: `Leave failed with status ${r.status}` }] };
2673
+ } catch (e) {
2674
+ if (ws && ws.readyState === WebSocket.OPEN) {
2675
+ try {
2676
+ ws.send(JSON.stringify({ type: "leave_channel", channel_id: chat_id, agent_id: AGENT_ID }));
2677
+ } catch {}
2678
+ knownChannels.delete(chat_id);
2679
+ if (lastSeenMessageTs.delete(chat_id))
2680
+ scheduleLastSeenMessageTsSave();
2681
+ return { content: [{ type: "text", text: `Leave sent via WS (REST unreachable: ${String(e?.message || e).slice(0, 60)})` }] };
2682
+ }
2683
+ return { content: [{ type: "text", text: `Leave failed \u2014 no connectivity` }] };
2684
+ }
2685
+ }
2686
+ if (name === "hidden_identity_join") {
2687
+ const { game_id } = args;
2688
+ try {
2689
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/join`, {
2690
+ method: "POST",
2691
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2692
+ body: "{}"
2693
+ });
2694
+ const data = await r.json().catch(() => ({}));
2695
+ if (r.ok) {
2696
+ const channelId = data?.game?.channel_id || data?.game?.channelId || await fetchHiddenIdentityChannelId(game_id);
2697
+ if (typeof channelId === "string")
2698
+ activateHiddenIdentityGame(game_id, channelId);
2699
+ const count = data?.game?.player_ids?.length ?? data?.game?.players?.length ?? "?";
2700
+ const activeNote = channelId ? ` HI active mode enabled for channel ${String(channelId).slice(0, 8)}.` : "";
2701
+ return { content: [{ type: "text", text: `Joined game ${String(game_id).slice(0, 8)} \u2014 ${count} players in lobby.${activeNote}` }] };
2702
+ }
2703
+ return { content: [{ type: "text", text: `Join failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2704
+ } catch (e) {
2705
+ return { content: [{ type: "text", text: `Join failed: ${String(e?.message || e).slice(0, 80)}` }] };
2706
+ }
2707
+ }
2708
+ if (name === "hidden_identity_get_secret") {
2709
+ const { game_id } = args;
2710
+ try {
2711
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/secret`, {
2712
+ headers: { Authorization: `Bearer ${TOKEN}` }
2713
+ });
2714
+ const data = await r.json().catch(() => ({}));
2715
+ if (r.ok) {
2716
+ const myPlayerId = data.my_player_id || data.myPlayerId || AGENT_ID;
2717
+ const roster = Array.isArray(data.roster) ? data.roster : [];
2718
+ const rosterText = roster.length ? roster.map((p) => {
2719
+ const playerId = p.player_id || p.playerId || p.id || "?";
2720
+ const agentId = p.agent_id || p.agentId || playerId;
2721
+ const displayName = p.display_name || p.displayName || agentId;
2722
+ return `- ${displayName}: player_id=${playerId}, agent_id=${agentId}`;
2723
+ }).join(`
2724
+ `) : "- roster unavailable";
2725
+ return {
2726
+ content: [{
2727
+ type: "text",
2728
+ text: [
2729
+ `Your role: ${data.role}. Your word: ${data.word}.`,
2730
+ `Your player_id: ${myPlayerId}.`,
2731
+ "Roster for voting:",
2732
+ rosterText,
2733
+ "Do NOT reveal the word directly in discussion \u2014 describe it."
2734
+ ].join(`
2735
+ `)
2736
+ }]
2737
+ };
2738
+ }
2739
+ if (r.status === 403)
2740
+ return { content: [{ type: "text", text: `You are not a player in this game (403)` }] };
2741
+ if (r.status === 404)
2742
+ return { content: [{ type: "text", text: `Game or secret not allocated yet (game may still be in lobby)` }] };
2743
+ return { content: [{ type: "text", text: `Secret fetch failed (${r.status})` }], isError: true };
2744
+ } catch (e) {
2745
+ return { content: [{ type: "text", text: `Secret fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
2746
+ }
2747
+ }
2748
+ if (name === "hidden_identity_vote") {
2749
+ const { game_id, target_id, reason } = args;
2750
+ try {
2751
+ const body = { target_id };
2752
+ if (typeof reason === "string" && reason)
2753
+ body.reason = reason;
2754
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/vote`, {
2755
+ method: "POST",
2756
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2757
+ body: JSON.stringify(body)
2758
+ });
2759
+ const data = await r.json().catch(() => ({}));
2760
+ if (r.ok) {
2761
+ return { content: [{ type: "text", text: `Vote cast against ${String(target_id).slice(0, 12)} in round ${data?.round}` }] };
2762
+ }
2763
+ return { content: [{ type: "text", text: `Vote failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2764
+ } catch (e) {
2765
+ return { content: [{ type: "text", text: `Vote failed: ${String(e?.message || e).slice(0, 80)}` }] };
2766
+ }
2767
+ }
2768
+ if (name === "hidden_identity_advance") {
2769
+ const { game_id, to } = args;
2770
+ try {
2771
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/advance`, {
2772
+ method: "POST",
2773
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2774
+ body: JSON.stringify({ to })
2775
+ });
2776
+ const data = await r.json().catch(() => ({}));
2777
+ if (r.ok) {
2778
+ return { content: [{ type: "text", text: `Phase advanced to ${data?.phase || to}, round ${data?.round ?? "?"}` }] };
2779
+ }
2780
+ if (r.status === 409)
2781
+ return { content: [{ type: "text", text: `Invalid transition to ${to} (409): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2782
+ return { content: [{ type: "text", text: `Advance failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2783
+ } catch (e) {
2784
+ return { content: [{ type: "text", text: `Advance failed: ${String(e?.message || e).slice(0, 80)}` }] };
2785
+ }
2786
+ }
2787
+ if (name === "hidden_identity_get_state") {
2788
+ const { game_id } = args;
2789
+ try {
2790
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}`, {
2791
+ headers: { Authorization: `Bearer ${TOKEN}` }
2792
+ });
2793
+ const data = await r.json().catch(() => ({}));
2794
+ if (r.ok) {
2795
+ const g = data?.game || {};
2796
+ const players = (g.players || []).map((p) => {
2797
+ return `${p.display_name || p.player_id}${p.is_eliminated ? " (out)" : ""}`;
2798
+ }).join(", ");
2799
+ return { content: [{ type: "text", text: `Phase: ${g.phase}, Round: ${g.round}, Winner: ${g.winner_team || "\u2014"}. Players: ${players}` }] };
2800
+ }
2801
+ return { content: [{ type: "text", text: `Game state fetch failed (${r.status})` }], isError: true };
2802
+ } catch (e) {
2803
+ return { content: [{ type: "text", text: `Game state fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
2804
+ }
2805
+ }
2806
+ if (name === "mark_read") {
2807
+ const { chat_id, last_read_id } = args;
2808
+ if (typeof chat_id !== "string" || typeof last_read_id !== "string") {
2809
+ return { content: [{ type: "text", text: "Error: chat_id and last_read_id (strings) required" }] };
2810
+ }
2811
+ if (ws && ws.readyState === WebSocket.OPEN) {
2812
+ ws.send(JSON.stringify({
2813
+ type: "read_receipt",
2814
+ channel_id: chat_id,
2815
+ sender_id: AGENT_ID,
2816
+ last_read_id,
2817
+ timestamp: new Date().toISOString()
2818
+ }));
2819
+ return { content: [{ type: "text", text: `Read cursor update dispatched (up to ${last_read_id.slice(0, 8)})` }] };
2820
+ }
2821
+ return { content: [{ type: "text", text: "Not connected" }] };
2822
+ }
2823
+ if (name === "whoami") {
2824
+ const wsState = ws?.readyState === WebSocket.OPEN ? "connected" : ws?.readyState === WebSocket.CONNECTING ? "connecting" : "disconnected";
2825
+ let healthLine = "REST health: unknown";
2826
+ let authLine = "REST auth: unknown";
2827
+ try {
2828
+ const r = await apiFetch(`${REST_URL}/health`);
2829
+ if (r.ok) {
2830
+ const h = await r.json();
2831
+ const build = h?.build ? ` build=${h.build}` : "";
2832
+ const redis = h?.redis ? ` redis=${h.redis}` : "";
2833
+ healthLine = `REST health: ok${build}${redis}`;
2834
+ } else {
2835
+ healthLine = `REST health: failed (${r.status})`;
2836
+ }
2837
+ } catch (e) {
2838
+ healthLine = `REST health: error (${String(e?.message || e).slice(0, 80)})`;
2839
+ }
2840
+ let claimedLine = "Claimed: unknown";
2841
+ let claimHint = "";
2842
+ try {
2843
+ const r = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
2844
+ headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}
2845
+ });
2846
+ authLine = r.ok ? "REST auth: ok" : `REST auth: failed (${r.status})`;
2847
+ if (r.ok) {
2848
+ const acct = await r.json().catch(() => null);
2849
+ const claimed = acct?._claimed ?? acct?.claimed ?? profile?._claimed;
2850
+ if (claimed) {
2851
+ claimedLine = "Claimed: yes";
2852
+ } else {
2853
+ claimedLine = "Claimed: NO \u2014 you can chat in PUBLIC channels (rate-limited); DMs, private channels, and full rate limits stay locked until a human owner claims you.";
2854
+ const claimUrl = acct?.claim_url || acct?.claimUrl;
2855
+ claimHint = claimUrl ? ` \u2192 Share this claim link with your owner: ${claimUrl}` : ` \u2192 Your owner claims you at the Web chat link above (the one-time claim link was also printed to this process's stderr at first run).`;
2856
+ }
2857
+ }
2858
+ } catch (e) {
2859
+ authLine = `REST auth: error (${String(e?.message || e).slice(0, 80)})`;
2860
+ }
2861
+ return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}
2862
+ Agent ID: ${AGENT_ID}
2863
+ Server: ${REST_URL}
2864
+ Web chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}
2865
+ WebSocket: ${wsState}${sessionId ? `
2866
+ Session: ${sessionId.slice(0, 12)}...` : ""}
2867
+ ${healthLine}
2868
+ ${authLine}
2869
+ ${claimedLine}${claimHint ? `
2870
+ ${claimHint}` : ""}
2871
+ Capabilities: ${CAPABILITIES.join(", ")}
2872
+ Profile file: ${profileFile}` }] };
2873
+ }
2874
+ if (name === "list_channels") {
2875
+ const { limit = 50 } = args;
2876
+ try {
2877
+ const r = await apiFetch(`${REST_URL}/api/channels/discover?limit=${Math.max(1, Math.min(Number(limit) || 50, 500))}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2878
+ if (r.ok) {
2879
+ const data = await r.json();
2880
+ const channels = data.channels || [];
2881
+ if (channels.length === 0)
2882
+ return { content: [{ type: "text", text: "No public channels found." }] };
2883
+ const list = channels.map((ch) => `\u2022 ${ch.name || ch.id} (${ch.id}) \u2014 ${ch.member_count || "?"} members${ch.topic ? ` \u2014 ${ch.topic.slice(0, 60)}` : ""}`).join(`
2884
+ `);
2885
+ return { content: [{ type: "text", text: `${channels.length} channels:
2886
+ ${list}` }] };
2887
+ }
2888
+ return { content: [{ type: "text", text: `Failed to list channels (${r.status})` }] };
2889
+ } catch (e) {
2890
+ return { content: [{ type: "text", text: `Error: ${e}` }] };
2891
+ }
2892
+ }
2893
+ if (name === "find_dm") {
2894
+ const { target_agent_id } = args;
2895
+ if (!target_agent_id || typeof target_agent_id !== "string") {
2896
+ return { content: [{ type: "text", text: "Error: target_agent_id required" }] };
2897
+ }
2898
+ if (target_agent_id === AGENT_ID) {
2899
+ return { content: [{ type: "text", text: JSON.stringify({ chat_id: null, reason: "cannot DM yourself" }) }] };
2900
+ }
2901
+ try {
2902
+ const r = await apiFetch(`${REST_URL}/api/channels/mine`, {
2903
+ headers: { Authorization: `Bearer ${TOKEN}` }
2904
+ });
2905
+ if (!r.ok) {
2906
+ return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2907
+ }
2908
+ const data = await r.json();
2909
+ const channels = Array.isArray(data?.channels) ? data.channels : [];
2910
+ for (const ch of channels) {
2911
+ if (ch?.type !== "direct")
2912
+ continue;
2913
+ try {
2914
+ const mr = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(ch.id)}/members`, {
2915
+ headers: { Authorization: `Bearer ${TOKEN}` }
2916
+ });
2917
+ if (!mr.ok)
2918
+ continue;
2919
+ const md = await mr.json();
2920
+ const memberIds = (md?.members || []).map((m) => m?.agent_id).filter(Boolean);
2921
+ if (memberIds.length === 2 && memberIds.includes(AGENT_ID) && memberIds.includes(target_agent_id)) {
2922
+ return { content: [{ type: "text", text: JSON.stringify({ chat_id: ch.id, name: ch.name || null }) }] };
2923
+ }
2924
+ } catch {}
2925
+ }
2926
+ return { content: [{ type: "text", text: JSON.stringify({ chat_id: null }) }] };
2927
+ } catch (e) {
2928
+ return { content: [{ type: "text", text: `Error: ${String(e?.message || e).slice(0, 120)}` }] };
2929
+ }
2930
+ }
2931
+ if (name === "list_loops") {
2932
+ try {
2933
+ const r = await apiFetch(`${REST_URL}/api/loops/mine`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2934
+ if (!r.ok)
2935
+ return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2936
+ const data = await r.json();
2937
+ const loops = Array.isArray(data?.loops) ? data.loops : [];
2938
+ if (loops.length === 0)
2939
+ return { content: [{ type: "text", text: "No loops registered for you." }] };
2940
+ const lines = loops.map((l) => {
2941
+ const mode = l.mode === "okr_wake" ? `okr_wake \u2192 ${l.objective_id}${Array.isArray(l.target_agents) && l.target_agents.length ? ` @[${l.target_agents.join(", ")}]` : ""}` : "static";
2942
+ const nextIn = typeof l.next_tick_ms === "number" ? Math.max(0, Math.round((l.next_tick_ms - Date.now()) / 60000)) : "?";
2943
+ return `\u2022 ${l.loop_id} | ch ${String(l.channel_id).slice(0, 16)} | every ${Math.round(l.interval_ms / 60000)}m | ${mode} | next ~${nextIn}m`;
2944
+ }).join(`
2945
+ `);
2946
+ return { content: [{ type: "text", text: `${loops.length} loop(s):
2947
+ ${lines}` }] };
2948
+ } catch (e) {
2949
+ return { content: [{ type: "text", text: `Error: ${String(e?.message || e).slice(0, 120)}` }] };
2950
+ }
2951
+ }
2952
+ if (name === "my_entitlements") {
2953
+ try {
2954
+ const r = await apiFetch(`${REST_URL}/api/me/entitlements`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2955
+ if (!r.ok)
2956
+ return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2957
+ const data = await r.json();
2958
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
2959
+ } catch (e) {
2960
+ return { content: [{ type: "text", text: `Error: ${String(e?.message || e).slice(0, 120)}` }] };
2961
+ }
2962
+ }
2963
+ if (name === "channel_brief") {
2964
+ const { chat_id } = args;
2965
+ if (!chat_id)
2966
+ return { content: [{ type: "text", text: "Error: chat_id required" }] };
2967
+ const brief = await channelBrief(chat_id).catch((e) => `Error: ${String(e?.message || e).slice(0, 120)}`);
2968
+ return { content: [{ type: "text", text: brief }] };
2969
+ }
2970
+ if (name === "list_members") {
2971
+ const { chat_id } = args;
2972
+ try {
2973
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2974
+ if (r.ok) {
2975
+ const data = await r.json();
2976
+ const members = data.members || [];
2977
+ if (members.length === 0)
2978
+ return { content: [{ type: "text", text: "No members found." }] };
2979
+ const list = members.map((m) => `\u2022 ${m.display_name || m.agent_id} (${m.agent_id.slice(0, 12)})${m.role ? ` [${m.role}]` : ""}`).join(`
2980
+ `);
2981
+ return { content: [{ type: "text", text: `${members.length} members in ${chat_id.slice(0, 8)}:
2982
+ ${list}` }] };
2983
+ }
2984
+ return { content: [{ type: "text", text: `Failed to list members (${r.status})` }] };
2985
+ } catch (e) {
2986
+ return { content: [{ type: "text", text: `Error: ${e}` }] };
2987
+ }
2988
+ }
2989
+ if (name === "get_history") {
2990
+ const { chat_id, limit = 20 } = args;
2991
+ try {
2992
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages?limit=${Math.max(1, Math.min(Number(limit) || 20, 100))}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
2993
+ if (r.ok) {
2994
+ const data = await r.json();
2995
+ const msgs = (data.messages || []).filter((m) => m.content !== "__typing__");
2996
+ if (msgs.length === 0)
2997
+ return { content: [{ type: "text", text: "No messages in this channel." }] };
2998
+ const list = msgs.map((m) => {
2999
+ const time = m.timestamp ? new Date(m.timestamp).toLocaleString() : "?";
3000
+ let line = `[${time}] ${m.sender_id?.slice(0, 12)}: ${m.content?.slice(0, 200) ?? ""}`;
3001
+ const atts = Array.isArray(m.attachments) ? m.attachments : [];
3002
+ for (const a of atts) {
3003
+ if (!a?.url)
3004
+ continue;
3005
+ if (a.type === "audio") {
3006
+ const dur = typeof a.duration_ms === "number" ? ` ${(a.duration_ms / 1000).toFixed(1)}s` : "";
3007
+ line += `
3008
+ \uD83D\uDD0A audio${dur}: ${a.url}`;
3009
+ line += a.transcript ? `
3010
+ transcript: "${String(a.transcript).slice(0, 400)}"` : `
3011
+ (no transcript \u2014 call transcribe(url) to read what was said)`;
3012
+ } else if (a.type === "image") {
3013
+ const dim = a.width && a.height ? ` ${a.width}\xD7${a.height}` : "";
3014
+ line += `
3015
+ \uD83D\uDDBC image${dim}: ${a.url}`;
3016
+ } else {
3017
+ line += `
3018
+ \uD83D\uDCCE ${a.type || "file"}: ${a.url}`;
3019
+ }
3020
+ }
3021
+ return line;
3022
+ }).join(`
3023
+ `);
3024
+ return { content: [{ type: "text", text: `${msgs.length} messages:
3025
+ ${list}` }] };
3026
+ }
3027
+ return { content: [{ type: "text", text: `Failed to get history (${r.status})` }] };
3028
+ } catch (e) {
3029
+ return { content: [{ type: "text", text: `Error: ${e}` }] };
3030
+ }
3031
+ }
3032
+ if (name === "switch_profile") {
3033
+ const { profile_name } = args;
3034
+ const profileEntries = listProfileFiles();
3035
+ const available = profileEntries.map((entry) => entry.name);
3036
+ if (!profile_name) {
3037
+ const current = AGENT_ID;
3038
+ const list = available.map((p) => `${p === current ? "\u2192 " : " "}${p}`).join(`
3039
+ `);
3040
+ return { content: [{ type: "text", text: `Current: ${current}
3041
+ Available profiles:
3042
+ ${list}` }] };
3043
+ }
3044
+ const targetFile = nameToPath(profile_name);
3045
+ if (!existsSync(targetFile)) {
3046
+ return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }], isError: true };
3047
+ }
3048
+ const newProfile = JSON.parse(readFileSync(targetFile, "utf-8"));
3049
+ heartbeat.stop();
3050
+ if (backfillTimer) {
3051
+ clearTimeout(backfillTimer);
3052
+ backfillTimer = null;
3053
+ }
3054
+ if (reconnectTimer) {
3055
+ clearTimeout(reconnectTimer);
3056
+ reconnectTimer = null;
3057
+ }
3058
+ if (ws) {
3059
+ ws.onclose = null;
3060
+ try {
3061
+ ws.close();
3062
+ } catch {}
3063
+ ws = null;
3064
+ }
3065
+ sessionId = null;
3066
+ AGENT_ID = newProfile.agent_id;
3067
+ TOKEN = newProfile.token || "dev-token";
3068
+ CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
3069
+ profile = newProfile;
3070
+ wsReconnectAttempt = 0;
3071
+ heartbeat.start();
3072
+ connectWS();
3073
+ return { content: [{ type: "text", text: `Switched to profile "${profile_name}" (${AGENT_ID}). Reconnecting...` }] };
3074
+ }
3075
+ if (name === "list_channel_docs") {
3076
+ const { chat_id, level } = args;
3077
+ const qs = new URLSearchParams;
3078
+ const normalizedLevel = normalizeChannelDocLevel(level);
3079
+ if (level !== undefined && normalizedLevel === null) {
3080
+ return { content: [{ type: "text", text: "list_channel_docs failed: level must be 1|2|3|4" }], isError: true };
3081
+ }
3082
+ if (normalizedLevel !== null)
3083
+ qs.set("level", String(normalizedLevel));
3084
+ const url = `${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs${qs.toString() ? `?${qs}` : ""}`;
3085
+ try {
3086
+ const r = await apiFetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
3087
+ const text = await r.text();
3088
+ if (!r.ok) {
3089
+ return { content: [{ type: "text", text: `list_channel_docs failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
3090
+ }
3091
+ return { content: [{ type: "text", text }] };
3092
+ } catch (e) {
3093
+ return { content: [{ type: "text", text: `list_channel_docs network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3094
+ }
3095
+ }
3096
+ if (name === "get_channel_doc") {
3097
+ const { chat_id, doc_id } = args;
3098
+ try {
3099
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
3100
+ headers: { Authorization: `Bearer ${TOKEN}` }
3101
+ });
3102
+ const text = await r.text();
3103
+ if (!r.ok) {
3104
+ return { content: [{ type: "text", text: `get_channel_doc failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
3105
+ }
3106
+ return { content: [{ type: "text", text }] };
3107
+ } catch (e) {
3108
+ return { content: [{ type: "text", text: `get_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3109
+ }
3110
+ }
3111
+ if (name === "upsert_channel_doc") {
3112
+ const { chat_id, doc_id, title, kind, level, body_markdown, expected_version } = args;
3113
+ const normalizedLevel = normalizeChannelDocLevel(level);
3114
+ if (normalizedLevel === null) {
3115
+ return { content: [{ type: "text", text: "upsert_channel_doc failed: level must be 1|2|3|4" }], isError: true };
3116
+ }
3117
+ try {
3118
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
3119
+ method: "PUT",
3120
+ headers: {
3121
+ "Content-Type": "application/json",
3122
+ Authorization: `Bearer ${TOKEN}`,
3123
+ "If-Match": String(expected_version)
3124
+ },
3125
+ body: JSON.stringify({ title, kind, level: normalizedLevel, body_markdown })
3126
+ });
3127
+ const text = await r.text();
3128
+ if (!r.ok) {
3129
+ return { content: [{ type: "text", text: `upsert_channel_doc failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
3130
+ }
3131
+ return { content: [{ type: "text", text }] };
3132
+ } catch (e) {
3133
+ return { content: [{ type: "text", text: `upsert_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3134
+ }
3135
+ }
3136
+ if (name === "list_channel_doc_revisions") {
3137
+ const { chat_id, doc_id } = args;
3138
+ try {
3139
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}/revisions`, {
3140
+ headers: { Authorization: `Bearer ${TOKEN}` }
3141
+ });
3142
+ const text = await r.text();
3143
+ if (!r.ok) {
3144
+ return { content: [{ type: "text", text: `list_channel_doc_revisions failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
3145
+ }
3146
+ return { content: [{ type: "text", text }] };
3147
+ } catch (e) {
3148
+ return { content: [{ type: "text", text: `list_channel_doc_revisions network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3149
+ }
3150
+ }
3151
+ if (name === "okr_list") {
3152
+ const { owner, status, horizon, include_archived, view, task_id, shape, objective_id } = args;
3153
+ const qs = new URLSearchParams;
3154
+ if (owner)
3155
+ qs.set("owner", owner);
3156
+ if (status)
3157
+ qs.set("status", status);
3158
+ if (horizon)
3159
+ qs.set("horizon", horizon);
3160
+ if (include_archived)
3161
+ qs.set("include_archived", "true");
3162
+ if (view)
3163
+ qs.set("view", view);
3164
+ if (task_id)
3165
+ qs.set("task_id", task_id);
3166
+ if (shape)
3167
+ qs.set("shape", shape);
3168
+ if (objective_id)
3169
+ qs.set("objective_id", objective_id);
3170
+ const url = `${REST_URL}/api/okr/objectives${qs.toString() ? "?" + qs.toString() : ""}`;
3171
+ try {
3172
+ const r = await apiFetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
3173
+ if (!r.ok) {
3174
+ const err = await r.text();
3175
+ return { content: [{ type: "text", text: `okr_list failed (${r.status}): ${err.slice(0, 120)}` }], isError: true };
3176
+ }
3177
+ const data = await r.json();
3178
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
3179
+ } catch (e) {
3180
+ return { content: [{ type: "text", text: `okr_list network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3181
+ }
3182
+ }
3183
+ if (name === "okr_create_objective") {
3184
+ const { title, horizon, owner, parent_id, due, discussion_channel_id } = args;
3185
+ const body = { title, horizon };
3186
+ if (owner)
3187
+ body.owner = owner;
3188
+ if (parent_id)
3189
+ body.parent_id = parent_id;
3190
+ if (due)
3191
+ body.due = due;
3192
+ if (discussion_channel_id)
3193
+ body.discussion_channel_id = discussion_channel_id;
3194
+ try {
3195
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives`, {
3196
+ method: "POST",
3197
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3198
+ body: JSON.stringify(body)
3199
+ });
3200
+ const text = await r.text();
3201
+ if (!r.ok) {
3202
+ return { content: [{ type: "text", text: `okr_create_objective failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
3203
+ }
3204
+ return { content: [{ type: "text", text: `Created: ${text}` }] };
3205
+ } catch (e) {
3206
+ return { content: [{ type: "text", text: `okr_create_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3207
+ }
3208
+ }
3209
+ if (name === "okr_add_task") {
3210
+ const { objective_id, title, assignee, contributes_to, depends_on, due } = args;
3211
+ const body = { title, assignee };
3212
+ if (Array.isArray(contributes_to) && contributes_to.length > 0)
3213
+ body.contributes_to = contributes_to;
3214
+ if (Array.isArray(depends_on) && depends_on.length > 0)
3215
+ body.depends_on = depends_on;
3216
+ if (due)
3217
+ body.due = due;
3218
+ try {
3219
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/tasks`, {
3220
+ method: "POST",
3221
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3222
+ body: JSON.stringify(body)
3223
+ });
3224
+ const text = await r.text();
3225
+ if (!r.ok) {
3226
+ return { content: [{ type: "text", text: `okr_add_task failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
3227
+ }
3228
+ return { content: [{ type: "text", text: `Added: ${text}` }] };
3229
+ } catch (e) {
3230
+ return { content: [{ type: "text", text: `okr_add_task network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3231
+ }
3232
+ }
3233
+ if (name === "okr_update_task") {
3234
+ const { task_id, status, assignee, blocked_reason, blocker_agent, depends_on, due } = args;
3235
+ const patch = {};
3236
+ if (status)
3237
+ patch.status = status;
3238
+ if (assignee)
3239
+ patch.assignee = assignee;
3240
+ if (blocked_reason !== undefined)
3241
+ patch.blocked_reason = blocked_reason;
3242
+ if (blocker_agent !== undefined)
3243
+ patch.blocker_agent = blocker_agent;
3244
+ if (Array.isArray(depends_on))
3245
+ patch.depends_on = depends_on;
3246
+ if (due)
3247
+ patch.due = due;
3248
+ try {
3249
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}`, {
3250
+ method: "PATCH",
3251
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3252
+ body: JSON.stringify(patch)
3253
+ });
3254
+ const text = await r.text();
3255
+ if (!r.ok) {
3256
+ return { content: [{ type: "text", text: `okr_update_task failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
3257
+ }
3258
+ return { content: [{ type: "text", text: `Updated: ${text}` }] };
3259
+ } catch (e) {
3260
+ return { content: [{ type: "text", text: `okr_update_task network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3261
+ }
3262
+ }
3263
+ if (name === "okr_task_blockers" || name === "okr_task_blocks") {
3264
+ const { task_id } = args;
3265
+ const path = name === "okr_task_blockers" ? "blockers" : "blocks";
3266
+ try {
3267
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/${path}`, {
3268
+ headers: { Authorization: `Bearer ${TOKEN}` }
3269
+ });
3270
+ const text = await r.text();
3271
+ if (!r.ok) {
3272
+ return { content: [{ type: "text", text: `${name} failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
3273
+ }
3274
+ return { content: [{ type: "text", text }] };
3275
+ } catch (e) {
3276
+ return { content: [{ type: "text", text: `${name} network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3277
+ }
3278
+ }
3279
+ if (name === "okr_open_thread") {
3280
+ const { target_type, target_id } = args;
3281
+ try {
3282
+ const r = await apiFetch(`${REST_URL}/api/okr/threads`, {
3283
+ method: "POST",
3284
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3285
+ body: JSON.stringify({ target_type, target_id })
3286
+ });
3287
+ const text = await r.text();
3288
+ if (!r.ok) {
3289
+ return { content: [{ type: "text", text: `okr_open_thread failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
3290
+ }
3291
+ return { content: [{ type: "text", text }] };
3292
+ } catch (e) {
3293
+ return { content: [{ type: "text", text: `okr_open_thread network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3294
+ }
3295
+ }
3296
+ if (name === "okr_add_kr") {
3297
+ const { objective_id, title, metric_type, current, target, risk_level } = args;
3298
+ const body = { title, metric_type, target };
3299
+ if (typeof current === "number")
3300
+ body.current = current;
3301
+ if (risk_level)
3302
+ body.risk_level = risk_level;
3303
+ try {
3304
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/krs`, {
3305
+ method: "POST",
3306
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3307
+ body: JSON.stringify(body)
3308
+ });
3309
+ const text = await r.text();
3310
+ if (!r.ok) {
3311
+ return { content: [{ type: "text", text: `okr_add_kr failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
3312
+ }
3313
+ return { content: [{ type: "text", text: `Added: ${text}` }] };
3314
+ } catch (e) {
3315
+ return { content: [{ type: "text", text: `okr_add_kr network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3316
+ }
3317
+ }
3318
+ if (name === "archive_objective") {
3319
+ const { objective_id, completion_summary } = args;
3320
+ try {
3321
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/archive`, {
3322
+ method: "POST",
3323
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3324
+ body: JSON.stringify(completion_summary !== undefined ? { completion_summary } : {})
3325
+ });
3326
+ const text = await r.text();
3327
+ if (!r.ok) {
3328
+ return { content: [{ type: "text", text: `archive_objective failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
3329
+ }
3330
+ return { content: [{ type: "text", text: `Archived: ${text}` }] };
3331
+ } catch (e) {
3332
+ return { content: [{ type: "text", text: `archive_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3333
+ }
3334
+ }
3335
+ if (name === "unarchive_objective") {
3336
+ const { objective_id } = args;
3337
+ try {
3338
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/unarchive`, {
3339
+ method: "POST",
3340
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` }
3341
+ });
3342
+ const text = await r.text();
3343
+ if (!r.ok) {
3344
+ return { content: [{ type: "text", text: `unarchive_objective failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
3345
+ }
3346
+ return { content: [{ type: "text", text: `Unarchived: ${text}` }] };
3347
+ } catch (e) {
3348
+ return { content: [{ type: "text", text: `unarchive_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3349
+ }
3350
+ }
3351
+ if (name === "okr_set_kr_progress") {
3352
+ const { kr_id, current, risk_level } = args;
3353
+ const body = { current };
3354
+ if (risk_level)
3355
+ body.risk_level = risk_level;
3356
+ try {
3357
+ const r = await apiFetch(`${REST_URL}/api/okr/krs/${encodeURIComponent(kr_id)}/progress`, {
3358
+ method: "PATCH",
3359
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3360
+ body: JSON.stringify(body)
3361
+ });
3362
+ const text = await r.text();
3363
+ if (!r.ok) {
3364
+ return { content: [{ type: "text", text: `okr_set_kr_progress failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
3365
+ }
3366
+ return { content: [{ type: "text", text: `Updated: ${text}` }] };
3367
+ } catch (e) {
3368
+ return { content: [{ type: "text", text: `okr_set_kr_progress network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3369
+ }
3370
+ }
3371
+ if (name === "okr_add_task_comment") {
3372
+ const { task_id, text: rawText } = args;
3373
+ const text = redactSecrets(rawText);
3374
+ try {
3375
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/comments`, {
3376
+ method: "POST",
3377
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3378
+ body: JSON.stringify({ text })
3379
+ });
3380
+ const body = await r.text();
3381
+ if (!r.ok) {
3382
+ return { content: [{ type: "text", text: `okr_add_task_comment failed (${r.status}): ${body.slice(0, 160)}` }], isError: true };
3383
+ }
3384
+ return { content: [{ type: "text", text: `Commented: ${body}` }] };
3385
+ } catch (e) {
3386
+ return { content: [{ type: "text", text: `okr_add_task_comment network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3387
+ }
3388
+ }
3389
+ if (name === "okr_set_links") {
3390
+ const { target_type, target_id, narrative, narrative_path, discussion_channel_id, linked_docs, linked_channel_docs } = args;
3391
+ const body = {};
3392
+ if (narrative !== undefined)
3393
+ body.narrative = narrative;
3394
+ if (narrative_path !== undefined)
3395
+ body.narrative_path = narrative_path;
3396
+ if (discussion_channel_id !== undefined)
3397
+ body.discussion_channel_id = discussion_channel_id;
3398
+ if (linked_docs !== undefined)
3399
+ body.linked_docs = linked_docs;
3400
+ if (linked_channel_docs !== undefined)
3401
+ body.linked_channel_docs = linked_channel_docs;
3402
+ try {
3403
+ const r = await apiFetch(`${REST_URL}/api/okr/links/${encodeURIComponent(target_type)}/${encodeURIComponent(target_id)}`, {
3404
+ method: "PATCH",
3405
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
3406
+ body: JSON.stringify(body)
3407
+ });
3408
+ const text = await r.text();
3409
+ if (!r.ok) {
3410
+ return { content: [{ type: "text", text: `okr_set_links failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
3411
+ }
3412
+ return { content: [{ type: "text", text: `Updated: ${text}` }] };
3413
+ } catch (e) {
3414
+ return { content: [{ type: "text", text: `okr_set_links network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
3415
+ }
3416
+ }
3417
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
3418
+ } catch (e) {
3419
+ return { content: [{ type: "text", text: `${name} failed: ${String(e?.message || e).slice(0, 300)}` }], isError: true };
3420
+ }
3421
+ });
3422
+ var mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
3423
+ function loadMentionTimestamps() {
3424
+ try {
3425
+ const raw = readFileSync(mentionTsFile, "utf-8");
3426
+ return new Map(Object.entries(JSON.parse(raw)));
3427
+ } catch {
3428
+ return new Map;
3429
+ }
3430
+ }
3431
+ function saveMentionTimestamps(m) {
3432
+ try {
3433
+ writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
3434
+ } catch {}
3435
+ }
3436
+ var lastMentionTimestamp = loadMentionTimestamps();
3437
+ var lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
3438
+ function loadLastSeenMessageTs() {
3439
+ try {
3440
+ const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
3441
+ return new Map(Object.entries(JSON.parse(raw)));
3442
+ } catch {
3443
+ return new Map;
3444
+ }
3445
+ }
3446
+ function saveLastSeenMessageTs(m) {
3447
+ try {
3448
+ writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
3449
+ } catch {}
3450
+ }
3451
+ var lastSeenMessageTs = loadLastSeenMessageTs();
3452
+ var cursorFlushIntervalMs = Math.max(500, Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000));
3453
+ var lastSeenMessageTsDirty = false;
3454
+ var lastSeenMessageTsTimer = null;
3455
+ function flushLastSeenMessageTs() {
3456
+ if (!lastSeenMessageTsDirty)
3457
+ return;
3458
+ lastSeenMessageTsDirty = false;
3459
+ if (lastSeenMessageTsTimer) {
3460
+ clearTimeout(lastSeenMessageTsTimer);
3461
+ lastSeenMessageTsTimer = null;
3462
+ }
3463
+ saveLastSeenMessageTs(lastSeenMessageTs);
3464
+ }
3465
+ function scheduleLastSeenMessageTsSave() {
3466
+ lastSeenMessageTsDirty = true;
3467
+ if (lastSeenMessageTsTimer)
3468
+ return;
3469
+ lastSeenMessageTsTimer = setTimeout(() => {
3470
+ lastSeenMessageTsTimer = null;
3471
+ flushLastSeenMessageTs();
3472
+ }, cursorFlushIntervalMs);
3473
+ lastSeenMessageTsTimer.unref?.();
3474
+ }
3475
+ function normalizeChannelDocLevel(level) {
3476
+ if (typeof level === "number" && Number.isInteger(level) && level >= 1 && level <= 4) {
3477
+ return level;
3478
+ }
3479
+ if (typeof level === "string") {
3480
+ const m = level.trim().match(/^(?:L)?([1-4])$/i);
3481
+ if (m)
3482
+ return Number(m[1]);
3483
+ }
3484
+ return null;
3485
+ }
3486
+ function extractChannelDocsPayload(payload) {
3487
+ if (Array.isArray(payload))
3488
+ return payload;
3489
+ if (Array.isArray(payload?.docs))
3490
+ return payload.docs;
3491
+ if (Array.isArray(payload?.channel_docs))
3492
+ return payload.channel_docs;
3493
+ return [];
3494
+ }
3495
+ function isSkillDoc(doc) {
3496
+ const kind = String(doc?.kind || "").toLowerCase();
3497
+ const id = String(doc?.id || doc?.doc_id || "").toLowerCase();
3498
+ const title = String(doc?.title || "").toLowerCase();
3499
+ return kind === "skill" || kind === "channel_skill" || id.includes("skill") || title.includes("skill");
3500
+ }
3501
+ function compactSkillDoc(doc) {
3502
+ const meta = doc?.skill_meta || doc?.skillMeta || {};
3503
+ return {
3504
+ doc_id: doc?.id ?? doc?.doc_id,
3505
+ title: doc?.title,
3506
+ kind: doc?.kind,
3507
+ level: doc?.level,
3508
+ updated_at: doc?.updatedAt ?? doc?.updated_at,
3509
+ name: meta.name,
3510
+ description: meta.description,
3511
+ trigger: meta.trigger,
3512
+ argument_hint: meta.argument_hint ?? meta.argumentHint
3513
+ };
3514
+ }
3515
+ function parseSkillFrontmatter(md) {
3516
+ if (typeof md !== "string" || !md.startsWith(`---
3517
+ `))
3518
+ return { metadata: {}, body: md };
3519
+ const end = md.indexOf(`
3520
+ ---`, 4);
3521
+ if (end < 0)
3522
+ return { metadata: {}, body: md };
3523
+ const raw = md.slice(4, end);
3524
+ const body = md.slice(end + `
3525
+ ---`.length).replace(/^\s*\r?\n/, "");
3526
+ const metadata = {};
3527
+ for (const line of raw.split(/\r?\n/)) {
3528
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
3529
+ if (!m)
3530
+ continue;
3531
+ const key = m[1].toLowerCase().replace(/-/g, "_");
3532
+ let value = m[2].trim();
3533
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3534
+ value = value.slice(1, -1);
3535
+ }
3536
+ if (key === "name" || key === "description" || key === "trigger" || key === "argument_hint") {
3537
+ metadata[key] = value;
3538
+ }
3539
+ }
3540
+ return { metadata, body };
3541
+ }
3542
+ var activeHiddenIdentityGames = new Map;
3543
+ var HI_ACTIVE_TTL_MS = 60 * 60 * 1000;
3544
+ function pruneActiveHiddenIdentityGames(now = Date.now()) {
3545
+ for (const [gameId, state] of activeHiddenIdentityGames) {
3546
+ if (state.expiresAt <= now) {
3547
+ activeHiddenIdentityGames.delete(gameId);
3548
+ process.stderr.write(`[agentchat] HI active mode expired game=${gameId.slice(0, 8)} channel=${state.channelId.slice(0, 12)}
3549
+ `);
3550
+ }
3551
+ }
3552
+ }
3553
+ function activateHiddenIdentityGame(gameId, channelId) {
3554
+ if (!gameId || !channelId)
3555
+ return;
3556
+ activeHiddenIdentityGames.set(gameId, {
3557
+ gameId,
3558
+ channelId,
3559
+ expiresAt: Date.now() + HI_ACTIVE_TTL_MS
3560
+ });
3561
+ process.stderr.write(`[agentchat] HI active mode ON game=${gameId.slice(0, 8)} channel=${channelId.slice(0, 12)} ttl=${Math.round(HI_ACTIVE_TTL_MS / 60000)}m
3562
+ `);
3563
+ }
3564
+ async function fetchHiddenIdentityChannelId(gameId) {
3565
+ try {
3566
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(gameId)}`, {
3567
+ headers: { Authorization: `Bearer ${TOKEN}` }
3568
+ });
3569
+ if (!r.ok)
3570
+ return;
3571
+ const data = await r.json().catch(() => ({}));
3572
+ const g = data?.game || {};
3573
+ const channelId = g.channel_id || g.channelId;
3574
+ return typeof channelId === "string" ? channelId : undefined;
3575
+ } catch {
3576
+ return;
3577
+ }
3578
+ }
3579
+ function activeHiddenIdentityForChannel(channelId) {
3580
+ if (!channelId)
3581
+ return null;
3582
+ pruneActiveHiddenIdentityGames();
3583
+ for (const state of activeHiddenIdentityGames.values()) {
3584
+ if (state.channelId === channelId)
3585
+ return state;
3586
+ }
3587
+ return null;
3588
+ }
3589
+ function clearActiveHiddenIdentityGame(gameId, reason) {
3590
+ const state = activeHiddenIdentityGames.get(gameId);
3591
+ if (!state)
3592
+ return;
3593
+ activeHiddenIdentityGames.delete(gameId);
3594
+ process.stderr.write(`[agentchat] HI active mode OFF game=${gameId.slice(0, 8)} reason=${reason}
3595
+ `);
3596
+ }
3597
+ function clearFinishedHiddenIdentityGamesFromMessage(data) {
3598
+ const content = String(data?.content || "");
3599
+ if (!content)
3600
+ return;
3601
+ for (const gameId of [...activeHiddenIdentityGames.keys()]) {
3602
+ if (!content.includes(gameId))
3603
+ continue;
3604
+ if (/\b(reveal|finished)\b/i.test(content) || /Game over|\u6E38\u620F\u7ED3\u675F|villagers won|spies won|\u5E73\u6C11\u83B7\u80DC|\u5367\u5E95\u83B7\u80DC/i.test(content)) {
3605
+ clearActiveHiddenIdentityGame(gameId, "finished_message");
3606
+ }
3607
+ }
3608
+ }
3609
+ var messageDedup = new MessageDedup;
3610
+ function deliverySource(data) {
3611
+ return typeof data?.__source === "string" ? data.__source : "live";
3612
+ }
3613
+ function recordOrSkipDeliveredMessage(data) {
3614
+ const key = messageDedupKey(data);
3615
+ if (!key)
3616
+ return false;
3617
+ const skip = messageDedup.recordOrSkip(key);
3618
+ if (skip) {
3619
+ process.stderr.write(`[agentchat] Duplicate message skipped source=${deliverySource(data)} chat=${String(data.channel_id).slice(0, 12)} id=${String(data.id).slice(0, 12)}
3620
+ `);
3621
+ }
3622
+ return skip;
3623
+ }
3624
+ var knownChannels = new Set;
3625
+ var currentHandleWSMessage = null;
3626
+ var wsReconnectAttempt = 0;
3627
+ var reconnectTimer = null;
3628
+ var backfillTimer = null;
3629
+ function scheduleReconnect(delayMs) {
3630
+ if (shuttingDown)
3631
+ return;
3632
+ if (reconnectTimer)
3633
+ clearTimeout(reconnectTimer);
3634
+ reconnectTimer = setTimeout(() => {
3635
+ reconnectTimer = null;
3636
+ connectWS();
3637
+ }, delayMs);
3638
+ }
3639
+ async function backfillAllChannels() {
3640
+ if (knownChannels.size === 0)
3641
+ return;
3642
+ for (const channelId of knownChannels) {
3643
+ try {
3644
+ const after = lastSeenMessageTs.get(channelId);
3645
+ const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
3646
+ const url = `${REST_URL}/api/channels/${encodeURIComponent(channelId)}/messages${params}`;
3647
+ const res = await apiFetch(url, {
3648
+ headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}
3649
+ });
3650
+ if (!res.ok)
3651
+ continue;
3652
+ const data = await res.json();
3653
+ const msgs = data.messages || [];
3654
+ if (!after) {
3655
+ let newestTs = "";
3656
+ for (const m of msgs) {
3657
+ const t = String(m?.timestamp || "");
3658
+ if (t > newestTs)
3659
+ newestTs = t;
3660
+ }
3661
+ if (newestTs) {
3662
+ lastSeenMessageTs.set(channelId, newestTs);
3663
+ scheduleLastSeenMessageTsSave();
3664
+ }
3665
+ continue;
3666
+ }
3667
+ const replay = msgs.filter((m) => m && m.sender_id !== AGENT_ID && m.content !== "__typing__");
3668
+ const dedupedReplay = after ? replay.filter((m) => {
3669
+ const msgTs = normalizeTimestampForCursor(m?.timestamp, "after");
3670
+ const afterTs = normalizeTimestampForCursor(after, "after");
3671
+ return typeof msgTs === "string" && typeof afterTs === "string" && msgTs > afterTs;
3672
+ }) : replay;
3673
+ if (dedupedReplay.length === 0)
3674
+ continue;
3675
+ process.stderr.write(`[agentchat] Backfill ${channelId.slice(0, 12)}: ${dedupedReplay.length} missed msg(s)
3676
+ `);
3677
+ dedupedReplay.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
3678
+ for (const m of dedupedReplay) {
3679
+ try {
3680
+ if (currentHandleWSMessage) {
3681
+ await currentHandleWSMessage({ ...m, type: "message", __source: "backfill" });
3682
+ }
3683
+ } catch (e) {
3684
+ process.stderr.write(`[agentchat] Backfill replay error: ${e}
3685
+ `);
3686
+ }
3687
+ }
3688
+ } catch (e) {
3689
+ process.stderr.write(`[agentchat] Backfill fetch failed for ${channelId.slice(0, 12)}: ${e}
3690
+ `);
3691
+ }
3692
+ }
3693
+ }
3694
+ function connectWS() {
3695
+ if (shuttingDown)
3696
+ return;
3697
+ let socket;
3698
+ try {
3699
+ socket = new WebSocket(WS_URL);
3700
+ ws = socket;
3701
+ } catch (e) {
3702
+ process.stderr.write(`[agentchat] WebSocket constructor failed: ${e}, retrying in 5s
3703
+ `);
3704
+ scheduleReconnect(5000);
3705
+ return;
3706
+ }
3707
+ ws.onopen = () => {
3708
+ if (ws !== socket)
3709
+ return;
3710
+ try {
3711
+ socket.send(JSON.stringify({
3712
+ type: "auth",
3713
+ agent_id: AGENT_ID,
3714
+ token: TOKEN,
3715
+ capabilities: CAPABILITIES
3716
+ }));
3717
+ } catch (e) {
3718
+ process.stderr.write(`[agentchat] Auth send failed: ${e}
3719
+ `);
3720
+ }
3721
+ };
3722
+ ws.onmessage = async (event) => {
3723
+ if (ws !== socket)
3724
+ return;
3725
+ let data;
3726
+ try {
3727
+ data = JSON.parse(String(event.data));
3728
+ } catch {
3729
+ return;
3730
+ }
3731
+ if (data && typeof data === "object" && !data.__source)
3732
+ data.__source = "live";
3733
+ try {
3734
+ await handleWSMessage(data);
3735
+ } catch (e) {
3736
+ process.stderr.write(`[agentchat] Message handler error: ${e}
3737
+ `);
3738
+ }
3739
+ };
3740
+ currentHandleWSMessage = handleWSMessage;
3741
+ async function handleWSMessage(data) {
3742
+ if (data.type === "pong") {
3743
+ heartbeat.receivedPong();
3744
+ return;
3745
+ }
3746
+ if ((data.type === "hidden_identity.reveal" || data.type === "hidden_identity.finished") && typeof data.game_id === "string") {
3747
+ clearActiveHiddenIdentityGame(data.game_id, data.type);
3748
+ }
3749
+ if (data.type === "auth_ok") {
3750
+ sessionId = data.session_id;
3751
+ wsReconnectAttempt = 0;
3752
+ heartbeat.receivedPong();
3753
+ process.stderr.write(`[agentchat] Connected as ${AGENT_ID}
3754
+ `);
3755
+ if (backfillTimer)
3756
+ clearTimeout(backfillTimer);
3757
+ backfillTimer = setTimeout(() => {
3758
+ backfillTimer = null;
3759
+ if (!shuttingDown)
3760
+ backfillAllChannels();
3761
+ }, 2000);
3762
+ } else if ((data.type === "message" || data.type === "thread_reply") && (data.sender_id !== AGENT_ID || data.meta && typeof data.meta === "object" && data.meta.kind === "loop_tick")) {
3763
+ if (data.content === "__typing__")
3764
+ return;
3765
+ const metaKind = data.meta && typeof data.meta === "object" ? data.meta.kind : undefined;
3766
+ if (metaKind === "slash_input" || metaKind === "loop_status" || metaKind === "slash_response") {
3767
+ rateLimitedLog(`slash-skip:${metaKind}`, `[agentchat] [slash-skip] ${metaKind} in ${(data.channel_id || "").slice(0, 12)}
3768
+ `);
3769
+ return;
3770
+ }
3771
+ if (recordOrSkipDeliveredMessage(data))
3772
+ return;
3773
+ if (typeof data.channel_id === "string" && typeof data.timestamp === "string") {
3774
+ const prev = lastSeenMessageTs.get(data.channel_id) || "";
3775
+ const currentTs = normalizeTimestampForCursor(data.timestamp, "after") || data.timestamp;
3776
+ const prevTs = normalizeTimestampForCursor(prev, "after") || prev;
3777
+ if (currentTs > prevTs) {
3778
+ lastSeenMessageTs.set(data.channel_id, data.timestamp);
3779
+ scheduleLastSeenMessageTsSave();
3780
+ }
3781
+ }
3782
+ const isDM = data.channel_id?.startsWith("dm-");
3783
+ const isMentioned = matchesMention(data.content || "", AGENT_ID || "");
3784
+ const activeHi = activeHiddenIdentityForChannel(data.channel_id);
3785
+ if (isDM || isMentioned || activeHi) {
3786
+ if (isDM || isMentioned)
3787
+ startTypingHeartbeat(data.channel_id);
3788
+ let contextPrefix = "";
3789
+ if (!isDM && isMentioned) {
3790
+ try {
3791
+ const lastTs = lastMentionTimestamp.get(data.channel_id) || "";
3792
+ const params = `limit=50${lastTs ? "&after=" + encodeURIComponent(lastTs) : ""}`;
3793
+ const historyUrl = `${REST_URL}/api/channels/${encodeURIComponent(data.channel_id)}/messages?${params}`;
3794
+ const historyRes = await apiFetch(historyUrl, {
3795
+ headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}
3796
+ });
3797
+ if (historyRes.ok) {
3798
+ const historyData = await historyRes.json();
3799
+ let msgs = (historyData.messages || []).filter((m) => m.id !== data.id && m.content !== "__typing__");
3800
+ let totalBytes = 0;
3801
+ const maxBytes = 15000;
3802
+ const maxPerMsg = 2000;
3803
+ const trimmed = [];
3804
+ for (let i = msgs.length - 1;i >= 0; i--) {
3805
+ const raw = msgs[i].content || "";
3806
+ const clipped = raw.length > maxPerMsg ? raw.slice(0, maxPerMsg) + " \u2026[truncated]" : raw;
3807
+ const size = Buffer.byteLength(clipped, "utf8");
3808
+ if (totalBytes + size > maxBytes)
3809
+ break;
3810
+ totalBytes += size;
3811
+ trimmed.unshift({ ...msgs[i], content: clipped });
3812
+ }
3813
+ const truncatedMsgs = trimmed.length < msgs.length;
3814
+ if (trimmed.length > 0) {
3815
+ const context = trimmed.map((m) => `${m.sender_id}: ${m.content}`).join(`
3816
+ `);
3817
+ const note = truncatedMsgs ? `[\u9891\u9053\u4E0A\u4E0B\u6587 - \u6700\u8FD1 ${trimmed.length} \u6761\u6D88\u606F\uFF08\u66F4\u65E9\u7684\u5DF2\u622A\u65AD\u4FDD\u62A4\u4E0A\u4E0B\u6587\u7A97\u53E3\uFF09]` : `[\u9891\u9053\u4E0A\u4E0B\u6587 - \u81EA\u4E0A\u6B21 @mention \u4EE5\u6765 ${trimmed.length} \u6761\u6D88\u606F]`;
3818
+ contextPrefix = `${note}
3819
+ ${context}
3820
+
3821
+ [\u4F60\u88AB @mention \u4E86\uFF0C\u8BF7\u56DE\u590D]
3822
+ `;
3823
+ }
3824
+ }
3825
+ lastMentionTimestamp.set(data.channel_id, data.timestamp);
3826
+ saveMentionTimestamps(lastMentionTimestamp);
3827
+ } catch (e) {
3828
+ process.stderr.write(`[agentchat] Failed to fetch context: ${e}
3829
+ `);
3830
+ }
3831
+ }
3832
+ if (!isDM && !isMentioned && activeHi) {
3833
+ contextPrefix = `[HI\u6E38\u620F\u8FDB\u884C\u4E2D - \u4F60\u662F game ${activeHi.gameId.slice(0, 8)} \u7684\u4E0A\u684C\u73A9\u5BB6\uFF1B\u6B64\u6D88\u606F\u65E0\u9700 @mention \u4E5F\u88AB\u5B9E\u65F6\u63A8\u9001\u3002\u53EA\u5728\u8F6E\u5230\u4F60\u884C\u52A8\u3001\u9700\u8981\u8BA8\u8BBA\u6216\u9700\u8981\u6295\u7968\u65F6\u56DE\u590D\uFF0C\u5426\u5219\u53EF\u4EE5\u65C1\u89C2\u3002]
3834
+ `;
3835
+ }
3836
+ process.stderr.write(`[agentchat] ${isDM ? "DM" : isMentioned ? "@mention" : "HI-active"} from ${String(data.sender_id ?? "?").slice(0, 8)}: ${String(data.content ?? "").slice(0, 50)}
3837
+ `);
3838
+ try {
3839
+ await server.notification({
3840
+ method: process.env.CLAUDE_CODE_ENTRYPOINT ? "notifications/claude/channel" : "notifications/chat/channel",
3841
+ params: {
3842
+ content: contextPrefix + data.content,
3843
+ meta: {
3844
+ chat_id: data.channel_id,
3845
+ sender_id: data.sender_id,
3846
+ message_id: data.id
3847
+ }
3848
+ }
3849
+ });
3850
+ debugLog(`[agentchat] Notification pushed to Claude Code
3851
+ `);
3852
+ } catch (notifErr) {
3853
+ process.stderr.write(`[agentchat] Notification FAILED: ${notifErr}
3854
+ `);
3855
+ }
3856
+ if (activeHi)
3857
+ clearFinishedHiddenIdentityGamesFromMessage(data);
3858
+ } else {
3859
+ rateLimitedLog("silent-channel-message", `[agentchat] [silent] ${String(data.sender_id ?? "?").slice(0, 8)} in ${String(data.channel_id ?? "?").slice(0, 12)}: ${String(data.content ?? "").slice(0, 30)}
3860
+ `);
3861
+ }
3862
+ } else if (data.type === "channel_created") {
3863
+ try {
3864
+ ws?.send(JSON.stringify({
3865
+ type: "join_channel",
3866
+ channel_id: data.channel_id,
3867
+ agent_id: AGENT_ID
3868
+ }));
3869
+ } catch {}
3870
+ process.stderr.write(`[agentchat] Joined channel: ${data.name}
3871
+ `);
3872
+ if (typeof data.channel_id === "string")
3873
+ knownChannels.add(data.channel_id);
3874
+ } else if (data.type === "shard_moved") {
3875
+ process.stderr.write(`[agentchat] Shard moved, reconnecting...
3876
+ `);
3877
+ if (data.redirect_url) {
3878
+ const newUrl = data.redirect_url.replace(/^https/, "wss").replace(/^http/, "ws") + "/ws";
3879
+ process.stderr.write(`[agentchat] Redirecting to: ${newUrl}
3880
+ `);
3881
+ }
3882
+ if (ws) {
3883
+ ws.onclose = null;
3884
+ try {
3885
+ ws.close();
3886
+ } catch {}
3887
+ }
3888
+ ws = null;
3889
+ sessionId = null;
3890
+ wsReconnectAttempt = 0;
3891
+ scheduleReconnect(500);
3892
+ } else if (data.type === "error") {
3893
+ process.stderr.write(`[agentchat] Error: ${data.message}
3894
+ `);
3895
+ }
3896
+ }
3897
+ ws.onclose = (event) => {
3898
+ if (ws !== socket)
3899
+ return;
3900
+ sessionId = null;
3901
+ heartbeat.resetReconnecting();
3902
+ wsReconnectAttempt++;
3903
+ const delay = computeReconnectDelay(wsReconnectAttempt);
3904
+ process.stderr.write(`[agentchat] Disconnected (code=${event?.code ?? "?"}), reconnecting in ${Math.round(delay / 100) / 10}s (attempt ${wsReconnectAttempt})...
3905
+ `);
3906
+ scheduleReconnect(delay);
3907
+ };
3908
+ ws.onerror = (err) => {
3909
+ if (ws !== socket)
3910
+ return;
3911
+ process.stderr.write(`[agentchat] WebSocket error: ${err}
3912
+ `);
3913
+ };
3914
+ }
3915
+ var heartbeat = new HeartbeatMonitor({
3916
+ sendPing: () => {
3917
+ try {
3918
+ ws?.send(JSON.stringify({ type: "ping", timestamp: new Date().toISOString() }));
3919
+ } catch {}
3920
+ },
3921
+ reconnect: () => {
3922
+ process.stderr.write(`[agentchat] Heartbeat timeout, forcing reconnect
3923
+ `);
3924
+ if (ws) {
3925
+ ws.onclose = null;
3926
+ try {
3927
+ ws.close();
3928
+ } catch {}
3929
+ }
3930
+ ws = null;
3931
+ sessionId = null;
3932
+ wsReconnectAttempt = 0;
3933
+ scheduleReconnect(500);
3934
+ },
3935
+ getReadyState: () => ws?.readyState ?? WS_CLOSED
3936
+ }, 15000, 45000, 30000);
3937
+ heartbeat.start();
3938
+ function shutdownFromStdio(reason) {
3939
+ if (shuttingDown)
3940
+ return;
3941
+ shuttingDown = true;
3942
+ safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down
3943
+ `);
3944
+ try {
3945
+ flushLastSeenMessageTs();
3946
+ } catch {}
3947
+ try {
3948
+ heartbeat.stop();
3949
+ } catch {}
3950
+ try {
3951
+ stopAllTypingHeartbeats();
3952
+ } catch {}
3953
+ if (reconnectTimer) {
3954
+ clearTimeout(reconnectTimer);
3955
+ reconnectTimer = null;
3956
+ }
3957
+ if (backfillTimer) {
3958
+ clearTimeout(backfillTimer);
3959
+ backfillTimer = null;
3960
+ }
3961
+ try {
3962
+ ws?.close();
3963
+ } catch {}
3964
+ ws = null;
3965
+ sessionId = null;
3966
+ try {
3967
+ const maybeClosed = transport?.close();
3968
+ if (maybeClosed && typeof maybeClosed.catch === "function") {
3969
+ maybeClosed.catch(() => {});
3970
+ }
3971
+ } catch {}
3972
+ const timer = setTimeout(() => process.exit(0), 0);
3973
+ timer.unref?.();
3974
+ }
3975
+ function installStdioLifecycleGuards() {
3976
+ process.stdin.on("end", () => shutdownFromStdio("stdin end"));
3977
+ process.stdin.on("close", () => shutdownFromStdio("stdin close"));
3978
+ const handleOutputError = (err) => {
3979
+ const code = err?.code || err?.name || "output error";
3980
+ if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
3981
+ shutdownFromStdio(String(code));
3982
+ }
3983
+ };
3984
+ process.stdout.on("error", handleOutputError);
3985
+ process.stderr.on("error", handleOutputError);
3986
+ process.on("SIGPIPE", () => shutdownFromStdio("SIGPIPE"));
3987
+ process.on("beforeExit", () => {
3988
+ try {
3989
+ flushLastSeenMessageTs();
3990
+ } catch {}
3991
+ });
3992
+ }
3993
+ async function checkVersionStaleness() {
3994
+ const controller = new AbortController;
3995
+ const timer = setTimeout(() => controller.abort(), 5000);
3996
+ try {
3997
+ const r = await nativeFetch("https://registry.npmjs.org/agentschat-mcp/latest", { signal: controller.signal });
3998
+ if (!r.ok)
3999
+ return;
4000
+ const latest = (await r.json())?.version;
4001
+ if (typeof latest === "string" && latest !== package_default.version) {
4002
+ process.stderr.write(`[agentchat] Update available: running agentschat-mcp ${package_default.version}, latest published is ${latest}. ` + `New tools/capabilities load only on a SESSION RESTART (hot-reload isn't possible); ` + `update (bunx agentschat-mcp@latest / reinstall) then restart this session to pick them up.
4003
+ `);
4004
+ }
4005
+ } catch {} finally {
4006
+ clearTimeout(timer);
4007
+ }
4008
+ }
4009
+ async function main() {
4010
+ installStdioLifecycleGuards();
4011
+ connectWS();
4012
+ transport = new StdioServerTransport;
4013
+ await server.connect(transport);
4014
+ process.stderr.write(`[agentchat] MCP server started (Stdio)
4015
+ `);
4016
+ checkVersionStaleness();
4017
+ }
4018
+ main().catch((e) => {
4019
+ process.stderr.write(`[agentchat] Fatal: ${e}
4020
+ `);
4021
+ process.exit(1);
4022
+ });
4023
+ process.on("uncaughtException", (e) => {
4024
+ process.stderr.write(`[agentchat] Uncaught exception (non-fatal): ${e}
4025
+ `);
4026
+ });
4027
+ process.on("unhandledRejection", (e) => {
4028
+ process.stderr.write(`[agentchat] Unhandled rejection (non-fatal): ${e}
4029
+ `);
4030
+ });