@chbo297/infoflow 2026.5.8 → 2026.5.9-beta.2

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
@@ -39,35 +39,49 @@ BAIDU_NPM_REGISTRY=http://registry.npm.baidu-int.com bash scripts/deploy.sh
39
39
 
40
40
  ### 首次安装(推荐命令)
41
41
 
42
- 首次在机器上安装时,推荐使用以下两种方式。
42
+ 下面的安装命令块由 `npm run sync-readme-install-version` 自动维护,版本号始终与 npm 上当前的 `latest` / `beta` dist-tag 保持一致,请直接复制使用。
43
43
 
44
- 方式 A:通过独立 tools 包安装并部署(推荐,支持 `update` 子命令)
44
+ #### 方式 A:通过独立 tools 包安装并部署(推荐,支持 `update` 子命令)
45
45
 
46
- <!-- sync:infoflow-plugin-version -->
46
+ 正式版(`latest` dist-tag):
47
+
48
+ <!-- sync:infoflow-plugin-version:latest -->
47
49
  ```bash
48
- # 正式版(latest)
49
- npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.8 --registry https://registry.npmjs.org
50
+ npm cache clean --force
51
+ npx -y --prefer-online @chbo297/infoflow-openclaw-tools update \
52
+ --version 2026.5.8 --registry https://registry.npmjs.org
50
53
  ```
51
- <!-- /sync:infoflow-plugin-version -->
54
+ <!-- /sync:infoflow-plugin-version:latest -->
55
+
56
+ Beta 版(`beta` dist-tag,按需):
52
57
 
58
+ <!-- sync:infoflow-plugin-version:beta -->
53
59
  ```bash
54
- # Beta 版(示例,版本号请按实际预发包替换)
55
- npx -y @chbo297/infoflow-openclaw-tools@beta update --version 2026.5.8-beta.1 --registry https://registry.npmjs.org
60
+ npm cache clean --force
61
+ npx -y --prefer-online @chbo297/infoflow-openclaw-tools@beta update \
62
+ --version 2026.5.9-beta.1 --registry https://registry.npmjs.org
56
63
  ```
64
+ <!-- /sync:infoflow-plugin-version:beta -->
57
65
 
58
- 方式 B:通过 OpenClaw 插件命令安装
66
+ > 加上 `npm cache clean --force` 和 `--prefer-online`,可避免本机 npm metadata 缓存尚未刷新而看不到刚发布版本(典型表现为 `ETARGET: No matching version found`)。
59
67
 
60
- <!-- sync:infoflow-plugin-version -->
68
+ #### 方式 B:通过 OpenClaw 插件命令安装
69
+
70
+ 正式版:
71
+
72
+ <!-- sync:infoflow-plugin-version:latest -->
61
73
  ```bash
62
- # 正式版
63
74
  openclaw plugins install @chbo297/infoflow@2026.5.8
64
75
  ```
65
- <!-- /sync:infoflow-plugin-version -->
76
+ <!-- /sync:infoflow-plugin-version:latest -->
77
+
78
+ Beta 版:
66
79
 
80
+ <!-- sync:infoflow-plugin-version:beta -->
67
81
  ```bash
68
- # Beta 版(示例,版本号请按实际预发包替换)
69
- openclaw plugins install @chbo297/infoflow@2026.5.8-beta.1
82
+ openclaw plugins install @chbo297/infoflow@2026.5.9-beta.1
70
83
  ```
84
+ <!-- /sync:infoflow-plugin-version:beta -->
71
85
 
72
86
  安装后建议检查插件状态:
73
87
 
@@ -76,17 +90,32 @@ openclaw plugins list
76
90
  openclaw plugins inspect infoflow
77
91
  ```
78
92
 
79
- ### 通过 npx 一键更新安装
93
+ #### 如遇 `ETARGET: No matching version found`
80
94
 
81
- 发布到 npm 后,可直接通过独立 tools 包执行安装/升级:
95
+ 刚发布的版本可能在本机 npm metadata 缓存里看不到,按下面顺序排查:
82
96
 
83
- <!-- sync:infoflow-plugin-version -->
84
97
  ```bash
85
- npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.8 --registry https://registry.npmjs.org
98
+ # 1) 强制清缓存 + 在线拉取最新元数据
99
+ npm cache clean --force
100
+ npx -y --prefer-online @chbo297/infoflow-openclaw-tools@beta update \
101
+ --version <要装的版本> --registry https://registry.npmjs.org
102
+
103
+ # 2) 直接查 registry,确认版本确实可见
104
+ npm view @chbo297/infoflow versions --registry https://registry.npmjs.org
105
+
106
+ # 3) 确认默认 registry 未被改到镜像源(有些内网会重写到 cnpm/baidu 镜像,那里同步可能滞后)
107
+ npm config get registry # 期望: https://registry.npmjs.org/
108
+ # 临时强制覆盖(不改全局配置):
109
+ npm_config_registry=https://registry.npmjs.org \
110
+ npx -y --prefer-online @chbo297/infoflow-openclaw-tools@beta update \
111
+ --version <要装的版本> --registry https://registry.npmjs.org
112
+
113
+ # 4) 直接 curl 验证那台机器能否拿到 manifest
114
+ curl -sI https://registry.npmjs.org/@chbo297/infoflow | head -5
115
+ curl -s https://registry.npmjs.org/@chbo297/infoflow/<要装的版本> | head -50
86
116
  ```
87
- <!-- /sync:infoflow-plugin-version -->
88
117
 
89
- 常用参数:
118
+ ### tools 包的常用参数
90
119
 
91
120
  - `--version <version>`: 指定安装版本(默认 `latest`)
92
121
  - `--registry <url>`: 插件包下载源(默认 `https://registry.npmjs.org`)
