clawgram 2.8.1 → 2.10.0

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/channel.js CHANGED
@@ -58,6 +58,7 @@ const normalize_1 = require("./normalize");
58
58
  const history_1 = require("./history");
59
59
  const joins_1 = require("./joins");
60
60
  const reactions_1 = require("./reactions");
61
+ const silent_reaction_1 = require("./silent-reaction");
61
62
  const chat_info_1 = require("./chat-info");
62
63
  const secret_refs_1 = require("./secret-refs");
63
64
  const secret_ref_runtime_1 = require("openclaw/plugin-sdk/secret-ref-runtime");
@@ -67,6 +68,46 @@ const helpers_1 = require("./helpers");
67
68
  const proxy_config_1 = require("./proxy-config");
68
69
  const constants_1 = require("./constants");
69
70
  const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
71
+ /** Reads the configured reaction level for an account, tolerating a missing config. */
72
+ function readAccountReactionLevel(cfg, accountId) {
73
+ const resolvedAccountId = (0, helpers_1.resolveConfiguredAccountId)(cfg, accountId);
74
+ if (!resolvedAccountId) {
75
+ return undefined;
76
+ }
77
+ return cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionLevel;
78
+ }
79
+ /**
80
+ * Wires `reactToSilentMention` to this account's runtime, config and log.
81
+ *
82
+ * The decision itself lives in `silent-reaction.ts`, testable without a
83
+ * Telegram connection; everything here is lookup. Missing pieces — no
84
+ * connected client, no model access — resolve to no reaction rather than to
85
+ * an error, because by this point the agent has already declined to reply.
86
+ */
87
+ async function reactToSilentMentionForAccount(params) {
88
+ const gram = params.gram;
89
+ const llm = params.pluginRuntime?.llm;
90
+ if (!gram || typeof llm?.complete !== "function") {
91
+ return;
92
+ }
93
+ await (0, silent_reaction_1.reactToSilentMention)({
94
+ appetite: (0, reactions_1.resolveAgentReactionGuidance)(readAccountReactionLevel(params.cfg, params.accountId)),
95
+ wasMentioned: params.wasMentioned,
96
+ chatId: params.chatId,
97
+ messageId: params.messageId,
98
+ messageText: params.messageText,
99
+ deps: {
100
+ // Bound rather than destructured: the SDK may implement this as a
101
+ // method that needs its receiver.
102
+ complete: (args) => llm.complete(args),
103
+ sendReaction: (args) => gram.sendReaction(args),
104
+ onDecision: (info) => actionLog.info("clawgram silent-mention reaction", {
105
+ accountId: params.accountId,
106
+ ...info,
107
+ }),
108
+ },
109
+ });
110
+ }
70
111
  /**
71
112
  * Read scope as configured for the account. Left `undefined` when the key is
72
113
  * absent so `isChatReadable` can tell "not configured" from "configured empty" —
@@ -233,31 +274,16 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
233
274
  },
234
275
  capabilities: CHANNEL_CAPABILITIES,
235
276
  agentPrompt: {
236
- // Core injects its `## Reactions` section only when this returns a
237
- // level. Without it the prompt never mentions reactions, and the agent
238
- // treats the `react` action as one more entry in a 106-property schema:
239
- // she used it when asked outright in a DM and never once on her own.
240
- reactionGuidance: ({ cfg, accountId }) => {
241
- const resolvedAccountId = (0, helpers_1.resolveConfiguredAccountId)(cfg, accountId);
242
- const account = resolvedAccountId
243
- ? cfg?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[resolvedAccountId]
244
- : undefined;
245
- const level = (0, reactions_1.resolveAgentReactionGuidance)(account?.reactionLevel);
246
- // Logged because "the hook returns the right thing" and "the prompt
247
- // gained a Reactions section" turned out to be different questions:
248
- // on 2.8.0 the first was verifiable by hand on the server while the
249
- // prompt stayed byte-identical. Without this line there is no way to
250
- // tell a hook core never calls from a hook that answers undefined.
251
- actionLog.info("clawgram reactionGuidance", {
252
- accountId: resolvedAccountId ?? null,
253
- requestedAccountId: accountId ?? null,
254
- configuredLevel: typeof account?.reactionLevel === "string" ? account.reactionLevel : null,
255
- level: level ?? null,
256
- });
257
- // "clawgram" is our plugin id; the section reads "Reactions are
258
- // enabled for <label>", and the label is the platform people see.
259
- return level ? { level, channelLabel: "Telegram" } : undefined;
260
- },
277
+ // Nothing here steers reactions, and that is deliberate. 2.8.0 added a
278
+ // `reactionGuidance` hook and 2.9.0 moved the same text onto these
279
+ // hints; instrumentation then showed both hooks logging zero
280
+ // invocations across live turns while the assembled prompt stayed
281
+ // byte-identical at 44 266 chars. Core resolves the channel for prompt
282
+ // assembly from `params.messageChannel ?? params.messageProvider`,
283
+ // which is empty on this path, so nothing this channel contributes to
284
+ // the prompt reaches the agent at all. Reactions are decided in code
285
+ // instead — see `reactToSilentMention`. Do not re-add prompt text here
286
+ // expecting it to arrive.
261
287
  messageToolHints: () => [
262
288
  "Use clawgram to send Telegram replies from the connected personal account.",
263
289
  "When replying in the current Telegram chat, omit `to`/`target` and clawgram will send to the current conversation automatically.",
@@ -816,15 +842,53 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
816
842
  const visibleFallbackText = fallbackText
817
843
  ? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
818
844
  : "";
819
- if (fallbackText && !visibleFallbackText) {
820
- log?.info?.("clawgram skipping silent transcript fallback", {
845
+ if (!visibleFallbackText) {
846
+ if (fallbackText) {
847
+ log?.info?.("clawgram skipping silent transcript fallback", {
848
+ accountId,
849
+ chatId: normalized.chatId,
850
+ messageId: normalized.messageId,
851
+ routeSessionKey: route.sessionKey,
852
+ });
853
+ }
854
+ else {
855
+ log?.warn?.("clawgram transcript fallback unavailable", {
856
+ accountId,
857
+ chatId: normalized.chatId,
858
+ messageId: normalized.messageId,
859
+ routeSessionKey: route.sessionKey,
860
+ });
861
+ }
862
+ // Named, and nothing came back: leave a reaction so the
863
+ // decision is visible instead of reading as her ignoring
864
+ // people. The condition is her silence, not the shape of
865
+ // the transcript — a turn that wrote no entry at all is
866
+ // just as silent as one that wrote the NO_REPLY token.
867
+ //
868
+ // Never allowed to disturb the turn: the reply is already
869
+ // settled by this point, so a failure here stays silent.
870
+ await reactToSilentMentionForAccount({
871
+ cfg,
821
872
  accountId,
873
+ gram: runtimes.get(accountId),
874
+ pluginRuntime,
822
875
  chatId: normalized.chatId,
823
876
  messageId: normalized.messageId,
824
- routeSessionKey: route.sessionKey,
877
+ messageText: normalized.text,
878
+ // Same sense of "addressed" the agent was given for this
879
+ // turn on line 817: a reply to her own message counts as
880
+ // being spoken to, mention or not.
881
+ wasMentioned: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
882
+ }).catch((err) => {
883
+ log?.info?.("clawgram silent-mention reaction failed", {
884
+ accountId,
885
+ chatId: normalized.chatId,
886
+ messageId: normalized.messageId,
887
+ error: String(err),
888
+ });
825
889
  });
826
890
  }
827
- else if (visibleFallbackText) {
891
+ else {
828
892
  log?.warn?.("clawgram using transcript fallback reply", {
829
893
  accountId,
830
894
  chatId: normalized.chatId,
@@ -838,14 +902,6 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
838
902
  messageThreadId,
839
903
  });
840
904
  }
841
- else {
842
- log?.warn?.("clawgram transcript fallback unavailable", {
843
- accountId,
844
- chatId: normalized.chatId,
845
- messageId: normalized.messageId,
846
- routeSessionKey: route.sessionKey,
847
- });
848
- }
849
905
  }
850
906
  }, {
851
907
  readMessageId: Number(normalized.messageId),
package/dist/reactions.js CHANGED
@@ -14,16 +14,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.resolveAgentReactionGuidance = resolveAgentReactionGuidance;
15
15
  exports.parseReactionParams = parseReactionParams;
16
16
  /**
17
- * How chatty the agent may be with reactions, as core understands it.
17
+ * How freely the agent may react, from the account's `reactionLevel`.
18
18
  *
19
- * Core injects a `## Reactions` section into the system prompt only when a
20
- * channel returns a level from `agentPrompt.reactionGuidance`. Return nothing
21
- * and the prompt never mentions reactions at all which is what happened
22
- * here until 2.8.0: the `react` action existed, the agent had it, and no line
23
- * of the prompt suggested using it.
24
- *
25
- * Levels and fallbacks mirror the bundled Telegram channel so the two behave
26
- * the same for the same config:
19
+ * This started as an answer for core's `agentPrompt.reactionGuidance` hook,
20
+ * which core turned out never to call for this channel. The config key stays
21
+ * and keeps its meaning; it now steers `reactToSilentMention` instead of a
22
+ * paragraph of prompt text. Levels mirror the bundled Telegram channel so the
23
+ * same config reads the same way in both:
27
24
  *
28
25
  * - `off`, `ack` — no agent reactions (`ack` is the "seen it" emoji core sends
29
26
  * by itself, which is a different feature);
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /**
3
+ * Reacting when the agent decides to stay silent.
4
+ *
5
+ * The agent is told, in her own workspace rules, to leave a reaction when she
6
+ * is named but has nothing to say. She never did — not once across every turn
7
+ * in the logs — and the reason turned out not to be the wording: core builds
8
+ * her system prompt without knowing which channel it is for, so nothing this
9
+ * channel contributes to the prompt reaches her at all. `messageToolHints`
10
+ * and `reactionGuidance` both logged zero invocations while the assembled
11
+ * prompt stayed byte-identical.
12
+ *
13
+ * So the decision moves out of the prompt and into code, at the one moment
14
+ * that is unambiguous: she returned NO_REPLY on a message that named her.
15
+ * The emoji is still chosen by a model — the point was a reaction that fits
16
+ * the message, not a fixed acknowledgement stamp — but choosing it is now a
17
+ * separate, cheap call that cannot be forgotten mid-prompt.
18
+ *
19
+ * Parsing lives here, away from the network, so the awkward cases can be
20
+ * tested without Telegram or a model.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.buildEmojiSystemPrompt = buildEmojiSystemPrompt;
24
+ exports.parseEmojiChoice = parseEmojiChoice;
25
+ exports.shouldReactToSilentTurn = shouldReactToSilentTurn;
26
+ exports.reactToSilentMention = reactToSilentMention;
27
+ function buildEmojiSystemPrompt(appetite) {
28
+ const shared = [
29
+ "You pick a single emoji reaction for a chat message.",
30
+ "The assistant was mentioned in this message but decided it needs no written reply.",
31
+ "Answer with exactly one emoji and nothing else, or the word NONE if no reaction fits.",
32
+ "Match the mood of the message: a joke gets something amused, praise something warm,",
33
+ "bad news something sympathetic, an achievement something celebratory.",
34
+ "Answer NONE when the message is conflictual, heavy, or discusses a person's",
35
+ "performance — a reaction there reads as a verdict on someone.",
36
+ ];
37
+ return appetite === "minimal"
38
+ ? [
39
+ ...shared,
40
+ "Be sparing: answer NONE unless the message clearly invites a reaction.",
41
+ ].join("\n")
42
+ : [
43
+ ...shared,
44
+ "Be generous: react whenever a reaction would feel natural to a colleague.",
45
+ ].join("\n");
46
+ }
47
+ /**
48
+ * Turns a model answer into an emoji, or nothing.
49
+ *
50
+ * Deliberately strict. A wrong emoji is a visible act on someone else's
51
+ * message, and Telegram rejects emoji outside the chat's allowed set anyway —
52
+ * so anything that does not look like a bare emoji is treated as "no
53
+ * reaction" rather than sent hopefully.
54
+ */
55
+ function parseEmojiChoice(raw) {
56
+ if (typeof raw !== "string") {
57
+ return undefined;
58
+ }
59
+ // Models like to wrap answers in quotes, backticks or a trailing period.
60
+ const cleaned = raw.trim().replace(/^["'`]+|["'`.]+$/g, "").trim();
61
+ if (!cleaned || /^none$/i.test(cleaned)) {
62
+ return undefined;
63
+ }
64
+ // A sentence is a refusal or an explanation, not a reaction.
65
+ if (/\s/.test(cleaned) || cleaned.length > 8) {
66
+ return undefined;
67
+ }
68
+ // Latin letters and digits mean words like "NONE", "ok" or "1" slipped
69
+ // through; an emoji has none of them.
70
+ if (/[A-Za-z0-9]/.test(cleaned)) {
71
+ return undefined;
72
+ }
73
+ return cleaned;
74
+ }
75
+ /**
76
+ * Whether a silent turn deserves a reaction attempt at all.
77
+ *
78
+ * Only mentions: the rule the owner asked for is about being named and having
79
+ * nothing to add. A silent turn on a message that never mentioned her is
80
+ * ordinary background reading, and reacting to it would be noise.
81
+ */
82
+ function shouldReactToSilentTurn(params) {
83
+ if (!params.appetite || !params.wasMentioned) {
84
+ return false;
85
+ }
86
+ return Boolean(params.messageText?.trim());
87
+ }
88
+ /** The message text is truncated before it reaches the model; a mood needs no more. */
89
+ const MAX_JUDGED_CHARS = 2000;
90
+ /**
91
+ * Leaves an emoji on a message the agent was named in but chose not to answer.
92
+ *
93
+ * Best-effort by construction: the reply is already settled when this runs, so
94
+ * a missing model, a refusal, or an emoji Telegram will not take all end as
95
+ * silence — the same outcome as before the feature existed. Callers still wrap
96
+ * it, because a throw here would surface as a failed turn on a message the
97
+ * agent had already decided needed nothing.
98
+ *
99
+ * Returns the emoji it sent, for tests and for nothing else.
100
+ */
101
+ async function reactToSilentMention(params) {
102
+ const { appetite } = params;
103
+ if (!appetite || !shouldReactToSilentTurn({
104
+ wasMentioned: params.wasMentioned,
105
+ appetite,
106
+ messageText: params.messageText,
107
+ })) {
108
+ return undefined;
109
+ }
110
+ const messageId = Number(params.messageId);
111
+ if (!Number.isInteger(messageId) || messageId <= 0) {
112
+ return undefined;
113
+ }
114
+ const answer = await params.deps.complete({
115
+ messages: [{ role: "user", content: String(params.messageText ?? "").slice(0, MAX_JUDGED_CHARS) }],
116
+ systemPrompt: buildEmojiSystemPrompt(appetite),
117
+ maxTokens: 8,
118
+ purpose: "clawgram: emoji reaction for a silent mention",
119
+ });
120
+ const emoji = parseEmojiChoice(answer?.text);
121
+ params.deps.onDecision?.({ messageId, appetite, chose: emoji ? "emoji" : "none" });
122
+ if (!emoji) {
123
+ return undefined;
124
+ }
125
+ await params.deps.sendReaction({
126
+ target: params.chatId,
127
+ messageId,
128
+ emoji,
129
+ remove: false,
130
+ });
131
+ return emoji;
132
+ }
@@ -2,7 +2,7 @@
2
2
  "id": "clawgram",
3
3
  "name": "Clawgram",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
- "version": "2.8.1",
5
+ "version": "2.10.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.8.1",
3
+ "version": "2.10.0",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {