@agentchatme/openclaw 0.7.8 → 0.7.82

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/index.js CHANGED
@@ -6,10 +6,16 @@ import { z } from 'zod';
6
6
  import { waitUntilAbort } from 'openclaw/plugin-sdk/channel-lifecycle';
7
7
  import pino from 'pino';
8
8
  import { WebSocket } from 'ws';
9
- import { dispatchInboundDirectDmWithRuntime } from 'openclaw/plugin-sdk/direct-dm';
10
9
  import { resolveInboundRouteEnvelopeBuilderWithRuntime } from 'openclaw/plugin-sdk/inbound-envelope';
11
- import { recordInboundSessionAndDispatchReply } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
10
+ import { dispatchChannelInboundReply } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
11
+ import fs2 from 'fs';
12
+ import * as os from 'os';
13
+ import os__default from 'os';
14
+ import * as path2 from 'path';
15
+ import path2__default from 'path';
16
+ import { readOpenClawProfileFromEnv } from './credentials/read-env.js';
12
17
  import { AgentChatClient } from 'agentchatme';
18
+ import { prepareSimpleCompletionModelForAgent, completeWithPreparedSimpleCompletionModel } from 'openclaw/plugin-sdk/simple-completion-runtime';
13
19
  import { Type } from '@sinclair/typebox';
14
20
 
15
21
  // src/channel.ts
@@ -263,9 +269,9 @@ async function registerAgentVerify(input, opts = {}) {
263
269
  }
264
270
  return { ok: false, reason: "server-error", status: res.status, message };
265
271
  }
266
- async function post(path, body, opts) {
272
+ async function post(path3, body, opts) {
267
273
  const base = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
268
- const url = `${base}${path}`;
274
+ const url = `${base}${path3}`;
269
275
  const controller = new AbortController();
270
276
  const fetchImpl = opts.fetch ?? fetch;
271
277
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -908,9 +914,9 @@ function createLogger(options) {
908
914
  function buildRedactPaths(keys) {
909
915
  const prefixes = ["", "config.", "headers.", "context.", "req.headers.", "res.headers."];
910
916
  const paths = [];
911
- for (const key of keys) {
917
+ for (const key2 of keys) {
912
918
  for (const prefix of prefixes) {
913
- paths.push(`${prefix}${key}`);
919
+ paths.push(`${prefix}${key2}`);
914
920
  }
915
921
  }
916
922
  paths.push(...keys.map((k) => `*.${k}`));
@@ -1517,6 +1523,19 @@ var messageContentSchema = z.object({
1517
1523
  data: z.record(z.string(), z.unknown()).optional(),
1518
1524
  attachment_id: z.string().optional()
1519
1525
  }).passthrough();
1526
+ var messageContextSchema = z.object({
1527
+ sender: z.object({
1528
+ handle: z.string(),
1529
+ display_name: z.string().nullable().optional(),
1530
+ kind: z.enum(["agent", "system"]).optional()
1531
+ }).passthrough().optional(),
1532
+ conversation: z.object({
1533
+ type: z.enum(["direct", "group"]).optional(),
1534
+ group_name: z.string().nullable().optional(),
1535
+ member_count: z.number().int().nullable().optional()
1536
+ }).passthrough().optional(),
1537
+ mentions: z.array(z.string()).optional()
1538
+ }).passthrough();
1520
1539
  var messageSchema = z.object({
1521
1540
  id: z.string(),
1522
1541
  conversation_id: z.string(),
@@ -1526,6 +1545,7 @@ var messageSchema = z.object({
1526
1545
  type: z.enum(["text", "structured", "file", "system"]),
1527
1546
  content: messageContentSchema,
1528
1547
  metadata: z.record(z.string(), z.unknown()).default({}),
1548
+ context: messageContextSchema.optional(),
1529
1549
  // Per-recipient delivery state lives in `message_deliveries` since
1530
1550
  // migration 011 — the `messages` row no longer carries `status`,
1531
1551
  // `delivered_at`, or `read_at`. Fresh-send envelopes over WS omit
@@ -1647,7 +1667,12 @@ function normalizeMessageNew(frame) {
1647
1667
  createdAt: msg.created_at,
1648
1668
  deliveredAt: msg.delivered_at ?? null,
1649
1669
  readAt: msg.read_at ?? null,
1650
- receivedAt: frame.receivedAt
1670
+ receivedAt: frame.receivedAt,
1671
+ senderDisplayName: msg.context?.sender?.display_name ?? null,
1672
+ senderKind: msg.context?.sender?.kind === "system" ? "system" : "agent",
1673
+ groupName: msg.context?.conversation?.group_name ?? null,
1674
+ memberCount: msg.context?.conversation?.member_count ?? null,
1675
+ mentions: (msg.context?.mentions ?? []).map((m) => m.toLowerCase())
1651
1676
  };
1652
1677
  }
1653
1678
  function normalizeMessageRead(frame) {
@@ -1726,7 +1751,7 @@ function normalizeGroupDeleted(frame) {
1726
1751
 
1727
1752
  // src/retry.ts
1728
1753
  function defaultSleep(ms) {
1729
- return new Promise((resolve) => setTimeout(resolve, ms));
1754
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1730
1755
  }
1731
1756
  async function retryWithPolicy(fn, policy) {
1732
1757
  const random = policy.random ?? Math.random;
@@ -1854,7 +1879,7 @@ var CircuitBreaker = class {
1854
1879
  };
1855
1880
 
1856
1881
  // src/version.ts
1857
- var PACKAGE_VERSION = "0.7.8";
1882
+ var PACKAGE_VERSION = "0.7.82";
1858
1883
 
1859
1884
  // src/outbound.ts
1860
1885
  var DEFAULT_RETRY_POLICY = {
@@ -2070,11 +2095,11 @@ var OutboundAdapter = class {
2070
2095
  `outbound queue full (${this.queue.length}) \u2014 shedding load`
2071
2096
  );
2072
2097
  }
2073
- return new Promise((resolve) => {
2098
+ return new Promise((resolve2) => {
2074
2099
  this.queue.push(() => {
2075
2100
  this.inFlight++;
2076
2101
  this.metrics.setInFlightDepth(this.inFlight);
2077
- resolve();
2102
+ resolve2();
2078
2103
  });
2079
2104
  });
2080
2105
  }
@@ -2150,10 +2175,10 @@ var AgentchatChannelRuntime = class {
2150
2175
  stop(deadlineMs) {
2151
2176
  if (this.stopPromise) return this.stopPromise;
2152
2177
  const deadline = deadlineMs ?? this.now() + 5e3;
2153
- this.stopPromise = new Promise((resolve) => {
2178
+ this.stopPromise = new Promise((resolve2) => {
2154
2179
  const off = this.ws.on("closed", () => {
2155
2180
  off();
2156
- resolve();
2181
+ resolve2();
2157
2182
  });
2158
2183
  this.ws.stop(deadline);
2159
2184
  });
@@ -2383,6 +2408,476 @@ function unregisterRuntime(accountId, deadlineMs = Date.now() + 5e3) {
2383
2408
  function getRuntime(accountId) {
2384
2409
  return registry.get(accountId)?.runtime;
2385
2410
  }
2411
+ function resolveWorkspaceDir(cfg) {
2412
+ const configured = cfg?.agents?.defaults?.workspace;
2413
+ if (typeof configured === "string" && configured.trim().length > 0) {
2414
+ return path2.resolve(configured);
2415
+ }
2416
+ const profile = readOpenClawProfileFromEnv();
2417
+ if (profile) {
2418
+ return path2.join(os.homedir(), ".openclaw", `workspace-${profile}`);
2419
+ }
2420
+ return path2.join(os.homedir(), ".openclaw", "workspace");
2421
+ }
2422
+
2423
+ // src/binding/thread-closures.ts
2424
+ var instances = /* @__PURE__ */ new Map();
2425
+ function fallbackWorkspaceDir() {
2426
+ const profile = process.env.OPENCLAW_PROFILE?.trim();
2427
+ if (profile && profile.toLowerCase() !== "default") {
2428
+ return path2__default.join(os__default.homedir(), ".openclaw", `workspace-${profile}`);
2429
+ }
2430
+ return path2__default.join(os__default.homedir(), ".openclaw", "workspace");
2431
+ }
2432
+ function resolveStatePath(cfg, accountId) {
2433
+ const workspaceDir = typeof cfg === "object" && cfg !== null ? resolveWorkspaceDir(cfg) : fallbackWorkspaceDir();
2434
+ return path2__default.join(workspaceDir, `.agentchat-closed-threads-${accountId}.json`);
2435
+ }
2436
+ var ThreadClosures = class {
2437
+ filePath;
2438
+ closed = /* @__PURE__ */ new Map();
2439
+ constructor(filePath) {
2440
+ this.filePath = filePath;
2441
+ this.load();
2442
+ }
2443
+ isClosed(conversationId) {
2444
+ return this.closed.has(conversationId);
2445
+ }
2446
+ close(conversationId, reason) {
2447
+ const record = {
2448
+ conversationId,
2449
+ closedAt: (/* @__PURE__ */ new Date()).toISOString(),
2450
+ reason: reason?.trim() ? reason.trim() : null
2451
+ };
2452
+ this.closed.set(conversationId, record);
2453
+ this.save();
2454
+ return record;
2455
+ }
2456
+ reopen(conversationId) {
2457
+ const existed = this.closed.delete(conversationId);
2458
+ if (existed) this.save();
2459
+ return existed;
2460
+ }
2461
+ list() {
2462
+ return [...this.closed.values()].sort((a, b) => b.closedAt.localeCompare(a.closedAt));
2463
+ }
2464
+ load() {
2465
+ if (!fs2.existsSync(this.filePath)) return;
2466
+ try {
2467
+ const raw = JSON.parse(fs2.readFileSync(this.filePath, "utf8"));
2468
+ if (!Array.isArray(raw)) return;
2469
+ for (const entry of raw) {
2470
+ if (!entry || typeof entry !== "object") continue;
2471
+ const conversationId = entry.conversationId;
2472
+ const closedAt = entry.closedAt;
2473
+ const reason = entry.reason;
2474
+ if (typeof conversationId !== "string" || typeof closedAt !== "string") continue;
2475
+ this.closed.set(conversationId, {
2476
+ conversationId,
2477
+ closedAt,
2478
+ reason: typeof reason === "string" ? reason : null
2479
+ });
2480
+ }
2481
+ } catch {
2482
+ this.closed = /* @__PURE__ */ new Map();
2483
+ }
2484
+ }
2485
+ save() {
2486
+ fs2.mkdirSync(path2__default.dirname(this.filePath), { recursive: true });
2487
+ const payload = JSON.stringify(this.list(), null, 2);
2488
+ const tempPath = `${this.filePath}.tmp`;
2489
+ fs2.writeFileSync(tempPath, payload, "utf8");
2490
+ fs2.renameSync(tempPath, this.filePath);
2491
+ }
2492
+ };
2493
+ function getThreadClosures(cfg, accountId) {
2494
+ const filePath = resolveStatePath(cfg, accountId);
2495
+ const existing = instances.get(filePath);
2496
+ if (existing) return existing;
2497
+ const created = new ThreadClosures(filePath);
2498
+ instances.set(filePath, created);
2499
+ return created;
2500
+ }
2501
+ var cache = /* @__PURE__ */ new Map();
2502
+ function getClient({ accountId, config, options }) {
2503
+ const existing = cache.get(accountId);
2504
+ if (existing && existing.apiKey === config.apiKey && existing.apiBase === config.apiBase) {
2505
+ return existing.client;
2506
+ }
2507
+ const client = new AgentChatClient({
2508
+ apiKey: config.apiKey,
2509
+ baseUrl: config.apiBase,
2510
+ ...options
2511
+ });
2512
+ cache.set(accountId, {
2513
+ client,
2514
+ apiKey: config.apiKey,
2515
+ apiBase: config.apiBase
2516
+ });
2517
+ return client;
2518
+ }
2519
+ function disposeClient(accountId) {
2520
+ cache.delete(accountId);
2521
+ }
2522
+
2523
+ // src/binding/send-tracker.ts
2524
+ var lastSendByKey = /* @__PURE__ */ new Map();
2525
+ var MAX_TRACKED = 512;
2526
+ function key(accountId, conversationId) {
2527
+ return `${accountId}\0${conversationId}`;
2528
+ }
2529
+ function recordAgentSend(accountId, conversationId, atMs = Date.now()) {
2530
+ if (!conversationId) return;
2531
+ const k = key(accountId, conversationId);
2532
+ lastSendByKey.delete(k);
2533
+ lastSendByKey.set(k, atMs);
2534
+ if (lastSendByKey.size > MAX_TRACKED) {
2535
+ const oldest = lastSendByKey.keys().next().value;
2536
+ if (oldest !== void 0) lastSendByKey.delete(oldest);
2537
+ }
2538
+ }
2539
+ function hasAgentSendSince(accountId, conversationId, sinceMs) {
2540
+ const at = lastSendByKey.get(key(accountId, conversationId));
2541
+ return at !== void 0 && at >= sinceMs;
2542
+ }
2543
+
2544
+ // src/binding/reply-gate.ts
2545
+ var DEFAULT_GATE_MAX_TOKENS = 256;
2546
+ var MAX_HISTORY_TURNS = 12;
2547
+ var CADENCE_WINDOW_SECONDS = 60;
2548
+ var VALID_CATEGORIES = /* @__PURE__ */ new Set([
2549
+ "open_request",
2550
+ "new_info",
2551
+ "goal_followup",
2552
+ "closing",
2553
+ "acknowledgement",
2554
+ "not_addressed",
2555
+ "no_action_needed",
2556
+ "spam",
2557
+ "other"
2558
+ ]);
2559
+ var REPLY_TOKENS = /* @__PURE__ */ new Set(["reply", "yes", "true", "respond"]);
2560
+ var NO_REPLY_TOKENS = /* @__PURE__ */ new Set([
2561
+ "no_reply",
2562
+ "no-reply",
2563
+ "noreply",
2564
+ "no",
2565
+ "false",
2566
+ "silent",
2567
+ "skip",
2568
+ "none"
2569
+ ]);
2570
+ var FENCE_RE = /```(?:json)?\s*([\s\S]+?)```/i;
2571
+ function computeConversationSignals(messages, params) {
2572
+ const windowSeconds = params.windowSeconds ?? CADENCE_WINDOW_SECONDS;
2573
+ const own = normalizeHandle(params.ownHandle);
2574
+ const prior = messages.filter(
2575
+ (m) => isRecord(m) && m.id !== params.triggerMessageId
2576
+ );
2577
+ const firstContact = prior.length === 0;
2578
+ const youHaveSpoken = prior.some((m) => messageIsOwn(m, own));
2579
+ const timestamps = [];
2580
+ for (const m of prior) {
2581
+ const ts = parseTimestamp(m.created_at);
2582
+ if (ts !== null) timestamps.push(ts);
2583
+ }
2584
+ const cutoff = params.nowMs - windowSeconds * 1e3;
2585
+ const recentInWindow = timestamps.filter((ts) => ts >= cutoff).length;
2586
+ let secondsSincePrevious = null;
2587
+ if (timestamps.length > 0) {
2588
+ const delta = (params.nowMs - Math.max(...timestamps)) / 1e3;
2589
+ secondsSincePrevious = delta > 0 ? delta : 0;
2590
+ }
2591
+ return {
2592
+ firstContact,
2593
+ youHaveSpoken,
2594
+ messagesLastWindow: recentInWindow + 1,
2595
+ // +1 for the new message
2596
+ secondsSincePrevious
2597
+ };
2598
+ }
2599
+ function messageIsOwn(msg, ownHandleNorm) {
2600
+ if (typeof msg.is_own === "boolean") return msg.is_own;
2601
+ const sender = msg.sender ?? msg.from ?? msg.sender_handle ?? "";
2602
+ return normalizeHandle(String(sender)) === ownHandleNorm;
2603
+ }
2604
+ function parseTimestamp(raw) {
2605
+ if (typeof raw !== "string" || !raw) return null;
2606
+ const t = Date.parse(raw);
2607
+ return Number.isNaN(t) ? null : t;
2608
+ }
2609
+ function normalizeHandle(handle) {
2610
+ return handle.replace(/^@/, "").toLowerCase();
2611
+ }
2612
+ function isRecord(value) {
2613
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2614
+ }
2615
+ function systemTemplate(handle) {
2616
+ const h = `@${handle}`;
2617
+ return `You are the reply gate for ${h}, an autonomous agent on AgentChat (a peer-to-peer messaging network for AI agents). A message just arrived. Your only job is to decide whether ${h} should reply to it now. You do NOT write the reply \u2014 you output one decision.
2618
+
2619
+ "no_reply" is a SUCCESS, not a failure. Most healthy conversations are SUPPOSED to end; going quiet is the normal, correct outcome and is never rude here \u2014 on this network silence IS the acknowledgement.
2620
+
2621
+ Judge DONE-NESS, not how interesting another message could be:
2622
+
2623
+ Choose "reply" only if a further message accomplishes a concrete, still-OPEN purpose:
2624
+ - answer a substantive pending question or supply specifically requested information
2625
+ - make or respond to a decision, or unblock the peer on a real task
2626
+ - ${h} started this thread toward a goal and the peer's reply needs a substantive follow-up to reach it
2627
+ - new information genuinely requires ${h}'s input
2628
+
2629
+ Choose "no_reply" when the exchange has reached its natural end \u2014 even if another clever or friendly message is easily possible:
2630
+ - it is trading pleasantries, mutual appreciation, agreement, or open-ended tangents / "riffing" with no open objective
2631
+ - a courtesy question that merely mirrors the exchange back ("and you?", "what are you working on?", "what tools are you using?") after the substantive part has run its course is part of the pleasantry, not an open task \u2014 it does not oblige a reply
2632
+ - the other side is acknowledging or closing out (thanks / ok / got it / sounds good / \u{1F44D} / bye) and replying would only prolong it
2633
+ - ${h} already answered what was asked and nothing new is on the table
2634
+ - in a group, the message is not addressed to ${h} and does not need it. (Groups only \u2014 in a direct conversation every message is addressed to ${h} by definition, so "not_addressed" never applies there.)
2635
+
2636
+ "I could add something" is NOT a reason to reply. "Something concrete is unresolved and my reply resolves it" IS. Two agents keeping a chat alive by each politely asking the next question is the exact failure you exist to prevent \u2014 the thread being pleasant does not make it open. If the Pace line shows messages flying back and forth with each turn only restating, appreciating, or re-asking a mirrored question, that IS the loop \u2014 choose "no_reply". When unsure, prefer "no_reply".
2637
+
2638
+ Respond with ONLY a JSON object \u2014 no prose, no markdown fences:
2639
+ {"decision": "reply" or "no_reply", "reason": "<one short sentence>", "category": "<one of: open_request, new_info, goal_followup, closing, acknowledgement, not_addressed, no_action_needed, spam, other>"}`;
2640
+ }
2641
+ function buildDecisionMessages(params) {
2642
+ return [
2643
+ { role: "system", content: systemTemplate(params.handle) },
2644
+ {
2645
+ role: "user",
2646
+ content: buildUserContent({
2647
+ handle: params.handle,
2648
+ event: params.event,
2649
+ history: params.history,
2650
+ signals: params.signals ?? null,
2651
+ maxHistory: params.maxHistory ?? MAX_HISTORY_TURNS
2652
+ })
2653
+ }
2654
+ ];
2655
+ }
2656
+ function formatReceivedAt(ms) {
2657
+ if (!Number.isFinite(ms)) return "an unknown time";
2658
+ const iso = new Date(ms).toISOString();
2659
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
2660
+ }
2661
+ function formatConversationContext(params) {
2662
+ const { handle, event, signals, priorCount } = params;
2663
+ const lines = [`Conversation type: ${formatConversationLabel(event)}`];
2664
+ if (signals) {
2665
+ lines.push(`Relationship: ${relationshipPhrase(signals)}`);
2666
+ if (signals.secondsSincePrevious !== null) {
2667
+ lines.push(
2668
+ `Pace: ${signals.messagesLastWindow} message(s) in the last ${CADENCE_WINDOW_SECONDS}s; ${formatGap(signals.secondsSincePrevious)} since the previous message`
2669
+ );
2670
+ }
2671
+ }
2672
+ lines.push(`Prior messages in this thread: ${priorCount}`);
2673
+ if (event.conversationKind === "group" && handle && (event.mentions ?? []).includes(handle.toLowerCase())) {
2674
+ lines.push("You were @-mentioned in this message.");
2675
+ }
2676
+ return lines;
2677
+ }
2678
+ function formatConversationLabel(event) {
2679
+ if (event.conversationKind !== "group") return "direct";
2680
+ let label = event.groupName ? `group "${event.groupName}"` : "group";
2681
+ if (event.memberCount != null) {
2682
+ label += ` (${event.memberCount} member${event.memberCount === 1 ? "" : "s"})`;
2683
+ }
2684
+ return label;
2685
+ }
2686
+ function buildUserContent(params) {
2687
+ const { handle, event, history, signals, maxHistory } = params;
2688
+ const lines = formatConversationContext({
2689
+ handle,
2690
+ event,
2691
+ signals,
2692
+ priorCount: history.length
2693
+ });
2694
+ lines.push("");
2695
+ const rendered = renderHistory(history, maxHistory);
2696
+ if (rendered.length > 0) {
2697
+ lines.push("Recent conversation (oldest first):");
2698
+ lines.push(...rendered);
2699
+ } else {
2700
+ lines.push("Recent conversation: (none \u2014 this is first contact)");
2701
+ }
2702
+ let newText = collapseWhitespace(event.contentText || "");
2703
+ if (newText.length > 2e3) newText = `${newText.slice(0, 2e3)}\u2026`;
2704
+ lines.push("");
2705
+ lines.push(`New message from @${event.senderHandle}: ${newText}`);
2706
+ lines.push("");
2707
+ lines.push("Decide now: reply or no_reply?");
2708
+ return lines.join("\n");
2709
+ }
2710
+ function renderHistory(history, maxHistory) {
2711
+ const recent = history.length > maxHistory ? history.slice(history.length - maxHistory) : history;
2712
+ const out = [];
2713
+ for (const turn of recent) {
2714
+ if (typeof turn.content !== "string" || !turn.content.trim()) continue;
2715
+ const speaker = turn.role === "assistant" ? "you" : "peer";
2716
+ let text = collapseWhitespace(turn.content);
2717
+ if (text.length > 400) text = `${text.slice(0, 400)}\u2026`;
2718
+ out.push(`${speaker}: ${text}`);
2719
+ }
2720
+ return out;
2721
+ }
2722
+ function relationshipPhrase(signals) {
2723
+ if (signals.firstContact) return "first contact \u2014 no prior messages in this thread";
2724
+ if (signals.youHaveSpoken) return "established \u2014 you have already replied in this thread";
2725
+ return "this peer is messaging you, but you have not replied yet";
2726
+ }
2727
+ function formatGap(seconds) {
2728
+ if (seconds < 90) return `${Math.round(seconds)}s`;
2729
+ if (seconds < 5400) return `${Math.round(seconds / 60)}m`;
2730
+ return `${Math.round(seconds / 3600)}h`;
2731
+ }
2732
+ function collapseWhitespace(text) {
2733
+ return text.split(/\s+/).filter(Boolean).join(" ");
2734
+ }
2735
+ function parseDecision(text, opts = {}) {
2736
+ const raw = extractJson(text);
2737
+ if (raw === null) return null;
2738
+ let obj;
2739
+ try {
2740
+ obj = JSON.parse(raw);
2741
+ } catch {
2742
+ return null;
2743
+ }
2744
+ if (!isRecord(obj)) return null;
2745
+ const decisionRaw = obj.decision;
2746
+ if (typeof decisionRaw !== "string") return null;
2747
+ const token = decisionRaw.trim().toLowerCase();
2748
+ let reply;
2749
+ if (REPLY_TOKENS.has(token)) reply = true;
2750
+ else if (NO_REPLY_TOKENS.has(token)) reply = false;
2751
+ else return null;
2752
+ const reasonRaw = obj.reason;
2753
+ let reason = typeof reasonRaw === "string" ? reasonRaw.trim() : "";
2754
+ if (reason.length > 280) reason = reason.slice(0, 280);
2755
+ const categoryRaw = obj.category;
2756
+ let category = typeof categoryRaw === "string" ? categoryRaw.trim().toLowerCase() : "other";
2757
+ if (!VALID_CATEGORIES.has(category)) category = "other";
2758
+ return {
2759
+ reply,
2760
+ reason,
2761
+ category,
2762
+ source: opts.source ?? "llm",
2763
+ latencyMs: opts.latencyMs ?? 0
2764
+ };
2765
+ }
2766
+ function extractJson(text) {
2767
+ if (!text || !text.trim()) return null;
2768
+ let s = text.trim();
2769
+ const fence = FENCE_RE.exec(s);
2770
+ if (fence?.[1]) s = fence[1].trim();
2771
+ const start = s.indexOf("{");
2772
+ const end = s.lastIndexOf("}");
2773
+ if (start === -1 || end === -1 || end <= start) return null;
2774
+ return s.slice(start, end + 1);
2775
+ }
2776
+ function gateFallback(failOpen, reason, latencyMs) {
2777
+ return {
2778
+ reply: failOpen,
2779
+ reason,
2780
+ category: "fallback",
2781
+ source: failOpen ? "fail_open" : "fail_closed",
2782
+ latencyMs
2783
+ };
2784
+ }
2785
+
2786
+ // src/binding/gate.ts
2787
+ var DEFAULT_GATE_TIMEOUT_MS = 2e4;
2788
+ var GATE_REASONING_LEVEL = "off";
2789
+ var GateTimeoutError = class extends Error {
2790
+ };
2791
+ function withTimeout(promise, ms) {
2792
+ if (!Number.isFinite(ms) || ms <= 0) return promise;
2793
+ return new Promise((resolve2, reject) => {
2794
+ const timer = setTimeout(() => reject(new GateTimeoutError("gate decision timed out")), ms);
2795
+ promise.then(
2796
+ (value) => {
2797
+ clearTimeout(timer);
2798
+ resolve2(value);
2799
+ },
2800
+ (err3) => {
2801
+ clearTimeout(timer);
2802
+ reject(err3);
2803
+ }
2804
+ );
2805
+ });
2806
+ }
2807
+ async function decideReply(params) {
2808
+ const signals = computeConversationSignals(params.rawMessages, {
2809
+ ownHandle: params.ownHandle,
2810
+ triggerMessageId: params.triggerMessageId,
2811
+ nowMs: params.nowMs
2812
+ });
2813
+ const messages = buildDecisionMessages({
2814
+ handle: params.handle,
2815
+ event: params.event,
2816
+ history: params.history,
2817
+ signals
2818
+ });
2819
+ const systemPrompt = messages.find((m) => m.role === "system")?.content ?? "";
2820
+ const userContent = messages.find((m) => m.role === "user")?.content ?? "";
2821
+ const caller = params.caller ?? createSimpleCompletionGateCaller(params.cfg, params.agentId);
2822
+ const maxTokens = params.maxTokens ?? DEFAULT_GATE_MAX_TOKENS;
2823
+ const timeoutMs = params.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
2824
+ const start = Date.now();
2825
+ let text;
2826
+ try {
2827
+ text = await withTimeout(caller({ systemPrompt, userContent, maxTokens }), timeoutMs);
2828
+ } catch (err3) {
2829
+ if (err3 instanceof GateTimeoutError) {
2830
+ return gateFallback(params.failOpen, "decision_timeout", Date.now() - start);
2831
+ }
2832
+ const detail = err3 instanceof Error ? err3.message : String(err3);
2833
+ return gateFallback(
2834
+ params.failOpen,
2835
+ `decision_call_error: ${detail}`.slice(0, 220),
2836
+ Date.now() - start
2837
+ );
2838
+ }
2839
+ const latencyMs = Date.now() - start;
2840
+ const parsed = parseDecision(text, { source: "llm", latencyMs });
2841
+ return parsed ?? gateFallback(params.failOpen, "unparseable_decision", latencyMs);
2842
+ }
2843
+ function createSimpleCompletionGateCaller(cfg, agentId) {
2844
+ return async ({ systemPrompt, userContent, maxTokens, signal }) => {
2845
+ const prepared = await prepareSimpleCompletionModelForAgent({
2846
+ cfg,
2847
+ // plugin-local OpenClawConfig alias → sdk's internal type
2848
+ agentId,
2849
+ allowMissingApiKeyModes: ["aws-sdk"],
2850
+ // Do NOT skip discovery: it loads provider model catalogs. Skipping it
2851
+ // works for providers with pure dynamic resolution (e.g. Fireworks) but
2852
+ // makes catalog-based providers (Google/Gemini, Anthropic, OpenAI, …)
2853
+ // fail with "Unknown model: <ref>" — the gate must resolve the agent's
2854
+ // own model regardless of which provider it runs on.
2855
+ skipAgentDiscovery: false
2856
+ });
2857
+ if ("error" in prepared) throw new Error(prepared.error);
2858
+ const result = await completeWithPreparedSimpleCompletionModel({
2859
+ model: prepared.model,
2860
+ auth: prepared.auth,
2861
+ cfg,
2862
+ context: {
2863
+ systemPrompt,
2864
+ messages: [{ role: "user", content: userContent }]
2865
+ },
2866
+ // temperature 0 mirrors the Hermes gate: a binary policy decision must
2867
+ // be as deterministic as the provider allows, not sampled creatively.
2868
+ options: {
2869
+ maxTokens,
2870
+ temperature: 0,
2871
+ reasoning: GATE_REASONING_LEVEL,
2872
+ ...signal ? { signal } : {}
2873
+ }
2874
+ });
2875
+ return result.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("");
2876
+ };
2877
+ }
2878
+
2879
+ // src/binding/inbound-bridge.ts
2880
+ var GATE_HISTORY_LIMIT = 30;
2386
2881
  function createInboundBridge(deps) {
2387
2882
  return async function onInbound(event) {
2388
2883
  switch (event.kind) {
@@ -2419,6 +2914,17 @@ async function handleMessage(deps, event) {
2419
2914
  if (!body && !event.content.attachmentId && !event.content.data) {
2420
2915
  return;
2421
2916
  }
2917
+ const threadClosures = getThreadClosures(
2918
+ deps.gatewayCfg,
2919
+ deps.accountId
2920
+ );
2921
+ if (threadClosures.isClosed(event.conversationId)) {
2922
+ deps.logger.info(
2923
+ { conversationId: event.conversationId, messageId: event.messageId, sender: event.sender },
2924
+ "inbound skipped for locally closed thread"
2925
+ );
2926
+ return;
2927
+ }
2422
2928
  const channelRuntime = deps.channelRuntime;
2423
2929
  if (!channelRuntime || typeof channelRuntime.reply?.dispatchReplyWithBufferedBlockDispatcher !== "function") {
2424
2930
  deps.logger.error(
@@ -2433,6 +2939,7 @@ async function handleMessage(deps, event) {
2433
2939
  );
2434
2940
  return;
2435
2941
  }
2942
+ const ts = typeof event.createdAt === "number" ? event.createdAt : Date.parse(event.createdAt);
2436
2943
  const recipientHandle = selfHandle ?? "me";
2437
2944
  const conversationLabel = event.conversationKind === "group" ? `group ${event.conversationId}` : `dm with @${senderHandle}`;
2438
2945
  const sendReply = async (replyText) => {
@@ -2445,36 +2952,131 @@ async function handleMessage(deps, event) {
2445
2952
  metadata: { reply_to: event.messageId }
2446
2953
  });
2447
2954
  };
2955
+ const turnStartMs = Date.now();
2448
2956
  const deliver2 = async (payload) => {
2449
- const replyText = payload.text ?? extractText(payload.blocks);
2450
- await sendReply(replyText);
2957
+ if (threadClosures.isClosed(event.conversationId)) {
2958
+ deps.logger.info(
2959
+ { conversationId: event.conversationId, messageId: event.messageId },
2960
+ "reply suppressed for locally closed thread"
2961
+ );
2962
+ return;
2963
+ }
2964
+ if (hasAgentSendSince(deps.accountId, event.conversationId, turnStartMs)) {
2965
+ deps.logger.info(
2966
+ { conversationId: event.conversationId, messageId: event.messageId },
2967
+ "final turn text suppressed \u2014 agent already sent its reply via a message tool this turn"
2968
+ );
2969
+ return;
2970
+ }
2971
+ await sendReply(payload.text ?? extractText(payload.blocks));
2451
2972
  };
2452
2973
  try {
2453
- if (event.conversationKind === "direct") {
2454
- await dispatchInboundDirectDmWithRuntime({
2455
- cfg: deps.gatewayCfg,
2456
- runtime: { channel: channelRuntime },
2457
- channel: "agentchat",
2458
- channelLabel: "AgentChat",
2459
- accountId: deps.accountId,
2460
- peer: { kind: "direct", id: senderHandle },
2461
- senderId: senderHandle,
2462
- senderAddress: `@${senderHandle}`,
2463
- recipientAddress: `@${recipientHandle}`,
2464
- conversationLabel,
2465
- rawBody: body,
2466
- messageId: event.messageId,
2467
- timestamp: typeof event.createdAt === "number" ? event.createdAt : Date.parse(event.createdAt),
2468
- provider: "agentchat",
2469
- surface: "agentchat",
2470
- deliver: deliver2,
2471
- onRecordError: (err3) => {
2472
- deps.logger.error(
2473
- { err: err3 instanceof Error ? err3.message : String(err3), messageId: event.messageId },
2474
- "recordInboundSession failed"
2475
- );
2974
+ const runtime = channelRuntime;
2975
+ const peer = event.conversationKind === "group" ? { kind: "group", id: event.conversationId } : { kind: "direct", id: senderHandle };
2976
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
2977
+ cfg: deps.gatewayCfg,
2978
+ channel: "agentchat",
2979
+ accountId: deps.accountId,
2980
+ peer,
2981
+ runtime
2982
+ });
2983
+ let gateContext = null;
2984
+ if (gateEnabled()) {
2985
+ const { decision, context } = await runReplyGate({
2986
+ deps,
2987
+ event,
2988
+ body,
2989
+ agentId: route.agentId,
2990
+ selfHandle,
2991
+ senderHandle,
2992
+ nowMs: Number.isFinite(ts) ? ts : Date.now()
2993
+ });
2994
+ deps.logger.info(
2995
+ {
2996
+ conversationId: event.conversationId,
2997
+ messageId: event.messageId,
2998
+ reply: decision.reply,
2999
+ source: decision.source,
3000
+ category: decision.category,
3001
+ latencyMs: decision.latencyMs,
3002
+ reason: decision.reason
2476
3003
  },
2477
- onDispatchError: (err3, info) => {
3004
+ "reply gate decision"
3005
+ );
3006
+ if (!decision.reply) return;
3007
+ gateContext = context;
3008
+ }
3009
+ const contextHeader = [formatSenderLine(event), `Received: ${formatReceivedAt(ts)}`];
3010
+ if (gateContext) {
3011
+ contextHeader.push(...gateContext);
3012
+ } else {
3013
+ contextHeader.push(
3014
+ `Conversation type: ${formatConversationLabel({
3015
+ conversationKind: event.conversationKind,
3016
+ senderHandle,
3017
+ contentText: body,
3018
+ groupName: event.groupName,
3019
+ memberCount: event.memberCount,
3020
+ mentions: event.mentions
3021
+ })}`
3022
+ );
3023
+ const self = (selfHandle ?? "").replace(/^@/, "").toLowerCase();
3024
+ if (event.conversationKind === "group" && self && event.mentions.includes(self)) {
3025
+ contextHeader.push("You were @-mentioned in this message.");
3026
+ }
3027
+ }
3028
+ const agentBody = `${contextHeader.join("\n")}
3029
+
3030
+ ${body}`;
3031
+ const { storePath, body: envelopeBody } = buildEnvelope({
3032
+ channel: "AgentChat",
3033
+ from: conversationLabel,
3034
+ body: agentBody,
3035
+ timestamp: ts
3036
+ });
3037
+ const finalize = channelRuntime.reply.finalizeInboundContext;
3038
+ const ctxPayload = finalize({
3039
+ Body: envelopeBody,
3040
+ BodyForAgent: agentBody,
3041
+ RawBody: body,
3042
+ CommandBody: body,
3043
+ From: `@${senderHandle}`,
3044
+ To: `@${recipientHandle}`,
3045
+ SessionKey: route.sessionKey,
3046
+ AccountId: deps.accountId,
3047
+ ChatType: event.conversationKind === "group" ? "group" : "direct",
3048
+ ConversationLabel: conversationLabel,
3049
+ SenderId: senderHandle,
3050
+ Provider: "agentchat",
3051
+ Surface: "agentchat",
3052
+ MessageSid: event.messageId,
3053
+ MessageSidFull: event.messageId,
3054
+ Timestamp: ts,
3055
+ OriginatingChannel: "agentchat",
3056
+ OriginatingTo: `@${recipientHandle}`
3057
+ });
3058
+ const session = channelRuntime.session;
3059
+ await dispatchChannelInboundReply({
3060
+ cfg: deps.gatewayCfg,
3061
+ channel: "agentchat",
3062
+ accountId: deps.accountId,
3063
+ agentId: route.agentId,
3064
+ routeSessionKey: route.sessionKey,
3065
+ storePath,
3066
+ ctxPayload,
3067
+ recordInboundSession: session.recordInboundSession,
3068
+ dispatchReplyWithBufferedBlockDispatcher: channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
3069
+ // Under `automatic` (the default) the framework hands the agent's final
3070
+ // turn text to this `deliver`, which sends it to the source. Under the
3071
+ // opt-in `message_tool_only` mode the turn text is suppressed and the
3072
+ // agent sends via the message tool instead, so `deliver` only fires on a
3073
+ // framework fallback. Either way a gated `no_reply` turn never runs, so
3074
+ // nothing is sent.
3075
+ delivery: {
3076
+ deliver: async (payload) => {
3077
+ await deliver2(payload);
3078
+ },
3079
+ onError: (err3, info) => {
2478
3080
  deps.logger.error(
2479
3081
  {
2480
3082
  err: err3 instanceof Error ? err3.message : String(err3),
@@ -2484,84 +3086,124 @@ async function handleMessage(deps, event) {
2484
3086
  "inbound dispatch failed"
2485
3087
  );
2486
3088
  }
2487
- });
2488
- } else {
2489
- const ts = typeof event.createdAt === "number" ? event.createdAt : Date.parse(event.createdAt);
2490
- const runtime = channelRuntime;
2491
- const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
2492
- cfg: deps.gatewayCfg,
2493
- channel: "agentchat",
2494
- accountId: deps.accountId,
2495
- peer: { kind: "group", id: event.conversationId },
2496
- runtime
2497
- });
2498
- const { storePath, body: envelopeBody } = buildEnvelope({
2499
- channel: "AgentChat",
2500
- from: conversationLabel,
2501
- body,
2502
- timestamp: ts
2503
- });
2504
- const finalize = channelRuntime.reply.finalizeInboundContext;
2505
- const ctxPayload = finalize({
2506
- Body: envelopeBody,
2507
- BodyForAgent: body,
2508
- RawBody: body,
2509
- CommandBody: body,
2510
- From: `@${senderHandle}`,
2511
- To: `@${recipientHandle}`,
2512
- SessionKey: route.sessionKey,
2513
- AccountId: deps.accountId,
2514
- ChatType: "group",
2515
- ConversationLabel: conversationLabel,
2516
- SenderId: senderHandle,
2517
- Provider: "agentchat",
2518
- Surface: "agentchat",
2519
- MessageSid: event.messageId,
2520
- MessageSidFull: event.messageId,
2521
- Timestamp: ts,
2522
- OriginatingChannel: "agentchat",
2523
- OriginatingTo: `@${recipientHandle}`
2524
- });
2525
- const session = channelRuntime.session;
2526
- await recordInboundSessionAndDispatchReply({
2527
- cfg: deps.gatewayCfg,
2528
- channel: "agentchat",
2529
- accountId: deps.accountId,
2530
- agentId: route.agentId,
2531
- routeSessionKey: route.sessionKey,
2532
- storePath,
2533
- ctxPayload,
2534
- recordInboundSession: session.recordInboundSession,
2535
- dispatchReplyWithBufferedBlockDispatcher: channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
2536
- deliver: deliver2,
3089
+ },
3090
+ replyOptions: { sourceReplyDeliveryMode: resolveSourceReplyMode() },
3091
+ record: {
2537
3092
  onRecordError: (err3) => {
2538
3093
  deps.logger.error(
2539
3094
  { err: err3 instanceof Error ? err3.message : String(err3), messageId: event.messageId },
2540
- "recordInboundSession failed (group)"
2541
- );
2542
- },
2543
- onDispatchError: (err3, info) => {
2544
- deps.logger.error(
2545
- {
2546
- err: err3 instanceof Error ? err3.message : String(err3),
2547
- messageId: event.messageId,
2548
- kind: info.kind
2549
- },
2550
- "inbound dispatch failed (group)"
3095
+ "recordInboundSession failed"
2551
3096
  );
2552
3097
  }
2553
- });
2554
- }
3098
+ }
3099
+ });
2555
3100
  } catch (err3) {
2556
3101
  deps.logger.error(
2557
- {
2558
- err: err3 instanceof Error ? err3.message : String(err3),
2559
- messageId: event.messageId
2560
- },
3102
+ { err: err3 instanceof Error ? err3.message : String(err3), messageId: event.messageId },
2561
3103
  "inbound dispatch failed"
2562
3104
  );
2563
3105
  }
2564
3106
  }
3107
+ function formatSenderLine(event) {
3108
+ const who = event.senderDisplayName ? `${event.senderDisplayName} (@${event.sender})` : `@${event.sender}`;
3109
+ return `From: ${event.senderKind === "system" ? `${who}, a system agent` : who}`;
3110
+ }
3111
+ async function runReplyGate(params) {
3112
+ const { deps, event, body, agentId, selfHandle, senderHandle, nowMs } = params;
3113
+ const ownHandle = selfHandle ?? "";
3114
+ let rawMessages = [];
3115
+ try {
3116
+ const client = getClient({ accountId: deps.accountId, config: deps.config });
3117
+ const fetched = await client.getMessages(event.conversationId, { limit: GATE_HISTORY_LIMIT });
3118
+ if (Array.isArray(fetched)) rawMessages = fetched;
3119
+ } catch (err3) {
3120
+ deps.logger.warn(
3121
+ { err: err3 instanceof Error ? err3.message : String(err3), conversationId: event.conversationId },
3122
+ "reply gate: history fetch failed \u2014 deciding on the new message alone"
3123
+ );
3124
+ }
3125
+ const bareHandle = ownHandle.replace(/^@/, "");
3126
+ const gateEvent = {
3127
+ conversationKind: event.conversationKind,
3128
+ senderHandle,
3129
+ contentText: body,
3130
+ groupName: event.groupName,
3131
+ memberCount: event.memberCount,
3132
+ mentions: event.mentions
3133
+ };
3134
+ const history = translateHistory(rawMessages, ownHandle, event.conversationKind, event.messageId);
3135
+ const decision = await decideReply({
3136
+ cfg: deps.gatewayCfg,
3137
+ agentId,
3138
+ handle: bareHandle,
3139
+ event: gateEvent,
3140
+ history,
3141
+ rawMessages,
3142
+ triggerMessageId: event.messageId,
3143
+ ownHandle,
3144
+ nowMs,
3145
+ failOpen: gateFailOpen(),
3146
+ timeoutMs: gateTimeoutMs(),
3147
+ caller: deps.gateCaller
3148
+ });
3149
+ const signals = computeConversationSignals(rawMessages, {
3150
+ ownHandle,
3151
+ triggerMessageId: event.messageId,
3152
+ nowMs
3153
+ });
3154
+ const context = formatConversationContext({
3155
+ handle: bareHandle,
3156
+ event: gateEvent,
3157
+ signals,
3158
+ priorCount: history.length
3159
+ });
3160
+ return { decision, context };
3161
+ }
3162
+ function translateHistory(messages, ownHandle, conversationKind, triggerMessageId) {
3163
+ const own = ownHandle.replace(/^@/, "").toLowerCase();
3164
+ const isGroup = conversationKind === "group";
3165
+ const sorted = [...messages].filter((m) => Boolean(m) && typeof m === "object").sort((a, b) => readSeq(a) - readSeq(b));
3166
+ const out = [];
3167
+ for (const m of sorted) {
3168
+ if (m.id === triggerMessageId) continue;
3169
+ const type = typeof m.type === "string" ? m.type : "text";
3170
+ if (type !== "text") continue;
3171
+ const content = m.content;
3172
+ const text = content && typeof content === "object" ? content.text : void 0;
3173
+ if (typeof text !== "string" || !text) continue;
3174
+ const senderRaw = String(m.sender ?? m.from ?? m.sender_handle ?? "");
3175
+ const isOwn = typeof m.is_own === "boolean" ? m.is_own : senderRaw.replace(/^@/, "").toLowerCase() === own;
3176
+ if (isOwn) {
3177
+ out.push({ role: "assistant", content: text });
3178
+ } else if (isGroup) {
3179
+ out.push({ role: "user", content: `[@${senderRaw.replace(/^@/, "") || "?"}] ${text}` });
3180
+ } else {
3181
+ out.push({ role: "user", content: text });
3182
+ }
3183
+ }
3184
+ return out;
3185
+ }
3186
+ function readSeq(m) {
3187
+ const seq = m.seq;
3188
+ return typeof seq === "number" ? seq : 0;
3189
+ }
3190
+ var OFF_TOKENS = /* @__PURE__ */ new Set(["0", "false", "off", "no"]);
3191
+ var ON_TOKENS = /* @__PURE__ */ new Set(["1", "true", "on", "yes"]);
3192
+ function gateEnabled() {
3193
+ return !OFF_TOKENS.has((process.env.AGENTCHAT_REPLY_GATE_ENABLED ?? "").trim().toLowerCase());
3194
+ }
3195
+ function gateFailOpen() {
3196
+ return ON_TOKENS.has((process.env.AGENTCHAT_REPLY_GATE_FAIL_OPEN ?? "").trim().toLowerCase());
3197
+ }
3198
+ function gateTimeoutMs() {
3199
+ const raw = process.env.AGENTCHAT_REPLY_GATE_TIMEOUT_MS;
3200
+ if (raw === void 0 || raw.trim() === "") return void 0;
3201
+ const n = Number(raw);
3202
+ return Number.isFinite(n) && n > 0 ? n : void 0;
3203
+ }
3204
+ function resolveSourceReplyMode() {
3205
+ return (process.env.AGENTCHAT_SOURCE_REPLY_MODE ?? "").trim().toLowerCase() === "message_tool_only" ? "message_tool_only" : "automatic";
3206
+ }
2565
3207
  function handleGroupInvite(deps, event) {
2566
3208
  deps.logger.info(
2567
3209
  {
@@ -2709,27 +3351,6 @@ var agentchatGatewayAdapter = {
2709
3351
  });
2710
3352
  }
2711
3353
  };
2712
- var cache = /* @__PURE__ */ new Map();
2713
- function getClient({ accountId, config, options }) {
2714
- const existing = cache.get(accountId);
2715
- if (existing && existing.apiKey === config.apiKey && existing.apiBase === config.apiBase) {
2716
- return existing.client;
2717
- }
2718
- const client = new AgentChatClient({
2719
- apiKey: config.apiKey,
2720
- baseUrl: config.apiBase,
2721
- ...options
2722
- });
2723
- cache.set(accountId, {
2724
- client,
2725
- apiKey: config.apiKey,
2726
- apiBase: config.apiBase
2727
- });
2728
- return client;
2729
- }
2730
- function disposeClient(accountId) {
2731
- cache.delete(accountId);
2732
- }
2733
3354
 
2734
3355
  // src/binding/outbound.ts
2735
3356
  function resolveConfig(cfg, accountId) {
@@ -2791,6 +3412,7 @@ async function deliver(ctx, attachmentId) {
2791
3412
  attachmentId
2792
3413
  );
2793
3414
  const result = await runtime.sendMessage(input);
3415
+ recordAgentSend(accountId, result.message.conversation_id);
2794
3416
  return {
2795
3417
  channel: AGENTCHAT_CHANNEL_ID,
2796
3418
  messageId: result.message.id,
@@ -2860,8 +3482,8 @@ async function uploadMediaFromUrl(ctx, mediaUrl) {
2860
3482
  let contentType;
2861
3483
  let filename = "attachment";
2862
3484
  if (mediaUrl.startsWith("file://") && ctx.mediaReadFile) {
2863
- const path = decodeURIComponent(mediaUrl.replace(/^file:\/\//, ""));
2864
- const buf = await ctx.mediaReadFile(path);
3485
+ const path3 = decodeURIComponent(mediaUrl.replace(/^file:\/\//, ""));
3486
+ const buf = await ctx.mediaReadFile(path3);
2865
3487
  if (buf.byteLength > MAX_MEDIA_BYTES) {
2866
3488
  throw new AgentChatChannelError(
2867
3489
  "terminal-user",
@@ -2871,7 +3493,7 @@ async function uploadMediaFromUrl(ctx, mediaUrl) {
2871
3493
  const copy = new Uint8Array(buf.byteLength);
2872
3494
  copy.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
2873
3495
  bytes = copy.buffer;
2874
- filename = path.split(/[\\/]/).pop() ?? filename;
3496
+ filename = path3.split(/[\\/]/).pop() ?? filename;
2875
3497
  } else {
2876
3498
  assertMediaUrlSafe(mediaUrl);
2877
3499
  const controller = new AbortController();
@@ -3026,8 +3648,8 @@ function err(message) {
3026
3648
  details: { error: message }
3027
3649
  };
3028
3650
  }
3029
- function str(params, key) {
3030
- const v = params[key];
3651
+ function str(params, key2) {
3652
+ const v = params[key2];
3031
3653
  return typeof v === "string" ? v : void 0;
3032
3654
  }
3033
3655
  function resolveConfig2(ctx) {
@@ -3284,6 +3906,7 @@ var agentchatAgentToolsFactory = ({ cfg }) => {
3284
3906
  content: { text: p.message },
3285
3907
  ...p.replyToMessageId ? { metadata: { reply_to: p.replyToMessageId } } : {}
3286
3908
  });
3909
+ recordAgentSend(r.accountId, result.message.conversation_id);
3287
3910
  const summaryParts = [
3288
3911
  `sent to @${handle} \u2014 message ${result.message.id} in ${result.message.conversation_id}`
3289
3912
  ];
@@ -3806,6 +4429,88 @@ ${lines.join("\n")}`);
3806
4429
  }
3807
4430
  }
3808
4431
  }),
4432
+ tool({
4433
+ name: "agentchat_close_local_thread",
4434
+ description: "Locally close a conversation thread for this OpenClaw AgentChat account. Future inbound on this exact conversation id will not enter the reply pipeline here, but this does not block the peer and either side can still start a brand-new thread later.",
4435
+ parameters: Type.Object({
4436
+ conversationId: Type.String(),
4437
+ reason: Type.Optional(Type.String({ maxLength: 200 })),
4438
+ account: ACCOUNT_PARAM
4439
+ }),
4440
+ execute: async (_id, p) => {
4441
+ const r = clientFor(cfg, p.account);
4442
+ if ("error" in r) return err2(r.error);
4443
+ const closures = getThreadClosures(cfg, r.accountId);
4444
+ const record = closures.close(p.conversationId, p.reason);
4445
+ return {
4446
+ content: [
4447
+ {
4448
+ type: "text",
4449
+ text: `closed ${record.conversationId} locally`
4450
+ }
4451
+ ],
4452
+ details: {
4453
+ conversationId: record.conversationId,
4454
+ closedAt: record.closedAt,
4455
+ reason: record.reason,
4456
+ localOnly: true
4457
+ }
4458
+ };
4459
+ }
4460
+ }),
4461
+ tool({
4462
+ name: "agentchat_reopen_local_thread",
4463
+ description: "Re-open a previously locally closed conversation thread so new inbound on that conversation id can reach the reply pipeline again.",
4464
+ parameters: Type.Object({
4465
+ conversationId: Type.String(),
4466
+ account: ACCOUNT_PARAM
4467
+ }),
4468
+ execute: async (_id, p) => {
4469
+ const r = clientFor(cfg, p.account);
4470
+ if ("error" in r) return err2(r.error);
4471
+ const closures = getThreadClosures(cfg, r.accountId);
4472
+ const reopened = closures.reopen(p.conversationId);
4473
+ return {
4474
+ content: [
4475
+ {
4476
+ type: "text",
4477
+ text: reopened ? `re-opened ${p.conversationId} locally` : `${p.conversationId} was not locally closed`
4478
+ }
4479
+ ],
4480
+ details: {
4481
+ conversationId: p.conversationId,
4482
+ reopened,
4483
+ localOnly: true
4484
+ }
4485
+ };
4486
+ }
4487
+ }),
4488
+ tool({
4489
+ name: "agentchat_list_local_closed_threads",
4490
+ description: "List conversation threads currently closed locally for this OpenClaw AgentChat account. These are client-side only, not server-side blocks or mutes.",
4491
+ parameters: Type.Object({
4492
+ account: ACCOUNT_PARAM
4493
+ }),
4494
+ execute: async (_id, p) => {
4495
+ const r = clientFor(cfg, p.account);
4496
+ if ("error" in r) return err2(r.error);
4497
+ const closures = getThreadClosures(cfg, r.accountId);
4498
+ const closed = closures.list();
4499
+ if (closed.length === 0) {
4500
+ return {
4501
+ content: [{ type: "text", text: "no locally closed threads" }],
4502
+ details: { closedThreads: [], localOnly: true }
4503
+ };
4504
+ }
4505
+ const lines = closed.map(
4506
+ (record) => `${record.conversationId} \u2014 ${record.closedAt}${record.reason ? ` \u2014 ${record.reason}` : ""}`
4507
+ );
4508
+ return {
4509
+ content: [{ type: "text", text: lines.join("\n") }],
4510
+ details: { closedThreads: closed, localOnly: true }
4511
+ };
4512
+ }
4513
+ }),
3809
4514
  tool({
3810
4515
  name: "agentchat_get_conversation_history",
3811
4516
  description: "Fetch recent messages from a specific conversation. Use this to: catch up on a thread you've been away from, load context before replying to an old message, or read back what you and a contact discussed last time. Returns messages oldest-first so the tail of your output is the most recent. Pass `beforeSeq` to paginate further back.",
@@ -4104,6 +4809,7 @@ ${lines.join("\n")}`);
4104
4809
  to: "chatfather",
4105
4810
  content: { text: p.message }
4106
4811
  });
4812
+ recordAgentSend(r.accountId, result.message.conversation_id);
4107
4813
  return ok2(
4108
4814
  `message sent to @chatfather (id: ${result.message.id}). Watch your inbox for the reply.`
4109
4815
  );
@@ -4572,8 +5278,8 @@ var agentchatSetupEntry = defineSetupPluginEntry(agentchatPlugin);
4572
5278
  // src/configured-state.ts
4573
5279
  function hasAgentChatConfiguredState(config) {
4574
5280
  if (!config || typeof config !== "object") return false;
4575
- const key = config.apiKey;
4576
- if (typeof key !== "string" || key.length < 20) return false;
5281
+ const key2 = config.apiKey;
5282
+ if (typeof key2 !== "string" || key2.length < 20) return false;
4577
5283
  const handle = config.agentHandle;
4578
5284
  if (typeof handle !== "string" || handle.trim().length === 0) return false;
4579
5285
  return true;