@chbo297/infoflow 2026.5.7 → 2026.5.9-beta.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/README.md CHANGED
@@ -43,22 +43,30 @@ BAIDU_NPM_REGISTRY=http://registry.npm.baidu-int.com bash scripts/deploy.sh
43
43
 
44
44
  方式 A:通过独立 tools 包安装并部署(推荐,支持 `update` 子命令)
45
45
 
46
+ <!-- sync:infoflow-plugin-version -->
46
47
  ```bash
47
48
  # 正式版(latest)
48
- npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.6 --registry https://registry.npmjs.org
49
+ npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.9-beta.1 --registry https://registry.npmjs.org
50
+ ```
51
+ <!-- /sync:infoflow-plugin-version -->
49
52
 
50
- # Beta 版(示例)
51
- npx -y @chbo297/infoflow-openclaw-tools@beta update --version 2026.5.7-beta.2 --registry https://registry.npmjs.org
53
+ ```bash
54
+ # Beta 版(示例,版本号请按实际预发包替换)
55
+ npx -y @chbo297/infoflow-openclaw-tools@beta update --version 2026.5.8-beta.1 --registry https://registry.npmjs.org
52
56
  ```
53
57
 
54
58
  方式 B:通过 OpenClaw 插件命令安装
55
59
 
60
+ <!-- sync:infoflow-plugin-version -->
56
61
  ```bash
57
62
  # 正式版
58
- openclaw plugins install @chbo297/infoflow@2026.5.6
63
+ openclaw plugins install @chbo297/infoflow@2026.5.9-beta.1
64
+ ```
65
+ <!-- /sync:infoflow-plugin-version -->
59
66
 
60
- # Beta 版(示例)
61
- openclaw plugins install @chbo297/infoflow@2026.5.7-beta.2
67
+ ```bash
68
+ # Beta 版(示例,版本号请按实际预发包替换)
69
+ openclaw plugins install @chbo297/infoflow@2026.5.8-beta.1
62
70
  ```
63
71
 
64
72
  安装后建议检查插件状态:
@@ -72,9 +80,11 @@ openclaw plugins inspect infoflow
72
80
 
73
81
  发布到 npm 后,可直接通过独立 tools 包执行安装/升级:
74
82
 
83
+ <!-- sync:infoflow-plugin-version -->
75
84
  ```bash
76
- npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.6 --registry https://registry.npmjs.org
85
+ npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.9-beta.1 --registry https://registry.npmjs.org
77
86
  ```
87
+ <!-- /sync:infoflow-plugin-version -->
78
88
 
79
89
  常用参数:
80
90
 
@@ -97,29 +107,34 @@ npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.6 --registry htt
97
107
 
98
108
  ### 版本升级、打 tag、推送与 npm 发布流程
99
109
 
100
- 每次发布新版本(例如 `2026.5.6`)建议按以下顺序执行:
110
+ 每次发布新版本时,先将 `package.json` 的 `version` 设为待发版本号,再按下述顺序执行。上文「首次安装 / npx 更新」与下文发版流程中,各 bash 代码块外侧有一对用于自动替换的 HTML 注释标记;发版前请执行 **`npm run sync-readme-install-version`**,脚本会按当前 `package.json` 的 `version` 更新这些块内的版本号,以免 README 与 npm 不一致。
101
111
 
112
+ <!-- sync:infoflow-plugin-version -->
102
113
  ```bash
103
114
  # 1) 修改版本号(会同步 package-lock.json)
104
- npm version 2026.5.6 --no-git-tag-version
115
+ npm version 2026.5.9-beta.1 --no-git-tag-version
116
+
117
+ # 2) 同步 README 中标记块内的推荐安装命令与下文中的 tag / commit 示例版本号
118
+ npm run sync-readme-install-version
105
119
 
106
- # 2) 发布前校验
120
+ # 3) 发布前校验
107
121
  npm run typecheck
108
122
  npm run test
109
123
  npm run build
110
124
 
111
- # 3) 提交版本变更
112
- git add package.json package-lock.json README.md scripts src
113
- git commit -m "2026.5.6"
125
+ # 4) 提交版本变更(含 README、CHANGELOG 等)
126
+ git add package.json package-lock.json README.md CHANGELOG.md scripts src
127
+ git commit -m "2026.5.9-beta.1"
114
128
 
115
- # 4) 打 tag 并推送代码与 tag
116
- git tag 2026.5.6
129
+ # 5) 打 tag 并推送代码与 tag
130
+ git tag 2026.5.9-beta.1
117
131
  git push origin main
118
- git push origin 2026.5.6
132
+ git push origin 2026.5.9-beta.1
119
133
 
120
- # 5) 发布 npm(可按需指定 registry)
134
+ # 6) 发布 npm(可按需指定 registry)
121
135
  npm publish
122
136
  # 或
123
137
  # npm publish --registry https://registry.npmjs.org
124
138
  ```
139
+ <!-- /sync:infoflow-plugin-version -->
125
140
 
@@ -6,6 +6,7 @@
6
6
  import { jsonResult, readStringParam } from "openclaw/plugin-sdk/core";
7
7
  import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
8
8
  import { resolveInfoflowAccount } from "./accounts.js";
9
+ import { lookupInboundContext } from "./inbound-context.js";
9
10
  import { logVerbose } from "./logging.js";
10
11
  import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
11
12
  import { sendInfoflowMessage, recallInfoflowGroupMessage, recallInfoflowPrivateMessage, } from "./send.js";
@@ -15,12 +16,52 @@ import { normalizeInfoflowTarget } from "./targets.js";
15
16
  const RECALL_OK_HINT = "Recall succeeded. output only NO_REPLY with no other text.";
16
17
  const RECALL_FAIL_HINT = "Recall failed. Send a brief reply stating only the failure reason.";
17
18
  const RECALL_PARTIAL_HINT = "Some recalls failed. Send a brief reply stating only the failure reason(s).";
19
+ /**
20
+ * Resolve the inbound replyToMessageId from the action ctx + inbound-context map.
21
+ * Returns the bot-sent messageId the user is quote-replying to (if any), so we
22
+ * can recover when the LLM accidentally passes the inbound user-message id as
23
+ * the delete target.
24
+ */
25
+ function resolveInboundReplyToMessageId(params) {
26
+ const currentMessageId = params.currentMessageId != null ? String(params.currentMessageId) : undefined;
27
+ if (!currentMessageId)
28
+ return undefined;
29
+ const ctx = lookupInboundContext(currentMessageId);
30
+ if (!ctx)
31
+ return undefined;
32
+ // Scope match: same account + target (avoid using a stale context from another chat).
33
+ if (ctx.accountId !== params.accountId)
34
+ return undefined;
35
+ if (ctx.target !== params.target)
36
+ return undefined;
37
+ return ctx.replyToMessageId;
38
+ }
39
+ /** Format up to N recent sent messages for an error-path hint to the LLM. */
40
+ function formatRecentCandidatesForError(records, limit = 5) {
41
+ if (!records || !Array.isArray(records) || records.length === 0)
42
+ return "";
43
+ const lines = records.slice(0, limit).map((r) => {
44
+ const previewText = r.digest || "(no preview)";
45
+ return `messageId=${r.messageid} preview="${previewText}"`;
46
+ });
47
+ return lines.join("; ");
48
+ }
49
+ /** Safe candidate lookup that never throws (errors → empty string). */
50
+ function safeRecentCandidates(accountId, target) {
51
+ try {
52
+ const records = querySentMessages(accountId, { target, count: 5 });
53
+ return formatRecentCandidatesForError(records);
54
+ }
55
+ catch {
56
+ return "";
57
+ }
58
+ }
18
59
  export const infoflowMessageActions = {
19
60
  describeMessageTool: () => ({
20
61
  actions: ["send", "delete"],
21
62
  }),
22
63
  extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
23
- handleAction: async ({ action, params, cfg, accountId }) => {
64
+ handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
24
65
  // -----------------------------------------------------------------------
25
66
  // delete (群消息撤回) — Mode A: by messageId, Mode B: by count
26
67
  // -----------------------------------------------------------------------
@@ -46,25 +87,49 @@ export const infoflowMessageActions = {
46
87
  const groupId = Number(groupMatch[1]);
47
88
  // Mode A: single message recall by messageId
48
89
  if (messageId) {
90
+ // Resolve msgseqid (group recall requires it). If the LLM-passed messageId
91
+ // is unknown to the store, fall back to the inbound replyToMessageId — the
92
+ // common failure mode is the LLM passing the inbound user-message id as
93
+ // the delete target instead of the bot-message id it's quote-replying to.
94
+ let effectiveMessageId = messageId;
49
95
  let msgseqid = readStringParam(params, "msgseqid") ?? "";
50
- if (!msgseqid) {
51
- const stored = findSentMessage(account.accountId, messageId);
52
- if (stored?.msgseqid) {
53
- msgseqid = stored.msgseqid;
96
+ let stored = findSentMessage(account.accountId, effectiveMessageId);
97
+ if (!stored && !msgseqid) {
98
+ const fallbackId = resolveInboundReplyToMessageId({
99
+ accountId: account.accountId,
100
+ target: `group:${groupId}`,
101
+ currentMessageId: toolContext?.currentMessageId,
102
+ });
103
+ if (fallbackId && fallbackId !== effectiveMessageId) {
104
+ const fallbackStored = findSentMessage(account.accountId, fallbackId);
105
+ if (fallbackStored) {
106
+ logVerbose(`[infoflow:delete] LLM passed unknown messageId=${effectiveMessageId}, falling back to replyToMessageId=${fallbackId}`);
107
+ effectiveMessageId = fallbackId;
108
+ stored = fallbackStored;
109
+ }
54
110
  }
55
111
  }
112
+ if (!msgseqid && stored?.msgseqid) {
113
+ msgseqid = stored.msgseqid;
114
+ }
56
115
  if (!msgseqid) {
57
- throw new Error("delete requires msgseqid (not found in store; provide it explicitly or send messages first).");
116
+ const candidates = safeRecentCandidates(account.accountId, `group:${groupId}`);
117
+ logVerbose(`[infoflow:delete] unknown messageId=${effectiveMessageId}, no fallback available, returning candidates to LLM`);
118
+ throw new Error(`delete: messageId=${effectiveMessageId} is not a known bot-sent message in this chat (msgseqid not found in store). ` +
119
+ `It looks like you may have passed the inbound (user) message id instead of the bot's. ` +
120
+ (candidates
121
+ ? `Recent bot-sent messages here: ${candidates}. Pick the right messageId and retry.`
122
+ : `No recent bot-sent messages on file for this chat. Aborting to avoid wrong recall.`));
58
123
  }
59
124
  const result = await recallInfoflowGroupMessage({
60
125
  account,
61
126
  groupId,
62
- messageid: messageId,
127
+ messageid: effectiveMessageId,
63
128
  msgseqid,
64
129
  });
65
130
  if (result.ok) {
66
131
  try {
67
- removeRecalledMessages(account.accountId, [messageId]);
132
+ removeRecalledMessages(account.accountId, [effectiveMessageId]);
68
133
  }
69
134
  catch {
70
135
  // ignore cleanup errors
@@ -74,6 +139,7 @@ export const infoflowMessageActions = {
74
139
  ok: result.ok,
75
140
  channel: "infoflow",
76
141
  to,
142
+ messageId: effectiveMessageId,
77
143
  ...(result.error ? { error: result.error } : {}),
78
144
  _hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
79
145
  });
@@ -157,14 +223,37 @@ export const infoflowMessageActions = {
157
223
  }
158
224
  // Mode A: single message recall by messageId (msgkey)
159
225
  if (messageId) {
226
+ // Attempt the inbound-context fallback when the LLM-passed messageId is
227
+ // unknown to the store. If we can swap it for a verified bot-message id
228
+ // from the inbound replyTo, do so. Otherwise PRESERVE the original
229
+ // permissive behavior (pass the LLM id straight to the API and let
230
+ // Infoflow's backend judge) — the store may legitimately not contain
231
+ // every recallable DM message (e.g., after the 7-day retention sweep
232
+ // or for messages sent before this plugin started recording).
233
+ let effectiveMessageId = messageId;
234
+ const stored = findSentMessage(account.accountId, effectiveMessageId);
235
+ if (!stored) {
236
+ const fallbackId = resolveInboundReplyToMessageId({
237
+ accountId: account.accountId,
238
+ target,
239
+ currentMessageId: toolContext?.currentMessageId,
240
+ });
241
+ if (fallbackId && fallbackId !== effectiveMessageId) {
242
+ const fallbackStored = findSentMessage(account.accountId, fallbackId);
243
+ if (fallbackStored) {
244
+ logVerbose(`[infoflow:delete] LLM passed unknown messageId=${effectiveMessageId}, falling back to replyToMessageId=${fallbackId}`);
245
+ effectiveMessageId = fallbackId;
246
+ }
247
+ }
248
+ }
160
249
  const result = await recallInfoflowPrivateMessage({
161
250
  account,
162
- msgkey: messageId,
251
+ msgkey: effectiveMessageId,
163
252
  appAgentId,
164
253
  });
165
254
  if (result.ok) {
166
255
  try {
167
- removeRecalledMessages(account.accountId, [messageId]);
256
+ removeRecalledMessages(account.accountId, [effectiveMessageId]);
168
257
  }
169
258
  catch {
170
259
  // ignore cleanup errors
@@ -174,6 +263,7 @@ export const infoflowMessageActions = {
174
263
  ok: result.ok,
175
264
  channel: "infoflow",
176
265
  to,
266
+ messageId: effectiveMessageId,
177
267
  ...(result.error ? { error: result.error } : {}),
178
268
  _hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
179
269
  });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * LLM-callable tools exposed by the Infoflow channel plugin.
3
+ *
4
+ * Currently exposes: `infoflow_list_sent_messages` — lets the LLM look up
5
+ * bot-sent messages by target / time window / content substring when the
6
+ * push-injected recent list (in bot.ts) isn't enough (e.g., older messages,
7
+ * search-by-content).
8
+ */
9
+ import { jsonResult } from "openclaw/plugin-sdk/core";
10
+ import { Type } from "typebox";
11
+ import { resolveDefaultInfoflowAccountId } from "./accounts.js";
12
+ import { logVerbose } from "./logging.js";
13
+ import { querySentMessages } from "./sent-message-store.js";
14
+ const listSentMessagesSchema = Type.Object({
15
+ target: Type.String({
16
+ description: "Chat target to query. Format: 'group:<groupId>' for groups or '<username>' for private chats. " +
17
+ "MUST be the current chat target — do not query other chats.",
18
+ }),
19
+ count: Type.Optional(Type.Integer({
20
+ minimum: 1,
21
+ maximum: 50,
22
+ default: 20,
23
+ description: "Maximum number of messages to return, newest first.",
24
+ })),
25
+ withinHours: Type.Optional(Type.Integer({
26
+ minimum: 1,
27
+ maximum: 168,
28
+ description: "Only include messages sent within the last N hours (1-168, i.e. up to 7 days, which matches the store's retention). Omit for no time filter.",
29
+ })),
30
+ containsText: Type.Optional(Type.String({
31
+ description: "Case-insensitive substring filter against the message digest (first ~100 chars of body). Use this to find a message by content, e.g. containsText='会议改到3点'.",
32
+ })),
33
+ accountId: Type.Optional(Type.String({
34
+ description: "Account id to query against (only needed when multiple Infoflow accounts are configured). Defaults to the configured default account.",
35
+ })),
36
+ });
37
+ const TOOL_DESCRIPTION = [
38
+ "List messages the bot previously sent to a given Infoflow chat, with optional time-window and content-substring filters.",
39
+ "Use this BEFORE action='delete' when:",
40
+ " (a) the message you need to recall is older than the recent list already injected into the message body, or",
41
+ " (b) you need to find a bot-sent message by its content (e.g. 'the joke about programmers', '会议通知').",
42
+ "Returns: target, count, and an array of { messageId, sentAt, ageMinutes, preview }.",
43
+ "Feed the chosen messageId back into action='delete' to recall it.",
44
+ ].join("\n");
45
+ export function createListSentMessagesTool(deps) {
46
+ return {
47
+ name: "infoflow_list_sent_messages",
48
+ label: "infoflow_list_sent_messages",
49
+ description: TOOL_DESCRIPTION,
50
+ parameters: listSentMessagesSchema,
51
+ execute: async (_toolCallId, rawParams) => {
52
+ const p = (rawParams ?? {});
53
+ if (typeof p.target !== "string" || !p.target.trim()) {
54
+ throw new Error("infoflow_list_sent_messages: 'target' is required.");
55
+ }
56
+ const target = p.target.trim();
57
+ const limit = Math.min(Math.max(p.count ?? 20, 1), 50);
58
+ const hasContains = typeof p.containsText === "string" && p.containsText.trim().length > 0;
59
+ const needle = hasContains ? p.containsText.trim().toLowerCase() : undefined;
60
+ let accountId = p.accountId?.trim();
61
+ if (!accountId) {
62
+ try {
63
+ accountId = resolveDefaultInfoflowAccountId(deps.getConfig());
64
+ }
65
+ catch {
66
+ accountId = undefined;
67
+ }
68
+ }
69
+ if (!accountId) {
70
+ throw new Error("infoflow_list_sent_messages: cannot resolve account id. Pass accountId explicitly or configure a default account.");
71
+ }
72
+ // Over-fetch when filtering by content so the post-filter slice still has up to `limit` rows.
73
+ const fetchCount = needle ? Math.max(limit * 4, 50) : limit;
74
+ let records;
75
+ try {
76
+ records = querySentMessages(accountId, { target, count: fetchCount });
77
+ }
78
+ catch (err) {
79
+ throw new Error(`infoflow_list_sent_messages: store query failed: ${err?.message ?? String(err)}`);
80
+ }
81
+ if (p.withinHours) {
82
+ const cutoff = Date.now() - p.withinHours * 60 * 60 * 1000;
83
+ records = records.filter((r) => r.sentAt >= cutoff);
84
+ }
85
+ if (needle) {
86
+ records = records.filter((r) => (r.digest ?? "").toLowerCase().includes(needle));
87
+ }
88
+ const sliced = records.slice(0, limit);
89
+ logVerbose(`[infoflow:tool:list_sent_messages] target=${target} accountId=${accountId} count=${sliced.length} (limit=${limit}, withinHours=${p.withinHours ?? "none"}, containsText=${hasContains ? "yes" : "no"})`);
90
+ return jsonResult({
91
+ target,
92
+ count: sliced.length,
93
+ messages: sliced.map((r) => ({
94
+ messageId: r.messageid,
95
+ sentAt: new Date(r.sentAt).toISOString(),
96
+ ageMinutes: Math.max(0, Math.round((Date.now() - r.sentAt) / 60000)),
97
+ preview: r.digest || "",
98
+ })),
99
+ });
100
+ },
101
+ };
102
+ }
package/dist/src/bot.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { buildAgentMediaPayload, getAgentScopedMediaLocalRoots, } from "openclaw/plugin-sdk/agent-media-payload";
2
2
  import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, } from "openclaw/plugin-sdk/reply-history";
3
3
  import { resolveInfoflowAccount } from "./accounts.js";
4
+ import { registerInboundContext } from "./inbound-context.js";
4
5
  import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "./logging.js";
5
6
  import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
6
7
  import { getInfoflowRuntime } from "./runtime.js";
7
- import { findSentMessage } from "./sent-message-store.js";
8
+ import { findSentMessage, querySentMessages } from "./sent-message-store.js";
8
9
  /**
9
10
  * Check if the bot was @mentioned in the message body.
10
11
  * Matches appAgentId, robotName, or robotId against AT items (same order as Baidu reference plugin).
@@ -183,14 +184,18 @@ function resolveFollowUpOtherMentioned(params) {
183
184
  logVerbose(`[infoflow:bot] skip dispatch: from=${fromuser}, group=${groupId}, reason=followUp-other-mentioned (record only, no LLM)`);
184
185
  return "record_only";
185
186
  }
186
- // ---------------------------------------------------------------------------
187
- // Reply-to-bot detection (引用回复机器人消息)
188
- // ---------------------------------------------------------------------------
189
187
  /**
190
- * Check if the message is a reply (引用回复) to one of the bot's own messages.
191
- * Looks up replyData body items' messageid against the sent-message-store.
188
+ * Resolve all replyData targets from inbound body items, including each target's
189
+ * messageid + body preview + isBotMessage (via sent-message-store lookup).
190
+ *
191
+ * This is the structured form behind `checkReplyToBot`: callers who only want a
192
+ * boolean can compute it as `targets.some((t) => t.isBotMessage)`. Returning the
193
+ * full list lets us surface the messageid to the LLM and resolve the right
194
+ * recall target — the previous bool-only API was the root cause of the LLM
195
+ * passing the inbound (user) messageId to action=delete.
192
196
  */
193
- function checkReplyToBot(bodyItems, accountId) {
197
+ function resolveReplyTargets(bodyItems, accountId) {
198
+ const out = [];
194
199
  for (const item of bodyItems) {
195
200
  if (item.type !== "replyData")
196
201
  continue;
@@ -200,16 +205,91 @@ function checkReplyToBot(bodyItems, accountId) {
200
205
  const msgIdStr = String(msgId);
201
206
  if (!msgIdStr)
202
207
  continue;
208
+ let isBotMessage = false;
203
209
  try {
204
- const found = findSentMessage(accountId, msgIdStr);
205
- if (found)
206
- return true;
210
+ isBotMessage = Boolean(findSentMessage(accountId, msgIdStr));
207
211
  }
208
212
  catch {
209
213
  // DB lookup failure should not block message processing
210
214
  }
215
+ out.push({
216
+ messageid: msgIdStr,
217
+ preview: (item.content ?? "").trim(),
218
+ isBotMessage,
219
+ });
211
220
  }
212
- return false;
221
+ return out;
222
+ }
223
+ // ---------------------------------------------------------------------------
224
+ // Sent-message context injection (push) — solves both the "AI uses inbound id
225
+ // as delete target" bug and the "DM-triggered cross-context send is invisible
226
+ // in the target group" bug. sent-messages.db tracks all bot-sent messages
227
+ // keyed by target, so a single push surfaces messages sent from any session.
228
+ // ---------------------------------------------------------------------------
229
+ /** Detect inbound text that semantically asks the bot to recall/delete messages. */
230
+ const RECALL_INTENT_REGEX = /(撤回|收回|删[掉了除]|取消|清除|recall|unsend|undo\s*send|delete\s+(?:that|those|the\s+(?:last|previous(?:\s+\d+)?)))/i;
231
+ function looksLikeRecallIntent(text) {
232
+ if (!text)
233
+ return false;
234
+ return RECALL_INTENT_REGEX.test(text);
235
+ }
236
+ const RECENT_BOT_AMBIENT_WINDOW_MS = 24 * 60 * 60 * 1000;
237
+ const RECENT_BOT_AMBIENT_COUNT = 5;
238
+ const RECENT_BOT_DETAIL_COUNT = 10;
239
+ /**
240
+ * Build a system-style section listing the bot's recent messages to this chat.
241
+ * Two modes — ambient (compact, awareness only) and detail (longer, with
242
+ * explicit instruction for recall). Returns undefined when there's nothing
243
+ * recent to report so unrelated chats stay token-cheap.
244
+ */
245
+ function buildBotRecentMessagesSection(params) {
246
+ const detail = params.inboundLooksLikeRecall || params.isReplyToBot;
247
+ const count = detail ? RECENT_BOT_DETAIL_COUNT : RECENT_BOT_AMBIENT_COUNT;
248
+ let records;
249
+ try {
250
+ records = querySentMessages(params.accountId, { target: params.target, count });
251
+ }
252
+ catch {
253
+ return undefined;
254
+ }
255
+ if (records.length === 0)
256
+ return undefined;
257
+ const cutoff = Date.now() - RECENT_BOT_AMBIENT_WINDOW_MS;
258
+ const recent = records.filter((r) => r.sentAt >= cutoff);
259
+ if (recent.length === 0)
260
+ return undefined;
261
+ const header = detail
262
+ ? "[System: Recent messages you (the bot) sent to this chat (newest first). Use these messageIds when recalling/deleting your own messages. NEVER pass the current inbound message_id as the delete target — that is the USER's message, not a bot message.]"
263
+ : "[System: Your recent messages to this chat (for awareness — these may have been sent from another session/context):]";
264
+ const lines = recent.map((r, i) => {
265
+ const ageMin = Math.max(0, Math.round((Date.now() - r.sentAt) / 60000));
266
+ const previewText = r.digest || "(no preview)";
267
+ return ` ${i + 1}. messageId=${r.messageid} sent=${ageMin}m ago preview="${previewText}"`;
268
+ });
269
+ return {
270
+ text: [header, ...lines].join("\n"),
271
+ mode: detail ? "detail" : "ambient",
272
+ count: recent.length,
273
+ };
274
+ }
275
+ /**
276
+ * Build a section describing each quoted-reply target on the inbound message,
277
+ * with messageid + isBotMessage. Only emitted when there's at least one target.
278
+ * This is the missing piece for the original bug — the LLM needs to know which
279
+ * id belongs to the quoted bot message, distinct from the inbound message's id.
280
+ */
281
+ function formatQuotedReplyTargetsSection(targets) {
282
+ if (!targets.length)
283
+ return undefined;
284
+ const lines = targets.map((t, i) => {
285
+ const previewText = t.preview ? t.preview.slice(0, 200) : "(no preview)";
286
+ return ` ${i + 1}. messageId=${t.messageid} sentByBot=${t.isBotMessage} preview="${previewText}"`;
287
+ });
288
+ return [
289
+ "[System: This message is a quoted reply to:",
290
+ ...lines,
291
+ "If the user is asking to act on (recall/edit/quote) a referenced message and sentByBot=true, use that messageId as the action target.]",
292
+ ].join("\n");
213
293
  }
214
294
  /** Shared judgment rules and reply format requirements for all conditional-reply prompts */
215
295
  function buildReplyJudgmentRules(options) {
@@ -309,6 +389,21 @@ function buildProactivePrompt() {
309
389
  buildReplyJudgmentRules(),
310
390
  ].join("\n");
311
391
  }
392
+ /**
393
+ * Appended last to every group-chat GroupSystemPrompt. Keeps the visible group reply
394
+ * conclusion-oriented: no raw tool transcripts or retrieval dumps; multi-step work
395
+ * stays in subagent (or equivalent) with only a synthesized answer to the group.
396
+ */
397
+ function buildGroupOutputHygienePrompt() {
398
+ return [
399
+ "# Group chat output (hard constraint)",
400
+ "",
401
+ "- Reply to the group with **only the final user-facing answer** in one concise message (or a few short messages if the channel requires splitting).",
402
+ "- **Do not** include tool-call traces, raw intermediate search/retrieval payloads, or long scratchpad-style reasoning in the group-visible text.",
403
+ "- If the task needs exploration across multiple steps, do that work in a **subagent** (or equivalent isolated context) and return **only** the merged conclusion to the group.",
404
+ "- If the user explicitly asks for your reasoning or steps, satisfy that with a **brief** numbered or bulleted summary **inside the same** conclusion-style reply — still **not** raw tool logs or full intermediate dumps.",
405
+ ].join("\n");
406
+ }
312
407
  // ---------------------------------------------------------------------------
313
408
  // Group reply tracking (in-memory) for follow-up window
314
409
  // ---------------------------------------------------------------------------
@@ -550,8 +645,13 @@ export async function handleGroupChatMessage(params) {
550
645
  const bodyForAgent = agentVisibleText.trim() || rawMes || mes;
551
646
  // Extract sender name from header or fallback to fromuser
552
647
  const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
553
- // Detect reply-to-bot: check if any replyData item quotes a bot-sent message
554
- const isReplyToBot = replyContext ? checkReplyToBot(bodyItems, accountId) : false;
648
+ // Resolve all replyData targets (id + preview + isBotMessage). We need the
649
+ // structured form (not just a boolean) so we can surface the bot-message
650
+ // messageId to the LLM for correct recall.
651
+ const replyTargets = Array.isArray(bodyItems) && bodyItems.length > 0
652
+ ? resolveReplyTargets(bodyItems, accountId)
653
+ : [];
654
+ const isReplyToBot = replyTargets.some((t) => t.isBotMessage);
555
655
  // Delegate to the common message handler (group chat)
556
656
  await handleInfoflowMessage({
557
657
  cfg,
@@ -570,6 +670,7 @@ export async function handleGroupChatMessage(params) {
570
670
  mentionIds: mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
571
671
  replyContext,
572
672
  isReplyToBot: isReplyToBot || undefined,
673
+ replyTargets: replyTargets.length > 0 ? replyTargets : undefined,
573
674
  imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
574
675
  },
575
676
  accountId,
@@ -751,6 +852,54 @@ export async function handleInfoflowMessage(params) {
751
852
  ctxPayload.BodyForAgent = bodyForAgent;
752
853
  logVerbose(`[infoflow] group: BodyForAgent set for LLM (${bodyForAgent.length} chars, includes @/robotid)`);
753
854
  }
855
+ // ---------------------------------------------------------------------------
856
+ // Inject sent-message context into the LLM body. Two sections:
857
+ // 1. Quoted-reply targets (when this inbound is a 引用回复): exposes the
858
+ // bot-message messageId so the LLM doesn't mistake the inbound id for
859
+ // the recall target.
860
+ // 2. Recent bot-sent messages (always, when records exist within 24h):
861
+ // gives the LLM both ambient awareness (for cross-context-sent messages
862
+ // that aren't in this session's history) and a candidate list for
863
+ // semantic recall ("撤回刚才那条笑话").
864
+ // ---------------------------------------------------------------------------
865
+ const ctxTarget = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
866
+ {
867
+ const sections = [];
868
+ const quotedSection = formatQuotedReplyTargetsSection(event.replyTargets ?? []);
869
+ if (quotedSection)
870
+ sections.push(quotedSection);
871
+ const recallTextSource = `${bodyForAgent ?? ""}\n${event.replyContext?.join(" ") ?? ""}`.trim();
872
+ const recentSection = buildBotRecentMessagesSection({
873
+ accountId,
874
+ target: ctxTarget,
875
+ inboundLooksLikeRecall: looksLikeRecallIntent(recallTextSource),
876
+ isReplyToBot: event.isReplyToBot === true,
877
+ });
878
+ if (recentSection) {
879
+ sections.push(recentSection.text);
880
+ logVerbose(`[infoflow:ctx] injected recent-bot-messages section (mode=${recentSection.mode}, count=${recentSection.count}, target=${ctxTarget})`);
881
+ }
882
+ if (sections.length > 0) {
883
+ const existing = String(ctxPayload.Body ?? "");
884
+ ctxPayload.Body =
885
+ `${existing}\n\n${sections.join("\n\n")}`.trim();
886
+ }
887
+ }
888
+ // Register inbound context so the delete action handler can fall back to the
889
+ // bot-message id the inbound is quote-replying to (when present) — only used
890
+ // when the LLM otherwise passes an unknown id. We intentionally only pick a
891
+ // bot-sent reply target: falling back to a non-bot reply id would never help
892
+ // (it can't be in sent-messages.db) and only adds noise.
893
+ if (event.messageId) {
894
+ registerInboundContext({
895
+ accountId,
896
+ target: ctxTarget,
897
+ inboundMessageId: event.messageId,
898
+ replyToMessageId: event.replyTargets?.find((t) => t.isBotMessage)?.messageid,
899
+ replyTargets: event.replyTargets,
900
+ registeredAt: Date.now(),
901
+ });
902
+ }
754
903
  // Record session using recordInboundSession for proper session tracking
755
904
  await core.channel.session.recordInboundSession({
756
905
  storePath,
@@ -922,6 +1071,12 @@ export async function handleInfoflowMessage(params) {
922
1071
  ? `${existing}\n\n---\n\n${groupCfg.systemPrompt}`
923
1072
  : groupCfg.systemPrompt;
924
1073
  }
1074
+ // Default output hygiene for every dispatched group message (always last)
1075
+ const hygiene = buildGroupOutputHygienePrompt();
1076
+ const beforeHygiene = ctxPayload.GroupSystemPrompt ?? "";
1077
+ ctxPayload.GroupSystemPrompt = beforeHygiene
1078
+ ? `${beforeHygiene}\n\n---\n\n${hygiene}`
1079
+ : hygiene;
925
1080
  }
926
1081
  // Build unified target: "group:<id>" for group chat, username for private chat
927
1082
  const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
@@ -998,5 +1153,17 @@ export const _checkWatchMentioned = checkWatchMentioned;
998
1153
  export const _extractMentionIds = extractMentionIds;
999
1154
  /** @internal — Check if message matches any watchRegex pattern (dotAll). Only exported for tests. */
1000
1155
  export const _checkWatchRegex = checkWatchRegex;
1001
- /** @internal — Check if message is a reply to one of the bot's own messages. Only exported for tests. */
1002
- export const _checkReplyToBot = checkReplyToBot;
1156
+ /** @internal — Resolve structured replyData targets (id + preview + isBotMessage). Only exported for tests. */
1157
+ export const _resolveReplyTargets = resolveReplyTargets;
1158
+ /** @internal — Back-compat boolean form used by older tests; prefer _resolveReplyTargets. */
1159
+ export function _checkReplyToBot(bodyItems, accountId) {
1160
+ return resolveReplyTargets(bodyItems, accountId).some((t) => t.isBotMessage);
1161
+ }
1162
+ /** @internal — Group output hygiene fragment appended to GroupSystemPrompt. Only exported for tests. */
1163
+ export const _buildGroupOutputHygienePrompt = buildGroupOutputHygienePrompt;
1164
+ /** @internal — Recall intent regex. Only exported for tests. */
1165
+ export const _looksLikeRecallIntent = looksLikeRecallIntent;
1166
+ /** @internal — Sent-messages section builder. Only exported for tests. */
1167
+ export const _buildBotRecentMessagesSection = buildBotRecentMessagesSection;
1168
+ /** @internal — Quoted-reply targets section builder. Only exported for tests. */
1169
+ export const _formatQuotedReplyTargetsSection = formatQuotedReplyTargetsSection;
@@ -1,6 +1,7 @@
1
1
  import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/core";
2
2
  import { getChannelSection, listInfoflowAccountIds, resolveDefaultInfoflowAccountId, resolveInfoflowAccount, } from "./accounts.js";
3
3
  import { infoflowMessageActions } from "./actions.js";
4
+ import { createListSentMessagesTool } from "./agent-tools.js";
4
5
  import { logVerbose } from "./logging.js";
5
6
  import { parseMarkdownForLocalImages } from "./markdown-local-images.js";
6
7
  import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
@@ -66,10 +67,21 @@ export const infoflowPlugin = {
66
67
  },
67
68
  reload: { configPrefixes: ["channels.infoflow"] },
68
69
  actions: infoflowMessageActions,
70
+ agentTools: (params) => [
71
+ createListSentMessagesTool({
72
+ getConfig: () => params.cfg ?? {},
73
+ }),
74
+ ],
69
75
  agentPrompt: {
70
76
  messageToolHints: () => [
71
77
  'Infoflow group @mentions: set atAll=true to @all members, or mentionUserIds="user1,user2" (comma-separated uuapName) to @mention specific users. Only effective for group targets (group:<id>).',
72
- 'Infoflow supports message recall (撤回): use action="delete" to recall the most recent message, or specify messageId to recall a specific message. Works for both private and group messages.',
78
+ 'Infoflow message recall (撤回): use action="delete" to recall a bot-sent message.',
79
+ ' - To recall a specific message, pass messageId=<the bot message id>. Get the id from (a) the "Recent messages you (the bot) sent" section that may be injected into the body, (b) the "quoted reply target" block when sentByBot=true, or (c) the infoflow_list_sent_messages tool.',
80
+ ' - To recall the most recent message without specifying id, omit messageId (defaults to count=1).',
81
+ ' - For batch recall use count=<N>.',
82
+ ' - NEVER pass the current inbound message_id (the user-sent message you are replying to) as the delete target — that is the USER\'s message, not a bot message; the call will fail.',
83
+ ' - When a quoted reply target is present with sentByBot=true, that messageId is the most likely recall target.',
84
+ ' - For messages older than the injected recent window, or hard-to-identify ones, call infoflow_list_sent_messages first (use containsText / withinHours filters) and then pass the chosen messageId to action="delete".',
73
85
  ],
74
86
  },
75
87
  config: {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * In-memory registry of inbound message context, used by the delete action handler
3
+ * to recover when the LLM passes a wrong messageId.
4
+ *
5
+ * Why: openclaw's ChannelMessageActionContext exposes the inbound trigger's
6
+ * currentMessageId (via ctx.toolContext) but does NOT carry the inbound's
7
+ * replyToMessageId / resolved reply targets. We register that context here on
8
+ * inbound, keyed by the inbound messageId, and consume it from the action
9
+ * handler.
10
+ *
11
+ * Entries auto-expire after RETENTION_MS to keep the map bounded.
12
+ */
13
+ import { logVerbose } from "./logging.js";
14
+ const RETENTION_MS = 10 * 60 * 1000; // 10 minutes — same order of magnitude as followUp window
15
+ const MAX_ENTRIES = 500;
16
+ const store = new Map();
17
+ function evictExpired() {
18
+ if (store.size === 0)
19
+ return;
20
+ const cutoff = Date.now() - RETENTION_MS;
21
+ let count = 0;
22
+ for (const [key, entry] of store) {
23
+ if (entry.registeredAt < cutoff) {
24
+ store.delete(key);
25
+ count++;
26
+ }
27
+ }
28
+ if (count > 0) {
29
+ logVerbose(`[infoflow:inbound-ctx] evicted ${count} expired entries`);
30
+ }
31
+ }
32
+ export function registerInboundContext(record) {
33
+ evictExpired();
34
+ // Cap the map size; if we're over, drop the oldest entries.
35
+ if (store.size >= MAX_ENTRIES) {
36
+ const sorted = Array.from(store.entries()).sort((a, b) => a[1].registeredAt - b[1].registeredAt);
37
+ const dropCount = store.size - MAX_ENTRIES + 1;
38
+ for (let i = 0; i < dropCount; i++) {
39
+ store.delete(sorted[i][0]);
40
+ }
41
+ }
42
+ store.set(record.inboundMessageId, record);
43
+ }
44
+ export function lookupInboundContext(inboundMessageId) {
45
+ const entry = store.get(inboundMessageId);
46
+ if (!entry)
47
+ return undefined;
48
+ if (Date.now() - entry.registeredAt > RETENTION_MS) {
49
+ store.delete(inboundMessageId);
50
+ return undefined;
51
+ }
52
+ return entry;
53
+ }
54
+ /** @internal — for tests */
55
+ export function _resetInboundContext() {
56
+ store.clear();
57
+ }
58
+ /** @internal — for tests */
59
+ export function _inboundContextSize() {
60
+ return store.size;
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chbo297/infoflow",
3
- "version": "2026.5.7",
3
+ "version": "2026.5.9-beta.1",
4
4
  "description": "OpenClaw Infoflow (如流) channel plugin for Baidu enterprise messaging",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,6 +22,9 @@
22
22
  "@baidu/infoflow-sdk-nodejs": ">=0.1.0",
23
23
  "openclaw": ">=2026.5.4"
24
24
  },
25
+ "dependencies": {
26
+ "typebox": "^1.1.0"
27
+ },
25
28
  "peerDependenciesMeta": {
26
29
  "@baidu/infoflow-sdk-nodejs": {
27
30
  "optional": true
@@ -47,9 +50,11 @@
47
50
  "scripts": {
48
51
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
49
52
  "typecheck": "tsc --noEmit",
50
- "test": "vitest run"
53
+ "test": "vitest run",
54
+ "sync-readme-install-version": "node scripts/sync-readme-install-version.mjs"
51
55
  },
52
56
  "devDependencies": {
57
+ "typebox": "^1.1.38",
53
58
  "typescript": "^6.0.3",
54
59
  "vitest": "^4.1.5"
55
60
  },