Sfoglia il codice sorgente

merge: publish fmode-qiwei 0.5.2

# Conflicts:
#	claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js
#	claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js
#	claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
#	claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
#	claude-code/claude-code-qiwe-assistant/package.json
#	claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs
#	claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js
#	claude-code/claude-code-qiwe-assistant/scripts/startup-preview-smoke-test.js
gangvy 1 mese fa
parent
commit
e8d9ccfbfd
40 ha cambiato i file con 1634 aggiunte e 163 eliminazioni
  1. 1 1
      claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json
  2. 6 0
      claude-code/claude-code-qiwe-assistant/.env.example
  3. 2 1
      claude-code/claude-code-qiwe-assistant/README.md
  4. 5 3
      claude-code/claude-code-qiwe-assistant/docs/RELEASE.md
  5. 120 0
      claude-code/claude-code-qiwe-assistant/docs/guides/ACCOUNT-SWITCH-DEMO.md
  6. 23 1
      claude-code/claude-code-qiwe-assistant/install.js
  7. 32 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js
  8. 41 11
      claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js
  9. 11 7
      claude-code/claude-code-qiwe-assistant/mcp/src/core/login-fallback-server.js
  10. 14 10
      claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js
  11. 201 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/message-archive.js
  12. 125 27
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  13. 115 23
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  14. 32 4
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js
  15. 10 2
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  16. 76 1
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js
  17. 11 5
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js
  18. 2 1
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  19. 25 2
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js
  20. 17 6
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js
  21. 3 2
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-subscription-run.js
  22. 11 7
      claude-code/claude-code-qiwe-assistant/package-lock.json
  23. 6 3
      claude-code/claude-code-qiwe-assistant/package.json
  24. 93 23
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/personal-polling.mjs
  25. 8 3
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs
  26. 151 0
      claude-code/claude-code-qiwe-assistant/scripts/account-switch-smoke-test.js
  27. 137 4
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  28. 35 0
      claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs
  29. 66 0
      claude-code/claude-code-qiwe-assistant/scripts/message-archive-smoke-test.js
  30. 1 0
      claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js
  31. 3 2
      claude-code/claude-code-qiwe-assistant/scripts/start-login-flow-4200.js
  32. 17 0
      claude-code/claude-code-qiwe-assistant/scripts/startup-preview-smoke-test.js
  33. 64 9
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  34. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md
  35. 3 2
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  36. 10 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-login/SKILL.md
  37. 53 0
      claude-code/claude-code-qiwe-assistant/templates/workspace.gitignore
  38. 51 0
      release/npm/fmode-qiwei/publish.ps1
  39. 15 1
      release/npm发布管理文档/NPM技能包发布管理.md
  40. 34 2
      release/npm发布管理文档/变更记录/fmode-qiwei.md

+ 1 - 1
claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "qiwei-assistant",
   "description": "Enterprise WeChat intelligent office, goal management, customer service, CRM and API skills for Claude Code and Fmode Studio.",
-  "version": "0.4.3",
+  "version": "0.5.2",
   "author": {
     "name": "fmode"
   }

+ 6 - 0
claude-code/claude-code-qiwe-assistant/.env.example

@@ -55,6 +55,12 @@ QIWEI_AGENT_INITIAL_SYNC_LIMIT=5000
 QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES=200
 QIWEI_AGENT_STARTUP_GRACE_SECONDS=10
 
+# 可选:额外生成便于检索的 JSONL 消息流水。默认关闭;Workbench 数据库归档不受影响。
+QIWEI_MESSAGE_ARCHIVE_ENABLED=0
+
+# 可选:为每位联系人生成并允许 Agent 维护通用五文件记忆。默认关闭。
+QIWEI_AGENT_MEMORY_ENABLED=0
+
 # 客服回复时效监控(仅本地提醒,不自动发送企微消息)
 QIWEI_RESPONSE_REMINDER_MINUTES=15
 QIWEI_RESPONSE_URGENT_MINUTES=60

+ 2 - 1
claude-code/claude-code-qiwe-assistant/README.md

@@ -479,9 +479,10 @@ node install.js workspace <客户项目目录> --smoke
 <客户项目>/.claude/plugins/qiwei-assistant
 <客户项目>/.claude/skills/<qiwei-skill>
 <客户项目>/.mcp.json
+<客户项目>/.gitignore
 ```
 
-安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前公开版本为 `fmode-qiwei@0.4.3`;版本与验证记录见 `docs/RELEASE.md`。
+安装器会幂等补全项目根目录 `.gitignore`,保留项目已有规则,并忽略本地凭据、运行数据库、Session、日志和依赖目录。安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前版本为 `fmode-qiwei@0.5.2`;版本与验证记录见 `docs/RELEASE.md`。
 
 ## 子 Skill 索引
 

+ 5 - 3
claude-code/claude-code-qiwe-assistant/docs/RELEASE.md

@@ -3,8 +3,8 @@
 ## 当前状态
 
 - npm 包名:`fmode-qiwei`
-- 当前版本:`0.4.3`
-- 状态:已发布到 npm,`latest=0.4.3`
+- 当前版本:`0.5.2`
+- 状态:已发布到 npm,`latest=0.5.2`
 - 运行要求:Node.js `22.5.0+`(使用内置 `node:sqlite`)
 - Dashboard:`http://127.0.0.1:4320/`
 
@@ -30,7 +30,7 @@ node install.js workspace <客户项目目录> --smoke
 本地 tgz:
 
 ```powershell
-npm install -g .\fmode-qiwei-0.4.3.tgz
+npm install -g .\fmode-qiwei-0.5.2.tgz
 fmode-qiwei workspace <客户项目目录> --smoke
 ```
 
@@ -46,9 +46,11 @@ workspace 安装会生成:
 <客户项目>/.claude/plugins/qiwei-assistant
 <客户项目>/.claude/skills/<skill-name>/SKILL.md
 <客户项目>/.mcp.json
+<客户项目>/.gitignore
 ```
 
 项目级 `.mcp.json` 使用安装目录内 `mcp/src/server.js` 的绝对路径,可从任意工作目录启动。
+项目级 `.gitignore` 使用随 npm 包发布的模板幂等合并,保留客户已有规则并过滤本地凭据、运行状态和临时文件。
 
 ## 凭据与本地数据
 

+ 120 - 0
claude-code/claude-code-qiwe-assistant/docs/guides/ACCOUNT-SWITCH-DEMO.md

@@ -0,0 +1,120 @@
+# 企业微信新账号扫码与多账号切换演示手册
+
+## 1. 本次演示目标
+
+在一个新克隆的项目目录中完成:
+
+1. 安装通用版 `fmode-qiwei`;
+2. 使用新设备 UID 生成企业微信二维码;
+3. 扫码、必要时提交 6 位验证码;
+4. 登录成功后保存新账号并切换到该账号;
+5. 重启工作台后仍恢复正确账号;
+6. 在两个已保存账号之间往返切换,客户 Workbench 与 Claude Session 不串号。
+
+本流程不包含房源、看房、购房画像或房源推荐 Skill。
+
+## 2. 演示前准备
+
+- Node.js `22.5.0+`;
+- 已登录 Fmode Studio,或当前项目能读取有效 Fmode 鉴权;
+- 手机企业微信可扫码;
+- 新项目目录可写;
+- 端口 `4320` 未被其他项目占用。
+
+克隆项目后,先备份项目根目录的 `.env.local`。若本次必须演示“全新账号首次绑定”,可删除克隆文件中的 `QIWEI_UID` 与 `QIWEI_GUID` 两行;保留现有 Fmode 鉴权配置。不要把 `.env.local` 发到聊天或提交到 Git。
+
+## 3. 从本地迁移版安装
+
+在 PowerShell 中执行:
+
+```powershell
+$Source = 'E:\workspace\openclaw-voc-skill\claude-code\claude-code-qiwe-assistant'
+$Project = 'E:\你的新克隆项目'
+node "$Source\install.js" workspace "$Project" --smoke
+Set-Location $Project
+node .claude\plugins\qiwei-assistant\install.js preview .
+```
+
+浏览器应打开:
+
+```text
+http://127.0.0.1:4320/
+```
+
+> `0.5.1` 发布后可直接使用 `npx --yes fmode-qiwei@latest workspace --smoke`;上述本地源码命令继续用于开发分支验证。
+
+## 4. 页面操作步骤
+
+1. 打开右上角账号菜单,点击 **添加账号**;或进入 **账号状态 → 添加账号**。
+2. 页面会生成一个新的设备 UID,并请求新的登录二维码;旧账号仍保留在已保存账号列表中。
+3. 用待演示的企业微信扫码,并在手机端确认。
+4. 若页面出现验证码输入框,输入手机端显示的 6 位数字。
+5. 等待页面显示“登录成功”和新账号名称。
+6. 进入 **账号状态**,确认新账号为当前账号且状态为在线。
+7. 在已保存账号中选择旧账号,点击 **切换**;再切回新账号。
+8. 打开 **智能会话**,确认切换后显示的是对应账号的独立会话数据。
+9. 选择测试联系人加入白名单,将全局模式保持为 **待审核**,再启动监听。
+
+## 5. 必做验收
+
+### 扫码链路
+
+- 二维码能展示;
+- 状态能从等待扫码进入已确认或验证码,再进入登录成功;
+- 新 UID 的请求没有携带旧账号 GUID;
+- 扫码取消后原账号仍可继续使用。
+
+### 切换与重启
+
+- 切换接口成功后页面才更新当前账号;
+- 项目 `.env.local` 中的 UID/GUID 属于同一个当前账号;
+- 关闭工作台并重新执行 preview,当前账号仍正确;
+- A → B → A 往返切换后,会话、草稿和 Claude Session 不串号;
+- 监听恢复不把原来的审核/自动/人工模式重置。
+
+### 工作区隔离
+
+- 同一浏览器打开另一个克隆项目时,账号列表使用新的 `workspaceId`;
+- 不会因为同样使用 `127.0.0.1:4320` 而自动写入上一个项目的账号。
+
+## 6. 快速自检命令
+
+在技能包目录执行:
+
+```powershell
+npm run check
+npm run account:switch-smoke
+npm run runtime:smoke
+npm run agent:smoke
+npm run connection:smoke
+npm run install:check
+npm run package:smoke
+```
+
+专项测试不访问真实账号,也不输出任何凭据。
+
+## 7. 刘总交给 Agent 的一键配置提示词
+
+```text
+请在当前克隆项目中完成 fmode-qiwei 通用版的安装、启动和新企业微信账号绑定,并把每一步结果简洁汇报给我。
+
+执行要求:
+1. 先确认 Node.js >= 22.5,并识别当前项目根目录;不要读取或输出任何 Token、GUID、sessionToken、Authorization 或 .env.local 全文。
+2. 优先使用已经安装在当前项目的 .claude/plugins/qiwei-assistant;若未安装,使用我提供的本地 fmode-qiwei 源码目录执行 workspace --smoke 安装。不要安装或启用任何房源、看房、购房画像、房源推荐 Skill。
+3. 检查 4320 端口;若被其他项目占用,先确认对应进程,停止旧项目服务后再启动当前项目,不要让两个项目共用同一个工作台进程。
+4. 启动 preview,读取 /api/health,确认返回的 workspaceId 属于当前项目。
+5. 本次目标是“添加新企微账号”:生成全新的 uid;调用登录开始、状态检查和验证码时始终使用该 uid,禁止复用旧账号 guid;扫码成功前 persistConfig=false。
+6. 展示二维码并等待我扫码。状态为 10 时只向我询问手机端 6 位验证码;状态为 2 后保存该 uid 与本次返回的 guid,并切换为当前账号。
+7. 若扫码取消或切换失败,恢复原当前账号并保留已保存账号列表,然后报告失败步骤和可重试动作。
+8. 登录成功后验证账号在线;重启一次工作台验证账号能恢复;如已有两个账号,再执行 A→B→A 切换并确认 Workbench/Claude 客户 Session 隔离。
+9. 打开智能会话,把测试联系人加入白名单;默认保持待审核模式,启动监听。不要自动向任何非白名单联系人发送消息。
+10. 最终只汇报:安装状态、workspaceId 后 6 位、扫码状态、当前账号脱敏名称、重启恢复、账号切换、白名单、监听状态和未完成项。不要回显任何凭据或完整设备标识。
+```
+
+## 8. 回滚
+
+1. 停止当前项目工作台与监听;
+2. 恢复演示前备份的 `.env.local`;
+3. 重新启动工作台;
+4. 在账号状态页切回原账号;
+5. 确认白名单与 Agent 模式符合演示前状态。

+ 23 - 1
claude-code/claude-code-qiwe-assistant/install.js

@@ -8,6 +8,9 @@ const { spawnSync } = require('child_process');
 const ROOT = __dirname;
 const EXCLUDED_ROOTS = new Set(['.claude', '.git', '.github', '.playwright-cli', '.vscode', 'coverage', 'dist', 'node_modules', 'output', 'outputs']);
 const EXCLUDED_FILES = new Set(['.env', '.env.local', '.npmrc', 'poll_reply.log', 'poll_state.json']);
+const WORKSPACE_GITIGNORE_TEMPLATE = path.join(ROOT, 'templates', 'workspace.gitignore');
+const WORKSPACE_GITIGNORE_START = '# >>> qiwei-assistant managed ignores >>>';
+const WORKSPACE_GITIGNORE_END = '# <<< qiwei-assistant managed ignores <<<';
 
 function usage() {
   return [
@@ -19,7 +22,7 @@ function usage() {
     '  fmode-qiwei workspace [客户项目目录] --smoke',
     '  fmode-qiwei preview [客户项目目录] [--no-open] [--port 4320]',
     '',
-    'workspace 模式会写入 .claude/plugins、.claude/skills 和项目级 .mcp.json。',
+    'workspace 模式会写入 .claude/plugins、.claude/skills、项目级 .mcp.json,并补全 .gitignore。',
     'preview 模式会启动工作台、检查当前状态并打开浏览器。',
   ].join('\n');
 }
@@ -42,6 +45,7 @@ function assertRequiredFiles() {
     'mcp/src/server.js',
     'runtime/callback-service/src/index.mjs',
     'qiwei.runtime.config.example.mjs',
+    'templates/workspace.gitignore',
     'skills/qiwei-dashboard/SKILL.md',
     'skills/qiwei-goal-management/SKILL.md',
     'skills/qiwei-real-estate-auto-reply/SKILL.md',
@@ -89,6 +93,23 @@ function copyTree(source, destination, filter = () => true) {
   fs.copyFileSync(source, destination);
 }
 
+function mergeWorkspaceGitignore(target) {
+  const gitignorePath = path.join(target, '.gitignore');
+  const managedBlock = fs.readFileSync(WORKSPACE_GITIGNORE_TEMPLATE, 'utf8').trim();
+  let current = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
+  const start = current.indexOf(WORKSPACE_GITIGNORE_START);
+  const end = current.indexOf(WORKSPACE_GITIGNORE_END, start + WORKSPACE_GITIGNORE_START.length);
+
+  if (start >= 0 && end >= start) {
+    current = `${current.slice(0, start)}${managedBlock}${current.slice(end + WORKSPACE_GITIGNORE_END.length)}`;
+  } else {
+    const prefix = current.trimEnd();
+    current = `${prefix}${prefix ? '\n\n' : ''}${managedBlock}\n`;
+  }
+  fs.writeFileSync(gitignorePath, current.replace(/\n*$/, '\n'), 'utf8');
+  return gitignorePath;
+}
+
 function run(command, args, cwd, options = {}) {
   const useCmd = process.platform === 'win32' && command === 'npm';
   const result = spawnSync(useCmd ? 'cmd.exe' : command, useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args, {
@@ -104,6 +125,7 @@ function run(command, args, cwd, options = {}) {
 function installWorkspace(targetInput, options = {}) {
   const target = path.resolve(targetInput || process.cwd());
   fs.mkdirSync(target, { recursive: true });
+  mergeWorkspaceGitignore(target);
   const pluginsRoot = path.join(target, '.claude', 'plugins');
   const skillsRoot = path.join(target, '.claude', 'skills');
   const pluginDir = path.join(pluginsRoot, 'qiwei-assistant');

+ 32 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js

@@ -4,6 +4,7 @@ const { isGroupMessage } = require('./agent-poller-policy');
 const { friendlyAgentError } = require('./agent-error-message');
 const { AgentMemoryManager } = require('./agent-memory');
 const { AgentMemoryExtractionWorker } = require('./agent-memory-worker');
+const { appendChatRecord, ensureMemory } = require('./message-archive');
 
 function looksLikeGroupId(value) {
   return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
@@ -199,6 +200,7 @@ class AgentWorkbenchService extends EventEmitter {
     if (isGroupMessage(raw || {}) || looksLikeGroupId(contactId)) return { status: 'ignored_group_message' };
     if (!this.isAllowed(contactId)) return { status: 'ignored_not_allowlisted' };
     const conversation = this.db.ensureConversation(contactId, contactName);
+    ensureMemory(contactId, contactName);
     const contentDuplicate = this.db.findRecentInboundDuplicate(conversation.id, content, timestamp, 60);
     if (contentDuplicate) {
       this.db.audit({
@@ -221,6 +223,16 @@ class AgentWorkbenchService extends EventEmitter {
       raw,
     });
     if (!inserted.created) return { status: 'duplicate', conversation, message: inserted.message };
+    appendChatRecord({
+      wxid: contactId,
+      messageId: inserted.message.id,
+      externalId,
+      dir: 'in',
+      senderType: 'customer',
+      content,
+      createdAt: timestamp,
+      source: raw?.source || 'ingest_inbound',
+    });
 
     this.db.audit({ actor: 'qiwei', action: 'message_received', conversationId: conversation.id, entityId: inserted.message.id });
     this.emit('change', { type: 'message', conversationId: conversation.id });
@@ -424,6 +436,16 @@ class AgentWorkbenchService extends EventEmitter {
         content: finalContent,
         status: result.isSendSuccess === false ? 'submitted' : 'sent',
       }).message;
+      appendChatRecord({
+        wxid: conversation.contact_id,
+        messageId: outbound.id,
+        externalId: null,
+        dir: 'out',
+        senderType: actor === 'agent:auto' ? 'agent' : 'human',
+        content: finalContent,
+        createdAt: outbound.created_at,
+        source: 'draft_approved',
+      });
       const sentRecommendations = propertyRecommendationsFromTrace(draft.tool_trace);
       if (sentRecommendations.length) {
         this.db.upsertCustomerRecommendations(conversation.id, sentRecommendations, {
@@ -513,6 +535,16 @@ class AgentWorkbenchService extends EventEmitter {
       content: text,
       status: result.isSendSuccess === false ? 'submitted' : 'sent',
     }).message;
+    appendChatRecord({
+      wxid: conversation.contact_id,
+      messageId: message.id,
+      externalId: null,
+      dir: 'out',
+      senderType: 'human',
+      content: text,
+      createdAt: message.created_at,
+      source: 'manual',
+    });
     if (/(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(text)) {
       this.db.completeCustomerTaskByBusinessKey(conversationId, 'recommendation:shortlist', 'manual_recommendation_sent');
     }

+ 41 - 11
claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js

@@ -74,7 +74,7 @@ function readEnvFiles() {
   return envCandidates().reduce((merged, filePath) => {
     const next = readEnvFileMaybe(filePath);
     for (const [key, value] of Object.entries(next)) {
-      if (!merged[key]) merged[key] = value;
+      if (!Object.prototype.hasOwnProperty.call(merged, key)) merged[key] = value;
     }
     return merged;
   }, {});
@@ -202,7 +202,13 @@ function readQiweiApiBase(input = {}) {
   ]) || DEFAULT_API_BASE).replace(/\/$/, '');
 }
 
-function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
+function resolveQiweiEnvPath(envRoot) {
+  return path.join(envRoot ? path.resolve(envRoot) : process.cwd(), '.env.local');
+}
+
+function saveQiweiClientConfig(input = {}) {
+  const { uid, apiBase, guid, authToken, envRoot } = input;
+  const hasGuid = Object.prototype.hasOwnProperty.call(input, 'guid');
   const saved = [];
   try {
     const dir = path.dirname(CREDENTIALS_FILE);
@@ -211,7 +217,10 @@ function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
     const next = { ...current };
     if (uid) next.uid = uid;
     if (apiBase) next.apiBase = apiBase;
-    if (guid) next.guid = guid;
+    if (hasGuid) {
+      if (guid) next.guid = guid;
+      else delete next.guid;
+    }
     next.updatedAt = new Date().toISOString();
     fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(next, null, 2), 'utf8');
     saved.push(CREDENTIALS_FILE);
@@ -219,11 +228,11 @@ function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
     // ignore, fall through to env file
   }
   try {
-    const envPath = path.join(process.cwd(), '.env.local');
+    const envPath = resolveQiweiEnvPath(envRoot);
     const pairs = [];
     if (uid) pairs.push(['QIWEI_UID', uid]);
     if (apiBase) pairs.push(['QIWEI_API_BASE', apiBase]);
-    if (guid) pairs.push(['QIWEI_GUID', guid]);
+    if (hasGuid) pairs.push(['QIWEI_GUID', String(guid || '').trim()]);
     if (authToken) pairs.push(['QIWEI_AUTH_TOKEN', normalizeToken(authToken)]);
     if (pairs.length) {
       let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
@@ -240,22 +249,42 @@ function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
   } catch {
     // ignore
   }
+  if (uid) process.env.QIWEI_UID = String(uid).trim();
+  if (apiBase) process.env.QIWEI_API_BASE = String(apiBase).trim().replace(/\/$/, '');
+  if (hasGuid) {
+    if (guid) process.env.QIWEI_GUID = String(guid).trim();
+    else process.env.QIWEI_GUID = '';
+  }
   return saved;
 }
 
 function readQiweiGuid(input = {}) {
   const fileEnv = readEnvFiles();
   const creds = readCredentialsFile();
+  const processGuid = Object.prototype.hasOwnProperty.call(process.env, 'QIWEI_GUID')
+    ? process.env.QIWEI_GUID
+    : process.env.QIWE_GUID;
+  const fileGuid = Object.prototype.hasOwnProperty.call(fileEnv, 'QIWEI_GUID')
+    ? fileEnv.QIWEI_GUID
+    : fileEnv.QIWE_GUID;
+  const requestedUid = firstNonEmpty([input.uid, input.qiweiUid]);
+  const storedUid = firstNonEmpty([
+    process.env.QIWEI_UID,
+    process.env.QIWE_UID,
+    fileEnv.QIWEI_UID,
+    fileEnv.QIWE_UID,
+    creds.uid,
+    activeQiweiContext.uid
+  ]);
+  const contextMatchesUid = !requestedUid || !storedUid || requestedUid === storedUid;
   return firstNonEmpty([
     input.guid,
     input.qiweiGuid,
     input.deviceGuid,
-    activeQiweiContext.guid,
-    process.env.QIWEI_GUID,
-    process.env.QIWE_GUID,
-    fileEnv.QIWEI_GUID,
-    fileEnv.QIWE_GUID,
-    creds.guid
+    contextMatchesUid ? activeQiweiContext.guid : '',
+    contextMatchesUid ? processGuid : '',
+    contextMatchesUid ? fileGuid : '',
+    contextMatchesUid ? creds.guid : ''
   ]);
 }
 
@@ -309,6 +338,7 @@ module.exports = {
   readFmodeLlmBase,
   ensureQiweiUid,
   readQiweiApiBase,
+  resolveQiweiEnvPath,
   saveQiweiClientConfig,
   setActiveQiweiContext,
   getActiveQiweiContext,

+ 11 - 7
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-fallback-server.js

@@ -9,6 +9,10 @@ let currentFallbackServer = null;
 let currentFallbackContext = null;
 let fallbackIdleTimer = null;
 
+function loginBody(uid, guid, extra = {}) {
+  return { uid, ...(guid ? { guid } : {}), ...extra };
+}
+
 function resetIdleTimer() {
   if (fallbackIdleTimer) clearTimeout(fallbackIdleTimer);
   fallbackIdleTimer = setTimeout(() => {
@@ -36,7 +40,7 @@ function createFallbackHandler(ctx) {
         return;
       }
       if (url.pathname === '/flow/check') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const data = result.data || {};
         json(res, 200, { status: data.status, detail: data.detail || {} });
         return;
@@ -48,12 +52,12 @@ function createFallbackHandler(ctx) {
           json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' });
           return;
         }
-        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: { uid: ctx.uid, code }, token: ctx.token, apiBase: ctx.apiBase });
+        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: loginBody(ctx.uid, ctx.guid, { code }), token: ctx.token, apiBase: ctx.apiBase });
         json(res, 200, { ok: true });
         return;
       }
       if (url.pathname === '/flow/refresh' && req.method === 'POST') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, '');
         if (!base64) {
           json(res, 502, { errorCode: 'QW-UP-502', message: '网关未返回二维码' });
@@ -70,20 +74,20 @@ function createFallbackHandler(ctx) {
       const safeStatus = httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502;
       json(res, safeStatus, {
         errorCode: 'QW-UP-' + safeStatus,
-        message: String((error && error.message) || '请求失败')
+        message: String((error && (error.bizMessage || error.message)) || '请求失败')
       });
     }
   };
 }
 
-function startLoginFallbackServer({ token, apiBase, uid, qrcodeBuffer, port, onQrcode } = {}) {
+function startLoginFallbackServer({ token, apiBase, uid, guid, qrcodeBuffer, port, onQrcode } = {}) {
   const listenPort = Number(port || process.env.QIWEI_FALLBACK_PORT) || DEFAULT_FALLBACK_PORT;
   if (currentFallbackServer && currentFallbackContext) {
-    Object.assign(currentFallbackContext, { token, apiBase, uid, qrcodeBuffer, onQrcode });
+    Object.assign(currentFallbackContext, { token, apiBase, uid, guid, qrcodeBuffer, onQrcode });
     resetIdleTimer();
     return Promise.resolve({ url: `http://127.0.0.1:${currentFallbackContext.port}/`, alreadyRunning: true });
   }
-  const ctx = { token, apiBase, uid, qrcodeBuffer, onQrcode };
+  const ctx = { token, apiBase, uid, guid, qrcodeBuffer, onQrcode };
   const server = http.createServer(createFallbackHandler(ctx));
   return new Promise((resolve, reject) => {
     server.once('error', reject);

+ 14 - 10
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js

@@ -42,6 +42,10 @@ const FLOW_PAGE_STYLE = `
 let currentServer = null;
 let currentContext = null;
 
+function loginBody(uid, guid, extra = {}) {
+  return { uid, ...(guid ? { guid } : {}), ...extra };
+}
+
 function buildFlowPageHtml({ monthlyPrice, checkoutToken }) {
   return `<!DOCTYPE html>
 <html lang="zh-CN">
@@ -423,18 +427,18 @@ function createFlowHandler(ctx) {
         let stateError = false;
         let monthlyPrice = QIWEI_MONTHLY_PRICE;
         try {
-          const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase });
+          const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase, cacheBust: true });
           subscribed = Boolean(sub.data && sub.data.subscribed);
           const livePrice = Number(sub.data && sub.data.price);
           if (Number.isFinite(livePrice) && livePrice > 0) monthlyPrice = livePrice;
           if (!subscribed) subscribeDetail = '尚未开通包月订阅';
         } catch (error) {
           stateError = true;
-          subscribeDetail = String((error && error.message) || '订阅状态查询失败');
+          subscribeDetail = String((error && (error.bizMessage || error.message)) || '订阅状态查询失败');
         }
         if (subscribed) {
           try {
-            const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+            const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid, ...(ctx.guid ? { guid: ctx.guid } : {}) }, token: ctx.token, apiBase: ctx.apiBase });
             online = Boolean(status.data && status.data.online);
             detail = (status.data && status.data.detail) || null;
           } catch {
@@ -484,7 +488,7 @@ function createFlowHandler(ctx) {
         return;
       }
       if (url.pathname === '/flow/start-login' && req.method === 'POST') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, '');
         if (!base64) {
           json(res, 502, subscribeErrorBody(502, '网关未返回二维码'));
@@ -505,7 +509,7 @@ function createFlowHandler(ctx) {
         return;
       }
       if (url.pathname === '/flow/check') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const data = result.data || {};
         json(res, 200, { status: data.status, detail: data.detail || {} });
         return;
@@ -517,25 +521,25 @@ function createFlowHandler(ctx) {
           json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' });
           return;
         }
-        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: { uid: ctx.uid, code }, token: ctx.token, apiBase: ctx.apiBase });
+        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: loginBody(ctx.uid, ctx.guid, { code }), token: ctx.token, apiBase: ctx.apiBase });
         json(res, 200, { ok: true });
         return;
       }
       json(res, 404, { message: 'not found' });
     } catch (error) {
       const httpStatus = Number((error && error.httpStatus) || 502);
-      json(res, httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502, subscribeErrorBody(httpStatus, error && error.message));
+      json(res, httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502, subscribeErrorBody(httpStatus, error && (error.bizMessage || error.message)));
     }
   };
 }
 
-function startLoginFlowServer({ token, apiBase, uid, port, onQrcode } = {}) {
+function startLoginFlowServer({ token, apiBase, uid, guid, port, onQrcode } = {}) {
   const listenPort = Number(port || process.env.QIWEI_FLOW_PORT) || DEFAULT_FLOW_PORT;
   if (currentServer && currentContext) {
-    Object.assign(currentContext, { token, apiBase, uid, onQrcode, checkoutToken: createCheckoutToken() });
+    Object.assign(currentContext, { token, apiBase, uid, guid, onQrcode, checkoutToken: createCheckoutToken() });
     return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true });
   }
