@shgroup/dsh-serenity-hooks 1.27.2 → 1.27.4

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.
@@ -295,9 +295,32 @@ async function createSkiffAgent(ctx, root, roleName, role, defaultModel, session
295
295
  resumed: handle.resumed
296
296
  };
297
297
  }
298
+ /** 取 live agent(DSH 文档:`ctx.agents.get(id)` 返回 bare Agent;会话 live 时 resume/create 均不可用) */
299
+ function getLiveAgent(ctx, id) {
300
+ try {
301
+ return ctx.agents?.get?.(id);
302
+ } catch {
303
+ return;
304
+ }
305
+ }
306
+ /** live 复用返回(resumed:true 历史延续;dispose 空操作——live agent 不归本模块所有,不能拆) */
307
+ function liveReuseRef(live, setup) {
308
+ return setup(live.ctx).catch(() => {}).then(() => ({
309
+ agent: live,
310
+ dispose: async () => {},
311
+ resumed: true
312
+ }));
313
+ }
298
314
  /**
299
315
  * resume-or-create 分派:固定 id → 优先 resume(持久化历史延续),失败降级 create;
300
316
  * 随机 id(无固定 sessionId)→ 恒 create。返回 handle + resumed 标志。
317
+ *
318
+ * **v1.27.3 修复(微信桥"重启后不响应"根因):live 会话优先复用**——重启后 DSH 把
319
+ * 关闭时仍 live 的持久化会话恢复为 live(crash/restart 恢复语义)。此时 resume 报
320
+ * "cannot prepare session X while it is live"(session-persistence coordinator 显式
321
+ * 拒绝 live 会话)、create 报 "session X already exists"(磁盘有持久化 log 必拒)→
322
+ * 微信桥 catch 吞错 → 用户消息无回复。唯一可行路径 = `ctx.agents.get(id)` 取 live
323
+ * agent 直接续用(resumed:true;角色提示词重挂与内存注册表登记由 createSkiffAgent 统一做)。
301
324
  */
302
325
  async function createOrResumeAgent(ctx, id, root, model, setup, fixedId) {
303
326
  if (!fixedId) return {
@@ -312,6 +335,8 @@ async function createOrResumeAgent(ctx, id, root, model, setup, fixedId) {
312
335
  }),
313
336
  resumed: false
314
337
  };
338
+ const liveAgent = getLiveAgent(ctx, id);
339
+ if (liveAgent) return liveReuseRef(liveAgent, setup);
315
340
  const agentsWithResume = ctx.agents;