@@ -107,34 +136,102 @@ npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.8 --registry htt
107
136
 
108
137
  ### 版本升级、打 tag、推送与 npm 发布流程
109
138
 
110
- 每次发布新版本时,先将 `package.json` `version` 设为待发版本号,再按下述顺序执行。上文「首次安装 / npx 更新」与下文发版流程中,各 bash 代码块外侧有一对用于自动替换的 HTML 注释标记;发版前请执行 **`npm run sync-readme-install-version`**,脚本会按当前 `package.json` `version` 更新这些块内的版本号,以免 README 与 npm 不一致。
139
+ 正式版与 Beta 预发各自有完整流程,区别只在 **`npm version` 用的版本号** **`npm publish` 是否带 `--tag beta`** 两处。请按需选择对应小节,从头到尾执行。
140
+
141
+ 执行 `npm run sync-readme-install-version` 时脚本会:
142
+
143
+ - 把"发版流程"代码块内 `--version` / `npm version` / `git tag` / `git commit -m "<version>"` 等示例同步到当前 `package.json.version`("current" stream);
144
+ - 同时把上文"首次安装"段落里 `:latest` / `:beta` 两个 marker 区按 npm 上的 dist-tag 刷新——发 stable 时 `:latest` 区会写成新版本号;发 prerelease 时 `:beta` 区会写成新版本号;另一条 stream 通过 npm registry 接口拉取真实 dist-tag。
145
+
146
+ #### A. 正式版(stable)发布流程
147
+
148
+ 发布一个不带预发后缀的版本(例如 `2026.5.10`),会同时占用 npm 的 `latest` dist-tag,是 `npx` 默认拉取的版本。
149
+
150
+ 将下方所有 `<X.Y.Z>` 替换为目标正式版本号(不带 `-beta.N`):
111
151
 
112
- <!-- sync:infoflow-plugin-version -->
113
152
  ```bash
114
153
  # 1) 修改版本号(会同步 package-lock.json)
115
- npm version 2026.5.8 --no-git-tag-version
154
+ npm version <X.Y.Z> --no-git-tag-version
116
155
 
117
- # 2) 同步 README 中标记块内的推荐安装命令与下文中的 tag / commit 示例版本号
156
+ # 2) 同步 README:current 区写入 <X.Y.Z>;:latest 区也写入 <X.Y.Z>(因为本次发的就是新 latest);
157
+ # :beta 区从 npm 拉取当前 beta dist-tag 保持不变
118
158
  npm run sync-readme-install-version
119
159
 
120
- # 3) 发布前校验
160
+ # 3) 编辑 CHANGELOG.md 顶部,添加本版本章节
161
+
162
+ # 4) 发布前校验
121
163
  npm run typecheck
122
164
  npm run test
123
165
  npm run build
124
166
 
125
- # 4) 提交版本变更(含 README、CHANGELOG 等)
167
+ # 5) 提交版本变更
126
168
  git add package.json package-lock.json README.md CHANGELOG.md scripts src
127
- git commit -m "2026.5.8"
169
+ git commit -m "<X.Y.Z>"
170
+
171
+ # 6) 打 tag 并推送代码与 tag
172
+ git tag <X.Y.Z>
173
+ git push origin main
174
+ git push origin <X.Y.Z>
175
+
176
+ # 7) 发布到 npm(占用 latest dist-tag)
177
+ npm publish --registry https://registry.npmjs.org
128
178
 
129
- # 5) tag 并推送代码与 tag
130
- git tag 2026.5.8
179
+ # 8) 发布成功后再跑一次 sync,把 :latest 区刷新成 npm registry 真实状态(通常已经一致,
180
+ # 但若另一条 stream 在期间也有新发布,这一步会顺带更新),并补一个 docs 提交
181
+ npm run sync-readme-install-version
182
+ git add README.md && git diff --cached --quiet || git commit -m "docs: refresh README install commands"
131
183
  git push origin main
132
- git push origin 2026.5.8
184
+ ```
185
+
186
+ #### B. Beta 预发布流程
187
+
188
+ 发布一个带预发后缀的版本(例如 `2026.5.10-beta.1`),通过 `--tag beta` 占用 `beta` dist-tag,**不会**改写 `latest`,默认 `npx` 装到的仍是正式版。
133
189
 
134
- # 6) 发布 npm(可按需指定 registry)
135
- npm publish
136
- # 或
137
- # npm publish --registry https://registry.npmjs.org
190
+ 将下方所有 `<X.Y.Z-beta.N>` 替换为目标预发版本号:
191
+
192
+ ```bash
193
+ # 1) 修改版本号(会同步 package-lock.json)
194
+ npm version <X.Y.Z-beta.N> --no-git-tag-version
195
+
196
+ # 2) 同步 README:current 区写入 <X.Y.Z-beta.N>;:beta 区也写入 <X.Y.Z-beta.N>(因为本次发的就是新 beta);
197
+ # :latest 区从 npm 拉取当前 latest dist-tag 保持不变
198
+ npm run sync-readme-install-version
199
+
200
+ # 3) 编辑 CHANGELOG.md 顶部,添加本版本章节
201
+
202
+ # 4) 发布前校验
203
+ npm run typecheck
204
+ npm run test
205
+ npm run build
206
+
207
+ # 5) 提交版本变更
208
+ git add package.json package-lock.json README.md CHANGELOG.md scripts src
209
+ git commit -m "<X.Y.Z-beta.N>"
210
+
211
+ # 6) 打 tag 并推送代码与 tag
212
+ git tag <X.Y.Z-beta.N>
213
+ git push origin main
214
+ git push origin <X.Y.Z-beta.N>
215
+
216
+ # 7) 发布到 npm(占用 beta dist-tag;不影响 latest)
217
+ npm publish --tag beta --registry https://registry.npmjs.org
218
+
219
+ # 8) 发布成功后再跑一次 sync 并补一个 docs 提交(同 A.8)
220
+ npm run sync-readme-install-version
221
+ git add README.md && git diff --cached --quiet || git commit -m "docs: refresh README install commands"
222
+ git push origin main
223
+ ```
224
+
225
+ #### 当前 `package.json` 的版本号示例
226
+
227
+ 以下示例由 `sync` 脚本自动维护,反映**仓库当前 `package.json.version`**(可作为复制 A / B 流程时的版本号参考):
228
+
229
+ <!-- sync:infoflow-plugin-version -->
230
+ ```bash
231
+ npm version 2026.5.9-beta.1 --no-git-tag-version
232
+ git commit -m "2026.5.9-beta.1"
233
+ git tag 2026.5.9-beta.1
234
+ git push origin 2026.5.9-beta.1
138
235
  ```
139
236
  <!-- /sync:infoflow-plugin-version -->
140
237
 
@@ -6,6 +6,8 @@
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 { looksLikeRecallLatest } from "./recall-intent.js";
10
+ import { lookupInboundContext } from "./inbound-context.js";
9
11
  import { logVerbose } from "./logging.js";
10
12
  import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
11
13
  import { sendInfoflowMessage, recallInfoflowGroupMessage, recallInfoflowPrivateMessage, } from "./send.js";
@@ -15,12 +17,89 @@ import { normalizeInfoflowTarget } from "./targets.js";
15
17
  const RECALL_OK_HINT = "Recall succeeded. output only NO_REPLY with no other text.";
16
18
  const RECALL_FAIL_HINT = "Recall failed. Send a brief reply stating only the failure reason.";
17
19
  const RECALL_PARTIAL_HINT = "Some recalls failed. Send a brief reply stating only the failure reason(s).";
20
+ /**
21
+ * Resolve the inbound replyToMessageId from the action ctx + inbound-context map.
22
+ * Returns the bot-sent messageId the user is quote-replying to (if any), so we
23
+ * can recover when the LLM accidentally passes the inbound user-message id as
24
+ * the delete target.
25
+ */
26
+ function resolveInboundReplyToMessageId(params) {
27
+ const currentMessageId = params.currentMessageId != null ? String(params.currentMessageId) : undefined;
28
+ if (!currentMessageId)
29
+ return undefined;
30
+ const ctx = lookupInboundContext(currentMessageId);
31
+ if (!ctx)
32
+ return undefined;
33
+ // Scope match: same account + target (avoid using a stale context from another chat).
34
+ if (ctx.accountId !== params.accountId)
35
+ return undefined;
36
+ if (ctx.target !== params.target)
37
+ return undefined;
38
+ }
39
+ /**
40
+ * Aggressive guard: when LLM passes messageId === inbound currentMessageId (a known
41
+ * confusion pattern), auto-correct based on context instead of failing.
42
+ *
43
+ * Priorities:
44
+ * 1) Use replyToMessageId — user quote-replied to a bot message (highest confidence).
45
+ * 2) Drop to count=1 mode — when no replyTo AND text indicates "recall latest one".
46
+ * 3) Defer to existing fallback chain — ambiguous intent (e.g., "recall the one about X").
47
+ *
48
+ * Returns the corrected messageId (or undefined to signal count=1 mode).
49
+ */
50
+ function applyAggressiveGuardForInboundMessageId(params) {
51
+ const inboundMsgId = params.currentMessageId != null ? String(params.currentMessageId) : undefined;
52
+ // Guard only triggers when LLM passes the inbound message id as delete target
53
+ if (!inboundMsgId || params.messageId !== inboundMsgId) {
54
+ return params.messageId;
55
+ }
56
+ const ctxRec = lookupInboundContext(inboundMsgId);
57
+ const scopeOk = ctxRec && ctxRec.accountId === params.accountId && ctxRec.target === params.target;
58
+ if (!scopeOk) {
59
+ // No inbound context to guide correction — defer to existing fallback chain
60
+ return params.messageId;
61
+ }
62
+ // Priority 1: replyToMessageId (user quote-replied to a bot message)
63
+ const replyToId = ctxRec.replyToMessageId;
64
+ if (replyToId && findSentMessage(params.accountId, replyToId)) {
65
+ logVerbose(`[infoflow:delete] aggressive: messageId==inboundMsgId(${params.messageId}); using replyTo=${replyToId}`);
66
+ return replyToId;
67
+ }
68
+ // Priority 2: text indicates "recall latest one" — safe to auto-correct to count=1
69
+ if (looksLikeRecallLatest(ctxRec.inboundBody ?? "")) {
70
+ logVerbose(`[infoflow:delete] aggressive: messageId==inboundMsgId(${params.messageId}); recall-latest intent → drop to count=1`);
71
+ return undefined; // undefined → count=1 mode
72
+ }
73
+ // Priority 3: ambiguous intent — defer to existing fallback chain
74
+ logVerbose(`[infoflow:delete] aggressive: messageId==inboundMsgId(${params.messageId}); ambiguous intent → defer to candidate-error path`);
75
+ return params.messageId;
76
+ }
77
+ /** Format up to N recent sent messages for an error-path hint to the LLM. */
78
+ function formatRecentCandidatesForError(records, limit = 5) {
79
+ if (!records || !Array.isArray(records) || records.length === 0)
80
+ return "";
81
+ const lines = records.slice(0, limit).map((r) => {
82
+ const previewText = r.digest || "(no preview)";
83
+ return `messageId=${r.messageid} preview="${previewText}"`;
84
+ });
85
+ return lines.join("; ");
86
+ }
87
+ /** Safe candidate lookup that never throws (errors → empty string). */
88
+ function safeRecentCandidates(accountId, target) {
89
+ try {
90
+ const records = querySentMessages(accountId, { target, count: 5 });
91
+ return formatRecentCandidatesForError(records);
92
+ }
93
+ catch {
94
+ return "";
95
+ }
96
+ }
18
97
  export const infoflowMessageActions = {
19
98
  describeMessageTool: () => ({
20
99
  actions: ["send", "delete"],
21
100
  }),
22
101
  extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
23
- handleAction: async ({ action, params, cfg, accountId }) => {
102
+ handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
24
103
  // -----------------------------------------------------------------------
25
104
  // delete (群消息撤回) — Mode A: by messageId, Mode B: by count
26
105
  // -----------------------------------------------------------------------
@@ -35,7 +114,7 @@ export const infoflowMessageActions = {
35
114
  if (!account.config.appKey || !account.config.appSecret) {
36
115
  throw new Error("Infoflow appKey/appSecret not configured.");
37
116
  }
38
- const messageId = readStringParam(params, "messageId");
117
+ let messageId = readStringParam(params, "messageId");
39
118
  // Default to count=1 (recall latest message) when neither messageId nor count is provided
40
119
  const countStr = readStringParam(params, "count") ?? (messageId ? undefined : "1");
41
120
  const groupMatch = target.match(/^group:(\d+)/i);
@@ -44,27 +123,61 @@ export const infoflowMessageActions = {
44
123
  // 群消息撤回
45
124
  // -----------------------------------------------------------------
46
125
  const groupId = Number(groupMatch[1]);
126
+ const targetForStore = `group:${groupId}`;
127
+ // Apply aggressive guard when messageId equals inbound currentMessageId (LLM confusion pattern)
128
+ if (messageId) {
129
+ messageId = applyAggressiveGuardForInboundMessageId({
130
+ messageId,
131
+ currentMessageId: toolContext?.currentMessageId,
132
+ accountId: account.accountId,
133
+ target: targetForStore,
134
+ });
135
+ }
47
136
  // Mode A: single message recall by messageId
48
137
  if (messageId) {
138
+ // Resolve msgseqid (group recall requires it). If the LLM-passed messageId
139
+ // is unknown to the store, fall back to the inbound replyToMessageId — the
140
+ // common failure mode is the LLM passing the inbound user-message id as
141
+ // the delete target instead of the bot-message id it's quote-replying to.
142
+ let effectiveMessageId = messageId;
49
143
  let msgseqid = readStringParam(params, "msgseqid") ?? "";
50
- if (!msgseqid) {
51
- const stored = findSentMessage(account.accountId, messageId);
52
- if (stored?.msgseqid) {
53
- msgseqid = stored.msgseqid;
144
+ let stored = findSentMessage(account.accountId, effectiveMessageId);
145
+ if (!stored && !msgseqid) {
146
+ const fallbackId = resolveInboundReplyToMessageId({
147
+ accountId: account.accountId,
148
+ target: targetForStore,
149
+ currentMessageId: toolContext?.currentMessageId,
150
+ });
151
+ if (fallbackId && fallbackId !== effectiveMessageId) {
152
+ const fallbackStored = findSentMessage(account.accountId, fallbackId);
153
+ if (fallbackStored) {
154
+ logVerbose(`[infoflow:delete] LLM passed unknown messageId=${effectiveMessageId}, falling back to replyToMessageId=${fallbackId}`);
155
+ effectiveMessageId = fallbackId;
156
+ stored = fallbackStored;
157
+ }
54
158
  }
55
159
  }
160
+ if (!msgseqid && stored?.msgseqid) {
161
+ msgseqid = stored.msgseqid;
162
+ }
56
163
  if (!msgseqid) {
57
- throw new Error("delete requires msgseqid (not found in store; provide it explicitly or send messages first).");
164
+ const candidates = safeRecentCandidates(account.accountId, `group:${groupId}`);
165
+ logVerbose(`[infoflow:delete] unknown messageId=${effectiveMessageId}, no fallback available, returning candidates to LLM`);
166
+ throw new Error(`delete: messageId=${effectiveMessageId} is not a known bot-sent message in this chat (msgseqid not found in store). ` +
167
+ `It looks like you may have passed the inbound (user) message id instead of the bot's. ` +
168
+ (candidates
169
+ ? `Recent bot-sent messages here: ${candidates}. Pick the right messageId and retry.`
170
+ : `No recent bot-sent messages on file for this chat. Aborting to avoid wrong recall.`));
58
171
  }
59
172
  const result = await recallInfoflowGroupMessage({
60
173
  account,
61
174
  groupId,
62
- messageid: messageId,
175
+ messageid: effectiveMessageId,
63
176
  msgseqid,
64
177
  });
65
178
  if (result.ok) {
66
179
  try {
67
- removeRecalledMessages(account.accountId, [messageId]);
180
+ removeRecalledMessages(account.accountId, [effectiveMessageId]);
68
181
  }
69
182
  catch {
70
183
  // ignore cleanup errors
@@ -74,6 +187,7 @@ export const infoflowMessageActions = {
74
187
  ok: result.ok,
75
188
  channel: "infoflow",
76
189
  to,
190
+ messageId: effectiveMessageId,
77
191
  ...(result.error ? { error: result.error } : {}),
78
192
  _hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
79
193
  });
@@ -85,7 +199,7 @@ export const infoflowMessageActions = {
85
199
  throw new Error("count must be a positive integer.");
86
200
  }
87
201
  const records = querySentMessages(account.accountId, {
88
- target: `group:${groupId}`,
202
+ target: targetForStore,
89
203
  count,
90
204
  });
91
205
  // Filter to records that have msgseqid (required for group recall)
@@ -155,16 +269,48 @@ export const infoflowMessageActions = {
155
269
  throw new Error("Infoflow private message recall requires appAgentId configuration. " +
156
270
  "Set channels.infoflow.appAgentId to your application ID (如流企业后台的应用ID).");
157
271
  }
272
+ // Apply aggressive guard when messageId equals inbound currentMessageId (LLM confusion pattern)
273
+ if (messageId) {
274
+ messageId = applyAggressiveGuardForInboundMessageId({
275
+ messageId,
276
+ currentMessageId: toolContext?.currentMessageId,
277
+ accountId: account.accountId,
278
+ target,
279
+ });
280
+ }
158
281
  // Mode A: single message recall by messageId (msgkey)
159
282
  if (messageId) {
283
+ // Attempt the inbound-context fallback when the LLM-passed messageId is
284
+ // unknown to the store. If we can swap it for a verified bot-message id
285
+ // from the inbound replyTo, do so. Otherwise PRESERVE the original
286
+ // permissive behavior (pass the LLM id straight to the API and let
287
+ // Infoflow's backend judge) — the store may legitimately not contain
288
+ // every recallable DM message (e.g., after the 7-day retention sweep
289
+ // or for messages sent before this plugin started recording).
290
+ let effectiveMessageId = messageId;
291
+ const stored = findSentMessage(account.accountId, effectiveMessageId);
292
+ if (!stored) {
293
+ const fallbackId = resolveInboundReplyToMessageId({
294
+ accountId: account.accountId,
295
+ target,
296
+ currentMessageId: toolContext?.currentMessageId,
297
+ });
298
+ if (fallbackId && fallbackId !== effectiveMessageId) {
299
+ const fallbackStored = findSentMessage(account.accountId, fallbackId);
300
+ if (fallbackStored) {
301
+ logVerbose(`[infoflow:delete] LLM passed unknown messageId=${effectiveMessageId}, falling back to replyToMessageId=${fallbackId}`);
302
+ effectiveMessageId = fallbackId;
303
+ }
304
+ }
305
+ }
160
306
  const result = await recallInfoflowPrivateMessage({
161
307
  account,
162
- msgkey: messageId,
308
+ msgkey: effectiveMessageId,
163
309
  appAgentId,
164
310
  });
165
311
  if (result.ok) {
166
312
  try {
167
- removeRecalledMessages(account.accountId, [messageId]);
313
+ removeRecalledMessages(account.accountId, [effectiveMessageId]);
168
314
  }
169
315
  catch {
170
316
  // ignore cleanup errors
@@ -174,6 +320,7 @@ export const infoflowMessageActions = {
174
320
  ok: result.ok,
175
321
  channel: "infoflow",
176
322
  to,
323
+ messageId: effectiveMessageId,
177
324
  ...(result.error ? { error: result.error } : {}),
178
325
  _hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
179
326
  });
@@ -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,12 @@
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";
7
+ import { looksLikeRecallIntent, looksLikeRecallLatest } from "./recall-intent.js";
6
8
  import { getInfoflowRuntime } from "./runtime.js";
7
- import { findSentMessage } from "./sent-message-store.js";
9
+ import { findSentMessage, querySentMessages } from "./sent-message-store.js";
8
10
  /**
9
11
  * Check if the bot was @mentioned in the message body.
10
12
  * Matches appAgentId, robotName, or robotId against AT items (same order as Baidu reference plugin).
@@ -183,14 +185,18 @@ function resolveFollowUpOtherMentioned(params) {
183
185
  logVerbose(`[infoflow:bot] skip dispatch: from=${fromuser}, group=${groupId}, reason=followUp-other-mentioned (record only, no LLM)`);
184
186
  return "record_only";
185
187
  }
186
- // ---------------------------------------------------------------------------
187
- // Reply-to-bot detection (引用回复机器人消息)
188
- // ---------------------------------------------------------------------------
189
188
  /**
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.
189
+ * Resolve all replyData targets from inbound body items, including each target's
190
+ * messageid + body preview + isBotMessage (via sent-message-store lookup).
191
+ *
192
+ * This is the structured form behind `checkReplyToBot`: callers who only want a
193
+ * boolean can compute it as `targets.some((t) => t.isBotMessage)`. Returning the
194
+ * full list lets us surface the messageid to the LLM and resolve the right
195
+ * recall target — the previous bool-only API was the root cause of the LLM
196
+ * passing the inbound (user) messageId to action=delete.
192
197
  */
193
- function checkReplyToBot(bodyItems, accountId) {
198
+ function resolveReplyTargets(bodyItems, accountId) {
199
+ const out = [];
194
200
  for (const item of bodyItems) {
195
201
  if (item.type !== "replyData")
196
202
  continue;
@@ -200,16 +206,85 @@ function checkReplyToBot(bodyItems, accountId) {
200
206
  const msgIdStr = String(msgId);
201
207
  if (!msgIdStr)
202
208
  continue;
209
+ let isBotMessage = false;
203
210
  try {
204
- const found = findSentMessage(accountId, msgIdStr);
205
- if (found)
206
- return true;
211
+ isBotMessage = Boolean(findSentMessage(accountId, msgIdStr));
207
212
  }
208
213
  catch {
209
214
  // DB lookup failure should not block message processing
210
215
  }
216
+ out.push({
217
+ messageid: msgIdStr,
218
+ preview: (item.content ?? "").trim(),
219
+ isBotMessage,
220
+ });
211
221
  }
212
- return false;
222
+ return out;
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // Sent-message context injection (push) — solves both the "AI uses inbound id
226
+ // as delete target" bug and the "DM-triggered cross-context send is invisible
227
+ // in the target group" bug. sent-messages.db tracks all bot-sent messages
228
+ // keyed by target, so a single push surfaces messages sent from any session.
229
+ // ---------------------------------------------------------------------------
230
+ // Recall-intent detection lives in ./recall-intent.js — shared with actions.ts.
231
+ const RECENT_BOT_AMBIENT_WINDOW_MS = 24 * 60 * 60 * 1000;
232
+ const RECENT_BOT_AMBIENT_COUNT = 5;
233
+ const RECENT_BOT_DETAIL_COUNT = 10;
234
+ /**
235
+ * Build a system-style section listing the bot's recent messages to this chat.
236
+ * Two modes — ambient (compact, awareness only) and detail (longer, with
237
+ * explicit instruction for recall). Returns undefined when there's nothing
238
+ * recent to report so unrelated chats stay token-cheap.
239
+ */
240
+ function buildBotRecentMessagesSection(params) {
241
+ const detail = params.inboundLooksLikeRecall || params.isReplyToBot;
242
+ const count = detail ? RECENT_BOT_DETAIL_COUNT : RECENT_BOT_AMBIENT_COUNT;
243
+ let records;
244
+ try {
245
+ records = querySentMessages(params.accountId, { target: params.target, count });
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ if (records.length === 0)
251
+ return undefined;
252
+ const cutoff = Date.now() - RECENT_BOT_AMBIENT_WINDOW_MS;
253
+ const recent = records.filter((r) => r.sentAt >= cutoff);
254
+ if (recent.length === 0)
255
+ return undefined;
256
+ const header = detail
257
+ ? "[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.]"
258
+ : "[System: Your recent messages to this chat (for awareness — these may have been sent from another session/context):]";
259
+ const lines = recent.map((r, i) => {
260
+ const ageMin = Math.max(0, Math.round((Date.now() - r.sentAt) / 60000));
261
+ const previewText = r.digest || "(no preview)";
262
+ return ` ${i + 1}. messageId=${r.messageid} sent=${ageMin}m ago preview="${previewText}"`;
263
+ });
264
+ return {
265
+ text: [header, ...lines].join("\n"),
266
+ mode: detail ? "detail" : "ambient",
267
+ count: recent.length,
268
+ };
269
+ }
270
+ /**
271
+ * Build a section describing each quoted-reply target on the inbound message,
272
+ * with messageid + isBotMessage. Only emitted when there's at least one target.
273
+ * This is the missing piece for the original bug — the LLM needs to know which
274
+ * id belongs to the quoted bot message, distinct from the inbound message's id.
275
+ */
276
+ function formatQuotedReplyTargetsSection(targets) {
277
+ if (!targets.length)
278
+ return undefined;
279
+ const lines = targets.map((t, i) => {
280
+ const previewText = t.preview ? t.preview.slice(0, 200) : "(no preview)";
281
+ return ` ${i + 1}. messageId=${t.messageid} sentByBot=${t.isBotMessage} preview="${previewText}"`;
282
+ });
283
+ return [
284
+ "[System: This message is a quoted reply to:",
285
+ ...lines,
286
+ "If the user is asking to act on (recall/edit/quote) a referenced message and sentByBot=true, use that messageId as the action target.]",
287
+ ].join("\n");
213
288
  }
214
289
  /** Shared judgment rules and reply format requirements for all conditional-reply prompts */
215
290
  function buildReplyJudgmentRules(options) {
@@ -565,8 +640,13 @@ export async function handleGroupChatMessage(params) {
565
640
  const bodyForAgent = agentVisibleText.trim() || rawMes || mes;
566
641
  // Extract sender name from header or fallback to fromuser
567
642
  const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
568
- // Detect reply-to-bot: check if any replyData item quotes a bot-sent message
569
- const isReplyToBot = replyContext ? checkReplyToBot(bodyItems, accountId) : false;
643
+ // Resolve all replyData targets (id + preview + isBotMessage). We need the
644
+ // structured form (not just a boolean) so we can surface the bot-message
645
+ // messageId to the LLM for correct recall.
646
+ const replyTargets = Array.isArray(bodyItems) && bodyItems.length > 0
647
+ ? resolveReplyTargets(bodyItems, accountId)
648
+ : [];
649
+ const isReplyToBot = replyTargets.some((t) => t.isBotMessage);
570
650
  // Delegate to the common message handler (group chat)
571
651
  await handleInfoflowMessage({
572
652
  cfg,
@@ -585,6 +665,7 @@ export async function handleGroupChatMessage(params) {
585
665
  mentionIds: mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
586
666
  replyContext,
587
667
  isReplyToBot: isReplyToBot || undefined,
668
+ replyTargets: replyTargets.length > 0 ? replyTargets : undefined,
588
669
  imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
589
670
  },
590
671
  accountId,
@@ -766,6 +847,56 @@ export async function handleInfoflowMessage(params) {
766
847
  ctxPayload.BodyForAgent = bodyForAgent;
767
848
  logVerbose(`[infoflow] group: BodyForAgent set for LLM (${bodyForAgent.length} chars, includes @/robotid)`);
768
849
  }
850
+ // ---------------------------------------------------------------------------
851
+ // Inject sent-message context into the LLM body. Two sections:
852
+ // 1. Quoted-reply targets (when this inbound is a 引用回复): exposes the
853
+ // bot-message messageId so the LLM doesn't mistake the inbound id for
854
+ // the recall target.
855
+ // 2. Recent bot-sent messages (always, when records exist within 24h):
856
+ // gives the LLM both ambient awareness (for cross-context-sent messages
857
+ // that aren't in this session's history) and a candidate list for
858
+ // semantic recall ("撤回刚才那条笑话").
859
+ // ---------------------------------------------------------------------------
860
+ const ctxTarget = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
861
+ {
862
+ const sections = [];
863
+ const quotedSection = formatQuotedReplyTargetsSection(event.replyTargets ?? []);
864
+ if (quotedSection)
865
+ sections.push(quotedSection);
866
+ const recallTextSource = `${bodyForAgent ?? ""}\n${event.replyContext?.join(" ") ?? ""}`.trim();
867
+ const recentSection = buildBotRecentMessagesSection({
868
+ accountId,
869
+ target: ctxTarget,
870
+ inboundLooksLikeRecall: looksLikeRecallIntent(recallTextSource),
871
+ isReplyToBot: event.isReplyToBot === true,
872
+ });
873
+ if (recentSection) {
874
+ sections.push(recentSection.text);
875
+ logVerbose(`[infoflow:ctx] injected recent-bot-messages section (mode=${recentSection.mode}, count=${recentSection.count}, target=${ctxTarget})`);
876
+ }
877
+ if (sections.length > 0) {
878
+ const existing = String(ctxPayload.Body ?? "");
879
+ ctxPayload.Body =
880
+ `${existing}\n\n${sections.join("\n\n")}`.trim();
881
+ }
882
+ }
883
+ // Register inbound context so the delete action handler can fall back to the
884
+ // bot-message id the inbound is quote-replying to (when present), or detect
885
+ // the "messageId===inboundMessageId" LLM confusion pattern with the body
886
+ // text to decide whether it's safe to auto-correct to count=1. We only pick
887
+ // a bot-sent reply target: falling back to a non-bot reply id would never
888
+ // help (it can't be in sent-messages.db) and only adds noise.
889
+ if (event.messageId) {
890
+ registerInboundContext({
891
+ accountId,
892
+ target: ctxTarget,
893
+ inboundMessageId: event.messageId,
894
+ replyToMessageId: event.replyTargets?.find((t) => t.isBotMessage)?.messageid,
895
+ replyTargets: event.replyTargets,
896
+ inboundBody: bodyForAgent || mes || event.replyContext?.join(" "),
897
+ registeredAt: Date.now(),
898
+ });
899
+ }
769
900
  // Record session using recordInboundSession for proper session tracking
770
901
  await core.channel.session.recordInboundSession({
771
902
  storePath,
@@ -1019,7 +1150,19 @@ export const _checkWatchMentioned = checkWatchMentioned;
1019
1150
  export const _extractMentionIds = extractMentionIds;
1020
1151
  /** @internal — Check if message matches any watchRegex pattern (dotAll). Only exported for tests. */
1021
1152
  export const _checkWatchRegex = checkWatchRegex;
1022
- /** @internal — Check if message is a reply to one of the bot's own messages. Only exported for tests. */
1023
- export const _checkReplyToBot = checkReplyToBot;
1153
+ /** @internal — Resolve structured replyData targets (id + preview + isBotMessage). Only exported for tests. */
1154
+ export const _resolveReplyTargets = resolveReplyTargets;
1155
+ /** @internal — Back-compat boolean form used by older tests; prefer _resolveReplyTargets. */
1156
+ export function _checkReplyToBot(bodyItems, accountId) {
1157
+ return resolveReplyTargets(bodyItems, accountId).some((t) => t.isBotMessage);
1158
+ }
1024
1159
  /** @internal — Group output hygiene fragment appended to GroupSystemPrompt. Only exported for tests. */
1025
1160
  export const _buildGroupOutputHygienePrompt = buildGroupOutputHygienePrompt;
1161
+ /** @internal — Recall intent regex. Only exported for tests. */
1162
+ export const _looksLikeRecallIntent = looksLikeRecallIntent;
1163
+ /** @internal — Stricter recall-latest detector. Only exported for tests. */
1164
+ export const _looksLikeRecallLatest = looksLikeRecallLatest;
1165
+ /** @internal — Sent-messages section builder. Only exported for tests. */
1166
+ export const _buildBotRecentMessagesSection = buildBotRecentMessagesSection;
1167
+ /** @internal — Quoted-reply targets section builder. Only exported for tests. */
1168
+ 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
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Lightweight recall-intent detection used by both the prompt-injection path
3
+ * (bot.ts) and the delete action's aggressive guard (actions.ts).
4
+ *
5
+ * Two helpers:
6
+ * - looksLikeRecallIntent: matches any "撤回/删除/recall/unsend/..." verb.
7
+ * - looksLikeRecallLatest: requires both a recall verb AND an explicit
8
+ * "the latest one" qualifier (上一条 / 最后一条 / 刚才那条 / 最近一条 /
9
+ * last / previous / most recent / ...). Used to decide whether it's safe
10
+ * to auto-correct `messageId=inbound_user_msg_id` to count=1 (recall most
11
+ * recent). Standalone "撤回那条" without a temporal qualifier is rejected:
12
+ * it could refer to a specific quoted message and we'd rather surface
13
+ * candidates to the LLM than risk recalling the wrong one.
14
+ */
15
+ const RECALL_INTENT_REGEX = /(撤回|收回|删[掉了除]|取消|清除|recall|unsend|undo\s*send|delete\s+(?:that|those|the\s+(?:last|previous(?:\s+\d+)?)))/i;
16
+ const RECALL_LATEST_HINT_REGEX = /(上一?条|最后一?条|刚才那?条|最近一?条|last(?:\s+(?:one|message|two|few|reply))?|previous|most\s*recent)/iu;
17
+ export function looksLikeRecallIntent(text) {
18
+ if (!text)
19
+ return false;
20
+ return RECALL_INTENT_REGEX.test(text);
21
+ }
22
+ export function looksLikeRecallLatest(text) {
23
+ if (!text)
24
+ return false;
25
+ return RECALL_INTENT_REGEX.test(text) && RECALL_LATEST_HINT_REGEX.test(text);
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chbo297/infoflow",
3
- "version": "2026.5.8",
3
+ "version": "2026.5.9-beta.2",
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
@@ -51,6 +54,7 @@
51
54
  "sync-readme-install-version": "node scripts/sync-readme-install-version.mjs"
52
55
  },
53
56
  "devDependencies": {
57
+ "typebox": "^1.1.38",
54
58
  "typescript": "^6.0.3",
55
59
  "vitest": "^4.1.5"
56
60
  },