-  const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
+  const ctx = { token, apiBase, uid, guid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
   const server = http.createServer(createFlowHandler(ctx));
   return new Promise((resolve, reject) => {
     server.once('error', reject);

+ 201 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/message-archive.js

@@ -0,0 +1,201 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { WORKSPACE_ROOT } = require('./output-paths');
+
+const MESSAGES_ROOT = process.env.QIWEI_MESSAGES_DIR
+  ? path.resolve(process.env.QIWEI_MESSAGES_DIR)
+  : path.join(WORKSPACE_ROOT, 'messages');
+const MEMORY_FILES = Object.freeze(['indexes.md', 'profile.md', 'facts.md', 'tasks.md', 'relationship.md']);
+const writeQueues = new Map();
+
+function safeDirName(raw) {
+  return String(raw || '')
+    .replace(/[\\/:*?"<>|\[\]{}]+/g, '_')
+    .replace(/[\s\r\n\t]+/g, '_')
+    .slice(0, 64) || '_default';
+}
+
+function dateOf(iso) {
+  const value = String(iso || '');
+  return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : new Date().toISOString().slice(0, 10);
+}
+
+function memoryEnabled(options = {}) {
+  if (typeof options.enabled === 'boolean') return options.enabled;
+  return String(process.env.QIWEI_AGENT_MEMORY_ENABLED || '').trim() === '1';
+}
+
+function archiveEnabled(options = {}) {
+  if (typeof options.enabled === 'boolean') return options.enabled;
+  return String(process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED || '').trim() === '1';
+}
+
+function enqueueAppend(filePath, line, options = {}) {
+  if (!archiveEnabled(options)) return Promise.resolve({ skipped: true });
+  const previous = writeQueues.get(filePath) || Promise.resolve();
+  const next = previous.then(() => {
+    try {
+      fs.mkdirSync(path.dirname(filePath), { recursive: true });
+      fs.appendFileSync(filePath, `${line}\n`, 'utf8');
+      return { written: true, filePath };
+    } catch (error) {
+      return { error: error.message };
+    }
+  }).catch(error => ({ error: error.message }));
+  writeQueues.set(filePath, next.catch(() => {}));
+  return next;
+}
+
+function drainQueues() {
+  return Promise.all([...writeQueues.values()]);
+}
+
+function appendChatRecord({ wxid, messageId, externalId, dir, senderType, content, createdAt, source }, options = {}) {
+  const outbound = String(dir) === 'out';
+  const row = {
+    id: messageId || '',
+    externalId: externalId || null,
+    ts: createdAt || new Date().toISOString(),
+    dir: outbound ? 'out' : 'in',
+    sender: wxid || '',
+    senderType: senderType || (outbound ? 'human' : 'customer'),
+    content: String(content ?? ''),
+    source: source || '',
+  };
+  const filePath = path.join(MESSAGES_ROOT, 'chat', safeDirName(wxid), `${dateOf(row.ts)}.json`);
+  return enqueueAppend(filePath, JSON.stringify(row), options);
+}
+
+function appendGroupRecord({ roomId, messageId, seq, msgType, senderId, senderName, self, content, timestamp }, options = {}) {
+  const row = {
+    id: messageId || '',
+    ts: timestamp || new Date().toISOString(),
+    roomId: roomId || '',
+    dir: self ? 'out' : 'in',
+    sender: senderId || '',
+    senderName: senderName || '',
+    self: Boolean(self),
+    seq: seq ?? null,
+    msgType: String(msgType ?? ''),
+    content: String(content ?? ''),
+  };
+  const filePath = path.join(MESSAGES_ROOT, 'group', safeDirName(roomId), `${dateOf(row.ts)}.json`);
+  return enqueueAppend(filePath, JSON.stringify(row), options);
+}
+
+function memoryTemplate(name, contactName = '') {
+  if (name === 'indexes.md') return `# 用户记忆索引
+> 仅记录长期有效、与服务相关且有依据的信息;普通聊天保留在消息流水中。
+
+## 一句话认知
+(角色、当前目标、服务阶段、最重要的约束或风险)
+
+## 各文件摘要
+| 文件 | 摘要 | 最后更新 |
+|---|---|---|
+| profile.md | 身份、目标、需求与偏好 | - |
+| facts.md | 已确认事实、待确认推断与冲突 | - |
+| tasks.md | 待办、承诺与进展 | - |
+| relationship.md | 沟通偏好、雷区与关系脉络 | - |
+
+## 待确认 / 存疑
+(未确认问题、矛盾点、下一步要问的关键变量)
+`;
+  if (name === 'profile.md') return `# 用户画像
+## 基本信息
+- 称呼:${contactName || '(待确认)'}
+- 身份 / 角色:
+- 所属组织 / 场景:
+
+## 当前目标与需求
+- 核心目标:
+- 必要条件:
+- 偏好:
+- 时间要求:
+- 决策角色:
+
+## 约束与风险
+- 预算 / 资源约束:
+- 合规 / 权限边界:
+- 待确认事项:
+
+## 证据
+- [YYYY-MM-DD] 来源:…
+`;
+  if (name === 'facts.md') return `# 用户关键事实
+> A=用户明确表达或工具核验;B=合理推断但待确认;C=矛盾或弱线索。
+
+## A 级(高置信)
+- [YYYY-MM-DD][A] …
+
+## B 级(待确认)
+- [YYYY-MM-DD][B] …
+
+## C 级(冲突 / 弱线索)
+- [YYYY-MM-DD][C] …
+`;
+  if (name === 'tasks.md') return `# 用户待办与承诺
+## 待办
+- [ ] YYYY-MM-DD …
+
+## 已作承诺
+- [ ] YYYY-MM-DD …
+
+## 进展时间线
+- YYYY-MM-DD …
+`;
+  if (name === 'relationship.md') return `# 用户沟通偏好
+## 沟通方式与时段
+
+## 回复风格与节奏
+
+## 近期话题脉络
+
+## 雷区 / 注意事项
+`;
+  return '';
+}
+
+function ensureMemory(wxid, contactName = '', options = {}) {
+  if (!memoryEnabled(options)) return { enabled: false, dir: '', created: [] };
+  const dir = path.join(MESSAGES_ROOT, 'memory', safeDirName(wxid));
+  fs.mkdirSync(dir, { recursive: true });
+  const created = [];
+  for (const name of MEMORY_FILES) {
+    const filePath = path.join(dir, name);
+    if (fs.existsSync(filePath)) continue;
+    fs.writeFileSync(filePath, memoryTemplate(name, contactName), 'utf8');
+    created.push(name);
+  }
+  return { enabled: true, dir, created };
+}
+
+function memoryIndexText(wxid, options = {}) {
+  if (!memoryEnabled(options)) return '';
+  try {
+    const filePath = path.join(MESSAGES_ROOT, 'memory', safeDirName(wxid), 'indexes.md');
+    return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
+  } catch {
+    return '';
+  }
+}
+
+function memoryRelBase(wxid) {
+  return path.posix.join('messages', 'memory', safeDirName(wxid));
+}
+
+module.exports = {
+  MESSAGES_ROOT,
+  MEMORY_FILES,
+  safeDirName,
+  memoryEnabled,
+  archiveEnabled,
+  appendChatRecord,
+  appendGroupRecord,
+  ensureMemory,
+  memoryIndexText,
+  memoryRelBase,
+  drainQueues,
+};

+ 125 - 27
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -16,7 +16,7 @@ const {
 } = require('./official-office-knowledge-service');
 const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
 const { messageTimestamp, roomIdOf, isGroupMessage, messageContent, evaluatePolledMessage } = require('../core/agent-poller-policy');
-const { setActiveQiweiContext } = require('../core/credentials');
+const { saveQiweiClientConfig, setActiveQiweiContext } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const { responseMonitor } = require('./response-monitor-service');
 const { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue } = require('../core/allowlist-config');
@@ -44,6 +44,18 @@ function readEnvFile(filePath) {
   }
 }
 
+function refreshAllowedSendersFromEnv(config = {}, envFile = ENV_FILE) {
+  const env = readEnvFile(envFile);
+  if (!Object.prototype.hasOwnProperty.call(env, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS')) {
+    return { changed: false, count: Array.isArray(config.allowedSenders) ? config.allowedSenders.length : 0 };
+  }
+  const next = normalizeAllowlistIds(env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS);
+  const current = Array.isArray(config.allowedSenders) ? config.allowedSenders.map(String) : [];
+  const changed = next.length !== current.length || next.some((id, index) => id !== current[index]);
+  if (changed) config.allowedSenders = next;
+  return { changed, count: next.length };
+}
+
 function readRuntimeState() {
   try {
     const filePath = path.join(outputsRoot(), 'runtime', 'qiwei-runtime.json');
@@ -133,7 +145,7 @@ function loadAgentConfig(overrides = {}) {
   const baseConfig = {
     dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
     legacyDbPath: path.resolve(PACKAGE_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
-    globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', true),
+    globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', false),
     conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
     autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
     knowledgeDir: resolvePath(
@@ -395,6 +407,10 @@ class QiweiAgentPoller {
   }
 
   async process(message) {
+    const allowlist = refreshAllowedSendersFromEnv(this.config);
+    if (allowlist.changed) {
+      this.db.audit({ actor: 'runtime', action: 'poller_allowlist_reloaded', detail: { count: allowlist.count } });
+    }
     return ingestMessageForWorkbench({ config: this.config, db: this.db, service: this.service, qiwei: this.qiwei }, message, 'polling');
   }
 }
@@ -644,9 +660,15 @@ async function switchActiveAccount(input = {}) {
   const accountPatch = {
     ...account,
     apiBase: account.apiBase || currentAccount.apiBase,
+    guid: account.guid || (accountChanged ? '' : currentAccount.guid),
   };
-  if (!accountPatch.guid) delete accountPatch.guid;
   applyActiveAccountContext({ ...currentAccount, ...accountPatch });
+  saveQiweiClientConfig({
+    uid: accountPatch.uid,
+    guid: accountPatch.guid,
+    apiBase: accountPatch.apiBase,
+    envRoot: PROJECT_ROOT,
+  });
   const status = provisionalAccountStatus();
   accountStatusCache = { checkedAt: Date.now(), value: status };
   void refreshAccountStatus();
@@ -1120,6 +1142,8 @@ async function getAllowlistCandidates() {
 }
 
 function updateAllowlist(input = {}) {
+  refreshAllowedSendersFromEnv(workbench.config.qiwei);
+  const previousIds = new Set(workbench.config.qiwei.allowedSenders.map(String));
   const ids = normalizeAllowlistIds(input.contactIds || input.allowedSenders || []);
   const serialized = ids.join(',');
   writeEnvValue(ENV_FILE, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS', serialized);
@@ -1127,32 +1151,54 @@ function updateAllowlist(input = {}) {
   process.env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
 
   let listenerStopped = false;
+  const contactNames = input.contactNames || {};
+  const addedIds = ids.filter(id => !previousIds.has(id));
   for (const target of workbenches.values()) {
     target.config.qiwei.allowedSenders = [...ids];
+    for (const contactId of addedIds) {
+      target.db.ensureConversation(contactId, String(contactNames[contactId] || ''));
+    }
+    if (ids.length && input.autoStart !== false) {
+      target.db.setSetting('listener_enabled', 'true');
+      target.service.setGlobal({ paused: false }, input.actor || 'allowlist');
+    } else if (!ids.length) {
+      target.db.setSetting('listener_enabled', 'false');
+    }
     if (!ids.length && target.poller.status().running) {
       target.poller.stop();
       listenerStopped = true;
     }
   }
   workbench.db.audit({
-    actor: 'human',
+    actor: input.actor || 'human',
     action: 'allowlist_updated',
-    detail: { count: ids.length, listenerStopped },
+    detail: { count: ids.length, addedCount: addedIds.length, listenerStopped, autoStart: ids.length > 0 && input.autoStart !== false },
   });
   return {
     status: 'ok',
     assistantMessage: ids.length
-      ? `白名单已保存,共 ${ids.length} 位联系人,立即生效`
+      ? `白名单已保存,共 ${ids.length} 位联系人;AI 监听将自动保持开启并按当前审核策略处理`
       : '白名单已清空,AI 监听已停止',
-    summary: { selectedCount: ids.length, listenerStopped },
-    data: { selectedIds: ids, selectedCount: ids.length },
+    summary: { selectedCount: ids.length, addedCount: addedIds.length, listenerStopped, autoStart: ids.length > 0 && input.autoStart !== false },
+    data: { selectedIds: ids, selectedCount: ids.length, addedIds },
     warnings: [],
     errors: [],
   };
 }
 
+function addAllowlistContacts(input = {}) {
+  refreshAllowedSendersFromEnv(workbench.config.qiwei);
+  const contactIds = normalizeAllowlistIds(input.contactIds || []);
+  return updateAllowlist({
+    ...input,
+    contactIds: [...workbench.config.qiwei.allowedSenders, ...contactIds],
+    autoStart: input.autoStart !== false,
+    actor: input.actor || 'agent:test-contact',
+  });
+}
+
 function displayableConversations() {
-  return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
+  return workbench.db.listConversations();
 }
 
 function getConversations() {
@@ -1280,9 +1326,30 @@ function updateCustomerRecommendation(conversationId, recommendationId, input =
   };
 }
 
-async function syncConversations() {
+function resolveConversationSyncScope(input = {}, allowlist = new Set(), db = workbench.db) {
+  const conversationId = String(input.conversationId || '').trim();
+  let contactId = String(input.contactId || '').trim();
+  if (conversationId) {
+    const conversation = db.getConversation(conversationId);
+    if (!conversation) throw new Error('当前会话不存在,请刷新后重试');
+    if (conversationChannelInfo(conversation, db.listMessages(conversation.id, 100)).channelType !== 'private') {
+      throw new Error('当前采集仅支持个人聊天会话');
+    }
+    contactId = String(conversation.contact_id || '').trim();
+  }
+  if (contactId && !allowlist.has(contactId)) throw new Error('当前联系人不在个人消息白名单中');
+  return {
+    conversationId,
+    contactId,
+    contacts: contactId ? new Set([contactId]) : allowlist,
+    scope: contactId ? 'conversation' : 'allowlist',
+  };
+}
+
+async function syncConversations(input = {}) {
   const allowlist = new Set(workbench.config.qiwei.allowedSenders.map(String));
   if (!allowlist.size) throw new Error('测试联系人白名单为空,无法同步会话');
+  const syncScope = resolveConversationSyncScope(input, allowlist);
   const account = await detectAccountStatus(true);
   if (!account.online) throw new Error('测试账号当前不在线,无法同步企微会话');
 
@@ -1302,7 +1369,7 @@ async function syncConversations() {
       if (isGroupMessage(message)) continue;
       const senderId = String(message.senderId || '');
       const receiverId = String(message.receiverId || '');
-      const contactId = allowlist.has(senderId) ? senderId : allowlist.has(receiverId) ? receiverId : '';
+      const contactId = syncScope.contacts.has(senderId) ? senderId : syncScope.contacts.has(receiverId) ? receiverId : '';
       const content = String(message.msgData?.content || '').trim();
       if (!contactId || !content || ![0, 1, 2].includes(Number(message.msgType))) continue;
 
@@ -1363,15 +1430,21 @@ async function syncConversations() {
   workbench.db.audit({
     actor: 'human',
     action: 'conversation_history_synced',
-    detail: { conversations: grouped.size, insertedMessages: syncedMessages, removedImportedMessages, scannedMessages, duplicatesRemoved },
+    detail: { scope: syncScope.scope, contactId: syncScope.contactId || null, conversations: grouped.size, insertedMessages: syncedMessages, removedImportedMessages, scannedMessages, duplicatesRemoved },
   });
 
   return {
     status: 'ok',
     assistantMessage: grouped.size
-      ? `已补采 ${grouped.size} 个白名单真实会话的最近消息;只入库,不运行 Agent、不发送回复`
-      : '未读取到白名单联系人的文字会话,请先在企微中与该联系人收发一条消息',
+      ? syncScope.scope === 'conversation'
+        ? '已采集当前个人会话的最近消息;只入库,不运行 Agent、不发送回复'
+        : `已补采 ${grouped.size} 个白名单真实会话的最近消息;只入库,不运行 Agent、不发送回复`
+      : syncScope.scope === 'conversation'
+        ? '当前个人会话暂无可采集的文字消息,请先在企微中收发一条消息'
+        : '未读取到白名单联系人的文字会话,请先在企微中与该联系人收发一条消息',
     data: {
+      scope: syncScope.scope,
+      conversationId: syncScope.conversationId || null,
       syncedConversationCount: grouped.size,
       syncedMessageCount: syncedMessages,
       scannedMessageCount: scannedMessages,
@@ -1388,6 +1461,7 @@ function changeGlobalMode(mode) {
   else if (selected === 'auto') update = { paused: false, defaultMode: 'auto' };
   else if (selected === 'human') update = { paused: false, defaultMode: 'human' };
   else throw new Error('不支持的 Agent 模式');
+  workbench.db.setSetting('listener_enabled', selected === 'paused' ? 'false' : 'true');
   const global = workbench.service.setGlobal(update);
   return {
     status: 'ok',
@@ -1701,8 +1775,14 @@ function getAudit(limit = 200) {
   return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(limit) || 200))) } };
 }
 
-async function startListenerForWorkbench(target, account) {
+async function startListenerForWorkbench(target, account, options = {}) {
   if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
+  refreshAllowedSendersFromEnv(target.config.qiwei, options.envFile || ENV_FILE);
+  if (options.automatic === true && target.db?.getSetting?.('listener_enabled', 'true') === 'false') {
+    return { status: 'ok', assistantMessage: 'AI 监听保持人工关闭状态', data: { running: false, disabled: true } };
+  }
+  target.db?.setSetting?.('listener_enabled', 'true');
+  target.service?.setGlobal?.({ paused: false }, options.automatic ? 'runtime:auto-start' : 'human');
   const status = await target.poller.start();
   return {
     status: 'ok',
@@ -1720,7 +1800,7 @@ async function ingestRuntimeMessage(message = {}, options = {}) {
   }, message, String(options.source || 'runtime'));
 }
 
-async function startListener() {
+async function startListener(options = {}) {
   const product = getProductMode();
   if (product.mode === 'enterprise') {
     const runtime = listenerStateWithRuntime(product, workbench.poller.status());
@@ -1733,28 +1813,45 @@ async function startListener() {
     };
   }
   const account = await detectAccountStatus(true);
-  return startListenerForWorkbench(workbench, account);
+  return startListenerForWorkbench(workbench, account, options);
 }
 
-function stopListener() {
+function applyManualTakeover(target) {
+  target.service.setGlobal({ paused: true, defaultMode: 'review' });
+  for (const conversation of target.db.listConversations()) {
+    target.service.setConversationMode(conversation.id, 'human');
+  }
+}
+
+function stopListener({ preserveAgentState = false } = {}) {
   const product = getProductMode();
   if (product.mode === 'enterprise') {
-    workbench.service.setGlobal({ paused: true, defaultMode: 'review' });
-    for (const conversation of workbench.db.listConversations()) {
-      workbench.service.setConversationMode(conversation.id, 'human');
+    if (!preserveAgentState) {
+      workbench.service.setGlobal({ paused: true, defaultMode: 'review' });
+      for (const conversation of workbench.db.listConversations()) {
+        workbench.service.setConversationMode(conversation.id, 'human');
+      }
     }
     return {
       status: 'ok',
-      assistantMessage: 'Enterprise Relay continues collecting messages; AI replies are paused and conversations are in human mode.',
+      assistantMessage: preserveAgentState
+        ? 'Enterprise Relay runtime stopped; Agent modes were preserved.'
+        : 'Enterprise Relay continues collecting messages; AI replies are paused and conversations are in human mode.',
       data: { running: false, relayRunning: true, transport: 'server_relay' },
     };
   }
   const status = workbench.poller.stop();
-  workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
-  for (const conversation of workbench.db.listConversations()) {
-    workbench.service.setConversationMode(conversation.id, 'human');
+  if (!preserveAgentState) {
+    workbench.db.setSetting('listener_enabled', 'false');
+    applyManualTakeover(workbench);
   }
-  return { status: 'ok', assistantMessage: 'AI 监听已关闭,现有会话已切换为人工接管', data: status };
+  return {
+    status: 'ok',
+    assistantMessage: preserveAgentState
+      ? 'Qiwei polling runtime stopped; Agent modes were preserved.'
+      : 'AI 监听已关闭,现有会话已切换为人工接管',
+    data: status,
+  };
 }
 
 function getAgentRuntimeConfig() {
@@ -1766,6 +1863,7 @@ module.exports = {
   getAgentStatus,
   getAllowlistCandidates,
   updateAllowlist,
+  addAllowlistContacts,
   getConversations,
   getResponseMonitor,
   updateCustomerProfile,
@@ -1796,5 +1894,5 @@ module.exports = {
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, startListenerForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, resolveConversationSyncScope, applyManualTakeover, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, startListenerForWorkbench },
 };

+ 115 - 23
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -2,8 +2,10 @@
   'use strict';
 
   const API_BASE = '';
-  const ACCOUNTS_KEY = 'qiwei-accounts';
-  const CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
+  const LEGACY_ACCOUNTS_KEY = 'qiwei-accounts';
+  const LEGACY_CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
+  let ACCOUNTS_KEY = LEGACY_ACCOUNTS_KEY;
+  let CURRENT_ACCOUNT_KEY = LEGACY_CURRENT_ACCOUNT_KEY;
 
   const CO_FILTERS_KEY = 'qiwei-co-filters';
   const PT_FILTERS_KEY = 'qiwei-pt-filters';
@@ -43,8 +45,8 @@
 
   const state = {
     page: 'overview',
-    accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
-    currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null,
+    accounts: [],
+    currentAccountId: null,
     status: null,
     groups: [],
     groupAutoSyncStarted: false,
@@ -60,6 +62,7 @@
     loginTimer: null,
     loginPhase: 'idle',
     loginUid: null,
+    loginPreviousAccountId: null,
     autoRecoverAttempted: false,
     preRecoveryPage: null,
     selectedGroupIds: new Set(),
@@ -142,21 +145,85 @@
     localStorage.setItem(CURRENT_ACCOUNT_KEY, state.currentAccountId || '');
   }
 
-  function addAccount(account) {
+  function parseStoredAccounts(value) {
+    try {
+      const parsed = JSON.parse(value || '[]');
+      return Array.isArray(parsed) ? parsed.filter(item => item && typeof item === 'object') : [];
+    } catch {
+      return [];
+    }
+  }
+
+  async function initializeWorkspaceAccountStorage() {
+    let workspaceId = '';
+    let activeAccountUid = '';
+    try {
+      const health = await api('GET', '/api/health', undefined, 4000);
+      workspaceId = String(health.data?.workspaceId || '').trim();
+      activeAccountUid = String(health.data?.activeAccountUid || '').trim();
+    } catch {
+      // 旧版服务端或短暂启动延迟时继续使用原键,避免阻断工作台。
+    }
+
+    if (workspaceId) {
+      ACCOUNTS_KEY = `qiwei-${workspaceId}-accounts`;
+      CURRENT_ACCOUNT_KEY = `qiwei-${workspaceId}-current-account`;
+    }
+
+    let accounts = parseStoredAccounts(localStorage.getItem(ACCOUNTS_KEY));
+    let currentId = localStorage.getItem(CURRENT_ACCOUNT_KEY) || null;
+    if (workspaceId && accounts.length === 0 && activeAccountUid) {
+      const legacy = parseStoredAccounts(localStorage.getItem(LEGACY_ACCOUNTS_KEY));
+      const activeLegacy = legacy.find(item => String(item.uid || '').trim() === activeAccountUid);
+      if (activeLegacy) {
+        accounts = legacy;
+        currentId = activeLegacy.id;
+      }
+    }
+    if (!accounts.some(item => item.id === currentId)) currentId = null;
+    state.accounts = accounts;
+    state.currentAccountId = currentId;
+    saveAccounts();
+  }
+
+  function isGroupSynced(roomId) {
+    return !!(state.groupSync[roomId] && state.groupSync[roomId].lastSyncAt);
+  }
+
+  function addAccount(account, options = {}) {
+    const activate = options.activate !== false;
     const existing = state.accounts.find(a =>
       (account.uid && a.uid === account.uid) ||
       ((account.guid || account.userId) && a.guid === account.guid && a.userId === account.userId)
     );
     if (existing) {
       Object.assign(existing, account, { id: existing.id });
-      state.currentAccountId = existing.id;
+      if (activate) state.currentAccountId = existing.id;
     } else {
       account.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
       state.accounts.push(account);
-      state.currentAccountId = account.id;
+      if (activate) state.currentAccountId = account.id;
     }
     saveAccounts();
     updateAccountSwitcher();
+    return existing?.id || account.id;
+  }
+
+  function beginAddAccountLogin() {
+    state.loginPreviousAccountId = state.currentAccountId;
+    state.loginUid = createDeviceUid();
+    state.currentAccountId = null;
+    saveAccounts();
+  }
+
+  function restorePreviousAccountSelection() {
+    const previousId = state.loginPreviousAccountId;
+    if (previousId && state.accounts.some(item => item.id === previousId)) {
+      state.currentAccountId = previousId;
+      saveAccounts();
+      updateAccountSwitcher();
+    }
+    state.loginPreviousAccountId = null;
   }
 
   async function switchAccount(id, options = {}) {
@@ -2851,7 +2918,12 @@
       })) return;
       saveButton.disabled = true;
       try {
-        const result = await api('POST', '/api/agent/allowlist', { contactIds: [...selected] });
+        const contactIds = [...selected];
+        const contactNames = {};
+        for (const contact of contacts) {
+          if (selected.has(contact.id) && String(contact.displayName || '').trim()) contactNames[contact.id] = contact.displayName;
+        }
+        const result = await api('POST', '/api/agent/allowlist', { contactIds, contactNames, autoStart: true });
         toast(result.assistantMessage || '白名单已保存');
         closeModal();
         await loadAgentData(page);
@@ -3161,6 +3233,7 @@
     const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
     const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
     const listenerRunning = Boolean(listener.running);
+    const aiListenerActive = listenerRunning && !globalPaused;
     const messageIngressRunning = product.mode === 'enterprise' ? Boolean(ingress.running) : listenerRunning;
     const allowlistCount = Number(config.allowedSenderCount) || 0;
     const allowlistReady = allowlistCount > 0;
@@ -3217,9 +3290,9 @@
           <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(product.label)} · ${escapeHtml(product.collectionLabel)} · ${messageIngressRunning ? '回调接收中' : '回调待恢复'}</small></div>
           <div class="agent-safety-actions">
             <button class="btn btn-secondary" data-agent-action="manage-allowlist">管理白名单</button>
-            <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采最近消息</button>
+            <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采全部白名单</button>
             ${product.mode === 'personal'
-              ? `<button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (listenerRunning ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>`
+              ? `<button class="btn ${aiListenerActive ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (aiListenerActive ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (aiListenerActive ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>`
               : ''}
           </div>
         </div>
@@ -3290,6 +3363,7 @@
             <header class="agent-chat-header">
               <div><strong>${escapeHtml(selected.displayName)}</strong><span>${escapeHtml(selected.maskedId)} · 真实企微会话 · ${agentModeLabel(selected.mode)}</span></div>
               <div class="agent-conversation-modes">
+                <button class="btn btn-sm btn-secondary" data-agent-action="sync-current-conversation" data-id="${selected.id}" ${account.online ? '' : 'disabled'}>采集当前会话</button>
                 <button class="btn btn-sm ${selected.mode === 'review' ? '' : 'btn-secondary'}" data-agent-conversation-mode="review" data-id="${selected.id}">待审核</button>
                 <button class="btn btn-sm ${selected.mode === 'auto' ? '' : 'btn-secondary'}" data-agent-conversation-mode="auto" data-id="${selected.id}">自动</button>
                 <button class="btn btn-sm ${selected.mode === 'human' ? '' : 'btn-secondary'}" data-agent-conversation-mode="human" data-id="${selected.id}">人工接管</button>
@@ -3747,14 +3821,22 @@
       actionButton.disabled = true;
       try {
         let result;
-        if (action === 'sync-conversations') {
-          if (!await confirmModal('只补采白名单联系人最近的真实消息,不运行 Agent,也不发送任何回复。', { title: '补采最近消息', confirmText: '开始补采' })) return;
-          result = await api('POST', '/api/agent/conversations/sync', {});
+        if (action === 'sync-conversations' || action === 'sync-current-conversation') {
+          const current = (state.agent.conversations || []).find(item => item.id === (id || state.agent.selectedId));
+          const single = action === 'sync-current-conversation';
+          if (single && !current) throw new Error('当前会话不存在,请刷新后重试');
+          const prompt = single
+            ? `只采集“${current.displayName || '当前客户'}”最近的真实消息,不运行 Agent,也不发送回复。`
+            : '补采全部白名单联系人的最近真实消息,不运行 Agent,也不发送回复。';
+          if (!await confirmModal(prompt, { title: single ? '采集当前会话' : '补采全部白名单', confirmText: '开始采集' })) return;
+          result = await api('POST', '/api/agent/conversations/sync', single ? { conversationId: current.id } : {});
           const synced = result.data?.conversations || [];
           if (synced.length) {
             state.agent.conversations = synced;
             state.agent.listMode = 'private';
-            state.agent.selectedId = synced.find(item => item.channelType !== 'group')?.id || null;
+            state.agent.selectedId = single && synced.some(item => item.id === current.id)
+              ? current.id
+              : synced.find(item => item.channelType !== 'group')?.id || null;
           }
         } else if (action === 'customer-task-sync-official') {
           const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId) || (state.agent.conversations || [])[0];
@@ -3989,6 +4071,7 @@
 
         if (statusCode === 4) {
           clearInterval(state.loginTimer);
+          restorePreviousAccountSelection();
           if (statusText) statusText.textContent = '登录已取消,请重新生成二维码';
           return;
         }
@@ -4065,10 +4148,19 @@
         toast('登录成功,但未获取到 Fmode 设备标识,请重新扫码', 'error');
         return;
       }
-      addAccount(account);
-      const switched = await switchAccount(state.currentAccountId, { silent: true, deferRender: state.bootstrapping });
-      if (!switched) return;
+      const previousAccountId = state.loginPreviousAccountId;
+      const accountId = addAccount(account, { activate: false });
+      const switched = await switchAccount(accountId, { silent: true, deferRender: state.bootstrapping });
+      if (!switched) {
+        state.currentAccountId = previousAccountId && state.accounts.some(item => item.id === previousAccountId)
+          ? previousAccountId
+          : null;
+        saveAccounts();
+        updateAccountSwitcher();
+        return;
+      }
       state.loginUid = null;
+      state.loginPreviousAccountId = null;
       toast(`登录成功:${account.nickname || account.userId}`);
       if (state.preRecoveryPage) {
         location.hash = `#${state.preRecoveryPage}`;
@@ -4129,7 +4221,9 @@
       if (res.status === 'needs_verify_code') {
         state.loginPhase = 'verify';
         state.preRecoveryPage = location.hash.slice(1) || 'groups';
+        state.loginPreviousAccountId ||= state.currentAccountId;
         state.currentAccountId = null;
+        saveAccounts();
         renderPage();
         setTimeout(() => showVerifyCodeInput(), 100);
         return false;
@@ -4155,6 +4249,7 @@
 
   async function recoverLogin() {
     state.autoRecoverAttempted = true;
+    state.loginPreviousAccountId = state.currentAccountId;
     state.loginUid = currentAccount()?.uid || null;
     const recovered = await attemptAutoRecover(false);
     if (!recovered) {
@@ -4290,9 +4385,7 @@
       `;
 
       page.querySelector('#btn-add-account-page').addEventListener('click', () => {
-        state.loginUid = createDeviceUid();
-        state.currentAccountId = null;
-        saveAccounts();
+        beginAddAccountLogin();
         renderPage();
       });
 
@@ -7329,6 +7422,7 @@
   }
 
   async function init() {
+    await initializeWorkspaceAccountStorage();
     setActiveNav(location.hash.slice(1) || 'overview');
     updateAccountSwitcher();
     const hadAccountAtStart = Boolean(currentAccount());
@@ -7346,9 +7440,7 @@
     });
 
     document.getElementById('btn-add-account').addEventListener('click', () => {
-      state.loginUid = createDeviceUid();
-      state.currentAccountId = null;
-      saveAccounts();
+      beginAddAccountLogin();
       location.hash = '#groups';
       renderPage();
     });

+ 32 - 4
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js

@@ -2,6 +2,7 @@ const fs = require('fs');
 const path = require('path');
 const crypto = require('crypto');
 const { roomIdOf } = require('../core/agent-poller-policy');
+const { appendGroupRecord, appendChatRecord, ensureMemory } = require('../core/message-archive');
 const { isStaffMessage, timestampOf } = require('./response-monitor-service');
 const {
   appendWebhookMessage,
@@ -328,9 +329,10 @@ class GroupAgentService {
     const content = messageContent(message);
     if (!content || ![0, 1, 2].includes(Number(message.msgType))) return { status: 'ignored_group_message_type' };
     const timestamp = message.timestamp ? new Date(Number(message.timestamp) > 1e12 ? Number(message.timestamp) : Number(message.timestamp) * 1000).toISOString() : new Date().toISOString();
+    const msgId = message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}`;
     this.appendMessage(roomId, {
-      msgId: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}`,
-      msgUniqueIdentifier: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}`,
+      msgId,
+      msgUniqueIdentifier: msgId,
       seq: message.seq,
       senderId: message.senderId || '',
       senderName: message.senderName || '',
@@ -341,8 +343,34 @@ class GroupAgentService {
       timestamp,
       rawData: message,
     });
-    if (String(message.senderId || '') === String(config.selfUserId || '')) return { status: 'group_staff_message_saved' };
-    return this.generate(roomId, { sourceMessageId: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}` });
+    const self = String(message.senderId || '') === String(config.selfUserId || '');
+    appendGroupRecord({
+      roomId,
+      messageId: msgId,
+      seq: message.seq,
+      msgType: String(message.msgType),
+      senderId: message.senderId || '',
+      senderName: message.senderName || '',
+      self,
+      content,
+      timestamp,
+    });
+    const memberId = String(message.senderId || '').trim();
+    if (memberId && !self) {
+      ensureMemory(memberId, message.senderName || '群成员');
+      appendChatRecord({
+        wxid: memberId,
+        messageId: msgId,
+        externalId: null,
+        dir: 'in',
+        senderType: 'customer',
+        content,
+        createdAt: timestamp,
+        source: 'group',
+      });
+    }
+    if (self) return { status: 'group_staff_message_saved' };
+    return this.generate(roomId, { sourceMessageId: msgId });
   }
 }
 

+ 10 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -4,6 +4,7 @@ const path = require('path');
 const { URL } = require('url');
 const { outputsRoot, categoryDir } = require('../core/output-paths');
 const { resolveWorkspaceRoot, workspaceIdentity } = require('../core/runtime-context');
+const { readQiweiUid } = require('../core/credentials');
 const {
   qiweiSyncExternalGroups,
   qiweiListExternalGroups,
@@ -479,7 +480,14 @@ async function handleRequest(req, res) {
       return;
     }
     if (pathname === '/api/health') {
-      json(res, 200, { status: 'ok', data: { workspaceId: WORKSPACE_ID, port: Number(DASHBOARD_PORT) } });
+      json(res, 200, {
+        status: 'ok',
+        data: {
+          workspaceId: WORKSPACE_ID,
+          port: Number(DASHBOARD_PORT),
+          activeAccountUid: readQiweiUid(),
+        },
+      });
       return;
     }
     if (pathname === '/api/status' && req.method === 'GET') {
@@ -649,7 +657,7 @@ async function handleRequest(req, res) {
       return;
     }
     if (pathname === '/api/agent/conversations/sync' && req.method === 'POST') {
-      json(res, 200, await syncConversations());
+      json(res, 200, await syncConversations(await readBody(req)));
       return;
     }
     if (pathname === '/api/agent/audit' && req.method === 'GET') {

+ 76 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js

@@ -20,7 +20,7 @@ class FmodeQiweiClient {
     return {
       token: readQiweiAuthToken({ authToken: this.config.authToken }),
       uid: readQiweiUid({ uid: this.config.uid }),
-      guid: readQiweiGuid({ guid: this.config.guid }),
+      guid: readQiweiGuid({ guid: this.config.guid, uid: this.config.uid }),
       apiBase: readQiweiApiBase({ apiBase: this.config.apiBase }),
     };
   }
@@ -156,6 +156,81 @@ class FmodeQiweiClient {
     return this.call('/msg/sendText', { toId, content, isNoNeedRead: false });
   }
 
+  sendLocation(toId, { title, address, latitude, longitude } = {}) {
+    if (!String(toId || '').trim()) throw new Error('位置消息缺少接收人');
+    if (!String(title || address || '').trim()) throw new Error('位置消息缺少名称或地址');
+    if (!Number.isFinite(Number(latitude)) || !Number.isFinite(Number(longitude))) {
+      throw new Error('位置消息需要有效经纬度');
+    }
+    return this.call('/msg/sendLocation', {
+      toId,
+      title: String(title || ''),
+      address: String(address || ''),
+      latitude: Number(latitude),
+      longitude: Number(longitude),
+    });
+  }
+
+  async uploadImageByUrl(fileUrl, filename = 'cover.jpg') {
+    if (!String(fileUrl || '').trim()) throw new Error('待上传的图片 URL 不能为空');
+    const data = await this.call('/cloud/cdnBigUploadByUrl', {
+      filename: String(filename || 'cover.jpg'),
+      fileUrl: String(fileUrl),
+      fileType: 1,
+    });
+    if (!data.fileId) throw new Error('企微图片 URL 上传未返回有效 fileId');
+    return data;
+  }
+
+  async sendWeapp(toId, {
+    appId,
+    username,
+    title,
+    desc = '',
+    pagePath,
+    coverUrl = '',
+    coverFileId = '',
+    coverFileAesKey = '',
+    coverFileSize = 0,
+  } = {}) {
+    if (!String(toId || '').trim()) throw new Error('小程序卡片缺少接收人');
+    if (!String(appId || '').trim()) throw new Error('小程序 appId 不能为空');
+    if (!String(username || '').trim()) throw new Error('小程序 username(原始 id,gh_ 开头)未配置');
+    if (!String(title || '').trim()) throw new Error('小程序卡片标题不能为空');
+    const rawUsername = String(username).trim();
+    const normalizedUsername = /@app$/.test(rawUsername) ? rawUsername : `${rawUsername}@app`;
+    let file = { coverFileId, coverFileAesKey, coverFileSize };
+    let coverUploadWarning = '';
+    if (!file.coverFileId && coverUrl) {
+      try {
+        const uploaded = await this.uploadImageByUrl(coverUrl, `cover_${Date.now()}.jpg`);
+        file = {
+          coverFileId: uploaded.fileId,
+          coverFileAesKey: uploaded.fileAesKey || '',
+          coverFileSize: uploaded.fileSize || 0,
+        };
+      } catch (error) {
+        coverUploadWarning = `封面上传失败:${error.message}`;
+        file = { coverFileId: '', coverFileAesKey: '', coverFileSize: 0 };
+      }
+    }
+    const result = await this.call('/msg/sendWeapp', {
+      toId,
+      appId: String(appId),
+      username: normalizedUsername,
+      title: String(title),
+      desc: String(desc || ''),
+      pagePath: String(pagePath || ''),
+      thumbUrl: String(coverUrl || ''),
+      coverFileId: file.coverFileId,
+      coverFileAesKey: file.coverFileAesKey,
+      coverFileSize: file.coverFileSize,
+    });
+    return result && typeof result === 'object'
+      ? { ...result, coverUploadWarning: coverUploadWarning || undefined }
+      : result;
+  }
+
   async uploadVoiceFile(filePath) {
     const ctx = this.requireContext();
     const resolved = path.resolve(String(filePath || ''));

+ 11 - 5
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js

@@ -11,8 +11,8 @@ function redactSecret(value) {
     .replace(/\bguid\b/gi, '设备标识');
 }
 
-function sanitizePayload(value) {
-  if (Array.isArray(value)) return value.map(sanitizePayload);
+function sanitizePayload(value, fieldName = '') {
+  if (Array.isArray(value)) return value.map(item => sanitizePayload(item, fieldName));
   if (value && typeof value === 'object') {
     const output = {};
     for (const [key, item] of Object.entries(value)) {
@@ -23,10 +23,11 @@ function sanitizePayload(value) {
       ) {
         continue;
       }
-      output[key] = sanitizePayload(item);
+      output[key] = sanitizePayload(item, key);
     }
     return output;
   }
+  if (typeof value === 'string' && /^(uid|guid|userId|corpId)$/i.test(fieldName)) return value;
   return typeof value === 'string' ? redactSecret(value) : value;
 }
 
@@ -107,7 +108,8 @@ async function callFmodeWecomGateway({
   token,
   apiBase,
   timeoutMs = 60000,
-  networkAttempts = 4
+  networkAttempts = 4,
+  cacheBust = false
 }) {
   if (!gatewayPath) {
     const err = new Error('missing gatewayPath');
@@ -121,7 +123,10 @@ async function callFmodeWecomGateway({
     throw err;
   }
 
-  const url = buildGatewayUrl(apiBase, gatewayPath, query);
+  const requestQuery = cacheBust
+    ? { ...(query || {}), _ts: Date.now() }
+    : query;
+  const url = buildGatewayUrl(apiBase, gatewayPath, requestQuery);
   const method = String(httpMethod || 'POST').toUpperCase();
   if (body !== undefined && formData !== undefined) {
     const err = new Error('body and formData are mutually exclusive');
@@ -135,6 +140,7 @@ async function callFmodeWecomGateway({
       headers: {
         Authorization: `Bearer ${token}`,
         Accept: 'application/json',
+        ...(cacheBust ? { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } : {}),
         ...(body !== undefined ? { 'Content-Type': 'application/json' } : {})
       },
       body: formData !== undefined

+ 2 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -174,7 +174,7 @@ function wrap(handler) {
 function createServer() {
   const server = new McpServer({
     name: 'enterprise-wechat-assistant',
-    version: '0.4.3'
+    version: '0.5.2'
   });
 
   server.registerTool(
@@ -432,6 +432,7 @@ function createServer() {
         defaultGreeting: z.string().optional(),
         rateLimitPerMinute: z.number().int().min(1).max(60).optional(),
         maxAttempts: z.number().int().min(1).max(5).optional(),
+        addToAllowlist: z.boolean().optional().describe('仅添加测试好友时设为 true;成功后自动加入个人消息白名单并启用待审核监听'),
         uid: z.string().optional(),
         authToken: z.string().optional(),
         apiBase: z.string().optional()

+ 25 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js

@@ -251,10 +251,33 @@ const qiweiBatchAddFriends = safeResult(async function qiweiBatchAddFriends(inpu
     });
   }
 
+  let allowlistResult = null;
+  let allowlistWarning = '';
+  if (input.addToAllowlist === true) {
+    const contactIds = [...new Set(details
+      .filter(item => item.status === 'SUCCESS' && item.externalUserId)
+      .map(item => String(item.externalUserId)))];
+    if (contactIds.length) {
+      const contactNames = Object.fromEntries(details
+        .filter(item => item.status === 'SUCCESS' && item.externalUserId && item.name)
+        .map(item => [String(item.externalUserId), String(item.name)]));
+      try {
+        const { addAllowlistContacts } = require('../dashboard/agent-service');
+        allowlistResult = addAllowlistContacts({ contactIds, contactNames, autoStart: true, actor: 'agent:test-contact' });
+        summary.allowlistAdded = Number(allowlistResult.summary?.addedCount) || 0;
+      } catch (error) {
+        allowlistWarning = `好友已添加,但测试白名单同步待重试:${String(error.message || error)}`;
+      }
+    } else {
+      allowlistWarning = '好友操作已完成,但返回结果中没有可加入测试白名单的联系人 ID';
+    }
+  }
+
   return okResult({
-    assistantMessage: `批量加好友完成:成功 ${success},失败 ${failed}。`,
+    assistantMessage: `批量加好友完成:成功 ${success},失败 ${failed}。${allowlistResult ? ' 测试联系人已自动加入白名单,AI 监听将保持开启。' : ''}`,
     summary,
-    data: { details, invalid }
+    data: { details, invalid, allowlist: allowlistResult?.data || null },
+    warnings: allowlistWarning ? [allowlistWarning] : []
   });
 });
 

+ 17 - 6
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js

@@ -122,7 +122,7 @@ function openInBrowser(target) {
 }
 
 function gatewayErrorResult(error, stage, fallbackMessage) {
-  const safeMessage = redactSecret(error && error.message);
+  const safeMessage = redactSecret(error && (error.bizMessage || error.message));
   const kind = String((error && error.kind) || 'upstream');
   if (kind === 'auth') {
     return {
@@ -161,16 +161,21 @@ function gatewayErrorResult(error, stage, fallbackMessage) {
   });
 }
 
+function loginBody(uid, guid, extra = {}) {
+  return { uid, ...(guid ? { guid } : {}), ...extra };
+}
+
 async function qiweiLoginStatus(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const uid = ensureQiweiUid(input);
+  const guid = readQiweiGuid(input);
   const apiBase = readQiweiApiBase(input);
   try {
     const result = await callFmodeWecomGateway({
       gatewayPath: '/login/status',
       httpMethod: 'GET',
-      query: { uid },
+      query: { uid, ...(guid ? { guid } : {}) },
       token,
       apiBase
     });
@@ -224,6 +229,7 @@ async function qiweiLoginStart(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const uid = ensureQiweiUid(input);
+  const guid = readQiweiGuid(input);
   const apiBase = readQiweiApiBase(input);
   if (input.persistConfig !== false) saveQiweiClientConfig({ uid, apiBase });
   if (input.flowUi !== false) {
@@ -232,6 +238,7 @@ async function qiweiLoginStart(input = {}) {
         token,
         apiBase,
         uid,
+        guid,
         port: input.flowPort,
         onQrcode: (buffer) => {
           try {
@@ -260,7 +267,8 @@ async function qiweiLoginStart(input = {}) {
         gatewayPath: '/subscribe/status',
         httpMethod: 'GET',
         token,
-        apiBase
+        apiBase,
+        cacheBust: true
       });
       const sub = statusResult.data || {};
       if (!sub.subscribed) {
@@ -277,7 +285,7 @@ async function qiweiLoginStart(input = {}) {
   try {
     const result = await callFmodeWecomGateway({
       gatewayPath: '/login/start',
-      body: { uid },
+      body: loginBody(uid, guid),
       token,
       apiBase
     });
@@ -292,6 +300,7 @@ async function qiweiLoginStart(input = {}) {
           token,
           apiBase,
           uid,
+          guid,
           qrcodeBuffer,
           port: 0,
           onQrcode: (buffer) => {
@@ -372,11 +381,12 @@ async function qiweiLoginCheck(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const uid = ensureQiweiUid(input);
+  const guid = readQiweiGuid(input);
   const apiBase = readQiweiApiBase(input);
   try {
     const result = await callFmodeWecomGateway({
       gatewayPath: '/login/check',
-      body: { uid, manual: Boolean(input.manual) },
+      body: loginBody(uid, guid, { manual: Boolean(input.manual) }),
       token,
       apiBase
     });
@@ -471,13 +481,14 @@ async function qiweiLoginVerify(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const uid = ensureQiweiUid(input);
+  const guid = readQiweiGuid(input);
   const code = String(input.code || '').trim();
   if (!/^\d{6}$/.test(code)) return errorResult('请提供手机端显示的 6 位数字验证码(入参 code)。');
   const apiBase = readQiweiApiBase(input);
   try {
     await callFmodeWecomGateway({
       gatewayPath: '/login/verify',
-      body: { uid, code },
+      body: loginBody(uid, guid, { code }),
       token,
       apiBase
     });

+ 3 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-subscription-run.js

@@ -16,7 +16,7 @@ function authRequiredResult() {
 }
 
 function subscriptionError(error, stage) {
-  const safeMessage = redactSecret(error && error.message);
+  const safeMessage = redactSecret(error && (error.bizMessage || error.message));
   const kind = String((error && error.kind) || 'upstream');
   if (kind === 'auth') return authRequiredResult();
   if (kind === 'billing') {
@@ -45,7 +45,8 @@ async function qiweiSubscriptionStatus(input = {}) {
       gatewayPath: '/subscribe/status',
       httpMethod: 'GET',
       token,
-      apiBase: readQiweiApiBase(input)
+      apiBase: readQiweiApiBase(input),
+      cacheBust: true
     });
     const data = result.data || {};
     return okResult({

+ 11 - 7
claude-code/claude-code-qiwe-assistant/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "fmode-qiwei",
-  "version": "0.4.3",
+  "version": "0.5.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "fmode-qiwei",
-      "version": "0.4.3",
+      "version": "0.5.2",
       "dependencies": {
         "@binsee/wx-voice": "^0.3.2",
         "@ffmpeg-installer/ffmpeg": "^1.1.0",
@@ -791,9 +791,9 @@
       "license": "MIT"
     },
     "node_modules/fast-uri": {
-      "version": "3.1.4",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
-      "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+      "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
       "funding": [
         {
           "type": "github",
@@ -947,7 +947,9 @@
       }
     },
     "node_modules/hono": {
-      "version": "4.12.27",
+      "version": "4.13.0",
+      "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
+      "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
       "license": "MIT",
       "engines": {
         "node": ">=16.9.0"
@@ -990,7 +992,9 @@
       "license": "ISC"
     },
     "node_modules/ip-address": {
-      "version": "10.2.0",
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
+      "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
       "license": "MIT",
       "engines": {
         "node": ">= 12"

+ 6 - 3
claude-code/claude-code-qiwe-assistant/package.json

@@ -1,6 +1,6 @@
 {
   "name": "fmode-qiwei",
-  "version": "0.4.3",
+  "version": "0.5.2",
   "description": "Fmode 企业微信智能办公技能包:客户运营、智能客服、Dashboard、Webhook/Relay、官方 CLI 与目标管理",
   "author": "fmode",
   "main": "mcp/src/server.js",
@@ -24,6 +24,7 @@
     "scripts/*.mjs",
     "skill-package-manifest.json",
     "skills/",
+    "templates/",
     "runtime/",
     "THIRD_PARTY_NOTICES.md",
     "wecom-cli-runtime.json"
@@ -66,13 +67,15 @@
     "runtime:stop": "node scripts/start-callback-runtime.mjs stop",
     "release:check": "node scripts/release-check.js",
     "response:smoke": "node scripts/response-monitor-smoke-test.js",
-    "smoke": "node scripts/startup-preview-smoke-test.js && node scripts/product-mode-smoke-test.js && node scripts/deadline-parser-smoke-test.js && node scripts/response-monitor-smoke-test.js && node scripts/smoke-test.js && node scripts/wecom-cli-smoke-test.js && node scripts/goal-management-smoke-test.js && node scripts/meeting-knowledge-smoke-test.js && node scripts/official-office-knowledge-smoke-test.js && node scripts/unified-task-smoke-test.js && node scripts/customer-master-smoke-test.js && node scripts/customer-journey-smoke-test.js && node scripts/callback-relay-smoke-test.js && node scripts/outbound-callback-smoke-test.js",
+    "smoke": "node scripts/startup-preview-smoke-test.js && node scripts/product-mode-smoke-test.js && node scripts/account-switch-smoke-test.js && node scripts/message-archive-smoke-test.js && node scripts/deadline-parser-smoke-test.js && node scripts/response-monitor-smoke-test.js && node scripts/smoke-test.js && node scripts/wecom-cli-smoke-test.js && node scripts/goal-management-smoke-test.js && node scripts/meeting-knowledge-smoke-test.js && node scripts/official-office-knowledge-smoke-test.js && node scripts/unified-task-smoke-test.js && node scripts/customer-master-smoke-test.js && node scripts/customer-journey-smoke-test.js && node scripts/callback-relay-smoke-test.js && node scripts/outbound-callback-smoke-test.js",
     "start": "node mcp/src/server.js",
     "task:smoke": "node scripts/unified-task-smoke-test.js",
     "voice:smoke": "node scripts/voice-clone-smoke-test.js",
     "wecom:init": "node bin/qiwei-official-cli.js init",
     "wecom:install": "node scripts/install-wecom-cli.js",
-    "wecom:status": "node bin/qiwei-official-cli.js status"
+    "wecom:status": "node bin/qiwei-official-cli.js status",
+    "account:switch-smoke": "node scripts/account-switch-smoke-test.js",
+    "archive:smoke": "node scripts/message-archive-smoke-test.js"
   },
   "dependencies": {
     "@binsee/wx-voice": "^0.3.2",

+ 93 - 23
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/personal-polling.mjs

@@ -1,22 +1,39 @@
 import path from 'node:path';
 import { spawn } from 'node:child_process';
 import { PACKAGE_ROOT } from './config-loader.mjs';
-import {
-  getPersonalListenerStatus,
-  startPersonalListener,
-  stopPersonalListener,
-} from './processor-bridge.mjs';
+
+let processorBridgePromise = null;
+
+function loadProcessorBridge() {
+  if (!processorBridgePromise) processorBridgePromise = import('./processor-bridge.mjs');
+  return processorBridgePromise;
+}
+
+const defaultListenerApi = {
+  getStatus: async () => (await loadProcessorBridge()).getPersonalListenerStatus(),
+  recoverLogin: async () => (await loadProcessorBridge()).recoverPersonalLogin(),
+  start: async () => (await loadProcessorBridge()).startPersonalListener(),
+  stop: async options => (await loadProcessorBridge()).stopPersonalListener(options),
+};
 
 export class PersonalPollingRuntime {
-  constructor({ config, workspaceRoot, onState = () => {} }) {
+  constructor({ config, workspaceRoot, onState = () => {}, listenerApi = {} }) {
     this.config = config;
     this.workspaceRoot = workspaceRoot;
     this.onState = onState;
+    this.listenerApi = {
+      getStatus: listenerApi.getStatus || defaultListenerApi.getStatus,
+      recoverLogin: listenerApi.recoverLogin || defaultListenerApi.recoverLogin,
+      start: listenerApi.start || defaultListenerApi.start,
+      stop: listenerApi.stop || defaultListenerApi.stop,
+    };
     this.running = false;
     this.listenerStarted = false;
     this.friendWorker = null;
     this.loopPromise = null;
     this.cancelWait = null;
+    this.lastRecoveryAttemptAt = 0;
+    this.lastRecoveryError = '';
   }
 
   wait(ms) {
@@ -49,25 +66,78 @@ export class PersonalPollingRuntime {
     });
   }
 
+  recoveryDue() {
+    const cooldownMs = Math.max(30000, Number(this.config.retryMs) || 10000);
+    return Date.now() - this.lastRecoveryAttemptAt >= cooldownMs;
+  }
+
+  async recoverAndRestart(listener = {}) {
+    if (!this.recoveryDue()) return null;
+    this.lastRecoveryAttemptAt = Date.now();
+    this.onState({ personalPolling: { status: 'reconnecting' } });
+    const recovered = await this.listenerApi.recoverLogin();
+    const recoveredCode = Number(recovered?.summary?.statusCode);
+    if (!recovered?.summary?.loggedIn && recoveredCode !== 2) {
+      this.lastRecoveryError = String(
+        recovered?.errors?.[0]?.message
+        || recovered?.assistantMessage
+        || 'Automatic login recovery is pending.',
+      );
+      return null;
+    }
+
+    this.lastRecoveryError = '';
+    if (listener.running) await this.listenerApi.stop({ preserveAgentState: true });
+    this.listenerStarted = false;
+    const started = await this.listenerApi.start();
+    this.listenerStarted = Boolean(started?.data?.running);
+    return started;
+  }
+
+  async pollOnce() {
+    let result = await this.listenerApi.getStatus();
+    let listener = result?.data?.listener || {};
+    let account = result?.data?.account || {};
+
+    if (account.online === false) {
+      await this.recoverAndRestart(listener);
+      result = await this.listenerApi.getStatus();
+      listener = result?.data?.listener || {};
+      account = result?.data?.account || {};
+    }
+
+    if (!listener.running && account.online !== false) {
+      try {
+        const started = await this.listenerApi.start();
+        this.listenerStarted = Boolean(started?.data?.running);
+      } catch (error) {
+        const recovered = await this.recoverAndRestart(listener);
+        if (!recovered) throw error;
+      }
+      result = await this.listenerApi.getStatus();
+      listener = result?.data?.listener || {};
+      account = result?.data?.account || {};
+    }
+
+    this.listenerStarted = Boolean(listener.running);
+    if (this.listenerStarted) this.startFriendWorker();
+    const listenerHealthy = listener.running && account.online !== false;
+    if (listenerHealthy) this.lastRecoveryError = '';
+    this.onState({
+      personalPolling: {
+        status: listenerHealthy ? 'running' : 'waiting',
+        syncKey: Number(listener.syncKey || 0),
+        startedAt: listener.startedAt || null,
+        lastError: listenerHealthy ? '' : (listener.lastError || this.lastRecoveryError),
+      },
+    });
+    return listener;
+  }
+
   async loop() {
     while (this.running) {
       try {
-        if (!this.listenerStarted) {
-          const result = await startPersonalListener();
-          this.listenerStarted = Boolean(result?.data?.running);
-          if (this.listenerStarted) this.startFriendWorker();
-        }
-        const result = await getPersonalListenerStatus();
-        const listener = result?.data?.listener || {};
-        this.listenerStarted = Boolean(listener.running);
-        this.onState({
-          personalPolling: {
-            status: listener.running ? 'running' : 'waiting',
-            syncKey: Number(listener.syncKey || 0),
-            startedAt: listener.startedAt || null,
-            lastError: listener.lastError || '',
-          },
-        });
+        await this.pollOnce();
       } catch (error) {
         this.listenerStarted = false;
         this.onState({ personalPolling: { status: 'waiting', lastError: error.message } });
@@ -87,7 +157,7 @@ export class PersonalPollingRuntime {
     this.running = false;
     if (this.cancelWait) this.cancelWait();
     if (this.listenerStarted) {
-      try { stopPersonalListener(); } catch {}
+      try { await this.listenerApi.stop({ preserveAgentState: true }); } catch {}
     }
     this.listenerStarted = false;
     if (this.friendWorker) {

+ 8 - 3
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs

@@ -7,6 +7,7 @@ const webhookServer = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'web
 const webhookTypes = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'webhook-types.js'));
 const agentService = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'agent-service.js'));
 const dashboardServer = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'server.js'));
+const loginTools = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'tools', 'qiwei-login-run.js'));
 
 export async function processRelayEnvelope(envelope) {
   const events = webhookTypes.parseWebhookEnvelope(envelope);
@@ -29,17 +30,21 @@ export function syncConversations() {
 }
 
 export function startPersonalListener() {
-  return agentService.startListener();
+  return agentService.startListener({ automatic: true });
 }
 
-export function stopPersonalListener() {
-  return agentService.stopListener();
+export function stopPersonalListener(options = {}) {
+  return agentService.stopListener(options);
 }
 
 export function getPersonalListenerStatus() {
   return agentService.getAgentStatus();
 }
 
+export function recoverPersonalLogin() {
+  return loginTools.qiweiLoginCheck({ manual: true, persistConfig: false });
+}
+
 export function startDashboard(port) {
   return dashboardServer.startServer(port);
 }

+ 151 - 0
claude-code/claude-code-qiwe-assistant/scripts/account-switch-smoke-test.js

@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+function envValue(filePath, key) {
+  const content = fs.readFileSync(filePath, 'utf8');
+  const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
+  return match ? match[1].trim() : undefined;
+}
+
+async function main() {
+  const packageRoot = path.resolve(__dirname, '..');
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-account-switch-'));
+  const previousCwd = process.cwd();
+  const previousEnv = { ...process.env };
+  const originalFetch = global.fetch;
+  const requests = [];
+
+  try {
+    process.env.USERPROFILE = tempRoot;
+    process.env.HOME = tempRoot;
+    process.env.QIWEI_WORKSPACE_ROOT = tempRoot;
+    process.env.QIWEI_OUTPUTS_DIR = path.join(tempRoot, 'outputs');
+    process.env.CLAUDE_CODE_WORKDIR = tempRoot;
+    process.env.QIWEI_AUTH_TOKEN = 'test-account-switch-token';
+    delete process.env.QIWEI_UID;
+    delete process.env.QIWE_UID;
+    delete process.env.QIWEI_GUID;
+    delete process.env.QIWE_GUID;
+    process.chdir(tempRoot);
+
+    const credentials = require(path.join(packageRoot, 'mcp', 'src', 'core', 'credentials'));
+    const login = require(path.join(packageRoot, 'mcp', 'src', 'tools', 'qiwei-login-run'));
+    assert.equal(path.dirname(credentials.CREDENTIALS_FILE), path.join(tempRoot, '.claude'));
+
+    const envFile = path.join(tempRoot, '.env.local');
+    credentials.saveQiweiClientConfig({
+      uid: 'uid-account-a',
+      guid: 'guid-account-a',
+      apiBase: 'https://gateway.example/api/qiwei',
+      envRoot: tempRoot,
+    });
+    assert.equal(envValue(envFile, 'QIWEI_UID'), 'uid-account-a');
+    assert.equal(envValue(envFile, 'QIWEI_GUID'), 'guid-account-a');
+    assert.equal(credentials.readQiweiGuid({ uid: 'uid-account-b' }), '');
+
+    let checkLoggedIn = false;
+    global.fetch = async (rawUrl, options = {}) => {
+      const url = new URL(String(rawUrl));
+      const body = options.body ? JSON.parse(options.body) : {};
+      requests.push({ path: url.pathname, body });
+      let data = {};
+      if (url.pathname.endsWith('/login/start')) data = { uid: body.uid };
+      else if (url.pathname.endsWith('/login/check')) {
+        data = checkLoggedIn
+          ? { uid: body.uid, status: 2, detail: { userId: 'user-b', nickname: '账号 B', guid: 'guid-account-b' } }
+          : { uid: body.uid, status: 10, detail: {} };
+      } else if (url.pathname.endsWith('/login/verify')) data = { accepted: true };
+      else if (url.pathname.endsWith('/login/status')) data = { configured: true, online: true, statusCode: 2, detail: { userId: 'user-b', nickname: '账号 B' } };
+      return new Response(JSON.stringify({ code: 0, data }), {
+        status: 200,
+        headers: { 'Content-Type': 'application/json' },
+      });
+    };
+
+    const common = {
+      uid: 'uid-account-b',
+      authToken: 'test-account-switch-token',
+      apiBase: 'https://gateway.example/api/qiwei',
+      flowUi: false,
+      openBrowser: false,
+      persistConfig: false,
+      skipSubscriptionCheck: true,
+    };
+    await login.qiweiLoginStart(common);
+    const pending = await login.qiweiLoginCheck(common);
+    assert.equal(pending.status, 'needs_verify_code');
+    await login.qiweiLoginVerify({ ...common, code: '123456' });
+    for (const request of requests.slice(0, 3)) {
+      assert.equal(request.body.uid, 'uid-account-b');
+      assert.equal(Object.prototype.hasOwnProperty.call(request.body, 'guid'), false);
+    }
+
+    checkLoggedIn = true;
+    const loggedIn = await login.qiweiLoginCheck(common);
+    assert.equal(loggedIn.summary.loggedIn, true);
+    assert.equal(loggedIn.data.guid, 'guid-account-b');
+    assert.equal(Object.prototype.hasOwnProperty.call(requests.at(-1).body, 'guid'), false);
+
+    const { switchActiveAccount } = require(path.join(packageRoot, 'mcp', 'src', 'dashboard', 'agent-service'));
+    await switchActiveAccount({
+      uid: 'uid-account-b',
+      guid: loggedIn.data.guid,
+      apiBase: common.apiBase,
+      userId: 'user-b',
+      nickname: '账号 B',
+    });
+    assert.equal(envValue(envFile, 'QIWEI_UID'), 'uid-account-b');
+    assert.equal(envValue(envFile, 'QIWEI_GUID'), 'guid-account-b');
+    assert.equal(credentials.readQiweiGuid({ uid: 'uid-account-b' }), 'guid-account-b');
+
+    await switchActiveAccount({
+      uid: 'uid-account-c',
+      guid: '',
+      apiBase: common.apiBase,
+      userId: 'user-c',
+      nickname: '账号 C',
+    });
+    assert.equal(envValue(envFile, 'QIWEI_UID'), 'uid-account-c');
+    assert.equal(envValue(envFile, 'QIWEI_GUID'), '');
+    assert.equal(credentials.readQiweiGuid({ uid: 'uid-account-c' }), '');
+    assert.equal(JSON.parse(fs.readFileSync(credentials.CREDENTIALS_FILE, 'utf8')).guid, undefined);
+
+    const appSource = fs.readFileSync(path.join(packageRoot, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
+    assert.match(appSource, /qiwei-\$\{workspaceId\}-accounts/);
+    assert.match(appSource, /await initializeWorkspaceAccountStorage\(\);[\s\S]*if \(currentAccount\(\)\?\.uid\)/);
+    assert.match(appSource, /addAccount\(account, \{ activate: false \}\)[\s\S]*await switchAccount\(accountId/);
+    assert.match(appSource, /if \(!switched\)[\s\S]*state\.currentAccountId = previousAccountId/);
+    assert.match(appSource, /statusCode === 4[\s\S]*restorePreviousAccountSelection\(\)/);
+
+    process.stdout.write(`${JSON.stringify({
+      status: 'ok',
+      checks: 20,
+      coverage: [
+        'new_uid_does_not_reuse_old_guid',
+        'verify_and_check_keep_uid_isolation',
+        'successful_switch_persists_uid_guid',
+        'guidless_switch_clears_stale_guid',
+        'workspace_scoped_browser_accounts',
+        'failed_or_cancelled_login_restores_previous_account',
+      ],
+    }, null, 2)}\n`);
+  } finally {
+    global.fetch = originalFetch;
+    process.chdir(previousCwd);
+    for (const key of Object.keys(process.env)) {
+      if (!(key in previousEnv)) delete process.env[key];
+    }
+    for (const [key, value] of Object.entries(previousEnv)) process.env[key] = value;
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+});

+ 137 - 4
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -30,6 +30,7 @@ const { AgentMemoryExtractionWorker } = require('../mcp/src/core/agent-memory-wo
 const { AgentKnowledgeStore } = require('../mcp/src/core/agent-knowledge');
 
 const results = [];
+const PACKAGE_ROOT = path.resolve(__dirname, '..');
 
 function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
   const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-'));
@@ -226,6 +227,13 @@ async function main() {
       await client.syncMessages(8, 50);
       const contacts = await client.listExternalContacts();
       await client.sendText('external-contact-1', '测试回复');
+      await client.sendLocation('external-contact-1', {
+        title: '会面地点', address: '示例路 1 号', latitude: 31.23, longitude: 121.47,
+      });
+      await client.sendWeapp('external-contact-1', {
+        appId: 'wx-demo-app', username: 'gh_demo', title: '服务入口', pagePath: '/pages/home',
+        coverFileId: 'cover-file', coverFileAesKey: 'cover-key', coverFileSize: 64,
+      });
       const voiceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-transport-'));
       const voicePath = path.join(voiceDir, 'voice.silk');
       fs.writeFileSync(voicePath, Buffer.from('#!SILK_V3'));
@@ -244,9 +252,13 @@ async function main() {
       assert.equal(calls[3].body.method, '/contact/batchGetUserinfo');
       assert.equal(calls[4].body.method, '/msg/sendText');
       assert.equal(calls[4].options.headers.Authorization, 'Bearer test-fmode-token');
-      assert.match(calls[5].url, /\/doFileApi$/);
-      assert.equal(calls[6].body.method, '/msg/sendVoice');
-      assert.equal(calls[6].body.params.voiceTime, 2);
+      assert.equal(calls[5].body.method, '/msg/sendLocation');
+      assert.equal(calls[5].body.params.latitude, 31.23);
+      assert.equal(calls[6].body.method, '/msg/sendWeapp');
+      assert.equal(calls[6].body.params.username, 'gh_demo@app');
+      assert.match(calls[7].url, /\/doFileApi$/);
+      assert.equal(calls[8].body.method, '/msg/sendVoice');
+      assert.equal(calls[8].body.params.voiceTime, 2);
 
       let failedSendAttempts = 0;
       global.fetch = async () => {
@@ -275,6 +287,121 @@ async function main() {
     assert.equal(configB.qiwei.guid, accountB.guid);
   });
 
+  await check('自动监听热加载白名单并尊重人工关闭状态', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-listener-default-'));
+    const envFile = path.join(dir, '.env.local');
+    fs.writeFileSync(envFile, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS=contact-new\n', 'utf8');
+    const settings = new Map();
+    const calls = [];
+    let starts = 0;
+    const target = {
+      config: { qiwei: { allowedSenders: ['contact-old'] } },
+      db: {
+        getSetting(name, fallback) { return settings.has(name) ? settings.get(name) : fallback; },
+        setSetting(name, value) { settings.set(name, value); },
+        globalState() { return { defaultMode: 'review' }; },
+      },
+      service: { setGlobal(input, actor) { calls.push({ input, actor }); } },
+      poller: {
+        async start() {
+          starts += 1;
+          assert.deepEqual(target.config.qiwei.allowedSenders, ['contact-new']);
+          return { running: true, syncKey: starts };
+        },
+      },
+    };
+    try {
+      const started = await __testing.startListenerForWorkbench(target, { online: true }, { automatic: true, envFile });
+      assert.equal(started.data.running, true);
+      assert.equal(settings.get('listener_enabled'), 'true');
+      assert.deepEqual(calls.at(-1).input, { paused: false });
+
+      settings.set('listener_enabled', 'false');
+      const disabled = await __testing.startListenerForWorkbench(target, { online: true }, { automatic: true, envFile });
+      assert.equal(disabled.data.disabled, true);
+      assert.equal(starts, 1);
+
+      const manual = await __testing.startListenerForWorkbench(target, { online: true }, { envFile });
+      assert.equal(manual.data.running, true);
+      assert.equal(starts, 2);
+      assert.equal(settings.get('listener_enabled'), 'true');
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  await check('当前会话采集仅允许白名单个人聊天', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const conversations = new Map([
+      ['private-1', { id: 'private-1', contact_id: 'contact-1' }],
+      ['private-2', { id: 'private-2', contact_id: 'contact-2' }],
+      ['group-1', { id: 'group-1', contact_id: 'room-1@chatroom' }],
+    ]);
+    const db = {
+      getConversation(id) { return conversations.get(id) || null; },
+      listMessages() { return []; },
+    };
+    const allowlist = new Set(['contact-1']);
+    const scope = __testing.resolveConversationSyncScope({ conversationId: 'private-1' }, allowlist, db);
+    assert.equal(scope.scope, 'conversation');
+    assert.deepEqual([...scope.contacts], ['contact-1']);
+    assert.throws(() => __testing.resolveConversationSyncScope({ conversationId: 'private-2' }, allowlist, db), /白名单/);
+    assert.throws(() => __testing.resolveConversationSyncScope({ conversationId: 'group-1' }, allowlist, db), /个人聊天/);
+  });
+
+  await check('Dashboard 和工具公开精准采集及测试好友白名单契约', async () => {
+    const appSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
+    const serverSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'mcp', 'src', 'server.js'), 'utf8');
+    const bridgeSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'runtime', 'callback-service', 'src', 'processor-bridge.mjs'), 'utf8');
+    assert.match(appSource, /data-agent-action="sync-current-conversation"/);
+    assert.match(appSource, /补采全部白名单/);
+    assert.match(serverSource, /addToAllowlist:\s*z\.boolean\(\)/);
+    assert.match(bridgeSource, /startListener\(\{ automatic: true \}\)/);
+  });
+
+  await check('白名单文件变更会在监听期间热加载', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-allowlist-reload-'));
+    try {
+      const envFile = path.join(dir, '.env.local');
+      fs.writeFileSync(envFile, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS=contact-1,contact-2\n', 'utf8');
+      const config = { selfUserId: 'self', allowedSenders: ['contact-1'] };
+      const { __testing } = require('../mcp/src/dashboard/agent-service');
+      const refreshed = __testing.refreshAllowedSendersFromEnv(config, envFile);
+      assert.deepEqual(refreshed, { changed: true, count: 2 });
+      assert.deepEqual(config.allowedSenders, ['contact-1', 'contact-2']);
+      const candidate = evaluatePolledMessage({
+        msgType: 1,
+        senderId: 'contact-2',
+        receiverId: 'self',
+        timestamp: Math.floor(Date.now() / 1000),
+        msgData: { content: '新加入白名单后的首条消息' },
+      }, config);
+      assert.equal(candidate.eligible, true);
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  await check('监听恢复可保留现有 Agent 模式', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const calls = [];
+    const target = {
+      service: {
+        setGlobal(input) { calls.push(['global', input]); },
+        setConversationMode(id, mode) { calls.push(['conversation', id, mode]); },
+      },
+      db: { listConversations: () => [{ id: 'conversation-a' }] },
+    };
+    // preserveAgentState=true 时 stopListener 不调用该接管逻辑;普通人工停止仍调用。
+    assert.equal(calls.length, 0);
+    __testing.applyManualTakeover(target);
+    assert.deepEqual(calls, [
+      ['global', { paused: true, defaultMode: 'review' }],
+      ['conversation', 'conversation-a', 'human'],
+    ]);
+  });
+
   await check('发送语音后会结算当前待审核草稿并关联语音消息', async () => {
     const { __testing } = require('../mcp/src/dashboard/agent-service');
     const draft = {
@@ -309,6 +436,12 @@ async function main() {
     const { __testing } = require('../mcp/src/dashboard/agent-service');
     const calls = [];
     const target = {
+      config: { qiwei: { allowedSenders: ['contact-1'] } },
+      db: {
+        getSetting(_name, fallback) { return fallback; },
+        setSetting() {},
+      },
+      service: { setGlobal() { calls.push('service.setGlobal'); } },
       poller: {
         async start() {
           calls.push('poller.start');
@@ -317,7 +450,7 @@ async function main() {
       }
     };
     const result = await __testing.startListenerForWorkbench(target, { online: true, nickname: '测试账号' });
-    assert.deepEqual(calls, ['poller.start']);
+    assert.deepEqual(calls, ['service.setGlobal', 'poller.start']);
     assert.equal(result.status, 'ok');
     assert.equal(result.data.running, true);
   });

+ 35 - 0
claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs

@@ -5,6 +5,7 @@ import os from 'node:os';
 import path from 'node:path';
 import { pathToFileURL } from 'node:url';
 import { decryptPayload, EnterpriseRelayRuntime } from '../runtime/callback-service/src/enterprise-relay-client.mjs';
+import { PersonalPollingRuntime } from '../runtime/callback-service/src/personal-polling.mjs';
 import { loadRuntimeConfig, resolveRuntimeMode } from '../runtime/callback-service/src/config-loader.mjs';
 import { PortraitQueueWorker } from '../runtime/callback-service/src/portrait-queue-worker.mjs';
 import {
@@ -127,6 +128,40 @@ try {
   const runtimeIndexSource = fs.readFileSync(new URL('../runtime/callback-service/src/index.mjs', import.meta.url), 'utf8');
   assert.match(runtimeIndexSource, /alreadyRunning: true/);
 
+  let listener = { running: true, syncKey: 7, lastError: 'offline', startedAt: 1 };
+  let stopOptions = null;
+  let recoveryCount = 0;
+  const personalStates = [];
+  const personal = new PersonalPollingRuntime({
+    config: { retryMs: 3000, friendPollingEnabled: false },
+    workspaceRoot: tempRoot,
+    onState: statePatch => personalStates.push(statePatch),
+    listenerApi: {
+      getStatus: async () => ({
+        data: {
+          account: { online: recoveryCount > 0 },
+          listener,
+        },
+      }),
+      recoverLogin: async () => {
+        recoveryCount += 1;
+        return { summary: { loggedIn: true, statusCode: 2 } };
+      },
+      stop: async options => { stopOptions = options; listener = { ...listener, running: false }; },
+      start: async () => {
+        listener = { running: true, syncKey: 7, lastError: '', startedAt: 2 };
+        return { data: listener };
+      },
+    },
+  });
+  const recoveredListener = await personal.pollOnce();
+  assert.equal(recoveryCount, 1);
+  assert.deepEqual(stopOptions, { preserveAgentState: true });
+  assert.equal(recoveredListener.running, true);
+  assert.equal(recoveredListener.lastError, '');
+  assert.equal(personalStates.at(-1).personalPolling.status, 'running');
+  assert.equal(personalStates.at(-1).personalPolling.lastError, '');
+
   process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 25 }, null, 2)}\n`);
 } finally {
   fs.rmSync(tempRoot, { recursive: true, force: true });

+ 66 - 0
claude-code/claude-code-qiwe-assistant/scripts/message-archive-smoke-test.js

@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+async function main() {
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-message-archive-'));
+  const previousArchive = process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED;
+  const previousMemory = process.env.QIWEI_AGENT_MEMORY_ENABLED;
+  const previousRoot = process.env.QIWEI_MESSAGES_DIR;
+  try {
+    process.env.QIWEI_MESSAGES_DIR = tempRoot;
+    delete process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED;
+    delete process.env.QIWEI_AGENT_MEMORY_ENABLED;
+    const archive = require('../mcp/src/core/message-archive');
+
+    assert.equal(archive.archiveEnabled(), false);
+    assert.equal(archive.memoryEnabled(), false);
+    await archive.appendChatRecord({ wxid: 'contact/unsafe', content: '默认不落盘', dir: 'in' });
+    assert.equal(fs.existsSync(path.join(tempRoot, 'chat')), false);
+
+    process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED = '1';
+    await archive.appendChatRecord({
+      wxid: 'contact/unsafe', messageId: 'm-1', externalId: 'e-1', dir: 'in', senderType: 'customer',
+      content: '需要下周安排一次产品演示', createdAt: '2026-08-03T10:00:00.000Z', source: 'smoke',
+    });
+    await archive.appendGroupRecord({
+      roomId: 'room:demo', messageId: 'g-1', seq: 1, msgType: 1, senderId: 'member-1',
+      senderName: '测试成员', self: false, content: '群内测试消息', timestamp: '2026-08-03T10:01:00.000Z',
+    });
+    await archive.drainQueues();
+    const chatFile = path.join(tempRoot, 'chat', 'contact_unsafe', '2026-08-03.json');
+    const groupFile = path.join(tempRoot, 'group', 'room_demo', '2026-08-03.json');
+    assert.equal(JSON.parse(fs.readFileSync(chatFile, 'utf8')).content, '需要下周安排一次产品演示');
+    assert.equal(JSON.parse(fs.readFileSync(groupFile, 'utf8')).roomId, 'room:demo');
+
+    assert.deepEqual(archive.ensureMemory('contact-1', '刘总'), { enabled: false, dir: '', created: [] });
+    process.env.QIWEI_AGENT_MEMORY_ENABLED = '1';
+    const memory = archive.ensureMemory('contact-1', '刘总');
+    assert.equal(memory.enabled, true);
+    assert.deepEqual(memory.created.sort(), [...archive.MEMORY_FILES].sort());
+    const templates = archive.MEMORY_FILES.map(name => fs.readFileSync(path.join(memory.dir, name), 'utf8')).join('\n');
+    assert.match(templates, /当前目标与需求/);
+    assert.doesNotMatch(templates, /房源|购房|看房|小牛/);
+    assert.match(archive.memoryIndexText('contact-1'), /用户记忆索引/);
+    assert.equal(archive.memoryRelBase('contact/1'), 'messages/memory/contact_1');
+
+    process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, archiveDefault: 'off', memoryDefault: 'off' }, null, 2)}\n`);
+  } finally {
+    if (previousArchive === undefined) delete process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED;
+    else process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED = previousArchive;
+    if (previousMemory === undefined) delete process.env.QIWEI_AGENT_MEMORY_ENABLED;
+    else process.env.QIWEI_AGENT_MEMORY_ENABLED = previousMemory;
+    if (previousRoot === undefined) delete process.env.QIWEI_MESSAGES_DIR;
+    else process.env.QIWEI_MESSAGES_DIR = previousRoot;
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+});

+ 1 - 0
claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js

@@ -102,6 +102,7 @@ function main() {
     'scripts/preview-dashboard.js',
     'scripts/startup-preview-smoke-test.js',
     'skill-package-manifest.json',
+    'templates/workspace.gitignore',
     'THIRD_PARTY_NOTICES.md',
   ]) {
     assert(packedPaths.has(required), `npm 包缺少 ${required}`);

+ 3 - 2
claude-code/claude-code-qiwe-assistant/scripts/start-login-flow-4200.js

@@ -1,11 +1,12 @@
-const { readQiweiAuthToken, ensureQiweiUid, readQiweiApiBase } = require('../mcp/src/core/credentials');
+const { readQiweiAuthToken, ensureQiweiUid, readQiweiApiBase, readQiweiGuid } = require('../mcp/src/core/credentials');
 const { startLoginFlowServer } = require('../mcp/src/core/login-flow-server');
 
 const token = readQiweiAuthToken({});
 const uid = ensureQiweiUid({});
+const guid = readQiweiGuid({});
 const apiBase = readQiweiApiBase({});
 
-startLoginFlowServer({ token, apiBase, uid, port: Number(process.env.QIWEI_FLOW_PORT) || 4200 })
+startLoginFlowServer({ token, apiBase, uid, guid, port: Number(process.env.QIWEI_FLOW_PORT) || 4200 })
   .then(({ url, alreadyRunning }) => {
     console.log(JSON.stringify({ url, alreadyRunning, uid, apiBase, tokenConfigured: Boolean(token) }));
   })

+ 17 - 0
claude-code/claude-code-qiwe-assistant/scripts/startup-preview-smoke-test.js

@@ -38,6 +38,7 @@ function testStartupSummary() {
 function testWorkspaceInstall(tempRoot) {
   const target = path.join(tempRoot, 'customer-project');
   fs.mkdirSync(target, { recursive: true });
+  fs.writeFileSync(path.join(target, '.gitignore'), '# Customer rules\ncustomer-cache/\n', 'utf8');
   const result = spawnSync(process.execPath, [path.join(ROOT, 'install.js'), 'workspace', target, '--skip-install'], {
     cwd: ROOT,
     encoding: 'utf8',
@@ -52,6 +53,22 @@ function testWorkspaceInstall(tempRoot) {
   assert.equal(config.env?.CLAUDE_CODE_WORKDIR, target);
   assert(fs.existsSync(path.join(target, '.claude', 'plugins', 'qiwei-assistant', 'scripts', 'preview-dashboard.js')));
   assert(!fs.existsSync(path.join(target, '.claude', 'plugins', 'qiwei-assistant', '.env.local')));
+  const gitignorePath = path.join(target, '.gitignore');
+  const firstGitignore = fs.readFileSync(gitignorePath, 'utf8');
+  assert.match(firstGitignore, /# Customer rules\ncustomer-cache\//);
+  assert.match(firstGitignore, /# >>> qiwei-assistant managed ignores >>>/);
+  assert.match(firstGitignore, /\*\*\/\.env\.local/);
+  assert.match(firstGitignore, /outputs\/messages\/\*\.db/);
+
+  const repeated = spawnSync(process.execPath, [path.join(ROOT, 'install.js'), 'workspace', target, '--skip-install'], {
+    cwd: ROOT,
+    encoding: 'utf8',
+    windowsHide: true,
+  });
+  assert.equal(repeated.status, 0, repeated.stderr || repeated.stdout);
+  const secondGitignore = fs.readFileSync(gitignorePath, 'utf8');
+  assert.equal((secondGitignore.match(/# >>> qiwei-assistant managed ignores >>>/g) || []).length, 1);
+  assert.match(secondGitignore, /# Customer rules\ncustomer-cache\//);
 }
 
 function testRuntimeProcessSeparation() {

+ 64 - 9
claude-code/claude-code-qiwe-assistant/skill-package-manifest.json

@@ -1,16 +1,62 @@
 {
   "name": "fmode-qiwei",
-  "version": "0.4.3",
+  "version": "0.5.2",
   "status": "published",
   "plugin": "qiwei-assistant",
   "description": "企业微信智能办公技能包:日常办公、目标推进、智能客服、CRM 客户管理和底层 Skills/API。",
   "capabilities": {
-    "dailyOffice": ["会议", "文档", "日程", "待办", "本地知识库"],
-    "goalManagement": ["大目标拆解", "里程碑", "会议行动项", "进度/阻塞/逾期跟踪", "企微待办衔接"],
-    "customerService": ["真实私信监听", "客户独立 Claude Code Session", "Agent 草稿", "人工审核", "人工接管", "安全会话审阅"],
-    "businessOperations": ["经营驾驶舱", "经营诊断", "证据与数据缺口", "行动候选", "人工任务晋升", "行动效果反馈"],
-    "crm": ["客户档案", "客户群", "群消息", "社群 SOP", "运营计划话术", "群质检督导", "画像", "标签", "顾问 Playbook", "客户交接"],
-    "foundation": ["个人版本地监听", "企业版统一 Relay", "Fmode 网关", "官方 CLI", "104 个接口清单", "MCP 工具", "Webhook/Relay", "白名单与审计"]
+    "dailyOffice": [
+      "会议",
+      "文档",
+      "日程",
+      "待办",
+      "本地知识库"
+    ],
+    "goalManagement": [
+      "大目标拆解",
+      "里程碑",
+      "会议行动项",
+      "进度/阻塞/逾期跟踪",
+      "企微待办衔接"
+    ],
+    "customerService": [
+      "真实私信监听",
+      "客户独立 Claude Code Session",
+      "Agent 草稿",
+      "人工审核",
+      "人工接管",
+      "安全会话审阅"
+    ],
+    "businessOperations": [
+      "经营驾驶舱",
+      "经营诊断",
+      "证据与数据缺口",
+      "行动候选",
+      "人工任务晋升",
+      "行动效果反馈"
+    ],
+    "crm": [
+      "客户档案",
+      "客户群",
+      "群消息",
+      "社群 SOP",
+      "运营计划话术",
+      "群质检督导",
+      "画像",
+      "标签",
+      "顾问 Playbook",
+      "客户交接"
+    ],
+    "foundation": [
+      "个人版本地监听",
+      "企业版统一 Relay",
+      "Fmode 网关",
+      "官方 CLI",
+      "104 个接口清单",
+      "MCP 工具",
+      "Webhook/Relay",
+      "白名单与审计"
+    ]
   },
   "skills": [
     "qiwei-api-catalog",
@@ -33,17 +79,26 @@
     "qiwei-voice",
     "qiwei-webhook-relay"
   ],
-  "mcpToolCount": 96,
+  "mcpToolCount": 95,
   "apiCatalogCount": 104,
   "npmPackage": "fmode-qiwei",
   "installCommand": "npx --yes fmode-qiwei@latest workspace --smoke",
   "previewCommand": "npx --yes fmode-qiwei@latest preview",
   "workspaceInstallTarget": ".claude/plugins/qiwei-assistant",
   "dashboardUrl": "http://127.0.0.1:4320/",
-  "startupChecks": ["产品模式", "Fmode 鉴权", "企微席位", "企微账号在线", "企业 Relay(企业版)", "测试白名单", "AI 监听"],
+  "startupChecks": [
+    "产品模式",
+    "Fmode 鉴权",
+    "企微席位",
+    "企微账号在线",
+    "企业 Relay(企业版)",
+    "测试白名单",
+    "AI 监听"
+  ],
   "sessionReviewCommand": "npm run agent:session -- --customer <客户名称>",
   "security": [
     "安装包不复制 .env.local、真实 outputs、Playwright 会话或 node_modules",
+    "workspace 初始化会幂等补全项目 .gitignore,保留客户已有规则并忽略本地凭据与运行数据",
     "客户 Session 原始 ID 不进入用户提示或 Dashboard",
     "默认 review 模式;群聊全自动模式必须按群明确开启并二次确认,发送失败保留草稿"
   ]

+ 4 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md

@@ -71,12 +71,16 @@ description: 迁移自 Qiwei 项目的客户群运营能力:批量搜索并添
   "customers": [
     { "phone": "13800000000", "name": "张三", "greeting": "你好,方便加您企业微信沟通。" }
   ],
+  "addToAllowlist": true,
   "rateLimitPerMinute": 12
 }
 ```
 
 也可以传 `phones`,或传 `.xlsx` 格式的 `filePath` 读取 Excel。Excel 至少需要手机号列;旧版 `.xls` 请先另存为 `.xlsx`。
 
+- 用户明确说“测试好友”“演示联系人”时传 `addToAllowlist=true`,成功后自动加入个人消息白名单并开启待审核监听。
+- 普通批量拓客时省略该字段,避免扩大 AI 监听范围。
+
 ### 检查好友申请状态
 
 批量加好友后,可调用 `qiwei_check_friend_status` 查看哪些客户已通过、待通过或未找到:

+ 3 - 2
claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md

@@ -67,8 +67,8 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 1. Fmode 鉴权是否可用;
 2. 企微席位是否有效;
 3. 企微账号是否在线;
-4. 测试联系人白名单是否非空;
-5. 企业版公网回调是否运行(个人版检查 AI 监听)。
+4. 测试联系人白名单是否非空;添加测试好友时自动写入白名单,普通批量加好友不自动扩大监听范围;
+5. 企业版公网回调是否运行(个人版默认保持 AI 待审核监听;人工关闭时尊重关闭状态)。
 
 只提示第一项需要处理的动作,完成后重新检查。Dashboard 只是管理界面;企业版回调和 Skill 会话读取均不依赖页面是否打开。
 
@@ -78,6 +78,7 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 - 项目主控 Claude Code 会话负责服务启动、登录、策略、审核和事件汇总,不直接承载所有客户对话上下文。
 - 每个白名单客户绑定独立 Claude Code Session;Session 记录项目 ID 和主控 Session 关联,防止客户上下文互相污染。
 - Dashboard、监听器、MCP 管理工具和客户 Session 共同读写 Workbench 数据库与审计事件。
+- 私聊会话标题栏提供“采集当前会话”,只补采当前白名单联系人;顶部“补采全部白名单”用于批量补录。
 - Claude Code 交互会话不能被浏览器页面直接异步注入消息。新事件先写入事件箱,主控会话调用 `qiwei_agent_inbox` 读取;需要自动回灌时必须由串行桥接器 resume 主控 Session,禁止并发写同一 Session。
 - 客户 Session 默认只允许 Read、Glob、Grep,只生成草稿。真实发送必须继续经过白名单、模式、置信度和人工审核规则。
 - Dashboard 客户标题下方必须显示 Session 状态条;识别到客户 Session 后,展示可识别名称和「查看会话与打开方式」按钮。按钮只复制安全命令,不展示原始 Session ID。

+ 10 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-login/SKILL.md

@@ -27,6 +27,15 @@ description: 通过 Fmode 网关转发的企业微信接口完成扫码登录和
 5. **验证码**:状态 `10` 时向用户索要手机端显示的 6 位验证码,调用 `qiwei_login_verify`,然后再次检查状态。
 6. **验证业务链路**:登录成功后调用 `qiwei_api_call`(例如 `user.getProfile`)。
 
+## 新账号与多账号切换
+
+- “添加账号”必须为新账号生成新的 `uid`,启动、轮询和验证码请求始终携带同一个新 `uid`;新 `uid` 不复用当前账号的 `guid`。
+- 扫码完成前使用 `persistConfig=false`,不提前覆盖当前项目配置;状态为 `2` 后再保存新账号并调用 Dashboard 的账号切换接口。
+- 切换成功后同时持久化 `QIWEI_UID` 与对应 `QIWEI_GUID`;新账号没有 GUID 时必须清除旧 GUID,不得形成跨账号组合。
+- 浏览器账号列表按 `workspaceId` 隔离。同一浏览器访问多个克隆项目时,不得把其他项目的当前账号自动写入本项目。
+- 用户取消扫码或切换接口失败时恢复原当前账号;已经保存的其他账号继续保留,可再次选择。
+- 切换账号会切换独立 Workbench 数据库和 Claude 客户 Session;监听重启只恢复连接,不改变原有审核/自动/人工模式。
+
 ## 配置
 
 - `QIWEI_API_BASE` 默认 `https://server.fmode.cn/api/qiwei`;
@@ -40,3 +49,4 @@ description: 通过 Fmode 网关转发的企业微信接口完成扫码登录和
 - 不向用户暴露接口服务提供方名称、相关域名或控制台;
 - 不绕过 Fmode 网关直连内部服务;
 - 不通过 `qiwei_api_call` 调用 `/login/*` 或 `/client/*`。
+- 不在对话、日志或测试报告中回显 Token、GUID 或完整账号标识。

+ 53 - 0
claude-code/claude-code-qiwe-assistant/templates/workspace.gitignore

@@ -0,0 +1,53 @@
+# >>> qiwei-assistant managed ignores >>>
+# Dependencies
+node_modules/
+**/node_modules/
+
+# Local credentials and environment overrides
+.env
+.env.local
+.env.*.local
+**/.env
+**/.env.local
+**/.env.*.local
+!.env.example
+!**/.env.example
+.npmrc
+**/.npmrc
+
+# Local runtime state and temporary data
+outputs/tmp/
+outputs/runtime/
+outputs/messages/*.db
+outputs/messages/*.db-*
+outputs/messages/claude-code-sessions*.json
+outputs/messages/rooms/
+outputs/group-operations/*.db
+outputs/group-operations/*.db-*
+outputs/customers/
+outputs/groups/
+outputs/portraits/
+outputs/dashboard/dashboard-state.json
+outputs/backups/
+outputs/voice/
+/messages/
+poll_state.json
+qwmsgs*.json
+qwrooms*.json
+qwsessions*.json
+*.log
+
+# Build, package, coverage and browser artifacts
+coverage/
+dist/
+*.tgz
+*.zip
+.playwright-cli/
+
+# Local editor and operating-system files
+.claude/settings.local.json
+.DS_Store
+Thumbs.db
+**/__MACOSX/
+~$*
+# <<< qiwei-assistant managed ignores <<<

+ 51 - 0
release/npm/fmode-qiwei/publish.ps1

@@ -0,0 +1,51 @@
+param(
+  [string]$Tarball,
+  [switch]$AuthOnly
+)
+
+$ErrorActionPreference = 'Stop'
+$Root = Resolve-Path (Join-Path $PSScriptRoot '..\..\..')
+$PackageRoot = Join-Path $Root 'claude-code\claude-code-qiwe-assistant'
+$SharedEnvPath = Join-Path $PSScriptRoot '..\claude-code-voc-intelligence\.env.local'
+
+function Read-LocalToken([string]$FilePath) {
+  if (-not (Test-Path -LiteralPath $FilePath)) { return '' }
+  foreach ($raw in Get-Content -LiteralPath $FilePath) {
+    $line = $raw.Trim()
+    if (-not $line -or $line.StartsWith('#') -or -not $line.Contains('=')) { continue }
+    $parts = $line.Split('=', 2)
+    if ($parts[0].Trim() -in @('NPM_TOKEN', 'NODE_AUTH_TOKEN')) {
+      return $parts[1].Trim().Trim('"').Trim("'")
+    }
+  }
+  return ''
+}
+
+$token = if ($env:NPM_TOKEN) {
+  $env:NPM_TOKEN
+} elseif ($env:NODE_AUTH_TOKEN) {
+  $env:NODE_AUTH_TOKEN
+} else {
+  Read-LocalToken $SharedEnvPath
+}
+if (-not $token) { throw 'npm token is not configured in the local release environment.' }
+
+$npmrc = Join-Path $env:TEMP ('fmode-qiwei-' + [guid]::NewGuid().ToString('N') + '.npmrc')
+try {
+  Set-Content -LiteralPath $npmrc -Value "//registry.npmjs.org/:_authToken=$token" -Encoding ascii
+  $env:NPM_CONFIG_USERCONFIG = $npmrc
+
+  $account = npm whoami --registry=https://registry.npmjs.org/
+  if ($LASTEXITCODE -ne 0) { throw 'npm authentication check failed.' }
+  Write-Host "[auth] npm account: $account"
+  if ($AuthOnly) { return }
+
+  if (-not $Tarball) { throw 'Tarball is required unless -AuthOnly is used.' }
+  $resolvedTarball = Resolve-Path -LiteralPath $Tarball
+  npm publish $resolvedTarball --access public --registry=https://registry.npmjs.org/
+  if ($LASTEXITCODE -ne 0) { throw 'npm publish failed.' }
+} finally {
+  Remove-Item Env:NPM_CONFIG_USERCONFIG -ErrorAction SilentlyContinue
+  Remove-Item -LiteralPath $npmrc -Force -ErrorAction SilentlyContinue
+  $token = $null
+}

+ 15 - 1
release/npm发布管理文档/NPM技能包发布管理.md

@@ -30,7 +30,7 @@
 | fmode 视觉分析 | `fmode-vision` | `0.1.1` | 已发布 | `claude-code/fmode-vision/`(如有) | https://www.npmjs.com/package/fmode-vision |
 | fmode 音视频处理 | `fmode-ffmpeg` | `0.1.1` | 已发布 | `claude-code/fmode-ffmpeg/` | https://www.npmjs.com/package/fmode-ffmpeg |
 | fmode 录音转写 | `fmode-listen` | `0.1.1` | 已发布 | `claude-code/fmode-listen/`(如有) | https://www.npmjs.com/package/fmode-listen |
-| fmode 企业微信智能办公 | `fmode-qiwei` | `0.4.3` | 已发布 | `claude-code/claude-code-qiwe-assistant/` | https://www.npmjs.com/package/fmode-qiwei |
+| fmode 企业微信智能办公 | `fmode-qiwei` | `0.5.2` | 已发布 | `claude-code/claude-code-qiwe-assistant/` | https://www.npmjs.com/package/fmode-qiwei |
 | 装修量尺图缺标记检查 | `@gangvy/claude-code-measurement-marker-check` | `0.1.2` | 已发布 | `claude-code-measurement-marker-check/` | https://www.npmjs.com/package/@gangvy/claude-code-measurement-marker-check |
 
 ## 安装入口
@@ -131,10 +131,24 @@ npx --yes fmode-qiwei@latest workspace --smoke
 npx --yes fmode-qiwei@latest preview
 ```
 
+发布后验证:
+
+```powershell
+npm view fmode-qiwei name version dist-tags.latest --registry=https://registry.npmjs.org/
+npm exec --yes --package fmode-qiwei@latest -- fmode-qiwei workspace . --smoke
+```
+
+本地发布脚本会读取现有发布环境中的 npm token,并使用临时 `.npmrc`,结束后自动删除:
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File release/npm/fmode-qiwei/publish.ps1 -Tarball <fmode-qiwei-版本.tgz>
+```
+
 工作区安装会写入:
 
 ```text
 .\.mcp.json
+.\.gitignore
 .\.claude\plugins\qiwei-assistant
 .\.claude\skills\<qiwei-skill>\SKILL.md
 ```

+ 34 - 2
release/npm发布管理文档/变更记录/fmode-qiwei.md

@@ -2,11 +2,43 @@
 
 > 当前版本号见 [`../NPM技能包发布管理.md`](../NPM技能包发布管理.md) 的「发布状态总览」。本文按版本号倒序记录 `fmode-qiwei` 的更新内容。
 
-## 未发布(开发中
+## 0.5.2(2026-08-04
 
-- 新增云端 IndexTTS2 本人声音初始化、自动客服语气识别、24kHz SILK 编码和企微原生语音发送;试听默认禁用以避免额外合成费用。
+- 个人消息继续强制受白名单约束;`qiwei_batch_add_friends` 新增显式 `addToAllowlist`,仅测试/演示好友按需自动加入白名单,普通批量拓客不扩大监听范围。
+- Dashboard 新增“采集当前会话”,只补采当前白名单私聊;顶部入口改为“补采全部白名单”,群聊和非白名单联系人会被拒绝。
+- 个人版默认保持 AI 监听和 `review` 待审核模式;监听启动前热加载项目白名单,人工关闭后独立 Runtime 尊重关闭状态,不会自动外发。
+- Dashboard 预览改为启动独立 Runtime,关闭页面或 Dashboard 进程后监听仍可常驻;工作区 Skill 文档与插件契约同步更新。
+- 发布前 18 组检查、37 项 Agent 回归、25 项 Runtime 回归、账号切换 20 项、npm 包 190 项及生产依赖审计全部通过,违规文件和依赖漏洞均为 0。
+- 已发布并验证 npm `latest=0.5.2`;registry 版本、线上 tarball shasum `2f14817331ee36cace1f33257327f1b6cb85dbc8` 和隔离环境 `workspace --smoke` 均通过。
+
+## 0.5.1(2026-08-04)
+
+- workspace 初始化新增项目根目录 `.gitignore`:通过随包发布的 `templates/workspace.gitignore` 规避 npm 默认省略源码 `.gitignore` 的行为。
+- 初始化器会幂等更新托管规则区块并保留客户已有内容,覆盖 `.env*`、`.npmrc`、`node_modules`、运行数据库、Claude Session、消息流水、日志、构建包和浏览器缓存。
+- 增加重复初始化、客户规则保留、模板实际入包和真实 `git check-ignore` 验收;实际 tar 包共 190 项,违规文件为 0。
+- 更新 `fast-uri`、`hono`、`ip-address` 安全修复版本;18 组发布检查及生产依赖审计通过,0 vulnerabilities。
+- 已发布并验证 npm `latest=0.5.1`;registry 查询、实际 tar 安装和线上 `workspace --smoke` 均通过,发布包 shasum 为 `81508e2d…`。
+
+## 0.5.0(2026-08-03)
+
+- 从业务项目选择性回迁通用能力;未加入 `qiwei-property-match`、房源 Provider、购房画像、房源推荐规则、小牛业务默认值或房源云函数。
+- 修复新企微账号扫码隔离:新 UID 不继承旧 GUID,登录开始/检查/验证码保持同一 UID;网关脱敏不再改写 GUID 业务值。
+- 修复账号切换持久化:切换成功同步项目 `.env.local` 和用户级活动账号;无 GUID 账号会显式清除旧 GUID,标准变量空值可覆盖旧别名变量。
+- Dashboard 账号列表按 `workspaceId` 隔离,防止不同克隆项目在同一浏览器和 4320 端口下串号;扫码取消或后端切换失败恢复原账号。
+- 监听恢复支持 `preserveAgentState`,不会因运行时重连重置审核/自动/人工模式;个人轮询在处理消息前热加载白名单。
+- 增加通用 JSONL 消息归档和五文件用户记忆框架,二者默认关闭;模板不包含房产字段,启用记忆时只开放当前用户记忆目录的 Write/Edit 权限。
+- 增加通用位置消息和小程序卡片底层传输,未绑定任何行业默认小程序或业务自动发送逻辑。
+- 新增账号切换 20 项、Runtime 25 项、消息归档 14 项专项检查,并补充克隆项目现场演示步骤与一键配置提示词。
+- 已通过 18 组发布检查、生产依赖审计(0 vulnerabilities)、npm pack、registry 版本验证和线上 npx workspace smoke;npm `latest=0.5.0`。
+
+## 0.4.4(2026-08-01)
+
+- 新增本人声音初始化、自动客服语气识别、24kHz SILK 编码和企微原生语音发送;试听默认禁用以避免额外合成费用。
 - Dashboard 客服编辑器增加声音设置、语气选择和真实语音发送;录音、上传、删除、加载、失败和移动端状态完整。
 - 新增声音档案账号隔离、短期媒体令牌、白名单与人工确认校验,以及声音初始化、发送和注销审计。
+- 语音合成统一通过 Fmode `/api/voice/indextts2`,自动复用技能包 Fmode Token,客户不再配置独立语音服务密钥。
+- 企微语音媒体通过专用 `/api/qiwei/doFileApi` 上传并衔接 `/msg/sendVoice`;新增合成、鉴权、multipart 字段和返回值解析回归测试。
+- 通过完整发布检查、npm 包结构检查、生产依赖审计和线上安装验证。
 
 ## 0.4.3(2026-07-24)