316
341
  if (typeof agentsWithResume.resume !== "function") return {
317
342
  ...await ctx.agents.create({
@@ -335,6 +360,8 @@ async function createOrResumeAgent(ctx, id, root, model, setup, fixedId) {
335
360
  resumed: true
336
361
  };
337
362
  } catch (err) {
363
+ const liveNow = getLiveAgent(ctx, id);
364
+ if (liveNow) return liveReuseRef(liveNow, setup);
338
365
  const msg = err instanceof Error ? err.message : String(err);
339
366
  const stack = err instanceof Error ? err.stack ?? "" : "";
340
367
  console.log(`[serenity-hooks] skiff resume 失败降级 create (id=${id}): ${msg}`);
@@ -1,8 +1,9 @@
1
1
  /**
2
- * autotrajectory-exp.ts — 自主轨迹实验一站式管理工具(v1.26.12 实验提案,默认关)
2
+ * autopilot-trajectory.ts — Autopilot Trajectory 一站式管理工具(v1.26.12 实验
3
+ * autotrajectory-exp → v1.27.4 正式化改名)
3
4
  *
4
- * 定位:dsp **只提供工具与知识**,不向 CCC 自动安装任何东西(实验可能失败,不污染 CCC)——
5
- * 实验是 CCC 的自选动作:agent 调本工具(doc/全报告)即懂实验,init/random/check 辅助,
5
+ * 定位:dsp **只提供工具与知识**,不向 CCC 自动安装任何东西(机制是 CCC 的自选动作)——
6
+ * agent 调本工具(doc/全报告)即懂机制,init/random/check 辅助,
6
7
  * 实际执行(写配置/写偏见脚本/标记会话)由 CCC 自己决定、自己用现有工具完成。
7
8
  *
8
9
  * 实现:薄封装——exec 包内静态脚本(npm files 含 experiments/),脚本是单一真相源。
@@ -13,12 +14,12 @@
13
14
  import { defineTool } from '@deepseek-ai/dsh-tools';
14
15
  import type { Context } from 'cordis';
15
16
  /**
16
- * 定位包内实验脚本(npm files 分发 experiments/autotrajectory/)。
17
+ * 定位包内脚本(npm files 分发 experiments/autopilot-trajectory/)。
17
18
  * 布局差异:tsdown bundle 后 import.meta.url 指向 lib/index.js(lib → 包根 1 层);
18
19
  * vitest 源码直跑时指向 src/tools/x.ts(src/tools → 包根 2 层)——逐级上溯查找,
19
- * 两种布局都稳(找到 experiments/autotrajectory/scripts/autotrajectory-exp.ts 即止)。
20
+ * 两种布局都稳(找到 experiments/autopilot-trajectory/scripts/autopilot-trajectory.ts 即止)。
20
21
  */
21
22
  export declare function findExpScript(startDir: string): string | null;
22
- export declare const AUTO_TRAJECTORY_EXP_ACTIONS: readonly ["all", "init", "random", "diag", "doc", "check", "status", "guide", "diag-live"];
23
- /** 创建 autotrajectory-exp 工具(闭包捕获 ctx → diag-live 进程内诊断;v1.26.14 */
24
- export declare function createAutoTrajectoryExpTool(ctx: Context): ReturnType<typeof defineTool>;
23
+ export declare const AUTOPILOT_ACTIONS: readonly ["all", "init", "random", "diag", "doc", "check", "status", "guide", "diag-live"];
24
+ /** 创建 autopilot-trajectory 工具(闭包捕获 ctx → diag-live 进程内诊断;v1.26.14 + v1.27.4 改名) */
25
+ export declare function createAutopilotTool(ctx: Context): ReturnType<typeof defineTool>;
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { randomBytes } from "node:crypto";
2
+ import { createDecipheriv, randomBytes } from "node:crypto";
3
3
  //#region src/weixin-api.ts
4
4
  /**
5
5
  * weixin-api.ts — 微信个人号 iLink Bot API 客户端(F4c-3,v1.27.0 实验性)
@@ -23,15 +23,25 @@ var weixin_api_exports = /* @__PURE__ */ __exportAll({
23
23
  MessageItemType: () => MessageItemType,
24
24
  MessageState: () => MessageState,
25
25
  MessageType: () => MessageType,
26
+ TypingStatus: () => TypingStatus,
27
+ aes128EcbDecrypt: () => aes128EcbDecrypt,
26
28
  buildClientVersion: () => buildClientVersion,
29
+ buildMediaDownloadUrl: () => buildMediaDownloadUrl,
30
+ downloadMedia: () => downloadMedia,
27
31
  fetchQRCode: () => fetchQRCode,
32
+ getConfig: () => getConfig,
28
33
  getUpdates: () => getUpdates,
29
34
  markdownToPlainText: () => markdownToPlainText,
35
+ parseMediaAesKey: () => parseMediaAesKey,
30
36
  pollQRStatus: () => pollQRStatus,
31
- sendTextMessage: () => sendTextMessage
37
+ sendTextMessage: () => sendTextMessage,
38
+ sendTyping: () => sendTyping,
39
+ sniffImageExt: () => sniffImageExt
32
40
  });
33
41
  /** iLink API Base URL(腾讯官方) */
34
42
  const ILINK_DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
43
+ /** CDN Base(媒体上传/下载;P3 媒体期用) */
44
+ const ILINK_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
35
45
  /** iLink-App-Id(openclaw-weixin 同款) */
36
46
  const ILINK_APP_ID = "bot";
37
47
  /** 通道版本(对齐 openclaw-weixin 2.1.1;buildClientVersion 编码) */
@@ -193,6 +203,119 @@ async function sendTextMessage(params) {
193
203
  timeoutMs: params.timeoutMs ?? 15e3
194
204
  });
195
205
  }
206
+ /** sendtyping status:1=TYPING(开始)0=CANCEL(结束)。
207
+ * ⚠️ 对齐**官方 openclaw-weixin 参考实现**(index.ts onReplyStart → status 1 /
208
+ * onCleanup → status 0)——早期协议注释"2=CANCEL"为误记,以参考实现为准。 */
209
+ const TypingStatus = {
210
+ TYPING: 1,
211
+ CANCEL: 0
212
+ };
213
+ /** 取对话配置(typing_ticket:sendtyping 的前置——先 getconfig 拿 ticket 再发状态) */
214
+ async function getConfig(params) {
215
+ const rawText = await apiPost({
216
+ baseUrl: params.baseUrl,
217
+ endpoint: "ilink/bot/getconfig",
218
+ body: JSON.stringify({
219
+ ilink_user_id: params.ilinkUserId,
220
+ context_token: params.contextToken
221
+ }),
222
+ token: params.token,
223
+ timeoutMs: params.timeoutMs ?? 1e4
224
+ });
225
+ return JSON.parse(rawText);
226
+ }
227
+ /** 发送"正在输入"状态(status: 1=开始 0=结束;ticket 来自 getConfig) */
228
+ async function sendTyping(params) {
229
+ await apiPost({
230
+ baseUrl: params.baseUrl,
231
+ endpoint: "ilink/bot/sendtyping",
232
+ body: JSON.stringify({
233
+ ilink_user_id: params.ilinkUserId,
234
+ typing_ticket: params.typingTicket,
235
+ status: params.status
236
+ }),
237
+ token: params.token,
238
+ timeoutMs: params.timeoutMs ?? 1e4
239
+ });
240
+ }
241
+ /** 构造 CDN 下载 URL(full_url 优先;否则 encrypted_query_param 拼 download 端点;都无 → null) */
242
+ function buildMediaDownloadUrl(media) {
243
+ if (media.full_url) return media.full_url;
244
+ if (media.encrypt_query_param) return `${ILINK_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(media.encrypt_query_param)}`;
245
+ return null;
246
+ }
247
+ /**
248
+ * 解析媒体 AES key(兼容三种编码,对齐参考实现):
249
+ * ① 32+ hex 字符 → hex Buffer(16) ② base64 解码 = 16 字节直接用
250
+ * ③ base64 解码 = 32 字节 → ascii 是 hex 串 → 再 hex → Buffer(16);其他 → null
251
+ */
252
+ function parseMediaAesKey(keyText) {
253
+ if (!keyText) return null;
254
+ if (/^[0-9a-fA-F]{32,}$/.test(keyText)) {
255
+ const buf = Buffer.from(keyText, "hex");
256
+ return buf.length === 16 ? buf : null;
257
+ }
258
+ try {
259
+ const decoded = Buffer.from(keyText, "base64");
260
+ if (decoded.length === 16) return decoded;
261
+ if (decoded.length === 32) {
262
+ const hexStr = decoded.toString("ascii");
263
+ if (/^[0-9a-fA-F]{32}$/.test(hexStr)) return Buffer.from(hexStr, "hex");
264
+ }
265
+ } catch {}
266
+ return null;
267
+ }
268
+ /** AES-128-ECB 解密(无 IV;node:crypto 内置) */
269
+ function aes128EcbDecrypt(data, key) {
270
+ const decipher = createDecipheriv("aes-128-ecb", key, null);
271
+ return Buffer.concat([decipher.update(data), decipher.final()]);
272
+ }
273
+ /** 图片 magic-byte 嗅探(vlm-describe 支持 jpg/png/gif/webp/bmp——比固定 .jpg 可靠) */
274
+ function sniffImageExt(data) {
275
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "jpg";
276
+ if (data.length >= 8 && data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "png";
277
+ if (data.length >= 3 && data[0] === 71 && data[1] === 73 && data[2] === 70) return "gif";
278
+ if (data.length >= 12 && data.subarray(0, 4).toString("ascii") === "RIFF" && data.subarray(8, 12).toString("ascii") === "WEBP") return "webp";
279
+ if (data.length >= 2 && data[0] === 66 && data[1] === 77) return "bmp";
280
+ return "bin";
281
+ }
282
+ /**
283
+ * 下载 + 解密一条媒体(图片/文件)。失败/超时/无 URL → null(不抛——调用方降级)。
284
+ * key 提取:item.aeskey || item.media.aes_key(对齐参考实现)。
285
+ */
286
+ async function downloadMedia(params) {
287
+ const mediaItem = params.item[params.mediaType];
288
+ if (!mediaItem) return null;
289
+ const media = mediaItem.media;
290
+ const url = buildMediaDownloadUrl(media ?? {});
291
+ if (!url) return null;
292
+ const itemKey = mediaItem.aeskey;
293
+ const fileName = mediaItem.file_name;
294
+ const key = parseMediaAesKey(itemKey ?? media?.aes_key);
295
+ const controller = new AbortController();
296
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 3e4);
297
+ try {
298
+ const res = await currentFetch(url, { signal: controller.signal });
299
+ if (!res.ok) return null;
300
+ const buf = Buffer.from(await res.arrayBuffer());
301
+ if (key) try {
302
+ return {
303
+ data: aes128EcbDecrypt(buf, key),
304
+ fileName
305
+ };
306
+ } catch {
307
+ return null;
308
+ }
309
+ return {
310
+ data: buf,
311
+ fileName
312
+ };
313
+ } catch {
314
+ return null;
315
+ } finally {
316
+ clearTimeout(timer);
317
+ }
318
+ }
196
319
  /** Markdown → 纯文本(代码块/图片/链接/表格/标题/粗斜体/删除线 7 类) */
197
320
  function markdownToPlainText(text) {
198
321
  let result = text;
@@ -208,4 +331,4 @@ function markdownToPlainText(text) {
208
331
  return result.trim();
209
332
  }
210
333
  //#endregion
211
- export { sendTextMessage as n, weixin_api_exports as r, getUpdates as t };
334
+ export { sendTextMessage as a, weixin_api_exports as c, getUpdates as i, downloadMedia as n, sendTyping as o, getConfig as r, sniffImageExt as s, TypingStatus as t };
@@ -47,16 +47,50 @@ export declare function pollQRStatus(params: {
47
47
  qrcode: string;
48
48
  timeoutMs?: number;
49
49
  }): Promise<QrStatusResult>;
50
+ /** CDN 媒体元数据(下载侧;协议见 docs/weixin-bot-api.md §6) */
51
+ export interface CDNMedia {
52
+ encrypt_query_param?: string;
53
+ aes_key?: string;
54
+ encrypt_type?: number;
55
+ full_url?: string;
56
+ }
57
+ /** 媒体项引用(extractWeixinMedia 产物:kind + **完整消息项**——downloadMedia 用 item[mediaType] 提取) */
58
+ export interface WeixinMediaRef {
59
+ kind: 'image' | 'file';
60
+ fileName?: string;
61
+ item: WeixinMessageItem;
62
+ }
50
63
  export interface WeixinMessageItem {
51
64
  type?: number;
52
65
  msg_id?: string;
53
66
  text_item?: {
54
67
  text?: string;
55
68
  };
56
- image_item?: Record<string, unknown>;
57
- voice_item?: Record<string, unknown>;
58
- file_item?: Record<string, unknown>;
59
- video_item?: Record<string, unknown>;
69
+ image_item?: {
70
+ media?: CDNMedia;
71
+ thumb_media?: CDNMedia;
72
+ aeskey?: string;
73
+ url?: string;
74
+ mid_size?: number;
75
+ thumb_size?: number;
76
+ };
77
+ /** 语音项:**text = 微信服务端自带语音转写**(官方 openclaw-weixin 直接读该字段,无需下载/ASR) */
78
+ voice_item?: {
79
+ text?: string;
80
+ media?: CDNMedia;
81
+ playtime?: number;
82
+ };
83
+ file_item?: {
84
+ media?: CDNMedia;
85
+ file_name?: string;
86
+ md5?: string;
87
+ len?: string;
88
+ };
89
+ video_item?: {
90
+ media?: CDNMedia;
91
+ video_size?: number;
92
+ thumb_media?: CDNMedia;
93
+ };
60
94
  }
61
95
  export interface WeixinMessage {
62
96
  seq?: number;
@@ -115,5 +149,58 @@ export declare function sendTextMessage(params: {
115
149
  clientId?: string;
116
150
  timeoutMs?: number;
117
151
  }): Promise<void>;
152
+ /** sendtyping status:1=TYPING(开始)0=CANCEL(结束)。
153
+ * ⚠️ 对齐**官方 openclaw-weixin 参考实现**(index.ts onReplyStart → status 1 /
154
+ * onCleanup → status 0)——早期协议注释"2=CANCEL"为误记,以参考实现为准。 */
155
+ export declare const TypingStatus: {
156
+ readonly TYPING: 1;
157
+ readonly CANCEL: 0;
158
+ };
159
+ export interface GetConfigResp {
160
+ ret?: number;
161
+ errmsg?: string;
162
+ typing_ticket?: string;
163
+ }
164
+ /** 取对话配置(typing_ticket:sendtyping 的前置——先 getconfig 拿 ticket 再发状态) */
165
+ export declare function getConfig(params: {
166
+ baseUrl: string;
167
+ token: string;
168
+ ilinkUserId: string;
169
+ contextToken: string;
170
+ timeoutMs?: number;
171
+ }): Promise<GetConfigResp>;
172
+ /** 发送"正在输入"状态(status: 1=开始 0=结束;ticket 来自 getConfig) */
173
+ export declare function sendTyping(params: {
174
+ baseUrl: string;
175
+ token: string;
176
+ ilinkUserId: string;
177
+ typingTicket: string;
178
+ status: number;
179
+ timeoutMs?: number;
180
+ }): Promise<void>;
181
+ /** 构造 CDN 下载 URL(full_url 优先;否则 encrypted_query_param 拼 download 端点;都无 → null) */
182
+ export declare function buildMediaDownloadUrl(media: CDNMedia): string | null;
183
+ /**
184
+ * 解析媒体 AES key(兼容三种编码,对齐参考实现):
185
+ * ① 32+ hex 字符 → hex Buffer(16) ② base64 解码 = 16 字节直接用
186
+ * ③ base64 解码 = 32 字节 → ascii 是 hex 串 → 再 hex → Buffer(16);其他 → null
187
+ */
188
+ export declare function parseMediaAesKey(keyText?: string): Buffer | null;
189
+ /** AES-128-ECB 解密(无 IV;node:crypto 内置) */
190
+ export declare function aes128EcbDecrypt(data: Buffer, key: Buffer): Buffer;
191
+ /** 图片 magic-byte 嗅探(vlm-describe 支持 jpg/png/gif/webp/bmp——比固定 .jpg 可靠) */
192
+ export declare function sniffImageExt(data: Buffer): 'jpg' | 'png' | 'gif' | 'webp' | 'bmp' | 'bin';
193
+ /**
194
+ * 下载 + 解密一条媒体(图片/文件)。失败/超时/无 URL → null(不抛——调用方降级)。
195
+ * key 提取:item.aeskey || item.media.aes_key(对齐参考实现)。
196
+ */
197
+ export declare function downloadMedia(params: {
198
+ item: WeixinMessageItem;
199
+ mediaType: 'image_item' | 'file_item';
200
+ timeoutMs?: number;
201
+ }): Promise<{
202
+ data: Buffer;
203
+ fileName?: string;
204
+ } | null>;
118
205
  /** Markdown → 纯文本(代码块/图片/链接/表格/标题/粗斜体/删除线 7 类) */
119
206
  export declare function markdownToPlainText(text: string): string;
@@ -14,6 +14,9 @@
14
14
  */
15
15
  import type { Context } from 'cordis';
16
16
  import { type WeixinAccountCredential } from './weixin-route.js';
17
+ import { type WeixinMessage } from './weixin-api.js';
18
+ /** 测试辅助:清空 typing_ticket 缓存(生产零调用) */
19
+ export declare function resetWeixinTypingCache(): void;
17
20
  /**
18
21
  * 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
19
22
  *
@@ -22,17 +25,17 @@ import { type WeixinAccountCredential } from './weixin-route.js';
22
25
  * 固定 id resume-or-create(v1.27.2):磁盘已有持久化 log → resume(历史延续,
23
26
  * 重启后记忆保留——真正的"同用户长期延续");无 log(首次)→ create。
24
27
  * "新的对话已开始"通知仅真正首次(create)时发送;resume/进程内延续不发。
28
+ *
29
+ * 完善(v1.27.3):
30
+ * - **语音支持**:`voice_item.text` = 微信服务端自带语音转写 → 与文本同路径进对话
31
+ * (无需下载/ASR);语音无转写 → 降级提示"暂时无法解析"
32
+ * - **正在输入**:处理前 sendtyping 1(微信显示"正在输入..."),处理后(含异常)0
33
+ * - **媒体接收(图片/文件)**:桥侧 CDN 下载 + AES 解密 → 落盘 CCC 根
34
+ * `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
35
+ * 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
36
+ * 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
25
37
  */
26
- export declare function handleIncoming(ctx: Context, root: string, _accountId: string, cred: WeixinAccountCredential, msg: {
27
- from_user_id?: string;
28
- context_token?: string;
29
- item_list?: Array<{
30
- type?: number;
31
- text_item?: {
32
- text?: string;
33
- };
34
- }>;
35
- }): Promise<void>;
38
+ export declare function handleIncoming(ctx: Context, root: string, accountId: string, cred: WeixinAccountCredential, msg: Pick<WeixinMessage, 'from_user_id' | 'context_token' | 'item_list'>): Promise<void>;
36
39
  /**
37
40
  * 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
38
41
  * 已存在(配置变化热重建)→ 先停旧循环再启动新的。
@@ -292,15 +292,19 @@ function runLocalStore(root, args) {
292
292
  var weixin_route_exports = /* @__PURE__ */ __exportAll({
293
293
  WEIXIN_SESSION_PREFIX: () => WEIXIN_SESSION_PREFIX,
294
294
  clearWeixinCredential: () => clearWeixinCredential,
295
+ extractWeixinMedia: () => extractWeixinMedia,
295
296
  extractWeixinText: () => extractWeixinText,
297
+ hasVoiceItem: () => hasVoiceItem,
296
298
  matchWeixinRoute: () => matchWeixinRoute,
297
299
  nextWeixinAccountId: () => nextWeixinAccountId,
298
300
  readWeixinCredential: () => readWeixinCredential,
299
301
  readWeixinSettings: () => readWeixinSettings,
300
302
  removeWeixinAccount: () => removeWeixinAccount,
303
+ sanitizeFileName: () => sanitizeFileName,
301
304
  saveWeixinRoutes: () => saveWeixinRoutes,
302
305
  setWeixinEnabled: () => setWeixinEnabled,
303
306
  upsertWeixinAccount: () => upsertWeixinAccount,
307
+ weixinInboundDir: () => weixinInboundDir,
304
308
  weixinSessionIdFor: () => weixinSessionIdFor,
305
309
  writeWeixinCredential: () => writeWeixinCredential
306
310
  });
@@ -314,7 +318,7 @@ function accountKeyPart(accountId) {
314
318
  /** 微信桥 skiff 会话 id 前缀(seams 旁路判定;对外面纯净——守卫识别外部面) */
315
319
  const WEIXIN_SESSION_PREFIX = "skiff-weixin-";
316
320
  /** 微信用户 → 固定会话 id(同用户长期同一会话,记忆延续;多用户天然隔离)。
317
- * **v1.27.3 错位一位(用户拍板)**:`.slice(0, 16)` → `.slice(1, 17)`——旧规则生成的
321
+ * **v1.27.2 错位一位(用户拍板)**:`.slice(0, 16)` → `.slice(1, 17)`——旧规则生成的
318
322
  * 固定 id 已与磁盘损坏的持久化 log 绑定(dsh 不可硬删,create 同 id 必撞)→
319
323
  * 新规则下同用户生成**全新 id**,避开损坏 log;固定可重建语义不变(同用户恒同 id)。 */
320
324
  function weixinSessionIdFor(fromUserId) {
@@ -390,18 +394,59 @@ function matchWeixinRoute(routes, fromUserId) {
390
394
  return null;
391
395
  }
392
396
  /**
393
- * 提取消息文本(item_list 首条 text_item;非文本消息 → null——P1 只处理文本)。
397
+ * 提取消息文本(文本项 + **语音项的服务端转写**):
398
+ * - type 1 TEXT → `text_item.text`
399
+ * - type 3 VOICE → `voice_item.text`——**微信服务端自带语音转写**(官方 openclaw-weixin
400
+ * 直接读该字段并入对话文本,无需下载/ASR;对齐参考实现 index.ts voiceTexts)。
394
401
  * @returns 纯文本;无文本 → null
395
402
  */
396
403
  function extractWeixinText(msg) {
397
404
  const items = msg.item_list;
398
405
  if (!Array.isArray(items) || items.length === 0) return null;
399
- for (const item of items) if (item.type === 1 && typeof item.text_item?.text === "string") {
400
- const text = item.text_item.text.trim();
401
- return text === "" ? null : text;
406
+ for (const item of items) {
407
+ if (item.type === 1 && typeof item.text_item?.text === "string") {
408
+ const text = item.text_item.text.trim();
409
+ if (text !== "") return text;
410
+ }
411
+ if (item.type === 3 && typeof item.voice_item?.text === "string") {
412
+ const text = item.voice_item.text.trim();
413
+ if (text !== "") return text;
414
+ }
402
415
  }
403
416
  return null;
404
417
  }
418
+ /** 是否包含语音项(无转写文本时的降级提示判定——bridge 用) */
419
+ function hasVoiceItem(msg) {
420
+ return (msg.item_list ?? []).some((item) => item.type === 3 && item.voice_item != null);
421
+ }
422
+ /**
423
+ * 提取消息媒体项(图片 type 2 / 文件 type 4):
424
+ * 只收集**带 media 元数据**的项(下载前置条件);返回 kind + **完整消息项**(downloadMedia 用
425
+ * `item[mediaType]` 取 image_item/file_item)。纯 ACC 层可达性——不做内容处理(v0.2 用户拍板)。
426
+ */
427
+ function extractWeixinMedia(msg) {
428
+ const out = [];
429
+ for (const item of msg.item_list ?? []) if (item.type === 2 && item.image_item?.media) out.push({
430
+ kind: "image",
431
+ fileName: void 0,
432
+ item
433
+ });
434
+ else if (item.type === 4 && item.file_item?.media) out.push({
435
+ kind: "file",
436
+ fileName: item.file_item.file_name,
437
+ item
438
+ });
439
+ return out;
440
+ }
441
+ /** 文件名净化:basename + 去控制字符 + 截断 128(防路径穿越——落盘安全) */
442
+ function sanitizeFileName(name) {
443
+ return (name.split(/[\\/]/).pop() ?? "").replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 128);
444
+ }
445
+ /** 落盘目录:`<CCC 根>/_tmp/weixin-inbound/<userhash>/`(gitignored ✓;agent read 边界内 ✓;按用户分目录) */
446
+ function weixinInboundDir(root, fromUserId) {
447
+ const userHash = createHash("sha256").update(fromUserId).digest("hex").slice(0, 12);
448
+ return join(root, "_tmp", "weixin-inbound", userHash);
449
+ }
405
450
  /**
406
451
  * 读取 serenity.json 文件(原始;不存在 → 空对象)。
407
452
  * 注意:loadSerenityConfig 返回解析对象但无法写回——此处直接文件读写。
@@ -486,4 +531,4 @@ function setWeixinEnabled(root, enabled) {
486
531
  return next;
487
532
  }
488
533
  //#endregion
489
- export { weixinSessionIdFor as a, checkLocalstoreGitCompliance as c, runLocalStore as d, readWeixinSettings as i, localstorePath as l, matchWeixinRoute as n, weixin_route_exports as o, readWeixinCredential as r, LOCALSTORE_SCOPES as s, extractWeixinText as t, readGitTrack as u };
534
+ export { readWeixinCredential as a, weixinInboundDir as c, LOCALSTORE_SCOPES as d, checkLocalstoreGitCompliance as f, runLocalStore as h, matchWeixinRoute as i, weixinSessionIdFor as l, readGitTrack as m, extractWeixinText as n, readWeixinSettings as o, localstorePath as p, hasVoiceItem as r, sanitizeFileName as s, extractWeixinMedia as t, weixin_route_exports as u };
@@ -12,12 +12,13 @@
12
12
  * 不绑定具体 role(用户自选路由目标)。
13
13
  */
14
14
  import { type WeixinSettings, type WeixinRouteConfig, type WeixinAccountConfig } from './ccc.js';
15
+ import type { WeixinMediaRef, WeixinMessageItem } from './weixin-api.js';
15
16
  /** 微信桥 skiff 会话 id 前缀(seams 旁路判定;对外面纯净——守卫识别外部面) */
16
17
  export declare const WEIXIN_SESSION_PREFIX = "skiff-weixin-";
17
18
  /** 判定 sessionId 是否为微信桥会话(外部面——输出守卫/轨迹隐藏生效) */
18
19
  export declare function isWeixinSessionId(sessionId: string | undefined): boolean;
19
20
  /** 微信用户 → 固定会话 id(同用户长期同一会话,记忆延续;多用户天然隔离)。
20
- * **v1.27.3 错位一位(用户拍板)**:`.slice(0, 16)` → `.slice(1, 17)`——旧规则生成的
21
+ * **v1.27.2 错位一位(用户拍板)**:`.slice(0, 16)` → `.slice(1, 17)`——旧规则生成的
21
22
  * 固定 id 已与磁盘损坏的持久化 log 绑定(dsh 不可硬删,create 同 id 必撞)→
22
23
  * 新规则下同用户生成**全新 id**,避开损坏 log;固定可重建语义不变(同用户恒同 id)。 */
23
24
  export declare function weixinSessionIdFor(fromUserId: string): string;
@@ -44,7 +45,10 @@ export declare function clearWeixinCredential(root: string, accountId: string):
44
45
  */
45
46
  export declare function matchWeixinRoute(routes: WeixinRouteConfig[], fromUserId: string): string | null;
46
47
  /**
47
- * 提取消息文本(item_list 首条 text_item;非文本消息 → null——P1 只处理文本)。
48
+ * 提取消息文本(文本项 + **语音项的服务端转写**):
49
+ * - type 1 TEXT → `text_item.text`
50
+ * - type 3 VOICE → `voice_item.text`——**微信服务端自带语音转写**(官方 openclaw-weixin
51
+ * 直接读该字段并入对话文本,无需下载/ASR;对齐参考实现 index.ts voiceTexts)。
48
52
  * @returns 纯文本;无文本 → null
49
53
  */
50
54
  export declare function extractWeixinText(msg: {
@@ -53,8 +57,30 @@ export declare function extractWeixinText(msg: {
53
57
  text_item?: {
54
58
  text?: string;
55
59
  };
60
+ voice_item?: {
61
+ text?: string;
62
+ };
56
63
  }>;
57
64
  }): string | null;
65
+ /** 是否包含语音项(无转写文本时的降级提示判定——bridge 用) */
66
+ export declare function hasVoiceItem(msg: {
67
+ item_list?: Array<{
68
+ type?: number;
69
+ voice_item?: unknown;
70
+ }>;
71
+ }): boolean;
72
+ /**
73
+ * 提取消息媒体项(图片 type 2 / 文件 type 4):
74
+ * 只收集**带 media 元数据**的项(下载前置条件);返回 kind + **完整消息项**(downloadMedia 用
75
+ * `item[mediaType]` 取 image_item/file_item)。纯 ACC 层可达性——不做内容处理(v0.2 用户拍板)。
76
+ */
77
+ export declare function extractWeixinMedia(msg: {
78
+ item_list?: WeixinMessageItem[];
79
+ }): WeixinMediaRef[];
80
+ /** 文件名净化:basename + 去控制字符 + 截断 128(防路径穿越——落盘安全) */
81
+ export declare function sanitizeFileName(name: string): string;
82
+ /** 落盘目录:`<CCC 根>/_tmp/weixin-inbound/<userhash>/`(gitignored ✓;agent read 边界内 ✓;按用户分目录) */
83
+ export declare function weixinInboundDir(root: string, fromUserId: string): string;
58
84
  /** 添加/更新账号(serenity.json 结构部分;凭据单独写 localstore) */
59
85
  export declare function upsertWeixinAccount(root: string, account: WeixinAccountConfig): WeixinSettings;
60
86
  /** 移除账号(serenity.json 结构 + localstore 凭据;同时停桥由调用方 syncCccBridge) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.27.2",
3
+ "version": "1.27.4",
4
4
  "description": "宁静号 ACC harness — Native Cordis 插件(DeepSeek Harness 运行时)。真实 DSH 工具注册(cc_fs/session/acc_msm 等 9 工具)+ 拦截缝机械约束(safe-mode/路径守卫/会话落盘)+ 系统提示词注入(ACC/CCE/Constraints/SKILL/Session 五块)。适配 DSH 公开版(deepseek-ai/deepseek-harness 0.1.0-rc)。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,8 +31,8 @@
31
31
  "cordis.patch.yml",
32
32
  "dsh.plugin.json",
33
33
  "README.md",
34
- "experiments/autotrajectory/SKILL.md",
35
- "experiments/autotrajectory/scripts/autotrajectory-exp.ts"
34
+ "experiments/autopilot-trajectory/SKILL.md",
35
+ "experiments/autopilot-trajectory/scripts/autopilot-trajectory.ts"
36
36
  ],
37
37
  "scripts": {
38
38
  "typecheck": "tsc --noEmit",