Ver código fonte

更新企微技能包

gangvy 2 meses atrás
pai
commit
bb2d315240
84 arquivos alterados com 13603 adições e 779 exclusões
  1. 8 0
      claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json
  2. 38 1
      claude-code/claude-code-qiwe-assistant/.env.example
  3. 7 0
      claude-code/claude-code-qiwe-assistant/.gitignore
  4. 85 3
      claude-code/claude-code-qiwe-assistant/README.md
  5. 7 0
      claude-code/claude-code-qiwe-assistant/docs/OUTPUT-STANDARD.md
  6. 76 0
      claude-code/claude-code-qiwe-assistant/docs/RELEASE.md
  7. 184 0
      claude-code/claude-code-qiwe-assistant/install.js
  8. 12 0
      claude-code/claude-code-qiwe-assistant/knowledge-base/README.md
  9. 54 0
      claude-code/claude-code-qiwe-assistant/knowledge-base/catalog.json
  10. 136 0
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/buyer-tags.json
  11. 133 0
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/buyers.json
  12. 302 0
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/match-engine-pg.js
  13. 496 0
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/match-engine.js
  14. 35 0
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/properties.json
  15. 17 0
      claude-code/claude-code-qiwe-assistant/knowledge/faq.md
  16. 17 0
      claude-code/claude-code-qiwe-assistant/knowledge/playbooks.md
  17. 25 0
      claude-code/claude-code-qiwe-assistant/knowledge/rules.md
  18. 331 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-console-store.js
  19. 143 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-knowledge.js
  20. 22 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-poller-policy.js
  21. 842 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js
  22. 68 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-session-guide.js
  23. 717 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-db.js
  24. 368 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js
  25. 35 15
      claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js
  26. 51 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/customer-task-official-sync.js
  27. 130 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/deadline-parser.js
  28. 8 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js
  29. 2 2
      claude-code/claude-code-qiwe-assistant/mcp/src/core/shared-gateway.js
  30. 2 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-server.js
  31. 47 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/xlsx-io.js
  32. 1138 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  33. 1597 19
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  34. 248 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js
  35. 16 3
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html
  36. 507 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/meeting-knowledge-service.js
  37. 487 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/official-office-knowledge-service.js
  38. 253 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  39. 1183 1
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  40. 363 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/unified-task-service.js
  41. 463 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/workspace-library-service.js
  42. 85 0
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js
  43. 22 8
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js
  44. 212 4
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  45. 264 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-control-run.js
  46. 0 355
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-skill-run.js
  47. 17 14
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js
  48. 18 22
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js
  49. 18 3
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js
  50. 198 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-goal-management-run.js
  51. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js
  52. 77 16
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js
  53. 69 288
      claude-code/claude-code-qiwe-assistant/package-lock.json
  54. 25 3
      claude-code/claude-code-qiwe-assistant/package.json
  55. 472 0
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  56. 76 0
      claude-code/claude-code-qiwe-assistant/scripts/customer-journey-smoke-test.js
  57. 70 0
      claude-code/claude-code-qiwe-assistant/scripts/customer-master-smoke-test.js
  58. 29 0
      claude-code/claude-code-qiwe-assistant/scripts/deadline-parser-smoke-test.js
  59. 78 0
      claude-code/claude-code-qiwe-assistant/scripts/excel-smoke-test.js
  60. 70 0
      claude-code/claude-code-qiwe-assistant/scripts/goal-management-smoke-test.js
  61. 182 0
      claude-code/claude-code-qiwe-assistant/scripts/meeting-knowledge-smoke-test.js
  62. 139 0
      claude-code/claude-code-qiwe-assistant/scripts/official-office-knowledge-smoke-test.js
  63. 196 0
      claude-code/claude-code-qiwe-assistant/scripts/open-customer-session.js
  64. 84 0
      claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js
  65. 41 0
      claude-code/claude-code-qiwe-assistant/scripts/release-check.js
  66. 9 8
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  67. 76 0
      claude-code/claude-code-qiwe-assistant/scripts/unified-task-smoke-test.js
  68. 4 1
      claude-code/claude-code-qiwe-assistant/scripts/validate-output-standard.js
  69. 1 1
      claude-code/claude-code-qiwe-assistant/scripts/wecom-cli-smoke-test.js
  70. 55 0
      claude-code/claude-code-qiwe-assistant/scripts/workspace-library-smoke-test.js
  71. 44 0
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  72. 17 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-capability-router/SKILL.md
  73. 24 2
      claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md
  74. 59 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  75. 37 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-goal-management/SKILL.md
  76. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-goal-management/agents/openai.yaml
  77. 13 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-official-doc/SKILL.md
  78. 17 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-official-meeting/SKILL.md
  79. 29 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-official-schedule/SKILL.md
  80. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-official-schedule/agents/openai.yaml
  81. 48 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-official-todo/SKILL.md
  82. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-official-todo/agents/openai.yaml
  83. 6 2
      claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md
  84. 56 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md

+ 8 - 0
claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json

@@ -0,0 +1,8 @@
+{
+  "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.0",
+  "author": {
+    "name": "BrainHack"
+  }
+}

+ 38 - 1
claude-code/claude-code-qiwe-assistant/.env.example

@@ -18,7 +18,7 @@ WECOM_CLI_CONFIG_DIR=
 
 # ---- Relay 中央 Webhook 配置(可选) ----
 # 中央 Relay 服务器地址,留空则不启用 Relay 模式
-RELAY_BASE_URL=http://8.138.37.248:4000
+RELAY_BASE_URL=
 
 # Relay 租户凭证(可由 qiwei_relay_register 自动写入)
 TENANT_API_KEY=
@@ -27,3 +27,40 @@ TENANT_ID=
 
 # RSA 私钥,必须写成单行,用 \n 替换真实换行符
 RELAY_PRIVATE_KEY=
+
+# ---- 智能会话监听与白名单 ----
+# 留空时监听器会拒绝启动,避免误回复真实客户。
+QIWEI_AUTO_REPLY_ALLOWED_SENDERS=
+QIWEI_AUTO_REPLY_SELF_USER_ID=
+QIWEI_AUTO_REPLY_INTERVAL_MS=10000
+
+# ---- 4320 智能会话 Agent(真实消息 + 人工监管) ----
+# 默认全局暂停;监听可接收白名单消息,但暂停时不会运行或发送 Agent 回复。
+QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED=true
+QIWEI_AGENT_DEFAULT_MODE=review
+QIWEI_AGENT_AUTO_SEND_CONFIDENCE=0.88
+QIWEI_AGENT_DB_PATH=
+QIWEI_AGENT_KNOWLEDGE_DIR=./knowledge
+QIWEI_AGENT_PROPERTY_DATA_FILE=
+QIWEI_AGENT_INITIAL_SYNC_LIMIT=5000
+QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES=200
+QIWEI_AGENT_STARTUP_GRACE_SECONDS=10
+
+# Agent 模型。密钥只放 .env.local 或用户级模型配置,禁止提交到仓库。
+AGENT_PROVIDER=claude-code
+AGENT_API_KEY=
+AGENT_BASE_URL=
+AGENT_MODEL=
+AGENT_MAX_TOOL_ROUNDS=4
+
+# 推荐:直接使用客户项目中的 Claude Code + Fmode 配置生成草稿。
+CLAUDE_CODE_EXECUTABLE=
+CLAUDE_CODE_WORKDIR=.
+CLAUDE_CODE_SESSION_FILE=
+CLAUDE_CODE_TIMEOUT_MS=120000
+CLAUDE_CODE_MAX_BUDGET_USD=0.35
+CLAUDE_CODE_ALLOWED_TOOLS=Read,Glob,Grep
+
+# 可选:绑定项目主控 Claude Code Session。未配置时仍会生成项目级控制器标识,客户 Session 保持独立。
+QIWEI_AGENT_PROJECT_ID=
+QIWEI_AGENT_MAIN_SESSION_ID=

+ 7 - 0
claude-code/claude-code-qiwe-assistant/.gitignore

@@ -1,8 +1,14 @@
 node_modules/
 outputs/
+.playwright-cli/
 .env
 .env.local
 !.env.example
+.npmrc
+coverage/
+dist/
+*.tgz
+*.zip
 
 # 本地运行日志与企微测试数据
 *.log
@@ -10,3 +16,4 @@ qwmsgs.json
 qwmsgs500.json
 qwrooms-once.json
 qwsessions.json
+poll_state.json

+ 85 - 3
claude-code/claude-code-qiwe-assistant/README.md

@@ -250,7 +250,9 @@ npm run wecom:status
 npm install
 npm run check
 npm run smoke
+npm run agent:smoke
 npm run outputs:validate
+node install.js --check
 ```
 
 冒烟测试会启动本地 mock Fmode 网关,验证 Authorization、`uid/method/params` 请求信封、登录和订阅接口,不访问真实服务。
@@ -258,7 +260,7 @@ npm run outputs:validate
 
 ## Dashboard(本地 Web 界面)
 
-本项目包含一个独立的本地 Web Dashboard,用于在浏览器中管理客户群、账号状态等:
+本项目包含一个独立的本地 Web Dashboard,用于在浏览器中管理智能会话、客户群和账号状态等:
 
 ```bash
 cd claude-code/claude-code-qiwe-assistant
@@ -271,9 +273,9 @@ npm run dashboard
 http://127.0.0.1:4320/
 ```
 
-### 为什么必须在项目目录下启动
+### 为什么建议在项目目录下启动
 
-Dashboard 需要读取项目根目录的 `.env.local` 才能拿到 `QIWEI_AUTH_TOKEN`、`QIWEI_UID` 和 `QIWEI_API_BASE`。如果从错误目录启动,会显示「网络请求失败」或鉴权失败,即使浏览器能直接访问 `https://server.fmode.cn/`
+Dashboard 优先使用启动进程或 MCP 请求中注入的 `QIWEI_AUTH_TOKEN`、`QIWEI_UID` 和 `QIWEI_API_BASE`,其次读取项目根目录的 `.env.local`、Fmode/Claude Code 用户配置。进程级凭据优先于本机文件,避免 Fmode Studio 当前账号被旧的本机 token 覆盖。仍建议从项目目录启动,以便知识库、账号工作台和输出路径落在正确项目中
 
 ### 端口与状态
 
@@ -281,10 +283,71 @@ Dashboard 需要读取项目根目录的 `.env.local` 才能拿到 `QIWEI_AUTH_T
 - 健康检查:`curl http://127.0.0.1:4320/api/health`;
 - 状态汇总:`curl http://127.0.0.1:4320/api/status`。
 
+### 智能会话演示
+
+Dashboard 的「智能会话」页把真实消息监听、意图识别、需求画像、业务匹配、自动回复和人工协同放在同一页面。启停逻辑如下:
+
+- 点击「启动 AI 监听」后,AI 自动回复白名单测试联系人;
+- 点击「关闭 AI 监听」后,轮询器停止,切换为人工回复;
+- 真实发送只允许命中 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS` 白名单的联系人;
+- 可对单个客户切换「人工接管 / 恢复 Agent」;
+- 首次启动会同步并跳过历史消息,避免把历史消息当成新消息重复处理。
+
+推荐的现场顺序:
+
+1. 打开 `http://127.0.0.1:4320/#agent`,确认测试账号显示在线;
+2. 点击「启动 AI 监听」;
+3. 用唯一白名单测试联系人发送一条新消息;
+4. 查看意图、需求画像、匹配结果和建议回复;
+5. 点击「关闭 AI 监听」,演示切换为人工回复;
+6. 如需展示单客户协同,可重新启动后演示「人工接管 / 恢复 Agent」。
+
 ### 离线恢复
 
 账号离线时,Dashboard「账号状态」页会显示「恢复登录」按钮。系统也会自动尝试免扫码恢复登录;若无法自动恢复,点击按钮后会进入二维码/验证码登录流程。
 
+## Claude Code/Fmode 项目主控架构
+
+客户把技能包安装到独立项目目录后,在该目录中的 Claude Code 会话作为项目主控入口:
+
+1. 调用 `qiwei_agent_dashboard_start` 启动 4320 工作台;
+2. 完成企微登录并用 `qiwei_agent_listener` 控制监听;
+3. 用 `qiwei_agent_set_global` 设置暂停、待审核、自动或人工策略;
+4. 每个白名单客户绑定独立 Claude Code Session,通过 Fmode 模型配置生成草稿;
+5. 前端和客户 Session 的动作统一写入 Workbench,主控会话通过 `qiwei_agent_inbox` 读取事件;
+6. `qiwei_agent_generate_draft` 只生成草稿,不会发送。真实发送仍需通过 Dashboard 审核和白名单校验。
+
+客户 Session 映射按企微账号隔离保存在 `outputs/messages/claude-code-sessions-<账号哈希>.json`,包含项目 ID、项目控制器关联和每客户独立 Session。旧版单账号工作台会在首次启动时自动迁入当前账号的独立数据库。默认仅开放 `Read,Glob,Grep`,不允许客户 Agent 修改项目文件。
+
+Dashboard 会在每个客户会话标题下主动显示 Session 状态、客户可识别会话名和打开入口;主控 Claude Code 也可以调用 `qiwei_agent_session_guide` 获取同样说明。两处都不会暴露原始 Session ID。
+
+查看某个客户对应的 Claude Code 历史时,不需要查找或复制原始 Session ID:
+
+```bash
+npm run agent:session:list
+npm run agent:session -- --customer 王刚
+```
+
+第二条命令会在当前 Fmode Studio 项目终端中打开一个 fork 后的审阅会话,保留客户 Session 的完整历史、模型思考和工具记录;审阅过程中发送的新问题只进入副本,不会污染生产客户 Session。
+
+## 安装到客户项目
+
+本地源码包已经支持 workspace 安装:
+
+```bash
+node install.js workspace <客户项目目录> --smoke
+```
+
+安装器会写入:
+
+```text
+<客户项目>/.claude/plugins/qiwei-assistant
+<客户项目>/.claude/skills/<qiwei-skill>
+<客户项目>/.mcp.json
+```
+
+安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前版本已通过发布检查,尚未发布到 npm;发布状态与上传检查见 `docs/RELEASE.md`。
+
 ## 子 Skill 索引
 
 Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部包含标准流程、前置条件、错误处理和工具选择建议。
@@ -305,6 +368,10 @@ Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部
 | qiwei-capability-router | skills/qiwei-capability-router/SKILL.md | 选择 Fmode 网关还是官方 CLI 通道 | 按 Skill 内部规则路由 |
 | qiwei-official-meeting | skills/qiwei-official-meeting/SKILL.md | 官方会议能力 | qiwei_official_call |
 | qiwei-official-doc | skills/qiwei-official-doc/SKILL.md | 官方文档能力 | qiwei_official_call |
+| qiwei-official-schedule | skills/qiwei-official-schedule/SKILL.md | 官方日程与多人空闲时间协调 | qiwei_official_help、qiwei_official_call |
+| qiwei-official-todo | skills/qiwei-official-todo/SKILL.md | 官方待办创建、分配与推进 | qiwei_official_help、qiwei_official_call |
+| qiwei-goal-management | skills/qiwei-goal-management/SKILL.md | 大目标拆解、会议行动项和进度台账 | qiwei_goal_create_plan、qiwei_goal_import_meeting_actions、qiwei_goal_update_task、qiwei_goal_get |
+| qiwei-real-estate-auto-reply | skills/qiwei-real-estate-auto-reply/SKILL.md | 客户独立 Claude Code Session、待审核草稿和人工接管 | qiwei_agent_*、npm run agent:session |
 
 ## 限制
 
@@ -313,3 +380,18 @@ Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部
 - 服务端未挂载前,默认生产地址会返回不可用;可通过 `QIWEI_API_BASE` 指向测试环境。
 - 官方 CLI 首次下载需要 npm 网络访问,首次业务调用前需要独立完成企业微信机器人扫码授权。
 - 官方 CLI 通道失败不会替代或改变原有 Fmode 网关接口通道。
+
+## 房产 AI 智能会话
+
+技能包内置 30 套脱敏房源以及客户、标签和匹配规则,可直接用于演示;正式客户数据可通过 `QIWEI_AGENT_PROPERTY_DATA_FILE` 覆盖。
+
+先在 `.env.local` 配置测试联系人白名单,并推荐复用 Fmode Studio 当前项目的 Claude Code 模型能力:
+
+```text
+QIWEI_AUTO_REPLY_ALLOWED_SENDERS=<测试联系人 userId,多个用逗号分隔>
+AGENT_PROVIDER=claude-code
+```
+
+然后运行 `npm run dashboard`,在 Dashboard「智能会话」页点击「启动 AI 监听」。登录状态通过 Fmode 专用端点读取,消息同步和发送通过 Fmode 网关执行;技能包只保存 Fmode 设备绑定信息,不接触上游企业微信接口凭据,也不保留本地直连接口。
+
+白名单为空时监听器会拒绝启动。默认使用审核模式,关闭监听后自动切回人工模式;状态、客户画像、待办、预警和审计记录写入 `outputs/messages/`。

+ 7 - 0
claude-code/claude-code-qiwe-assistant/docs/OUTPUT-STANDARD.md

@@ -28,6 +28,8 @@ claude-code-qiwei-assistant/
 | `meetings/` | 官方 CLI 会议相关导出 |
 | `docs/` | 官方 CLI 文档能力导出 |
 | `messages/` | 消息发送记录与回执 |
+| `knowledge/` | 持续知识库与知识沉淀(当前注册 `meetings/`) |
+| `goals/` | 目标、里程碑与行动项状态 |
 | `smoke/` | 冒烟测试产物 |
 | `tmp/` | 临时文件,可随时清理 |
 
@@ -48,6 +50,10 @@ claude-code-qiwei-assistant/
 }
 ```
 
+3. **persistent-store 模式**(持续知识库):适用于需要跨次同步、增量更新和稳定索引的知识沉淀。
+   路径:`outputs/<类别>/<已注册库名>/`。库名必须在 `OUTPUT_PERSISTENT_STORES` 中注册;当前仅允许 `outputs/knowledge/meetings/`。
+   此模式可包含索引、按业务键组织的记录目录和说明文件,不按单次 run 生成 manifest。
+
 ### 2.2 命名规则
 
 - 目录与文件名一律小写 kebab-case;slug 由 `slugify()` 生成,最长 60 字符。
@@ -71,6 +77,7 @@ createRunDir(category, slug, date?) // run 模式目录
 writeRunManifest(runDir, manifest)  // 写 manifest.json
 slugify(value) / dateStamp() / timeStamp()
 OUTPUT_CATEGORIES                   // 允许的类别列表
+OUTPUT_PERSISTENT_STORES            // 允许的持续知识库目录
 ```
 
 新增输出类别时:先在 `OUTPUT_CATEGORIES` 注册,再更新本文件第 2 节表格。

+ 76 - 0
claude-code/claude-code-qiwe-assistant/docs/RELEASE.md

@@ -0,0 +1,76 @@
+# 发布说明
+
+## 当前状态
+
+- npm 包名:`claude-code-qiwei-assistant`
+- 当前版本:`0.4.0`
+- 状态:已通过发布检查,可上传,尚未发布到 npm
+- 运行要求:Node.js `22.5.0+`(使用内置 `node:sqlite`)
+- Dashboard:`http://127.0.0.1:4320/`
+
+## 上传前检查
+
+```powershell
+npm install
+npm run release:check
+npm pack --dry-run --json
+```
+
+`release:check` 会覆盖语法、MCP mock 网关、Agent 工作台、知识库、会议/文档/待办、目标管理、客户主档、客户旅程、输出规范、安装结构和实际 npm 包内容。
+
+## 安装方式
+
+本地源码或解压包:
+
+```powershell
+node install.js workspace <客户项目目录> --smoke
+```
+
+本地 tgz:
+
+```powershell
+npm install -g .\claude-code-qiwei-assistant-0.4.0.tgz
+qiwei-assistant workspace <客户项目目录> --smoke
+```
+
+npm 发布后:
+
+```powershell
+npx --yes claude-code-qiwei-assistant@latest workspace <客户项目目录> --smoke
+```
+
+workspace 安装会生成:
+
+```text
+<客户项目>/.claude/plugins/qiwei-assistant
+<客户项目>/.claude/skills/<skill-name>/SKILL.md
+<客户项目>/.mcp.json
+```
+
+项目级 `.mcp.json` 使用安装目录内 `mcp/src/server.js` 的绝对路径,可从任意工作目录启动。
+
+## 凭据与本地数据
+
+- 优先级:MCP 请求参数 → 当前进程/Fmode Studio 环境 → 项目 `.env.local` → 用户级 Fmode/Claude Code 配置。
+- 不把 token、设备 ID、客户 Session、数据库、日志或 Playwright 会话打入 npm 包。
+- `.env.example` 只提供空占位符;Relay 默认关闭。
+- 每个企微账号使用独立 Workbench 数据库和 Claude Code Session 映射。
+- 旧版单账号 `agent-workbench.db` 会在首次启动时迁入当前账号独立库。
+
+## 已知边界
+
+- 白名单为空时拒绝启动真实监听。
+- 高置信自动回复仍受白名单和置信度阈值约束,低置信内容进入人工审核。
+- 官方会议 CLI 是否可用取决于企业微信向当前企业开放的机器人权限;不可用时页面会明确降级,不生成 Mock 会议。
+- Fmode 登录/设备操作使用专用端点;业务消息通过 `/doApi`,不允许从通用转发口调用 `/client/*`。
+
+## 发布命令
+
+仅在版本、账号和仓库信息确认后执行:
+
+```powershell
+npm publish
+npm view claude-code-qiwei-assistant version
+```
+
+只有 `npm publish` 成功且 registry 查询验证通过后,才能将状态改为“已发布”。

+ 184 - 0
claude-code/claude-code-qiwe-assistant/install.js

@@ -0,0 +1,184 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+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']);
+
+function usage() {
+  return [
+    '企业微信 Claude Code 技能包安装器',
+    '',
+    '用法:',
+    '  node install.js --check',
+    '  node install.js workspace <客户项目目录>',
+    '  node install.js workspace <客户项目目录> --smoke',
+    '',
+    'workspace 模式会写入 .claude/plugins、.claude/skills 和项目级 .mcp.json。',
+  ].join('\n');
+}
+
+function readJson(filePath, fallback = {}) {
+  try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
+  catch { return fallback; }
+}
+
+function writeJson(filePath, value) {
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
+}
+
+function assertRequiredFiles() {
+  const required = [
+    'package.json',
+    '.claude-plugin/plugin.json',
+    'skill-package-manifest.json',
+    'mcp/src/server.js',
+    'skills/qiwei-dashboard/SKILL.md',
+    'skills/qiwei-goal-management/SKILL.md',
+    'skills/qiwei-real-estate-auto-reply/SKILL.md',
+  ];
+  for (const relative of required) {
+    if (!fs.existsSync(path.join(ROOT, relative))) throw new Error(`缺少安装文件:${relative}`);
+  }
+  const [major, minor] = process.versions.node.split('.').map(Number);
+  if (!Number.isFinite(major) || major < 22 || (major === 22 && minor < 5)) {
+    throw new Error(`需要 Node.js 22.5+(使用内置 node:sqlite),当前版本 ${process.version}`);
+  }
+}
+
+function assertInside(target, parent) {
+  const resolvedTarget = path.resolve(target);
+  const resolvedParent = path.resolve(parent);
+  const relative = path.relative(resolvedParent, resolvedTarget);
+  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
+    throw new Error(`拒绝操作不安全路径:${resolvedTarget}`);
+  }
+}
+
+function copyFilter(source) {
+  const relative = path.relative(ROOT, source);
+  if (!relative) return true;
+  const parts = relative.split(path.sep);
+  if (EXCLUDED_ROOTS.has(parts[0])) return false;
+  if (EXCLUDED_FILES.has(path.basename(source))) return false;
+  if (/\.(?:tgz|zip)$/i.test(source)) return false;
+  if (/^\.env\..+/i.test(path.basename(source)) && path.basename(source) !== '.env.example') return false;
+  return true;
+}
+
+function copyTree(source, destination, filter = () => true) {
+  if (!filter(source)) return;
+  const stat = fs.statSync(source);
+  if (stat.isDirectory()) {
+    fs.mkdirSync(destination, { recursive: true });
+    for (const entry of fs.readdirSync(source)) {
+      copyTree(path.join(source, entry), path.join(destination, entry), filter);
+    }
+    return;
+  }
+  fs.mkdirSync(path.dirname(destination), { recursive: true });
+  fs.copyFileSync(source, destination);
+}
+
+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, {
+    cwd,
+    env: { ...process.env, ...(options.env || {}) },
+    stdio: 'inherit',
+    encoding: 'utf8',
+    windowsHide: true,
+  });
+  if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} 执行失败`);
+}
+
+function installWorkspace(targetInput, options = {}) {
+  const target = path.resolve(targetInput || process.cwd());
+  fs.mkdirSync(target, { recursive: true });
+  const pluginsRoot = path.join(target, '.claude', 'plugins');
+  const skillsRoot = path.join(target, '.claude', 'skills');
+  const pluginDir = path.join(pluginsRoot, 'qiwei-assistant');
+  assertInside(pluginDir, pluginsRoot);
+  fs.rmSync(pluginDir, { recursive: true, force: true });
+  fs.mkdirSync(pluginDir, { recursive: true });
+  copyTree(ROOT, pluginDir, copyFilter);
+
+  for (const entry of fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })) {
+    if (!entry.isDirectory() || !fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) continue;
+    const destination = path.join(skillsRoot, entry.name);
+    assertInside(destination, skillsRoot);
+    fs.rmSync(destination, { recursive: true, force: true });
+    fs.mkdirSync(path.dirname(destination), { recursive: true });
+    copyTree(path.join(ROOT, 'skills', entry.name), destination);
+  }
+
+  const mcpPath = path.join(target, '.mcp.json');
+  const mcp = readJson(mcpPath, { mcpServers: {} });
+  mcp.mcpServers ||= {};
+  mcp.mcpServers['qiwei-assistant'] = {
+    command: process.execPath,
+    args: [path.join(pluginDir, 'mcp', 'src', 'server.js')],
+    cwd: pluginDir,
+  };
+  writeJson(mcpPath, mcp);
+
+  if (!options.skipInstall) run('npm', ['install', '--omit=dev', '--ignore-scripts'], pluginDir);
+  if (options.smoke) {
+    const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-install-smoke-'));
+    const smokeOutputs = path.join(smokeRoot, 'outputs');
+    const smokeEnv = {
+      QIWEI_OUTPUTS_DIR: smokeOutputs,
+      QIWEI_AGENT_DB_PATH: path.join(smokeOutputs, 'messages', 'agent-workbench.db'),
+    };
+    try {
+      run('npm', ['run', 'check'], pluginDir, { env: smokeEnv });
+      run('npm', ['run', 'agent:smoke'], pluginDir, { env: smokeEnv });
+      run('npm', ['run', 'goal:smoke'], pluginDir, { env: smokeEnv });
+    } finally {
+      fs.rmSync(smokeRoot, { recursive: true, force: true });
+    }
+  }
+
+  process.stdout.write([
+    '',
+    '企业微信技能包已安装到客户项目:',
+    `  ${target}`,
+    '',
+    '下一步:',
+    '  1. 在 Fmode Studio 中打开该项目。',
+    '  2. 启动 Claude Code,调用 qiwei_agent_dashboard_start。',
+    '  3. 在项目终端运行 npm --prefix ".claude/plugins/qiwei-assistant" run agent:session:list 查看客户 Session。',
+    '',
+  ].join('\n'));
+}
+
+function main() {
+  const args = process.argv.slice(2);
+  if (args.includes('--help') || args.includes('-h')) {
+    process.stdout.write(`${usage()}\n`);
+    return;
+  }
+  assertRequiredFiles();
+  if (args.includes('--check') || !args.length) {
+    process.stdout.write('技能包结构检查通过。\n');
+    if (!args.length) process.stdout.write(`${usage()}\n`);
+    return;
+  }
+  if (args[0] !== 'workspace') throw new Error('仅支持 workspace 安装模式');
+  const target = args[1] && !args[1].startsWith('--') ? args[1] : process.cwd();
+  installWorkspace(target, {
+    smoke: args.includes('--smoke'),
+    skipInstall: args.includes('--skip-install'),
+  });
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`安装失败:${error.message}\n`);
+  process.exit(1);
+}

+ 12 - 0
claude-code/claude-code-qiwe-assistant/knowledge-base/README.md

@@ -0,0 +1,12 @@
+# 企微统一知识库
+
+这个目录是 4320 Dashboard 的知识库目录入口。实际文件可以继续保存在各自技能包或业务项目中,通过 `catalog.json` 统一登记。
+
+当前目录映射:
+
+- `Agent 规则与知识`:`../knowledge/`
+- `房源与匹配数据`:`../../../huaxiangpipei/src/assets/data/`
+- `知识库运行输出`:`../outputs/knowledge/`
+- `技能包说明`:由 Dashboard 自动扫描源码版、OpenClaw 版和 Agent Workbench 的 `skills/` 目录
+
+知识库页面只读取允许的 Markdown、JSON、CSV、TXT 和 JavaScript 文件,不读取 `.env.local`、Token 或其他凭据文件。

+ 54 - 0
claude-code/claude-code-qiwe-assistant/knowledge-base/catalog.json

@@ -0,0 +1,54 @@
+{
+  "version": 1,
+  "libraries": [
+    {
+      "id": "agent-knowledge",
+      "label": "Agent 规则与知识",
+      "description": "回复规则、FAQ 和顾问沟通 Playbook",
+      "path": "../knowledge",
+      "extensions": [".md", ".json", ".csv", ".txt"]
+    },
+    {
+      "id": "property-data",
+      "label": "房源与匹配数据",
+      "description": "花巷匹配项目中的房源、客户、标签和匹配引擎",
+      "path": "./property-data",
+      "extensions": [".json", ".js", ".md", ".csv", ".txt"]
+    },
+    {
+      "id": "meeting-knowledge",
+      "label": "企微会议知识沉淀",
+      "description": "通过企业微信官方 CLI 同步的会议详情、AI 分析和待办候选",
+      "path": "../outputs/knowledge/meetings",
+      "extensions": [".md", ".json", ".csv", ".txt"]
+    },
+    {
+      "id": "doc-knowledge",
+      "label": "企微文档知识沉淀",
+      "description": "通过企业微信官方 CLI 创建、读取、保存并分析的真实企微文档",
+      "path": "../outputs/knowledge/docs",
+      "extensions": [".md", ".json", ".csv", ".txt"]
+    },
+    {
+      "id": "todo-knowledge",
+      "label": "企微官方待办中心",
+      "description": "通过企业微信官方 CLI 同步和管理的真实待办事项",
+      "path": "../outputs/knowledge/todos",
+      "extensions": [".md", ".json", ".csv", ".txt"]
+    },
+    {
+      "id": "task-knowledge",
+      "label": "统一任务中心",
+      "description": "聚合客户会话、企微待办、目标、文档和会议行动项的内部任务工作台",
+      "path": "../outputs/knowledge/tasks",
+      "extensions": [".md", ".json", ".csv", ".txt"]
+    },
+    {
+      "id": "knowledge-outputs",
+      "label": "知识库运行输出",
+      "description": "导入、生成和整理后的知识文件输出目录",
+      "path": "../outputs/knowledge",
+      "extensions": [".md", ".json", ".csv", ".txt"]
+    }
+  ]
+}

+ 136 - 0
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/buyer-tags.json

@@ -0,0 +1,136 @@
+{
+  "tagTree": {
+    "categories": {
+      "A_基础信息": {
+        "label": "基础信息",
+        "color": "blue",
+        "subcategories": {
+          "A1_年龄": ["A1_25以下","A1_25-30","A1_30-35","A1_35-40","A1_40-45","A1_45-50","A1_50以上"],
+          "A2_家庭结构": ["A2_单身","A2_夫妻无孩","A2_有孩0-3岁","A2_有孩3-6岁","A2_有孩6-12岁","A2_有孩12岁以上","A2_三代同堂","A2_空巢老人"],
+          "A3_职业": ["A3_公务员体制内","A3_企业白领","A3_企业管理层","A3_个体经营","A3_自由职业","A3_退休"],
+          "A4_户籍": ["A4_常州本地","A4_省内迁入","A4_省外迁入"],
+          "A5_当前居住": ["A5_自有住房","A5_租房","A5_与父母同住"],
+          "A6_车辆": ["A6_有车","A6_无车"],
+          "A7_健康": ["A7_无特殊需求","A7_需电梯","A7_需无障碍"]
+        }
+      },
+      "B_购房需求": {
+        "label": "购房需求",
+        "color": "green",
+        "subcategories": {
+          "B1_购房动机": ["B1_首套刚需","B1_婚房","B1_改善置换","B1_学区驱动","B1_投资","B1_养老","B1_分巢"],
+          "B2_预算区间": ["B2_80万以下","B2_80-120万","B2_120-150万","B2_150-200万","B2_200-300万","B2_300万以上"],
+          "B3_预算弹性": ["B3_固定无弹性","B3_可上浮10%","B3_可上浮20%","B3_可上浮30%以上"],
+          "B4_期望户型": ["B4_2室1厅","B4_2室2厅","B4_3室1厅","B4_3室2厅","B4_4室以上","B4_不限"],
+          "B5_面积偏好": ["B5_60㎡以下","B5_60-90㎡","B5_90-110㎡","B5_110-130㎡","B5_130-150㎡","B5_150㎡以上"],
+          "B6_装修要求": ["B6_毛坯可接受","B6_简装可接受","B6_必须精装","B6_必须豪装","B6_不限"],
+          "B7_楼层偏好": ["B7_必须低层","B7_偏好中层","B7_偏好高层","B7_不限","B7_必须电梯"],
+          "B8_朝向要求": ["B8_必须南北通透","B8_南向可接受","B8_不限"]
+        }
+      },
+      "C_区域配套": {
+        "label": "区域与配套",
+        "color": "yellow",
+        "subcategories": {
+          "C1_目标区域": ["C1_新北区","C1_天宁区","C1_钟楼区","C1_武进区","C1_金坛区","C1_溧阳市"],
+          "C2_区域弹性": ["C2_锁定单一小区","C2_锁定单一板块","C2_2-3个板块","C2_全城不限"],
+          "C3_通勤要求": ["C3_必须近地铁","C3_通勤30分钟内","C3_通勤1小时内","C3_不限"],
+          "C4_学区要求": ["C4_必须顶级学区","C4_有学区即可","C4_不需要学区","C4_学区是加分项"],
+          "C5_配套要求": ["C5_必须近商超","C5_必须近医院","C5_必须近公园","C5_配套齐全优先","C5_不限"]
+        }
+      },
+      "D_决策特征": {
+        "label": "决策特征",
+        "color": "purple",
+        "subcategories": {
+          "D1_决策风格": ["D1_冲动型","D1_对比型","D1_谨慎型","D1_感性型","D1_理性数据型"],
+          "D2_决策主体": ["D2_男方主导","D2_女方主导","D2_共同决策","D2_父母主导","D2_子女主导"],
+          "D3_决策周期": ["D3_1个月内","D3_1-3个月","D3_3-6个月","D3_半年以上"],
+          "D4_看房频率": ["D4_每周都看","D4_每月1-2次","D4_偶尔看看","D4_首次看房"],
+          "D5_信息渠道": ["D5_APP贝壳链家","D5_朋友推荐","D5_门店进店","D5_网络端口","D5_老客户转介绍"]
+        }
+      },
+      "E_心理价值观": {
+        "label": "心理与价值观",
+        "color": "pink",
+        "subcategories": {
+          "E1_核心驱动力": ["E1_价格优先","E1_品质优先","E1_学区至上","E1_性价比至上","E1_面子身份认同"],
+          "E2_信任建立": ["E2_需要数据说话","E2_需要情感共鸣","E2_需要权威背书","E2_需要朋友推荐"],
+          "E3_抗性触发": ["E3_怕买贵","E3_怕买错","E3_怕被骗","E3_怕麻烦","E3_怕后悔"],
+          "E4_沟通偏好": ["E4_文字为主","E4_电话为主","E4_面谈为主","E4_被动等待"],
+          "E5_生活方式": ["E5_居家型","E5_社交型","E5_运动型","E5_教育型"]
+        }
+      },
+      "F_特殊标签": {
+        "label": "特殊标签",
+        "color": "red",
+        "subcategories": {
+          "F1_紧急度": ["F1_非常紧急1月内","F1_较紧急3月内","F1_正常","F1_观望中"],
+          "F2_客户等级": ["F2_S级决策人在钱到位","F2_A级需求明确","F2_B级有意向","F2_C级随便看看"],
+          "F3_付款方式": ["F3_全款","F3_商贷","F3_公积金","F3_组合贷"],
+          "F4_特殊需求": ["F4_必须有车位","F4_必须人车分流","F4_不接受开放式厨房","F4_需无障碍设施","F4_养宠物","F4_必须三房","F4_必须明厨明卫"],
+          "F5_明确排斥": ["F5_排斥底层","F5_排斥顶楼","F5_排斥临街","F5_排斥朝北","F5_排斥无电梯","F5_排斥老小区","F5_排斥开放式厨房"]
+        }
+      }
+    }
+  },
+
+  "buyerTags": {
+    "buyer_001": {
+      "type": "婚房刚需型",
+      "tags": [
+        "A1_25-30", "A2_夫妻无孩", "A3_企业白领", "A4_常州本地", "A5_租房", "A6_有车",
+        "B1_婚房", "B2_80-120万", "B3_可上浮30%以上", "B4_3室1厅", "B5_90-110㎡", "B6_必须精装", "B7_偏好中层", "B8_南向可接受",
+        "C1_新北区", "C2_2-3个板块", "C3_通勤30分钟内", "C4_不需要学区", "C5_配套齐全优先",
+        "D1_谨慎型", "D2_共同决策", "D3_1-3个月", "D4_首次看房", "D5_朋友推荐",
+        "E1_性价比至上", "E2_需要数据说话", "E3_怕买贵", "E4_文字为主", "E5_居家型",
+        "F1_较紧急3月内", "F2_A级需求明确", "F3_商贷", "F4_必须有车位", "F5_排斥底层", "F5_排斥顶楼", "F5_排斥临街"
+      ]
+    },
+    "buyer_002": {
+      "type": "学区焦虑型",
+      "tags": [
+        "A1_30-35", "A2_有孩3-6岁", "A3_公务员体制内", "A4_常州本地", "A5_自有住房", "A6_有车",
+        "B1_学区驱动", "B2_120-150万", "B3_可上浮10%", "B4_2室2厅", "B5_60-90㎡", "B6_不限", "B7_不限", "B8_不限",
+        "C1_天宁区", "C2_锁定单一板块", "C3_不限", "C4_必须顶级学区", "C5_不限",
+        "D1_对比型", "D2_共同决策", "D3_1个月内", "D4_每周都看", "D5_APP贝壳链家",
+        "E1_学区至上", "E2_需要数据说话", "E3_怕买错", "E4_电话为主", "E5_教育型",
+        "F1_非常紧急1月内", "F2_S级决策人在钱到位", "F3_公积金", "F5_排斥无电梯"
+      ]
+    },
+    "buyer_003": {
+      "type": "置换改善型",
+      "tags": [
+        "A1_35-40", "A2_三代同堂", "A3_企业管理层", "A4_常州本地", "A5_自有住房", "A6_有车",
+        "B1_改善置换", "B2_150-200万", "B3_可上浮10%", "B4_3室2厅", "B5_110-130㎡", "B6_必须精装", "B7_必须电梯", "B8_必须南北通透",
+        "C1_钟楼区", "C2_2-3个板块", "C3_通勤1小时内", "C4_不需要学区", "C5_必须近公园",
+        "D1_谨慎型", "D2_共同决策", "D3_3-6个月", "D4_每月1-2次", "D5_门店进店",
+        "E1_品质优先", "E2_需要情感共鸣", "E3_怕买贵", "E4_面谈为主", "E5_居家型",
+        "F1_正常", "F2_A级需求明确", "F3_组合贷", "F4_必须人车分流", "F4_必须三房", "F5_排斥底层", "F5_排斥无电梯"
+      ]
+    },
+    "buyer_004": {
+      "type": "隐性需求型",
+      "tags": [
+        "A1_30-35", "A2_有孩0-3岁", "A3_自由职业", "A4_常州本地", "A5_自有住房", "A6_有车",
+        "B1_改善置换", "B2_80-120万", "B3_可上浮20%", "B4_3室2厅", "B5_90-110㎡", "B6_必须精装", "B7_偏好中层", "B8_南向可接受",
+        "C1_金坛区", "C2_锁定单一板块", "C3_不限", "C4_不需要学区", "C5_不限",
+        "D1_冲动型", "D2_女方主导", "D3_3-6个月", "D4_偶尔看看", "D5_朋友推荐",
+        "E1_品质优先", "E2_需要情感共鸣", "E3_怕买错", "E4_面谈为主", "E5_居家型",
+        "F1_正常", "F2_B级有意向", "F3_组合贷", "F5_排斥底层"
+      ]
+    },
+    "buyer_005": {
+      "type": "刚需升级型",
+      "tags": [
+        "A1_25-30", "A2_夫妻无孩", "A3_企业白领", "A4_常州本地", "A5_租房", "A6_有车",
+        "B1_首套刚需", "B2_120-150万", "B3_可上浮30%以上", "B4_3室2厅", "B5_90-110㎡", "B6_必须精装", "B7_偏好高层", "B8_必须南北通透",
+        "C1_武进区", "C2_2-3个板块", "C3_不限", "C4_不需要学区", "C5_配套齐全优先",
+        "D1_冲动型", "D2_男方主导", "D3_1-3个月", "D4_每周都看", "D5_APP贝壳链家",
+        "E1_性价比至上", "E2_需要数据说话", "E3_怕买贵", "E4_文字为主", "E5_社交型",
+        "F1_较紧急3月内", "F2_A级需求明确", "F3_商贷", "F5_排斥底层", "F5_排斥顶楼"
+      ]
+    }
+  }
+}
+

+ 133 - 0
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/buyers.json

@@ -0,0 +1,133 @@
+{
+  "clients": [
+    {
+      "id": "buyer_001",
+      "type": "婚房刚需型",
+      "name": "张先生夫妇",
+      "targetDistricts": ["新北区-薛家", "新北区-三井"],
+      "budgetMin": 80,
+      "budgetMax": 120,
+      "preferredLayouts": ["2室2厅1卫", "3室1厅1卫"],
+      "areaMin": 80,
+      "areaMax": 100,
+      "floorPreference": "中",
+      "preferredOrientations": ["南向", "南北通透"],
+      "purpose": "婚房刚需",
+      "decorationRequirement": "精装",
+      "buildingAgeMax": 15,
+      "schoolDistrictRequired": false,
+      "decisionStyle": "谨慎型",
+      "familyStructure": { "members": 2, "hasElderly": false, "hasKids": false, "planKids": true },
+      "coreConcerns": ["性价比", "未来可改造空间", "通勤便利"],
+      "resistFactors": ["底层", "顶楼", "临街"],
+      "specialRequirements": ["车位或可租车位"],
+      "surfaceBudget": 80,
+      "downPayment": 40,
+      "monthlyPaymentCapacity": 2500
+    },
+    {
+      "id": "buyer_002",
+      "type": "学区焦虑型",
+      "name": "王女士家庭",
+      "targetDistricts": ["天宁区-文化宫", "天宁区-红梅", "天宁区-兰陵"],
+      "budgetMin": 120,
+      "budgetMax": 180,
+      "preferredLayouts": ["2室1厅1卫", "2室2厅1卫", "3室1厅1卫"],
+      "areaMin": 60,
+      "areaMax": 90,
+      "floorPreference": "不限",
+      "preferredOrientations": ["不限"],
+      "purpose": "学区",
+      "decorationRequirement": "不限",
+      "buildingAgeMax": 30,
+      "schoolDistrictRequired": true,
+      "targetSchools": ["局前街小学", "博爱路小学", "解放路小学"],
+      "decisionStyle": "对比型",
+      "familyStructure": { "members": 3, "hasElderly": false, "hasKids": true, "kidAge": 5 },
+      "coreConcerns": ["学区确定性", "学位占用情况", "划片稳定性"],
+      "resistFactors": [],
+      "specialRequirements": [],
+      "surfaceBudget": 150,
+      "downPayment": 60,
+      "monthlyPaymentCapacity": 5000
+    },
+    {
+      "id": "buyer_003",
+      "type": "置换改善型",
+      "name": "李先生家庭",
+      "targetDistricts": ["钟楼区-青枫公园", "武进区-湖塘"],
+      "budgetMin": 150,
+      "budgetMax": 200,
+      "preferredLayouts": ["3室2厅2卫", "4室2厅2卫"],
+      "areaMin": 110,
+      "areaMax": 130,
+      "floorPreference": "中",
+      "preferredOrientations": ["南北通透", "南向"],
+      "purpose": "改善居住",
+      "decorationRequirement": "精装",
+      "buildingAgeMax": 15,
+      "schoolDistrictRequired": false,
+      "decisionStyle": "谨慎型",
+      "familyStructure": { "members": 5, "hasElderly": true, "hasKids": true, "kidAge": 10 },
+      "coreConcerns": ["空间够用", "电梯", "小区品质", "人车分流"],
+      "resistFactors": ["底层", "无电梯"],
+      "specialRequirements": ["电梯", "人车分流", "小区绿化好"],
+      "surfaceBudget": 180,
+      "downPayment": 100,
+      "monthlyPaymentCapacity": 6000,
+      "needSellFirst": true,
+      "oldHouseEstimatedValue": 120
+    },
+    {
+      "id": "buyer_004",
+      "type": "隐性需求型",
+      "name": "赵女士",
+      "targetDistricts": ["金坛区-金坛新城", "溧阳市-燕山新区"],
+      "budgetMin": 80,
+      "budgetMax": 120,
+      "preferredLayouts": ["2室2厅1卫"],
+      "areaMin": 80,
+      "areaMax": 110,
+      "floorPreference": "中",
+      "preferredOrientations": ["南向"],
+      "purpose": "改善居住",
+      "decorationRequirement": "精装",
+      "buildingAgeMax": 15,
+      "schoolDistrictRequired": false,
+      "decisionStyle": "冲动型",
+      "familyStructure": { "members": 3, "hasElderly": false, "hasKids": true, "kidAge": 3 },
+      "coreConcerns": ["装修好看", "小区环境", "离孩子学校近"],
+      "resistFactors": ["底层"],
+      "specialRequirements": [],
+      "surfaceBudget": 80,
+      "downPayment": 30,
+      "monthlyPaymentCapacity": 3000
+    },
+    {
+      "id": "buyer_005",
+      "type": "刚需升级型",
+      "name": "陈先生",
+      "targetDistricts": ["武进区-湖塘", "武进区-大学城"],
+      "budgetMin": 80,
+      "budgetMax": 150,
+      "preferredLayouts": ["2室2厅1卫", "3室2厅2卫"],
+      "areaMin": 85,
+      "areaMax": 120,
+      "floorPreference": "中高",
+      "preferredOrientations": ["南北通透", "南向"],
+      "purpose": "首套刚需",
+      "decorationRequirement": "精装",
+      "buildingAgeMax": 12,
+      "schoolDistrictRequired": false,
+      "decisionStyle": "冲动型",
+      "familyStructure": { "members": 2, "hasElderly": false, "hasKids": false, "planKids": true },
+      "coreConcerns": ["品质感", "户型好", "性价比"],
+      "resistFactors": ["底层", "顶楼"],
+      "specialRequirements": [],
+      "surfaceBudget": 80,
+      "downPayment": 40,
+      "monthlyPaymentCapacity": 4000
+    }
+  ]
+}
+

+ 302 - 0
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/match-engine-pg.js

@@ -0,0 +1,302 @@
+#!/usr/bin/env node
+/**
+ * 小牛看房 — 房源智能匹配引擎 v2 (PostgreSQL)
+ * 用法: node src/assets/data/match-engine-pg.js [--buyer buyer_001] [--top 5]
+ */
+
+const { Client } = require('pg');
+
+const PG_CONFIG = {
+  host: 'localhost', port: 5432,
+  user: 'postgres', password: '20061003',
+  database: 'huaxiangpipei'
+};
+
+// ============================================================
+// 数据加载(从 PostgreSQL)
+// ============================================================
+async function loadBuyers(pg) {
+  const res = await pg.query('SELECT * FROM buyers ORDER BY id');
+  if (res.rows.length === 0) {
+    // 没有buyers数据,从JSON加载默认5个客户
+    const json = require('./buyers.json');
+    return json.clients;
+  }
+  return res.rows.map(r => ({
+    id: r.buyer_code || ('buyer_' + r.id),
+    type: r.buyer_type,
+    name: r.name,
+    targetDistricts: r.target_districts || [],
+    budgetMin: parseFloat(r.budget_min) || 0,
+    budgetMax: parseFloat(r.budget_max) || 0,
+    preferredLayouts: r.preferred_layouts || [],
+    areaMin: parseFloat(r.area_min) || 0,
+    areaMax: parseFloat(r.area_max) || 0,
+    floorPreference: r.floor_preference || '中',
+    preferredOrientations: r.preferred_orientations || [],
+    purpose: r.purpose || '',
+    decorationRequirement: r.decoration_requirement || '不限',
+    buildingAgeMax: r.building_age_max || 30,
+    schoolDistrictRequired: r.school_district_required || false,
+    targetSchools: r.target_schools || [],
+    decisionStyle: r.decision_style || '对比型',
+    familyStructure: { members: r.family_members || 2, hasElderly: r.has_elderly || false, hasKids: r.has_kids || false },
+    coreConcerns: r.core_concerns || [],
+    resistFactors: r.resist_factors || [],
+    specialRequirements: r.special_requirements || [],
+    surfaceBudget: parseFloat(r.surface_budget) || 0,
+    downPayment: parseFloat(r.down_payment) || 0,
+    monthlyPaymentCapacity: parseFloat(r.monthly_payment_capacity) || 0,
+    oldHouseEstimatedValue: 0,
+    tags: r.tags || []
+  }));
+}
+
+async function loadProperties(pg) {
+  const res = await pg.query('SELECT * FROM properties ORDER BY total_price');
+  return res.rows;
+}
+
+// ============================================================
+// 权重模板(不变)
+// ============================================================
+// POI别名→名称映射
+const POI_ALIASES = {
+  '三中': '常州市第三中学',
+  '常州三中': '常州市第三中学',
+  '第三中学': '常州市第三中学',
+  '局小': '局前街小学',
+  '局前街': '局前街小学',
+  '博小': '博爱路小学',
+  '博爱路': '博爱路小学',
+  '解小': '解放路小学',
+  '文化宫': '文化宫商圈',
+  '万达': '万达商圈(新北)',
+  '吾悦': '吾悦广场(武进)',
+  '常州站': '地铁1号线常州火车站',
+  '火车站': '地铁1号线常州火车站',
+  '红梅公园': '红梅公园',
+};
+
+const WEIGHT_TEMPLATES = {
+  '婚房刚需型': { priceAdvantage:0.30, layoutMatch:0.15, areaMatch:0.10, decoration:0.10, schoolMatch:0.05, transport:0.10, community:0.05, floorMatch:0.05, orientation:0.05, surrounding:0.05, poiProximity:0.00 },
+  '学区焦虑型': { priceAdvantage:0.15, layoutMatch:0.10, areaMatch:0.05, decoration:0.05, schoolMatch:0.45, transport:0.10, community:0.05, floorMatch:0.02, orientation:0.02, surrounding:0.01, poiProximity:0.00 },
+  '置换改善型': { priceAdvantage:0.20, layoutMatch:0.20, areaMatch:0.20, decoration:0.15, schoolMatch:0.05, transport:0.05, community:0.10, floorMatch:0.03, orientation:0.02, surrounding:0.00, poiProximity:0.00 },
+  '隐性需求型': { priceAdvantage:0.20, layoutMatch:0.10, areaMatch:0.10, decoration:0.25, schoolMatch:0.05, transport:0.10, community:0.10, floorMatch:0.05, orientation:0.03, surrounding:0.02, poiProximity:0.00 },
+  '刚需升级型': { priceAdvantage:0.25, layoutMatch:0.15, areaMatch:0.15, decoration:0.20, schoolMatch:0.05, transport:0.05, community:0.05, floorMatch:0.05, orientation:0.03, surrounding:0.02, poiProximity:0.00 },
+};
+
+const HIGHLIGHT_BONUS = {
+  '豪装': { '隐性需求型':3, '刚需升级型':3 },
+  '精装': { '隐性需求型':2, '刚需升级型':2, '置换改善型':1 },
+  '满五唯一': { '婚房刚需型':2 },
+  '急售': { '婚房刚需型':2, '置换改善型':-1 },
+  '南北通透': { '婚房刚需型':1, '置换改善型':1, '刚需升级型':1, '隐性需求型':1, '学区焦虑型':1 },
+  '近地铁': { '婚房刚需型':2 },
+  '地铁': { '婚房刚需型':2 },
+  '人车分流': { '置换改善型':2 },
+  '总价低': { '婚房刚需型':2, '刚需升级型':1 },
+  '品牌开发商': { '置换改善型':1, '刚需升级型':1 },
+  '次新房': { '婚房刚需型':1, '置换改善型':1, '刚需升级型':1, '隐性需求型':1 },
+};
+
+// ============================================================
+// 匹配核心(与原版完全一致)
+// ============================================================
+function hardFilter(buyer, properties) {
+  const loanCoefficient = 180;
+  const maxLoan = buyer.monthlyPaymentCapacity * loanCoefficient / 10000;
+  const realBudgetMax = Math.max(buyer.budgetMax, (buyer.downPayment + maxLoan) * 0.9);
+
+  return properties.filter(p => {
+    const totalPrice = parseFloat(p.total_price);
+    if (totalPrice > realBudgetMax * 1.1) return false;
+    const districtMatch = buyer.targetDistricts.length === 0 || buyer.targetDistricts.some(d => {
+      const short = d.split('-')[0]; // "新北区-薛家" → "新北区"
+      return (p.district || '').includes(short) || short.includes(p.district || '');
+    });
+    if (!districtMatch) return false;
+    if (p.building_age && p.building_age > buyer.buildingAgeMax) return false;
+    // 学区:同区有学区房则严格过滤,无则放行(真实房源学区数据不完整)
+    if (buyer.schoolDistrictRequired && !p.is_school_district) {
+      const hasSchoolInDistrict = properties.some(x => x.is_school_district && x.district === p.district);
+      if (hasSchoolInDistrict) return false;
+    }
+    if (buyer.resistFactors.includes('底层') && p.is_ground_floor) return false;
+    if (buyer.resistFactors.includes('顶楼') && p.is_top_floor) return false;
+    // 人车分流降为软偏好(贝壳数据无此标注)
+    if (buyer.areaMin > 0 && parseFloat(p.area) < buyer.areaMin * 0.85) return false;
+    // POI距离硬过滤:客户说"三中附近1公里内"
+    if (buyer.nearbyPoi && buyer.nearbyPoi.name) {
+      const poiName = POI_ALIASES[buyer.nearbyPoi.name] || buyer.nearbyPoi.name;
+      const dists = p.poi_distances ? (typeof p.poi_distances === 'string' ? JSON.parse(p.poi_distances) : p.poi_distances) : {};
+      const dist = parseFloat(dists[poiName]);
+      if (dists[poiName] !== undefined && buyer.nearbyPoi.maxDistance && dist > buyer.nearbyPoi.maxDistance) return false;
+    }
+    return true;
+  });
+}
+
+function scoreProperty(buyer, p, weights) {
+  const s = {};
+  const totalPrice = parseFloat(p.total_price);
+  const area = parseFloat(p.area);
+  const tags = p.highlight_tags || [];
+
+  s.priceAdvantage = (p.price_advantage || 5) / 10;
+  s.layoutMatch = buyer.preferredLayouts.includes(p.layout) ? 1.0 :
+    buyer.preferredLayouts.some(pl => pl.split('室')[0] === (p.layout||'').split('室')[0]) ? 0.6 : 0.3;
+  s.areaMatch = area >= buyer.areaMin && area <= buyer.areaMax ? 1.0 :
+    area >= buyer.areaMin * 0.85 && area <= buyer.areaMax * 1.15 ? 0.6 : 0.2;
+  const decoMap = { '豪装':1.0, '精装':0.75, '简装':0.4, '毛坯':0.2 };
+  const reqLevel = buyer.decorationRequirement === '不限' ? 0 : (decoMap[buyer.decorationRequirement] || 0.5);
+  const actLevel = decoMap[p.decoration] || 0.5;
+  s.decoration = actLevel >= reqLevel ? 1.0 : actLevel / Math.max(reqLevel, 0.1);
+  s.schoolMatch = !buyer.schoolDistrictRequired ? 1.0 : (p.is_school_district ? 1.0 : 0.0);
+  s.transport = (p.transport_score || 5) / 10;
+  s.community = (p.community_quality || 5) / 10;
+  const floorMap = { '低':0, '中':1, '高':2 };
+  const bf = floorMap[buyer.floorPreference] ?? 1;
+  const pf = floorMap[p.floor_level] ?? 1;
+  s.floorMatch = Math.abs(bf - pf) === 0 ? 1.0 : Math.abs(bf - pf) === 1 ? 0.6 : 0.3;
+  if (buyer.preferredOrientations.includes('不限')) s.orientation = 0.8;
+  else s.orientation = buyer.preferredOrientations.some(o => (p.orientation||'').includes(o)) ? 1.0 : 0.3;
+  s.surrounding = (p.surrounding_score || 5) / 10;
+
+  // POI距离评分:客户说"三中附近"→ 越近分越高
+  if (buyer.nearbyPoi && buyer.nearbyPoi.name && buyer.nearbyPoi.maxDistance) {
+    const poiName = POI_ALIASES[buyer.nearbyPoi.name] || buyer.nearbyPoi.name;
+    const dists = p.poi_distances ? (typeof p.poi_distances === 'string' ? JSON.parse(p.poi_distances) : p.poi_distances) : {};
+    const dist = parseFloat(dists[poiName]);
+    if (!isNaN(dist) && buyer.nearbyPoi.maxDistance > 0) {
+      s.poiProximity = Math.max(0, 1 - dist / buyer.nearbyPoi.maxDistance);
+    } else {
+      s.poiProximity = 0.5;
+    }
+  } else {
+    s.poiProximity = 0.5;
+  }
+
+  let total = 0, totalW = 0;
+  for (const [k, w] of Object.entries(weights)) {
+    if (s[k] !== undefined) { total += s[k] * w; totalW += w; }
+  }
+  return totalW > 0 ? (total / totalW) * 100 : 0;
+}
+
+function calcHighlightBonus(buyerType, tags) {
+  let bonus = 0;
+  for (const tag of (tags || [])) {
+    const b = HIGHLIGHT_BONUS[tag];
+    if (b && b[buyerType]) bonus += b[buyerType];
+  }
+  return Math.min(bonus, 5);
+}
+
+function calcPsychBonus(buyer, p) {
+  let bonus = 0;
+  const tags = p.highlight_tags || [];
+  if (buyer.decisionStyle === '对比型' && (p.price_advantage || 5) >= 7) bonus += 2;
+  if (buyer.decisionStyle === '谨慎型' && !p.is_ground_floor && !p.is_top_floor) bonus += 2;
+  if ((buyer.decisionStyle === '冲动型' || buyer.type === '隐性需求型') && tags.some(t => t.includes('豪装'))) bonus += 3;
+  if (buyer.type === '置换改善型' && tags.includes('人车分流') && (p.community_quality || 5) >= 8) bonus += 1;
+  return Math.min(bonus, 3);
+}
+
+function generateCons(p) {
+  const cons = [];
+  if (p.building_age >= 20) cons.push('房龄较老,需关注管道老化和渗水情况');
+  if (p.building_age >= 25) cons.push('房龄超过25年,贷款年限可能受限');
+  if (p.decoration === '简装') cons.push('装修简单,入住前可能需翻新');
+  if (p.decoration === '毛坯') cons.push('毛坯房,需额外准备装修预算约10-15万');
+  if (p.is_ground_floor) cons.push('底层,需关注防潮和隐私问题');
+  if (p.is_top_floor) cons.push('顶楼,夏季较热,需关注防水');
+  if ((p.parking || '无') === '无') cons.push('无车位,周边停车可能不便');
+  if ((p.community_quality || 5) <= 4) cons.push('小区品质一般');
+  if ((p.transport_score || 5) <= 5) cons.push('交通便利度一般');
+  if ((p.floor_level === '低') && !p.is_ground_floor) cons.push('低楼层,采光可能受遮挡影响');
+  return cons.length > 0 ? cons : ['无明显硬伤,整体较为均衡'];
+}
+
+function match(buyer, properties, topN = 5) {
+  const weights = WEIGHT_TEMPLATES[buyer.type] || WEIGHT_TEMPLATES['婚房刚需型'];
+  const candidates = hardFilter(buyer, properties);
+
+  const scored = candidates.map(p => {
+    const s = scoreProperty(buyer, p, weights);
+    const hb = calcHighlightBonus(buyer.type, p.highlight_tags || []);
+    const pb = calcPsychBonus(buyer, p);
+    const final = s + hb + pb;
+    return { property: p, finalScore: final, stage2: s, highlightBonus: hb, psychBonus: pb,
+      level: final >= 82 ? '强烈推荐' : final >= 72 ? '推荐' : final >= 62 ? '备选' : '不推荐' };
+  });
+
+  scored.sort((a, b) => b.finalScore - a.finalScore);
+  return scored.slice(0, topN);
+}
+
+// ============================================================
+// 输出
+// ============================================================
+function printResults(buyer, results) {
+  console.log(`\n${'='.repeat(70)}`);
+  console.log(`🎯 ${buyer.name}(${buyer.type})| 预算${buyer.surfaceBudget}万 | 首付${buyer.downPayment}万`);
+  console.log(`   区域: ${buyer.targetDistricts.join('、')} | 关注: ${buyer.coreConcerns.join(' > ')}`);
+  console.log(`${'='.repeat(70)}`);
+
+  if (results.length === 0) {
+    console.log('\n⚠️ 无匹配房源');
+    return;
+  }
+
+  results.forEach((r, i) => {
+    const p = r.property;
+    const cons = generateCons(p);
+    console.log(`\n${'─'.repeat(70)}`);
+    console.log(`🏠 #${i+1} [${r.level}] ${p.community} · ${p.layout} · ${p.total_price}万`);
+    console.log(`   综合 ${r.finalScore.toFixed(1)} (基础${r.stage2.toFixed(1)}+卖点${r.highlightBonus}+心理${r.psychBonus})`);
+    console.log(`   ${p.district} | ${p.area}㎡ | ${p.floor_info||p.floor_level} | ${p.orientation} | ${p.decoration} | ${p.building_age}年`);
+    console.log(`   亮点: ${(p.highlight_tags||[]).join(' · ')}`);
+    console.log(`   ⚠️ ${cons.join(';')}`);
+    console.log(`   来源: ${p.source}`);
+  });
+
+  console.log(`\n${'─'.repeat(70)}`);
+  console.log(`📊 ${results.length}条结果\n`);
+}
+
+// ============================================================
+// 入口
+// ============================================================
+async function main() {
+  const args = process.argv.slice(2);
+  const buyerId = args.includes('--buyer') ? args[args.indexOf('--buyer')+1] : null;
+  const topN = args.includes('--top') ? parseInt(args[args.indexOf('--top')+1]) || 5 : 5;
+
+  const pg = new Client(PG_CONFIG);
+  await pg.connect();
+
+  const buyers = await loadBuyers(pg);
+  const properties = await loadProperties(pg);
+
+  console.log(`📦 数据库: ${buyers.length} 客户, ${properties.length} 房源 (${properties.filter(p=>p.source==='scraped').length}真实 + ${properties.filter(p=>p.source==='mock').length}模拟)\n`);
+
+  const targets = buyerId ? buyers.filter(b => b.id === buyerId || b.name?.includes(buyerId)) : buyers;
+  if (targets.length === 0) {
+    console.error(`❌ 未找到: ${buyerId}`);
+    console.error(`   可用: ${buyers.map(b => b.id + ':' + b.name).join(', ')}`);
+    process.exit(1);
+  }
+
+  for (const buyer of targets) {
+    const results = match(buyer, properties, topN);
+    printResults(buyer, results);
+  }
+
+  await pg.end();
+}
+
+if (require.main === module) {
+  main().catch(e => { console.error(e.message); process.exit(1); });
+}
+

+ 496 - 0
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/match-engine.js

@@ -0,0 +1,496 @@
+#!/usr/bin/env node
+/**
+ * 小牛看房 — 房源智能匹配引擎 v1.0
+ *
+ * 用法:
+ *   node match-engine.js                          # 匹配全部客户
+ *   node match-engine.js --buyer buyer_001        # 匹配单个客户
+ *   node match-engine.js --buyer buyer_001 --top 3 # 输出 Top-3
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+// ============================================================
+// 一、数据加载
+// ============================================================
+
+function loadData() {
+  const buyers = JSON.parse(
+    fs.readFileSync(path.join(__dirname, 'buyers.json'), 'utf8')
+  ).clients;
+  const properties = JSON.parse(
+    fs.readFileSync(path.join(__dirname, 'properties.json'), 'utf8')
+  ).properties;
+  return { buyers, properties };
+}
+
+// ============================================================
+// 二、五类客户权重模板(来源:经纪人访谈 + 客户画像分析)
+// ============================================================
+
+const WEIGHT_TEMPLATES = {
+  '婚房刚需型': {
+    priceAdvantage: 0.30,
+    layoutMatch: 0.15,
+    areaMatch: 0.10,
+    decoration: 0.10,
+    schoolMatch: 0.05,
+    transport: 0.10,
+    community: 0.05,
+    floorMatch: 0.05,
+    orientation: 0.05,
+    surrounding: 0.05,
+  },
+  '学区焦虑型': {
+    priceAdvantage: 0.15,
+    layoutMatch: 0.10,
+    areaMatch: 0.05,
+    decoration: 0.05,
+    schoolMatch: 0.45,
+    transport: 0.10,
+    community: 0.05,
+    floorMatch: 0.02,
+    orientation: 0.02,
+    surrounding: 0.01,
+  },
+  '置换改善型': {
+    priceAdvantage: 0.20,
+    layoutMatch: 0.20,
+    areaMatch: 0.20,
+    decoration: 0.15,
+    schoolMatch: 0.05,
+    transport: 0.05,
+    community: 0.10,
+    floorMatch: 0.03,
+    orientation: 0.02,
+    surrounding: 0.00,
+  },
+  '隐性需求型': {
+    priceAdvantage: 0.20,
+    layoutMatch: 0.10,
+    areaMatch: 0.10,
+    decoration: 0.25,
+    schoolMatch: 0.05,
+    transport: 0.10,
+    community: 0.10,
+    floorMatch: 0.05,
+    orientation: 0.03,
+    surrounding: 0.02,
+  },
+  '刚需升级型': {
+    priceAdvantage: 0.25,
+    layoutMatch: 0.15,
+    areaMatch: 0.15,
+    decoration: 0.20,
+    schoolMatch: 0.05,
+    transport: 0.05,
+    community: 0.05,
+    floorMatch: 0.05,
+    orientation: 0.03,
+    surrounding: 0.02,
+  },
+};
+
+// ============================================================
+// 三、卖点标签 → 客户类型加分映射(来源:经纪人访谈提炼)
+// ============================================================
+
+const HIGHLIGHT_BONUS = {
+  '豪装': { '隐性需求型': 3, '刚需升级型': 3 },
+  '精装': { '隐性需求型': 2, '刚需升级型': 2, '置换改善型': 1 },
+  '满五唯一': { '婚房刚需型': 2 },
+  '业主急售': { '婚房刚需型': 2, '置换改善型': -1 },
+  '急售': { '婚房刚需型': 2, '置换改善型': -1 },
+  '南北通透': { '婚房刚需型': 1, '置换改善型': 1, '刚需升级型': 1, '隐性需求型': 1, '学区焦虑型': 1 },
+  '明厨明卫': { '置换改善型': 2 },
+  '近地铁': { '婚房刚需型': 2 },
+  '地铁房': { '婚房刚需型': 2 },
+  '人车分流': { '置换改善型': 2 },
+  '近公园': { '置换改善型': 1, '隐性需求型': 1 },
+  '次新房': { '婚房刚需型': 1, '置换改善型': 1, '刚需升级型': 1 },
+  '有车位': { '置换改善型': 1 },
+  '总价低': { '婚房刚需型': 2, '刚需升级型': 1 },
+  '品牌开发商': { '置换改善型': 1, '刚需升级型': 1 },
+  '婚装': { '隐性需求型': 2 },
+};
+
+// ============================================================
+// 四、客户心理 → 房源加分(来源:七步法 + 访谈)
+// ============================================================
+
+function calcPsychologyBonus(buyer, property) {
+  let bonus = 0;
+  const style = buyer.decisionStyle;
+  const type = buyer.type;
+
+  // 对比型 — 性价比突出
+  if (style === '对比型' && property.priceAdvantage >= 7) bonus += 2;
+
+  // 谨慎型 — 无硬伤、产权清晰
+  if (style === '谨慎型' && property.isFiveYearOnly && !property.isGroundFloor && !property.isTopFloor) {
+    bonus += 2;
+  }
+
+  // 感性/冲动型(隐性需求、刚需升级)— 装修亮眼
+  if ((style === '冲动型' || type === '隐性需求型') && ['豪装'].some(t => property.highlightTags.includes(t))) {
+    bonus += 3;
+  }
+  if ((style === '冲动型' || type === '刚需升级型') && ['豪装', '婚装'].some(t => property.highlightTags.includes(t))) {
+    bonus += 2;
+  }
+
+  // 置换改善 — 人车分流+绿化好
+  if (type === '置换改善型' && property.highlightTags.includes('人车分流') && property.communityQuality >= 8) {
+    bonus += 1;
+  }
+
+  return Math.min(bonus, 3);
+}
+
+// ============================================================
+// 五、阶段 1:硬约束过滤
+// ============================================================
+
+function hardFilter(buyer, properties) {
+  // 反推真实预算上限
+  const loanCoefficient = 180; // 简化:30年贷款系数
+  const maxLoan = buyer.monthlyPaymentCapacity * loanCoefficient / 10000;
+  const realBudgetMax = Math.max(
+    buyer.budgetMax,
+    (buyer.downPayment + maxLoan) * 0.9
+  );
+
+  return properties.filter(p => {
+    // F1: 预算上限(真实上限×1.1)
+    if (p.totalPrice > realBudgetMax * 1.1) return false;
+
+    // F2: 区域不匹配
+    const districtMatch = buyer.targetDistricts.some(d => p.district.startsWith(d));
+    if (!districtMatch) return false;
+
+    // F3: 房龄超标
+    if (p.buildingAge > buyer.buildingAgeMax) return false;
+
+    // F4: 必须学区
+    if (buyer.schoolDistrictRequired && !p.isSchoolDistrict) return false;
+    if (buyer.schoolDistrictRequired && buyer.targetSchools && !buyer.targetSchools.includes(p.schoolName)) return false;
+
+    // F5: 排斥项
+    if (buyer.resistFactors.includes('底层') && p.isGroundFloor) return false;
+    if (buyer.resistFactors.includes('顶楼') && p.isTopFloor) return false;
+    if (buyer.resistFactors.includes('临街') && (p.highlightTags.includes('临街') || p.communityQuality <= 3)) return false;
+
+    // F6: 特殊需求
+    if (buyer.specialRequirements.includes('车位或可租车位') && p.parking === '无') return false;
+    if (buyer.specialRequirements.includes('电梯') && !p.highlightTags.includes('电梯') && p.floorLevel !== '低') {
+      // 简单判断:如果是低楼层且没有电梯标签,可能无电梯
+    }
+    if (buyer.specialRequirements.includes('人车分流') && !p.highlightTags.includes('人车分流')) return false;
+
+    // F7: 面积硬下限
+    if (p.area < buyer.areaMin * 0.85) return false;
+
+    return true;
+  });
+}
+
+// ============================================================
+// 六、阶段 2:加权评分
+// ============================================================
+
+function scoreDimension(buyer, property, weights) {
+  const scores = {};
+
+  // D1: 价格优势
+  scores.priceAdvantage = property.priceAdvantage / 10;
+
+  // D2: 户型匹配
+  const layoutExact = buyer.preferredLayouts.includes(property.layout);
+  const layoutPartial = buyer.preferredLayouts.some(pl => {
+    const pMatch = pl.match(/(\d+)室(\d+)厅/);
+    const propMatch = property.layout.match(/(\d+)室(\d+)厅/);
+    if (pMatch && propMatch) {
+      const pRooms = parseInt(pMatch[1]);
+      const propRooms = parseInt(propMatch[1]);
+      return pRooms === propRooms; // 室数相同算部分匹配
+    }
+    return false;
+  });
+  scores.layoutMatch = layoutExact ? 1.0 : layoutPartial ? 0.6 : 0.3;
+
+  // D3: 面积匹配
+  if (property.area >= buyer.areaMin && property.area <= buyer.areaMax) {
+    scores.areaMatch = 1.0;
+  } else if (property.area >= buyer.areaMin * 0.85 && property.area <= buyer.areaMax * 1.15) {
+    const center = (buyer.areaMin + buyer.areaMax) / 2;
+    const deviation = Math.abs(property.area - center) / center;
+    scores.areaMatch = Math.max(0.3, 1 - deviation * 2);
+  } else {
+    scores.areaMatch = 0.2;
+  }
+
+  // D4: 装修品质
+  const decorationMap = { '豪装': 1.0, '精装': 0.75, '简装': 0.4, '毛坯': 0.2 };
+  const requiredDecoMap = { '豪装': 1.0, '精装': 0.75, '简装': 0.4, '不限': 0.0 };
+  const requiredLevel = requiredDecoMap[buyer.decorationRequirement] || 0.5;
+  const actualLevel = decorationMap[property.decoration] || 0.5;
+  scores.decoration = actualLevel >= requiredLevel ? 1.0 : actualLevel / Math.max(requiredLevel, 0.1);
+
+  // D5: 学区匹配
+  if (!buyer.schoolDistrictRequired) {
+    scores.schoolMatch = 1.0; // 不在乎学区,不影响评分
+  } else {
+    if (buyer.targetSchools && buyer.targetSchools.includes(property.schoolName)) {
+      scores.schoolMatch = 1.0;
+    } else if (property.isSchoolDistrict) {
+      scores.schoolMatch = 0.5;
+    } else {
+      scores.schoolMatch = 0.0;
+    }
+  }
+
+  // D6: 交通便利
+  scores.transport = property.transportScore / 10;
+
+  // D7: 小区品质
+  scores.community = property.communityQuality / 10;
+
+  // D8: 楼层匹配
+  const floorMap = { '低': 0, '中': 1, '高': 2 };
+  const buyerFloor = floorMap[buyer.floorPreference] !== undefined ? floorMap[buyer.floorPreference] : 1;
+  const propFloor = floorMap[property.floorLevel] !== undefined ? floorMap[property.floorLevel] : 1;
+  const floorDiff = Math.abs(buyerFloor - propFloor);
+  scores.floorMatch = floorDiff === 0 ? 1.0 : floorDiff === 1 ? 0.6 : 0.3;
+
+  // D9: 朝向匹配
+  if (buyer.preferredOrientations.includes('不限')) {
+    scores.orientation = 0.8;
+  } else {
+    const orientExact = buyer.preferredOrientations.some(o => property.orientation.includes(o));
+    const orientPartial = buyer.preferredOrientations.some(o => property.orientation.includes(o.replace('南北通透', '南').replace('南向', '南')));
+    scores.orientation = orientExact ? 1.0 : orientPartial ? 0.5 : 0.2;
+  }
+
+  // D10: 周边配套
+  scores.surrounding = property.surroundingScore / 10;
+
+  // 加权汇总
+  let totalScore = 0;
+  let totalWeight = 0;
+  for (const [key, weight] of Object.entries(weights)) {
+    if (scores[key] !== undefined) {
+      totalScore += scores[key] * weight;
+      totalWeight += weight;
+    }
+  }
+
+  return {
+    totalScore: totalWeight > 0 ? (totalScore / totalWeight) * 100 : 0,
+    breakdown: scores,
+  };
+}
+
+// ============================================================
+// 七、阶段 3:智能择优(卖点 + 心理加分)
+// ============================================================
+
+function calcHighlightBonus(buyerType, property) {
+  let bonus = 0;
+  for (const tag of property.highlightTags) {
+    const tagBonus = HIGHLIGHT_BONUS[tag];
+    if (tagBonus && tagBonus[buyerType]) {
+      bonus += tagBonus[buyerType];
+    }
+  }
+  return Math.min(bonus, 5);
+}
+
+// ============================================================
+// 八、主匹配函数
+// ============================================================
+
+function matchBuyer(buyer, properties, topN = 5) {
+  const weights = WEIGHT_TEMPLATES[buyer.type] || WEIGHT_TEMPLATES['婚房刚需型'];
+
+  // 阶段 1: 硬约束过滤
+  const candidates = hardFilter(buyer, properties);
+
+  // 阶段 2: 加权评分
+  const scored = candidates.map(p => {
+    const { totalScore, breakdown } = scoreDimension(buyer, p, weights);
+    return { property: p, stage2Score: totalScore, breakdown };
+  });
+
+  // 阶段 3: 智能择优
+  const final = scored.map(s => {
+    const highlightBonus = calcHighlightBonus(buyer.type, s.property);
+    const psychBonus = calcPsychologyBonus(buyer, s.property);
+    const finalScore = s.stage2Score + highlightBonus + psychBonus;
+    return {
+      ...s,
+      highlightBonus,
+      psychBonus,
+      finalScore,
+      level: finalScore >= 90 ? '强烈推荐' : finalScore >= 80 ? '推荐' : finalScore >= 70 ? '备选' : '不推荐',
+    };
+  });
+
+  // 排序
+  final.sort((a, b) => b.finalScore - a.finalScore);
+  return final.slice(0, topN);
+}
+
+// ============================================================
+// 九、沟通策略生成
+// ============================================================
+
+const STRATEGY_TEMPLATES = {
+  '婚房刚需型': {
+    style: '财务顾问+生活规划师',
+    keyScript: (p, buyer) => `这套${p.layout}总价${p.totalPrice}万,首付约${Math.round(p.totalPrice*0.2)}万,月供大概${Math.round(p.totalPrice*0.8*0.0045*10000)}元。两居的客厅够大,以后改成三居也完全没问题。`,
+    precautions: ['帮客户算清"多花10万首付=未来5年不换房"这笔账', '关注女方感受,感性决策占比大'],
+    followUp: '首次推荐后2天发对比分析,1周内约带看',
+  },
+  '学区焦虑型': {
+    style: '政策专家+数据提供者',
+    keyScript: (p, buyer) => `这套对口${p.schoolName},近3年划片都没有变动,入学年限满足要求,学位也未被占用。这是去年该小区划片文件和升学率数据。`,
+    precautions: ['必须准备书面证据:划片文件、学位占用情况', '客户严谨较真,不要口头承诺'],
+    followUp: '首次推荐后立即发学区资料,3天内约实地看房',
+  },
+  '置换改善型': {
+    style: '全流程管家',
+    keyScript: (p, buyer) => `这套${p.layout}目前在售。您的老房子预计能卖${buyer.oldHouseEstimatedValue || 120}万,这套${p.totalPrice}万,贷款${p.totalPrice - (buyer.oldHouseEstimatedValue || 120)}万左右。如果两边节奏配合好,可以实现无缝衔接。`,
+    precautions: ['提供"卖+买"一体化时间线方案', '决策链长,尽量约全家人一起看'],
+    followUp: '了解老房子挂牌进展,同步推送新上房源',
+  },
+  '隐性需求型': {
+    style: '需求翻译官+引导者',
+    keyScript: (p, buyer) => `不用急着定,我们先多看几套对比一下。这套装修是亮点,您看这个阳台,以后周末在这里喝喝茶,感觉很舒服的。`,
+    precautions: ['同一天带看2套差异大的房源做对比', '看房后引导客户说出喜欢/不喜欢哪里'],
+    followUp: '每次带看后现场复盘感受,逐步收敛需求',
+  },
+  '刚需升级型': {
+    style: '品质推手+性价比计算器',
+    keyScript: (p, buyer) => `这套虽然比您预算多了点,但多一个独立书房和主卧套间。多花10万首付,月供只多500块,未来5年不用换房。`,
+    precautions: ['先推达标房源再推品质房源,形成对比', '让客户看到"价值差"而不是"价格差"'],
+    followUp: '先推一套80万,再推120万,让客户自己感受差异',
+  },
+};
+
+function generateStrategy(buyer, property) {
+  const template = STRATEGY_TEMPLATES[buyer.type] || STRATEGY_TEMPLATES['婚房刚需型'];
+  return {
+    style: template.style,
+    openingScript: template.keyScript(property, buyer),
+    precautions: template.precautions,
+    followUp: template.followUp,
+  };
+}
+
+// ============================================================
+// 十、格式化输出
+// ============================================================
+
+// 生成坦诚缺点(来自蓝领岗位匹配的启发:坦诚 > 隐瞒)
+function generateHonestCons(property) {
+  const cons = [];
+  if (property.buildingAge >= 20) cons.push('房龄较老,需关注管道老化和渗水情况');
+  if (property.buildingAge >= 25) cons.push('房龄超过25年,贷款年限可能受限');
+  if (property.decoration === '简装') cons.push('装修简单,入住前可能需翻新');
+  if (property.decoration === '毛坯') cons.push('毛坯房,需额外准备装修预算约10-15万');
+  if (property.isGroundFloor) cons.push('底层,需关注防潮和隐私问题');
+  if (property.isTopFloor) cons.push('顶楼,夏季较热,需关注防水');
+  if (property.parking === '无') cons.push('无车位,周边停车可能不便');
+  if (property.communityQuality <= 4) cons.push('小区品质一般,绿化物业配套有限');
+  if (property.transportScore <= 5) cons.push('交通便利度一般,公交/地铁覆盖较少');
+  if (property.surroundingScore <= 5) cons.push('周边商业配套有限,生活便利度较低');
+  if (!property.isFiveYearOnly && property.buildingAge < 5) cons.push('不满五,交易税费较高');
+  if (property.floorLevel === '低' && !property.isGroundFloor) cons.push('低楼层,采光可能受遮挡影响');
+  if (property.floorLevel === '高' && !property.isTopFloor && property.communityQuality <= 5) cons.push('高楼层,需确认电梯运行状况');
+  if (!property.isSchoolDistrict && property.district.includes('天宁区-文化宫')) cons.push('非学区房,天宁区核心学区客户需注意');
+  if (property.priceDropSpace <= 5) cons.push('业主议价空间较小,谈价弹性有限');
+  return cons.length > 0 ? cons : ['无明显硬伤,整体较为均衡'];
+}
+
+function printResults(buyer, results) {
+  console.log(`\n${'='.repeat(70)}`);
+  console.log(`🎯 客户:${buyer.name}(${buyer.type})`);
+  console.log(`   口述预算:${buyer.surfaceBudget}万 | 首付:${buyer.downPayment}万 | 月供承受:${buyer.monthlyPaymentCapacity}元`);
+  console.log(`   目标区域:${buyer.targetDistricts.join('、')}`);
+  console.log(`   核心关注:${buyer.coreConcerns.join(' > ')}`);
+  console.log(`${'='.repeat(70)}`);
+
+  if (results.length === 0) {
+    console.log(`\n⚠️  无匹配房源。建议:`);
+    if (buyer.budgetMax < 100) console.log(`   → 预算偏低,考虑提高预算或扩大区域`);
+    if (buyer.schoolDistrictRequired) console.log(`   → 学区要求严格,建议扩大目标学校范围`);
+    return;
+  }
+
+  results.forEach((r, i) => {
+    const p = r.property;
+    const s = generateStrategy(buyer, p);
+    console.log(`\n${'─'.repeat(70)}`);
+    console.log(`🏠 #${i + 1}  [${r.level}]  ${p.community} · ${p.layout} · ${p.totalPrice}万`);
+    console.log(`   综合分:${r.finalScore.toFixed(1)}  (基础${r.stage2Score.toFixed(1)} + 卖点${r.highlightBonus} + 心理${r.psychBonus})`);
+    console.log(`   区域:${p.district} | 面积:${p.area}㎡ | 楼层:${p.floor} | 朝向:${p.orientation}`);
+    console.log(`   装修:${p.decoration} | 房龄:${p.buildingAge}年 | 学区:${p.isSchoolDistrict ? p.schoolName : '无'}`);
+    console.log(`   亮点:${p.highlightTags.join(' · ')}`);
+    console.log(`   业主:${p.ownerSituation} | 议价空间:${p.priceDropSpace}万`);
+
+    // 坦诚缺点(来自蓝领匹配启发)
+    const cons = generateHonestCons(p);
+    console.log(`   ⚠️  坦诚提醒:${cons.join(';')}`);
+
+    // 评分分解
+    const bd = r.breakdown;
+    console.log(`   📊 维度分解:价格${(bd.priceAdvantage*100).toFixed(0)} | 户型${(bd.layoutMatch*100).toFixed(0)} | 面积${(bd.areaMatch*100).toFixed(0)} | 装修${(bd.decoration*100).toFixed(0)} | 交通${(bd.transport*100).toFixed(0)} | 品质${(bd.community*100).toFixed(0)} | 楼层${(bd.floorMatch*100).toFixed(0)} | 朝向${(bd.orientation*100).toFixed(0)}`);
+
+    console.log(`\n   💬 沟通策略(${s.style}):`);
+    console.log(`   "${s.openingScript}"`);
+    console.log(`   ⚠️  注意事项:${s.precautions.join(';')}`);
+    console.log(`   📅 跟进节奏:${s.followUp}`);
+  });
+
+  console.log(`\n${'─'.repeat(70)}`);
+  console.log(`📊 筛选统计:全量${30}套 → 硬约束过滤后${results.length > 0 ? '匹配' : '0'}套 → Top-${results.length}`);
+  console.log(`${'='.repeat(70)}\n`);
+}
+
+// ============================================================
+// 十一、入口
+// ============================================================
+
+function main() {
+  const { buyers, properties } = loadData();
+
+  // 命令行参数
+  const args = process.argv.slice(2);
+  const buyerId = args.includes('--buyer') ? args[args.indexOf('--buyer') + 1] : null;
+  const topIdx = args.includes('--top') ? args.indexOf('--top') : -1;
+  const topN = topIdx >= 0 ? parseInt(args[topIdx + 1]) || 5 : 5;
+
+  const targets = buyerId
+    ? buyers.filter(b => b.id === buyerId)
+    : buyers;
+
+  if (targets.length === 0) {
+    console.error(`❌ 未找到客户 ${buyerId}`);
+    console.error(`   可用客户ID:${buyers.map(b => b.id).join(', ')}`);
+    process.exit(1);
+  }
+
+  targets.forEach(buyer => {
+    const results = matchBuyer(buyer, properties, topN);
+    printResults(buyer, results);
+  });
+}
+
+if (require.main === module) {
+  main();
+}
+
+// 导出供外部调用
+module.exports = { matchBuyer, generateStrategy, hardFilter, WEIGHT_TEMPLATES, STRATEGY_TEMPLATES };
+

+ 35 - 0
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/properties.json

@@ -0,0 +1,35 @@
+{
+  "properties": [
+    {"id":"P001","community":"凤凰湖花园","district":"新北区-薛家","totalPrice":95,"unitPrice":10500,"layout":"2室2厅1卫","area":90,"floor":"8/18","orientation":"南向","decoration":"精装","buildingAge":8,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近地铁1号线薛家站","ownerSituation":"自住,想换大户型","priceDropSpace":8,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","精装","近地铁"],"communityQuality":7,"transportScore":8,"surroundingScore":6,"priceAdvantage":8,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P002","community":"顺园新村","district":"新北区-薛家","totalPrice":85,"unitPrice":9800,"layout":"2室1厅1卫","area":87,"floor":"5/6","orientation":"南向","decoration":"简装","buildingAge":12,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"近薛家中心小学","ownerSituation":"已搬走,空置中","priceDropSpace":10,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","总价低"],"communityQuality":5,"transportScore":6,"surroundingScore":6,"priceAdvantage":9,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P003","community":"中央花园","district":"新北区-三井","totalPrice":115,"unitPrice":12500,"layout":"3室2厅1卫","area":92,"floor":"12/25","orientation":"南北通透","decoration":"精装","buildingAge":6,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"万达商圈,配套成熟","ownerSituation":"置换已签新房合同,急售","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["南北通透","急售","近商圈","精装"],"communityQuality":8,"transportScore":9,"surroundingScore":9,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P004","community":"凤凰湖花园","district":"新北区-薛家","totalPrice":108,"unitPrice":11200,"layout":"3室2厅1卫","area":96,"floor":"15/18","orientation":"南向","decoration":"豪装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近地铁,通勤方便","ownerSituation":"自住,诚意出售","priceDropSpace":8,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","近地铁","次新房"],"communityQuality":7,"transportScore":8,"surroundingScore":7,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P005","community":"西狮子巷","district":"天宁区-文化宫","totalPrice":155,"unitPrice":23000,"layout":"2室1厅1卫","area":67,"floor":"3/6","orientation":"南向","decoration":"简装","buildingAge":25,"isSchoolDistrict":true,"schoolName":"局前街小学","parking":"无","surrounding":"文化宫核心区,生活便利","ownerSituation":"出租中,投资套现","priceDropSpace":15,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["局小本部学区","满五唯一","总价学区入门"],"communityQuality":4,"transportScore":8,"surroundingScore":9,"priceAdvantage":5,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P006","community":"桃园新村","district":"天宁区-文化宫","totalPrice":140,"unitPrice":21000,"layout":"2室1厅1卫","area":66,"floor":"2/5","orientation":"南向","decoration":"简装","buildingAge":28,"isSchoolDistrict":true,"schoolName":"局前街小学","parking":"无","surrounding":"近红梅公园","ownerSituation":"老人自住,换电梯房","priceDropSpace":10,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["局小本部学区","满五唯一","近公园"],"communityQuality":3,"transportScore":7,"surroundingScore":8,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P007","community":"博爱路小区","district":"天宁区-红梅","totalPrice":138,"unitPrice":18000,"layout":"2室2厅1卫","area":77,"floor":"4/7","orientation":"南北通透","decoration":"精装","buildingAge":22,"isSchoolDistrict":true,"schoolName":"博爱路小学","parking":"地面停车","surrounding":"近博爱路,生活配套成熟","ownerSituation":"自住,孩子已毕业","priceDropSpace":12,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["博小学区","南北通透","精装"],"communityQuality":5,"transportScore":7,"surroundingScore":7,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P008","community":"西狮子巷","district":"天宁区-文化宫","totalPrice":170,"unitPrice":24000,"layout":"2室2厅1卫","area":71,"floor":"4/6","orientation":"南向","decoration":"精装","buildingAge":20,"isSchoolDistrict":true,"schoolName":"局前街小学","parking":"无","surrounding":"文化宫核心","ownerSituation":"自住,孩子已入学","priceDropSpace":8,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["局小本部学区","精装","学位未占用"],"communityQuality":4,"transportScore":8,"surroundingScore":9,"priceAdvantage":4,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P009","community":"兰陵家园","district":"天宁区-兰陵","totalPrice":128,"unitPrice":15500,"layout":"2室2厅1卫","area":82,"floor":"8/18","orientation":"南向","decoration":"精装","buildingAge":10,"isSchoolDistrict":true,"schoolName":"解放路小学","parking":"有地下车位","surrounding":"近兰陵商圈","ownerSituation":"自住,换房改善","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["解小学区","电梯房","有车位"],"communityQuality":6,"transportScore":7,"surroundingScore":7,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P010","community":"青枫公馆","district":"钟楼区-青枫公园","totalPrice":168,"unitPrice":13500,"layout":"3室2厅2卫","area":124,"floor":"10/20","orientation":"南北通透","decoration":"精装","buildingAge":7,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近青枫公园,环境好","ownerSituation":"自住,卖一买一","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["南北通透","近公园","人车分流","电梯"],"communityQuality":9,"transportScore":7,"surroundingScore":8,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P011","community":"景瑞曦城","district":"钟楼区-青枫公园","totalPrice":180,"unitPrice":14200,"layout":"3室2厅2卫","area":127,"floor":"15/22","orientation":"南北通透","decoration":"豪装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"紧邻青枫公园","ownerSituation":"自住,诚心出售","priceDropSpace":12,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","南北通透","次新房","近公园"],"communityQuality":8,"transportScore":7,"surroundingScore":8,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P012","community":"青枫公馆","district":"钟楼区-青枫公园","totalPrice":155,"unitPrice":12800,"layout":"3室2厅1卫","area":121,"floor":"6/20","orientation":"南向","decoration":"精装","buildingAge":8,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"青枫公园板块,配套成熟","ownerSituation":"已置换,急售","priceDropSpace":20,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["急售","满五唯一","三居性价比"],"communityQuality":9,"transportScore":7,"surroundingScore":8,"priceAdvantage":9,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P013","community":"吾悦广场公寓","district":"武进区-湖塘","totalPrice":145,"unitPrice":13000,"layout":"3室2厅2卫","area":112,"floor":"9/30","orientation":"南北通透","decoration":"精装","buildingAge":3,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"吾悦广场旁,商圈核心","ownerSituation":"投资客,套现","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["次新房","近商圈","南北通透"],"communityQuality":8,"transportScore":8,"surroundingScore":9,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P014","community":"星河国际","district":"武进区-湖塘","totalPrice":130,"unitPrice":12000,"layout":"3室2厅2卫","area":108,"floor":"11/28","orientation":"南向","decoration":"精装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"湖塘核心区","ownerSituation":"自住,换大户型","priceDropSpace":12,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","精装","品牌开发商"],"communityQuality":9,"transportScore":8,"surroundingScore":8,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P015","community":"新城域","district":"武进区-大学城","totalPrice":95,"unitPrice":10000,"layout":"2室2厅1卫","area":95,"floor":"7/18","orientation":"南向","decoration":"精装","buildingAge":8,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"大学城板块","ownerSituation":"自住,首次出售","priceDropSpace":8,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","总价低","近大学城"],"communityQuality":6,"transportScore":7,"surroundingScore":7,"priceAdvantage":8,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P016","community":"绿地香颂","district":"武进区-湖塘","totalPrice":155,"unitPrice":12800,"layout":"3室2厅2卫","area":121,"floor":"13/25","orientation":"南北通透","decoration":"豪装","buildingAge":4,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"湖塘核心,近万达","ownerSituation":"自住,诚心出售","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["豪装","南北通透","次新房"],"communityQuality":8,"transportScore":8,"surroundingScore":8,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P017","community":"星河国际","district":"武进区-大学城","totalPrice":140,"unitPrice":12200,"layout":"3室2厅2卫","area":115,"floor":"16/28","orientation":"南向","decoration":"精装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"大学城+星河商圈","ownerSituation":"自住,换城市","priceDropSpace":12,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["精装","品牌开发商","近商圈"],"communityQuality":8,"transportScore":7,"surroundingScore":8,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P018","community":"金谷华城","district":"金坛区-金坛新城","totalPrice":75,"unitPrice":9000,"layout":"2室2厅1卫","area":83,"floor":"5/11","orientation":"南向","decoration":"精装","buildingAge":10,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"金坛新城核心","ownerSituation":"自住,换房","priceDropSpace":8,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","总价低","配套成熟"],"communityQuality":6,"transportScore":6,"surroundingScore":7,"priceAdvantage":8,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P019","community":"中景华庭","district":"金坛区-金坛新城","totalPrice":105,"unitPrice":10500,"layout":"3室2厅2卫","area":100,"floor":"8/18","orientation":"南北通透","decoration":"精装","buildingAge":6,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"金坛新城","ownerSituation":"自住,诚意出售","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["南北通透","精装","次新房"],"communityQuality":7,"transportScore":6,"surroundingScore":6,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P020","community":"金谷华城","district":"金坛区-金坛新城","totalPrice":65,"unitPrice":8500,"layout":"2室1厅1卫","area":76,"floor":"3/6","orientation":"南向","decoration":"简装","buildingAge":14,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"老城区,配套成��","ownerSituation":"空置,急售","priceDropSpace":10,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["急售","满五唯一","单价低"],"communityQuality":4,"transportScore":5,"surroundingScore":6,"priceAdvantage":9,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P021","community":"燕山公馆","district":"溧阳市-燕山新区","totalPrice":110,"unitPrice":10500,"layout":"3室2厅1卫","area":105,"floor":"9/18","orientation":"南向","decoration":"精装","buildingAge":4,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"燕山新区,新城区","ownerSituation":"自住,换别墅","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["精装","次新房","新城区"],"communityQuality":8,"transportScore":6,"surroundingScore":7,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P022","community":"燕山一号","district":"溧阳市-燕山新区","totalPrice":95,"unitPrice":9800,"layout":"2室2厅1卫","area":97,"floor":"12/20","orientation":"南北通透","decoration":"精装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"燕山新区核心","ownerSituation":"自住,诚售","priceDropSpace":8,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["南北通透","精装","近商圈"],"communityQuality":7,"transportScore":6,"surroundingScore":7,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P023","community":"中央花园","district":"新北区-三井","totalPrice":130,"unitPrice":13000,"layout":"3室2厅2卫","area":100,"floor":"20/25","orientation":"南北通透","decoration":"豪装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"万达商圈","ownerSituation":"自住,换大房","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","南北通透","近商圈"],"communityQuality":8,"transportScore":9,"surroundingScore":9,"priceAdvantage":5,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P024","community":"桃园新村","district":"天宁区-文化宫","totalPrice":165,"unitPrice":22000,"layout":"3室1厅1卫","area":75,"floor":"3/7","orientation":"南向","decoration":"精装","buildingAge":20,"isSchoolDistrict":true,"schoolName":"局前街小学","parking":"无","surrounding":"文化宫核心","ownerSituation":"自住,诚意出售","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"低","highlightTags":["局小本部学区","精装","三居学区"],"communityQuality":4,"transportScore":8,"surroundingScore":9,"priceAdvantage":5,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P025","community":"青枫公馆","district":"钟楼区-青枫公园","totalPrice":195,"unitPrice":14500,"layout":"4室2厅2卫","area":134,"floor":"18/20","orientation":"南北通透","decoration":"豪装","buildingAge":6,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近公园","ownerSituation":"高端自住,诚意出售","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","南北通透","四居","人车分流"],"communityQuality":9,"transportScore":7,"surroundingScore":8,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P026","community":"新城域","district":"武进区-大学城","totalPrice":88,"unitPrice":9500,"layout":"2室2厅1卫","area":92,"floor":"4/18","orientation":"南向","decoration":"精装","buildingAge":9,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"大学城","ownerSituation":"投资客,套现","priceDropSpace":10,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["满五唯一","总价低"],"communityQuality":5,"transportScore":7,"surroundingScore":7,"priceAdvantage":9,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P027","community":"绿地香颂","district":"武进区-湖塘","totalPrice":165,"unitPrice":13200,"layout":"3室2厅2卫","area":125,"floor":"11/25","orientation":"南北通透","decoration":"豪装","buildingAge":4,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"万达商圈","ownerSituation":"自住,精心装修","priceDropSpace":8,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["豪装","南北通透","次新房","品牌开发商"],"communityQuality":8,"transportScore":8,"surroundingScore":9,"priceAdvantage":5,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P028","community":"中景华庭","district":"金坛区-金坛新城","totalPrice":98,"unitPrice":10000,"layout":"2室2厅1卫","area":98,"floor":"14/18","orientation":"南向","decoration":"豪装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"金坛新城核心","ownerSituation":"新婚装修,换城市","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","次新房","婚装"],"communityQuality":7,"transportScore":6,"surroundingScore":6,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P029","community":"燕山公馆","district":"溧阳市-燕山新区","totalPrice":125,"unitPrice":11000,"layout":"3室2厅2卫","area":114,"floor":"16/20","orientation":"南北通透","decoration":"豪装","buildingAge":3,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"新区核心","ownerSituation":"投资客,套现","priceDropSpace":12,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","南北通透","次新房","大三居"],"communityQuality":8,"transportScore":6,"surroundingScore":7,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
+    {"id":"P030","community":"凤凰湖花园","district":"新北区-薛家","totalPrice":100,"unitPrice":10800,"layout":"3室2厅1卫","area":92,"floor":"3/18","orientation":"南向","decoration":"精装","buildingAge":7,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近地铁","ownerSituation":"自住,已定新房","priceDropSpace":12,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["满五唯一","精装","近地铁","业主急售"],"communityQuality":7,"transportScore":8,"surroundingScore":7,"priceAdvantage":8,"isGroundFloor":false,"isTopFloor":false}
+  ]
+}
+

+ 17 - 0
claude-code/claude-code-qiwe-assistant/knowledge/faq.md

@@ -0,0 +1,17 @@
+# 客户服务知识库
+
+## Agent 能做什么
+
+Agent 可以理解客户消息、维护多轮会话、检索客户画像和业务知识、调用房源搜索工具、生成回复草稿,并将低置信或敏感内容提交人工审核。
+
+## 为什么有些回复需要人工确认
+
+价格承诺、合同条款、学区资格、产权和投诉等内容风险较高。系统保留 Agent 草稿和依据,由人工编辑或批准后发送。
+
+## 人工接管
+
+顾问可在监管台把单个会话切换为人工接管。接管期间 Agent 不生成或发送回复;恢复托管后可选择审核模式或高置信自动模式。
+
+## 数据边界
+
+客户画像和推荐结果必须来自已接入的数据源。未接入的数据不应被当成真实业务事实。

+ 17 - 0
claude-code/claude-code-qiwe-assistant/knowledge/playbooks.md

@@ -0,0 +1,17 @@
+# 顾问沟通 Playbook
+
+## 首次咨询
+
+确认客户当前问题;识别区域、预算、户型三项基础需求;一次只追问一个最影响匹配的缺失条件。
+
+## 房源推荐
+
+按“为什么匹配、关键事实、待确认风险、建议下一步”的顺序表达。不要只报分数,也不要把评分当成客观事实。
+
+## 异议处理
+
+先复述客户顾虑,再说明已有证据和未知信息。未知信息交给人工或业务工具核验,不使用话术掩盖不确定性。
+
+## 跟进节奏
+
+客户未回复时不要连续发送。需要再次跟进时,应由人工策略或明确计划触发,而不是 Agent 自行频繁营销。

+ 25 - 0
claude-code/claude-code-qiwe-assistant/knowledge/rules.md

@@ -0,0 +1,25 @@
+# 企业微信回复规则
+
+## 真实性
+
+- 不编造房源、价格、优惠、学区、交通、产权和业主意愿。
+- 业务工具没有返回的数据必须明确说“需要进一步确认”。
+- 推荐结论必须能追溯到客户明确表达或知识库证据。
+
+## 沟通方式
+
+- 使用简洁、自然、专业的中文,每次优先控制在 2 到 5 句话。
+- 先回答客户当前问题,再追问最关键的一个缺失信息。
+- 不连续轰炸客户,不在客户明确结束对话后继续营销。
+
+## 人工审核
+
+- 价格承诺、合同、产权、学区资格、金融方案、投诉、退款和敏感个人信息必须人工审核。
+- Agent 置信度低于阈值或知识证据不足时必须人工审核。
+- 人工接管、会话暂停、全局暂停时禁止自动发送。
+
+## 房产咨询
+
+- 需求识别优先关注区域、总价预算、户型、面积、用途、时间计划和排斥项。
+- 第一次推荐属于初步判断,需要通过追问和带看反馈继续校准。
+- 推荐房源时同时展示匹配优势和待确认风险。

+ 331 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-console-store.js

@@ -0,0 +1,331 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { latestPath } = require('./output-paths');
+
+const CONTROL_FILE = latestPath('messages', 'agent-control.json');
+const CONVERSATIONS_FILE = latestPath('messages', 'agent-conversations.json');
+const VALID_MODES = new Set(['monitor', 'suggest', 'auto']);
+const MAX_MESSAGES = 80;
+
+function nowIso() {
+  return new Date().toISOString();
+}
+
+function readJson(filePath, fallback) {
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return fallback;
+  }
+}
+
+function writeJsonAtomic(filePath, value) {
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+
+function publicId(senderId) {
+  return crypto.createHash('sha256').update(String(senderId)).digest('hex').slice(0, 16);
+}
+
+function maskedId(senderId) {
+  const value = String(senderId || '');
+  if (value.length <= 4) return '测试联系人';
+  return `${value.slice(0, 2)}***${value.slice(-2)}`;
+}
+
+function defaultControl() {
+  return {
+    version: 1,
+    globalMode: 'suggest',
+    customerModes: {},
+    listener: { running: false, pid: null, startedAt: null, heartbeatAt: null },
+    updatedAt: nowIso(),
+  };
+}
+
+function loadControl() {
+  const stored = readJson(CONTROL_FILE, {});
+  return {
+    ...defaultControl(),
+    ...stored,
+    customerModes: stored.customerModes || {},
+    listener: { ...defaultControl().listener, ...(stored.listener || {}) },
+  };
+}
+
+function saveControl(control) {
+  const next = { ...control, updatedAt: nowIso() };
+  writeJsonAtomic(CONTROL_FILE, next);
+  return next;
+}
+
+function getAgentControl(senderId) {
+  const control = loadControl();
+  return {
+    globalMode: VALID_MODES.has(control.globalMode) ? control.globalMode : 'suggest',
+    customerMode: control.customerModes[String(senderId)] === 'manual' ? 'manual' : 'agent',
+  };
+}
+
+function setGlobalMode(mode) {
+  if (!VALID_MODES.has(mode)) throw new Error('不支持的 Agent 模式');
+  const control = loadControl();
+  control.globalMode = mode;
+  return saveControl(control);
+}
+
+function setCustomerMode(senderId, mode) {
+  if (!['agent', 'manual'].includes(mode)) throw new Error('不支持的客户接管模式');
+  const control = loadControl();
+  if (mode === 'manual') control.customerModes[String(senderId)] = 'manual';
+  else delete control.customerModes[String(senderId)];
+  saveControl(control);
+
+  const store = loadStore();
+  const conversation = store.conversations[String(senderId)];
+  if (conversation) {
+    conversation.mode = mode;
+    conversation.updatedAt = nowIso();
+    store.updatedAt = nowIso();
+    saveStore(store);
+  }
+  return { senderId: String(senderId), mode };
+}
+
+function updateListenerStatus(update = {}) {
+  const control = loadControl();
+  control.listener = { ...control.listener, ...update };
+  if (update.running) control.listener.heartbeatAt = nowIso();
+  if (update.running === false) {
+    control.listener.pid = null;
+    control.listener.heartbeatAt = nowIso();
+  }
+  return saveControl(control).listener;
+}
+
+function getListenerStatus() {
+  const listener = loadControl().listener;
+  const heartbeat = Date.parse(listener.heartbeatAt || '');
+  const fresh = Number.isFinite(heartbeat) && Date.now() - heartbeat < 35000;
+  return { ...listener, running: Boolean(listener.running && fresh) };
+}
+
+function defaultStore() {
+  return { version: 1, conversations: {}, updatedAt: nowIso() };
+}
+
+function loadStore() {
+  const stored = readJson(CONVERSATIONS_FILE, {});
+  return {
+    ...defaultStore(),
+    ...stored,
+    conversations: stored.conversations || {},
+  };
+}
+
+function saveStore(store) {
+  writeJsonAtomic(CONVERSATIONS_FILE, { ...store, updatedAt: nowIso() });
+}
+
+function normalizeAnalysis(analysis = {}) {
+  const matches = Array.isArray(analysis.matches) ? analysis.matches.slice(0, 5) : [];
+  return {
+    intent: analysis.intent || '需求咨询',
+    intentLabel: analysis.intentLabel || analysis.intent || '需求咨询',
+    demand: analysis.demand || analysis.extractedNeeds || {},
+    completenessScore: Number(analysis.completenessScore ?? analysis.completeness_score ?? 0),
+    matches,
+    knowledgeSources: Array.isArray(analysis.knowledgeSources)
+      ? analysis.knowledgeSources.slice(0, 8)
+      : ['客户当前消息', '结构化需求识别规则'],
+    reasoning: analysis.reasoning || '根据客户当前表达提取明确需求,未提及的信息保持待确认。',
+  };
+}
+
+function ensureConversation(store, senderId, senderName) {
+  const key = String(senderId);
+  const control = getAgentControl(key);
+  if (!store.conversations[key]) {
+    store.conversations[key] = {
+      senderId: key,
+      displayName: senderName || '企微测试客户',
+      mode: control.customerMode,
+      source: 'live',
+      messages: [],
+      analysis: normalizeAnalysis(),
+      pendingReply: null,
+      lastMessageAt: null,
+      updatedAt: nowIso(),
+    };
+  }
+  const conversation = store.conversations[key];
+  if (senderName) conversation.displayName = senderName;
+  conversation.mode = control.customerMode;
+  return conversation;
+}
+
+function addMessage(conversation, message) {
+  if (message.externalId && conversation.messages.some(item => item.externalId === message.externalId)) return false;
+  conversation.messages.push({
+    id: message.id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
+    role: message.role,
+    content: String(message.content || ''),
+    timestamp: message.timestamp || nowIso(),
+    status: message.status || 'received',
+    source: message.source || 'live',
+    externalId: message.externalId || null,
+  });
+  conversation.messages = conversation.messages.slice(-MAX_MESSAGES);
+  return true;
+}
+
+function recordAgentTurn({ senderId, senderName, customerContent, reply, analysis, externalId, source = 'live' }) {
+  const store = loadStore();
+  const conversation = ensureConversation(store, senderId, senderName);
+  const added = addMessage(conversation, {
+    role: 'customer',
+    content: customerContent,
+    status: 'received',
+    source,
+    externalId,
+  });
+  if (!added) return { ...publicConversation(conversation), duplicate: true };
+
+  const control = getAgentControl(senderId);
+  conversation.analysis = normalizeAnalysis(analysis);
+  conversation.source = source;
+  conversation.lastMessageAt = nowIso();
+  conversation.updatedAt = nowIso();
+
+  if (reply && control.globalMode !== 'monitor' && control.customerMode !== 'manual') {
+    const suggestionId = `${Date.now().toString(36)}-suggestion`;
+    conversation.pendingReply = { id: suggestionId, content: String(reply), createdAt: nowIso(), status: 'pending' };
+    addMessage(conversation, {
+      id: suggestionId,
+      role: 'agent',
+      content: reply,
+      status: control.globalMode === 'auto' ? 'sending' : 'pending',
+      source: 'agent',
+    });
+  } else {
+    conversation.pendingReply = null;
+  }
+
+  saveStore(store);
+  return { ...publicConversation(conversation), duplicate: false };
+}
+
+function recordHistoricalTurn({ senderId, senderName, customerContent, reply, analysis, timestamp }) {
+  const store = loadStore();
+  const conversation = ensureConversation(store, senderId, senderName);
+  if (conversation.messages.length) return publicConversation(conversation);
+  conversation.source = 'verified-history';
+  conversation.analysis = normalizeAnalysis(analysis);
+  conversation.lastMessageAt = timestamp || nowIso();
+  conversation.updatedAt = nowIso();
+  addMessage(conversation, {
+    role: 'customer', content: customerContent, timestamp, status: 'received', source: 'verified-history', externalId: 'legacy-customer',
+  });
+  if (reply) {
+    addMessage(conversation, {
+      role: 'agent', content: reply, timestamp, status: 'sent', source: 'verified-history', externalId: 'legacy-agent',
+    });
+  }
+  conversation.pendingReply = null;
+  saveStore(store);
+  return publicConversation(conversation);
+}
+
+function findByPublicId(store, id) {
+  return Object.values(store.conversations).find(item => publicId(item.senderId) === String(id));
+}
+
+function markReplySent(id, content, sentBy = 'agent') {
+  const store = loadStore();
+  const conversation = findByPublicId(store, id);
+  if (!conversation) throw new Error('会话不存在');
+  const pending = conversation.pendingReply;
+  const text = String(content || pending?.content || '').trim();
+  if (!text) throw new Error('回复内容不能为空');
+
+  const pendingMessage = pending
+    ? conversation.messages.find(item => item.id === pending.id)
+    : null;
+  if (pendingMessage && sentBy === 'agent' && pendingMessage.content === text) {
+    pendingMessage.status = 'sent';
+    pendingMessage.timestamp = nowIso();
+  } else {
+    if (pendingMessage) pendingMessage.status = 'replaced';
+    addMessage(conversation, {
+      role: sentBy === 'agent' ? 'agent' : 'human',
+      content: text,
+      status: 'sent',
+      source: sentBy,
+    });
+  }
+  conversation.pendingReply = null;
+  conversation.updatedAt = nowIso();
+  saveStore(store);
+  return publicConversation(conversation);
+}
+
+function markReplyFailed(id) {
+  const store = loadStore();
+  const conversation = findByPublicId(store, id);
+  if (!conversation) return;
+  if (conversation.pendingReply) conversation.pendingReply.status = 'failed';
+  const message = conversation.pendingReply
+    ? conversation.messages.find(item => item.id === conversation.pendingReply.id)
+    : null;
+  if (message) message.status = 'failed';
+  saveStore(store);
+}
+
+function publicConversation(conversation) {
+  return {
+    id: publicId(conversation.senderId),
+    displayName: conversation.displayName || '企微测试客户',
+    maskedId: maskedId(conversation.senderId),
+    mode: conversation.mode || 'agent',
+    source: conversation.source || 'live',
+    messages: (conversation.messages || []).map(({ externalId, ...message }) => message),
+    analysis: normalizeAnalysis(conversation.analysis),
+    pendingReply: conversation.pendingReply ? { ...conversation.pendingReply } : null,
+    lastMessageAt: conversation.lastMessageAt,
+    updatedAt: conversation.updatedAt,
+  };
+}
+
+function listConversations() {
+  const store = loadStore();
+  return Object.values(store.conversations)
+    .map(publicConversation)
+    .sort((a, b) => Date.parse(b.lastMessageAt || 0) - Date.parse(a.lastMessageAt || 0));
+}
+
+function getInternalConversation(id) {
+  const store = loadStore();
+  const conversation = findByPublicId(store, id);
+  return conversation || null;
+}
+
+module.exports = {
+  CONTROL_FILE,
+  CONVERSATIONS_FILE,
+  getAgentControl,
+  setGlobalMode,
+  setCustomerMode,
+  updateListenerStatus,
+  getListenerStatus,
+  loadControl,
+  recordAgentTurn,
+  recordHistoricalTurn,
+  listConversations,
+  getInternalConversation,
+  markReplySent,
+  markReplyFailed,
+};

+ 143 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-knowledge.js

@@ -0,0 +1,143 @@
+const fs = require('fs');
+const path = require('path');
+
+function walkMarkdown(dir) {
+  if (!dir || !fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
+    const full = path.join(dir, entry.name);
+    if (entry.isDirectory()) return walkMarkdown(full);
+    return entry.isFile() && entry.name.toLowerCase().endsWith('.md') ? [full] : [];
+  });
+}
+
+function terms(text) {
+  const normalized = String(text || '').toLowerCase();
+  const base = normalized.match(/[a-z0-9]+|[\p{Script=Han}]{2,}/gu) || [];
+  const expanded = [];
+  for (const token of base) {
+    expanded.push(token);
+    if (/^[\p{Script=Han}]+$/u.test(token) && token.length > 2) {
+      for (let i = 0; i < token.length - 1; i += 1) expanded.push(token.slice(i, i + 2));
+    }
+  }
+  return [...new Set(expanded)];
+}
+
+function splitMarkdown(filePath, root) {
+  const source = path.relative(root, filePath).replace(/\\/g, '/');
+  const text = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
+  const lines = text.split(/\r?\n/);
+  const chunks = [];
+  let heading = path.basename(filePath, '.md');
+  let body = [];
+  const flush = () => {
+    const content = body.join('\n').trim();
+    if (content) chunks.push({ id: `${source}#${chunks.length + 1}`, source, heading, content });
+    body = [];
+  };
+  for (const line of lines) {
+    const match = line.match(/^#{1,3}\s+(.+)$/);
+    if (match) { flush(); heading = match[1].trim(); } else body.push(line);
+  }
+  flush();
+  return chunks;
+}
+
+function normalizeProperty(raw) {
+  return {
+    id: raw.id || raw.objectId || raw.property_id || '',
+    community: raw.community || raw.community_name || '',
+    district: raw.district || raw.area_name || '',
+    totalPrice: Number(raw.totalPrice ?? raw.total_price ?? raw.price_total ?? 0),
+    layout: raw.layout || raw.house_type || '',
+    area: Number(raw.area || 0),
+    decoration: raw.decoration || '',
+    orientation: raw.orientation || '',
+    floor: raw.floor || raw.floor_info || raw.floorLevel || raw.floor_level || '',
+    isSchoolDistrict: Boolean(raw.isSchoolDistrict ?? raw.is_school_district),
+    schoolName: raw.schoolName || raw.school_name || '',
+    highlights: raw.highlightTags || raw.highlight_tags || raw.tags || [],
+    ownerSituation: raw.ownerSituation || raw.owner_situation || '',
+    priceDropSpace: Number(raw.priceDropSpace ?? raw.price_drop_space ?? 0),
+  };
+}
+
+function parseDemand(input = {}) {
+  const query = String(input.query || '');
+  const district = input.district || (query.match(/(新北区|武进区|天宁区|钟楼区)/)?.[1] || '');
+  const budgetMax = Number(input.budgetMax || query.match(/(\d{2,4})\s*万/)?.[1] || 0);
+  const roomToken = input.rooms || query.match(/([1-5一二三四五])\s*(?:室|房|居)/)?.[1] || '';
+  const map = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5 };
+  const rooms = Number(roomToken) || map[roomToken] || 0;
+  const decoration = input.decoration || query.match(/(豪装|精装|简装|毛坯)/)?.[1] || '';
+  const orientation = input.orientation || query.match(/(南北通透|朝南|朝北|南向|北向|东向|西向)/)?.[1] || '';
+  const schoolRequired = Boolean(input.schoolRequired || /学区|上学|学校/.test(query));
+  return { district, budgetMax, rooms, decoration, orientation, schoolRequired };
+}
+
+class AgentKnowledgeStore {
+  constructor({ knowledgeDir, propertyDataFile = '' }) {
+    this.knowledgeDir = knowledgeDir;
+    this.propertyDataFile = propertyDataFile;
+    this.reload();
+  }
+
+  reload() {
+    this.chunks = walkMarkdown(this.knowledgeDir).flatMap(file => splitMarkdown(file, this.knowledgeDir));
+    this.properties = [];
+    if (this.propertyDataFile && fs.existsSync(this.propertyDataFile)) {
+      const parsed = JSON.parse(fs.readFileSync(this.propertyDataFile, 'utf8').replace(/^\uFEFF/, ''));
+      const rows = Array.isArray(parsed) ? parsed : (parsed.properties || parsed.data || []);
+      this.properties = rows.map(normalizeProperty).filter(item => item.id || item.community);
+    }
+  }
+
+  rulesText() {
+    return this.chunks.filter(chunk => chunk.source === 'rules.md').map(chunk => `## ${chunk.heading}\n${chunk.content}`).join('\n\n');
+  }
+
+  search(query, limit = 5) {
+    const queryTerms = terms(query);
+    return this.chunks.map(chunk => {
+      const haystack = `${chunk.heading}\n${chunk.content}`.toLowerCase();
+      const score = queryTerms.reduce((sum, term) => sum + (haystack.includes(term) ? Math.max(1, term.length) : 0), 0);
+      return { ...chunk, score };
+    }).filter(item => item.score > 0).sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(10, limit)))
+      .map(item => ({ id: item.id, source: item.source, heading: item.heading, content: item.content, score: item.score }));
+  }
+
+  searchProperties(input = {}) {
+    const demand = parseDemand(input);
+    const results = this.properties.filter(property => {
+      if (demand.district && !property.district.includes(demand.district)) return false;
+      if (demand.budgetMax && property.totalPrice > demand.budgetMax * 1.1) return false;
+      if (demand.rooms && !property.layout.includes(`${demand.rooms}室`)) return false;
+      if (demand.decoration && property.decoration !== demand.decoration) return false;
+      if (demand.schoolRequired && !property.isSchoolDistrict) return false;
+      if (demand.orientation) {
+        const key = demand.orientation.replace('朝', '').replace('向', '');
+        if (!property.orientation.includes(key)) return false;
+      }
+      return true;
+    });
+    return {
+      dataSource: this.propertyDataFile ? path.basename(this.propertyDataFile) : null,
+      dataLabel: this.propertyDataFile ? '演示房源数据集' : null,
+      demand,
+      total: results.length,
+      items: results.slice(0, 5),
+      warning: this.propertyDataFile ? '当前接入的是演示房源数据,不代表实时在售事实。' : '未配置房源数据源,不能提供真实房源事实。',
+    };
+  }
+
+  stats() {
+    return {
+      knowledgeChunks: this.chunks.length,
+      propertyCount: this.properties.length,
+      propertyDataConfigured: Boolean(this.propertyDataFile),
+      propertyDataLabel: this.propertyDataFile ? '演示房源数据集' : '未接入',
+    };
+  }
+}
+
+module.exports = { AgentKnowledgeStore };

+ 22 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-poller-policy.js

@@ -0,0 +1,22 @@
+'use strict';
+
+function messageTimestamp(raw) {
+  const parsed = Number(raw);
+  if (!Number.isFinite(parsed)) return 0;
+  return parsed > 1e12 ? Math.floor(parsed / 1000) : parsed;
+}
+
+function evaluatePolledMessage(message = {}, config = {}) {
+  if (![0, 1, 2].includes(Number(message.msgType))) return { eligible: false, reason: 'unsupported_type' };
+  const content = String(message.msgData?.content || '').trim();
+  const senderId = String(message.senderId || '');
+  if (!content || !senderId) return { eligible: false, reason: 'empty_message' };
+  if (senderId === String(config.selfUserId || '')) return { eligible: false, reason: 'self_message' };
+  if (!(config.allowedSenders || []).map(String).includes(senderId)) return { eligible: false, reason: 'not_allowlisted' };
+  const timestamp = messageTimestamp(message.timestamp);
+  if (!timestamp) return { eligible: false, reason: 'invalid_timestamp' };
+  // 使用持久化游标恢复停机期间的积压消息;不要再按本次进程启动时间丢弃。
+  return { eligible: true, content, senderId, timestamp };
+}
+
+module.exports = { messageTimestamp, evaluatePolledMessage };

+ 842 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js

@@ -0,0 +1,842 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const RISK_PATTERN = /合同|签约|产权|学区资格|保证|承诺|最低价|贷款|利率|首付|投诉|退款|发票|身份证|银行卡|法律|违约/;
+
+class AgentNotConfiguredError extends Error {
+  constructor() {
+    super('Agent 模型尚未配置,消息已保留但不会生成伪造回复');
+    this.name = 'AgentNotConfiguredError';
+  }
+}
+
+class OpenAICompatibleClient {
+  constructor(config) { this.config = config; }
+
+  async complete(messages, tools) {
+    if (!this.config.apiKey) throw new AgentNotConfiguredError();
+    const body = {
+      model: this.config.model,
+      temperature: 0.2,
+      messages,
+    };
+    if (Array.isArray(tools) && tools.length) {
+      body.tools = tools;
+      body.tool_choice = 'auto';
+    }
+    const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}` },
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(30000),
+    });
+    const payload = await response.json().catch(() => ({}));
+    if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`);
+    const message = payload.choices?.[0]?.message;
+    if (!message) throw new Error('Agent 上游没有返回有效消息');
+    return message;
+  }
+}
+
+class AnthropicCompatibleClient {
+  constructor(config) { this.config = config; }
+
+  toAnthropicMessages(messages) {
+    return messages.filter(message => message.role !== 'system').map(message => {
+      if (message.role === 'tool') {
+        return { role: 'user', content: [{ type: 'tool_result', tool_use_id: message.tool_call_id, content: message.content }] };
+      }
+      if (message.role === 'assistant' && message.tool_calls?.length) {
+        const content = [];
+        if (message.content) content.push({ type: 'text', text: message.content });
+        for (const call of message.tool_calls) {
+          let input = {};
+          try { input = JSON.parse(call.function.arguments || '{}'); } catch {}
+          content.push({ type: 'tool_use', id: call.id, name: call.function.name, input });
+        }
+        return { role: 'assistant', content };
+      }
+      return { role: message.role, content: message.content };
+    });
+  }
+
+  async complete(messages, tools) {
+    if (!this.config.apiKey) throw new AgentNotConfiguredError();
+    const system = messages.find(message => message.role === 'system')?.content || '';
+    const body = {
+      model: this.config.model,
+      max_tokens: 1400,
+      temperature: 0.2,
+      system,
+      messages: this.toAnthropicMessages(messages),
+    };
+    if (Array.isArray(tools) && tools.length) {
+      body.tools = tools.map(tool => ({
+        name: tool.function.name,
+        description: tool.function.description,
+        input_schema: tool.function.parameters,
+      }));
+    }
+    const response = await fetch(`${this.config.baseUrl}/v1/messages`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'anthropic-version': '2023-06-01',
+        'x-api-key': this.config.apiKey,
+        Authorization: `Bearer ${this.config.apiKey}`,
+      },
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(45000),
+    });
+    const payload = await response.json().catch(() => ({}));
+    if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`);
+    const blocks = Array.isArray(payload.content) ? payload.content : [];
+    const toolCalls = blocks.filter(block => block.type === 'tool_use').map(block => ({
+      id: block.id,
+      function: { name: block.name, arguments: JSON.stringify(block.input || {}) },
+    }));
+    return {
+      content: blocks.filter(block => block.type === 'text').map(block => block.text).join('\n'),
+      ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
+    };
+  }
+}
+
+function readJson(filePath, fallback = {}) {
+  try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
+  catch { return fallback; }
+}
+
+function writeJsonAtomic(filePath, value) {
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+
+function resolveClaudeExecutable(config = {}) {
+  const candidates = [
+    config.claudeExecutable,
+    process.env.CLAUDE_CODE_EXECUTABLE,
+    path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
+    path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
+  ].filter(Boolean);
+  return candidates.find(candidate => fs.existsSync(candidate)) || '';
+}
+
+function normalizeSessionLabel(value) {
+  return String(value || '企微客户')
+    .trim()
+    .replace(/[\\/:*?"<>|\r\n]+/g, '-')
+    .replace(/\s+/g, '-')
+    .replace(/-+/g, '-')
+    .replace(/^-|-$/g, '')
+    .slice(0, 24) || '企微客户';
+}
+
+function buildClaudeSessionName(context = {}, key = '') {
+  const customerName = normalizeSessionLabel(
+    context.conversation?.contact_name || context.conversation?.displayName || '企微客户'
+  );
+  const reference = crypto.createHash('sha256').update(String(key || 'qiwei-default')).digest('hex').slice(0, 4);
+  return `企微客户-${customerName}-${reference}`;
+}
+
+function selectAuthoritativeHistory(messages = [], limit = 10) {
+  const history = messages.filter(message => message.role !== 'system');
+  const latestInbound = history.findLastIndex(message => message.role === 'user');
+  if (latestInbound < 0) return history.slice(-limit);
+  const previousOutbound = history.slice(0, latestInbound).findLastIndex(message => message.role === 'assistant');
+  const start = previousOutbound >= 0 ? previousOutbound : Math.max(0, history.length - limit);
+  return history.slice(start).slice(-limit);
+}
+
+function uniqueIntelligence(items = [], keyFn) {
+  const seen = new Set();
+  return items.filter(item => {
+    const key = keyFn(item);
+    if (!key || seen.has(key)) return false;
+    seen.add(key);
+    return true;
+  });
+}
+
+function normalizedEvidence(value) {
+  return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, '');
+}
+
+function evidenceIsSupported(evidence, authoritativeText) {
+  const source = normalizedEvidence(authoritativeText);
+  const claim = normalizedEvidence(evidence);
+  if (!source || !claim) return false;
+  return source.includes(claim) || (source.length >= 4 && claim.includes(source));
+}
+
+function supportedModelProfileUpdates(updates = {}, authoritativeText = '') {
+  if (!updates || typeof updates !== 'object' || Array.isArray(updates)) return {};
+  const explicitNumbers = new Set(String(authoritativeText || '').match(/\d+(?:\.\d+)?/g) || []);
+  return Object.fromEntries(Object.entries(updates).filter(([, value]) => {
+    if (value === undefined || value === null || value === '') return false;
+    const values = Array.isArray(value) ? value : [value];
+    return values.every(item => typeof item === 'number'
+      ? explicitNumbers.has(String(item))
+      : evidenceIsSupported(String(item), authoritativeText));
+  }));
+}
+
+function unsupportedAttributedClaims(final = {}, authoritativeHistory = []) {
+  const output = `${String(final.reply || '')}\n${String(final.reason || '')}`;
+  const source = authoritativeHistory.map(item => item.content || '').join('\n');
+  const claims = [];
+  const marker = /(?:您|客户)(?:之前|此前|刚才)?(?:曾经)?(?:提到|说过|说|表示)/g;
+  for (const match of output.matchAll(marker)) {
+    const tail = output.slice((match.index || 0) + match[0].length, (match.index || 0) + match[0].length + 100);
+    const quoted = tail.match(/^\s*[::]?\s*["“‘']([^"”’'\r\n]{2,60})["”’']/);
+    if (quoted && !evidenceIsSupported(quoted[1], source)) claims.push(quoted[1].trim());
+  }
+  return [...new Set(claims)];
+}
+
+function groundedConfirmationReply(profile = {}, inboundContent = '') {
+  const region = profile.preferredRegion || profile.region || profile.district || '';
+  const layout = profile.layout || profile.roomType || '';
+  const budget = Number(profile.budgetWan || profile.budget || 0);
+  const confirmed = [region, layout, budget ? `${budget}万预算` : ''].filter(Boolean).join('、');
+  const opening = confirmed ? `收到,我先按${confirmed}继续整理。` : '收到,您刚才的信息我已经记录。';
+  const questions = [];
+  if (budget && (!profile.budgetType || profile.budgetType === '待确认')) questions.push(`这${budget}万是总价预算还是首付预算`);
+  if (!profile.purpose) questions.push('主要用于自住还是投资');
+  if (!profile.timeline) questions.push('希望什么时候购置');
+  if (!questions.length) return `${opening}我会先核对可用方案,再给您准确回复。`;
+  return `${opening}为了避免理解偏差,想再确认一下:${questions.join(';')}?`;
+}
+
+function enforceAuthoritativeGrounding(final = {}, authoritativeHistory = [], profile = {}, inboundContent = '') {
+  const unsupported = unsupportedAttributedClaims(final, authoritativeHistory);
+  if (!unsupported.length) return { ...final, groundingWarnings: [] };
+  return {
+    ...final,
+    reply: groundedConfirmationReply(profile, inboundContent),
+    reason: `检测到模型引用了本轮有效会话中不存在的客户原话,已降级为确认式草稿。未支持内容:${unsupported.join('、')}`,
+    confidence: Math.min(clamp(final.confidence), 0.68),
+    requiresHuman: true,
+    groundingWarnings: unsupported,
+  };
+}
+
+function extractExplicitCustomerIntelligence(content, currentProfile = {}, modelOutput = {}) {
+  const text = String(content || '').trim();
+  const profileUpdates = supportedModelProfileUpdates(modelOutput.profileUpdates, text);
+  const amount = text.match(/(\d+(?:\.\d+)?)\s*万/);
+  if (amount && (/预算|总价|首付/.test(text) || /^\s*\d+(?:\.\d+)?\s*万(?:吧|左右|上下|以内|起)?\s*[。!!??]*$/.test(text))) {
+    profileUpdates.budgetWan = Number(amount[1]);
+    profileUpdates.budgetType = /首付/.test(text) ? '首付' : /总价/.test(text) ? '总价' : (currentProfile.budgetType || '待确认');
+  }
+  const region = text.match(/([\p{Script=Han}]{2,8}(?:区|市|镇|板块))/u);
+  if (region) {
+    let regionValue = region[1].replace(/^.*(?:想咨询一下|咨询一下|了解一下|考虑在|想在|咨询|了解|看看|一下)/, '');
+    if (regionValue.length > 5 && !regionValue.endsWith('板块')) regionValue = regionValue.slice(-4);
+    profileUpdates.preferredRegion = regionValue;
+  }
+  const layout = text.match(/([一二三四五六七八九十两\d]+)\s*室/);
+  if (layout) profileUpdates.layout = `${layout[1]}室`;
+  if (/自己住|自住|婚房|改善/.test(text)) profileUpdates.purpose = /自己住|自住/.test(text) ? '自住' : text.match(/婚房|改善/)?.[0];
+  if (/投资|出租|收租/.test(text)) profileUpdates.purpose = '投资';
+  if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) profileUpdates.urgency = '高';
+
+  const mergedProfile = { ...currentProfile, ...profileUpdates };
+  const factEvidence = [
+    mergedProfile.budgetWan || mergedProfile.budget ? `预算${mergedProfile.budgetWan || mergedProfile.budget}万` : '',
+    mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area ? `区域${mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area}` : '',
+    mergedProfile.layout || mergedProfile.roomType ? `户型${mergedProfile.layout || mergedProfile.roomType}` : '',
+    mergedProfile.purpose ? `用途${mergedProfile.purpose}` : '',
+    mergedProfile.timeline ? `时间${mergedProfile.timeline}` : '',
+  ].filter(Boolean).join(',').slice(0, 240);
+  const evidence = text.slice(0, 240);
+  const tasks = Array.isArray(modelOutput.tasks)
+    ? modelOutput.tasks.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({
+        ...item,
+        businessKey: item.businessKey || item.key,
+        managedBy: 'agent',
+      }))
+    : [];
+  const alerts = Array.isArray(modelOutput.alerts)
+    ? modelOutput.alerts.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({
+        ...item,
+        businessKey: item.businessKey || item.key,
+        managedBy: 'agent',
+      }))
+    : [];
+  const hasBudget = Number(mergedProfile.budgetWan || mergedProfile.budget || 0) > 0;
+  const hasRegion = Boolean(mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area);
+  const hasLayout = Boolean(mergedProfile.layout || mergedProfile.roomType);
+  const hasPurpose = Boolean(mergedProfile.purpose);
+  const hasTimeline = Boolean(mergedProfile.timeline || mergedProfile.purchaseTime || mergedProfile.purchase_time);
+
+  if (hasBudget && (!hasPurpose || !hasTimeline)) {
+    tasks.push({
+      businessKey: 'qualification:purpose_and_timeline',
+      managedBy: 'rule',
+      type: 'qualification',
+      title: '确认客户用途与购置时间',
+      owner: '待分配',
+      dueAt: '',
+      priority: hasRegion || hasLayout ? 'high' : 'medium',
+      reason: `客户已经给出预算,但${[!hasPurpose ? '用途' : '', !hasTimeline ? '购置时间' : ''].filter(Boolean).join('和')}仍不明确。`,
+      evidence: factEvidence || evidence,
+    });
+  }
+  if (hasBudget && hasRegion && hasLayout) {
+    tasks.push({
+      businessKey: 'recommendation:shortlist',
+      managedBy: 'rule',
+      type: 'recommendation',
+      title: '按已确认条件筛选并发送重点方案',
+      owner: '待分配',
+      dueAt: '',
+      priority: 'high',
+      reason: '预算、区域和户型三项核心条件已经明确。',
+      evidence: factEvidence || evidence,
+    });
+    alerts.push({
+      businessKey: 'high_intent:core_demand_ready',
+      managedBy: 'rule',
+      type: 'high_intent',
+      severity: 'high',
+      title: '客户核心需求已基本成形',
+      detail: '预算、区域和户型信息已具备,可以从泛咨询进入重点方案或下一步转化。',
+      evidence: factEvidence || evidence,
+      recommendedAction: '优先核对用途和时间计划,再给出少量重点方案。',
+    });
+  }
+  if (/投诉|不满意|骗人|退款|举报|再也不|太差|生气/.test(text)) {
+    alerts.push({
+      businessKey: 'complaint:manual_takeover',
+      managedBy: 'event',
+      type: 'complaint',
+      severity: 'critical',
+      title: '检测到投诉或强烈负面情绪',
+      detail: '该消息不应由自动回复独立处理。',
+      evidence,
+      recommendedAction: '立即人工接管,先确认事实和客户诉求。',
+    });
+  } else if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) {
+    alerts.push({
+      businessKey: 'time_sensitive:follow_up',
+      managedBy: 'event',
+      type: 'time_sensitive',
+      severity: 'high',
+      title: '客户表达了明确时效要求',
+      detail: '消息中包含较紧迫的时间表达。',
+      evidence,
+      recommendedAction: '优先确认具体截止时间并安排跟进。',
+    });
+  }
+
+  return {
+    profileUpdates,
+    tasks: uniqueIntelligence(tasks, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 8),
+    alerts: uniqueIntelligence(alerts, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 6),
+  };
+}
+
+class ClaudeCodeSessionStore {
+  constructor(filePath, project = {}) {
+    this.filePath = filePath;
+    this.project = project;
+  }
+
+  loadState() {
+    const state = readJson(this.filePath, { version: 1, project: {}, sessions: {} });
+    state.version = 1;
+    state.sessions ||= {};
+    state.project ||= {};
+    state.project.projectId = this.project.projectId || state.project.projectId || 'qiwei-project';
+    state.project.projectRoot = this.project.projectRoot || state.project.projectRoot || '';
+    state.project.controllerSessionId = this.project.mainSessionId
+      || state.project.controllerSessionId
+      || crypto.randomUUID();
+    state.project.boundMainSessionId = this.project.mainSessionId || state.project.boundMainSessionId || null;
+    return state;
+  }
+
+  get(key) {
+    return this.loadState().sessions?.[key] || null;
+  }
+
+  ensure(key, metadata = {}) {
+    const state = this.loadState();
+    if (!state.sessions[key]) {
+      state.sessions[key] = {
+        id: crypto.randomUUID(),
+        initialized: false,
+        role: 'customer-agent',
+        projectId: state.project.projectId,
+        parentControllerSessionId: state.project.controllerSessionId,
+        createdAt: new Date().toISOString(),
+      };
+    }
+    const session = state.sessions[key];
+    session.role ||= 'customer-agent';
+    session.projectId ||= state.project.projectId;
+    session.parentControllerSessionId ||= state.project.controllerSessionId;
+    if (metadata.customerName) session.customerName = String(metadata.customerName).trim().slice(0, 80);
+    if (metadata.displayName) session.displayName = String(metadata.displayName).trim().slice(0, 80);
+    writeJsonAtomic(this.filePath, state);
+    return session;
+  }
+
+  markInitialized(key) {
+    const state = this.loadState();
+    if (!state.sessions?.[key]) return;
+    state.sessions[key].initialized = true;
+    state.sessions[key].updatedAt = new Date().toISOString();
+    writeJsonAtomic(this.filePath, state);
+  }
+
+  setRole(key, role) {
+    const state = this.loadState();
+    if (!state.sessions?.[key]) return false;
+    state.sessions[key].role = String(role || 'customer-agent');
+    state.sessions[key].updatedAt = new Date().toISOString();
+    writeJsonAtomic(this.filePath, state);
+    return true;
+  }
+
+  reset(key, metadata = {}) {
+    const state = this.loadState();
+    state.sessions[key] = {
+      id: crypto.randomUUID(),
+      initialized: false,
+      role: 'customer-agent',
+      projectId: state.project.projectId,
+      parentControllerSessionId: state.project.controllerSessionId,
+      customerName: metadata.customerName || undefined,
+      displayName: metadata.displayName || undefined,
+      createdAt: new Date().toISOString(),
+    };
+    writeJsonAtomic(this.filePath, state);
+    return state.sessions[key];
+  }
+}
+
+class ClaudeCodeClient {
+  constructor(config) {
+    this.config = config;
+    this.executable = resolveClaudeExecutable(config);
+    this.workdir = path.resolve(config.claudeWorkdir || process.cwd());
+    this.sessionStore = new ClaudeCodeSessionStore(config.claudeSessionFile, {
+      projectId: config.claudeProjectId,
+      projectRoot: this.workdir,
+      mainSessionId: config.claudeMainSessionId,
+    });
+    this.queues = new Map();
+  }
+
+  isConfigured() {
+    return Boolean(this.executable && fs.existsSync(this.workdir));
+  }
+
+  outputSchema() {
+    return this.config.outputSchema || {
+      type: 'object',
+      additionalProperties: false,
+      properties: {
+        reply: { type: 'string' },
+        confidence: { type: 'number', minimum: 0, maximum: 1 },
+        intent: { type: 'string' },
+        reason: { type: 'string' },
+        requiresHuman: { type: 'boolean' },
+        profileUpdates: { type: 'object' },
+        tasks: {
+          type: 'array',
+          maxItems: 6,
+          items: {
+            type: 'object',
+            additionalProperties: false,
+            properties: {
+              key: { type: 'string' },
+              type: { type: 'string' },
+              title: { type: 'string' },
+              owner: { type: 'string' },
+              dueAt: { type: 'string' },
+              priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] },
+              reason: { type: 'string' },
+              evidence: { type: 'string' },
+            },
+            required: ['type', 'title', 'owner', 'dueAt', 'priority', 'reason', 'evidence'],
+          },
+        },
+        alerts: {
+          type: 'array',
+          maxItems: 4,
+          items: {
+            type: 'object',
+            additionalProperties: false,
+            properties: {
+              key: { type: 'string' },
+              type: { type: 'string' },
+              severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
+              title: { type: 'string' },
+              detail: { type: 'string' },
+              evidence: { type: 'string' },
+              recommendedAction: { type: 'string' },
+            },
+            required: ['type', 'severity', 'title', 'detail', 'evidence', 'recommendedAction'],
+          },
+        },
+      },
+      required: ['reply', 'confidence', 'intent', 'reason', 'requiresHuman', 'profileUpdates', 'tasks', 'alerts'],
+    };
+  }
+
+  sessionKey(context = {}) {
+    return String(context.conversation?.id || context.conversation?.contact_id || 'qiwei-default');
+  }
+
+  buildPrompt(messages, context = {}) {
+    if (context.directPrompt) return String(context.directPrompt);
+    const history = selectAuthoritativeHistory(messages, 10)
+      .map(message => `${message.role === 'assistant' ? '客服' : message.role === 'tool' ? '工具' : '客户'}:${String(message.content || '').slice(0, 1200)}`)
+      .join('\n\n');
+    const profile = context.profile?.profile || context.profile || {};
+    const customerIntelligence = context.customerIntelligence || {};
+    const activeTasks = (customerIntelligence.tasks || []).filter(item => ['open', 'in_progress'].includes(item.status));
+    const activeAlerts = (customerIntelligence.alerts || []).filter(item => ['open', 'acknowledged'].includes(item.status));
+    const recommendations = (customerIntelligence.recommendations || []).map(item => ({
+      propertyId: item.property_id || item.propertyId,
+      community: item.property_snapshot?.community || item.property?.community,
+      totalPrice: item.property_snapshot?.totalPrice || item.property?.totalPrice,
+      status: item.status,
+      feedbackReason: item.feedback_reason || item.feedbackReason || '',
+    }));
+    return [
+      '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库、规则和房源数据。',
+      '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。',
+      '严格按照 JSON Schema 输出;reply 面向客户,reason 仅供内部审核。',
+      '【上下文边界】下面的“本轮有效会话”是本轮唯一可信的客户对话。即使当前 Claude Code Session 曾经出现过其他客户原话,也不得引用未在本轮有效会话中重复出现的内容。',
+      '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断购买数量、预算用途、投资意图等高影响需求,必须先向客户确认。',
+      'reply 使用适合企微的简洁纯文本,不输出 Markdown 星号、标题语法或内部推理。客户给出明确事实时,在 profileUpdates 中同步记录;不明确的字段标记待确认。',
+      '同时输出内部 tasks 和 alerts:tasks 只记录明确可执行的跟进动作,alerts 只记录有证据的高意向、时效、矛盾、投诉或人工接管风险;每项 evidence 必须来自本轮有效会话。没有就输出空数组。',
+      '',
+      `当前客户画像:${JSON.stringify(profile)}`,
+      `当前未完成待办:${JSON.stringify(activeTasks.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, reason: item.reason })))}`,
+      `当前未解决预警:${JSON.stringify(activeAlerts.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, detail: item.detail })))}`,
+      `房源推荐与反馈历史:${JSON.stringify(recommendations)}`,
+      '不要重复推荐已明确标记 rejected 的房源;已推荐但未反馈的房源应优先询问感受,不能把“已推荐”说成“客户感兴趣”。',
+      '不要重复创建语义相同的待办或预警;如需引用已有项,沿用其 key。画像事实变化时,优先更新已有业务项。',
+      '',
+      '本轮有效会话:',
+      history || `客户:${String(context.inboundContent || '')}`,
+    ].join('\n');
+  }
+
+  runProcess(args, input = '') {
+    if (!this.isConfigured()) throw new AgentNotConfiguredError();
+    return new Promise((resolve, reject) => {
+      const child = spawn(this.executable, args, {
+        cwd: this.workdir,
+        env: { ...process.env, NO_COLOR: '1' },
+        windowsHide: true,
+        stdio: ['pipe', 'pipe', 'pipe'],
+      });
+      let stdout = '';
+      let stderr = '';
+      let finished = false;
+      const maxBuffer = 8 * 1024 * 1024;
+      const timer = setTimeout(() => {
+        if (finished) return;
+        child.kill('SIGTERM');
+        reject(new Error('Claude Code 处理超时,已转人工审核'));
+      }, Number(this.config.claudeTimeoutMs || 120000));
+
+      child.stdout.on('data', chunk => {
+        stdout += chunk.toString('utf8');
+        if (stdout.length > maxBuffer) child.kill('SIGTERM');
+      });
+      child.stderr.on('data', chunk => {
+        stderr += chunk.toString('utf8');
+        if (stderr.length > maxBuffer) child.kill('SIGTERM');
+      });
+      child.stdin.on('error', () => {});
+      child.stdin.end(String(input || ''), 'utf8');
+      child.once('error', error => {
+        if (finished) return;
+        finished = true;
+        clearTimeout(timer);
+        reject(error);
+      });
+      child.once('close', code => {
+        if (finished) return;
+        finished = true;
+        clearTimeout(timer);
+        if (code !== 0) {
+          const safeError = stderr
+            .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
+            .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
+            .trim()
+            .slice(0, 300);
+          let structuredError = '';
+          try {
+            const failedPayload = JSON.parse(stdout.trim());
+            structuredError = String(failedPayload.error || failedPayload.message || failedPayload.result || failedPayload.subtype || '').trim().slice(0, 300);
+          } catch {}
+          const detail = safeError || structuredError;
+          return reject(new Error(`Claude Code 调用失败(退出码 ${code})${detail ? `:${detail}` : ''}`));
+        }
+        try { resolve(JSON.parse(stdout.trim())); }
+        catch { reject(new Error(`Claude Code 未返回有效 JSON${stderr ? ',请检查 Fmode 配置' : ''}`)); }
+      });
+    });
+  }
+
+  async invoke(messages, context, session) {
+    const system = messages.find(message => message.role === 'system')?.content || '';
+    const prompt = `${system}\n\n${this.buildPrompt(messages, context)}`;
+    const args = [
+      '--print',
+      '--output-format', 'json',
+      '--permission-mode', 'dontAsk',
+      '--tools', String(this.config.claudeTools || 'Read,Glob,Grep'),
+      '--model', String(this.config.model || 'sonnet'),
+      '--max-budget-usd', String(this.config.claudeMaxBudgetUsd || 0.25),
+      '--json-schema', JSON.stringify(this.outputSchema()),
+      '--name', session.displayName || buildClaudeSessionName(context, this.sessionKey(context)),
+    ];
+    for (const dir of this.config.claudeAddDirs || []) {
+      if (dir && fs.existsSync(dir)) args.push('--add-dir', path.resolve(dir));
+    }
+    if (session.initialized) args.push('--resume', session.id);
+    else args.push('--session-id', session.id);
+
+    const payload = await this.runProcess(args, prompt);
+    if (payload.is_error) {
+      const subtype = String(payload.subtype || 'unknown');
+      const error = new Error(`Claude Code/Fmode 暂时不可用(${subtype})`);
+      error.subtype = subtype;
+      throw error;
+    }
+    const structured = payload.structured_output ?? payload.result;
+    if (structured === undefined || structured === null || structured === '') {
+      throw new Error('Claude Code 没有返回客服草稿');
+    }
+    return {
+      content: typeof structured === 'string' ? structured : JSON.stringify(structured),
+      claudeCode: {
+        model: this.config.model,
+        sessionName: session.displayName || buildClaudeSessionName(context, this.sessionKey(context)),
+        durationMs: Number(payload.duration_ms || 0),
+        costUsd: Number(payload.total_cost_usd || 0),
+        resumed: Boolean(session.initialized),
+      },
+    };
+  }
+
+  async complete(messages, tools, context = {}) {
+    const key = this.sessionKey(context);
+    const metadata = {
+      customerName: context.conversation?.contact_name || context.conversation?.displayName || '',
+      displayName: buildClaudeSessionName(context, key),
+    };
+    const previous = this.queues.get(key) || Promise.resolve();
+    const current = previous.catch(() => {}).then(async () => {
+      let session = this.sessionStore.ensure(key, metadata);
+      try {
+        const result = await this.invoke(messages, context, session);
+        this.sessionStore.markInitialized(key);
+        return result;
+      } catch (error) {
+        if (/session|conversation|resume/i.test(`${error.message} ${error.subtype || ''}`)) {
+          session = this.sessionStore.reset(key, metadata);
+          const result = await this.invoke(messages, context, session);
+          this.sessionStore.markInitialized(key);
+          return result;
+        }
+        throw error;
+      }
+    });
+    this.queues.set(key, current);
+    try { return await current; }
+    finally { if (this.queues.get(key) === current) this.queues.delete(key); }
+  }
+}
+
+function parseFinal(content) {
+  const text = String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+  try { return JSON.parse(text); } catch {
+    return {
+      reply: text,
+      confidence: 0.5,
+      intent: 'unknown',
+      reason: '模型未返回结构化结果,必须人工审核',
+      requiresHuman: true,
+      profileUpdates: {},
+    };
+  }
+}
+
+function clamp(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }
+
+class QiweiAgentRuntime {
+  constructor({ config, knowledge, modelClient = null }) {
+    this.config = config;
+    this.knowledge = knowledge;
+    this.modelClient = modelClient || (config.provider === 'claude-code'
+      ? new ClaudeCodeClient(config)
+      : config.provider === 'anthropic'
+        ? new AnthropicCompatibleClient(config)
+        : new OpenAICompatibleClient(config));
+  }
+
+  tools() {
+    return [
+      {
+        type: 'function',
+        function: {
+          name: 'search_knowledge',
+          description: '检索企业规则、FAQ 和沟通 Playbook。',
+          parameters: {
+            type: 'object',
+            properties: { query: { type: 'string' }, limit: { type: 'integer' } },
+            required: ['query'],
+          },
+        },
+      },
+      {
+        type: 'function',
+        function: {
+          name: 'search_properties',
+          description: '按客户明确需求查询已接入的房源数据。数据源未配置或仅为演示数据时会明确返回警告。',
+          parameters: {
+            type: 'object',
+            properties: {
+              query: { type: 'string' },
+              district: { type: 'string' },
+              budgetMax: { type: 'number' },
+              rooms: { type: 'integer' },
+              decoration: { type: 'string' },
+              orientation: { type: 'string' },
+              schoolRequired: { type: 'boolean' },
+            },
+          },
+        },
+      },
+      {
+        type: 'function',
+        function: {
+          name: 'get_customer_profile',
+          description: '读取当前客户画像与标签。',
+          parameters: { type: 'object', properties: {} },
+        },
+      },
+    ];
+  }
+
+  async executeTool(name, args, context) {
+    if (name === 'search_knowledge') return this.knowledge.search(args.query, args.limit || 5);
+    if (name === 'search_properties') return this.knowledge.searchProperties(args);
+    if (name === 'get_customer_profile') return context.profile || { profile: {}, tags: [] };
+    return { error: `未知工具 ${name}` };
+  }
+
+  async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent }) {
+    const history = messages.slice(-16).map(message => ({
+      role: message.direction === 'inbound' ? 'user' : 'assistant',
+      content: message.content,
+    }));
+    const recommendationContext = (customerIntelligence.recommendations || []).map(item => ({ propertyId: item.property_id || item.propertyId, community: item.property_snapshot?.community || item.property?.community, totalPrice: item.property_snapshot?.totalPrice || item.property?.totalPrice, status: item.status, feedbackReason: item.feedback_reason || item.feedbackReason || '' }));
+    const system = [
+      '你是企业微信客户服务 Agent。你需要自主判断是否检索知识、客户画像或房源工具,再生成回复草稿。',
+      '严禁编造业务事实。工具没有证据时明确说需要确认。敏感或低置信内容标记 requiresHuman=true。',
+      '最终必须只输出 JSON,包含 reply、confidence、intent、reason、requiresHuman、profileUpdates、tasks 和 alerts。',
+      'reason 用于内部监管台,简要说明依据和不确定性,不要发送给客户。',
+      `历史房源推荐与反馈:${JSON.stringify(recommendationContext)}`,
+      '不得重复推荐已明确 rejected 的房源;recommended 只代表已发给客户,不能推断客户感兴趣。',
+      '',
+      '必须遵守的规则:',
+      this.knowledge.rulesText(),
+    ].join('\n');
+    const llmMessages = [{ role: 'system', content: system }, ...history];
+    const toolTrace = [];
+    const citations = [];
+
+    for (let round = 0; round < this.config.maxToolRounds; round += 1) {
+      const assistant = await this.modelClient.complete(llmMessages, this.tools(), {
+        conversation,
+        profile,
+        customerIntelligence,
+        inboundContent,
+      });
+      if (assistant.claudeCode) {
+        toolTrace.push({
+          tool: 'claude_code_session',
+          args: { provider: 'Fmode Studio', model: assistant.claudeCode.model },
+          result: {
+            sessionName: assistant.claudeCode.sessionName,
+            durationMs: assistant.claudeCode.durationMs,
+            costUsd: assistant.claudeCode.costUsd,
+            resumed: assistant.claudeCode.resumed,
+          },
+        });
+      }
+      if (assistant.tool_calls?.length) {
+        llmMessages.push({ role: 'assistant', content: assistant.content || '', tool_calls: assistant.tool_calls });
+        for (const call of assistant.tool_calls) {
+          let args = {};
+          try { args = JSON.parse(call.function.arguments || '{}'); } catch {}
+          const result = await this.executeTool(call.function.name, args, { conversation, profile, customerIntelligence });
+          toolTrace.push({ tool: call.function.name, args, result });
+          if (call.function.name === 'search_knowledge') {
+            for (const item of result) citations.push({ id: item.id, source: item.source, heading: item.heading });
+          }
+          if (call.function.name === 'search_properties' && result.dataSource) {
+            citations.push({
+              id: `properties:${result.dataSource}`,
+              source: result.dataSource,
+              heading: `${result.dataLabel || '房源数据'}查询 ${result.total} 条`,
+            });
+          }
+          llmMessages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
+        }
+        continue;
+      }
+
+      const parsedFinal = parseFinal(assistant.content);
+      const authoritativeHistory = selectAuthoritativeHistory(history, 10);
+      const currentProfile = profile?.profile || profile || {};
+      const final = enforceAuthoritativeGrounding(parsedFinal, authoritativeHistory, currentProfile, inboundContent);
+      const intelligence = extractExplicitCustomerIntelligence(inboundContent, currentProfile, final);
+      const risky = RISK_PATTERN.test(inboundContent) || RISK_PATTERN.test(final.reply || '');
+      const confidence = risky ? Math.min(clamp(final.confidence), 0.75) : clamp(final.confidence);
+      return {
+        content: String(final.reply || '').trim(),
+        confidence,
+        intent: String(final.intent || 'unknown'),
+        reason: String(final.reason || 'Agent 未提供说明'),
+        requiresHuman: Boolean(final.requiresHuman || risky || confidence < 0.7),
+        profileUpdates: intelligence.profileUpdates,
+        tasks: intelligence.tasks,
+        alerts: intelligence.alerts,
+        citations: [...new Map(citations.map(item => [item.id, item])).values()],
+        toolTrace,
+      };
+    }
+    throw new Error('Agent 工具调用轮次超过上限,已转人工处理');
+  }
+}
+
+module.exports = {
+  AgentNotConfiguredError,
+  OpenAICompatibleClient,
+  AnthropicCompatibleClient,
+  ClaudeCodeClient,
+  ClaudeCodeSessionStore,
+  buildClaudeSessionName,
+  selectAuthoritativeHistory,
+  enforceAuthoritativeGrounding,
+  extractExplicitCustomerIntelligence,
+  resolveClaudeExecutable,
+  QiweiAgentRuntime,
+};

+ 68 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-session-guide.js

@@ -0,0 +1,68 @@
+const fs = require('fs');
+const path = require('path');
+const { latestPath } = require('./output-paths');
+const { buildClaudeSessionName } = require('./agent-runtime');
+
+const PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..');
+
+function sessionCommandPrefix() {
+  const pluginsDir = path.dirname(PACKAGE_ROOT);
+  const claudeDir = path.dirname(pluginsDir);
+  if (path.basename(pluginsDir) === 'plugins' && path.basename(claudeDir) === '.claude') {
+    return `npm --prefix ".claude/plugins/${path.basename(PACKAGE_ROOT)}" run`;
+  }
+  return 'npm run';
+}
+
+function safeCustomerArgument(value) {
+  return String(value || '企微客户')
+    .replace(/[^\p{L}\p{N} _.-]/gu, '')
+    .trim()
+    .slice(0, 60) || '企微客户';
+}
+
+function readSessionState(filePath = latestPath('messages', 'claude-code-sessions.json')) {
+  try {
+    const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+    parsed.sessions ||= {};
+    parsed.project ||= {};
+    return parsed;
+  } catch {
+    return { version: 1, project: {}, sessions: {} };
+  }
+}
+
+function getCustomerSessionGuide(conversation = {}, options = {}) {
+  const conversationId = String(conversation.id || conversation.conversationId || '');
+  const customerName = String(conversation.contact_name || conversation.displayName || '企微客户').trim();
+  const state = options.state || readSessionState(options.sessionFile);
+  const session = state.sessions?.[conversationId] || null;
+  const displayName = session?.displayName || buildClaudeSessionName({
+    conversation: { contact_name: customerName },
+  }, conversationId || customerName);
+  const safeName = safeCustomerArgument(customerName);
+  return {
+    detected: Boolean(session),
+    ready: Boolean(session?.initialized),
+    status: !session ? 'not_created' : session.initialized ? 'ready' : 'waiting_first_run',
+    customerName,
+    displayName,
+    openLocation: 'Fmode Studio → 当前企微项目 → 终端',
+    openCommand: `${sessionCommandPrefix()} agent:session -- --customer "${safeName}"`,
+    openMode: 'forked-review',
+    productionSessionProtected: true,
+    guidance: session?.initialized
+      ? '已检测到客户专属 Claude Code Session。运行打开命令后会 fork 审阅副本,可查看完整历史、模型思考和工具记录。'
+      : session
+        ? '客户专属 Session 已分配,首次生成草稿后即可打开完整 Claude Code 对话。'
+        : '该客户尚未创建 Claude Code Session;首次运行 Agent 后系统会自动创建并显示打开方式。',
+    updatedAt: session?.updatedAt || session?.createdAt || null,
+  };
+}
+
+module.exports = {
+  safeCustomerArgument,
+  sessionCommandPrefix,
+  readSessionState,
+  getCustomerSessionGuide,
+};

+ 717 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-db.js

@@ -0,0 +1,717 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { DatabaseSync } = require('node:sqlite');
+
+const now = () => new Date().toISOString();
+const makeId = prefix => `${prefix}_${crypto.randomUUID()}`;
+const json = value => JSON.stringify(value ?? null);
+const parse = (value, fallback) => {
+  try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
+};
+
+const normalizeKeyPart = value => String(value || '')
+  .trim()
+  .toLowerCase()
+  .replace(/[\s\p{P}\p{S}]+/gu, '_')
+  .replace(/^_+|_+$/g, '')
+  .slice(0, 160);
+
+function evidenceItems(value, fallbackText = '', sourceMessageId = null) {
+  const rows = Array.isArray(value) ? value : [];
+  const items = rows.map(item => typeof item === 'string'
+    ? { text: item, sourceMessageId: null }
+    : {
+        text: String(item?.text || item?.evidence || '').trim(),
+        sourceMessageId: item?.sourceMessageId || item?.source_message_id || null,
+        createdAt: item?.createdAt || item?.created_at || null,
+      }).filter(item => item.text);
+  const text = String(fallbackText || '').trim();
+  if (text) items.push({ text, sourceMessageId: sourceMessageId || null, createdAt: now() });
+  const unique = new Map();
+  for (const item of items) {
+    const key = `${item.sourceMessageId || ''}\u0000${item.text}`;
+    if (!unique.has(key)) unique.set(key, item);
+  }
+  return [...unique.values()].slice(-30);
+}
+
+class AgentWorkbenchDb {
+  constructor(filePath, defaults = {}) {
+    fs.mkdirSync(path.dirname(filePath), { recursive: true });
+    this.filePath = filePath;
+    this.db = new DatabaseSync(filePath);
+    this.db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
+    this.init(defaults);
+  }
+
+  init(defaults) {
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS settings (
+        key TEXT PRIMARY KEY,
+        value TEXT NOT NULL,
+        updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS conversations (
+        id TEXT PRIMARY KEY,
+        contact_id TEXT NOT NULL UNIQUE,
+        contact_name TEXT NOT NULL DEFAULT '',
+        mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','human','paused')),
+        last_message_at TEXT,
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS messages (
+        id TEXT PRIMARY KEY,
+        conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+        external_id TEXT UNIQUE,
+        direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
+        sender_type TEXT NOT NULL CHECK(sender_type IN ('customer','agent','human','system')),
+        content TEXT NOT NULL,
+        status TEXT NOT NULL DEFAULT 'received',
+        created_at TEXT NOT NULL,
+        raw_json TEXT
+      );
+      CREATE TABLE IF NOT EXISTS customer_profiles (
+        conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
+        profile_json TEXT NOT NULL DEFAULT '{}',
+        tags_json TEXT NOT NULL DEFAULT '[]',
+        updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS customer_tasks (
+        id TEXT PRIMARY KEY,
+        conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+        fingerprint TEXT NOT NULL,
+        type TEXT NOT NULL DEFAULT 'follow_up',
+        title TEXT NOT NULL,
+        owner TEXT NOT NULL DEFAULT '',
+        due_at TEXT NOT NULL DEFAULT '',
+        priority TEXT NOT NULL DEFAULT 'medium',
+        status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','in_progress','done','dismissed')),
+        reason TEXT NOT NULL DEFAULT '',
+        evidence TEXT NOT NULL DEFAULT '',
+        source_message_id TEXT,
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL,
+        UNIQUE(conversation_id, fingerprint)
+      );
+      CREATE TABLE IF NOT EXISTS customer_alerts (
+        id TEXT PRIMARY KEY,
+        conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+        fingerprint TEXT NOT NULL,
+        type TEXT NOT NULL DEFAULT 'attention',
+        severity TEXT NOT NULL DEFAULT 'medium',
+        title TEXT NOT NULL,
+        detail TEXT NOT NULL DEFAULT '',
+        evidence TEXT NOT NULL DEFAULT '',
+        recommended_action TEXT NOT NULL DEFAULT '',
+        status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','acknowledged','resolved','dismissed')),
+        source_message_id TEXT,
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL,
+        UNIQUE(conversation_id, fingerprint)
+      );
+      CREATE TABLE IF NOT EXISTS customer_recommendations (
+        id TEXT PRIMARY KEY,
+        conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+        property_id TEXT NOT NULL,
+        property_snapshot_json TEXT NOT NULL DEFAULT '{}',
+        sources_json TEXT NOT NULL DEFAULT '[]',
+        status TEXT NOT NULL DEFAULT 'recommended' CHECK(status IN ('candidate','recommended','interested','rejected','viewing','viewed','closed')),
+        feedback_reason TEXT NOT NULL DEFAULT '',
+        recommend_count INTEGER NOT NULL DEFAULT 1,
+        first_recommended_at TEXT NOT NULL,
+        last_recommended_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL,
+        UNIQUE(conversation_id, property_id)
+      );
+      CREATE TABLE IF NOT EXISTS drafts (
+        id TEXT PRIMARY KEY,
+        conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+        inbound_message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
+        content TEXT NOT NULL,
+        status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','sent','failed')),
+        confidence REAL NOT NULL DEFAULT 0,
+        intent TEXT NOT NULL DEFAULT '',
+        reason TEXT NOT NULL DEFAULT '',
+        requires_human INTEGER NOT NULL DEFAULT 1,
+        citations_json TEXT NOT NULL DEFAULT '[]',
+        tool_trace_json TEXT NOT NULL DEFAULT '[]',
+        created_at TEXT NOT NULL,
+        reviewed_at TEXT,
+        reviewer TEXT,
+        error TEXT,
+        sent_message_id TEXT
+      );
+      CREATE TABLE IF NOT EXISTS audit_logs (
+        id TEXT PRIMARY KEY,
+        actor TEXT NOT NULL,
+        action TEXT NOT NULL,
+        conversation_id TEXT,
+        entity_id TEXT,
+        detail_json TEXT NOT NULL DEFAULT '{}',
+        created_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS poll_state (
+        key TEXT PRIMARY KEY,
+        value TEXT NOT NULL,
+        updated_at TEXT NOT NULL
+      );
+      CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
+      CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(conversation_id, direction, content, created_at);
+      CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts(status, created_at);
+      CREATE INDEX IF NOT EXISTS idx_customer_tasks_conversation ON customer_tasks(conversation_id, status, updated_at DESC);
+      CREATE INDEX IF NOT EXISTS idx_customer_alerts_conversation ON customer_alerts(conversation_id, status, updated_at DESC);
+      CREATE INDEX IF NOT EXISTS idx_customer_recommendations_conversation ON customer_recommendations(conversation_id, status, last_recommended_at DESC);
+      CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at DESC);
+    `);
+    this.ensureColumn('customer_tasks', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
+    this.ensureColumn('customer_tasks', 'managed_by', "managed_by TEXT NOT NULL DEFAULT 'agent'");
+    this.ensureColumn('customer_tasks', 'evidence_json', "evidence_json TEXT NOT NULL DEFAULT '[]'");
+    this.ensureColumn('customer_tasks', 'resolution_reason', "resolution_reason TEXT NOT NULL DEFAULT ''");
+    this.ensureColumn('customer_tasks', 'official_todo_id', "official_todo_id TEXT NOT NULL DEFAULT ''");
+    this.ensureColumn('customer_tasks', 'official_sync_status', "official_sync_status TEXT NOT NULL DEFAULT ''");
+    this.ensureColumn('customer_tasks', 'official_synced_at', 'official_synced_at TEXT');
+    this.ensureColumn('customer_alerts', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
+    this.ensureColumn('customer_alerts', 'managed_by', "managed_by TEXT NOT NULL DEFAULT 'agent'");
+    this.ensureColumn('customer_alerts', 'evidence_json', "evidence_json TEXT NOT NULL DEFAULT '[]'");
+    this.ensureColumn('customer_alerts', 'resolution_reason', "resolution_reason TEXT NOT NULL DEFAULT ''");
+    this.lastIntelligenceMigration = this.migrateCustomerIntelligenceRecords();
+    this.db.exec(`
+      CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
+      CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
+    `);
+    this.setDefault('global_paused', defaults.globalPaused ? 'true' : 'false');
+    this.setDefault('default_mode', defaults.defaultMode || 'review');
+    this.setDefault('auto_send_confidence', String(defaults.autoSendConfidence ?? 0.88));
+    this.setDefault('agent_cutover_at', defaults.cutoverAt || now());
+  }
+
+  close() { this.db.close(); }
+
+  ensureColumn(table, column, definition) {
+    const exists = this.db.prepare(`PRAGMA table_info(${table})`).all().some(item => item.name === column);
+    if (!exists) this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
+  }
+
+  customerTaskBusinessKey(item = {}) {
+    const title = String(item.title || '').trim();
+    const type = normalizeKeyPart(item.type || 'follow_up');
+    if (type === 'qualification' && /用途.*(购置)?时间/.test(title)) return 'qualification:purpose_and_timeline';
+    if (type === 'recommendation' && /(筛选|发送).*(重点|方案)/.test(title)) return 'recommendation:shortlist';
+    const explicit = item.businessKey || item.business_key || item.taskKey || item.key;
+    if (explicit) return String(explicit).split(':').map(normalizeKeyPart).filter(Boolean).join(':');
+    return `${type}:${normalizeKeyPart(title || 'task')}`;
+  }
+
+  customerAlertBusinessKey(item = {}) {
+    const title = String(item.title || '').trim();
+    const type = normalizeKeyPart(item.type || 'attention');
+    if (type === 'high_intent' && /核心需求.*成形/.test(title)) return 'high_intent:core_demand_ready';
+    if (type === 'complaint') return 'complaint:manual_takeover';
+    if (type === 'time_sensitive') return 'time_sensitive:follow_up';
+    const explicit = item.businessKey || item.business_key || item.alertKey || item.key;
+    if (explicit) return String(explicit).split(':').map(normalizeKeyPart).filter(Boolean).join(':');
+    return `${type}:${normalizeKeyPart(title || 'alert')}`;
+  }
+
+  migrateCustomerIntelligenceRecords() {
+    const migrate = ({ table, keyFor, terminalStatus }) => {
+      const rows = this.db.prepare(`SELECT * FROM ${table} ORDER BY created_at,id`).all();
+      const groups = new Map();
+      for (const row of rows) {
+        const businessKey = keyFor.call(this, row);
+        const groupKey = `${row.conversation_id}\u0000${businessKey}`;
+        if (!groups.has(groupKey)) groups.set(groupKey, { businessKey, rows: [] });
+        groups.get(groupKey).rows.push(row);
+      }
+      let removed = 0;
+      for (const group of groups.values()) {
+        const statusRank = terminalStatus === 'done'
+          ? { dismissed: 0, open: 1, in_progress: 2, done: 3 }
+          : { dismissed: 0, open: 1, acknowledged: 2, resolved: 3 };
+        const canonical = [...group.rows].sort((a, b) =>
+          (statusRank[b.status] || 0) - (statusRank[a.status] || 0) ||
+          Date.parse(a.created_at) - Date.parse(b.created_at))[0];
+        const evidence = evidenceItems(
+          group.rows.flatMap(row => evidenceItems(parse(row.evidence_json, []), row.evidence, row.source_message_id)),
+        );
+        const duplicates = group.rows.filter(row => row.id !== canonical.id);
+        for (const row of duplicates) {
+          this.db.prepare(`DELETE FROM ${table} WHERE id=?`).run(row.id);
+          removed += 1;
+        }
+        const fingerprint = this.intelligenceFingerprint(group.businessKey);
+        this.db.prepare(`UPDATE ${table} SET business_key=?,fingerprint=?,evidence_json=?,evidence=?,updated_at=? WHERE id=?`)
+          .run(group.businessKey, fingerprint, json(evidence), evidence.at(-1)?.text || canonical.evidence || '', canonical.updated_at || now(), canonical.id);
+      }
+      return { rows: rows.length, removed };
+    };
+    this.db.exec('BEGIN IMMEDIATE');
+    try {
+      const tasks = migrate({ table: 'customer_tasks', keyFor: this.customerTaskBusinessKey, terminalStatus: 'done' });
+      const alerts = migrate({ table: 'customer_alerts', keyFor: this.customerAlertBusinessKey, terminalStatus: 'resolved' });
+      this.db.exec('COMMIT');
+      return { tasks, alerts };
+    } catch (error) {
+      this.db.exec('ROLLBACK');
+      throw error;
+    }
+  }
+
+  setDefault(key, value) {
+    this.db.prepare('INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)').run(key, value, now());
+  }
+
+  getSetting(key, fallback = '') {
+    return this.db.prepare('SELECT value FROM settings WHERE key=?').get(key)?.value ?? fallback;
+  }
+
+  setSetting(key, value) {
+    this.db.prepare(`INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)
+      ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`).run(key, String(value), now());
+  }
+
+  globalState() {
+    return {
+      paused: this.getSetting('global_paused', 'true') === 'true',
+      defaultMode: this.getSetting('default_mode', 'review'),
+      autoSendConfidence: Number(this.getSetting('auto_send_confidence', '0.88')),
+    };
+  }
+
+  ensureConversation(contactId, contactName = '') {
+    const existing = this.db.prepare('SELECT * FROM conversations WHERE contact_id=?').get(String(contactId));
+    if (existing) {
+      if (contactName && existing.contact_name !== contactName) {
+        this.db.prepare('UPDATE conversations SET contact_name=?,updated_at=? WHERE id=?').run(contactName, now(), existing.id);
+      }
+      return this.getConversation(existing.id);
+    }
+    const conversationId = makeId('conv');
+    const timestamp = now();
+    this.db.prepare(`INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at)
+      VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), contactName, this.getSetting('default_mode', 'review'), timestamp, timestamp);
+    this.db.prepare('INSERT INTO customer_profiles(conversation_id,updated_at) VALUES(?,?)').run(conversationId, timestamp);
+    return this.getConversation(conversationId);
+  }
+
+  getConversationByContactId(contactId) {
+    return this.db.prepare('SELECT * FROM conversations WHERE contact_id=?').get(String(contactId)) || null;
+  }
+
+  getConversation(conversationId) {
+    return this.db.prepare('SELECT * FROM conversations WHERE id=?').get(conversationId) || null;
+  }
+
+  listConversations() {
+    return this.db.prepare(`SELECT c.*,
+      (SELECT content FROM messages m WHERE m.conversation_id=c.id ORDER BY m.created_at DESC LIMIT 1) AS last_content,
+      (SELECT COUNT(*) FROM drafts d WHERE d.conversation_id=c.id AND d.status='pending') AS pending_count
+      FROM conversations c ORDER BY COALESCE(c.last_message_at,c.created_at) DESC`).all();
+  }
+
+  setConversationMode(conversationId, mode) {
+    if (!['review', 'auto', 'human', 'paused'].includes(mode)) throw new Error('不支持的会话模式');
+    const result = this.db.prepare('UPDATE conversations SET mode=?,updated_at=? WHERE id=?').run(mode, now(), conversationId);
+    if (!result.changes) throw new Error('会话不存在');
+    return this.getConversation(conversationId);
+  }
+
+  insertMessage({ conversationId, externalId = null, direction, senderType, content, status = 'received', createdAt = now(), raw = null }) {
+    if (externalId) {
+      const existing = this.db.prepare('SELECT * FROM messages WHERE external_id=?').get(externalId);
+      if (existing) return { message: existing, created: false };
+    }
+    const messageId = makeId('msg');
+    this.db.prepare(`INSERT INTO messages(id,conversation_id,external_id,direction,sender_type,content,status,created_at,raw_json)
+      VALUES(?,?,?,?,?,?,?,?,?)`).run(messageId, conversationId, externalId, direction, senderType, String(content), status, createdAt, raw ? json(raw) : null);
+    this.db.prepare('UPDATE conversations SET last_message_at=?,updated_at=? WHERE id=?').run(createdAt, now(), conversationId);
+    return { message: this.getMessage(messageId), created: true };
+  }
+
+  getMessage(messageId) { return this.db.prepare('SELECT * FROM messages WHERE id=?').get(messageId) || null; }
+
+  getLatestInbound(conversationId) {
+    return this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' ORDER BY created_at DESC LIMIT 1").get(conversationId) || null;
+  }
+
+  findRecentInboundDuplicate(conversationId, content, createdAt, windowSeconds = 60) {
+    const rows = this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' AND content=? ORDER BY created_at DESC LIMIT 20").all(conversationId, String(content));
+    const timestamp = Date.parse(createdAt);
+    return rows.find(row => Number.isFinite(timestamp) && Math.abs(timestamp - Date.parse(row.created_at)) <= windowSeconds * 1000) || null;
+  }
+
+  cleanupInboundContentDuplicates(windowSeconds = 60) {
+    const rows = this.db.prepare("SELECT * FROM messages WHERE direction='inbound' ORDER BY conversation_id,created_at,id").all();
+    const lastSeen = new Map();
+    const duplicateIds = [];
+    for (const row of rows) {
+      const key = `${row.conversation_id}\u0000${row.content}`;
+      const previous = lastSeen.get(key);
+      const timestamp = Date.parse(row.created_at);
+      if (previous && Number.isFinite(timestamp) && Math.abs(timestamp - previous.timestamp) <= windowSeconds * 1000) duplicateIds.push(row.id);
+      else lastSeen.set(key, { timestamp, id: row.id });
+    }
+    const remove = this.db.prepare('DELETE FROM messages WHERE id=?');
+    for (const messageId of duplicateIds) remove.run(messageId);
+    return duplicateIds.length;
+  }
+
+  listMessages(conversationId, limit = 100) {
+    return this.db.prepare(`SELECT * FROM (
+      SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT ?
+    ) ORDER BY created_at ASC`).all(conversationId, limit);
+  }
+
+  deleteImportedMessages(conversationId, source = 'manual_sync') {
+    const pattern = `%\"source\":\"${String(source).replace(/[\"%]/g, '')}\"%`;
+    const result = this.db.prepare('DELETE FROM messages WHERE conversation_id=? AND raw_json LIKE ?').run(conversationId, pattern);
+    const latest = this.db.prepare('SELECT MAX(created_at) AS value FROM messages WHERE conversation_id=?').get(conversationId)?.value || null;
+    this.db.prepare('UPDATE conversations SET last_message_at=?,updated_at=? WHERE id=?').run(latest, now(), conversationId);
+    return Number(result.changes || 0);
+  }
+
+  getProfile(conversationId) {
+    const row = this.db.prepare('SELECT * FROM customer_profiles WHERE conversation_id=?').get(conversationId);
+    return row ? { profile: parse(row.profile_json, {}), tags: parse(row.tags_json, []), updatedAt: row.updated_at } : { profile: {}, tags: [] };
+  }
+
+  updateProfile(conversationId, profile, tags = []) {
+    this.db.prepare(`INSERT INTO customer_profiles(conversation_id,profile_json,tags_json,updated_at) VALUES(?,?,?,?)
+      ON CONFLICT(conversation_id) DO UPDATE SET profile_json=excluded.profile_json,tags_json=excluded.tags_json,updated_at=excluded.updated_at`)
+      .run(conversationId, json(profile || {}), json(tags || []), now());
+    return this.getProfile(conversationId);
+  }
+
+  mergeProfileByContactId(contactId, patch = {}, tags) {
+    const conversation = this.getConversationByContactId(contactId);
+    if (!conversation) return null;
+    const current = this.getProfile(conversation.id);
+    return this.updateProfile(
+      conversation.id,
+      { ...current.profile, ...(patch || {}) },
+      tags === undefined ? current.tags : tags,
+    );
+  }
+
+  intelligenceFingerprint(...parts) {
+    return crypto.createHash('sha256').update(parts.map(item => String(item || '').trim().toLowerCase()).join('\u0000')).digest('hex').slice(0, 24);
+  }
+
+  upsertCustomerTasks(conversationId, tasks = [], sourceMessageId = null) {
+    const results = [];
+    const find = this.db.prepare('SELECT * FROM customer_tasks WHERE conversation_id=? AND business_key=?');
+    const insert = this.db.prepare(`INSERT INTO customer_tasks(id,conversation_id,fingerprint,business_key,managed_by,type,title,owner,due_at,priority,status,reason,evidence,evidence_json,source_message_id,created_at,updated_at)
+      VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
+    const update = this.db.prepare(`UPDATE customer_tasks SET managed_by=?,type=?,title=?,owner=CASE WHEN owner='' THEN ? ELSE owner END,due_at=CASE WHEN due_at='' THEN ? ELSE due_at END,priority=?,reason=?,evidence=?,evidence_json=?,source_message_id=COALESCE(?,source_message_id),updated_at=? WHERE id=?`);
+    for (const item of tasks) {
+      const title = String(item?.title || '').trim();
+      if (!title) continue;
+      const type = String(item.type || 'follow_up').trim();
+      const evidence = String(item.evidence || '').trim();
+      const businessKey = this.customerTaskBusinessKey(item);
+      const fingerprint = this.intelligenceFingerprint(businessKey);
+      const managedBy = String(item.managedBy || item.managed_by || 'agent').trim();
+      const itemSourceMessageId = Object.prototype.hasOwnProperty.call(item, 'sourceMessageId')
+        ? item.sourceMessageId
+        : sourceMessageId;
+      const existing = find.get(conversationId, businessKey);
+      if (existing) {
+        const evidences = evidenceItems(parse(existing.evidence_json, []), itemSourceMessageId ? evidence : '', itemSourceMessageId);
+        update.run(managedBy, type, title, String(item.owner || '').trim().replace(/^待分配$/, ''), String(item.dueAt || item.due_at || '').trim(), String(item.priority || 'medium').trim(), String(item.reason || '').trim(), evidence || existing.evidence || '', json(evidences), itemSourceMessageId, now(), existing.id);
+        results.push(this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(existing.id));
+      } else {
+        const id = makeId('task');
+        const evidences = evidenceItems([], evidence, itemSourceMessageId);
+        insert.run(id, conversationId, fingerprint, businessKey, managedBy, type, title, String(item.owner || '').trim().replace(/^待分配$/, ''), String(item.dueAt || item.due_at || '').trim(), String(item.priority || 'medium').trim(), 'open', String(item.reason || '').trim(), evidence, json(evidences), itemSourceMessageId, now(), now());
+        results.push(this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(id));
+      }
+    }
+    return results;
+  }
+
+  reconcileCustomerTasks(conversationId, tasks = [], sourceMessageId = null) {
+    const results = this.upsertCustomerTasks(conversationId, tasks, sourceMessageId);
+    const activeRuleKeys = new Set(tasks
+      .filter(item => String(item.managedBy || item.managed_by || 'agent') === 'rule')
+      .map(item => this.customerTaskBusinessKey(item)));
+    const existingRules = this.db.prepare("SELECT * FROM customer_tasks WHERE conversation_id=? AND managed_by='rule' AND status IN ('open','in_progress')").all(conversationId);
+    const resolved = [];
+    for (const task of existingRules) {
+      if (activeRuleKeys.has(task.business_key)) continue;
+      this.db.prepare(`UPDATE customer_tasks SET status='done',resolution_reason='profile_condition_resolved',
+        official_sync_status=CASE WHEN official_todo_id<>'' THEN 'completion_pending' ELSE official_sync_status END,updated_at=? WHERE id=?`).run(now(), task.id);
+      resolved.push(task.id);
+    }
+    return { tasks: results, resolved };
+  }
+
+  listCustomerTasks(conversationId, limit = 100) {
+    return this.db.prepare(`SELECT * FROM customer_tasks WHERE conversation_id=?
+      ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'in_progress' THEN 1 ELSE 2 END,
+      CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, updated_at DESC LIMIT ?`).all(conversationId, limit);
+  }
+
+  getCustomerTask(taskId) {
+    return this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(taskId) || null;
+  }
+
+  completeCustomerTaskByBusinessKey(conversationId, businessKey, reason = 'business_action_completed') {
+    const key = this.customerTaskBusinessKey({ businessKey });
+    const task = this.db.prepare('SELECT * FROM customer_tasks WHERE conversation_id=? AND business_key=?').get(conversationId, key);
+    if (!task || !['open', 'in_progress'].includes(task.status)) return task || null;
+    this.db.prepare(`UPDATE customer_tasks SET status='done',resolution_reason=?,
+      official_sync_status=CASE WHEN official_todo_id<>'' THEN 'completion_pending' ELSE official_sync_status END,updated_at=? WHERE id=?`).run(reason, now(), task.id);
+    return this.getCustomerTask(task.id);
+  }
+
+  updateCustomerTask(taskId, fields = {}) {
+    const allowed = ['status', 'owner', 'due_at', 'priority', 'reason', 'resolution_reason', 'official_todo_id', 'official_sync_status', 'official_synced_at'];
+    const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
+    if (entries.length) {
+      const assignments = entries.map(([key]) => `${key}=?`).join(',');
+      this.db.prepare(`UPDATE customer_tasks SET ${assignments},updated_at=? WHERE id=?`).run(...entries.map(([, value]) => value), now(), taskId);
+    }
+    return this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(taskId) || null;
+  }
+
+  upsertCustomerAlerts(conversationId, alerts = [], sourceMessageId = null) {
+    const results = [];
+    const find = this.db.prepare('SELECT * FROM customer_alerts WHERE conversation_id=? AND business_key=?');
+    const insert = this.db.prepare(`INSERT INTO customer_alerts(id,conversation_id,fingerprint,business_key,managed_by,type,severity,title,detail,evidence,evidence_json,recommended_action,status,source_message_id,created_at,updated_at)
+      VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
+    const update = this.db.prepare(`UPDATE customer_alerts SET managed_by=?,type=?,severity=?,title=?,detail=?,evidence=?,evidence_json=?,recommended_action=?,source_message_id=COALESCE(?,source_message_id),updated_at=? WHERE id=?`);
+    for (const item of alerts) {
+      const title = String(item?.title || '').trim();
+      if (!title) continue;
+      const type = String(item.type || 'attention').trim();
+      const evidence = String(item.evidence || '').trim();
+      const businessKey = this.customerAlertBusinessKey(item);
+      const fingerprint = this.intelligenceFingerprint(businessKey);
+      const managedBy = String(item.managedBy || item.managed_by || 'agent').trim();
+      const itemSourceMessageId = Object.prototype.hasOwnProperty.call(item, 'sourceMessageId')
+        ? item.sourceMessageId
+        : sourceMessageId;
+      const existing = find.get(conversationId, businessKey);
+      if (existing) {
+        const evidences = evidenceItems(parse(existing.evidence_json, []), itemSourceMessageId ? evidence : '', itemSourceMessageId);
+        update.run(managedBy, type, String(item.severity || 'medium').trim(), title, String(item.detail || '').trim(), evidence || existing.evidence || '', json(evidences), String(item.recommendedAction || item.recommended_action || '').trim(), itemSourceMessageId, now(), existing.id);
+        results.push(this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(existing.id));
+      } else {
+        const id = makeId('alert');
+        const evidences = evidenceItems([], evidence, itemSourceMessageId);
+        insert.run(id, conversationId, fingerprint, businessKey, managedBy, type, String(item.severity || 'medium').trim(), title, String(item.detail || '').trim(), evidence, json(evidences), String(item.recommendedAction || item.recommended_action || '').trim(), 'open', itemSourceMessageId, now(), now());
+        results.push(this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(id));
+      }
+    }
+    return results;
+  }
+
+  reconcileCustomerAlerts(conversationId, alerts = [], sourceMessageId = null) {
+    const results = this.upsertCustomerAlerts(conversationId, alerts, sourceMessageId);
+    const activeRuleKeys = new Set(alerts
+      .filter(item => String(item.managedBy || item.managed_by || 'agent') === 'rule')
+      .map(item => this.customerAlertBusinessKey(item)));
+    const existingRules = this.db.prepare("SELECT * FROM customer_alerts WHERE conversation_id=? AND managed_by='rule' AND status IN ('open','acknowledged')").all(conversationId);
+    const resolved = [];
+    for (const alert of existingRules) {
+      if (activeRuleKeys.has(alert.business_key)) continue;
+      this.db.prepare("UPDATE customer_alerts SET status='resolved',resolution_reason='profile_condition_resolved',updated_at=? WHERE id=?").run(now(), alert.id);
+      resolved.push(alert.id);
+    }
+    return { alerts: results, resolved };
+  }
+
+  listCustomerAlerts(conversationId, limit = 100) {
+    return this.db.prepare(`SELECT * FROM customer_alerts WHERE conversation_id=?
+      ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
+      CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, updated_at DESC LIMIT ?`).all(conversationId, limit);
+  }
+
+  updateCustomerAlert(alertId, fields = {}) {
+    const allowed = ['status', 'severity', 'recommended_action'];
+    const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
+    if (entries.length) {
+      const assignments = entries.map(([key]) => `${key}=?`).join(',');
+      this.db.prepare(`UPDATE customer_alerts SET ${assignments},updated_at=? WHERE id=?`).run(...entries.map(([, value]) => value), now(), alertId);
+    }
+    return this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(alertId) || null;
+  }
+
+  hydrateCustomerRecommendation(row) {
+    return row ? {
+      ...row,
+      property_snapshot: parse(row.property_snapshot_json, {}),
+      sources: parse(row.sources_json, []),
+    } : null;
+  }
+
+  upsertCustomerRecommendations(conversationId, items = [], source = {}) {
+    const find = this.db.prepare('SELECT * FROM customer_recommendations WHERE conversation_id=? AND property_id=?');
+    const insert = this.db.prepare(`INSERT INTO customer_recommendations(id,conversation_id,property_id,property_snapshot_json,sources_json,status,feedback_reason,recommend_count,first_recommended_at,last_recommended_at,updated_at)
+      VALUES(?,?,?,?,?,?,?,?,?,?,?)`);
+    const update = this.db.prepare(`UPDATE customer_recommendations SET property_snapshot_json=?,sources_json=?,status=CASE WHEN status IN ('interested','rejected','viewing','viewed','closed') THEN status ELSE ? END,recommend_count=?,last_recommended_at=?,updated_at=? WHERE id=?`);
+    const results = [];
+    for (const item of items) {
+      const propertyId = String(item?.id || item?.propertyId || item?.property_id || '').trim();
+      if (!propertyId) continue;
+      const timestamp = String(source.createdAt || item.recommendedAt || now());
+      const sourceItem = {
+        type: String(source.type || 'unknown'),
+        entityId: source.entityId || null,
+        evidence: String(source.evidence || '').trim(),
+        createdAt: timestamp,
+      };
+      const existing = find.get(conversationId, propertyId);
+      const requestedStatus = ['candidate', 'recommended'].includes(String(source.status || '')) ? String(source.status) : 'recommended';
+      if (!existing) {
+        const id = makeId('recommendation');
+        insert.run(id, conversationId, propertyId, json(item), json([sourceItem]), requestedStatus, '', 1, timestamp, timestamp, timestamp);
+        results.push(this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=?').get(id)));
+        continue;
+      }
+      const sources = parse(existing.sources_json, []);
+      const sourceKey = `${sourceItem.type}\u0000${sourceItem.entityId || ''}`;
+      const isNewSource = !sources.some(entry => `${entry.type || ''}\u0000${entry.entityId || ''}` === sourceKey);
+      if (isNewSource) sources.push(sourceItem);
+      update.run(json({ ...parse(existing.property_snapshot_json, {}), ...item }), json(sources.slice(-30)), requestedStatus, Number(existing.recommend_count || 0) + (isNewSource ? 1 : 0), isNewSource ? timestamp : existing.last_recommended_at, now(), existing.id);
+      results.push(this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=?').get(existing.id)));
+    }
+    return results;
+  }
+
+  listCustomerRecommendations(conversationId, limit = 100) {
+    return this.db.prepare(`SELECT * FROM customer_recommendations WHERE conversation_id=? ORDER BY last_recommended_at DESC LIMIT ?`).all(conversationId, limit).map(row => this.hydrateCustomerRecommendation(row));
+  }
+
+  updateCustomerRecommendation(conversationId, recommendationId, fields = {}) {
+    const allowedStatuses = new Set(['candidate', 'recommended', 'interested', 'rejected', 'viewing', 'viewed', 'closed']);
+    const status = String(fields.status || '');
+    const entries = [];
+    if (allowedStatuses.has(status)) entries.push(['status', status]);
+    if (fields.feedback_reason !== undefined || fields.feedbackReason !== undefined) entries.push(['feedback_reason', String(fields.feedback_reason ?? fields.feedbackReason ?? '').trim()]);
+    if (entries.length) {
+      const assignments = entries.map(([key]) => `${key}=?`).join(',');
+      this.db.prepare(`UPDATE customer_recommendations SET ${assignments},updated_at=? WHERE id=? AND conversation_id=?`).run(...entries.map(([, value]) => value), now(), recommendationId, conversationId);
+    }
+    return this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=? AND conversation_id=?').get(recommendationId, conversationId));
+  }
+
+  clearCustomerIntelligence() {
+    const alerts = Number(this.db.prepare('DELETE FROM customer_alerts').run().changes || 0);
+    const tasks = Number(this.db.prepare('DELETE FROM customer_tasks').run().changes || 0);
+    return { tasks, alerts };
+  }
+
+  createDraft({ conversationId, inboundMessageId, content, confidence, intent, reason, requiresHuman, citations, toolTrace }) {
+    const draftId = makeId('draft');
+    this.db.prepare(`INSERT INTO drafts(id,conversation_id,inbound_message_id,content,confidence,intent,reason,requires_human,citations_json,tool_trace_json,created_at)
+      VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(draftId, conversationId, inboundMessageId, String(content), Number(confidence) || 0, intent || '', reason || '', requiresHuman ? 1 : 0, json(citations || []), json(toolTrace || []), now());
+    return this.getDraft(draftId);
+  }
+
+  getDraft(draftId) {
+    const row = this.db.prepare('SELECT * FROM drafts WHERE id=?').get(draftId);
+    return row ? this.hydrateDraft(row) : null;
+  }
+
+  hydrateDraft(row) {
+    return { ...row, requires_human: Boolean(row.requires_human), citations: parse(row.citations_json, []), tool_trace: parse(row.tool_trace_json, []) };
+  }
+
+  listDrafts({ status = '', conversationId = '', limit = 100 } = {}) {
+    let sql = 'SELECT * FROM drafts WHERE 1=1';
+    const params = [];
+    if (status) { sql += ' AND status=?'; params.push(status); }
+    if (conversationId) { sql += ' AND conversation_id=?'; params.push(conversationId); }
+    sql += ' ORDER BY created_at DESC LIMIT ?';
+    params.push(limit);
+    return this.db.prepare(sql).all(...params).map(row => this.hydrateDraft(row));
+  }
+
+  updateDraft(draftId, fields) {
+    const allowed = ['content', 'status', 'reviewed_at', 'reviewer', 'error', 'sent_message_id'];
+    const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
+    if (!entries.length) return this.getDraft(draftId);
+    const assignments = entries.map(([key]) => `${key}=?`).join(',');
+    this.db.prepare(`UPDATE drafts SET ${assignments} WHERE id=?`).run(...entries.map(([, value]) => value), draftId);
+    return this.getDraft(draftId);
+  }
+
+  audit({ actor = 'system', action, conversationId = null, entityId = null, detail = {} }) {
+    const auditId = makeId('audit');
+    this.db.prepare('INSERT INTO audit_logs(id,actor,action,conversation_id,entity_id,detail_json,created_at) VALUES(?,?,?,?,?,?,?)')
+      .run(auditId, actor, action, conversationId, entityId, json(detail), now());
+    return auditId;
+  }
+
+  listAudit(limit = 200, conversationId = '') {
+    const rows = conversationId
+      ? this.db.prepare('SELECT * FROM audit_logs WHERE conversation_id=? ORDER BY created_at DESC LIMIT ?').all(conversationId, limit)
+      : this.db.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?').all(limit);
+    return rows.map(row => ({ ...row, detail: parse(row.detail_json, {}) }));
+  }
+
+  latestAgentState(conversationId) {
+    const row = this.db.prepare(`SELECT * FROM audit_logs
+      WHERE conversation_id=? AND action IN ('draft_created','agent_failed','agent_not_configured')
+      ORDER BY created_at DESC LIMIT 1`).get(conversationId);
+    if (!row || row.action === 'draft_created') return null;
+    return { action: row.action, message: parse(row.detail_json, {}).message || 'Agent 上游不可用', createdAt: row.created_at };
+  }
+
+  getPollState(key, fallback = '') {
+    return this.db.prepare('SELECT value FROM poll_state WHERE key=?').get(key)?.value ?? fallback;
+  }
+
+  setPollState(key, value) {
+    this.db.prepare(`INSERT INTO poll_state(key,value,updated_at) VALUES(?,?,?)
+      ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`).run(key, String(value), now());
+  }
+
+  importCompatibleDatabase(sourcePath) {
+    if (!sourcePath || !fs.existsSync(sourcePath) || path.resolve(sourcePath) === path.resolve(this.filePath)) return { imported: false, reason: 'source_missing' };
+    if (this.listConversations().length) return { imported: false, reason: 'target_not_empty' };
+    const source = new DatabaseSync(sourcePath, { readOnly: true });
+    const tableOrder = ['settings', 'conversations', 'messages', 'customer_profiles', 'customer_tasks', 'customer_alerts', 'customer_recommendations', 'drafts', 'audit_logs', 'poll_state'];
+    let rowsImported = 0;
+    this.db.exec(`
+      DROP INDEX IF EXISTS idx_customer_tasks_business_key;
+      DROP INDEX IF EXISTS idx_customer_alerts_business_key;
+    `);
+    this.db.exec('BEGIN IMMEDIATE');
+    try {
+      for (const table of tableOrder) {
+        const exists = source.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
+        if (!exists) continue;
+        const columns = source.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name);
+        if (!columns.length) continue;
+        const placeholders = columns.map(() => '?').join(',');
+        const insert = this.db.prepare(`INSERT OR IGNORE INTO ${table}(${columns.join(',')}) VALUES(${placeholders})`);
+        for (const row of source.prepare(`SELECT ${columns.join(',')} FROM ${table}`).all()) {
+          rowsImported += Number(insert.run(...columns.map(column => row[column])).changes || 0);
+        }
+      }
+      this.db.exec('COMMIT');
+    } catch (error) {
+      this.db.exec('ROLLBACK');
+      this.db.exec(`
+        CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
+        CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
+      `);
+      source.close();
+      throw error;
+    }
+    source.close();
+    const intelligenceMigration = this.migrateCustomerIntelligenceRecords();
+    this.db.exec(`
+      CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
+      CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
+    `);
+    this.audit({ actor: 'migration', action: 'legacy_workbench_imported', detail: { source: path.basename(sourcePath), rowsImported, intelligenceMigration } });
+    return { imported: true, rowsImported, intelligenceMigration };
+  }
+}
+
+module.exports = { AgentWorkbenchDb };

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

@@ -0,0 +1,368 @@
+const { EventEmitter } = require('events');
+const { AgentNotConfiguredError } = require('./agent-runtime');
+
+function propertyRecommendationsFromTrace(toolTrace = []) {
+  const items = [];
+  for (const call of toolTrace || []) {
+    if (call?.tool !== 'search_properties') continue;
+    for (const property of call.result?.items || []) {
+      if (property?.id) items.push(property);
+    }
+  }
+  return [...new Map(items.map(item => [String(item.id), item])).values()];
+}
+
+class AgentWorkbenchService extends EventEmitter {
+  constructor({ db, agent, qiwei, config }) {
+    super();
+    this.db = db;
+    this.agent = agent;
+    this.qiwei = qiwei;
+    this.config = config;
+  }
+
+  state(pollerStatus = {}) {
+    return {
+      global: this.db.globalState(),
+      agent: {
+        configured: typeof this.agent.modelClient?.isConfigured === 'function'
+          ? this.agent.modelClient.isConfigured()
+          : Boolean(this.config.agent.apiKey),
+        model: this.config.agent.model,
+        provider: this.config.agent.provider,
+      },
+      qiwei: {
+        configured: this.qiwei.isConfigured(),
+        transport: this.config.qiwei.transport || 'fmode-gateway',
+        allowlistCount: this.config.qiwei.allowedSenders.length,
+      },
+      poller: pollerStatus,
+    };
+  }
+
+  isAllowed(contactId) {
+    return this.config.qiwei.allowedSenders.includes(String(contactId));
+  }
+
+  requireAllowed(contactId) {
+    if (!this.isAllowed(contactId)) throw new Error('该联系人不在测试白名单,禁止处理或发送');
+  }
+
+  conversationDetail(conversationId) {
+    const conversation = this.db.getConversation(conversationId);
+    if (!conversation) return null;
+    return {
+      conversation,
+      messages: this.db.listMessages(conversationId),
+      profile: this.db.getProfile(conversationId),
+      tasks: this.db.listCustomerTasks(conversationId),
+      alerts: this.db.listCustomerAlerts(conversationId),
+      recommendations: this.db.listCustomerRecommendations(conversationId),
+      drafts: this.db.listDrafts({ conversationId }),
+      audit: this.db.listAudit(200, conversationId),
+      agentError: this.db.latestAgentState(conversationId),
+    };
+  }
+
+  setGlobal({ paused, defaultMode, autoSendConfidence }, actor = 'human') {
+    if (paused !== undefined) this.db.setSetting('global_paused', paused ? 'true' : 'false');
+    if (defaultMode !== undefined) {
+      if (!['review', 'auto', 'human', 'paused'].includes(defaultMode)) throw new Error('不支持的默认模式');
+      this.db.setSetting('default_mode', defaultMode);
+    }
+    if (autoSendConfidence !== undefined) {
+      const value = Number(autoSendConfidence);
+      if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error('自动发送置信度必须在 0 到 1 之间');
+      this.db.setSetting('auto_send_confidence', String(value));
+    }
+    this.db.audit({ actor, action: 'global_settings_updated', detail: this.db.globalState() });
+    this.emit('change', { type: 'global' });
+    return this.db.globalState();
+  }
+
+  setConversationMode(conversationId, mode, actor = 'human') {
+    const conversation = this.db.setConversationMode(conversationId, mode);
+    this.db.audit({ actor, action: 'conversation_mode_changed', conversationId, detail: { mode } });
+    this.emit('change', { type: 'conversation', conversationId });
+    return conversation;
+  }
+
+  async ingestInbound({ externalId, contactId, contactName = '', content, timestamp = new Date().toISOString(), raw = null }) {
+    if (!this.isAllowed(contactId)) return { status: 'ignored_not_allowlisted' };
+    const conversation = this.db.ensureConversation(contactId, contactName);
+    const contentDuplicate = this.db.findRecentInboundDuplicate(conversation.id, content, timestamp, 60);
+    if (contentDuplicate) {
+      this.db.audit({
+        actor: 'policy',
+        action: 'message_duplicate_content_skipped',
+        conversationId: conversation.id,
+        entityId: contentDuplicate.id,
+        detail: { externalId },
+      });
+      return { status: 'duplicate_content', conversation, message: contentDuplicate };
+    }
+    const inserted = this.db.insertMessage({
+      conversationId: conversation.id,
+      externalId,
+      direction: 'inbound',
+      senderType: 'customer',
+      content,
+      status: 'received',
+      createdAt: timestamp,
+      raw,
+    });
+    if (!inserted.created) return { status: 'duplicate', conversation, message: inserted.message };
+
+    this.db.audit({ actor: 'qiwei', action: 'message_received', conversationId: conversation.id, entityId: inserted.message.id });
+    this.emit('change', { type: 'message', conversationId: conversation.id });
+    const global = this.db.globalState();
+    const current = this.db.getConversation(conversation.id);
+
+    if (global.paused) {
+      this.db.audit({ actor: 'policy', action: 'agent_skipped_global_paused', conversationId: conversation.id, entityId: inserted.message.id });
+      return { status: 'paused', conversation: current, message: inserted.message };
+    }
+    if (['human', 'paused'].includes(current.mode)) {
+      this.db.audit({ actor: 'policy', action: `agent_skipped_${current.mode}`, conversationId: conversation.id, entityId: inserted.message.id });
+      return { status: current.mode, conversation: current, message: inserted.message };
+    }
+
+    return this.generateDraftForMessage(current, inserted.message);
+  }
+
+  async generateDraftForMessage(conversation, inboundMessage) {
+    try {
+      const profile = this.db.getProfile(conversation.id);
+      const currentTasks = this.db.listCustomerTasks(conversation.id);
+      const currentAlerts = this.db.listCustomerAlerts(conversation.id);
+      const currentRecommendations = this.db.listCustomerRecommendations(conversation.id);
+      const output = await this.agent.run({
+        conversation,
+        messages: this.db.listMessages(conversation.id),
+        profile,
+        customerIntelligence: { tasks: currentTasks, alerts: currentAlerts, recommendations: currentRecommendations },
+        inboundContent: inboundMessage.content,
+      });
+      if (!String(output.content || '').trim()) throw new Error('Agent 没有生成可审核的回复内容');
+      const changedProfileFields = Object.keys(output.profileUpdates || {});
+      const profileEvidence = { ...(profile.profile?.__evidence || {}) };
+      for (const field of changedProfileFields) {
+        profileEvidence[field] = {
+          text: inboundMessage.content,
+          sourceMessageId: inboundMessage.id,
+          updatedAt: new Date().toISOString(),
+        };
+      }
+      const updatedProfile = changedProfileFields.length
+        ? this.db.updateProfile(conversation.id, { ...profile.profile, ...output.profileUpdates, __evidence: profileEvidence }, profile.tags)
+        : profile;
+      const tasksForReconciliation = (output.tasks || []).map(item => ({
+        ...item,
+        sourceMessageId: item.managedBy === 'rule' && !changedProfileFields.length ? null : inboundMessage.id,
+      }));
+      const alertsForReconciliation = (output.alerts || []).map(item => ({
+        ...item,
+        sourceMessageId: item.managedBy === 'rule' && !changedProfileFields.length ? null : inboundMessage.id,
+      }));
+      const taskReconciliation = this.db.reconcileCustomerTasks(conversation.id, tasksForReconciliation, inboundMessage.id);
+      const alertReconciliation = this.db.reconcileCustomerAlerts(conversation.id, alertsForReconciliation, inboundMessage.id);
+      const customerTasks = taskReconciliation.tasks;
+      const customerAlerts = alertReconciliation.alerts;
+      if (changedProfileFields.length || customerTasks.length || customerAlerts.length || taskReconciliation.resolved.length || alertReconciliation.resolved.length) {
+        this.db.audit({
+          actor: 'agent',
+          action: 'customer_intelligence_updated',
+          conversationId: conversation.id,
+          entityId: inboundMessage.id,
+          detail: {
+            profileFields: changedProfileFields,
+            taskCount: customerTasks.length,
+            alertCount: customerAlerts.length,
+            tasksResolved: taskReconciliation.resolved.length,
+            alertsResolved: alertReconciliation.resolved.length,
+          },
+        });
+      }
+      const draft = this.db.createDraft({
+        conversationId: conversation.id,
+        inboundMessageId: inboundMessage.id,
+        content: output.content,
+        confidence: output.confidence,
+        intent: output.intent,
+        reason: output.reason,
+        requiresHuman: output.requiresHuman,
+        citations: output.citations,
+        toolTrace: output.toolTrace,
+      });
+      const draftRecommendations = propertyRecommendationsFromTrace(output.toolTrace);
+      if (draftRecommendations.length) {
+        this.db.upsertCustomerRecommendations(conversation.id, draftRecommendations, {
+          type: 'agent-tool',
+          entityId: draft.id,
+          status: 'candidate',
+          evidence: 'Agent 房源工具查询结果,尚未确认已发送给客户',
+          createdAt: draft.created_at,
+        });
+      }
+      this.db.audit({
+        actor: 'agent',
+        action: 'draft_created',
+        conversationId: conversation.id,
+        entityId: draft.id,
+        detail: { confidence: draft.confidence, requiresHuman: draft.requires_human },
+      });
+      this.emit('change', { type: 'draft', conversationId: conversation.id });
+      const global = this.db.globalState();
+      if (conversation.mode === 'auto' && !draft.requires_human && draft.confidence >= global.autoSendConfidence) {
+        return this.approveDraft(draft.id, { actor: 'agent:auto' });
+      }
+      return {
+        status: 'pending_review',
+        conversation,
+        message: inboundMessage,
+        draft,
+        intelligence: { profile: updatedProfile, tasks: customerTasks, alerts: customerAlerts },
+      };
+    } catch (error) {
+      const action = error instanceof AgentNotConfiguredError ? 'agent_not_configured' : 'agent_failed';
+      this.db.audit({
+        actor: 'agent',
+        action,
+        conversationId: conversation.id,
+        entityId: inboundMessage.id,
+        detail: { message: error.message },
+      });
+      this.emit('change', { type: 'agent_error', conversationId: conversation.id });
+      return { status: action, error: error.message, conversation, message: inboundMessage };
+    }
+  }
+
+  async approveDraft(draftId, { content = '', actor = 'human' } = {}) {
+    const draft = this.db.getDraft(draftId);
+    if (!draft) throw new Error('回复草稿不存在');
+    if (draft.status !== 'pending') throw new Error(`该草稿已经是 ${draft.status} 状态,不能重复发送`);
+    const conversation = this.db.getConversation(draft.conversation_id);
+    if (!conversation) throw new Error('会话不存在');
+    this.requireAllowed(conversation.contact_id);
+    const finalContent = String(content || draft.content).trim();
+    if (!finalContent) throw new Error('回复内容不能为空');
+    if (finalContent.length > 2000) throw new Error('回复内容过长');
+
+    this.db.updateDraft(draftId, {
+      content: finalContent,
+      status: 'approved',
+      reviewed_at: new Date().toISOString(),
+      reviewer: actor,
+    });
+    this.db.audit({
+      actor,
+      action: 'draft_approved',
+      conversationId: conversation.id,
+      entityId: draftId,
+      detail: { edited: finalContent !== draft.content },
+    });
+    try {
+      const result = await this.qiwei.sendText(conversation.contact_id, finalContent);
+      const outbound = this.db.insertMessage({
+        conversationId: conversation.id,
+        direction: 'outbound',
+        senderType: actor === 'agent:auto' ? 'agent' : 'human',
+        content: finalContent,
+        status: result.isSendSuccess === false ? 'submitted' : 'sent',
+      }).message;
+      const sentRecommendations = propertyRecommendationsFromTrace(draft.tool_trace);
+      if (sentRecommendations.length) {
+        this.db.upsertCustomerRecommendations(conversation.id, sentRecommendations, {
+          type: 'outbound-message',
+          entityId: outbound.id,
+          status: 'recommended',
+          evidence: finalContent.slice(0, 500),
+          createdAt: outbound.created_at,
+        });
+      }
+      if (
+        (draft.tool_trace || []).some(item => item.tool === 'search_properties') ||
+        /(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(finalContent)
+      ) {
+        this.db.completeCustomerTaskByBusinessKey(conversation.id, 'recommendation:shortlist', 'outbound_recommendation_sent');
+      }
+      const updated = this.db.updateDraft(draftId, { status: 'sent', sent_message_id: outbound.id, error: null });
+      this.db.audit({ actor, action: 'message_sent', conversationId: conversation.id, entityId: outbound.id, detail: { draftId } });
+      this.emit('change', { type: 'message', conversationId: conversation.id });
+      return { status: 'sent', draft: updated, message: outbound };
+    } catch (error) {
+      const updated = this.db.updateDraft(draftId, { status: 'failed', error: error.message });
+      this.db.audit({
+        actor,
+        action: 'message_send_failed',
+        conversationId: conversation.id,
+        entityId: draftId,
+        detail: { message: error.message },
+      });
+      this.emit('change', { type: 'send_error', conversationId: conversation.id });
+      throw Object.assign(new Error(error.message), { draft: updated });
+    }
+  }
+
+  rejectDraft(draftId, { actor = 'human', reason = '' } = {}) {
+    const draft = this.db.getDraft(draftId);
+    if (!draft) throw new Error('回复草稿不存在');
+    if (draft.status !== 'pending') throw new Error(`该草稿已经是 ${draft.status} 状态`);
+    const updated = this.db.updateDraft(draftId, {
+      status: 'rejected',
+      reviewed_at: new Date().toISOString(),
+      reviewer: actor,
+      error: reason || null,
+    });
+    this.db.audit({ actor, action: 'draft_rejected', conversationId: draft.conversation_id, entityId: draftId, detail: { reason } });
+    this.emit('change', { type: 'draft', conversationId: draft.conversation_id });
+    return updated;
+  }
+
+  async regenerateDraft(draftId, actor = 'human') {
+    const draft = this.db.getDraft(draftId);
+    if (!draft) throw new Error('回复草稿不存在');
+    if (draft.status === 'pending') this.rejectDraft(draftId, { actor, reason: 'regenerated' });
+    const conversation = this.db.getConversation(draft.conversation_id);
+    const inbound = this.db.getMessage(draft.inbound_message_id);
+    this.db.audit({ actor, action: 'draft_regenerate_requested', conversationId: conversation.id, entityId: draftId });
+    return this.generateDraftForMessage(conversation, inbound);
+  }
+
+  async generateLatestDraft(conversationId, actor = 'human') {
+    const conversation = this.db.getConversation(conversationId);
+    if (!conversation) throw new Error('会话不存在');
+    this.requireAllowed(conversation.contact_id);
+    const inbound = this.db.getLatestInbound(conversationId);
+    if (!inbound) throw new Error('没有可处理的客户消息');
+    const existing = this.db.listDrafts({ conversationId }).find(item => item.inbound_message_id === inbound.id && item.status === 'pending');
+    if (existing) return { status: 'pending_review', conversation, message: inbound, draft: existing };
+    this.db.audit({ actor, action: 'manual_agent_run_requested', conversationId, entityId: inbound.id });
+    return this.generateDraftForMessage(conversation, inbound);
+  }
+
+  async manualSend(conversationId, content, actor = 'human') {
+    const conversation = this.db.getConversation(conversationId);
+    if (!conversation) throw new Error('会话不存在');
+    this.requireAllowed(conversation.contact_id);
+    const text = String(content || '').trim();
+    if (!text) throw new Error('回复内容不能为空');
+    if (text.length > 2000) throw new Error('回复内容过长');
+    const result = await this.qiwei.sendText(conversation.contact_id, text);
+    const message = this.db.insertMessage({
+      conversationId,
+      direction: 'outbound',
+      senderType: 'human',
+      content: text,
+      status: result.isSendSuccess === false ? 'submitted' : 'sent',
+    }).message;
+    if (/(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(text)) {
+      this.db.completeCustomerTaskByBusinessKey(conversationId, 'recommendation:shortlist', 'manual_recommendation_sent');
+    }
+    this.db.audit({ actor, action: 'manual_message_sent', conversationId, entityId: message.id });
+    this.emit('change', { type: 'message', conversationId });
+    return message;
+  }
+}
+
+module.exports = { AgentWorkbenchService };

+ 35 - 15
claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js

@@ -5,6 +5,20 @@ const os = require('os');
 
 const DEFAULT_API_BASE = 'https://server.fmode.cn/api/qiwei';
 const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', 'qiwei-credentials.json');
+let activeQiweiContext = {};
+
+function setActiveQiweiContext(input = {}) {
+  activeQiweiContext = {
+    uid: String(input.uid || input.qiweiUid || '').trim(),
+    guid: String(input.guid || input.qiweiGuid || input.deviceGuid || '').trim(),
+    apiBase: String(input.apiBase || input.baseUrl || '').trim().replace(/\/$/, ''),
+  };
+  return { ...activeQiweiContext };
+}
+
+function getActiveQiweiContext() {
+  return { ...activeQiweiContext };
+}
 
 function readJsonMaybe(filePath) {
   try {
@@ -123,23 +137,23 @@ function readQiweiAuthToken(input = {}) {
     input.sessionToken,
     input.apiToken,
     input.token,
-    fileEnv.QIWEI_AUTH_TOKEN,
-    fileEnv.QIWE_AUTH_TOKEN,
-    fileEnv.FMODE_API_KEY,
-    fileEnv.FMODE_API_TOKEN,
-    fileEnv.NEWAPI_TOKEN,
     process.env.QIWEI_AUTH_TOKEN,
     process.env.QIWE_AUTH_TOKEN,
     process.env.FMODE_API_KEY,
     process.env.FMODE_API_TOKEN,
     process.env.NEWAPI_TOKEN,
+    pickFmodeAnthropicToken(process.env),
+    fileEnv.QIWEI_AUTH_TOKEN,
+    fileEnv.QIWE_AUTH_TOKEN,
+    fileEnv.FMODE_API_KEY,
+    fileEnv.FMODE_API_TOKEN,
+    fileEnv.NEWAPI_TOKEN,
     fmodeConfig.newapiToken,
     fmodeConfig.newApiToken,
     fmodeConfig.fmodeApiKey,
     fmodeConfig.fmodeApiToken,
     claudeEnv.FMODE_API_KEY,
     claudeEnv.NEWAPI_TOKEN,
-    pickFmodeAnthropicToken(process.env),
     pickFmodeAnthropicToken(claudeEnv),
     fileEnv.VOC_TOKEN,
     fileEnv.VOC_SOCIAL_TOKEN,
@@ -159,10 +173,11 @@ function readQiweiUid(input = {}) {
   return firstNonEmpty([
     input.uid,
     input.qiweiUid,
-    fileEnv.QIWEI_UID,
-    fileEnv.QIWE_UID,
+    activeQiweiContext.uid,
     process.env.QIWEI_UID,
     process.env.QIWE_UID,
+    fileEnv.QIWEI_UID,
+    fileEnv.QIWE_UID,
     creds.uid
   ]);
 }
@@ -173,20 +188,21 @@ function readQiweiApiBase(input = {}) {
   return String(firstNonEmpty([
     input.apiBase,
     input.baseUrl,
-    fileEnv.QIWEI_API_BASE,
-    fileEnv.QIWEI_RELAY_BASE_URL,
-    fileEnv.QIWE_API_BASE,
-    fileEnv.QIWE_RELAY_BASE_URL,
+    activeQiweiContext.apiBase,
     process.env.QIWEI_API_BASE,
     process.env.QIWEI_RELAY_BASE_URL,
     process.env.QIWE_API_BASE,
     process.env.QIWE_RELAY_BASE_URL,
+    fileEnv.QIWEI_API_BASE,
+    fileEnv.QIWEI_RELAY_BASE_URL,
+    fileEnv.QIWE_API_BASE,
+    fileEnv.QIWE_RELAY_BASE_URL,
     creds.apiBase,
     DEFAULT_API_BASE
   ]) || DEFAULT_API_BASE).replace(/\/$/, '');
 }
 
-function saveQiweiClientConfig({ uid, apiBase, guid } = {}) {
+function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
   const saved = [];
   try {
     const dir = path.dirname(CREDENTIALS_FILE);
@@ -208,6 +224,7 @@ function saveQiweiClientConfig({ uid, apiBase, guid } = {}) {
     if (uid) pairs.push(['QIWEI_UID', uid]);
     if (apiBase) pairs.push(['QIWEI_API_BASE', apiBase]);
     if (guid) pairs.push(['QIWEI_GUID', guid]);
+    if (authToken) pairs.push(['QIWEI_AUTH_TOKEN', normalizeToken(authToken)]);
     if (pairs.length) {
       let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
       for (const [key, value] of pairs) {
@@ -233,10 +250,11 @@ function readQiweiGuid(input = {}) {
     input.guid,
     input.qiweiGuid,
     input.deviceGuid,
-    fileEnv.QIWEI_GUID,
-    fileEnv.QIWE_GUID,
+    activeQiweiContext.guid,
     process.env.QIWEI_GUID,
     process.env.QIWE_GUID,
+    fileEnv.QIWEI_GUID,
+    fileEnv.QIWE_GUID,
     creds.guid
   ]);
 }
@@ -262,6 +280,8 @@ module.exports = {
   ensureQiweiUid,
   readQiweiApiBase,
   saveQiweiClientConfig,
+  setActiveQiweiContext,
+  getActiveQiweiContext,
   isConfigured,
   readEnvFiles,
   readClaudeSettingsEnv,

+ 51 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/customer-task-official-sync.js

@@ -0,0 +1,51 @@
+'use strict';
+
+const { parseFlexibleDeadline } = require('./deadline-parser');
+
+function createCustomerTaskOfficialSync({ db, searchTodoUsers, createTodoKnowledge } = {}) {
+  if (!db || typeof db.getCustomerTask !== 'function') throw new Error('缺少客户待办数据库');
+  if (typeof searchTodoUsers !== 'function' || typeof createTodoKnowledge !== 'function') throw new Error('缺少企微官方待办服务');
+
+  return async function syncCustomerTaskToOfficialTodo(taskId, input = {}) {
+    const task = db.getCustomerTask(taskId);
+    if (!task) throw new Error('客户待办不存在');
+    if (task.official_todo_id) {
+      return { status: 'ok', assistantMessage: '该待办已经同步到企业微信,无需重复创建', data: { task } };
+    }
+
+    const owner = String(input.owner || task.owner || '').trim().replace(/^待分配$/, '');
+    const dueAtInput = String(input.dueAt || input.due_at || task.due_at || '').trim();
+    if (!owner) throw new Error('同步企微待办前,请确认内部负责人姓名或别名');
+    const dueAt = parseFlexibleDeadline(dueAtInput);
+
+    let followerId = String(input.followerId || '').trim();
+    if (!followerId) {
+      const search = await searchTodoUsers({ keyword: owner });
+      if (search.status !== 'ok') return search;
+      const users = search.data?.users || [];
+      const exact = users.filter(item => item.name === owner || item.alias === owner);
+      const selected = exact.length === 1 ? exact[0] : users.length === 1 ? users[0] : null;
+      if (!selected) throw new Error(users.length ? '负责人匹配到多位企微成员,请填写准确姓名或别名' : '没有找到对应的企微内部成员');
+      followerId = selected.id;
+    }
+
+    const conversation = db.getConversation(task.conversation_id);
+    const content = `跟进客户 ${conversation?.contact_name || '客户'}:${task.title}`;
+    const created = await createTodoKnowledge({ content, followerIds: [followerId], endTime: dueAt, remindType: input.remindType ?? 1 });
+    if (created.status !== 'ok') return created;
+    const officialTodoId = String(created.summary?.todoId || created.data?.todo?.id || '').trim();
+    if (!officialTodoId) throw new Error('企微待办创建成功,但没有返回可关联的待办 ID');
+    const updated = db.updateCustomerTask(task.id, {
+      owner,
+      due_at: dueAt,
+      status: task.status === 'open' ? 'in_progress' : task.status,
+      official_todo_id: officialTodoId,
+      official_sync_status: 'synced',
+      official_synced_at: new Date().toISOString(),
+    });
+    db.audit({ actor: 'human', action: 'customer_task_synced_to_official_todo', conversationId: task.conversation_id, entityId: task.id, detail: { owner, dueAt } });
+    return { status: 'ok', assistantMessage: '客户待办已创建为真实企业微信待办', data: { task: updated, officialTodo: created.data?.todo || null } };
+  };
+}
+
+module.exports = { createCustomerTaskOfficialSync };

+ 130 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/deadline-parser.js

@@ -0,0 +1,130 @@
+'use strict';
+
+const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
+const DEFAULT_HOUR = 18;
+
+function pad(value) {
+  return String(value).padStart(2, '0');
+}
+
+function shanghaiCalendar(now = new Date()) {
+  const value = now instanceof Date ? now : new Date(now);
+  if (Number.isNaN(value.getTime())) throw new Error('无法识别当前时间');
+  const shifted = new Date(value.getTime() + SHANGHAI_OFFSET_MS);
+  return {
+    year: shifted.getUTCFullYear(),
+    month: shifted.getUTCMonth() + 1,
+    day: shifted.getUTCDate(),
+  };
+}
+
+function addCalendarDays(calendar, days) {
+  const value = new Date(Date.UTC(calendar.year, calendar.month - 1, calendar.day + days));
+  return { year: value.getUTCFullYear(), month: value.getUTCMonth() + 1, day: value.getUTCDate() };
+}
+
+function chineseNumber(value) {
+  const text = String(value || '').trim();
+  if (/^\d+$/.test(text)) return Number(text);
+  const digits = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
+  if (text === '十') return 10;
+  if (text.includes('十')) {
+    const [before, after] = text.split('十');
+    return (before ? digits[before] : 1) * 10 + (after ? digits[after] : 0);
+  }
+  return digits[text];
+}
+
+function validCalendarDate(year, month, day) {
+  const value = new Date(Date.UTC(year, month - 1, day));
+  return value.getUTCFullYear() === year && value.getUTCMonth() + 1 === month && value.getUTCDate() === day;
+}
+
+function parseClock(value, defaultHour = DEFAULT_HOUR) {
+  let text = String(value || '').trim().replace(/^[T\s]+/, '').replace(/\s+/g, '').replace(/整$/, '');
+  if (!text) return { hour: defaultHour, minute: 0 };
+
+  const periodMatch = text.match(/^(凌晨|早上|上午|中午|下午|傍晚|晚上)/);
+  const period = periodMatch?.[1] || '';
+  if (period) text = text.slice(period.length);
+  if (!text) return { hour: ['凌晨', '早上', '上午'].includes(period) ? 9 : period === '中午' ? 12 : defaultHour, minute: 0 };
+
+  let match = text.match(/^(\d{1,2})(?::(\d{1,2}))(?::\d{1,2})?$/);
+  if (!match) match = text.match(/^(\d{1,2})(?:点|时)(?:(半)|(\d{1,2})(?:分)?)?$/);
+  if (!match) throw new Error('无法识别截止时间,请选择日期时间,或使用“明天”“三天后”等表达');
+
+  let hour = Number(match[1]);
+  const minute = match[2] === '半' ? 30 : Number(match[2] || match[3] || 0);
+  if (['下午', '傍晚', '晚上'].includes(period) && hour < 12) hour += 12;
+  if (['凌晨', '早上', '上午'].includes(period) && hour === 12) hour = 0;
+  if (period === '中午' && hour < 11) hour += 12;
+  if (hour > 23 || minute > 59) throw new Error('截止时间超出有效范围,请重新选择');
+  return { hour, minute };
+}
+
+function formatDeadline(calendar, clock) {
+  if (!validCalendarDate(calendar.year, calendar.month, calendar.day)) throw new Error('截止日期不存在,请重新选择');
+  return `${calendar.year}-${pad(calendar.month)}-${pad(calendar.day)} ${pad(clock.hour)}:${pad(clock.minute)}`;
+}
+
+function parseFlexibleDeadline(value, options = {}) {
+  let text = String(value || '').trim();
+  if (!text) throw new Error('请选择截止时间');
+  text = text
+    .replace(/[,,]/g, ' ')
+    .replace(/^截止(?:时间)?[::]?\s*/, '')
+    .replace(/^明晚/, '明天晚上')
+    .replace(/^今晚/, '今天晚上');
+
+  const today = shanghaiCalendar(options.now || new Date());
+  let calendar;
+  let clockText = '';
+
+  let match = text.match(/^(今天|明天|后天|大后天)(.*)$/);
+  if (match) {
+    const offsets = { 今天: 0, 明天: 1, 后天: 2, 大后天: 3 };
+    calendar = addCalendarDays(today, offsets[match[1]]);
+    clockText = match[2];
+  }
+
+  if (!calendar) {
+    match = text.match(/^([一二三四五六七八九十两\d]+)天后(.*)$/);
+    if (match) {
+      const days = chineseNumber(match[1]);
+      if (!Number.isFinite(days) || days < 0 || days > 3650) throw new Error('截止日期跨度过大,请直接选择日期');
+      calendar = addCalendarDays(today, days);
+      clockText = match[2];
+    }
+  }
+
+  if (!calendar) {
+    match = text.match(/^(?:下周|([一二三四五六七八九十两\d]+)周后)(.*)$/);
+    if (match) {
+      const weeks = match[1] ? chineseNumber(match[1]) : 1;
+      if (!Number.isFinite(weeks) || weeks < 1 || weeks > 520) throw new Error('截止日期跨度过大,请直接选择日期');
+      calendar = addCalendarDays(today, weeks * 7);
+      clockText = match[2];
+    }
+  }
+
+  if (!calendar) {
+    match = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?(.*)$/);
+    if (match) {
+      calendar = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
+      clockText = match[4];
+    }
+  }
+
+  if (!calendar) {
+    match = text.match(/^(\d{1,2})月(\d{1,2})日?(.*)$/);
+    if (match) {
+      calendar = { year: today.year, month: Number(match[1]), day: Number(match[2]) };
+      clockText = match[3];
+    }
+  }
+
+  if (!calendar) throw new Error('无法识别截止时间,请选择日期时间,或使用“明天”“三天后”等表达');
+  return formatDeadline(calendar, parseClock(clockText));
+}
+
+module.exports = { parseFlexibleDeadline, shanghaiCalendar };

+ 8 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js

@@ -10,8 +10,11 @@ const OUTPUT_CATEGORIES = Object.freeze([
   'meetings',
   'docs',
   'messages',
+  'customers',
   'groups',
   'portraits',
+  'knowledge',
+  'goals',
   'broker-playbooks',
   'tags',
   'transfers',
@@ -22,6 +25,10 @@ const OUTPUT_CATEGORIES = Object.freeze([
   'tmp'
 ]);
 
+const OUTPUT_PERSISTENT_STORES = Object.freeze({
+  knowledge: Object.freeze(['meetings', 'docs', 'todos', 'tasks'])
+});
+
 function outputsRoot() {
   const override = process.env.QIWEI_OUTPUTS_DIR;
   if (override && String(override).trim()) return path.resolve(String(override).trim());
@@ -89,6 +96,7 @@ function writeRunManifest(runDir, manifest = {}) {
 module.exports = {
   PACKAGE_ROOT,
   OUTPUT_CATEGORIES,
+  OUTPUT_PERSISTENT_STORES,
   outputsRoot,
   ensureDir,
   slugify,

+ 2 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/core/shared-gateway.js

@@ -29,8 +29,8 @@ async function gatewayCall(ctx, method, params, timeoutMs = 60000) {
 }
 
 function requireGuid(ctx) {
-  if (!ctx.guid) {
-    const err = new Error('缺少 guid。请先用 qiwei_login_status/qiwei_login_start 完成设备登录,或显式传入 guid。');
+  if (!ctx.uid) {
+    const err = new Error('缺少 Fmode 设备 uid。请先用 qiwei_login_status/qiwei_login_start 完成设备登录。');
     err.kind = 'request';
     throw err;
   }

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

@@ -1,7 +1,7 @@
 const http = require('http');
 const fs = require('fs');
 const path = require('path');
-const { outputsRoot, createRunDir } = require('./output-paths');
+const { outputsRoot, createRunDir, writeRunManifest } = require('./output-paths');
 
 let activeServer = null;
 let activePort = 0;
@@ -91,6 +91,7 @@ function saveWebhookEvent(event, source = 'callback') {
   const fileName = `event-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
+  writeRunManifest(runDir, { kind: 'webhook-event', source, files: [filePath] });
   return filePath;
 }
 

+ 47 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/xlsx-io.js

@@ -0,0 +1,47 @@
+'use strict';
+
+const { readSheet } = require('read-excel-file/node');
+const writeXlsxFile = require('write-excel-file/node');
+
+function normalizeCellValue(value) {
+  if (value === null || value === undefined) return '';
+  if (value instanceof Date || ['string', 'number', 'boolean'].includes(typeof value)) return value;
+  if (Array.isArray(value)) return value.join(',');
+  return JSON.stringify(value);
+}
+
+async function readWorksheetRows(filePath, options = {}) {
+  return readSheet(filePath, options.sheetName);
+}
+
+async function writeObjectRows(filePath, sheetName, rows = []) {
+  const headers = [];
+  const seen = new Set();
+  for (const row of rows) {
+    for (const key of Object.keys(row || {})) {
+      if (!seen.has(key)) {
+        seen.add(key);
+        headers.push(key);
+      }
+    }
+  }
+
+  if (!headers.length) headers.push('message');
+  const columns = headers.map(key => ({
+    header: { value: key, fontWeight: 'bold' },
+    cell: row => ({ value: normalizeCellValue(row && row[key]) }),
+    width: Math.min(40, Math.max(12, key.length + 4)),
+  }));
+  const data = rows.length ? rows : [{ message: '无数据' }];
+  await writeXlsxFile(data, {
+    columns,
+    sheet: sheetName,
+    stickyRowsCount: 1,
+  }).toFile(filePath);
+}
+
+module.exports = {
+  normalizeCellValue,
+  readWorksheetRows,
+  writeObjectRows,
+};

+ 1138 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -0,0 +1,1138 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { latestPath } = require('../core/output-paths');
+const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
+const { AgentKnowledgeStore } = require('../core/agent-knowledge');
+const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
+const { getCustomerSessionGuide } = require('../core/agent-session-guide');
+const { AgentWorkbenchService } = require('../core/agent-workbench-service');
+const {
+  searchTodoUsers,
+  createTodoKnowledge,
+  completeTodoKnowledge,
+} = require('./official-office-knowledge-service');
+const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
+const { messageTimestamp, evaluatePolledMessage } = require('../core/agent-poller-policy');
+const { setActiveQiweiContext } = require('../core/credentials');
+const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
+
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
+const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
+
+function readEnvFile(filePath) {
+  try {
+    const env = {};
+    for (const rawLine of fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)) {
+      const line = rawLine.trim();
+      if (!line || line.startsWith('#') || !line.includes('=')) continue;
+      const index = line.indexOf('=');
+      const key = line.slice(0, index).trim();
+      let value = line.slice(index + 1).trim();
+      if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
+      env[key] = value;
+    }
+    return env;
+  } catch {
+    return {};
+  }
+}
+
+function readClaudeSettingsEnv() {
+  const result = {};
+  const home = process.env.USERPROFILE || process.env.HOME || '';
+  for (const filePath of [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude', 'settings.local.json')]) {
+    try {
+      const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
+      for (const [key, item] of Object.entries(parsed.env || {})) {
+        if (!result[key] && typeof item === 'string' && item.trim()) result[key] = item.trim();
+      }
+    } catch {}
+  }
+  return result;
+}
+
+const fileEnv = readEnvFile(ENV_FILE);
+const claudeEnv = readClaudeSettingsEnv();
+
+function value(name, fallback = '') {
+  const candidates = [process.env[name], fileEnv[name], claudeEnv[name], fallback];
+  return String(candidates.find(item => typeof item === 'string' && item.trim()) ?? '').trim();
+}
+
+function bool(name, fallback = false) {
+  return /^(1|true|yes|on)$/i.test(value(name, fallback ? 'true' : 'false'));
+}
+
+function number(name, fallback, min = -Infinity, max = Infinity) {
+  const parsed = Number(value(name, String(fallback)));
+  return Math.min(max, Math.max(min, Number.isFinite(parsed) ? parsed : fallback));
+}
+
+function resolvePath(input, fallback) {
+  const selected = input || fallback;
+  return selected ? path.resolve(PROJECT_ROOT, selected) : '';
+}
+
+function loadAgentConfig(overrides = {}) {
+  const provider = value('AGENT_PROVIDER', 'claude-code');
+  const anthropic = provider === 'anthropic';
+  const claudeCode = provider === 'claude-code';
+  const bundledPropertyFile = path.join(PROJECT_ROOT, 'knowledge-base', 'property-data', 'properties.json');
+  const workspacePropertyFile = path.resolve(PROJECT_ROOT, '..', '..', 'huaxiangpipei', 'src', 'assets', 'data', 'properties.json');
+  const configuredPropertyFile = value('QIWEI_AGENT_PROPERTY_DATA_FILE');
+  const propertyDataFile = configuredPropertyFile
+    ? resolvePath(configuredPropertyFile)
+    : (fs.existsSync(bundledPropertyFile) ? bundledPropertyFile : (fs.existsSync(workspacePropertyFile) ? workspacePropertyFile : ''));
+  const baseConfig = {
+    dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
+    legacyDbPath: path.resolve(PROJECT_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
+    globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', true),
+    conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
+    autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
+    knowledgeDir: resolvePath(value('QIWEI_AGENT_KNOWLEDGE_DIR'), path.join(PROJECT_ROOT, 'knowledge')),
+    propertyDataFile,
+    agent: {
+      provider,
+      apiKey: value('AGENT_API_KEY') || value(anthropic ? 'ANTHROPIC_AUTH_TOKEN' : 'OPENAI_API_KEY') || value('QIWEI_AUTO_REPLY_AI_KEY'),
+      baseUrl: (value('AGENT_BASE_URL') || value(anthropic ? 'ANTHROPIC_BASE_URL' : 'OPENAI_BASE_URL') || value('QIWEI_AUTO_REPLY_AI_BASE_URL') || (anthropic ? 'https://api.anthropic.com' : 'https://api.openai.com/v1')).replace(/\/$/, ''),
+      model: value('AGENT_MODEL') || value(anthropic || claudeCode ? 'ANTHROPIC_MODEL' : 'OPENAI_MODEL') || value('QIWEI_AUTO_REPLY_AI_MODEL') || (anthropic || claudeCode ? 'sonnet' : 'gpt-4.1-mini'),
+      maxToolRounds: number('AGENT_MAX_TOOL_ROUNDS', 4, 1, 8),
+      claudeExecutable: value('CLAUDE_CODE_EXECUTABLE') || path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
+      claudeWorkdir: resolvePath(value('CLAUDE_CODE_WORKDIR'), PROJECT_ROOT),
+      claudeSessionFile: resolvePath(value('CLAUDE_CODE_SESSION_FILE'), latestPath('messages', 'claude-code-sessions.json')),
+      claudeProjectId: value('QIWEI_AGENT_PROJECT_ID') || crypto.createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 16),
+      claudeMainSessionId: value('QIWEI_AGENT_MAIN_SESSION_ID'),
+      claudeTimeoutMs: number('CLAUDE_CODE_TIMEOUT_MS', 120000, 15000, 300000),
+      claudeMaxBudgetUsd: number('CLAUDE_CODE_MAX_BUDGET_USD', 0.35, 0.05, 5),
+      claudeTools: value('CLAUDE_CODE_ALLOWED_TOOLS', 'Read,Glob,Grep'),
+    },
+    qiwei: {
+      transport: 'fmode-gateway',
+      authToken: value('QIWEI_AUTH_TOKEN'),
+      uid: value('QIWEI_UID') || value('QIWE_UID'),
+      guid: value('QIWEI_GUID') || value('QIWE_GUID'),
+      apiBase: value('QIWEI_API_BASE') || value('QIWE_API_BASE'),
+      userId: '',
+      nickname: '',
+      corpName: '',
+      allowedSenders: value('QIWEI_AUTO_REPLY_ALLOWED_SENDERS').split(',').map(item => item.trim()).filter(Boolean),
+      selfUserId: value('QIWEI_AUTO_REPLY_SELF_USER_ID'),
+      intervalMs: number('QIWEI_AUTO_REPLY_INTERVAL_MS', 10000, 3000, 60000),
+      initialSyncLimit: number('QIWEI_AGENT_INITIAL_SYNC_LIMIT', 5000, 100, 5000),
+      initialSyncMaxPages: number('QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES', 200, 10, 500),
+      startupGraceSeconds: number('QIWEI_AGENT_STARTUP_GRACE_SECONDS', 10, 0, 60),
+    },
+  };
+  const config = {
+    ...baseConfig,
+    ...overrides,
+    agent: { ...baseConfig.agent, ...(overrides.agent || {}) },
+    qiwei: { ...baseConfig.qiwei, ...(overrides.qiwei || {}) },
+  };
+  config.agent.claudeAddDirs = [config.knowledgeDir, config.propertyDataFile ? path.dirname(config.propertyDataFile) : ''].filter(Boolean);
+  return config;
+}
+
+function accountRuntimeKey(input = {}) {
+  const source = String(input.uid || input.guid || input.userId || 'default').trim();
+  return crypto.createHash('sha256').update(source || 'default').digest('hex').slice(0, 16);
+}
+
+function accountWorkbenchOverrides(input = {}) {
+  const storageKey = accountRuntimeKey(input);
+  return {
+    dbPath: latestPath('messages', `agent-workbench-${storageKey}.db`),
+    agent: {
+      claudeSessionFile: latestPath('messages', `claude-code-sessions-${storageKey}.json`),
+      claudeProjectId: `${crypto.createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 12)}-${storageKey.slice(0, 8)}`,
+    },
+    qiwei: {
+      uid: String(input.uid || '').trim(),
+      guid: String(input.guid || '').trim(),
+      userId: String(input.userId || '').trim(),
+      nickname: String(input.nickname || '').trim(),
+      corpName: String(input.corpName || '').trim(),
+      apiBase: String(input.apiBase || '').trim(),
+    },
+  };
+}
+
+
+function backfillCustomerIntelligence(db) {
+  const version = '4';
+  if (db.getSetting('customer_intelligence_backfill_version', '') === version) return { skipped: true, profileFields: 0, taskCount: 0, alertCount: 0 };
+  const removed = db.lastIntelligenceMigration || { tasks: { removed: 0 }, alerts: { removed: 0 } };
+  let profileFields = 0;
+  let taskCount = 0;
+  let alertCount = 0;
+  for (const conversation of db.listConversations()) {
+    const existing = db.getProfile(conversation.id);
+    let current = { profile: { ...existing.profile }, tags: existing.tags || [] };
+    for (const message of db.listMessages(conversation.id, 200).filter(item => item.direction === 'inbound')) {
+      const intelligence = extractExplicitCustomerIntelligence(message.content, current.profile || {}, {});
+      if (Object.keys(intelligence.profileUpdates).length) {
+        current = { profile: { ...current.profile, ...intelligence.profileUpdates }, tags: current.tags };
+        profileFields += Object.keys(intelligence.profileUpdates).length;
+      }
+      const changed = Object.keys(intelligence.profileUpdates).length > 0;
+      const tasks = intelligence.tasks.map(item => ({ ...item, sourceMessageId: changed ? message.id : null }));
+      const alerts = intelligence.alerts.map(item => ({ ...item, sourceMessageId: changed || item.managedBy === 'event' ? message.id : null }));
+      taskCount += db.reconcileCustomerTasks(conversation.id, tasks, message.id).tasks.length;
+      alertCount += db.reconcileCustomerAlerts(conversation.id, alerts, message.id).alerts.length;
+    }
+    if (Object.keys(current.profile).length) db.updateProfile(conversation.id, { ...existing.profile, ...current.profile }, existing.tags);
+    const recommendationSent = db.listMessages(conversation.id, 200).some(message =>
+      message.direction === 'outbound' && /(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(String(message.content || ''))
+    );
+    if (recommendationSent) db.completeCustomerTaskByBusinessKey(conversation.id, 'recommendation:shortlist', 'historical_recommendation_sent');
+  }
+  db.setSetting('customer_intelligence_backfill_version', version);
+  if (profileFields || taskCount || alertCount) {
+    db.audit({ actor: 'migration', action: 'customer_intelligence_backfilled', detail: { version, removed, profileFields, taskCount, alertCount } });
+  }
+  return { version, removed, profileFields, taskCount, alertCount };
+}
+
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+class QiweiAgentPoller {
+  constructor({ config, db, qiwei, service }) {
+    this.config = config;
+    this.db = db;
+    this.qiwei = qiwei;
+    this.service = service;
+    this.running = false;
+    this.loopPromise = null;
+    this.startedAt = 0;
+    this.lastError = '';
+  }
+
+  status() {
+    return {
+      running: this.running,
+      syncKey: Number(this.db.getPollState('sync_key', '0')),
+      lastError: this.lastError,
+      startedAt: this.startedAt || null,
+    };
+  }
+
+  async start() {
+    if (this.running) return this.status();
+    if (!this.qiwei.isConfigured()) throw new Error('Fmode 企微网关尚未配置,请先完成 Fmode 鉴权和企微扫码登录');
+    if (this.config.allowedSenders.length === 0) throw new Error('企微联系人白名单为空,拒绝启动');
+    await this.qiwei.checkLogin();
+    this.running = true;
+    this.startedAt = Math.floor(Date.now() / 1000);
+    this.lastError = '';
+    let syncKey = Number(this.db.getPollState('sync_key', '0')) || 0;
+    if (syncKey === 0) syncKey = await this.establishBaseline();
+    this.db.audit({ actor: 'runtime', action: 'poller_started', detail: { syncKey, allowlistCount: this.config.allowedSenders.length } });
+    this.loopPromise = this.loop(syncKey);
+    return this.status();
+  }
+
+  stop() {
+    if (!this.running) return this.status();
+    this.running = false;
+    this.db.audit({ actor: 'runtime', action: 'poller_stopped', detail: this.status() });
+    return this.status();
+  }
+
+  async establishBaseline() {
+    let cursor = 0;
+    let reachedEnd = false;
+    let pages = 0;
+    let count = 0;
+    while (pages < this.config.initialSyncMaxPages) {
+      const result = await this.qiwei.syncMessages(cursor, this.config.initialSyncLimit);
+      const list = result.syncMsgList || [];
+      pages += 1;
+      count += list.length;
+      const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
+      const next = Math.max(cursor, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
+      if (list.length === 0) { reachedEnd = true; break; }
+      if (next <= cursor) throw new Error(`历史消息游标没有前进(seq=${cursor})`);
+      cursor = next;
+    }
+    if (!reachedEnd) throw new Error(`历史消息超过 ${this.config.initialSyncMaxPages} 页,拒绝启动自动处理`);
+    this.db.setPollState('sync_key', cursor);
+    this.db.audit({ actor: 'runtime', action: 'poller_baseline_established', detail: { cursor, pages, skippedHistory: count } });
+    return cursor;
+  }
+
+  async loop(initialSyncKey) {
+    let syncKey = initialSyncKey;
+    while (this.running) {
+      try {
+        const result = await this.qiwei.syncMessages(syncKey, 50);
+        const list = result.syncMsgList || [];
+        const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
+        const next = Math.max(syncKey, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
+        for (const message of list) await this.process(message);
+        syncKey = next;
+        this.db.setPollState('sync_key', syncKey);
+        this.lastError = '';
+      } catch (error) {
+        this.lastError = error.message;
+        this.db.audit({ actor: 'runtime', action: 'poller_error', detail: { message: error.message } });
+      }
+      if (this.running) await delay(this.config.intervalMs);
+    }
+  }
+
+  async process(message) {
+    const candidate = evaluatePolledMessage(message, this.config);
+    if (!candidate.eligible) return;
+    const { content, senderId, timestamp } = candidate;
+    await this.service.ingestInbound({
+      externalId: String(message.msgServerId || message.msgUniqueIdentifier || `${senderId}:${message.seq}`),
+      contactId: senderId,
+      contactName: message.senderName || '王刚',
+      content,
+      timestamp: new Date(timestamp * 1000).toISOString(),
+      raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp },
+    });
+  }
+}
+
+function createWorkbench(overrides = {}) {
+  const config = loadAgentConfig(overrides.config || {});
+  const db = overrides.db || new AgentWorkbenchDb(config.dbPath, {
+    globalPaused: config.globalDefaultPaused,
+    defaultMode: config.conversationDefaultMode,
+    autoSendConfidence: config.autoSendConfidence,
+  });
+  if (!overrides.db && !overrides.skipLegacyImport) {
+    try { db.importCompatibleDatabase(config.legacyDbPath); } catch (error) {
+      db.audit({ actor: 'migration', action: 'legacy_workbench_import_failed', detail: { message: error.message } });
+    }
+    const removed = db.cleanupInboundContentDuplicates(60);
+    if (removed) db.audit({ actor: 'migration', action: 'duplicate_messages_cleaned', detail: { removed } });
+    backfillCustomerIntelligence(db);
+  }
+  const knowledge = overrides.knowledge || new AgentKnowledgeStore({
+    knowledgeDir: config.knowledgeDir,
+    propertyDataFile: config.propertyDataFile,
+  });
+  backfillPropertyRecommendations(db, knowledge);
+  const qiwei = overrides.qiwei || new FmodeQiweiClient(config.qiwei);
+  const agent = overrides.agent || new QiweiAgentRuntime({ config: config.agent, knowledge });
+  const service = overrides.service || new AgentWorkbenchService({ db, agent, qiwei, config });
+  const poller = overrides.poller || new QiweiAgentPoller({ config: config.qiwei, db, qiwei, service });
+  return { config, db, knowledge, qiwei, agent, service, poller };
+}
+
+function configuredStartupAccount() {
+  return {
+    uid: value('QIWEI_UID') || value('QIWE_UID'),
+    guid: value('QIWEI_GUID') || value('QIWE_GUID'),
+    apiBase: value('QIWEI_API_BASE') || value('QIWE_API_BASE'),
+    userId: '',
+    nickname: '',
+    corpName: '',
+  };
+}
+
+const startupAccount = configuredStartupAccount();
+let workbench = createWorkbench(startupAccount.uid ? {
+  config: accountWorkbenchOverrides(startupAccount),
+  skipLegacyImport: true,
+} : {});
+
+function migrateStartupWorkbench(target) {
+  if (!startupAccount.uid || target.db.listConversations().length) return { imported: false, reason: 'target_not_empty' };
+  const candidates = [latestPath('messages', 'agent-workbench.db'), target.config.legacyDbPath];
+  for (const sourcePath of candidates) {
+    try {
+      const result = target.db.importCompatibleDatabase(sourcePath);
+      if (!result.imported) continue;
+      const removed = target.db.cleanupInboundContentDuplicates(60);
+      backfillCustomerIntelligence(target.db);
+      backfillPropertyRecommendations(target.db, target.knowledge);
+      target.db.audit({
+        actor: 'migration',
+        action: 'account_workbench_migration_completed',
+        detail: { source: path.basename(sourcePath), removedDuplicates: removed },
+      });
+      return result;
+    } catch (error) {
+      target.db.audit({
+        actor: 'migration',
+        action: 'account_workbench_migration_failed',
+        detail: { source: path.basename(sourcePath), message: error.message },
+      });
+    }
+  }
+  return { imported: false, reason: 'source_missing_or_incompatible' };
+}
+
+migrateStartupWorkbench(workbench);
+let accountStatusCache = { checkedAt: 0, value: null };
+let accountStatusRefresh = null;
+let accountStatusRefreshKey = '';
+const accountLastOnlineAt = new Map();
+const accountOfflineChecks = new Map();
+const ONLINE_STATUS_GRACE_MS = 60000;
+const workbenches = new Map();
+
+function activeAccountMetadata() {
+  const context = workbench.qiwei.context();
+  return {
+    uid: String(context.uid || workbench.config.qiwei.uid || '').trim(),
+    guid: String(context.guid || workbench.config.qiwei.guid || '').trim(),
+    apiBase: String(context.apiBase || workbench.config.qiwei.apiBase || '').trim(),
+    userId: String(workbench.config.qiwei.userId || '').trim(),
+    nickname: String(workbench.config.qiwei.nickname || '').trim(),
+    corpName: String(workbench.config.qiwei.corpName || '').trim(),
+  };
+}
+
+function applyActiveAccountContext(account) {
+  setActiveQiweiContext(account);
+  Object.assign(workbench.config.qiwei, account);
+  if (workbench.qiwei && workbench.qiwei.config) Object.assign(workbench.qiwei.config, account);
+}
+
+function provisionalAccountStatus(selected = activeAccountMetadata(), statusText = '正在检测账号状态') {
+  return {
+    uid: selected.uid,
+    guid: selected.guid,
+    userId: selected.userId,
+    configured: workbench.qiwei.isConfigured(),
+    online: false,
+    nickname: selected.nickname || selected.userId || '当前企微账号',
+    corpName: selected.corpName || '',
+    statusCode: null,
+    statusText,
+  };
+}
+
+const initialAccount = activeAccountMetadata();
+workbenches.set(accountRuntimeKey(initialAccount), workbench);
+applyActiveAccountContext(initialAccount);
+
+async function switchActiveAccount(input = {}) {
+  const account = {
+    uid: String(input.uid || '').trim(),
+    guid: String(input.guid || '').trim(),
+    apiBase: String(input.apiBase || '').trim(),
+    userId: String(input.userId || '').trim(),
+    nickname: String(input.nickname || input.userId || '').trim(),
+    corpName: String(input.corpName || '').trim(),
+  };
+  if (!account.uid) throw new Error('该账号缺少 Fmode 设备 uid,请重新扫码绑定后再切换');
+
+  const current = activeAccountMetadata();
+  const currentKey = accountRuntimeKey(current);
+  const nextKey = accountRuntimeKey(account);
+  const accountChanged = currentKey !== nextKey;
+  if (accountChanged && workbench.poller.status().running) workbench.poller.stop();
+
+  let nextWorkbench = workbenches.get(nextKey);
+  if (!nextWorkbench) {
+    nextWorkbench = createWorkbench({
+      config: accountWorkbenchOverrides(account),
+      skipLegacyImport: true,
+    });
+    workbenches.set(nextKey, nextWorkbench);
+  }
+  workbench = nextWorkbench;
+  applyActiveAccountContext({
+    ...activeAccountMetadata(),
+    ...account,
+    apiBase: account.apiBase || activeAccountMetadata().apiBase,
+  });
+  const status = provisionalAccountStatus();
+  accountStatusCache = { checkedAt: Date.now(), value: status };
+  void refreshAccountStatus();
+  return {
+    status: 'ok',
+    assistantMessage: `已切换到账号:${status.nickname || account.nickname || account.userId || account.uid}`,
+    summary: {
+      switched: accountChanged,
+      storageKey: nextKey,
+      online: status.online,
+      listenerStopped: accountChanged,
+    },
+    data: { account: status },
+  };
+}
+
+async function refreshAccountStatus() {
+  const selected = activeAccountMetadata();
+  const targetWorkbench = workbench;
+  const refreshKey = accountRuntimeKey(selected);
+  if (accountStatusRefresh && accountStatusRefreshKey === refreshKey) return accountStatusRefresh;
+  accountStatusRefreshKey = refreshKey;
+  accountStatusRefresh = (async () => {
+    let next;
+    try {
+      const data = await targetWorkbench.qiwei.checkLogin();
+      const online = Number(data.userOnlineStatus) === 2 && Number(data.errorCode || 0) === 0;
+      next = {
+        uid: selected.uid,
+        guid: selected.guid,
+        userId: data.userId || selected.userId,
+        configured: data.configured !== false,
+        online,
+        nickname: data.nickname || selected.nickname || selected.userId || '当前企微账号',
+        corpName: data.corpName || selected.corpName || '',
+        statusCode: data.userOnlineStatus ?? null,
+        statusText: online ? '账号在线' : '账号离线',
+      };
+      if (online) {
+        accountLastOnlineAt.set(refreshKey, Date.now());
+        accountOfflineChecks.set(refreshKey, 0);
+      } else {
+        const offlineChecks = Number(accountOfflineChecks.get(refreshKey) || 0) + 1;
+        accountOfflineChecks.set(refreshKey, offlineChecks);
+        const lastOnlineAt = Number(accountLastOnlineAt.get(refreshKey) || 0);
+        if (offlineChecks < 2 && lastOnlineAt && Date.now() - lastOnlineAt < ONLINE_STATUS_GRACE_MS) {
+          next.online = true;
+          next.statusCode = 2;
+          next.statusText = '账号在线(正在复核)';
+        }
+      }
+    } catch {
+      next = {
+        ...provisionalAccountStatus(selected, '状态检测失败'),
+        configured: targetWorkbench.qiwei.isConfigured(),
+      };
+      const lastOnlineAt = Number(accountLastOnlineAt.get(refreshKey) || 0);
+      if (lastOnlineAt && Date.now() - lastOnlineAt < ONLINE_STATUS_GRACE_MS) {
+        next.online = true;
+        next.statusCode = 2;
+        next.statusText = '账号在线(状态刷新中)';
+      }
+    }
+    if (accountRuntimeKey(activeAccountMetadata()) === refreshKey) {
+      accountStatusCache = { checkedAt: Date.now(), value: next };
+    }
+    return next;
+  })().finally(() => {
+    if (accountStatusRefreshKey === refreshKey) {
+      accountStatusRefresh = null;
+      accountStatusRefreshKey = '';
+    }
+  });
+  return accountStatusRefresh;
+}
+
+async function detectAccountStatus(force = false) {
+  const selected = activeAccountMetadata();
+  const selectedKey = accountRuntimeKey(selected);
+  const cacheMatches = accountStatusCache.value && accountRuntimeKey(accountStatusCache.value) === selectedKey;
+  if (force) return refreshAccountStatus();
+  if (cacheMatches) {
+    if (Date.now() - accountStatusCache.checkedAt >= 8000) void refreshAccountStatus();
+    return accountStatusCache.value;
+  }
+  const provisional = provisionalAccountStatus(selected);
+  accountStatusCache = { checkedAt: Date.now(), value: provisional };
+  void refreshAccountStatus();
+  return provisional;
+}
+
+function maskedId(value) {
+  const text = String(value || '');
+  if (text.length <= 4) return '测试联系人';
+  return `${text.slice(0, 2)}***${text.slice(-2)}`;
+}
+
+function completeness(profile = {}) {
+  const values = [
+    profile.preferredRegion || profile.region || profile.district || profile.intent_area || profile.districts,
+    profile.budgetWan || profile.budget || profile.budgetMax || profile.budget_max,
+    profile.layout || profile.rooms || profile.house_type,
+    profile.area || profile.areaMin || profile.area_min,
+    profile.decoration,
+    profile.timeline || profile.urgency,
+  ];
+  return Math.round(values.filter(value => Array.isArray(value) ? value.length : Boolean(value)).length / values.length * 100);
+}
+
+function parseJson(value, fallback) {
+  try { return value ? JSON.parse(value) : fallback; }
+  catch { return fallback; }
+}
+
+function propertyMatches(toolTrace = []) {
+  const call = [...toolTrace].reverse().find(item => item.tool === 'search_properties');
+  return (call?.result?.items || []).map(item => ({
+    community: item.community,
+    title: item.layout,
+    price: item.totalPrice,
+    layout: item.layout,
+    area: item.area,
+    score: null,
+    level: call.result.warning || '',
+    highlights: item.highlights || [],
+  }));
+}
+
+function propertyRecommendationsFromTrace(toolTrace = []) {
+  return [...new Map((toolTrace || []).filter(item => item?.tool === 'search_properties').flatMap(item => item.result?.items || []).filter(item => item?.id).map(item => [String(item.id), item])).values()];
+}
+
+function detectedPropertiesInMessage(content, properties = []) {
+  const text = String(content || '');
+  if (!text) return [];
+  return properties.filter(property => {
+    const id = String(property.id || '').trim();
+    if (id && text.includes(id)) return true;
+    const community = String(property.community || '').trim();
+    const price = Number(property.totalPrice || 0);
+    if (!community || !price || !text.includes(community)) return false;
+    return new RegExp(`${price}(?:\\.0+)?\\s*万`).test(text);
+  });
+}
+
+function backfillPropertyRecommendations(db, knowledge) {
+  if (!db || typeof db.upsertCustomerRecommendations !== 'function') return 0;
+  const properties = Array.isArray(knowledge?.properties) ? knowledge.properties : [];
+  let count = 0;
+  for (const conversation of db.listConversations()) {
+    for (const draft of db.listDrafts({ conversationId: conversation.id, limit: 500 })) {
+      const items = propertyRecommendationsFromTrace(draft.tool_trace);
+      if (!items.length || draft.status === 'rejected') continue;
+      db.upsertCustomerRecommendations(conversation.id, items, {
+        type: 'agent-tool',
+        entityId: draft.id,
+        status: ['sent', 'approved'].includes(draft.status) ? 'recommended' : 'candidate',
+        evidence: draft.status === 'sent' ? String(draft.content || '').slice(0, 500) : 'Agent 房源工具查询结果,尚未确认已发送给客户',
+        createdAt: draft.created_at,
+      });
+      count += items.length;
+    }
+    if (!properties.length) continue;
+    for (const message of db.listMessages(conversation.id, 500).filter(item => item.direction === 'outbound')) {
+      const items = detectedPropertiesInMessage(message.content, properties);
+      if (!items.length) continue;
+      db.upsertCustomerRecommendations(conversation.id, items, {
+        type: 'outbound-message-detected',
+        entityId: message.id,
+        status: 'recommended',
+        evidence: String(message.content || '').slice(0, 500),
+        createdAt: message.created_at,
+      });
+      count += items.length;
+    }
+  }
+  return count;
+}
+
+function publicConversation(row) {
+  const detail = workbench.service.conversationDetail(row.id);
+  const claudeSession = getCustomerSessionGuide(row, {
+    sessionFile: workbench.config.agent.claudeSessionFile,
+  });
+  const drafts = detail.drafts || [];
+  const latestInbound = [...(detail.messages || [])].reverse().find(message => message.direction === 'inbound') || null;
+  const currentDrafts = latestInbound
+    ? drafts.filter(item => item.inbound_message_id === latestInbound.id)
+    : [];
+  const pending = currentDrafts.find(item => item.status === 'pending') || null;
+  const latestDraft = pending || currentDrafts.find(item => ['sent', 'approved'].includes(item.status)) || null;
+  const rawProfile = detail.profile?.profile || {};
+  const { __evidence: profileEvidence = {}, ...profile } = rawProfile;
+  const customerTasks = detail.tasks || [];
+  const customerAlerts = detail.alerts || [];
+  const customerRecommendations = detail.recommendations || [];
+  const citations = latestDraft?.citations || [];
+  const cutoverAt = Date.parse(workbench.db.getSetting('agent_cutover_at', '')) || Date.now();
+  const displayMessages = [...new Map((detail.messages || []).map(message => [message.id, message])).values()]
+    .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))
+    .slice(-60);
+  const visibleEntityIds = new Set(displayMessages.map(message => message.id));
+  const visibleAudit = (detail.audit || []).filter(item =>
+    Date.parse(item.created_at) >= cutoverAt || visibleEntityIds.has(item.entity_id)
+  ).slice(0, 40);
+  return {
+    id: row.id,
+    displayName: row.contact_name || '白名单测试联系人',
+    maskedId: maskedId(row.contact_id),
+    mode: row.mode,
+    source: 'live',
+    claudeSession,
+    messages: displayMessages.map(message => ({
+      id: message.id,
+      role: message.direction === 'inbound' ? 'customer' : message.sender_type,
+      content: message.content,
+      timestamp: message.created_at,
+      status: message.status,
+      source: message.direction === 'inbound' ? 'live' : message.sender_type,
+    })),
+    analysis: {
+      intent: latestDraft?.intent || '',
+      intentLabel: latestDraft?.intent || (detail.agentError ? 'Agent 上游不可用' : '待 Agent 处理'),
+      demand: profile,
+      completenessScore: completeness(profile),
+      matches: propertyMatches(latestDraft?.tool_trace || []),
+      knowledgeSources: citations.length
+        ? citations.map(item => `${item.heading || item.source}${item.source ? ` · ${item.source}` : ''}`)
+        : ['真实企微消息', '客户画像', '企业规则库与知识库'],
+      reasoning: latestDraft?.reason || detail.agentError?.message || '消息已进入真实企微链路,等待 Agent 生成可审核草稿。',
+    },
+    customerIntelligence: {
+      profile,
+      profileEvidence,
+      profileUpdatedAt: detail.profile?.updatedAt || null,
+      tags: detail.profile?.tags || [],
+      tasks: customerTasks.map(item => ({
+        id: item.id,
+        businessKey: item.business_key,
+        type: item.type,
+        title: item.title,
+        owner: item.owner,
+        dueAt: item.due_at,
+        priority: item.priority,
+        status: item.status,
+        reason: item.reason,
+        evidence: item.evidence,
+        evidenceItems: parseJson(item.evidence_json, []),
+        resolutionReason: item.resolution_reason,
+        officialTodoId: item.official_todo_id,
+        officialSyncStatus: item.official_sync_status,
+        officialSyncedAt: item.official_synced_at,
+        updatedAt: item.updated_at,
+      })),
+      alerts: customerAlerts.map(item => ({
+        id: item.id,
+        businessKey: item.business_key,
+        type: item.type,
+        severity: item.severity,
+        title: item.title,
+        detail: item.detail,
+        evidence: item.evidence,
+        evidenceItems: parseJson(item.evidence_json, []),
+        resolutionReason: item.resolution_reason,
+        recommendedAction: item.recommended_action,
+        status: item.status,
+        updatedAt: item.updated_at,
+      })),
+      recommendations: customerRecommendations.map(item => ({
+        id: item.id,
+        propertyId: item.property_id,
+        property: item.property_snapshot,
+        status: item.status,
+        feedbackReason: item.feedback_reason,
+        recommendCount: item.recommend_count,
+        sources: item.sources,
+        firstRecommendedAt: item.first_recommended_at,
+        lastRecommendedAt: item.last_recommended_at,
+        updatedAt: item.updated_at,
+      })),
+      summary: {
+        openTasks: customerTasks.filter(item => ['open', 'in_progress'].includes(item.status)).length,
+        openAlerts: customerAlerts.filter(item => item.status === 'open').length,
+        highAlerts: customerAlerts.filter(item => item.status === 'open' && ['high', 'critical'].includes(item.severity)).length,
+        recommendationCount: customerRecommendations.length,
+        pendingFeedbackCount: customerRecommendations.filter(item => ['candidate', 'recommended'].includes(item.status)).length,
+      },
+    },
+    pendingReply: pending ? {
+      id: pending.id,
+      content: pending.content,
+      status: pending.status,
+      confidence: pending.confidence,
+      reason: pending.reason,
+      requiresHuman: pending.requires_human,
+      citations: pending.citations,
+      toolTrace: pending.tool_trace,
+      createdAt: pending.created_at,
+    } : null,
+    drafts,
+    audit: visibleAudit,
+    agentError: detail.agentError,
+    lastMessageAt: displayMessages.at(-1)?.created_at || row.last_message_at,
+    updatedAt: row.updated_at,
+  };
+}
+
+async function getAgentStatus() {
+  const account = await detectAccountStatus();
+  const state = workbench.service.state(workbench.poller.status());
+  return {
+    status: 'ok',
+    data: {
+      globalMode: state.global.paused ? 'paused' : state.global.defaultMode,
+      global: state.global,
+      listener: state.poller,
+      account,
+      agent: state.agent,
+      knowledge: workbench.knowledge.stats(),
+      config: {
+        allowedSenderCount: state.qiwei.allowlistCount,
+        testMode: false,
+        demoMode: false,
+        transport: state.qiwei.transport,
+        pollIntervalMs: workbench.config.qiwei.intervalMs,
+      },
+      safety: {
+        whitelistEnabled: state.qiwei.allowlistCount > 0,
+        defaultReviewMode: true,
+        globalPauseSupported: true,
+        messageSendRequiresWhitelist: true,
+        demoReplyDisabled: true,
+      },
+    },
+  };
+}
+
+function displayableConversations() {
+  return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
+}
+
+function getConversations() {
+  return { status: 'ok', data: { conversations: displayableConversations().map(publicConversation) } };
+}
+
+function updateCustomerProfile(conversationId, input = {}) {
+  const conversation = workbench.db.getConversation(conversationId);
+  if (!conversation) throw new Error('客户会话不存在');
+  const current = workbench.db.getProfile(conversationId);
+  const nextProfile = { ...(current.profile || {}) };
+  const evidence = { ...(nextProfile.__evidence || {}) };
+  const patch = input.profile && typeof input.profile === 'object' ? input.profile : {};
+  const changedFields = [];
+  for (const [field, rawValue] of Object.entries(patch)) {
+    if (!field || field.startsWith('__')) continue;
+    const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
+    if (value === '' || value === null || value === undefined) delete nextProfile[field];
+    else nextProfile[field] = value;
+    evidence[field] = {
+      text: String(input.reason || '客户管理人工核对').trim(),
+      sourceMessageId: null,
+      source: 'human',
+      updatedAt: new Date().toISOString(),
+    };
+    changedFields.push(field);
+  }
+  nextProfile.__evidence = evidence;
+  const tags = input.tags === undefined
+    ? current.tags
+    : [...new Set((Array.isArray(input.tags) ? input.tags : String(input.tags || '').split(/[,,]/)).map(item => String(item).trim()).filter(Boolean))];
+  const updated = workbench.db.updateProfile(conversationId, nextProfile, tags);
+  workbench.db.audit({
+    actor: 'human',
+    action: 'customer_profile_updated',
+    conversationId,
+    entityId: conversationId,
+    detail: { fields: changedFields, tagCount: tags.length, reason: String(input.reason || '').trim() },
+  });
+  const { __evidence, ...visibleProfile } = updated.profile || {};
+  return {
+    status: 'ok',
+    assistantMessage: `客户“${conversation.contact_name || '未命名客户'}”的主档已更新。`,
+    summary: { changedFields, tagCount: tags.length },
+    data: { profile: visibleProfile, profileEvidence: __evidence || {}, tags, updatedAt: updated.updatedAt },
+    warnings: [],
+    errors: [],
+  };
+}
+
+function updateCustomerRecommendation(conversationId, recommendationId, input = {}) {
+  const recommendation = workbench.db.updateCustomerRecommendation(conversationId, recommendationId, {
+    status: input.status,
+    feedbackReason: input.feedbackReason,
+  });
+  if (!recommendation) throw new Error('房源推荐记录不存在');
+  const current = workbench.db.getProfile(conversationId);
+  const existingFeedback = Array.isArray(current.profile?.propertyFeedback) ? current.profile.propertyFeedback : [];
+  const feedback = {
+    propertyId: recommendation.property_id,
+    status: recommendation.status,
+    reason: recommendation.feedback_reason,
+    recordedAt: new Date().toISOString(),
+  };
+  const byProperty = new Map(existingFeedback.map(item => [String(item.propertyId || ''), item]));
+  byProperty.set(String(feedback.propertyId), feedback);
+  const evidence = { ...(current.profile?.__evidence || {}) };
+  evidence.propertyFeedback = { text: recommendation.feedback_reason || `人工标记为${recommendation.status}`, sourceMessageId: null, source: 'human', updatedAt: feedback.recordedAt };
+  workbench.db.updateProfile(conversationId, { ...current.profile, propertyFeedback: [...byProperty.values()].slice(-50), __evidence: evidence }, current.tags);
+  workbench.db.audit({ actor: 'human', action: 'property_recommendation_feedback', conversationId, entityId: recommendation.id, detail: { propertyId: recommendation.property_id, status: recommendation.status, reason: recommendation.feedback_reason } });
+  return {
+    status: 'ok',
+    assistantMessage: `房源“${recommendation.property_snapshot?.community || recommendation.property_id}”反馈已记录为 ${recommendation.status}。`,
+    summary: { recommendationId: recommendation.id, status: recommendation.status },
+    data: { recommendation },
+    warnings: [],
+    errors: [],
+  };
+}
+
+async function syncConversations() {
+  const allowlist = new Set(workbench.config.qiwei.allowedSenders.map(String));
+  if (!allowlist.size) throw new Error('测试联系人白名单为空,无法同步会话');
+  const account = await detectAccountStatus(true);
+  if (!account.online) throw new Error('测试账号当前不在线,无法同步企微会话');
+
+  const grouped = new Map();
+  const seen = new Set();
+  const seenSemantic = new Set();
+  let cursor = 0;
+  let pages = 0;
+  let scannedMessages = 0;
+
+  while (pages < workbench.config.qiwei.initialSyncMaxPages) {
+    const result = await workbench.qiwei.syncMessages(cursor, workbench.config.qiwei.initialSyncLimit);
+    const list = Array.isArray(result.syncMsgList) ? result.syncMsgList : [];
+    scannedMessages += list.length;
+
+    for (const message of list) {
+      const senderId = String(message.senderId || '');
+      const receiverId = String(message.receiverId || '');
+      const contactId = allowlist.has(senderId) ? senderId : allowlist.has(receiverId) ? receiverId : '';
+      const content = String(message.msgData?.content || '').trim();
+      if (!contactId || !content || ![0, 1, 2].includes(Number(message.msgType))) continue;
+
+      const inbound = senderId === contactId;
+      const timestampSeconds = messageTimestamp(message.timestamp);
+      if (!timestampSeconds) continue;
+      const timestamp = new Date(timestampSeconds * 1000).toISOString();
+      const externalId = String(message.msgServerId || message.msgUniqueIdentifier || '');
+      const dedupeKey = externalId || `${contactId}|${inbound ? 'in' : 'out'}|${timestamp}|${content}`;
+      if (seen.has(dedupeKey)) continue;
+      seen.add(dedupeKey);
+      const semanticKey = `${contactId}|${inbound ? 'in' : 'out'}|${timestamp}|${content}`;
+      if (seenSemantic.has(semanticKey)) continue;
+      seenSemantic.add(semanticKey);
+
+      if (!grouped.has(contactId)) grouped.set(contactId, { contactName: '', messages: [] });
+      const group = grouped.get(contactId);
+      if (inbound && message.senderName) group.contactName = String(message.senderName);
+      group.messages.push({
+        externalId: externalId || null,
+        inbound,
+        content,
+        timestamp,
+        raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp, source: 'manual_sync' },
+      });
+    }
+
+    const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
+    const next = Math.max(cursor, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
+    pages += 1;
+    if (!list.length || next <= cursor) break;
+    cursor = next;
+  }
+
+  let syncedMessages = 0;
+  let removedImportedMessages = 0;
+  for (const [contactId, group] of grouped.entries()) {
+    const conversation = workbench.db.ensureConversation(contactId, group.contactName || '白名单企微联系人');
+    removedImportedMessages += workbench.db.deleteImportedMessages(conversation.id, 'manual_sync');
+    const ordered = group.messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
+    const recent = ordered.slice(-20);
+    for (const message of recent) {
+      const inserted = workbench.db.insertMessage({
+        conversationId: conversation.id,
+        externalId: message.externalId,
+        direction: message.inbound ? 'inbound' : 'outbound',
+        senderType: message.inbound ? 'customer' : 'human',
+        content: message.content,
+        status: message.inbound ? 'received' : 'sent',
+        createdAt: message.timestamp,
+        raw: message.raw,
+      });
+      if (inserted.created) syncedMessages += 1;
+    }
+  }
+
+  const duplicatesRemoved = workbench.db.cleanupInboundContentDuplicates(60);
+  workbench.db.audit({
+    actor: 'human',
+    action: 'conversation_history_synced',
+    detail: { conversations: grouped.size, insertedMessages: syncedMessages, removedImportedMessages, scannedMessages, duplicatesRemoved },
+  });
+
+  return {
+    status: 'ok',
+    assistantMessage: grouped.size
+      ? `已补采 ${grouped.size} 个白名单真实会话的最近消息;只入库,不运行 Agent、不发送回复`
+      : '未读取到白名单联系人的文字会话,请先在企微中与该联系人收发一条消息',
+    data: {
+      syncedConversationCount: grouped.size,
+      syncedMessageCount: syncedMessages,
+      scannedMessageCount: scannedMessages,
+      conversations: displayableConversations().map(publicConversation),
+    },
+  };
+}
+
+function changeGlobalMode(mode) {
+  const selected = String(mode || '');
+  let update;
+  if (['paused', 'monitor'].includes(selected)) update = { paused: true };
+  else if (['review', 'suggest'].includes(selected)) update = { paused: false, defaultMode: 'review' };
+  else if (selected === 'auto') update = { paused: false, defaultMode: 'auto' };
+  else if (selected === 'human') update = { paused: false, defaultMode: 'human' };
+  else throw new Error('不支持的 Agent 模式');
+  const global = workbench.service.setGlobal(update);
+  return {
+    status: 'ok',
+    assistantMessage: global.paused ? 'Agent 已全局暂停;仍可接收消息,但不会生成或发送回复' : `全局策略已切换为 ${global.defaultMode}`,
+    data: { global, globalMode: global.paused ? 'paused' : global.defaultMode },
+  };
+}
+
+function changeConversationMode(id, mode) {
+  const mapped = mode === 'agent' ? 'review' : mode === 'manual' ? 'human' : mode;
+  const conversation = workbench.service.setConversationMode(id, mapped);
+  const labels = { review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' };
+  return { status: 'ok', assistantMessage: `会话已切换为${labels[mapped]}`, data: { conversation } };
+}
+
+async function approveReply(id, content) {
+  const detail = workbench.service.conversationDetail(id);
+  if (!detail) throw new Error('会话不存在');
+  const pending = detail.drafts.find(item => item.status === 'pending');
+  const result = pending
+    ? await workbench.service.approveDraft(pending.id, { content, actor: 'human' })
+    : { status: 'sent', message: await workbench.service.manualSend(id, content, 'human') };
+  return { status: 'ok', assistantMessage: '回复已真实发送给白名单测试联系人', data: result };
+}
+
+async function approveDraft(draftId, content) {
+  const result = await workbench.service.approveDraft(draftId, { content, actor: 'human' });
+  return { status: 'ok', assistantMessage: '审核通过,回复已真实发送且只发送一次', data: result };
+}
+
+function rejectDraft(draftId, reason) {
+  const result = workbench.service.rejectDraft(draftId, { reason, actor: 'human' });
+  return { status: 'ok', assistantMessage: '草稿已驳回,不会发送给客户', data: result };
+}
+
+async function regenerateDraft(draftId) {
+  const result = await workbench.service.regenerateDraft(draftId, 'human');
+  return {
+    status: 'ok',
+    assistantMessage: result.status === 'pending_review' ? 'Agent 已重新生成待审核草稿' : (result.error || 'Agent 重新生成未完成'),
+    data: result,
+  };
+}
+
+async function generateLatestDraft(conversationId) {
+  const result = await workbench.service.generateLatestDraft(conversationId, 'human');
+  return {
+    status: 'ok',
+    assistantMessage: result.status === 'pending_review' ? 'Agent 已生成待审核草稿' : (result.error || 'Agent 未生成草稿'),
+    data: result,
+  };
+}
+
+async function manualSend(conversationId, content) {
+  const message = await workbench.service.manualSend(conversationId, content, 'human');
+  return { status: 'ok', assistantMessage: '人工回复已真实发送给白名单测试联系人', data: { message } };
+}
+
+async function updateCustomerTask(taskId, input = {}) {
+  if (!['open', 'in_progress', 'done', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户待办状态');
+  const existing = workbench.db.getCustomerTask(taskId);
+  if (!existing) throw new Error('客户待办不存在');
+  let officialResult = null;
+  if (input.status === 'done' && existing.official_todo_id) {
+    officialResult = await completeTodoKnowledge(existing.official_todo_id);
+  }
+  const officialOk = !officialResult || officialResult.status === 'ok';
+  const task = workbench.db.updateCustomerTask(taskId, {
+    status: input.status,
+    resolution_reason: input.status === 'done' ? 'human_completed' : input.status === 'dismissed' ? 'human_dismissed' : '',
+    ...(existing.official_todo_id ? { official_sync_status: officialOk ? input.status : 'error' } : {}),
+  });
+  if (!task) throw new Error('客户待办不存在');
+  workbench.db.audit({ actor: 'human', action: 'customer_task_updated', conversationId: task.conversation_id, entityId: task.id, detail: { status: task.status, officialSynced: Boolean(existing.official_todo_id), officialOk } });
+  return {
+    status: 'ok',
+    assistantMessage: existing.official_todo_id && !officialOk
+      ? `本地待办已更新为 ${task.status},但企微官方待办同步失败,请稍后重试`
+      : `客户待办已更新为 ${task.status}${existing.official_todo_id ? ',企微官方待办已同步' : ''}`,
+    data: { task, officialResult },
+    warnings: existing.official_todo_id && !officialOk ? ['企微官方待办状态尚未同步'] : [],
+  };
+}
+
+async function syncCustomerTaskToOfficialTodo(taskId, input = {}) {
+  const syncCurrentAccountTask = createCustomerTaskOfficialSync({
+    db: workbench.db,
+    searchTodoUsers,
+    createTodoKnowledge,
+  });
+  return syncCurrentAccountTask(taskId, input);
+}
+
+function updateCustomerAlert(alertId, input = {}) {
+  if (!['open', 'acknowledged', 'resolved', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户预警状态');
+  const alert = workbench.db.updateCustomerAlert(alertId, { status: input.status });
+  if (!alert) throw new Error('客户预警不存在');
+  workbench.db.audit({ actor: 'human', action: 'customer_alert_updated', conversationId: alert.conversation_id, entityId: alert.id, detail: { status: alert.status } });
+  return { status: 'ok', assistantMessage: `客户预警已更新为 ${alert.status}`, data: { alert } };
+}
+
+function getAudit(limit = 200) {
+  return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(limit) || 200))) } };
+}
+
+async function startListener() {
+  const account = await detectAccountStatus(true);
+  if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
+  workbench.service.setGlobal({ paused: false, defaultMode: 'auto' });
+  for (const conversation of workbench.db.listConversations()) {
+    workbench.service.setConversationMode(conversation.id, 'auto');
+  }
+  let status;
+  try {
+    status = await workbench.poller.start();
+  } catch (error) {
+    workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
+    for (const conversation of workbench.db.listConversations()) {
+      workbench.service.setConversationMode(conversation.id, 'human');
+    }
+    throw error;
+  }
+  return {
+    status: 'ok',
+    assistantMessage: 'AI 监听已启动:仅处理白名单联系人,高置信回复可自动发送,人工可随时接管',
+    data: status,
+  };
+}
+
+function stopListener() {
+  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');
+  }
+  return { status: 'ok', assistantMessage: 'AI 监听已关闭,现有会话已切换为人工接管', data: status };
+}
+
+function getAgentRuntimeConfig() {
+  return { ...workbench.config.agent };
+}
+
+module.exports = {
+  switchActiveAccount,
+  getAgentStatus,
+  getConversations,
+  updateCustomerProfile,
+  updateCustomerRecommendation,
+  syncConversations,
+  changeGlobalMode,
+  changeConversationMode,
+  approveReply,
+  approveDraft,
+  rejectDraft,
+  regenerateDraft,
+  generateLatestDraft,
+  manualSend,
+  updateCustomerTask,
+  syncCustomerTaskToOfficialTodo,
+  updateCustomerAlert,
+  getAudit,
+  startListener,
+  stopListener,
+  getAgentRuntimeConfig,
+  createWorkbench,
+  __testing: { loadAgentConfig, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage },
+};

Diferenças do arquivo suprimidas por serem muito extensas
+ 1597 - 19
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js


+ 248 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js

@@ -0,0 +1,248 @@
+const fs = require('fs');
+const path = require('path');
+const { outputsRoot } = require('../core/output-paths');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { getConversations, updateCustomerProfile, updateCustomerRecommendation } = require('./agent-service');
+
+const FIELD_LABELS = {
+  intent: '客户意图',
+  purpose: '购置用途',
+  preferredRegion: '意向区域',
+  region: '意向区域',
+  district: '意向区域',
+  budgetWan: '预算',
+  budgetRange: '预算范围',
+  budgetType: '预算口径',
+  layout: '意向户型',
+  houseType: '意向户型',
+  area: '面积偏好',
+  decoration: '装修偏好',
+  floorPreference: '楼层偏好',
+  timeline: '购置时间',
+  urgency: '紧迫度',
+  keyConcerns: '核心关注',
+  schoolRequirement: '学区需求',
+  decisionMaker: '决策人',
+  loanCapacity: '贷款能力',
+  negotiationStage: '谈判阶段',
+  aiSummary: '画像摘要',
+};
+
+function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); return dirPath; }
+
+function writeJsonAtomic(filePath, value) {
+  ensureDir(path.dirname(filePath));
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+
+function profileValue(value, field) {
+  if (Array.isArray(value)) return value.join('、');
+  if (value && typeof value === 'object') return value.note || value.value || JSON.stringify(value);
+  if (field === 'budgetWan' && Number.isFinite(Number(value))) return `${Number(value)} 万`;
+  return String(value ?? '');
+}
+
+function customerStage(conversation, fieldCount) {
+  const summary = conversation.customerIntelligence?.summary || {};
+  if (summary.highAlerts > 0) return { key: 'priority', label: '重点跟进' };
+  if (summary.openTasks > 0 && Number(conversation.analysis?.completenessScore || 0) >= 50) return { key: 'qualified', label: '需求已成形' };
+  if (fieldCount > 0) return { key: 'profiling', label: '画像积累中' };
+  return { key: 'new', label: '待完善' };
+}
+
+function hasProfileValue(profile, ...keys) {
+  return keys.some(key => {
+    const value = profile?.[key];
+    return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && String(value).trim() !== '' && String(value).trim() !== '待确认';
+  });
+}
+
+function recommendationStatusLabel(status) {
+  return ({ candidate: 'Agent 候选', recommended: '已推荐·待反馈', interested: '客户感兴趣', rejected: '客户不合适', viewing: '已约带看', viewed: '已带看', closed: '已关闭' })[status] || status || '待确认';
+}
+
+function buildNextActions(customer) {
+  const actions = [];
+  const add = (id, title, description, evidence, priority = 'medium', confidence = 'rule') => actions.push({ id, title, description, evidence: (evidence || []).filter(Boolean), priority, confidence, status: 'suggested', action: 'open-conversation' });
+  const profile = customer.profile || {};
+  const openTaskEvidence = customer.tasks.find(task => ['open', 'in_progress'].includes(task.status))?.evidence;
+  if (!hasProfileValue(profile, 'intent', 'purpose')) add('confirm-purpose', '确认购置用途', '询问客户是自住、投资还是为家人购置,再调整房源权重和沟通重点。', [openTaskEvidence, '客户主档中购置用途尚未确认'], 'high');
+  if (!hasProfileValue(profile, 'timeline')) add('confirm-timeline', '确认购置时间', '确认客户计划何时购置,以判断跟进频率和是否需要安排带看。', [openTaskEvidence, '客户主档中购置时间尚未确认'], 'high');
+  if (!hasProfileValue(profile, 'floorPreference')) add('confirm-floor', '补充楼层偏好', '在继续扩大推荐前确认楼层、电梯和采光偏好。', ['现有画像已有区域、预算和户型,但没有楼层偏好'], 'medium');
+  if (String(profile.budgetType || '') === '待确认') add('confirm-budget-type', '确认预算口径', '确认 200 万是总价上限、理想预算还是包含税费装修。', [`预算已记录为 ${profile.budgetWan || '-'} 万,但预算口径为待确认`], 'medium');
+  const awaitingFeedback = customer.recommendations.filter(item => item.status === 'recommended');
+  if (awaitingFeedback.length) add('collect-property-feedback', '收集已推荐房源反馈', `已有 ${awaitingFeedback.length} 套房源处于“已推荐·待反馈”,先确认喜欢和排斥点再继续匹配。`, awaitingFeedback.slice(0, 3).map(item => `${item.property.community || item.propertyId} · ${item.property.totalPrice || '-'} 万`), 'high');
+  if (!customer.recommendations.length && customer.completenessScore >= 50) add('prepare-first-shortlist', '生成首轮重点方案', '核心需求已基本成形,可从房源库筛选 2—3 套差异化方案供客户比较。', customer.fields.slice(0, 4).map(field => `${field.label}:${field.displayValue}`), 'medium');
+  for (const alert of customer.alerts.filter(item => item.status === 'open' && ['high', 'critical'].includes(item.severity)).slice(0, 2)) add(`alert:${alert.id}`, alert.recommendedAction || alert.title, alert.detail || '优先处理高等级客户预警。', [alert.evidence], 'high');
+  return actions.slice(0, 6);
+}
+
+function buildTimeline(customer) {
+  const events = [];
+  for (const message of customer.messages || []) {
+    events.push({ id: `message:${message.id}`, type: message.role === 'customer' ? 'inbound-message' : 'outbound-message', title: message.role === 'customer' ? '收到客户消息' : '发送客户回复', description: String(message.content || '').slice(0, 300), occurredAt: message.timestamp, source: message.source || message.role, fact: true });
+  }
+  if (customer.profileUpdatedAt && customer.fields.length) events.push({ id: `profile:${customer.profileUpdatedAt}`, type: 'profile', title: '客户画像已更新', description: customer.fields.slice(0, 5).map(field => `${field.label}:${field.displayValue}`).join(' · '), occurredAt: customer.profileUpdatedAt, source: 'customer-intelligence-db', fact: true });
+  for (const task of customer.tasks || []) events.push({ id: `task:${task.id}`, type: 'task', title: `内部任务 · ${task.title}`, description: task.evidence || task.reason || '', occurredAt: task.updatedAt, source: 'customer-task', status: task.status, fact: true });
+  for (const alert of customer.alerts || []) events.push({ id: `alert:${alert.id}`, type: 'alert', title: `客户预警 · ${alert.title}`, description: alert.evidence || alert.detail || '', occurredAt: alert.updatedAt, source: 'customer-alert', status: alert.status, fact: true });
+  for (const recommendation of customer.recommendations || []) events.push({ id: `recommendation:${recommendation.id}`, type: 'property', title: `房源${recommendationStatusLabel(recommendation.status)}`, description: `${recommendation.property.community || recommendation.propertyId} · ${recommendation.property.totalPrice || '-'} 万${recommendation.feedbackReason ? ` · ${recommendation.feedbackReason}` : ''}`, occurredAt: recommendation.lastRecommendedAt, source: 'property-recommendation', status: recommendation.status, fact: true });
+  return events.filter(item => item.occurredAt).sort((a, b) => String(b.occurredAt).localeCompare(String(a.occurredAt))).slice(0, 80);
+}
+
+function normalizeCustomer(conversation) {
+  const intelligence = conversation.customerIntelligence || {};
+  const profile = intelligence.profile || {};
+  const profileEvidence = intelligence.profileEvidence || {};
+  const fields = Object.entries(profile).filter(([key]) => !key.startsWith('__') && key !== 'propertyFeedback').map(([key, value]) => ({
+    key,
+    label: FIELD_LABELS[key] || key,
+    value,
+    displayValue: profileValue(value, key),
+    evidence: profileEvidence[key] ? {
+      text: String(profileEvidence[key].text || ''),
+      sourceMessageId: profileEvidence[key].sourceMessageId || null,
+      source: profileEvidence[key].source || (profileEvidence[key].sourceMessageId ? 'live-message' : 'unknown'),
+      updatedAt: profileEvidence[key].updatedAt || null,
+    } : null,
+  }));
+  const stage = customerStage(conversation, fields.length);
+  const messages = conversation.messages || [];
+  const latestMessage = messages.at(-1) || null;
+  const tasks = intelligence.tasks || [];
+  const alerts = intelligence.alerts || [];
+  const recommendations = (intelligence.recommendations || []).map(item => ({ ...item, statusLabel: recommendationStatusLabel(item.status) }));
+  const evidenced = fields.filter(item => item.evidence?.text).length;
+  const customer = {
+    id: conversation.id,
+    displayName: conversation.displayName,
+    maskedId: conversation.maskedId,
+    source: conversation.source === 'live' ? '真实企微会话' : conversation.source,
+    mode: conversation.mode,
+    stage,
+    profile,
+    fields,
+    tags: intelligence.tags || [],
+    completenessScore: Number(conversation.analysis?.completenessScore || 0),
+    evidenceCoverage: fields.length ? Math.round(evidenced / fields.length * 100) : 0,
+    profileUpdatedAt: intelligence.profileUpdatedAt || conversation.updatedAt || null,
+    lastMessageAt: conversation.lastMessageAt,
+    latestMessage: latestMessage ? { role: latestMessage.role, content: latestMessage.content, timestamp: latestMessage.timestamp } : null,
+    messages,
+    messageCount: messages.length,
+    tasks,
+    alerts,
+    recommendations,
+    openTaskCount: tasks.filter(item => ['open', 'in_progress'].includes(item.status)).length,
+    openAlertCount: alerts.filter(item => item.status === 'open').length,
+    highAlertCount: alerts.filter(item => item.status === 'open' && ['high', 'critical'].includes(item.severity)).length,
+    recommendationCount: recommendations.length,
+    pendingFeedbackCount: recommendations.filter(item => item.status === 'recommended').length,
+    actions: { openConversation: true, editProfile: true },
+  };
+  customer.nextActions = buildNextActions(customer);
+  customer.timeline = buildTimeline(customer);
+  return customer;
+}
+
+function createCustomerMasterService(dependencies = {}) {
+  const conversationReader = dependencies.getConversations || getConversations;
+  const profileUpdater = dependencies.updateCustomerProfile || updateCustomerProfile;
+  const recommendationFeedbackUpdater = dependencies.updateCustomerRecommendation || updateCustomerRecommendation;
+  const projectionPath = path.resolve(dependencies.projectionPath || path.join(outputsRoot(), 'customers', 'index.json'));
+
+  function persistProjection(customers) {
+    const snapshot = {
+      version: 1,
+      updatedAt: new Date().toISOString(),
+      customers: customers.map(customer => ({
+        id: customer.id,
+        displayName: customer.displayName,
+        maskedId: customer.maskedId,
+        source: customer.source,
+        stage: customer.stage,
+        profile: customer.profile,
+        tags: customer.tags,
+        completenessScore: customer.completenessScore,
+        evidenceCoverage: customer.evidenceCoverage,
+        profileUpdatedAt: customer.profileUpdatedAt,
+        lastMessageAt: customer.lastMessageAt,
+        openTaskCount: customer.openTaskCount,
+        openAlertCount: customer.openAlertCount,
+        recommendationCount: customer.recommendationCount,
+        pendingFeedbackCount: customer.pendingFeedbackCount,
+      })),
+    };
+    writeJsonAtomic(projectionPath, snapshot);
+    return snapshot.updatedAt;
+  }
+
+  function hub() {
+    const result = conversationReader() || {};
+    const conversations = result.data?.conversations || [];
+    const customers = conversations.map(normalizeCustomer).sort((a, b) => String(b.lastMessageAt || '').localeCompare(String(a.lastMessageAt || '')));
+    const projectedAt = persistProjection(customers);
+    return okResult({
+      assistantMessage: `客户管理已汇总 ${customers.length} 位真实会话客户,其中 ${customers.filter(item => item.fields.length).length} 位已形成画像。`,
+      summary: {
+        customerCount: customers.length,
+        profiledCount: customers.filter(item => item.fields.length).length,
+        priorityCount: customers.filter(item => item.stage.key === 'priority').length,
+        openTaskCount: customers.reduce((sum, item) => sum + item.openTaskCount, 0),
+        openAlertCount: customers.reduce((sum, item) => sum + item.openAlertCount, 0),
+        recommendationCount: customers.reduce((sum, item) => sum + item.recommendationCount, 0),
+        pendingFeedbackCount: customers.reduce((sum, item) => sum + item.pendingFeedbackCount, 0),
+        projectedAt,
+      },
+      data: {
+        customers,
+        policy: {
+          canonicalStore: 'SQLite 客户画像主账',
+          autoSync: '真实企微消息经 Agent 提取后自动更新客户主档;画像与标签工具也回写同一主账。',
+          evidence: '客户原话证据只在本机管理页面显示;导出的客户投影不复制原话。',
+          nextAction: '下一步行动属于规则或 AI 建议,不自动发送、不自动改写客户事实。',
+        },
+      },
+      files: [projectionPath],
+    });
+  }
+
+  function update(conversationId, input = {}) {
+    const id = String(conversationId || '').trim();
+    if (!id) return errorResult('缺少客户主档 ID。');
+    const result = profileUpdater(id, input);
+    if (result?.status !== 'ok') return result;
+    const refreshed = hub();
+    return okResult({
+      assistantMessage: result.assistantMessage || '客户主档已更新。',
+      summary: result.summary,
+      data: { customer: refreshed.data.customers.find(item => item.id === id) || null },
+      files: refreshed.files,
+    });
+  }
+
+  function updateRecommendation(conversationId, recommendationId, input = {}) {
+    const result = recommendationFeedbackUpdater(String(conversationId || ''), String(recommendationId || ''), input);
+    if (result?.status !== 'ok') return result;
+    const refreshed = hub();
+    return okResult({
+      assistantMessage: result.assistantMessage || '房源反馈已记录。',
+      summary: result.summary,
+      data: { customer: refreshed.data.customers.find(item => item.id === String(conversationId)) || null },
+      files: refreshed.files,
+    });
+  }
+
+  return { hub, update, updateRecommendation };
+}
+
+const service = createCustomerMasterService();
+
+module.exports = {
+  createCustomerMasterService,
+  getCustomerMasterHub: service.hub,
+  updateCustomerMaster: service.update,
+  updateCustomerRecommendationFeedback: service.updateRecommendation,
+  __testing: { normalizeCustomer, customerStage, profileValue, buildNextActions, buildTimeline, recommendationStatusLabel },
+};

+ 16 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html

@@ -3,7 +3,8 @@
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>企微助手 · 客户群管理</title>
+<title>企微智能办公助手</title>
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23fa8c16'/%3E%3Cpath d='M18 20h28v20H34l-8 7v-7h-8z' fill='white'/%3E%3C/svg%3E">
 <link rel="stylesheet" href="/dashboard/styles.css">
 </head>
 <body>
@@ -18,13 +19,25 @@
     <nav class="nav" id="nav">
       <div class="nav-group">
         <div class="nav-label">功能</div>
-        <a class="nav-item active" href="#groups" data-page="groups">
+        <a class="nav-item active" href="#agent" data-page="agent">
+          <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h3l4 4 4-4h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-8 10H7v-2h4v2zm6-4H7V7h10v2zm0 4h-4v-2h4v2z"/></svg>
+          <span>智能会话</span>
+        </a>
+        <a class="nav-item" href="#skills" data-page="skills">
+          <svg viewBox="0 0 24 24"><path d="M19 13h-2V7h-6V5H5c-1.1 0-2 .9-2 2v6h2v6c0 1.1.9 2 2 2h6v-2H7v-6h6v2h6c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2h-6v2h6v6zM9 9H5V7h4v2zm10 4h-4V7h4v6z"/></svg>
+          <span>技能中心</span>
+        </a>
+        <a class="nav-item" href="#knowledge" data-page="knowledge">
+          <svg viewBox="0 0 24 24"><path d="M10 4H2v16h20V6H12l-2-2zm10 14H4V6h5.17l2 2H20v10zM8 10h8v2H8v-2zm0 4h6v2H8v-2z"/></svg>
+          <span>知识库</span>
+        </a>
+        <a class="nav-item" href="#groups" data-page="groups">
           <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
           <span>客户群管理</span>
         </a>
         <a class="nav-item" href="#customer-ops" data-page="customer-ops">
           <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
-          <span>客户运营</span>
+          <span>客户管理</span>
         </a>
         <a class="nav-item" href="#portraits" data-page="portraits">
           <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4zM9 10c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z"/></svg>

+ 507 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/meeting-knowledge-service.js

@@ -0,0 +1,507 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { categoryDir, latestPath, slugify } = require('../core/output-paths');
+const { getOfficialCliStatus, getInitCommand, runOfficialCli, sanitizeCliText } = require('../core/wecom-cli-runtime');
+const { qiweiOfficialCall } = require('../providers/wecom-official-cli');
+const { unwrapOfficialResponse } = require('./official-office-knowledge-service');
+const {
+  AgentNotConfiguredError,
+  OpenAICompatibleClient,
+  AnthropicCompatibleClient,
+  ClaudeCodeClient,
+} = require('../core/agent-runtime');
+
+const DEFAULT_STORE_DIR = path.join(categoryDir('knowledge'), 'meetings');
+const INDEX_FILENAME = 'index.json';
+const DAY_MS = 24 * 60 * 60 * 1000;
+const STATUS_LABELS = { 1: '待开始', 2: '会议中', 3: '已结束', 4: '已取消', 5: '已过期' };
+const TYPE_LABELS = { 0: '一次性会议', 1: '周期性会议', 2: '微信专属会议', 3: 'Rooms 投屏会议', 5: '个人会议号会议', 6: '网络研讨会' };
+const SENSITIVE_KEY = /(?:password|host_?key|phone_?number|secret|access_?token|authorization|credentials?)/i;
+
+const MEETING_ANALYSIS_SCHEMA = {
+  type: 'object',
+  additionalProperties: false,
+  properties: {
+    summary: { type: 'string' },
+    topics: { type: 'array', items: { type: 'string' } },
+    decisions: { type: 'array', items: { type: 'string' } },
+    actionItems: {
+      type: 'array',
+      items: {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          title: { type: 'string' },
+          owner: { type: 'string' },
+          dueDate: { type: 'string' },
+          priority: { type: 'string', enum: ['high', 'medium', 'low'] },
+          evidence: { type: 'string' },
+          confidence: { type: 'number', minimum: 0, maximum: 1 },
+        },
+        required: ['title', 'owner', 'dueDate', 'priority', 'evidence', 'confidence'],
+      },
+    },
+    suggestedActions: { type: 'array', items: { type: 'string' } },
+    risks: { type: 'array', items: { type: 'string' } },
+    knowledgeTags: { type: 'array', items: { type: 'string' } },
+    analysisBasis: { type: 'string' },
+    requiresReview: { type: 'boolean' },
+  },
+  required: ['summary', 'topics', 'decisions', 'actionItems', 'suggestedActions', 'risks', 'knowledgeTags', 'analysisBasis', 'requiresReview'],
+};
+
+function ensureDir(dirPath) {
+  fs.mkdirSync(dirPath, { recursive: true });
+  return dirPath;
+}
+
+function readJson(filePath, fallback) {
+  try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); }
+  catch { return fallback; }
+}
+
+function writeJsonAtomic(filePath, value) {
+  ensureDir(path.dirname(filePath));
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+
+function sanitizeValue(value) {
+  if (Array.isArray(value)) return value.map(sanitizeValue);
+  if (value && typeof value === 'object') {
+    const output = {};
+    for (const [key, item] of Object.entries(value)) {
+      if (SENSITIVE_KEY.test(key)) continue;
+      output[key] = sanitizeValue(item);
+    }
+    return output;
+  }
+  return value;
+}
+
+function localDateTime(date) {
+  const pad = value => String(value).padStart(2, '0');
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+function defaultRange(now = new Date()) {
+  const begin = new Date(now.getTime() - 30 * DAY_MS);
+  begin.setHours(0, 0, 0, 0);
+  const end = new Date(now.getTime() + 30 * DAY_MS);
+  end.setHours(23, 59, 0, 0);
+  return { beginDatetime: localDateTime(begin), endDatetime: localDateTime(end) };
+}
+
+function parseLocalDateTime(value) {
+  const text = String(value || '').trim();
+  const date = new Date(text.replace(' ', 'T'));
+  return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function validateSyncInput(input = {}, now = new Date()) {
+  const defaults = defaultRange(now);
+  const beginDatetime = String(input.beginDatetime || input.begin_datetime || defaults.beginDatetime).trim();
+  const endDatetime = String(input.endDatetime || input.end_datetime || defaults.endDatetime).trim();
+  const begin = parseLocalDateTime(beginDatetime);
+  const end = parseLocalDateTime(endDatetime);
+  if (!begin || !end || begin >= end) throw new Error('会议同步时间范围无效');
+  if (end.getTime() - begin.getTime() > 61 * DAY_MS) throw new Error('会议同步范围不能超过前后 30 天');
+  const earliest = new Date(now.getTime() - 31 * DAY_MS);
+  const latest = new Date(now.getTime() + 31 * DAY_MS);
+  if (begin < earliest || end > latest) throw new Error('官方会议仅支持查询当日前后 30 天');
+  const limit = Math.max(1, Math.min(100, Number(input.limit || 20) || 20));
+  return { beginDatetime: localDateTime(begin), endDatetime: localDateTime(end), limit };
+}
+
+function officialResponse(result) {
+  return unwrapOfficialResponse(result);
+}
+
+async function defaultMeetingCapability() {
+  try {
+    const result = await runOfficialCli(['meeting', '--help'], { ensure: false, timeoutMs: 15000 });
+    if (result.exitCode === 0) {
+      return { available: true, reason: 'available', message: '当前企业已开放授权机器人的会议 CLI 权限。' };
+    }
+    const output = sanitizeCliText(`${result.stdout}\n${result.stderr}`).trim();
+    if (/暂不支持授权机器人.*会议.*权限/.test(output)) {
+      return {
+        available: false,
+        reason: 'enterprise-policy',
+        message: '扫码授权已成功,但企微官方暂未向当前企业开放授权机器人的会议 CLI 权限。10 人以上企业当前可使用文档和待办 CLI;完整会议 CLI 需使用 10 人及以下测试企业。',
+      };
+    }
+    return { available: false, reason: 'probe-failed', message: '官方会议权限检查未通过,请在企微管理后台确认机器人权限或更换测试企业。' };
+  } catch {
+    return { available: false, reason: 'probe-failed', message: '暂时无法确认官方会议权限,请稍后重试。' };
+  }
+}
+
+function analysisDefaults(status = 'pending') {
+  return {
+    status,
+    summary: '',
+    topics: [],
+    decisions: [],
+    actionItems: [],
+    suggestedActions: [],
+    risks: [],
+    knowledgeTags: [],
+    analysisBasis: '仅基于企业微信官方会议元数据与会议描述,不包含录音、转写或会议纪要。',
+    requiresReview: true,
+    analyzedAt: null,
+    error: null,
+  };
+}
+
+function normalizeMeeting(raw, meetingId, previous = null, syncedAt = new Date().toISOString()) {
+  const source = sanitizeValue(raw || {});
+  const id = String(source.meetingid || meetingId || source.current_sub_meetingid || '');
+  const startAt = String(source.meeting_start_datetime || source.start_datetime || '');
+  const durationSeconds = Number(source.meeting_duration || source.duration || 0);
+  const startDate = parseLocalDateTime(startAt);
+  const endAt = startDate && durationSeconds ? localDateTime(new Date(startDate.getTime() + durationSeconds * 1000)) : '';
+  const members = Array.isArray(source.attendees?.member) ? source.attendees.member : [];
+  const external = Array.isArray(source.attendees?.tmp_external_user) ? source.attendees.tmp_external_user : [];
+  const guests = Array.isArray(source.guests) ? source.guests : [];
+  const participantRows = [...members, ...external];
+  const attendedCount = participantRows.filter(item => Number(item.status) === 1).length;
+  const sourceFingerprint = crypto.createHash('sha256').update(JSON.stringify(source)).digest('hex').slice(0, 20);
+  let analysis = previous?.analysis || analysisDefaults();
+  if (previous?.sourceFingerprint && previous.sourceFingerprint !== sourceFingerprint && analysis.status === 'completed') {
+    analysis = { ...analysis, status: 'stale', requiresReview: true };
+  }
+  return {
+    id,
+    title: String(source.title || '未命名会议'),
+    startAt,
+    endAt,
+    durationSeconds,
+    description: String(source.description || ''),
+    location: String(source.location || ''),
+    status: Number(source.status || 0),
+    statusLabel: STATUS_LABELS[Number(source.status)] || '未知状态',
+    meetingType: Number(source.meeting_type ?? -1),
+    meetingTypeLabel: TYPE_LABELS[Number(source.meeting_type)] || '其他会议',
+    meetingCode: String(source.meeting_code || ''),
+    meetingLink: String(source.meeting_link || ''),
+    creatorId: String(source.creator_userid || source.admin_userid || ''),
+    attendees: {
+      internalCount: members.length,
+      externalCount: external.length + guests.length,
+      attendedCount,
+      absentCount: participantRows.filter(item => Number(item.status) === 2).length,
+      memberIds: members.map(item => String(item.userid || '')).filter(Boolean),
+    },
+    settings: {
+      waitingRoom: Boolean(source.settings?.enable_waiting_room),
+      allowExternalUser: Boolean(source.settings?.allow_external_user),
+      autoRecordType: String(source.settings?.auto_record_type || ''),
+      hasVote: Boolean(source.has_vote),
+    },
+    analysis,
+    sourceKind: 'official-cli-live',
+    sourceFingerprint,
+    syncedAt,
+    source,
+  };
+}
+
+function markdownList(items, emptyText = '暂无明确内容') {
+  return items?.length ? items.map(item => `- ${item}`).join('\n') : `- ${emptyText}`;
+}
+
+function meetingMarkdown(record) {
+  const analysis = record.analysis || analysisDefaults();
+  const actionItems = analysis.actionItems?.length
+    ? analysis.actionItems.map(item => `- [ ] ${item.title}${item.owner ? ` · 负责人:${item.owner}` : ''}${item.dueDate ? ` · 截止:${item.dueDate}` : ''} · 置信度:${Math.round(Number(item.confidence || 0) * 100)}%\n  - 依据:${item.evidence || '待人工确认'}`).join('\n')
+    : '- 暂无从会议资料中提取出的明确待办';
+  return `# ${record.title}\n\n> 数据来源:企业微信官方 CLI(真实会议)  \n> 同步时间:${record.syncedAt}\n\n## 会议信息\n\n- 会议 ID:\`${record.id}\`\n- 时间:${record.startAt || '待确认'}${record.endAt ? ` - ${record.endAt}` : ''}\n- 时长:${record.durationSeconds ? `${Math.round(record.durationSeconds / 60)} 分钟` : '待确认'}\n- 状态:${record.statusLabel}\n- 类型:${record.meetingTypeLabel}\n- 地点:${record.location || '未填写'}\n- 参会统计:内部 ${record.attendees.internalCount} 人,外部 ${record.attendees.externalCount} 人,已参会 ${record.attendees.attendedCount} 人\n\n## 原始描述\n\n${record.description || '未填写会议描述。'}\n\n## AI 分析\n\n- 状态:${analysis.status === 'completed' ? '已完成' : analysis.status === 'stale' ? '资料变化,待重新分析' : analysis.status === 'failed' ? '分析失败,未生成伪结论' : '待生成'}\n- 摘要:${analysis.summary || '暂无'}\n- 分析边界:${analysis.analysisBasis}\n\n### 关键议题\n\n${markdownList(analysis.topics)}\n\n### 明确决策\n\n${markdownList(analysis.decisions, '会议资料中没有可验证的明确决策')}\n\n### 待办候选\n\n${actionItems}\n\n### 建议后续动作\n\n${markdownList(analysis.suggestedActions)}\n\n### 风险与待确认\n\n${markdownList(analysis.risks)}\n\n### 知识标签\n\n${analysis.knowledgeTags?.length ? analysis.knowledgeTags.map(item => `\`${item}\``).join(' ') : '暂无'}\n`;
+}
+
+function recordDirectory(storeDir, record) {
+  const year = /^\d{4}/.test(record.startAt) ? record.startAt.slice(0, 4) : 'unknown-year';
+  const idHash = crypto.createHash('sha256').update(record.id).digest('hex').slice(0, 10);
+  return path.join(storeDir, 'records', year, `${slugify(record.title, 'meeting')}-${idHash}`);
+}
+
+function persistRecord(storeDir, record) {
+  const dir = ensureDir(recordDirectory(storeDir, record));
+  const jsonPath = path.join(dir, 'meeting.json');
+  const markdownPath = path.join(dir, 'knowledge.md');
+  record.files = { json: jsonPath, markdown: markdownPath };
+  writeJsonAtomic(jsonPath, record);
+  fs.writeFileSync(markdownPath, meetingMarkdown(record), 'utf8');
+  return record;
+}
+
+function parseModelJson(content) {
+  const text = String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+  const parsed = JSON.parse(text);
+  return {
+    ...analysisDefaults('completed'),
+    summary: String(parsed.summary || ''),
+    topics: Array.isArray(parsed.topics) ? parsed.topics.map(String) : [],
+    decisions: Array.isArray(parsed.decisions) ? parsed.decisions.map(String) : [],
+    actionItems: Array.isArray(parsed.actionItems) ? parsed.actionItems.map(item => ({
+      title: String(item.title || ''),
+      owner: String(item.owner || ''),
+      dueDate: String(item.dueDate || ''),
+      priority: ['high', 'medium', 'low'].includes(item.priority) ? item.priority : 'medium',
+      evidence: String(item.evidence || ''),
+      confidence: Math.max(0, Math.min(1, Number(item.confidence) || 0)),
+    })).filter(item => item.title) : [],
+    suggestedActions: Array.isArray(parsed.suggestedActions) ? parsed.suggestedActions.map(String) : [],
+    risks: Array.isArray(parsed.risks) ? parsed.risks.map(String) : [],
+    knowledgeTags: Array.isArray(parsed.knowledgeTags) ? parsed.knowledgeTags.map(String) : [],
+    analysisBasis: String(parsed.analysisBasis || analysisDefaults().analysisBasis),
+    requiresReview: Boolean(parsed.requiresReview),
+    analyzedAt: new Date().toISOString(),
+  };
+}
+
+async function defaultAnalyzer(record, storeDir) {
+  const { getAgentRuntimeConfig } = require('./agent-service');
+  const baseConfig = getAgentRuntimeConfig();
+  const config = {
+    ...baseConfig,
+    outputSchema: MEETING_ANALYSIS_SCHEMA,
+    claudeSessionFile: latestPath('meetings', 'claude-meeting-analysis-sessions.json'),
+    claudeProjectId: `${baseConfig.claudeProjectId || 'qiwei'}-meeting-knowledge`,
+    claudeAddDirs: [storeDir],
+  };
+  const client = config.provider === 'claude-code'
+    ? new ClaudeCodeClient(config)
+    : config.provider === 'anthropic'
+      ? new AnthropicCompatibleClient(config)
+      : new OpenAICompatibleClient(config);
+  if (typeof client.isConfigured === 'function' && !client.isConfigured()) throw new AgentNotConfiguredError();
+  const evidence = {
+    title: record.title,
+    startAt: record.startAt,
+    endAt: record.endAt,
+    durationSeconds: record.durationSeconds,
+    description: record.description,
+    location: record.location,
+    status: record.statusLabel,
+    meetingType: record.meetingTypeLabel,
+    attendeeSummary: record.attendees,
+  };
+  const system = [
+    '你是企业会议知识沉淀分析 Agent。只根据给定的真实会议元数据与会议描述进行分析。',
+    '不得把会议标题、参会统计或常识扩写成已经发生的讨论、决策或承诺。没有证据时数组保持为空。',
+    'actionItems 只放资料中有明确依据的待办;suggestedActions 才能放建议动作,并明确它们不是已确认任务。',
+    '所有结论都需要人工审核。只输出符合下面 JSON Schema 的 JSON,不要添加 Markdown 代码围栏。',
+    JSON.stringify(MEETING_ANALYSIS_SCHEMA),
+  ].join('\n');
+  const directPrompt = `分析下面的会议资料并形成知识沉淀:\n${JSON.stringify(evidence, null, 2)}`;
+  const result = await client.complete(
+    [{ role: 'system', content: system }, { role: 'user', content: directPrompt }],
+    [],
+    { conversation: { id: `meeting:${record.id}`, displayName: record.title }, directPrompt },
+  );
+  return parseModelJson(result.content);
+}
+
+function createMeetingKnowledgeService(dependencies = {}) {
+  const storeDir = path.resolve(dependencies.storeDir || DEFAULT_STORE_DIR);
+  const officialStatus = dependencies.officialStatus || getOfficialCliStatus;
+  const officialCall = dependencies.officialCall || qiweiOfficialCall;
+  const analyzer = dependencies.analyzer || (record => defaultAnalyzer(record, storeDir));
+  const initCommand = dependencies.initCommand || getInitCommand();
+  const meetingCapability = dependencies.meetingCapability || defaultMeetingCapability;
+  const indexPath = path.join(storeDir, INDEX_FILENAME);
+  let capabilityCache = null;
+
+  async function getMeetingCapability(cli) {
+    if (!cli.ready) return { available: false, reason: 'not-authorized', message: '完成官方 CLI 扫码授权后才能检查会议权限。' };
+    if (capabilityCache && Date.now() - capabilityCache.checkedAt < 30000) return capabilityCache;
+    capabilityCache = { ...(await meetingCapability()), checkedAt: Date.now() };
+    return capabilityCache;
+  }
+
+  function readIndex() {
+    return readJson(indexPath, { version: 1, lastSyncedAt: null, range: null, meetings: [] });
+  }
+
+  function writeIndex(index) {
+    writeJsonAtomic(indexPath, index);
+  }
+
+  async function hub() {
+    ensureDir(storeDir);
+    const cli = await officialStatus();
+    const [capability, index] = await Promise.all([getMeetingCapability(cli), Promise.resolve(readIndex())]);
+    const meetings = Array.isArray(index.meetings) ? index.meetings : [];
+    const actionItemCount = meetings.reduce((sum, item) => sum + (item.analysis?.actionItems?.length || 0), 0);
+    const meetingReady = Boolean(cli.ready && capability.available);
+    return okResult({
+      assistantMessage: meetingReady
+        ? `官方会议通道已就绪,当前沉淀 ${meetings.length} 场会议。`
+        : cli.ready
+          ? capability.message
+          : '会议知识库已就绪;完成官方 CLI 扫码授权后即可检查会议权限。',
+      summary: {
+        cliReady: Boolean(cli.ready),
+        meetingReady,
+        meetingCount: meetings.length,
+        analyzedCount: meetings.filter(item => item.analysis?.status === 'completed').length,
+        actionItemCount,
+        lastSyncedAt: index.lastSyncedAt,
+      },
+      data: {
+        cli: {
+          installed: Boolean(cli.installed),
+          valid: Boolean(cli.valid),
+          authorized: Boolean(cli.authorized),
+          ready: Boolean(cli.ready),
+          meetingAvailable: Boolean(capability.available),
+          capabilityReason: capability.reason,
+          capabilityMessage: capability.message,
+          version: cli.installedVersion || cli.version || cli.expectedVersion || '',
+          initCommand: cli.initCommand || initCommand,
+        },
+        range: index.range || defaultRange(),
+        meetings,
+        storeDir,
+      },
+      nextActions: !cli.ready
+        ? [{ action: 'run_command', command: cli.initCommand || initCommand, description: '完成企业微信官方 CLI 扫码授权' }]
+        : meetingReady
+          ? []
+          : [
+            { action: 'switch_tenant', description: '使用 10 人及以下企业测试官方会议 CLI' },
+            { action: 'use_supported_capability', description: '当前企业继续演示官方文档和待办能力' },
+          ],
+    });
+  }
+
+  async function sync(input = {}) {
+    ensureDir(storeDir);
+    const cli = await officialStatus();
+    if (!cli.ready) {
+      return errorResult('企业微信官方 CLI 尚未完成扫码授权,暂不能拉取真实会议。', {
+        summary: { needsInitialization: true, cliReady: false },
+        data: { cli: { installed: Boolean(cli.installed), authorized: Boolean(cli.authorized), ready: false } },
+        nextActions: [{ action: 'run_command', command: cli.initCommand || initCommand, description: '完成企业微信官方 CLI 扫码授权' }],
+      });
+    }
+    const capability = await getMeetingCapability(cli);
+    if (!capability.available) {
+      return errorResult(capability.message, {
+        summary: { needsMeetingCapability: true, cliReady: true, meetingReady: false, capabilityReason: capability.reason },
+        data: { cli: { installed: true, authorized: true, ready: true, meetingAvailable: false } },
+        nextActions: [
+          { action: 'switch_tenant', description: '使用 10 人及以下企业测试官方会议 CLI' },
+          { action: 'use_supported_capability', description: '当前企业继续演示官方文档和待办能力' },
+        ],
+      });
+    }
+    const range = validateSyncInput(input);
+    const ids = [];
+    const warnings = [];
+    let cursor = '';
+    do {
+      const result = await officialCall({
+        category: 'meeting',
+        method: 'list_user_meetings',
+        args: {
+          begin_datetime: range.beginDatetime,
+          end_datetime: range.endDatetime,
+          limit: Math.min(100, range.limit - ids.length),
+          ...(cursor ? { cursor } : {}),
+        },
+      });
+      if (result.status !== 'ok') return result;
+      const response = officialResponse(result);
+      for (const id of response.meetingid_list || []) {
+        if (!ids.includes(String(id))) ids.push(String(id));
+        if (ids.length >= range.limit) break;
+      }
+      cursor = String(response.next_cursor || '');
+    } while (cursor && ids.length < range.limit);
+
+    const previousIndex = readIndex();
+    const previousById = new Map((previousIndex.meetings || []).map(item => [item.id, item]));
+    const syncedAt = new Date().toISOString();
+    const syncedMeetings = [];
+    for (const id of ids) {
+      const detailResult = await officialCall({ category: 'meeting', method: 'get_meeting_info', args: { meetingid: id } });
+      if (detailResult.status !== 'ok') {
+        warnings.push(`会议 ${id} 详情读取失败,已跳过`);
+        continue;
+      }
+      const record = normalizeMeeting(officialResponse(detailResult), id, previousById.get(id), syncedAt);
+      syncedMeetings.push(persistRecord(storeDir, record));
+    }
+    const meetingsById = new Map((previousIndex.meetings || []).map(item => [item.id, item]));
+    for (const meeting of syncedMeetings) meetingsById.set(meeting.id, meeting);
+    const meetings = [...meetingsById.values()];
+    meetings.sort((a, b) => String(a.startAt).localeCompare(String(b.startAt)));
+    const index = { version: 1, lastSyncedAt: syncedAt, range, meetings };
+    writeIndex(index);
+    fs.writeFileSync(path.join(storeDir, 'README.md'), '# 企微会议知识库\n\n本目录由 4320 Dashboard 通过企业微信官方 CLI 同步生成。`records/` 保存脱敏后的会议详情和 AI 知识沉淀;不要在此目录写入会议密码、主持人密钥、手机号或访问凭据。\n', 'utf8');
+    return okResult({
+      assistantMessage: `已从企业微信官方 CLI 同步 ${syncedMeetings.length} 场真实会议,知识库累计 ${meetings.length} 场;尚未分析的会议可在页面逐场生成 AI 知识沉淀。`,
+      summary: { syncedCount: syncedMeetings.length, meetingCount: meetings.length, requestedCount: ids.length, live: true, ...range },
+      data: { meetings, range, storeDir },
+      files: syncedMeetings.flatMap(item => [item.files.markdown, item.files.json]),
+      warnings,
+    });
+  }
+
+  async function analyze(meetingId) {
+    const index = readIndex();
+    const position = (index.meetings || []).findIndex(item => item.id === String(meetingId));
+    if (position < 0) return errorResult('会议尚未同步到知识库,请先执行官方会议同步。');
+    const record = index.meetings[position];
+    try {
+      record.analysis = await analyzer(record);
+    } catch (error) {
+      record.analysis = {
+        ...analysisDefaults('failed'),
+        error: error instanceof AgentNotConfiguredError ? 'AI 模型尚未配置' : String(error.message || 'AI 分析失败').slice(0, 240),
+      };
+    }
+    persistRecord(storeDir, record);
+    index.meetings[position] = record;
+    writeIndex(index);
+    if (record.analysis.status !== 'completed') {
+      return errorResult(`会议资料已保留,但 AI 分析未完成:${record.analysis.error}`, {
+        summary: { meetingId: record.id, analysisStatus: record.analysis.status, noFabricatedOutput: true },
+        data: { meeting: record },
+        files: [record.files.markdown, record.files.json],
+      });
+    }
+    return okResult({
+      assistantMessage: `已完成“${record.title}”的 AI 分析,生成 ${record.analysis.actionItems.length} 条有依据的待办候选。`,
+      summary: { meetingId: record.id, actionItemCount: record.analysis.actionItems.length, requiresReview: record.analysis.requiresReview },
+      data: { meeting: record },
+      files: [record.files.markdown, record.files.json],
+      warnings: ['分析只基于会议元数据和描述,未包含会议录音、转写或人工纪要,所有结论需人工复核。'],
+    });
+  }
+
+  function get(meetingId) {
+    const meeting = (readIndex().meetings || []).find(item => item.id === String(meetingId));
+    return meeting ? okResult({ data: { meeting } }) : errorResult('会议知识记录不存在');
+  }
+
+  return { hub, sync, analyze, get, readIndex };
+}
+
+const service = createMeetingKnowledgeService();
+
+module.exports = {
+  DEFAULT_STORE_DIR,
+  MEETING_ANALYSIS_SCHEMA,
+  createMeetingKnowledgeService,
+  getMeetingKnowledgeHub: service.hub,
+  syncMeetingKnowledge: service.sync,
+  analyzeMeetingKnowledge: service.analyze,
+  getMeetingKnowledge: service.get,
+  __testing: { sanitizeValue, validateSyncInput, normalizeMeeting, meetingMarkdown, defaultRange, defaultMeetingCapability },
+};

+ 487 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/official-office-knowledge-service.js

@@ -0,0 +1,487 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { outputsRoot, slugify, latestPath } = require('../core/output-paths');
+const { getOfficialCliStatus, runOfficialCli, sanitizeCliText } = require('../core/wecom-cli-runtime');
+const { qiweiOfficialCall } = require('../providers/wecom-official-cli');
+const {
+  AgentNotConfiguredError,
+  OpenAICompatibleClient,
+  AnthropicCompatibleClient,
+  ClaudeCodeClient,
+} = require('../core/agent-runtime');
+
+const DEFAULT_DOC_STORE = path.join(outputsRoot(), 'knowledge', 'docs');
+const DEFAULT_TODO_STORE = path.join(outputsRoot(), 'knowledge', 'todos');
+const SENSITIVE_KEY = /(?:password|host_?key|phone_?number|secret|access_?token|authorization|credentials?)/i;
+
+const DOC_ANALYSIS_SCHEMA = {
+  type: 'object',
+  additionalProperties: false,
+  properties: {
+    summary: { type: 'string' },
+    keyPoints: { type: 'array', items: { type: 'string' } },
+    decisions: { type: 'array', items: { type: 'string' } },
+    actionItems: {
+      type: 'array',
+      items: {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          title: { type: 'string' }, owner: { type: 'string' }, dueDate: { type: 'string' }, evidence: { type: 'string' }, confidence: { type: 'number', minimum: 0, maximum: 1 },
+        },
+        required: ['title', 'owner', 'dueDate', 'evidence', 'confidence'],
+      },
+    },
+    risks: { type: 'array', items: { type: 'string' } },
+    knowledgeTags: { type: 'array', items: { type: 'string' } },
+    analysisBasis: { type: 'string' },
+    requiresReview: { type: 'boolean' },
+  },
+  required: ['summary', 'keyPoints', 'decisions', 'actionItems', 'risks', 'knowledgeTags', 'analysisBasis', 'requiresReview'],
+};
+
+function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); return dirPath; }
+function readJson(filePath, fallback) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); } catch { return fallback; } }
+function writeJsonAtomic(filePath, value) {
+  ensureDir(path.dirname(filePath));
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+function sanitizeValue(value) {
+  if (Array.isArray(value)) return value.map(sanitizeValue);
+  if (value && typeof value === 'object') {
+    const output = {};
+    for (const [key, item] of Object.entries(value)) if (!SENSITIVE_KEY.test(key)) output[key] = sanitizeValue(item);
+    return output;
+  }
+  return value;
+}
+function textJson(value) { try { return JSON.parse(String(value || '').trim()); } catch { return null; } }
+
+function unwrapOfficialResponse(result) {
+  let raw = result?.data?.response;
+  if (raw === undefined && typeof result?.data?.output === 'string') raw = textJson(result.data.output) ?? result.data.output;
+  if (raw?.jsonrpc && raw.result) {
+    const blocks = Array.isArray(raw.result.content) ? raw.result.content : [];
+    for (const block of blocks) {
+      if (block?.type !== 'text') continue;
+      const parsed = textJson(block.text);
+      if (parsed !== null) return sanitizeValue(parsed);
+    }
+    return sanitizeValue({ text: blocks.map(item => item.text || '').filter(Boolean).join('\n'), isError: Boolean(raw.result.isError) });
+  }
+  return sanitizeValue(raw || {});
+}
+
+function businessFailure(payload) { return Number(payload?.errcode || 0) !== 0; }
+function businessErrorMessage(label, payload) {
+  if (Number(payload?.errcode) === 860046) return `${label}没有匹配结果。`;
+  if (Number(payload?.errcode) === 851008) return `${label}需要额外的文档读取授权;请在企微文档中将机器人加入可访问范围后重试。`;
+  return `${label}未完成,请确认对象权限、参数和当前授权用户的可见范围。`;
+}
+
+async function defaultCapability(category) {
+  try {
+    const result = await runOfficialCli([category, '--help'], { ensure: false, timeoutMs: 15000 });
+    if (result.exitCode === 0) return { available: true, reason: 'available', message: `当前企业已开放官方${category === 'doc' ? '文档' : '待办'} CLI。` };
+    const output = sanitizeCliText(`${result.stdout}\n${result.stderr}`);
+    if (/暂不支持授权机器人/.test(output)) return { available: false, reason: 'enterprise-policy', message: `扫码授权已成功,但当前企业未开放官方${category === 'doc' ? '文档' : '待办'} CLI。` };
+    return { available: false, reason: 'probe-failed', message: `官方${category === 'doc' ? '文档' : '待办'}权限检查未通过。` };
+  } catch {
+    return { available: false, reason: 'probe-failed', message: `暂时无法确认官方${category === 'doc' ? '文档' : '待办'}权限。` };
+  }
+}
+
+function analysisDefaults(status = 'pending') {
+  return { status, summary: '', keyPoints: [], decisions: [], actionItems: [], risks: [], knowledgeTags: [], analysisBasis: '仅基于当前同步的企微文档 Markdown 内容。', requiresReview: true, analyzedAt: null, error: null };
+}
+
+function parseAnalysis(content) {
+  const parsed = JSON.parse(String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''));
+  return {
+    ...analysisDefaults('completed'),
+    summary: String(parsed.summary || ''),
+    keyPoints: Array.isArray(parsed.keyPoints) ? parsed.keyPoints.map(String) : [],
+    decisions: Array.isArray(parsed.decisions) ? parsed.decisions.map(String) : [],
+    actionItems: Array.isArray(parsed.actionItems) ? parsed.actionItems.map(item => ({
+      title: String(item.title || ''), owner: String(item.owner || ''), dueDate: String(item.dueDate || ''), evidence: String(item.evidence || ''), confidence: Math.max(0, Math.min(1, Number(item.confidence) || 0)),
+    })).filter(item => item.title) : [],
+    risks: Array.isArray(parsed.risks) ? parsed.risks.map(String) : [],
+    knowledgeTags: Array.isArray(parsed.knowledgeTags) ? parsed.knowledgeTags.map(String) : [],
+    analysisBasis: String(parsed.analysisBasis || analysisDefaults().analysisBasis),
+    requiresReview: true,
+    analyzedAt: new Date().toISOString(),
+  };
+}
+
+async function defaultDocAnalyzer(record, storeDir) {
+  const { getAgentRuntimeConfig } = require('./agent-service');
+  const baseConfig = getAgentRuntimeConfig();
+  const config = {
+    ...baseConfig,
+    outputSchema: DOC_ANALYSIS_SCHEMA,
+    claudeSessionFile: latestPath('docs', 'claude-doc-analysis-sessions.json'),
+    claudeProjectId: `${baseConfig.claudeProjectId || 'qiwei'}-doc-knowledge`,
+    claudeAddDirs: [storeDir],
+  };
+  const client = config.provider === 'claude-code' ? new ClaudeCodeClient(config) : config.provider === 'anthropic' ? new AnthropicCompatibleClient(config) : new OpenAICompatibleClient(config);
+  if (typeof client.isConfigured === 'function' && !client.isConfigured()) throw new AgentNotConfiguredError();
+  const system = [
+    '你是企业文档知识沉淀分析 Agent,只能依据给定文档原文。',
+    '不得把建议写成已经决定的事项;actionItems 只有在原文有负责人、动作或明确要求的证据时才能输出。',
+    '只输出符合下面 JSON Schema 的 JSON,所有结论都需人工复核。',
+    JSON.stringify(DOC_ANALYSIS_SCHEMA),
+  ].join('\n');
+  const directPrompt = `分析下面的企微文档:\n标题:${record.title}\n\n${record.markdown.slice(0, 50000)}`;
+  const response = await client.complete([{ role: 'system', content: system }, { role: 'user', content: directPrompt }], [], { conversation: { id: `doc:${record.id}`, displayName: record.title }, directPrompt });
+  return parseAnalysis(response.content);
+}
+
+function docMarkdown(record) {
+  const analysis = record.analysis || analysisDefaults();
+  const list = items => items?.length ? items.map(item => `- ${item}`).join('\n') : '- 暂无';
+  const actions = analysis.actionItems?.length ? analysis.actionItems.map(item => `- [ ] ${item.title}${item.owner ? ` · ${item.owner}` : ''}${item.dueDate ? ` · ${item.dueDate}` : ''}\n  - 依据:${item.evidence || '待复核'}`).join('\n') : '- 暂无有依据的待办候选';
+  return `# ${record.title}\n\n> 来源:企业微信官方 CLI(真实文档)  \n> 同步时间:${record.syncedAt}\n> 原文链接:${record.url || '未返回'}\n\n## AI 摘要\n\n${analysis.summary || '待分析'}\n\n### 关键要点\n\n${list(analysis.keyPoints)}\n\n### 明确决策\n\n${list(analysis.decisions)}\n\n### 待办候选\n\n${actions}\n\n### 风险与待确认\n\n${list(analysis.risks)}\n\n## 文档原文\n\n${record.markdown || '文档为空'}\n`;
+}
+
+function docRecordDir(storeDir, record) { return path.join(storeDir, 'records', `${slugify(record.title, 'doc')}-${crypto.createHash('sha256').update(record.id).digest('hex').slice(0, 10)}`); }
+function persistDoc(storeDir, record) {
+  const dir = ensureDir(docRecordDir(storeDir, record));
+  record.files = { json: path.join(dir, 'document.json'), markdown: path.join(dir, 'knowledge.md') };
+  writeJsonAtomic(record.files.json, record);
+  fs.writeFileSync(record.files.markdown, docMarkdown(record), 'utf8');
+  return record;
+}
+
+function createDocumentKnowledgeService(dependencies = {}) {
+  const storeDir = path.resolve(dependencies.storeDir || DEFAULT_DOC_STORE);
+  const officialStatus = dependencies.officialStatus || getOfficialCliStatus;
+  const officialCall = dependencies.officialCall || qiweiOfficialCall;
+  const capabilityProbe = dependencies.capability || (() => defaultCapability('doc'));
+  const analyzer = dependencies.analyzer || (record => defaultDocAnalyzer(record, storeDir));
+  const wait = dependencies.wait || (ms => new Promise(resolve => setTimeout(resolve, ms)));
+  const indexPath = path.join(storeDir, 'index.json');
+  let capabilityCache = null;
+  const readIndex = () => readJson(indexPath, { version: 1, documents: [], lastSyncedAt: null });
+  const writeIndex = index => writeJsonAtomic(indexPath, index);
+  async function capability(cli) {
+    if (!cli.ready) return { available: false, reason: 'not-authorized', message: '请先完成官方 CLI 扫码授权。' };
+    if (!capabilityCache || Date.now() - capabilityCache.checkedAt > 30000) capabilityCache = { ...(await capabilityProbe()), checkedAt: Date.now() };
+    return capabilityCache;
+  }
+  async function requireCapability() {
+    const cli = await officialStatus();
+    if (!cli.ready) return { error: errorResult('官方 CLI 尚未完成授权。', { summary: { needsInitialization: true } }) };
+    const access = await capability(cli);
+    if (!access.available) return { error: errorResult(access.message, { summary: { capabilityReason: access.reason } }) };
+    return { cli, access };
+  }
+  async function hub() {
+    ensureDir(storeDir);
+    const cli = await officialStatus();
+    const access = await capability(cli);
+    const index = readIndex();
+    return okResult({
+      assistantMessage: access.available ? `官方文档通道已就绪,当前沉淀 ${index.documents.length} 篇文档。` : access.message,
+      summary: { cliReady: Boolean(cli.ready), docReady: Boolean(cli.ready && access.available), documentCount: index.documents.length, analyzedCount: index.documents.filter(item => item.analysis?.status === 'completed').length, lastSyncedAt: index.lastSyncedAt },
+      data: { cli: { ready: Boolean(cli.ready), available: Boolean(access.available), reason: access.reason, message: access.message, version: cli.installedVersion || cli.version || '' }, documents: index.documents, storeDir },
+    });
+  }
+  async function readRemoteDocument(locator) {
+    let taskId = '';
+    for (let attempt = 0; attempt < 15; attempt += 1) {
+      const args = { type: 2, ...(locator.docid ? { docid: locator.docid } : { url: locator.url }), ...(taskId ? { task_id: taskId } : {}) };
+      const call = await officialCall({ category: 'doc', method: 'get_doc_content', args, timeoutMs: 120000 });
+      if (call.status !== 'ok') return { error: call };
+      const payload = unwrapOfficialResponse(call);
+      if (businessFailure(payload)) return { error: errorResult(businessErrorMessage('读取企微文档', payload)) };
+      taskId = String(payload.task_id || payload.taskId || taskId || '');
+      const markdown = payload.content ?? payload.markdown ?? payload.doc_content ?? payload.data?.content;
+      const done = payload.task_done ?? payload.taskDone ?? payload.done ?? (typeof markdown === 'string');
+      if (done) return { payload, markdown: String(markdown || '') };
+      await wait(500);
+    }
+    return { error: errorResult('企微文档导出任务等待超时,请稍后重试。') };
+  }
+  async function importDocument(input = {}) {
+    const access = await requireCapability();
+    if (access.error) return access.error;
+    const docid = String(input.docid || '').trim();
+    const url = String(input.url || '').trim();
+    if (!docid && !url) return errorResult('请提供企微文档 docid 或访问链接。');
+    const remote = await readRemoteDocument({ docid, url });
+    if (remote.error) return remote.error;
+    const resolvedId = String(remote.payload.docid || remote.payload.doc_id || docid || crypto.createHash('sha256').update(url).digest('hex').slice(0, 20));
+    const index = readIndex();
+    const previous = index.documents.find(item => item.id === resolvedId);
+    const firstHeading = remote.markdown.match(/^#\s+(.+)$/m)?.[1];
+    const record = persistDoc(storeDir, {
+      id: resolvedId,
+      docid: String(remote.payload.docid || remote.payload.doc_id || docid),
+      url: String(remote.payload.url || remote.payload.doc_url || url),
+      title: String(input.title || remote.payload.doc_name || firstHeading || previous?.title || '企微文档'),
+      docType: Number(remote.payload.doc_type || previous?.docType || 3),
+      markdown: remote.markdown,
+      analysis: previous?.analysis || analysisDefaults(),
+      sourceKind: 'official-cli-live',
+      syncedAt: new Date().toISOString(),
+    });
+    const byId = new Map(index.documents.map(item => [item.id, item]));
+    byId.set(record.id, record);
+    index.documents = [...byId.values()].sort((a, b) => String(b.syncedAt).localeCompare(String(a.syncedAt)));
+    index.lastSyncedAt = record.syncedAt;
+    writeIndex(index);
+    return okResult({ assistantMessage: `已读取并沉淀真实企微文档“${record.title}”。`, summary: { documentId: record.id, live: true }, data: { document: record }, files: [record.files.markdown, record.files.json] });
+  }
+  function recordCreatedDocument(input = {}) {
+    const docid = String(input.docid || '').trim();
+    const url = String(input.url || '').trim();
+    const title = String(input.title || '企微文档').trim();
+    if (!docid && !url) return errorResult('缺少已创建文档的 docid 或链接。');
+    const id = docid || crypto.createHash('sha256').update(url).digest('hex').slice(0, 20);
+    const index = readIndex();
+    const previous = index.documents.find(item => item.id === id);
+    const record = persistDoc(storeDir, {
+      id,
+      docid,
+      url,
+      title,
+      docType: 3,
+      markdown: String(input.content || ''),
+      analysis: previous?.analysis || analysisDefaults(),
+      sourceKind: 'official-cli-live-created',
+      readbackStatus: String(input.readbackStatus || 'pending-permission'),
+      syncedAt: new Date().toISOString(),
+    });
+    const byId = new Map(index.documents.map(item => [item.id, item]));
+    byId.set(record.id, record);
+    index.documents = [...byId.values()].sort((a, b) => String(b.syncedAt).localeCompare(String(a.syncedAt)));
+    index.lastSyncedAt = record.syncedAt;
+    writeIndex(index);
+    return okResult({
+      assistantMessage: `真实企微文档“${record.title}”已创建并写入;当前机器人缺少读回权限,已按实际写入内容沉淀并标记待读权限确认。`,
+      summary: { documentId: record.id, live: true, readbackPending: true },
+      data: { document: record },
+      files: [record.files.markdown, record.files.json],
+      warnings: ['文档创建与写入已由官方 CLI 确认成功,但尚未通过读取接口复核原文。'],
+    });
+  }
+  async function createDocument(input = {}) {
+    const access = await requireCapability();
+    if (access.error) return access.error;
+    const title = String(input.title || '').trim();
+    const content = String(input.content || '').trim();
+    if (!title) return errorResult('请输入文档标题。');
+    const createdCall = await officialCall({ category: 'doc', method: 'create_doc', args: { doc_type: 3, doc_name: title } });
+    if (createdCall.status !== 'ok') return createdCall;
+    const created = unwrapOfficialResponse(createdCall);
+    if (businessFailure(created)) return errorResult(businessErrorMessage('创建企微文档', created));
+    const docid = String(created.docid || created.doc_id || created.data?.docid || '');
+    const url = String(created.url || created.doc_url || created.data?.url || '');
+    if (!docid && !url) return errorResult('企微已返回创建结果,但缺少 docid 和访问链接,无法继续写入。');
+    if (content) {
+      const editCall = await officialCall({ category: 'doc', method: 'edit_doc_content', args: { ...(docid ? { docid } : { url }), content, content_type: 1 } });
+      if (editCall.status !== 'ok') return editCall;
+      const edited = unwrapOfficialResponse(editCall);
+      if (businessFailure(edited)) return errorResult(businessErrorMessage('写入企微文档', edited));
+    }
+    const imported = await importDocument({ docid, url, title });
+    if (imported.status === 'ok') return imported;
+    if (content) return recordCreatedDocument({ docid, url, title, content, readbackStatus: 'pending-permission' });
+    return imported;
+  }
+  async function analyze(id) {
+    const index = readIndex();
+    const position = index.documents.findIndex(item => item.id === String(id));
+    if (position < 0) return errorResult('文档尚未沉淀到本地知识库。');
+    const record = index.documents[position];
+    try { record.analysis = await analyzer(record); }
+    catch (error) { record.analysis = { ...analysisDefaults('failed'), error: error instanceof AgentNotConfiguredError ? 'AI 模型尚未配置' : String(error.message || 'AI 分析失败').slice(0, 240) }; }
+    persistDoc(storeDir, record);
+    index.documents[position] = record;
+    writeIndex(index);
+    if (record.analysis.status !== 'completed') return errorResult(`真实文档已保留,但 AI 分析未完成:${record.analysis.error}`, { data: { document: record }, summary: { noFabricatedOutput: true } });
+    return okResult({ assistantMessage: `已完成“${record.title}”的 AI 知识分析。`, data: { document: record }, summary: { actionItemCount: record.analysis.actionItems.length }, files: [record.files.markdown, record.files.json], warnings: ['AI 结果需人工复核。'] });
+  }
+  async function refresh(id) {
+    const record = readIndex().documents.find(item => item.id === String(id));
+    if (!record) return errorResult('文档知识记录不存在。');
+    const result = await importDocument({ docid: record.docid, url: record.url, title: record.title });
+    if (result.status !== 'ok') {
+      return errorResult(result.assistantMessage || '文档重新读取未完成。', {
+        summary: { documentId: record.id, readbackStatus: record.readbackStatus || 'pending-permission' },
+        data: { document: record },
+        nextActions: [{ action: 'grant_doc_access', description: '在企微文档中将机器人加入可访问范围后重试' }],
+      });
+    }
+    return okResult({ ...result, assistantMessage: `已重新读取并复核真实企微文档“${record.title}”。`, summary: { ...result.summary, readbackVerified: true } });
+  }
+  function get(id) { const document = readIndex().documents.find(item => item.id === String(id)); return document ? okResult({ data: { document } }) : errorResult('文档知识记录不存在。'); }
+  return { hub, importDocument, createDocument, recordCreatedDocument, analyze, refresh, get, readIndex };
+}
+
+function todoMarkdown(index) {
+  const rows = index.todos.map(item => `- [${item.status === 0 ? 'x' : ' '}] ${item.content}${item.endTime ? ` · 截止:${item.endTime}` : ''} · ${item.statusLabel}`).join('\n') || '- 暂无通过当前机器人创建的待办';
+  return `# 企微官方待办\n\n> 来源:企业微信官方 CLI  \n> 最近同步:${index.lastSyncedAt || '尚未同步'}\n> 边界:只能查询和管理当前机器人创建的待办。\n\n${rows}\n`;
+}
+function normalizeTodo(raw) {
+  const source = sanitizeValue(raw || {});
+  const followers = source.follower_list?.followers || source.followers || [];
+  const status = Number(source.todo_status ?? source.status ?? 1);
+  return { id: String(source.todo_id || source.id || ''), content: String(source.content || source.title || '未命名待办'), status, statusLabel: status === 0 ? '已完成' : '进行中', endTime: String(source.end_time || source.deadline || ''), followers: followers.map(item => ({ id: String(item.follower_id || item.userid || ''), name: String(item.name || item.alias || ''), status: item.follower_status ?? item.status ?? null })).filter(item => item.id), sourceKind: 'official-cli-live', syncedAt: new Date().toISOString() };
+}
+
+function createTodoKnowledgeService(dependencies = {}) {
+  const storeDir = path.resolve(dependencies.storeDir || DEFAULT_TODO_STORE);
+  const officialStatus = dependencies.officialStatus || getOfficialCliStatus;
+  const officialCall = dependencies.officialCall || qiweiOfficialCall;
+  const capabilityProbe = dependencies.capability || (() => defaultCapability('todo'));
+  const indexPath = path.join(storeDir, 'index.json');
+  let capabilityCache = null;
+  const readIndex = () => readJson(indexPath, { version: 1, followerId: '', todos: [], lastSyncedAt: null });
+  function writeIndex(index) { writeJsonAtomic(indexPath, index); fs.writeFileSync(path.join(storeDir, 'todos.md'), todoMarkdown(index), 'utf8'); }
+  async function capability(cli) {
+    if (!cli.ready) return { available: false, reason: 'not-authorized', message: '请先完成官方 CLI 扫码授权。' };
+    if (!capabilityCache || Date.now() - capabilityCache.checkedAt > 30000) capabilityCache = { ...(await capabilityProbe()), checkedAt: Date.now() };
+    return capabilityCache;
+  }
+  async function requireCapability() {
+    const cli = await officialStatus();
+    if (!cli.ready) return { error: errorResult('官方 CLI 尚未完成授权。', { summary: { needsInitialization: true } }) };
+    const access = await capability(cli);
+    if (!access.available) return { error: errorResult(access.message, { summary: { capabilityReason: access.reason } }) };
+    return { cli, access };
+  }
+  async function hub() {
+    ensureDir(storeDir);
+    const cli = await officialStatus();
+    const access = await capability(cli);
+    const index = readIndex();
+    return okResult({ assistantMessage: access.available ? `官方待办通道已就绪,本地同步 ${index.todos.length} 条。` : access.message, summary: { cliReady: Boolean(cli.ready), todoReady: Boolean(cli.ready && access.available), todoCount: index.todos.length, activeCount: index.todos.filter(item => item.status !== 0).length, completedCount: index.todos.filter(item => item.status === 0).length, lastSyncedAt: index.lastSyncedAt }, data: { cli: { ready: Boolean(cli.ready), available: Boolean(access.available), reason: access.reason, message: access.message, version: cli.installedVersion || cli.version || '' }, followerId: index.followerId, todos: index.todos, storeDir } });
+  }
+  async function searchUsers(input = {}) {
+    const access = await requireCapability();
+    if (access.error) return access.error;
+    const keyword = String(input.keyword || '').trim();
+    if (!keyword) return errorResult('请输入姓名或别名。');
+    const call = await officialCall({ category: 'todo', method: 'search_todo_userid', args: { keyword } });
+    if (call.status !== 'ok') return call;
+    const payload = unwrapOfficialResponse(call);
+    if (businessFailure(payload)) {
+      if (Number(payload.errcode) === 860046) return okResult({ assistantMessage: `没有找到“${keyword}”对应的企微成员。`, summary: { count: 0 }, data: { users: [] } });
+      return errorResult(businessErrorMessage('搜索企微成员', payload));
+    }
+    const rows = payload.user_list || payload.users || payload.data?.user_list || [];
+    const users = rows.map(item => ({ id: String(item.userid || item.user_id || item.id || ''), name: String(item.name || item.alias || item.userid || ''), alias: String(item.alias || '') })).filter(item => item.id);
+    return okResult({ assistantMessage: `找到 ${users.length} 位匹配成员。`, summary: { count: users.length }, data: { users } });
+  }
+  async function sync(input = {}) {
+    const access = await requireCapability();
+    if (access.error) return access.error;
+    const followerId = String(input.followerId || input.follower_id || '').trim();
+    if (!followerId) return errorResult('请输入当前待办参与人的 userid;可先按姓名搜索。');
+    const limit = Math.max(1, Math.min(20, Number(input.limit || 20) || 20));
+    const call = await officialCall({ category: 'todo', method: 'get_todo_list', args: { follower_id: followerId, limit, ...(input.status === 0 || input.status === 1 ? { todo_status: Number(input.status) } : {}) } });
+    if (call.status !== 'ok') return call;
+    const payload = unwrapOfficialResponse(call);
+    if (businessFailure(payload)) return errorResult(businessErrorMessage('同步企微待办', payload));
+    const rows = payload.todo_list || payload.todos || payload.todo_info_list || payload.data?.todo_list || [];
+    const previous = readIndex();
+    const liveTodos = rows.map(normalizeTodo).filter(item => item.id);
+    const preserved = liveTodos.length === 0 && previous.todos.length > 0;
+    const index = { version: 1, followerId, todos: preserved ? previous.todos : liveTodos, lastSyncedAt: new Date().toISOString() };
+    writeIndex(index);
+    return okResult({
+      assistantMessage: preserved ? `官方列表本次返回空,已保留 ${index.todos.length} 条经详情确认的机器人待办。` : `已同步 ${index.todos.length} 条真实企微待办。`,
+      summary: { syncedCount: liveTodos.length, preservedCount: preserved ? index.todos.length : 0, live: true },
+      data: { todos: index.todos, followerId },
+      files: [indexPath, path.join(storeDir, 'todos.md')],
+      warnings: preserved ? ['官方列表仅返回当前授权用户可见范围;已保留通过 todo_id 详情确认的记录。'] : [],
+    });
+  }
+  async function refreshDetails(input = {}) {
+    const access = await requireCapability();
+    if (access.error) return access.error;
+    const todoIds = (Array.isArray(input.todoIds) ? input.todoIds : input.todo_id_list || []).map(String).map(item => item.trim()).filter(Boolean).slice(0, 20);
+    if (!todoIds.length) return errorResult('请提供至少一个待办 ID。');
+    const call = await officialCall({ category: 'todo', method: 'get_todo_detail', args: { todo_id_list: todoIds } });
+    if (call.status !== 'ok') return call;
+    const payload = unwrapOfficialResponse(call);
+    if (businessFailure(payload)) return errorResult(businessErrorMessage('读取企微待办详情', payload));
+    const rows = payload.data_list || payload.todo_list || payload.todos || payload.data?.data_list || [];
+    const records = rows.map(normalizeTodo).filter(item => item.id);
+    const index = readIndex();
+    const byId = new Map(index.todos.map(item => [item.id, item]));
+    for (const record of records) byId.set(record.id, record);
+    index.todos = [...byId.values()];
+    index.followerId = String(input.followerId || index.followerId || records[0]?.followers?.[0]?.id || '');
+    index.lastSyncedAt = new Date().toISOString();
+    writeIndex(index);
+    return okResult({ assistantMessage: `已从官方详情恢复 ${records.length} 条企微待办。`, summary: { refreshedCount: records.length, todoCount: index.todos.length, live: true }, data: { todos: index.todos, followerId: index.followerId }, files: [indexPath, path.join(storeDir, 'todos.md')] });
+  }
+  async function create(input = {}) {
+    const access = await requireCapability();
+    if (access.error) return access.error;
+    const content = String(input.content || '').trim();
+    const followerIds = Array.isArray(input.followerIds) ? input.followerIds.map(String).map(item => item.trim()).filter(Boolean) : String(input.followerIds || '').split(/[,,\s]+/).map(item => item.trim()).filter(Boolean);
+    if (!content || !followerIds.length) return errorResult('请输入待办内容和至少一位参与人 userid。');
+    const endTime = String(input.endTime || input.end_time || '').trim().replace('T', ' ');
+    const args = { content, follower_list: { followers: followerIds.map(follower_id => ({ follower_id })) }, ...(endTime ? { end_time: endTime.length === 16 ? `${endTime}:00` : endTime } : {}), remind_type_list: [Number(input.remindType ?? 1)] };
+    const call = await officialCall({ category: 'todo', method: 'create_todo', args });
+    if (call.status !== 'ok') return call;
+    const payload = unwrapOfficialResponse(call);
+    if (businessFailure(payload)) return errorResult(businessErrorMessage('创建企微待办', payload));
+    const record = normalizeTodo({ ...payload, todo_id: payload.todo_id || payload.id || payload.data?.todo_id, content, end_time: args.end_time, follower_list: args.follower_list, todo_status: 1 });
+    if (!record.id) return errorResult('企微已返回创建结果,但缺少 todo_id,无法写入本地索引。');
+    const index = readIndex();
+    const byId = new Map(index.todos.map(item => [item.id, item]));
+    byId.set(record.id, record);
+    index.todos = [...byId.values()];
+    index.followerId = index.followerId || followerIds[0];
+    index.lastSyncedAt = new Date().toISOString();
+    writeIndex(index);
+    return okResult({ assistantMessage: `已创建真实企微待办“${content}”。`, summary: { todoId: record.id, live: true }, data: { todo: record }, files: [indexPath, path.join(storeDir, 'todos.md')] });
+  }
+  async function complete(id) {
+    const access = await requireCapability();
+    if (access.error) return access.error;
+    const todoId = String(id || '').trim();
+    const call = await officialCall({ category: 'todo', method: 'update_todo', args: { todo_id: todoId, todo_status: 0 } });
+    if (call.status !== 'ok') return call;
+    const payload = unwrapOfficialResponse(call);
+    if (businessFailure(payload)) return errorResult(businessErrorMessage('完成企微待办', payload));
+    const index = readIndex();
+    const todo = index.todos.find(item => item.id === todoId);
+    if (todo) { todo.status = 0; todo.statusLabel = '已完成'; todo.syncedAt = new Date().toISOString(); writeIndex(index); }
+    return okResult({ assistantMessage: '企微待办已标记完成。', summary: { todoId, live: true }, data: { todo: todo || { id: todoId, status: 0, statusLabel: '已完成' } } });
+  }
+  return { hub, searchUsers, sync, refreshDetails, create, complete, readIndex };
+}
+
+const docs = createDocumentKnowledgeService();
+const todos = createTodoKnowledgeService();
+
+module.exports = {
+  DEFAULT_DOC_STORE,
+  DEFAULT_TODO_STORE,
+  DOC_ANALYSIS_SCHEMA,
+  createDocumentKnowledgeService,
+  createTodoKnowledgeService,
+  unwrapOfficialResponse,
+  getDocumentKnowledgeHub: docs.hub,
+  importDocumentKnowledge: docs.importDocument,
+  createDocumentKnowledge: docs.createDocument,
+  analyzeDocumentKnowledge: docs.analyze,
+  refreshDocumentKnowledge: docs.refresh,
+  getDocumentKnowledge: docs.get,
+  getTodoKnowledgeHub: todos.hub,
+  searchTodoUsers: todos.searchUsers,
+  syncTodoKnowledge: todos.sync,
+  refreshTodoKnowledgeDetails: todos.refreshDetails,
+  createTodoKnowledge: todos.create,
+  completeTodoKnowledge: todos.complete,
+  __testing: { unwrapOfficialResponse, sanitizeValue, normalizeTodo, analysisDefaults },
+};

+ 253 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -45,6 +45,64 @@ const {
   qiweiPreviewTransferPackage,
   qiweiExecuteTransfer
 } = require('../tools/qiwei-customer-transfer-run');
+const {
+  switchActiveAccount,
+  getAgentStatus,
+  getConversations,
+  syncConversations,
+  changeGlobalMode,
+  changeConversationMode,
+  approveReply,
+  approveDraft,
+  rejectDraft,
+  regenerateDraft,
+  generateLatestDraft,
+  manualSend,
+  updateCustomerTask,
+  syncCustomerTaskToOfficialTodo,
+  updateCustomerAlert,
+  getAudit,
+  startListener,
+  stopListener
+} = require('./agent-service');
+const {
+  listKnowledgeTree,
+  readKnowledgeFile,
+  listProperties,
+  getProperty,
+  listSkillRegistry,
+  getSkillDetail
+} = require('./workspace-library-service');
+const {
+  getMeetingKnowledgeHub,
+  syncMeetingKnowledge,
+  analyzeMeetingKnowledge,
+  getMeetingKnowledge
+} = require('./meeting-knowledge-service');
+const {
+  getDocumentKnowledgeHub,
+  importDocumentKnowledge,
+  createDocumentKnowledge,
+  analyzeDocumentKnowledge,
+  refreshDocumentKnowledge,
+  getDocumentKnowledge,
+  getTodoKnowledgeHub,
+  searchTodoUsers,
+  syncTodoKnowledge,
+  refreshTodoKnowledgeDetails,
+  createTodoKnowledge,
+  completeTodoKnowledge
+} = require('./official-office-knowledge-service');
+const {
+  getUnifiedTaskHub,
+  createUnifiedLocalTask,
+  updateUnifiedTask
+} = require('./unified-task-service');
+const {
+  getCustomerMasterHub,
+  updateCustomerMaster,
+  updateCustomerRecommendationFeedback
+} = require('./customer-master-service');
 
 const DASHBOARD_PORT = process.env.QIWEI_DASHBOARD_PORT || 4320;
 const STATIC_DIR = path.join(__dirname);
@@ -210,6 +268,187 @@ async function handleRequest(req, res) {
       json(res, 200, await combinedStatus());
       return;
     }
+    if (pathname === '/api/skills' && req.method === 'GET') {
+      json(res, 200, listSkillRegistry());
+      return;
+    }
+    if (pathname === '/api/knowledge/tree' && req.method === 'GET') {
+      json(res, 200, listKnowledgeTree());
+      return;
+    }
+    if (pathname === '/api/knowledge/file' && req.method === 'GET') {
+      json(res, 200, readKnowledgeFile(url.searchParams.get('id')));
+      return;
+    }
+    if (pathname === '/api/knowledge/meetings' && req.method === 'GET') {
+      json(res, 200, await getMeetingKnowledgeHub());
+      return;
+    }
+    if (pathname === '/api/knowledge/meetings/sync' && req.method === 'POST') {
+      json(res, 200, await syncMeetingKnowledge(await readBody(req)));
+      return;
+    }
+    const meetingAnalyzeRoute = pathname.match(/^\/api\/knowledge\/meetings\/([^/]+)\/analyze$/);
+    if (meetingAnalyzeRoute && req.method === 'POST') {
+      json(res, 200, await analyzeMeetingKnowledge(decodeURIComponent(meetingAnalyzeRoute[1])));
+      return;
+    }
+    const meetingDetailRoute = pathname.match(/^\/api\/knowledge\/meetings\/([^/]+)$/);
+    if (meetingDetailRoute && req.method === 'GET') {
+      json(res, 200, getMeetingKnowledge(decodeURIComponent(meetingDetailRoute[1])));
+      return;
+    }
+    if (pathname === '/api/knowledge/docs' && req.method === 'GET') {
+      json(res, 200, await getDocumentKnowledgeHub());
+      return;
+    }
+    if (pathname === '/api/knowledge/docs/import' && req.method === 'POST') {
+      json(res, 200, await importDocumentKnowledge(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/knowledge/docs/create' && req.method === 'POST') {
+      json(res, 200, await createDocumentKnowledge(await readBody(req)));
+      return;
+    }
+    const documentAnalyzeRoute = pathname.match(/^\/api\/knowledge\/docs\/([^/]+)\/analyze$/);
+    if (documentAnalyzeRoute && req.method === 'POST') {
+      json(res, 200, await analyzeDocumentKnowledge(decodeURIComponent(documentAnalyzeRoute[1])));
+      return;
+    }
+    const documentRefreshRoute = pathname.match(/^\/api\/knowledge\/docs\/([^/]+)\/refresh$/);
+    if (documentRefreshRoute && req.method === 'POST') {
+      json(res, 200, await refreshDocumentKnowledge(decodeURIComponent(documentRefreshRoute[1])));
+      return;
+    }
+    const documentDetailRoute = pathname.match(/^\/api\/knowledge\/docs\/([^/]+)$/);
+    if (documentDetailRoute && req.method === 'GET') {
+      json(res, 200, getDocumentKnowledge(decodeURIComponent(documentDetailRoute[1])));
+      return;
+    }
+    if (pathname === '/api/knowledge/todos' && req.method === 'GET') {
+      json(res, 200, await getTodoKnowledgeHub());
+      return;
+    }
+    if (pathname === '/api/knowledge/tasks' && req.method === 'GET') {
+      json(res, 200, getUnifiedTaskHub());
+      return;
+    }
+    if (pathname === '/api/knowledge/tasks/create' && req.method === 'POST') {
+      json(res, 200, createUnifiedLocalTask(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/knowledge/tasks/update' && req.method === 'POST') {
+      json(res, 200, await updateUnifiedTask(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/knowledge/todos/search-users' && req.method === 'POST') {
+      json(res, 200, await searchTodoUsers(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/knowledge/todos/sync' && req.method === 'POST') {
+      json(res, 200, await syncTodoKnowledge(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/knowledge/todos/details' && req.method === 'POST') {
+      json(res, 200, await refreshTodoKnowledgeDetails(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/knowledge/todos/create' && req.method === 'POST') {
+      json(res, 200, await createTodoKnowledge(await readBody(req)));
+      return;
+    }
+    const todoCompleteRoute = pathname.match(/^\/api\/knowledge\/todos\/([^/]+)\/complete$/);
+    if (todoCompleteRoute && req.method === 'POST') {
+      json(res, 200, await completeTodoKnowledge(decodeURIComponent(todoCompleteRoute[1])));
+      return;
+    }
+    if (pathname === '/api/knowledge/properties' && req.method === 'GET') {
+      json(res, 200, listProperties(Object.fromEntries(url.searchParams.entries())));
+      return;
+    }
+    const propertyRoute = pathname.match(/^\/api\/knowledge\/properties\/([^/]+)$/);
+    if (propertyRoute && req.method === 'GET') {
+      json(res, 200, getProperty(decodeURIComponent(propertyRoute[1])));
+      return;
+    }
+    const skillRoute = pathname.match(/^\/api\/skills\/(.+)$/);
+    if (skillRoute && req.method === 'GET') {
+      json(res, 200, getSkillDetail(decodeURIComponent(skillRoute[1])));
+      return;
+    }
+    if (pathname === '/api/agent/status' && req.method === 'GET') {
+      json(res, 200, await getAgentStatus());
+      return;
+    }
+    if (pathname === '/api/accounts/switch' && req.method === 'POST') {
+      json(res, 200, await switchActiveAccount(await readBody(req)));
+      return;
+    }
+    if (pathname === '/api/agent/conversations' && req.method === 'GET') {
+      json(res, 200, getConversations());
+      return;
+    }
+    if (pathname === '/api/agent/conversations/sync' && req.method === 'POST') {
+      json(res, 200, await syncConversations());
+      return;
+    }
+    if (pathname === '/api/agent/audit' && req.method === 'GET') {
+      json(res, 200, getAudit(url.searchParams.get('limit')));
+      return;
+    }
+    if (pathname === '/api/agent/mode' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, changeGlobalMode(body.mode));
+      return;
+    }
+    if (pathname === '/api/agent/listener/start' && req.method === 'POST') {
+      json(res, 200, await startListener());
+      return;
+    }
+    if (pathname === '/api/agent/listener/stop' && req.method === 'POST') {
+      json(res, 200, stopListener());
+      return;
+    }
+    const customerTaskSyncRoute = pathname.match(/^\/api\/agent\/tasks\/([^/]+)\/sync-official$/);
+    if (customerTaskSyncRoute && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await syncCustomerTaskToOfficialTodo(customerTaskSyncRoute[1], body));
+      return;
+    }
+    const customerTaskRoute = pathname.match(/^\/api\/agent\/tasks\/([^/]+)$/);
+    if (customerTaskRoute && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await updateCustomerTask(customerTaskRoute[1], body));
+      return;
+    }
+    const customerAlertRoute = pathname.match(/^\/api\/agent\/alerts\/([^/]+)$/);
+    if (customerAlertRoute && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, updateCustomerAlert(customerAlertRoute[1], body));
+      return;
+    }
+    const agentConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/(takeover|resume|pause|auto|approve-reply|generate|manual-send)$/);
+    if (agentConversationRoute && req.method === 'POST') {
+      const [, conversationId, action] = agentConversationRoute;
+      const body = await readBody(req);
+      if (action === 'takeover') json(res, 200, changeConversationMode(conversationId, 'human'));
+      else if (action === 'resume') json(res, 200, changeConversationMode(conversationId, 'review'));
+      else if (action === 'pause') json(res, 200, changeConversationMode(conversationId, 'paused'));
+      else if (action === 'auto') json(res, 200, changeConversationMode(conversationId, 'auto'));
+      else if (action === 'generate') json(res, 200, await generateLatestDraft(conversationId));
+      else if (action === 'manual-send') json(res, 200, await manualSend(conversationId, body.content));
+      else json(res, 200, await approveReply(conversationId, body.content));
+      return;
+    }
+    const agentDraftRoute = pathname.match(/^\/api\/agent\/drafts\/([^/]+)\/(approve|reject|regenerate)$/);
+    if (agentDraftRoute && req.method === 'POST') {
+      const [, draftId, action] = agentDraftRoute;
+      const body = await readBody(req);
+      if (action === 'approve') json(res, 200, await approveDraft(draftId, body.content));
+      else if (action === 'reject') json(res, 200, rejectDraft(draftId, body.reason));
+      else json(res, 200, await regenerateDraft(draftId));
+      return;
+    }
     if (pathname === '/api/login/start' && req.method === 'POST') {
       const body = await readBody(req);
       const result = await qiweiLoginStart({
@@ -281,6 +520,20 @@ async function handleRequest(req, res) {
     }
 
     // Customer Operations
+    if (pathname === '/api/customers' && req.method === 'GET') {
+      json(res, 200, getCustomerMasterHub());
+      return;
+    }
+    const customerMasterRoute = pathname.match(/^\/api\/customers\/([^/]+)\/profile$/);
+    if (customerMasterRoute && req.method === 'POST') {
+      json(res, 200, updateCustomerMaster(decodeURIComponent(customerMasterRoute[1]), await readBody(req)));
+      return;
+    }
+    const customerRecommendationRoute = pathname.match(/^\/api\/customers\/([^/]+)\/recommendations\/([^/]+)$/);
+    if (customerRecommendationRoute && req.method === 'POST') {
+      json(res, 200, updateCustomerRecommendationFeedback(decodeURIComponent(customerRecommendationRoute[1]), decodeURIComponent(customerRecommendationRoute[2]), await readBody(req)));
+      return;
+    }
     if (pathname === '/api/customer-ops/batch-add-friends' && req.method === 'POST') {
       const body = await readBody(req);
       const id = createJob(() => qiweiBatchAddFriends(body));

Diferenças do arquivo suprimidas por serem muito extensas
+ 1183 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css


+ 363 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/unified-task-service.js

@@ -0,0 +1,363 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { outputsRoot } = require('../core/output-paths');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { getConversations, updateCustomerTask } = require('./agent-service');
+const { completeTodoKnowledge } = require('./official-office-knowledge-service');
+const { qiweiGoalUpdateTask } = require('../tools/qiwei-goal-management-run');
+
+const ACTIVE_STATUSES = new Set(['open', 'in_progress', 'blocked']);
+const LOCAL_STATUSES = new Set(['open', 'in_progress', 'blocked', 'done', 'dismissed']);
+
+function ensureDir(dirPath) {
+  fs.mkdirSync(dirPath, { recursive: true });
+  return dirPath;
+}
+
+function readJson(filePath, fallback) {
+  try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); }
+  catch { return fallback; }
+}
+
+function writeJsonAtomic(filePath, value) {
+  ensureDir(path.dirname(filePath));
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+
+function stableId(...parts) {
+  return crypto.createHash('sha256').update(parts.map(value => String(value || '')).join('\u0000')).digest('hex').slice(0, 20);
+}
+
+function normalizeDate(value) {
+  const text = String(value || '').trim();
+  if (!text) return '';
+  const parsed = new Date(text.replace(' ', 'T'));
+  return Number.isNaN(parsed.getTime()) ? text : parsed.toISOString();
+}
+
+function isOverdue(task, now = Date.now()) {
+  if (!ACTIVE_STATUSES.has(task.status) || !task.dueAt) return false;
+  const timestamp = Date.parse(task.dueAt);
+  return Number.isFinite(timestamp) && timestamp < now;
+}
+
+function sourceMeta(source) {
+  return ({
+    customer: { label: '客户会话跟进', kind: 'formal', channel: 'local-agent' },
+    official: { label: '企微官方待办', kind: 'formal', channel: 'official-cli' },
+    goal: { label: '目标计划', kind: 'formal', channel: 'local-goal' },
+    local: { label: '本地内部任务', kind: 'formal', channel: 'local-task' },
+    'document-candidate': { label: '文档待办候选', kind: 'candidate', channel: 'ai-analysis' },
+    'meeting-candidate': { label: '会议待办候选', kind: 'candidate', channel: 'ai-analysis' },
+  })[source] || { label: source || '其他来源', kind: 'formal', channel: 'local' };
+}
+
+function baseTask(input) {
+  const meta = sourceMeta(input.source);
+  const task = {
+    id: String(input.id || ''),
+    source: input.source,
+    sourceLabel: meta.label,
+    sourceKind: meta.kind,
+    channel: meta.channel,
+    title: String(input.title || '未命名任务'),
+    owner: String(input.owner || ''),
+    dueAt: normalizeDate(input.dueAt),
+    priority: String(input.priority || 'medium'),
+    status: String(input.status || 'open'),
+    evidence: String(input.evidence || ''),
+    sourceTitle: String(input.sourceTitle || ''),
+    sourceId: String(input.sourceId || ''),
+    updatedAt: String(input.updatedAt || ''),
+    audience: input.audience === 'external' ? 'external' : 'internal',
+    conversationId: String(input.conversationId || ''),
+    contactName: String(input.contactName || ''),
+    goalId: String(input.goalId || ''),
+    officialTodoId: String(input.officialTodoId || ''),
+    officialSyncStatus: String(input.officialSyncStatus || ''),
+    confidence: input.confidence === undefined ? null : Number(input.confidence),
+    promotedTo: String(input.promotedTo || ''),
+    actions: input.actions || {},
+  };
+  task.overdue = isOverdue(task);
+  return task;
+}
+
+function createUnifiedTaskService(dependencies = {}) {
+  const root = path.resolve(dependencies.outputsDir || outputsRoot());
+  const storeDir = path.resolve(dependencies.storeDir || path.join(root, 'knowledge', 'tasks'));
+  const localIndexPath = path.join(storeDir, 'index.json');
+  const paths = {
+    official: path.resolve(dependencies.officialTodoPath || path.join(root, 'knowledge', 'todos', 'index.json')),
+    goals: path.resolve(dependencies.goalPath || path.join(root, 'goals', 'plans.json')),
+    documents: path.resolve(dependencies.documentPath || path.join(root, 'knowledge', 'docs', 'index.json')),
+    meetings: path.resolve(dependencies.meetingPath || path.join(root, 'knowledge', 'meetings', 'index.json')),
+  };
+  const conversationReader = dependencies.getConversations || getConversations;
+  const customerUpdater = dependencies.updateCustomerTask || updateCustomerTask;
+  const officialCompleter = dependencies.completeTodoKnowledge || completeTodoKnowledge;
+  const goalUpdater = dependencies.qiweiGoalUpdateTask || qiweiGoalUpdateTask;
+
+  function readLocalStore() {
+    const store = readJson(localIndexPath, { version: 1, tasks: [], updatedAt: null });
+    store.version ||= 1;
+    store.tasks = Array.isArray(store.tasks) ? store.tasks : [];
+    return store;
+  }
+
+  function persistLocalStore(store) {
+    store.updatedAt = new Date().toISOString();
+    writeJsonAtomic(localIndexPath, store);
+    const lines = [
+      '# 统一任务中心',
+      '',
+      '本目录保存经人工确认后形成的本地内部任务。客户会话、企微官方待办、目标计划、文档和会议候选会在运行时统一聚合,不复制凭据或原始敏感消息。',
+      '',
+      `最近更新:${store.updatedAt}`,
+      '',
+      ...store.tasks.map(task => `- [${task.status === 'done' ? 'x' : ' '}] ${task.title}(${task.owner || '待分配'})`),
+      '',
+    ];
+    fs.writeFileSync(path.join(storeDir, 'README.md'), lines.join('\n'), 'utf8');
+  }
+
+  function collectCustomerTasks() {
+    const result = conversationReader() || {};
+    const conversations = result.data?.conversations || [];
+    return conversations.flatMap(conversation => (conversation.customerIntelligence?.tasks || []).map(task => baseTask({
+      id: task.id,
+      source: 'customer',
+      title: task.title,
+      owner: task.owner,
+      dueAt: task.dueAt,
+      priority: task.priority,
+      status: task.status,
+      evidence: task.evidence,
+      sourceTitle: `与 ${conversation.displayName || '客户'} 的真实企微会话`,
+      sourceId: task.id,
+      updatedAt: task.updatedAt,
+      audience: 'external',
+      conversationId: conversation.id,
+      contactName: conversation.displayName,
+      officialTodoId: task.officialTodoId,
+      officialSyncStatus: task.officialSyncStatus,
+      actions: {
+        openConversation: true,
+        complete: ['open', 'in_progress'].includes(task.status),
+        dismiss: ['open', 'in_progress'].includes(task.status),
+        syncOfficial: ['open', 'in_progress'].includes(task.status) && !task.officialTodoId,
+      },
+    })));
+  }
+
+  function collectOfficialTasks(linkedOfficialIds) {
+    const index = readJson(paths.official, { todos: [] });
+    const rows = Array.isArray(index) ? index : index.todos || [];
+    return rows.filter(todo => !linkedOfficialIds.has(String(todo.id || ''))).map(todo => baseTask({
+      id: String(todo.id || ''),
+      source: 'official',
+      title: todo.content,
+      owner: (todo.followers || []).map(item => item.name || '企微成员').filter(Boolean).join('、'),
+      dueAt: todo.endTime,
+      status: Number(todo.status) === 0 ? 'done' : 'open',
+      sourceTitle: '当前机器人创建并同步的企微官方待办',
+      sourceId: todo.id,
+      updatedAt: todo.syncedAt,
+      officialTodoId: todo.id,
+      actions: { complete: Number(todo.status) !== 0 },
+    }));
+  }
+
+  function collectGoalTasks() {
+    const store = readJson(paths.goals, { plans: [] });
+    return (store.plans || []).flatMap(plan => (plan.milestones || []).flatMap(milestone => (milestone.tasks || []).map(task => baseTask({
+      id: task.id,
+      source: 'goal',
+      title: task.title,
+      owner: task.owner,
+      dueAt: task.deadline,
+      priority: task.priority,
+      status: ({ planned: 'open', in_progress: 'in_progress', blocked: 'blocked', done: 'done', cancelled: 'dismissed' })[task.status] || 'open',
+      evidence: task.acceptance || task.note,
+      sourceTitle: `${plan.title} · ${milestone.title}`,
+      sourceId: task.id,
+      goalId: plan.id,
+      updatedAt: task.updatedAt || plan.updatedAt,
+      actions: { complete: !['done', 'cancelled'].includes(task.status), dismiss: !['done', 'cancelled'].includes(task.status) },
+    }))));
+  }
+
+  function collectLocalTasks(store) {
+    return store.tasks.map(task => baseTask({
+      ...task,
+      source: 'local',
+      sourceId: task.id,
+      actions: { complete: ACTIVE_STATUSES.has(task.status), dismiss: ACTIVE_STATUSES.has(task.status) },
+    }));
+  }
+
+  function collectCandidates(localStore) {
+    const promoted = new Map(localStore.tasks.filter(task => task.sourceRef?.key).map(task => [task.sourceRef.key, task.id]));
+    const candidates = [];
+    const documents = readJson(paths.documents, { documents: [] }).documents || [];
+    for (const document of documents) {
+      for (const [index, item] of (document.analysis?.actionItems || []).entries()) {
+        const key = `document:${document.id}:${stableId(item.title, item.evidence, index)}`;
+        candidates.push(baseTask({
+          id: key,
+          source: 'document-candidate',
+          title: item.title,
+          owner: item.owner,
+          dueAt: item.dueDate,
+          priority: item.priority,
+          status: 'suggested',
+          evidence: item.evidence,
+          sourceTitle: document.title,
+          sourceId: key,
+          confidence: item.confidence,
+          promotedTo: promoted.get(key),
+          updatedAt: document.analysis?.analyzedAt || document.syncedAt,
+          actions: { promote: !promoted.has(key) },
+        }));
+      }
+    }
+    const meetings = readJson(paths.meetings, { meetings: [] }).meetings || [];
+    for (const meeting of meetings) {
+      for (const [index, item] of (meeting.analysis?.actionItems || []).entries()) {
+        const key = `meeting:${meeting.id}:${stableId(item.title, item.evidence, index)}`;
+        candidates.push(baseTask({
+          id: key,
+          source: 'meeting-candidate',
+          title: item.title,
+          owner: item.owner,
+          dueAt: item.dueDate,
+          priority: item.priority,
+          status: 'suggested',
+          evidence: item.evidence,
+          sourceTitle: meeting.title,
+          sourceId: key,
+          confidence: item.confidence,
+          promotedTo: promoted.get(key),
+          updatedAt: meeting.analysis?.analyzedAt || meeting.syncedAt,
+          actions: { promote: !promoted.has(key) },
+        }));
+      }
+    }
+    return candidates;
+  }
+
+  function buildHub() {
+    ensureDir(storeDir);
+    const localStore = readLocalStore();
+    const customerTasks = collectCustomerTasks();
+    const linkedOfficialIds = new Set(customerTasks.map(task => task.officialTodoId).filter(Boolean));
+    const formalTasks = [
+      ...customerTasks,
+      ...collectOfficialTasks(linkedOfficialIds),
+      ...collectGoalTasks(),
+      ...collectLocalTasks(localStore),
+    ];
+    const candidates = collectCandidates(localStore);
+    const tasks = [...formalTasks, ...candidates].sort((a, b) => {
+      const statusOrder = value => value.overdue ? 0 : ACTIVE_STATUSES.has(value.status) ? 1 : value.status === 'suggested' ? 2 : 3;
+      return statusOrder(a) - statusOrder(b) || String(b.updatedAt).localeCompare(String(a.updatedAt));
+    });
+    const bySource = {};
+    for (const task of tasks) bySource[task.source] = (bySource[task.source] || 0) + 1;
+    return okResult({
+      assistantMessage: `统一任务中心已汇总 ${formalTasks.length} 条正式任务和 ${candidates.length} 条待人工确认候选。`,
+      summary: {
+        total: formalTasks.length,
+        active: formalTasks.filter(task => ACTIVE_STATUSES.has(task.status)).length,
+        completed: formalTasks.filter(task => task.status === 'done').length,
+        overdue: formalTasks.filter(task => task.overdue).length,
+        candidates: candidates.filter(task => !task.promotedTo).length,
+        promotedCandidates: candidates.filter(task => task.promotedTo).length,
+        deduplicatedOfficialCount: linkedOfficialIds.size,
+        bySource,
+      },
+      data: {
+        tasks,
+        storeDir,
+        policy: {
+          internalTasksOnly: true,
+          externalCustomerSafety: '客户会话任务只能转到智能会话,由 Agent 结合上下文拟定回复;系统不会把内部任务标题直接发送给客户。',
+          candidateSafety: '文档和会议中的 AI 行动项默认只是候选,必须人工确认后才会转为正式内部任务。',
+        },
+      },
+      files: [localIndexPath].filter(fs.existsSync),
+    });
+  }
+
+  function createLocalTask(input = {}) {
+    const title = String(input.title || '').trim();
+    if (!title) return errorResult('请输入任务标题。');
+    const store = readLocalStore();
+    const sourceKey = String(input.sourceKey || input.sourceId || '').trim();
+    if (sourceKey) {
+      const existing = store.tasks.find(task => task.sourceRef?.key === sourceKey);
+      if (existing) return okResult({ assistantMessage: '该候选已转为正式内部任务,无需重复创建。', summary: { created: false }, data: { task: existing } });
+    }
+    const now = new Date().toISOString();
+    const task = {
+      id: `local_${crypto.randomUUID()}`,
+      title,
+      owner: String(input.owner || '').trim(),
+      dueAt: normalizeDate(input.dueAt),
+      priority: String(input.priority || 'medium'),
+      status: 'open',
+      evidence: String(input.evidence || '').trim(),
+      sourceTitle: String(input.sourceTitle || '人工创建'),
+      sourceRef: sourceKey ? { key: sourceKey, type: String(input.source || 'candidate') } : null,
+      audience: 'internal',
+      createdAt: now,
+      updatedAt: now,
+    };
+    store.tasks.push(task);
+    persistLocalStore(store);
+    return okResult({ assistantMessage: `已创建内部任务“${title}”。`, summary: { created: true }, data: { task }, files: [localIndexPath, path.join(storeDir, 'README.md')] });
+  }
+
+  async function update(input = {}) {
+    const source = String(input.source || '');
+    const taskId = String(input.taskId || input.id || '');
+    const status = String(input.status || '');
+    if (!taskId || !status) return errorResult('更新任务需要 taskId 和 status。');
+    if (source === 'local') {
+      if (!LOCAL_STATUSES.has(status)) return errorResult('不支持的本地任务状态。');
+      const store = readLocalStore();
+      const task = store.tasks.find(item => item.id === taskId);
+      if (!task) return errorResult('本地任务不存在。');
+      task.status = status;
+      task.updatedAt = new Date().toISOString();
+      persistLocalStore(store);
+      return okResult({ assistantMessage: `本地任务已更新为 ${status}。`, data: { task } });
+    }
+    if (source === 'customer') return customerUpdater(taskId, { status });
+    if (source === 'official') {
+      if (status !== 'done') return errorResult('企微官方待办当前只支持标记完成。');
+      return officialCompleter(taskId);
+    }
+    if (source === 'goal') {
+      const mapped = ({ open: 'planned', in_progress: 'in_progress', blocked: 'blocked', done: 'done', dismissed: 'cancelled' })[status];
+      if (!mapped) return errorResult('不支持的目标任务状态。');
+      return goalUpdater({ goalId: String(input.goalId || ''), taskId, status: mapped });
+    }
+    return errorResult('该来源的任务不能直接更新;AI 候选需先人工确认。');
+  }
+
+  return { hub: buildHub, createLocalTask, update, readLocalStore };
+}
+
+const service = createUnifiedTaskService();
+
+module.exports = {
+  createUnifiedTaskService,
+  getUnifiedTaskHub: service.hub,
+  createUnifiedLocalTask: service.createLocalTask,
+  updateUnifiedTask: service.update,
+  __testing: { baseTask, stableId, isOverdue, sourceMeta },
+};

+ 463 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/workspace-library-service.js

@@ -0,0 +1,463 @@
+const fs = require('fs');
+const path = require('path');
+
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
+const KNOWLEDGE_BASE_DIR = path.join(PROJECT_ROOT, 'knowledge-base');
+const CATALOG_FILE = path.join(KNOWLEDGE_BASE_DIR, 'catalog.json');
+const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '..', '..');
+const ALLOWED_EXTENSIONS = new Set(['.md', '.json', '.csv', '.txt', '.js']);
+const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
+
+const PAGE_BY_SKILL = {
+  'qiwei-real-estate-auto-reply': 'agent',
+  'qiwei-agent-supervisor': 'agent',
+  'qiwei-group-management': 'groups',
+  'qiwei-customer-ops': 'customer-ops',
+  'qiwei-portrait-tags': 'portraits',
+  'qiwei-customer-transfer': 'transfers',
+  'qiwei-official-meeting': 'knowledge',
+  'qiwei-official-doc': 'knowledge',
+  'qiwei-official-todo': 'knowledge',
+  'qiwei-login': 'status',
+  'qiwei-dashboard': 'status',
+};
+
+const CAPABILITY_TAGS = {
+  'qiwei-api-catalog': ['100+ API', '接口文档', '通用调用'],
+  'qiwei-broker-playbook': ['顾问策略', '批量生成', '导出'],
+  'qiwei-capability-router': ['能力路由', '双通道', '自动选择'],
+  'qiwei-customer-ops': ['批量加好友', '建群', '欢迎语'],
+  'qiwei-customer-transfer': ['客户交接', '预览', '执行'],
+  'qiwei-dashboard': ['本地工作台', '状态管理'],
+  'qiwei-group-management': ['群识别', '群同步', '历史消息'],
+  'qiwei-login': ['扫码登录', '订阅', '席位'],
+  'qiwei-official-doc': ['文档知识库', 'Markdown', '官方 CLI'],
+  'qiwei-official-meeting': ['会议同步', 'AI 知识沉淀', '官方 CLI'],
+  'qiwei-official-todo': ['待办同步', '任务推进', '官方 CLI'],
+  'qiwei-portrait-tags': ['客户画像', '标签', '批量导出'],
+  'qiwei-real-estate-auto-reply': ['房产 Agent', '需求识别', '房源工具'],
+  'qiwei-agent-supervisor': ['人工监管', '审核', '审计'],
+  'qiwei-voice': ['语音下载', '解码', '转写'],
+  'qiwei-webhook-relay': ['Webhook', 'Relay', '长轮询'],
+};
+
+function readJson(filePath, fallback = {}) {
+  try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); }
+  catch { return fallback; }
+}
+
+function packageDefinitions() {
+  const definitions = [
+    {
+      id: 'unified-source',
+      label: '统一源码版',
+      description: '当前 4320 Dashboard 的主运行包,承载页面、MCP 工具和真实企微 Agent。',
+      root: PROJECT_ROOT,
+      status: 'running',
+    },
+    {
+      id: 'openclaw',
+      label: 'OpenClaw 企微技能包',
+      description: 'OpenClaw 版本的企微技能与独立 Agent 工具实现。',
+      root: path.resolve(WORKSPACE_ROOT, '..', 'openclaw-voc-skill', 'claude-code', 'claude-code-qiwe-assistant'),
+      status: 'connected',
+    },
+    {
+      id: 'agent-workbench',
+      label: '企微 Agent Workbench',
+      description: 'SQLite 状态机、人工审核、暂停、审计和工具循环的来源实现。',
+      root: path.join(WORKSPACE_ROOT, 'qiwei-agent-workbench'),
+      status: 'merged',
+    },
+  ];
+  const seenRoots = new Set();
+  return definitions.filter(item => {
+    if (!fs.existsSync(item.root)) return false;
+    const root = path.resolve(item.root).toLowerCase();
+    if (seenRoots.has(root)) return false;
+    seenRoots.add(root);
+    return true;
+  });
+}
+
+function loadCatalog() {
+  const parsed = readJson(CATALOG_FILE, { version: 1, libraries: [] });
+  const libraries = (parsed.libraries || []).map(item => ({
+    ...item,
+    root: path.resolve(KNOWLEDGE_BASE_DIR, item.path),
+    extensions: new Set((item.extensions || []).map(value => String(value).toLowerCase())),
+    kind: item.id === 'property-data'
+      ? 'property-library'
+      : item.id === 'meeting-knowledge'
+        ? 'meeting-library'
+        : item.id === 'doc-knowledge'
+          ? 'doc-library'
+          : item.id === 'todo-knowledge'
+            ? 'todo-library'
+            : item.id === 'task-knowledge'
+              ? 'task-library'
+        : 'document-library',
+  })).filter(item => item.id !== 'property-data' || fs.existsSync(item.root));
+  for (const pkg of packageDefinitions()) {
+    const skillsRoot = path.join(pkg.root, 'skills');
+    if (!fs.existsSync(skillsRoot)) continue;
+    libraries.push({
+      id: `skills-${pkg.id}`,
+      label: `${pkg.label} · 技能说明`,
+      description: 'SKILL.md 与 references 文档',
+      root: skillsRoot,
+      extensions: new Set(['.md', '.json', '.txt']),
+      kind: 'skill-library',
+    });
+  }
+  return { version: parsed.version || 1, libraries };
+}
+
+function encodeNodeId(libraryId, relativePath) {
+  return `${libraryId}~${Buffer.from(String(relativePath || ''), 'utf8').toString('base64url')}`;
+}
+
+function decodeNodeId(nodeId) {
+  const index = String(nodeId || '').indexOf('~');
+  if (index < 1) throw new Error('知识库节点不存在');
+  const libraryId = nodeId.slice(0, index);
+  const relativePath = Buffer.from(nodeId.slice(index + 1), 'base64url').toString('utf8');
+  return { libraryId, relativePath };
+}
+
+function safeNodePath(nodeId) {
+  const { libraryId, relativePath } = decodeNodeId(nodeId);
+  const library = loadCatalog().libraries.find(item => item.id === libraryId);
+  if (!library) throw new Error('知识库目录不存在');
+  const resolved = path.resolve(library.root, relativePath);
+  const rootPrefix = `${path.resolve(library.root).toLowerCase()}${path.sep}`;
+  if (resolved.toLowerCase() !== path.resolve(library.root).toLowerCase() && !resolved.toLowerCase().startsWith(rootPrefix)) {
+    throw new Error('禁止访问知识库目录之外的文件');
+  }
+  return { library, filePath: resolved, relativePath };
+}
+
+function fileKind(filePath) {
+  const base = path.basename(filePath).toLowerCase();
+  const extension = path.extname(filePath).toLowerCase();
+  if (base === 'properties.json') return 'property-dataset';
+  if (base === 'skill.md') return 'skill-document';
+  if (extension === '.md') return 'markdown';
+  if (extension === '.json') return 'json';
+  if (extension === '.csv') return 'csv';
+  if (extension === '.js') return 'code';
+  return 'text';
+}
+
+function buildDirectoryChildren(library, directory, relativeDir = '', depth = 0) {
+  if (depth > 7 || !fs.existsSync(directory)) return [];
+  const entries = fs.readdirSync(directory, { withFileTypes: true })
+    .filter(entry => !entry.name.startsWith('.') && entry.name !== 'node_modules')
+    .map(entry => {
+      const relativePath = path.join(relativeDir, entry.name);
+      const fullPath = path.join(directory, entry.name);
+      if (entry.isDirectory()) {
+        const children = buildDirectoryChildren(library, fullPath, relativePath, depth + 1);
+        if (!children.length) return null;
+        return {
+          id: encodeNodeId(library.id, relativePath),
+          name: entry.name,
+          type: 'folder',
+          children,
+          fileCount: children.reduce((sum, item) => sum + (item.type === 'file' ? 1 : item.fileCount || 0), 0),
+        };
+      }
+      const extension = path.extname(entry.name).toLowerCase();
+      if (!ALLOWED_EXTENSIONS.has(extension) || !library.extensions.has(extension)) return null;
+      const stats = fs.statSync(fullPath);
+      return {
+        id: encodeNodeId(library.id, relativePath),
+        name: entry.name,
+        type: 'file',
+        kind: fileKind(fullPath),
+        size: stats.size,
+        modifiedAt: stats.mtime.toISOString(),
+        relativePath: relativePath.replace(/\\/g, '/'),
+      };
+    })
+    .filter(Boolean);
+  return entries.sort((a, b) => {
+    if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
+    return a.name.localeCompare(b.name, 'zh-CN');
+  });
+}
+
+function propertySourcePath() {
+  const library = loadCatalog().libraries.find(item => item.id === 'property-data');
+  if (!library) throw new Error('房源数据目录未配置');
+  const filePath = path.join(library.root, 'properties.json');
+  if (!fs.existsSync(filePath)) throw new Error('properties.json 不存在');
+  return filePath;
+}
+
+function normalizeProperty(raw) {
+  return {
+    id: String(raw.id || raw.objectId || raw.property_id || ''),
+    community: raw.community || raw.community_name || '',
+    district: raw.district || raw.area_name || '',
+    totalPrice: Number(raw.totalPrice ?? raw.total_price ?? raw.price_total ?? 0),
+    unitPrice: Number(raw.unitPrice ?? raw.unit_price ?? raw.price_unit ?? 0),
+    layout: raw.layout || raw.house_type || '',
+    area: Number(raw.area || 0),
+    floor: raw.floor || raw.floor_info || '',
+    floorLevel: raw.floorLevel || raw.floor_level || '',
+    orientation: raw.orientation || '',
+    decoration: raw.decoration || '',
+    buildingAge: Number(raw.buildingAge ?? raw.building_age ?? 0),
+    isSchoolDistrict: Boolean(raw.isSchoolDistrict ?? raw.is_school_district),
+    schoolName: raw.schoolName || raw.school_name || '',
+    parking: raw.parking || '',
+    surrounding: raw.surrounding || '',
+    ownerSituation: raw.ownerSituation || raw.owner_situation || '',
+    priceDropSpace: Number(raw.priceDropSpace ?? raw.price_drop_space ?? 0),
+    isFiveYearOnly: Boolean(raw.isFiveYearOnly ?? raw.is_five_year_only),
+    highlights: raw.highlightTags || raw.highlight_tags || raw.tags || [],
+    scores: {
+      community: Number(raw.communityQuality || 0),
+      transport: Number(raw.transportScore || 0),
+      surrounding: Number(raw.surroundingScore || 0),
+      priceAdvantage: Number(raw.priceAdvantage || 0),
+    },
+  };
+}
+
+function allProperties() {
+  const parsed = readJson(propertySourcePath(), {});
+  const rows = Array.isArray(parsed) ? parsed : (parsed.properties || parsed.data || []);
+  return rows.map(normalizeProperty).filter(item => item.id || item.community);
+}
+
+function propertyStats(properties) {
+  const prices = properties.map(item => item.totalPrice).filter(value => value > 0);
+  return {
+    total: properties.length,
+    districts: [...new Set(properties.map(item => item.district.split('-')[0]).filter(Boolean))].sort(),
+    layouts: [...new Set(properties.map(item => item.layout).filter(Boolean))].sort(),
+    decorations: [...new Set(properties.map(item => item.decoration).filter(Boolean))].sort(),
+    minPrice: prices.length ? Math.min(...prices) : 0,
+    maxPrice: prices.length ? Math.max(...prices) : 0,
+    averagePrice: prices.length ? Math.round(prices.reduce((sum, value) => sum + value, 0) / prices.length) : 0,
+    schoolDistrictCount: properties.filter(item => item.isSchoolDistrict).length,
+  };
+}
+
+function listKnowledgeTree() {
+  const catalog = loadCatalog();
+  const roots = catalog.libraries.map(library => {
+    const fileChildren = buildDirectoryChildren(library, library.root);
+    const virtualNode = library.kind === 'meeting-library'
+      ? { relativePath: '__meeting_dashboard__', name: '会议工作台', kind: 'meeting-dashboard' }
+      : library.kind === 'doc-library'
+        ? { relativePath: '__doc_dashboard__', name: '文档工作台', kind: 'doc-dashboard' }
+        : library.kind === 'todo-library'
+          ? { relativePath: '__todo_dashboard__', name: '待办中心', kind: 'todo-dashboard' }
+          : library.kind === 'task-library'
+            ? { relativePath: '__task_dashboard__', name: '统一任务工作台', kind: 'task-dashboard' }
+          : null;
+    const children = virtualNode
+      ? [{ id: encodeNodeId(library.id, virtualNode.relativePath), name: virtualNode.name, type: 'file', kind: virtualNode.kind, virtual: true }, ...fileChildren]
+      : fileChildren;
+    return {
+      id: encodeNodeId(library.id, ''),
+      libraryId: library.id,
+      name: library.label,
+      description: library.description,
+      type: 'folder',
+      kind: library.kind,
+      path: library.root,
+      children,
+      fileCount: fileChildren.reduce((sum, item) => sum + (item.type === 'file' ? 1 : item.fileCount || 0), 0),
+    };
+  });
+  const properties = allProperties();
+  return {
+    status: 'ok',
+    data: {
+      catalogVersion: catalog.version,
+      roots,
+      summary: {
+        libraryCount: roots.length,
+        fileCount: roots.reduce((sum, item) => sum + item.fileCount, 0),
+        propertyCount: properties.length,
+        skillCount: listSkillRegistry().data.summary.skillCount,
+      },
+    },
+  };
+}
+
+function readKnowledgeFile(nodeId) {
+  const { library, filePath, relativePath } = safeNodePath(nodeId);
+  if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) throw new Error('知识库文件不存在');
+  const extension = path.extname(filePath).toLowerCase();
+  if (!ALLOWED_EXTENSIONS.has(extension) || !library.extensions.has(extension)) throw new Error('不支持预览该文件');
+  const stats = fs.statSync(filePath);
+  if (stats.size > MAX_PREVIEW_BYTES) throw new Error('文件超过 2MB,请缩小后再预览');
+  const kind = fileKind(filePath);
+  const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
+  let structured = null;
+  if (extension === '.json') {
+    try { structured = JSON.parse(content); } catch {}
+  }
+  return {
+    status: 'ok',
+    data: {
+      id: nodeId,
+      name: path.basename(filePath),
+      kind,
+      library: library.label,
+      relativePath: relativePath.replace(/\\/g, '/'),
+      size: stats.size,
+      modifiedAt: stats.mtime.toISOString(),
+      content,
+      structured,
+      propertyStats: kind === 'property-dataset' ? propertyStats(allProperties()) : null,
+    },
+  };
+}
+
+function listProperties(query = {}) {
+  const q = String(query.q || '').trim().toLowerCase();
+  const district = String(query.district || '').trim();
+  const layout = String(query.layout || '').trim();
+  const decoration = String(query.decoration || '').trim();
+  const maxPrice = Number(query.maxPrice || 0);
+  const page = Math.max(1, Number(query.page || 1) || 1);
+  const pageSize = Math.max(6, Math.min(60, Number(query.pageSize || 18) || 18));
+  const all = allProperties();
+  const filtered = all.filter(item => {
+    const haystack = `${item.id} ${item.community} ${item.district} ${item.layout} ${(item.highlights || []).join(' ')}`.toLowerCase();
+    if (q && !haystack.includes(q)) return false;
+    if (district && !item.district.includes(district)) return false;
+    if (layout && item.layout !== layout) return false;
+    if (decoration && item.decoration !== decoration) return false;
+    if (maxPrice && item.totalPrice > maxPrice) return false;
+    return true;
+  });
+  const offset = (page - 1) * pageSize;
+  return {
+    status: 'ok',
+    data: {
+      items: filtered.slice(offset, offset + pageSize),
+      total: filtered.length,
+      page,
+      pageSize,
+      pageCount: Math.max(1, Math.ceil(filtered.length / pageSize)),
+      stats: propertyStats(all),
+      source: propertySourcePath(),
+      sourceLabel: '演示房源数据集',
+    },
+  };
+}
+
+function getProperty(propertyId) {
+  const property = allProperties().find(item => item.id === String(propertyId));
+  if (!property) throw new Error('房源不存在');
+  return { status: 'ok', data: { property, source: propertySourcePath(), sourceLabel: '演示房源数据集' } };
+}
+
+function parseSkillFrontmatter(content, folderName) {
+  const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
+  const frontmatter = block?.[1] || '';
+  const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim() || folderName;
+  const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim() || '';
+  const title = content.match(/^#\s+(.+)$/m)?.[1]?.trim() || name;
+  return { name, description, title };
+}
+
+function listSkillRegistry() {
+  const packages = packageDefinitions().map(pkg => {
+    const skillsRoot = path.join(pkg.root, 'skills');
+    const skills = fs.existsSync(skillsRoot)
+      ? fs.readdirSync(skillsRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => {
+        const skillFile = path.join(skillsRoot, entry.name, 'SKILL.md');
+        if (!fs.existsSync(skillFile)) return null;
+        const content = fs.readFileSync(skillFile, 'utf8').replace(/^\uFEFF/, '');
+        const meta = parseSkillFrontmatter(content, entry.name);
+        return {
+          id: `${pkg.id}:${entry.name}`,
+          packageId: pkg.id,
+          folder: entry.name,
+          name: meta.name,
+          title: meta.title,
+          description: meta.description,
+          tags: CAPABILITY_TAGS[entry.name] || ['Skill/MCP'],
+          page: PAGE_BY_SKILL[entry.name] || '',
+          status: pkg.id === 'unified-source' ? 'integrated' : pkg.status,
+          content,
+          filePath: skillFile,
+        };
+      }).filter(Boolean)
+      : [];
+    return {
+      id: pkg.id,
+      label: pkg.label,
+      description: pkg.description,
+      status: pkg.status,
+      root: pkg.root,
+      skillCount: skills.length,
+      skills,
+    };
+  });
+
+  const properties = allProperties();
+  packages.push({
+    id: 'huaxiang-property-matching',
+    label: '花巷房源匹配项目',
+    description: '房源数据、客户样本、标签体系与多维匹配引擎。',
+    status: 'data-connected',
+    root: path.dirname(propertySourcePath()),
+    skillCount: 1,
+    skills: [{
+      id: 'huaxiang-property-matching:property-matching',
+      packageId: 'huaxiang-property-matching',
+      folder: 'property-matching',
+      name: 'property-matching',
+      title: '房源智能匹配',
+      description: `已接入 ${properties.length} 套演示房源、客户样本、标签和匹配引擎,可作为 Agent 的业务工具与知识数据源。`,
+      tags: ['房源列表', '客户画像', '多维评分'],
+      page: 'knowledge',
+      status: 'integrated',
+      content: '# 房源智能匹配\n\n房源数据与匹配引擎已经接入 4320 的知识库页面。\n\n- 数据源:`properties.json`\n- 客户样本:`buyers.json`\n- 标签体系:`buyer-tags.json`\n- 匹配引擎:`match-engine.js`\n',
+      filePath: propertySourcePath(),
+    }],
+  });
+
+  const allSkills = packages.flatMap(item => item.skills);
+  const uniqueNames = new Set(allSkills.map(item => item.name));
+  return {
+    status: 'ok',
+    data: {
+      packages,
+      summary: {
+        packageCount: packages.length,
+        skillCount: allSkills.length,
+        uniqueCapabilityCount: uniqueNames.size,
+        integratedCount: allSkills.filter(item => item.status === 'integrated').length,
+      },
+    },
+  };
+}
+
+function getSkillDetail(skillId) {
+  const registry = listSkillRegistry().data;
+  const skill = registry.packages.flatMap(item => item.skills.map(entry => ({ ...entry, package: item.label, packageRoot: item.root })))
+    .find(item => item.id === skillId);
+  if (!skill) throw new Error('技能不存在');
+  return { status: 'ok', data: skill };
+}
+
+module.exports = {
+  PROJECT_ROOT,
+  KNOWLEDGE_BASE_DIR,
+  listKnowledgeTree,
+  readKnowledgeFile,
+  listProperties,
+  getProperty,
+  listSkillRegistry,
+  getSkillDetail,
+};

+ 85 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js

@@ -0,0 +1,85 @@
+'use strict';
+
+const {
+  readQiweiAuthToken,
+  readQiweiUid,
+  readQiweiGuid,
+  readQiweiApiBase,
+} = require('../core/credentials');
+const { callFmodeWecomGateway } = require('./fmode-wecom-gateway');
+
+class FmodeQiweiClient {
+  constructor(config = {}) {
+    this.config = config;
+  }
+
+  context() {
+    return {
+      token: readQiweiAuthToken({ authToken: this.config.authToken }),
+      uid: readQiweiUid({ uid: this.config.uid }),
+      guid: readQiweiGuid({ guid: this.config.guid }),
+      apiBase: readQiweiApiBase({ apiBase: this.config.apiBase }),
+    };
+  }
+
+  isConfigured() {
+    const ctx = this.context();
+    return Boolean(ctx.token && ctx.uid && ctx.apiBase);
+  }
+
+  requireContext() {
+    const ctx = this.context();
+    if (!ctx.token) throw new Error('缺少 Fmode 鉴权,请先在 Fmode Studio 中完成登录');
+    if (!ctx.uid) throw new Error('缺少 Fmode 企微设备 uid,请先完成企微扫码登录');
+    return ctx;
+  }
+
+  async call(method, params = {}) {
+    const ctx = this.requireContext();
+    const result = await callFmodeWecomGateway({
+      gatewayPath: '/doApi',
+      body: {
+        uid: ctx.uid,
+        method,
+        params: ctx.guid ? { ...params, guid: params.guid || ctx.guid } : params,
+      },
+      token: ctx.token,
+      apiBase: ctx.apiBase,
+      timeoutMs: 30000,
+    });
+    return result.data && result.data.data !== undefined ? result.data.data : (result.data || {});
+  }
+
+  async checkLogin() {
+    const ctx = this.requireContext();
+    const result = await callFmodeWecomGateway({
+      gatewayPath: '/login/status',
+      httpMethod: 'GET',
+      query: { uid: ctx.uid },
+      token: ctx.token,
+      apiBase: ctx.apiBase,
+      timeoutMs: 30000,
+    });
+    const data = result.data || {};
+    const detail = data.detail || {};
+    return {
+      ...detail,
+      configured: Boolean(data.configured),
+      online: Boolean(data.online),
+      userOnlineStatus: data.online ? 2 : (data.statusCode ?? 0),
+      errorCode: data.online ? 0 : (detail.errorCode ?? -1),
+      nickname: detail.nickname || detail.userName || '',
+      corpName: detail.corpName || detail.corpFullName || '',
+    };
+  }
+
+  syncMessages(msgSeq, limit) {
+    return this.call('/msg/syncMsg', { msgSeq, limit });
+  }
+
+  sendText(toId, content) {
+    return this.call('/msg/sendText', { toId, content, isNoNeedRead: false });
+  }
+}
+
+module.exports = { FmodeQiweiClient };

+ 22 - 8
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js

@@ -79,6 +79,25 @@ function publicErrorMessage(kind) {
   return messages[kind] || messages.business;
 }
 
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+async function fetchWithNetworkRetry(url, options, timeoutMs, attempts = 4) {
+  let lastError;
+  for (let attempt = 1; attempt <= attempts; attempt += 1) {
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), timeoutMs);
+    try {
+      return await fetch(url, { ...options, signal: controller.signal });
+    } catch (error) {
+      lastError = error;
+      if (attempt < attempts) await delay(250 * attempt);
+    } finally {
+      clearTimeout(timer);
+    }
+  }
+  throw lastError;
+}
+
 async function callFmodeWecomGateway({
   gatewayPath,
   httpMethod = 'POST',
@@ -102,27 +121,22 @@ async function callFmodeWecomGateway({
 
   const url = buildGatewayUrl(apiBase, gatewayPath, query);
   const method = String(httpMethod || 'POST').toUpperCase();
-  const controller = new AbortController();
-  const timer = setTimeout(() => controller.abort(), timeoutMs);
   let response;
   try {
-    response = await fetch(url, {
+    response = await fetchWithNetworkRetry(url, {
       method,
       headers: {
         Authorization: `Bearer ${token}`,
         Accept: 'application/json',
         ...(body !== undefined ? { 'Content-Type': 'application/json' } : {})
       },
-      body: body === undefined ? undefined : JSON.stringify(body),
-      signal: controller.signal
-    });
+      body: body === undefined ? undefined : JSON.stringify(body)
+    }, timeoutMs);
   } catch (error) {
     const err = new Error('网络请求失败');
     err.kind = 'upstream';
     err.httpStatus = 0;
     throw err;
-  } finally {
-    clearTimeout(timer);
   }
 
   const text = await response.text();

+ 212 - 4
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -2,6 +2,7 @@
 const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
 const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
 const { z } = require('zod');
+const { redactSecret } = require('./providers/fmode-wecom-gateway');
 const { searchQiweiApis, getQiweiApiDoc, callQiweiApi } = require('./tools/qiwei-api-catalog-run');
 const {
   qiweiLoginStatus,
@@ -66,6 +67,12 @@ const {
   qiweiPreviewTransferPackage,
   qiweiExecuteTransfer
 } = require('./tools/qiwei-customer-transfer-run');
+const {
+  qiweiGoalCreatePlan,
+  qiweiGoalGet,
+  qiweiGoalUpdateTask,
+  qiweiGoalImportMeetingActions
+} = require('./tools/qiwei-goal-management-run');
 const { qiweiTranscribeVoice } = require('./tools/qiwei-voice-run');
 const {
   qiweiWebhookStatus,
@@ -79,6 +86,38 @@ const {
   qiweiRelayRegister,
   qiweiRelayConnect
 } = require('./tools/qiwei-webhook-relay-run');
+const {
+  qiweiAgentDashboardStart,
+  qiweiAgentBindController,
+  qiweiAgentStatus,
+  qiweiAgentListConversations,
+  qiweiAgentSessionGuide,
+  qiweiAgentCustomerIntelligence,
+  qiweiAgentInbox,
+  qiweiAgentListener,
+  qiweiAgentSetGlobal,
+  qiweiAgentGenerateDraft
+} = require('./tools/qiwei-agent-control-run');
+
+const goalTaskSchema = z.object({
+  title: z.string(),
+  owner: z.string().optional(),
+  deadline: z.string().optional(),
+  priority: z.string().optional(),
+  acceptance: z.string().optional(),
+  status: z.enum(['planned', 'in_progress', 'blocked', 'done', 'cancelled']).optional(),
+  note: z.string().optional(),
+  blockedReason: z.string().optional()
+});
+
+const goalMilestoneSchema = z.object({
+  title: z.string(),
+  owner: z.string().optional(),
+  deadline: z.string().optional(),
+  acceptance: z.string().optional(),
+  status: z.enum(['planned', 'in_progress', 'blocked', 'done', 'cancelled']).optional(),
+  tasks: z.array(goalTaskSchema).optional()
+});
 
 function asToolResult(result) {
   const normalized = result && typeof result === 'object' ? result : { status: 'ok', assistantMessage: String(result) };
@@ -99,10 +138,11 @@ function wrap(handler) {
     try {
       return asToolResult(await handler(input));
     } catch (error) {
+      const safeMessage = redactSecret(error && error.message ? error.message : error);
       return asToolResult({
         status: 'error',
-        assistantMessage: `工具执行异常:${error && error.message ? error.message : error}`,
-        errors: [{ message: String(error && error.message ? error.message : error) }]
+        assistantMessage: `工具执行异常:${safeMessage}`,
+        errors: [{ message: safeMessage }]
       });
     }
   };
@@ -111,7 +151,7 @@ function wrap(handler) {
 function createServer() {
   const server = new McpServer({
     name: 'enterprise-wechat-assistant',
-    version: '0.3.0'
+    version: '0.4.0'
   });
 
   server.registerTool(
@@ -329,7 +369,7 @@ function createServer() {
       title: '批量搜索并添加企微好友',
       description: [
         '迁移自 Qiwei 项目的批量加好友能力。按手机号搜索联系人,依据 qiwei-endpoints.json 中的 /contact/searchContact、/contact/addSearchWxContact、/contact/addSearchWxWorkContact 调用 Fmode 网关。',
-        '不修改 qiwei_login_* 和 qiwei_api_* 既有流程;需要已登录设备 guid。支持 customers、phones,或安装 xlsx 后传 filePath 读取 Excel。'
+    '不修改 qiwei_login_* 和 qiwei_api_* 既有流程;需要已登录设备 guid。支持 customers、phones,或传入 .xlsx filePath 读取 Excel。'
       ].join(' '),
       inputSchema: {
         guid: z.string().optional(),
@@ -1047,6 +1087,174 @@ function createServer() {
     wrap(qiweiRelayConnect)
   );
 
+  server.registerTool(
+    'qiwei_agent_dashboard_start',
+    {
+      title: '启动企微智能工作台',
+      description: '在当前技能包项目中启动企微 Dashboard(默认 4320 端口),返回智能会话页面地址。主控 Claude Code 会话首次使用时调用。',
+      inputSchema: {}
+    },
+    wrap(qiweiAgentDashboardStart)
+  );
+
+  server.registerTool(
+    'qiwei_goal_create_plan',
+    {
+      title: '建立目标推进计划',
+      description: '把已确认的大目标、里程碑和任务保存为本地推进台账;不会自动创建企微待办。',
+      inputSchema: {
+        title: z.string(),
+        objective: z.string(),
+        owner: z.string().optional(),
+        deadline: z.string().optional(),
+        acceptance: z.string().optional(),
+        status: z.enum(['planned', 'in_progress', 'blocked', 'done', 'cancelled']).optional(),
+        milestones: z.array(goalMilestoneSchema).optional()
+      }
+    },
+    wrap(qiweiGoalCreatePlan)
+  );
+
+  server.registerTool(
+    'qiwei_goal_get',
+    {
+      title: '查看目标与推进状态',
+      description: '列出目标计划,或读取单个计划的里程碑、任务、完成率、阻塞和逾期情况。',
+      inputSchema: { goalId: z.string().optional() }
+    },
+    wrap(qiweiGoalGet)
+  );
+
+  server.registerTool(
+    'qiwei_goal_update_task',
+    {
+      title: '更新目标任务进度',
+      description: '更新目标任务的状态、负责人、截止时间、完成说明或阻塞原因。',
+      inputSchema: {
+        goalId: z.string(),
+        taskId: z.string(),
+        status: z.enum(['planned', 'in_progress', 'blocked', 'done', 'cancelled']).optional(),
+        owner: z.string().optional(),
+        deadline: z.string().optional(),
+        note: z.string().optional(),
+        blockedReason: z.string().optional()
+      }
+    },
+    wrap(qiweiGoalUpdateTask)
+  );
+
+  server.registerTool(
+    'qiwei_goal_import_meeting_actions',
+    {
+      title: '把会议行动项写入目标计划',
+      description: '把已经过用户确认的会议行动项写入目标推进台账,并标记缺失的负责人或截止时间。',
+      inputSchema: {
+        goalId: z.string(),
+        meetingTitle: z.string(),
+        meetingDate: z.string().optional(),
+        sourceUrl: z.string().optional(),
+        actions: z.array(goalTaskSchema).min(1)
+      }
+    },
+    wrap(qiweiGoalImportMeetingActions)
+  );
+
+  server.registerTool(
+    'qiwei_agent_bind_controller',
+    {
+      title: '绑定企微项目主控会话',
+      description: '把当前或指定 Claude Code Session 绑定为项目主控,并让每个客户专属 Session 记录该主控关联。通常由 qiwei_agent_dashboard_start 自动尝试。',
+      inputSchema: { sessionId: z.string().uuid().optional() }
+    },
+    wrap(qiweiAgentBindController)
+  );
+
+  server.registerTool(
+    'qiwei_agent_status',
+    {
+      title: '查看企微 Agent 状态',
+      description: '读取账号在线状态、监听状态、全局策略、Claude Code/Fmode 模型和客户会话数量。',
+      inputSchema: {}
+    },
+    wrap(qiweiAgentStatus)
+  );
+
+  server.registerTool(
+    'qiwei_agent_list_conversations',
+    {
+      title: '列出企微 Agent 客户会话',
+      description: '列出白名单客户会话、最新消息、会话模式和待审核草稿。每个客户由独立 Claude Code Session 处理。',
+      inputSchema: {}
+    },
+    wrap(qiweiAgentListConversations)
+  );
+
+  server.registerTool(
+    'qiwei_agent_session_guide',
+    {
+      title: '查看客户 Claude Code 会话入口',
+      description: '当用户问某个企微客户由哪个 Claude Code Session 处理、会话在哪里或怎么打开时调用。返回客户可识别会话名和 Fmode Studio 安全打开命令,不暴露原始 Session ID。',
+      inputSchema: {
+        conversationId: z.string().optional(),
+        customerName: z.string().optional()
+      }
+    },
+    wrap(qiweiAgentSessionGuide)
+  );
+
+  server.registerTool(
+    'qiwei_agent_customer_intelligence',
+    {
+      title: '查看客户画像、待办与预警',
+      description: '读取监听和 Agent 持续沉淀的客户画像、内部跟进待办和风险/高意向预警。用户问客户需求进展、下一步该做什么或有哪些风险时调用。',
+      inputSchema: {
+        conversationId: z.string().optional(),
+        customerName: z.string().optional()
+      }
+    },
+    wrap(qiweiAgentCustomerIntelligence)
+  );
+
+  server.registerTool(
+    'qiwei_agent_inbox',
+    {
+      title: '读取项目主控事件箱',
+      description: '读取企微监听、Agent 决策、人工审核和发送动作的审计事件,供项目主控 Claude Code 会话掌握前端与客户会话状态。',
+      inputSchema: { limit: z.number().int().min(1).max(200).optional() }
+    },
+    wrap(qiweiAgentInbox)
+  );
+
+  server.registerTool(
+    'qiwei_agent_listener',
+    {
+      title: '启动或停止企微监听',
+      description: '控制真实企微消息监听。running=true 启动,false 停止。启动监听不等于自动发送,发送仍受全局策略、会话模式和白名单约束。',
+      inputSchema: { running: z.boolean() }
+    },
+    wrap(qiweiAgentListener)
+  );
+
+  server.registerTool(
+    'qiwei_agent_set_global',
+    {
+      title: '设置企微 Agent 全局策略',
+      description: '设置 paused(全局暂停)、review(生成待审核草稿)、auto(仅高置信且无需人工时自动发送)或 human(人工模式)。',
+      inputSchema: { mode: z.enum(['paused', 'review', 'auto', 'human']) }
+    },
+    wrap(qiweiAgentSetGlobal)
+  );
+
+  server.registerTool(
+    'qiwei_agent_generate_draft',
+    {
+      title: '让 Claude Code 生成企微回复草稿',
+      description: '对指定客户会话的最新入站消息运行专用 Claude Code/Fmode Session,只生成待审核草稿,不发送给客户。',
+      inputSchema: { conversationId: z.string() }
+    },
+    wrap(qiweiAgentGenerateDraft)
+  );
+
   return server;
 }
 

+ 264 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-control-run.js

@@ -0,0 +1,264 @@
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { latestPath } = require('../core/output-paths');
+
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
+const DASHBOARD_PORT = Number(process.env.QIWEI_DASHBOARD_PORT || 4320);
+const DASHBOARD_URL = `http://127.0.0.1:${DASHBOARD_PORT}`;
+const SESSION_FILE = latestPath('messages', 'claude-code-sessions.json');
+
+function writeJsonAtomic(filePath, value) {
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+
+function bindControllerSession(sessionId = '') {
+  const selected = String(sessionId || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || '').trim();
+  if (!selected) return { bound: false, reason: 'session_id_unavailable' };
+  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(selected)) {
+    return { bound: false, reason: 'invalid_session_id' };
+  }
+  const state = (() => {
+    try { return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); }
+    catch { return { version: 1, project: {}, sessions: {} }; }
+  })();
+  state.project ||= {};
+  state.sessions ||= {};
+  state.project.controllerSessionId = selected;
+  state.project.boundMainSessionId = selected;
+  state.project.updatedAt = new Date().toISOString();
+  for (const session of Object.values(state.sessions)) {
+    if (session.role === 'customer-agent') session.parentControllerSessionId = selected;
+  }
+  writeJsonAtomic(SESSION_FILE, state);
+  return { bound: true };
+}
+
+async function requestJson(method, pathname, body, timeoutMs = 15000) {
+  const response = await fetch(`${DASHBOARD_URL}${pathname}`, {
+    method,
+    headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
+    body: body === undefined ? undefined : JSON.stringify(body),
+    signal: AbortSignal.timeout(timeoutMs),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.status === 'error') throw new Error(payload.message || payload.assistantMessage || `Dashboard HTTP ${response.status}`);
+  return payload;
+}
+
+async function dashboardAvailable() {
+  try { await requestJson('GET', '/api/agent/status', undefined, 2500); return true; }
+  catch { return false; }
+}
+
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+async function qiweiAgentDashboardStart() {
+  bindControllerSession();
+  if (!(await dashboardAvailable())) {
+    const child = spawn(process.execPath, [path.join(PROJECT_ROOT, 'scripts', 'start-dashboard.js')], {
+      cwd: PROJECT_ROOT,
+      detached: true,
+      windowsHide: true,
+      stdio: 'ignore',
+      env: { ...process.env, QIWEI_DASHBOARD_PORT: String(DASHBOARD_PORT) },
+    });
+    child.unref();
+    for (let attempt = 0; attempt < 40 && !(await dashboardAvailable()); attempt += 1) await delay(250);
+  }
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台启动失败,请检查端口是否被占用');
+  return okResult({
+    assistantMessage: `企微智能工作台已启动:${DASHBOARD_URL}/#agent`,
+    summary: { running: true, port: DASHBOARD_PORT },
+    data: { dashboardUrl: `${DASHBOARD_URL}/#agent` },
+  });
+}
+
+async function qiweiAgentBindController(input = {}) {
+  const result = bindControllerSession(input.sessionId);
+  if (!result.bound) return errorResult(result.reason === 'invalid_session_id'
+    ? '主控 Session ID 格式无效'
+    : '当前 Claude Code 环境未暴露 Session ID,请显式传入 sessionId');
+  return okResult({
+    assistantMessage: '当前 Claude Code 会话已绑定为企微项目主控;后续客户 Session 将关联到该主控。',
+    summary: { bound: true },
+    data: { controller: readProjectLink() },
+  });
+}
+
+async function qiweiAgentStatus() {
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动,请先调用 qiwei_agent_dashboard_start');
+  const [status, conversations] = await Promise.all([
+    requestJson('GET', '/api/agent/status'),
+    requestJson('GET', '/api/agent/conversations'),
+  ]);
+  const items = conversations.data?.conversations || [];
+  const readySessions = items.filter(item => item.claudeSession?.ready).length;
+  return okResult({
+    assistantMessage: `企微 Agent ${status.data?.listener?.running ? '监听中' : '监听已停止'},${items.length} 个客户会话,已识别 ${readySessions} 个可审阅的客户 Claude Code Session。`,
+    summary: {
+      online: Boolean(status.data?.account?.online),
+      listenerRunning: Boolean(status.data?.listener?.running),
+      global: status.data?.global || null,
+      provider: status.data?.agent?.provider || null,
+      model: status.data?.agent?.model || null,
+      conversationCount: items.length,
+      customerSessionReadyCount: readySessions,
+    },
+    data: { dashboardUrl: `${DASHBOARD_URL}/#agent` },
+  });
+}
+
+async function qiweiAgentListConversations() {
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
+  const result = await requestJson('GET', '/api/agent/conversations');
+  const conversations = (result.data?.conversations || []).map(item => ({
+    id: item.id,
+    displayName: item.displayName,
+    mode: item.mode,
+    lastMessageAt: item.lastMessageAt,
+    lastContent: item.messages?.at(-1)?.content || '',
+    pendingDraft: item.pendingReply ? {
+      id: item.pendingReply.id,
+      content: item.pendingReply.content,
+      confidence: item.pendingReply.confidence,
+      requiresHuman: item.pendingReply.requiresHuman,
+    } : null,
+    agentError: item.agentError || null,
+    claudeSession: item.claudeSession || null,
+  }));
+  const readySessions = conversations.filter(item => item.claudeSession?.ready).length;
+  return okResult({
+    assistantMessage: `读取到 ${conversations.length} 个企微客户会话,其中 ${readySessions} 个已绑定可审阅的 Claude Code Session。需要查看时调用 qiwei_agent_session_guide。`,
+    summary: { count: conversations.length, readySessions },
+    data: { conversations },
+    nextActions: readySessions ? ['调用 qiwei_agent_session_guide,按客户名称获取会话名称和安全打开方式'] : [],
+  });
+}
+
+async function qiweiAgentSessionGuide(input = {}) {
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动,请先调用 qiwei_agent_dashboard_start');
+  const result = await requestJson('GET', '/api/agent/conversations');
+  const conversations = result.data?.conversations || [];
+  let matches = conversations;
+  if (input.conversationId) matches = matches.filter(item => item.id === input.conversationId);
+  if (input.customerName) matches = matches.filter(item => String(item.displayName || '').includes(String(input.customerName)));
+  if (matches.length !== 1) {
+    return errorResult(matches.length
+      ? '匹配到多个客户会话,请传入 conversationId 精确选择'
+      : '没有找到对应客户会话,请先调用 qiwei_agent_list_conversations', {
+        data: { candidates: conversations.map(item => ({ id: item.id, displayName: item.displayName, maskedId: item.maskedId })) },
+      });
+  }
+  const selected = matches[0];
+  const guide = selected.claudeSession;
+  if (!guide?.detected) {
+    return okResult({
+      assistantMessage: `${selected.displayName} 尚未创建客户 Claude Code Session;首次让 Agent 生成草稿后,系统会自动创建并显示打开方式。`,
+      summary: { detected: false, ready: false },
+      data: { conversation: { id: selected.id, displayName: selected.displayName, maskedId: selected.maskedId }, session: guide },
+      nextActions: ['调用 qiwei_agent_generate_draft 生成待审核草稿,不会发送消息'],
+    });
+  }
+  return okResult({
+    assistantMessage: `已检测到 ${selected.displayName} 的客户专属 Claude Code Session“${guide.displayName}”。在 ${guide.openLocation} 运行:${guide.openCommand}。系统会打开 fork 审阅副本,不会污染生产会话。`,
+    summary: { detected: true, ready: guide.ready, openMode: guide.openMode },
+    data: { conversation: { id: selected.id, displayName: selected.displayName, maskedId: selected.maskedId }, session: guide },
+    nextActions: guide.ready ? ['运行 openCommand 查看完整历史、模型思考和工具轨迹'] : ['首次生成草稿后再打开'],
+  });
+}
+
+async function qiweiAgentCustomerIntelligence(input = {}) {
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动,请先调用 qiwei_agent_dashboard_start');
+  const result = await requestJson('GET', '/api/agent/conversations');
+  let matches = result.data?.conversations || [];
+  if (input.conversationId) matches = matches.filter(item => item.id === input.conversationId);
+  if (input.customerName) matches = matches.filter(item => String(item.displayName || '').includes(String(input.customerName)));
+  if (matches.length !== 1) return errorResult(matches.length ? '匹配到多个客户,请传入 conversationId' : '没有找到对应客户会话');
+  const selected = matches[0];
+  const intelligence = selected.customerIntelligence || { profile: {}, tasks: [], alerts: [], summary: {} };
+  return okResult({
+    assistantMessage: `${selected.displayName}:画像字段 ${Object.keys(intelligence.profile || {}).length} 个,待处理任务 ${intelligence.summary?.openTasks || 0} 项,未处理预警 ${intelligence.summary?.openAlerts || 0} 项。`,
+    summary: { customer: selected.displayName, ...(intelligence.summary || {}) },
+    data: {
+      conversation: { id: selected.id, displayName: selected.displayName, maskedId: selected.maskedId },
+      intelligence,
+      claudeSession: selected.claudeSession || null,
+    },
+    nextActions: [
+      '补齐画像中仍不明确的字段',
+      '在 Dashboard 处理内部待办和预警',
+      '需要审阅 Agent 依据时调用 qiwei_agent_session_guide',
+    ],
+  });
+}
+
+async function qiweiAgentInbox(input = {}) {
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
+  const limit = Math.max(1, Math.min(200, Number(input.limit || 50)));
+  const result = await requestJson('GET', `/api/agent/audit?limit=${limit}`);
+  const audit = result.data?.audit || [];
+  return okResult({
+    assistantMessage: `主控事件箱读取完成:最近 ${audit.length} 条事件。`,
+    summary: { count: audit.length },
+    data: { events: audit, controller: readProjectLink() },
+  });
+}
+
+async function qiweiAgentListener(input = {}) {
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
+  const action = input.running === false ? 'stop' : 'start';
+  const result = await requestJson('POST', `/api/agent/listener/${action}`, {});
+  return okResult({ assistantMessage: result.assistantMessage, data: result.data });
+}
+
+async function qiweiAgentSetGlobal(input = {}) {
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
+  const result = await requestJson('POST', '/api/agent/mode', { mode: input.mode });
+  return okResult({ assistantMessage: result.assistantMessage, data: result.data });
+}
+
+async function qiweiAgentGenerateDraft(input = {}) {
+  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
+  if (!input.conversationId) return errorResult('缺少 conversationId,请先调用 qiwei_agent_list_conversations');
+  const result = await requestJson('POST', `/api/agent/conversations/${encodeURIComponent(input.conversationId)}/generate`, {}, 180000);
+  const guideResult = await qiweiAgentSessionGuide({ conversationId: input.conversationId });
+  const guide = guideResult.data?.session || null;
+  return okResult({
+    assistantMessage: guide?.ready
+      ? `${result.assistantMessage}。本次由“${guide.displayName}”处理;需要审阅 Claude Code 原始会话时运行:${guide.openCommand}`
+      : result.assistantMessage,
+    data: { result: result.data, claudeSession: guide },
+    nextActions: guide?.ready ? ['在 Fmode Studio 当前项目终端运行 openCommand 查看 fork 审阅副本'] : [],
+  });
+}
+
+function readProjectLink() {
+  try {
+    const parsed = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
+    return {
+      projectId: parsed.project?.projectId || null,
+      controllerBound: Boolean(parsed.project?.boundMainSessionId),
+      customerSessionCount: Object.values(parsed.sessions || {}).filter(session => session.role === 'customer-agent').length,
+    };
+  } catch {
+    return { projectId: null, controllerBound: false, customerSessionCount: 0 };
+  }
+}
+
+module.exports = {
+  qiweiAgentDashboardStart,
+  qiweiAgentBindController,
+  qiweiAgentStatus,
+  qiweiAgentListConversations,
+  qiweiAgentSessionGuide,
+  qiweiAgentCustomerIntelligence,
+  qiweiAgentInbox,
+  qiweiAgentListener,
+  qiweiAgentSetGlobal,
+  qiweiAgentGenerateDraft,
+};

+ 0 - 355
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-skill-run.js

@@ -1,355 +0,0 @@
-const fs = require('fs');
-const path = require('path');
-const { spawn } = require('child_process');
-const { okResult, errorResult } = require('../core/result-envelope');
-const { redactSecret } = require('../providers/fmode-wecom-gateway');
-const { PACKAGE_ROOT, latestPath } = require('../core/output-paths');
-
-const LEGACY_RUNTIME_DIR = path.join(PACKAGE_ROOT, 'legacy', 'qiwei-agent-skill');
-const DEFAULT_SERVICE_URL = 'http://localhost:3000';
-
-const CONTROL_FIELDS = new Set([
-  'serviceUrl',
-  'apiKey',
-  'timeoutMs',
-  'endpoint',
-  'path',
-  'route',
-  'method',
-  'body',
-  'payload',
-  'params'
-]);
-
-const LEGACY_SKILL_TOOLS = [
-  ['qiwei_legacy_batch_add_friends', 'POST', '/api/skill/batch-add-friends', '源后端:批量加好友'],
-  ['qiwei_legacy_auto_create_group', 'POST', '/api/skill/auto-create-group', '源后端:自动建客户服务群'],
-  ['qiwei_sync_group_messages', 'POST', '/api/skill/sync-group-messages', '同步客户群消息到本地 SQLite'],
-  ['qiwei_sync_external_groups', 'POST', '/api/skill/sync-external-groups', '扫描并导入历史外部群'],
-  ['qiwei_review_imported_groups', 'POST', '/api/skill/review-imported-groups', '重新分析待确认外部群'],
-  ['qiwei_confirm_external_group', 'POST', '/api/skill/confirm-external-group', '确认外部群为客户群'],
-  ['qiwei_add_external_group', 'POST', '/api/skill/add-external-group', '手动添加外部群'],
-  ['qiwei_update_customer_portrait', 'POST', '/api/skill/update-customer-portrait', '更新客户画像'],
-  ['qiwei_prepare_customer_portrait', 'POST', '/api/skill/prepare-customer-portrait', '准备客户画像分析上下文'],
-  ['qiwei_save_customer_portrait', 'POST', '/api/skill/save-customer-portrait', '保存客户画像分析结果'],
-  ['qiwei_batch_update_customer_portrait', 'POST', '/api/skill/batch-update-customer-portrait', '批量更新客户画像'],
-  ['qiwei_batch_save_customer_portrait', 'POST', '/api/skill/batch-save-customer-portrait', '批量保存客户画像结果'],
-  ['qiwei_export_customer_portraits', 'POST', '/api/skill/export-customer-portraits', '导出客户画像 Excel'],
-  ['qiwei_transcribe_voice', 'POST', '/api/skill/transcribe-voice', '转写群聊语音消息'],
-  ['qiwei_get_customer_profile', 'POST', '/api/skill/get-customer-profile', '查询客户完整档案'],
-  ['qiwei_distill_broker', 'POST', '/api/skill/distill-broker', '蒸馏服务顾问 playbook'],
-  ['qiwei_prepare_broker_playbook', 'POST', '/api/skill/prepare-broker-playbook', '准备服务顾问 playbook 上下文'],
-  ['qiwei_save_broker_playbook', 'POST', '/api/skill/save-broker-playbook', '保存服务顾问 playbook'],
-  ['qiwei_batch_distill_broker', 'POST', '/api/skill/batch-distill-broker', '批量蒸馏服务顾问 playbook'],
-  ['qiwei_batch_save_broker_playbook', 'POST', '/api/skill/batch-save-broker-playbook', '批量保存服务顾问 playbook'],
-  ['qiwei_export_broker_playbooks', 'POST', '/api/skill/export-broker-playbooks', '导出服务顾问 playbook Excel'],
-  ['qiwei_preview_transfer_package', 'POST', '/api/skill/preview-transfer-package', '预览客户交接包'],
-  ['qiwei_execute_transfer', 'POST', '/api/skill/execute-transfer', '执行客户交接'],
-  ['qiwei_record_collaboration', 'POST', '/api/skill/record-collaboration', '记录服务顾问协作分边'],
-  ['qiwei_add_customer_tags', 'POST', '/api/skill/add-customer-tags', '添加本地客户标签'],
-  ['qiwei_remove_customer_tags', 'POST', '/api/skill/remove-customer-tags', '移除本地客户标签'],
-  ['qiwei_list_customer_tags', 'POST', '/api/skill/list-customer-tags', '查询客户标签'],
-  ['qiwei_list_all_tags', 'POST', '/api/skill/list-all-tags', '查询全部本地标签'],
-  ['qiwei_sync_qiwe_personal_labels', 'POST', '/api/skill/sync-qiwe-personal-labels', '同步企微个人标签'],
-  ['qiwei_create_qiwe_personal_label', 'POST', '/api/skill/create-qiwe-personal-label', '创建企微个人标签'],
-  ['qiwei_update_qiwe_personal_label', 'POST', '/api/skill/update-qiwe-personal-label', '修改企微个人标签'],
-  ['qiwei_delete_qiwe_personal_label', 'POST', '/api/skill/delete-qiwe-personal-label', '删除企微个人标签'],
-  ['qiwei_apply_qiwe_labels_to_customer', 'POST', '/api/skill/apply-qiwe-labels-to-customer', '给客户打/删企微个人标签'],
-  ['qiwei_friend_polling_status', 'GET', '/api/skill/friend-polling-status', '查询好友通过轮询 worker 状态'],
-  ['qiwei_webhook_status', 'GET', '/api/webhook/status', '查询源后端 Webhook 状态'],
-  ['qiwei_webhook_discover', 'GET', '/api/webhook/discover', '发现 Webhook 回调配置'],
-  ['qiwei_webhook_auto_setup', 'POST', '/api/webhook/auto-setup', '自动配置 Webhook 回调'],
-  ['qiwei_webhook_setup', 'POST', '/api/webhook/setup', '手动设置 Webhook 回调'],
-  ['qiwei_webhook_relay_config', 'GET', '/api/webhook/relay-config', '查询 Webhook Relay 配置'],
-  ['qiwei_webhook_relay_save_config', 'POST', '/api/webhook/relay-config', '保存 Webhook Relay 配置'],
-  ['qiwei_webhook_relay_connect', 'POST', '/api/webhook/relay-connect', '连接 Webhook Relay']
-].map(([name, method, endpoint, title]) => ({ name, method, endpoint, title }));
-
-function readEnvFile(filePath) {
-  if (!fs.existsSync(filePath)) return {};
-  const env = {};
-  const text = fs.readFileSync(filePath, 'utf8');
-  for (const line of text.split(/\r?\n/)) {
-    const trimmed = line.trim();
-    if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
-    const idx = trimmed.indexOf('=');
-    const key = trimmed.slice(0, idx).trim();
-    let value = trimmed.slice(idx + 1).trim();
-    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
-      value = value.slice(1, -1);
-    }
-    env[key] = value;
-  }
-  return env;
-}
-
-function legacyEnv() {
-  return readEnvFile(path.join(LEGACY_RUNTIME_DIR, '.env'));
-}
-
-function serviceUrl(input = {}) {
-  return String(input.serviceUrl || process.env.QIWEI_AGENT_SERVICE_URL || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
-}
-
-function apiKey(input = {}) {
-  const env = legacyEnv();
-  return input.apiKey || process.env.QIWEI_AGENT_API_KEY || process.env.QIWEI_SKILL_API_KEY || env.API_KEY || '';
-}
-
-function stripControl(input = {}) {
-  const body = {};
-  for (const [key, value] of Object.entries(input || {})) {
-    if (!CONTROL_FIELDS.has(key)) body[key] = value;
-  }
-  return body;
-}
-
-function requestBody(input = {}) {
-  if (input.body && typeof input.body === 'object') return input.body;
-  if (input.payload && typeof input.payload === 'object') return input.payload;
-  if (input.params && typeof input.params === 'object') return input.params;
-  return stripControl(input);
-}
-
-function parseJsonMaybe(text) {
-  if (!text) return null;
-  try {
-    return JSON.parse(text);
-  } catch {
-    return text;
-  }
-}
-
-function summarizePayload(payload) {
-  if (!payload || typeof payload !== 'object') return {};
-  return {
-    success: payload.success,
-    status: payload.status,
-    message: payload.message,
-    total: payload.total,
-    successCount: payload.successCount || payload.success,
-    failed: payload.failed,
-    count: payload.count,
-    filePath: payload.filePath
-  };
-}
-
-async function requestLegacyService(input = {}) {
-  const endpoint = input.endpoint || input.path || input.route;
-  if (!endpoint) return errorResult('缺少 endpoint/path/route。');
-
-  const method = String(input.method || 'POST').toUpperCase();
-  const url = `${serviceUrl(input)}${String(endpoint).startsWith('/') ? endpoint : `/${endpoint}`}`;
-  const timeoutMs = Math.max(1000, Number(input.timeoutMs || 120000));
-  const controller = new AbortController();
-  const timer = setTimeout(() => controller.abort(), timeoutMs);
-  const key = apiKey(input);
-
-  const headers = {};
-  if (key) {
-    headers.Authorization = `Bearer ${key}`;
-    headers['X-API-Key'] = key;
-  }
-  const options = { method, headers, signal: controller.signal };
-  if (!['GET', 'HEAD'].includes(method)) {
-    headers['Content-Type'] = 'application/json';
-    options.body = JSON.stringify(input.body !== undefined ? input.body : requestBody(input));
-  }
-
-  try {
-    const response = await fetch(url, options);
-    const text = await response.text();
-    const payload = parseJsonMaybe(text);
-    if (!response.ok) {
-      return {
-        status: response.status === 401 || response.status === 403 ? 'needs_auth' : 'error',
-        assistantMessage: `源后端接口调用失败:${method} ${endpoint} -> HTTP ${response.status}。`,
-        summary: { endpoint, method, httpStatus: response.status },
-        data: { response: payload },
-        files: [],
-        nextActions: response.status === 401 || response.status === 403
-          ? ['在 legacy/qiwei-agent-skill/.env 配置 API_KEY,或调用工具时传 apiKey']
-          : ['检查源后端日志后重试'],
-        warnings: [],
-        errors: [{ message: typeof payload === 'string' ? payload : JSON.stringify(payload) }]
-      };
-    }
-
-    const message = payload && typeof payload === 'object'
-      ? payload.message || payload.result || payload.status || `源后端接口调用成功:${method} ${endpoint}`
-      : `源后端接口调用成功:${method} ${endpoint}`;
-    return okResult({
-      assistantMessage: String(message),
-      summary: { endpoint, method, httpStatus: response.status, ...summarizePayload(payload) },
-      data: { response: payload }
-    });
-  } catch (error) {
-    const message = error && error.name === 'AbortError'
-      ? `源后端接口超时:${method} ${endpoint}`
-      : `无法访问源后端服务 ${url}:${error && error.message ? error.message : String(error)}`;
-    return {
-      status: 'needs_start',
-      assistantMessage: redactSecret(message),
-      summary: { endpoint, method, serviceUrl: serviceUrl(input) },
-      data: {},
-      files: [],
-      nextActions: [
-        '先调用 qiwei_agent_service_status 检查运行时',
-        '如未启动,调用 qiwei_agent_service_start 或在 legacy/qiwei-agent-skill 下运行 npm install && npm run dev'
-      ],
-      warnings: [],
-      errors: [{ message: redactSecret(message) }]
-    };
-  } finally {
-    clearTimeout(timer);
-  }
-}
-
-async function qiweiAgentServiceStatus(input = {}) {
-  const runtimeExists = fs.existsSync(LEGACY_RUNTIME_DIR);
-  const packageJson = path.join(LEGACY_RUNTIME_DIR, 'package.json');
-  const depsInstalled = fs.existsSync(path.join(LEGACY_RUNTIME_DIR, 'node_modules'));
-  const schemaExists = fs.existsSync(path.join(LEGACY_RUNTIME_DIR, 'lib', 'schema.ts'));
-  const envExists = fs.existsSync(path.join(LEGACY_RUNTIME_DIR, '.env'));
-
-  const base = {
-    runtimeDir: LEGACY_RUNTIME_DIR,
-    runtimeExists,
-    packageJsonExists: fs.existsSync(packageJson),
-    depsInstalled,
-    schemaExists,
-    envExists,
-    serviceUrl: serviceUrl(input)
-  };
-
-  if (!runtimeExists) {
-    return errorResult('源 Qiwei 后端运行时尚未迁入目标包。', { summary: base });
-  }
-
-  const health = await requestLegacyService({ ...input, method: 'GET', endpoint: '/api/health', timeoutMs: input.timeoutMs || 3000 });
-  const skills = health.status === 'ok'
-    ? await requestLegacyService({ ...input, method: 'GET', endpoint: '/api/skills', timeoutMs: input.timeoutMs || 5000 })
-    : null;
-  const skillsPayload = skills && skills.status === 'ok' ? skills.data.response : null;
-  const skillCount = Array.isArray(skillsPayload)
-    ? skillsPayload.length
-    : (skillsPayload && Array.isArray(skillsPayload.skills) ? skillsPayload.skills.length : undefined);
-
-  return okResult({
-    assistantMessage: health.status === 'ok'
-      ? '源 Qiwei 后端已迁入且服务在线。'
-      : '源 Qiwei 后端已迁入,但本地服务未在线或未完成依赖安装。',
-    summary: {
-      ...base,
-      serviceOnline: health.status === 'ok',
-      skillCount
-    },
-    data: { health, skills },
-    nextActions: depsInstalled ? [] : [`在 ${LEGACY_RUNTIME_DIR} 运行 npm install`],
-    warnings: envExists ? [] : ['legacy/qiwei-agent-skill/.env 尚不存在;首次运行前通常需要从 .env.example 创建并配置。']
-  });
-}
-
-async function qiweiAgentServiceStart(input = {}) {
-  if (!fs.existsSync(LEGACY_RUNTIME_DIR)) {
-    return errorResult('源 Qiwei 后端运行时不存在,无法启动。', { summary: { runtimeDir: LEGACY_RUNTIME_DIR } });
-  }
-  if (!fs.existsSync(path.join(LEGACY_RUNTIME_DIR, 'node_modules'))) {
-    return {
-      status: 'needs_setup',
-      assistantMessage: '源 Qiwei 后端依赖尚未安装,不能启动。',
-      summary: { runtimeDir: LEGACY_RUNTIME_DIR },
-      data: {},
-      files: [],
-      nextActions: [`cd ${LEGACY_RUNTIME_DIR}`, 'npm install', 'npm run dev'],
-      warnings: [],
-      errors: []
-    };
-  }
-
-  const health = await requestLegacyService({ ...input, method: 'GET', endpoint: '/api/health', timeoutMs: 2000 });
-  if (health.status === 'ok') {
-    return okResult({
-      assistantMessage: '源 Qiwei 后端已经在线,无需重复启动。',
-      summary: health.summary,
-      data: health.data
-    });
-  }
-
-  const outPath = latestPath('tmp', 'qiwei-agent-skill.log');
-  const errPath = latestPath('tmp', 'qiwei-agent-skill.err.log');
-  const out = fs.openSync(outPath, 'a');
-  const err = fs.openSync(errPath, 'a');
-  const command = process.platform === 'win32' ? 'npm.cmd' : 'npm';
-  const child = spawn(command, ['run', 'dev'], {
-    cwd: LEGACY_RUNTIME_DIR,
-    detached: true,
-    stdio: ['ignore', out, err],
-    windowsHide: true,
-    env: { ...process.env, PORT: String(input.port || process.env.PORT || 3000) }
-  });
-  child.unref();
-
-  const pidPath = latestPath('tmp', 'qiwei-agent-skill.pid');
-  fs.writeFileSync(pidPath, String(child.pid), 'utf8');
-
-  return okResult({
-    assistantMessage: `已启动源 Qiwei 后端,PID=${child.pid}。`,
-    summary: { pid: child.pid, runtimeDir: LEGACY_RUNTIME_DIR, serviceUrl: serviceUrl(input) },
-    data: { pid: child.pid, pidPath, outPath, errPath },
-    files: [pidPath, outPath, errPath],
-    nextActions: ['稍等 3-5 秒后调用 qiwei_agent_service_status 确认在线']
-  });
-}
-
-async function qiweiAgentSkillCall(input = {}) {
-  return requestLegacyService(input);
-}
-
-function handlerForTool(tool) {
-  return async (input = {}) => {
-    if (tool.name === 'qiwei_get_broker_playbook') {
-      const brokerId = input.brokerId;
-      if (!brokerId) return errorResult('缺少 brokerId。');
-      return requestLegacyService({
-        ...input,
-        method: 'GET',
-        endpoint: `/api/skill/broker-playbook/${encodeURIComponent(String(brokerId))}`
-      });
-    }
-    return requestLegacyService({
-      ...input,
-      method: tool.method,
-      endpoint: tool.endpoint,
-      body: requestBody(input)
-    });
-  };
-}
-
-function legacySkillHandlers() {
-  const handlers = {};
-  for (const tool of LEGACY_SKILL_TOOLS) handlers[tool.name] = handlerForTool(tool);
-  handlers.qiwei_get_broker_playbook = handlerForTool({
-    name: 'qiwei_get_broker_playbook',
-    method: 'GET',
-    endpoint: '/api/skill/broker-playbook/:brokerId'
-  });
-  return handlers;
-}
-
-module.exports = {
-  LEGACY_RUNTIME_DIR,
-  LEGACY_SKILL_TOOLS: [
-    ...LEGACY_SKILL_TOOLS,
-    {
-      name: 'qiwei_get_broker_playbook',
-      method: 'GET',
-      endpoint: '/api/skill/broker-playbook/:brokerId',
-      title: '查询服务顾问 playbook'
-    }
-  ],
-  qiweiAgentServiceStatus,
-  qiweiAgentServiceStart,
-  qiweiAgentSkillCall,
-  legacySkillHandlers
-};

+ 17 - 14
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js

@@ -1,8 +1,9 @@
 const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const { safeResult } = require('../core/shared-gateway');
+const { writeObjectRows } = require('../core/xlsx-io');
 
 const PLAYBOOK_DIMENSIONS = [
   '客户分层', '开场白', '需求挖掘', '异议处理', '跟进节奏',
@@ -133,12 +134,18 @@ const qiweiPrepareBrokerPlaybook = safeResult(async function qiweiPrepareBrokerP
   const runDir = createRunDir('broker-playbooks', `context-${brokerUserId}`);
   const filePath = path.join(runDir, `context-${brokerUserId}.json`);
   fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+  const manifestPath = writeRunManifest(runDir, {
+    kind: 'broker-playbook-context',
+    brokerUserId,
+    messageCount: messages.length,
+    files: [filePath]
+  });
 
   return okResult({
     assistantMessage: `已为顾问 ${brokerUserId} 准备 playbook 分析上下文:共 ${messages.length} 条消息。`,
     summary: { brokerUserId, messageCount: messages.length },
     data: { context, contextFile: path.relative(outputsRoot(), filePath) },
-    files: [filePath],
+    files: [filePath, manifestPath],
     nextActions: ['基于 context 分析后调用 qiwei_save_broker_playbook 保存']
   });
 });
@@ -168,12 +175,18 @@ const qiweiDistillBroker = safeResult(async function qiweiDistillBroker(input =
   const runDir = createRunDir('broker-playbooks', `context-${brokerUserId}`);
   const filePath = path.join(runDir, `context-${brokerUserId}.json`);
   fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+  const manifestPath = writeRunManifest(runDir, {
+    kind: 'broker-playbook-context',
+    brokerUserId,
+    messageCount: messages.length,
+    files: [filePath]
+  });
 
   return okResult({
     assistantMessage: `已为顾问 ${brokerUserId} 准备 playbook 分析上下文,请 Agent 分析后调用 qiwei_save_broker_playbook 保存。`,
     summary: { brokerUserId, messageCount: messages.length, requiresAgentAnalysis: true },
     data: { context, contextFile: path.relative(outputsRoot(), filePath), saveEndpoint: 'qiwei_save_broker_playbook' },
-    files: [filePath],
+    files: [filePath, manifestPath],
     nextActions: ['分析 context 后调用 qiwei_save_broker_playbook']
   });
 });
@@ -257,13 +270,6 @@ const qiweiExportBrokerPlaybooks = safeResult(async function qiweiExportBrokerPl
 
   if (!brokerUserIds.length) return errorResult('没有可导出的 playbook');
 
-  let XLSX;
-  try {
-    XLSX = require('xlsx');
-  } catch {
-    return errorResult('当前包尚未安装 xlsx,无法导出 Excel;请运行 npm install xlsx');
-  }
-
   const rows = [];
   for (const brokerUserId of brokerUserIds) {
     const data = readPlaybook(brokerUserId);
@@ -274,10 +280,7 @@ const qiweiExportBrokerPlaybooks = safeResult(async function qiweiExportBrokerPl
   ensureBrokerPlaybooksDir();
   const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
   const filePath = path.join(brokerPlaybooksDir(), fileName);
-  const worksheet = XLSX.utils.json_to_sheet(rows);
-  const workbook = XLSX.utils.book_new();
-  XLSX.utils.book_append_sheet(workbook, worksheet, 'playbooks');
-  XLSX.writeFile(workbook, filePath);
+  await writeObjectRows(filePath, 'playbooks', rows);
 
   return okResult({
     assistantMessage: `已导出 ${rows.length} 条顾问 playbook:${filePath}。`,

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

@@ -2,6 +2,7 @@ const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { outputsRoot } = require('../core/output-paths');
+const { readWorksheetRows } = require('../core/xlsx-io');
 const {
   buildContext,
   gatewayCall,
@@ -28,14 +29,14 @@ function cleanPhone(raw) {
   return value;
 }
 
-function normalizeCustomers(input = {}) {
+async function normalizeCustomers(input = {}) {
   const raw = [];
   if (Array.isArray(input.customers)) {
     for (const item of input.customers) raw.push(item && typeof item === 'object' ? item : { phone: item });
   } else if (Array.isArray(input.phones)) {
     for (const phone of input.phones) raw.push({ phone });
   } else if (input.filePath) {
-    raw.push(...readCustomersFromExcel(input.filePath));
+    raw.push(...await readCustomersFromExcel(input.filePath));
   }
 
   const invalid = [];
@@ -64,31 +65,25 @@ function normalizeCustomers(input = {}) {
   return { customers, invalidRemoved: invalid.length, duplicateRemoved, invalid };
 }
 
-function readCustomersFromExcel(filePath) {
+async function readCustomersFromExcel(filePath) {
   const resolved = path.resolve(String(filePath));
   if (!fs.existsSync(resolved)) throw new Error(`Excel 文件不存在: ${resolved}`);
-  if (!/\.xlsx?$/i.test(resolved)) throw new Error('filePath 仅支持 .xlsx / .xls');
-  let XLSX;
-  try {
-    XLSX = require('xlsx');
-  } catch {
-    throw new Error('当前包尚未安装 xlsx,无法读取 Excel;请改传 customers/phones,或运行 npm install xlsx');
-  }
-  const workbook = XLSX.readFile(resolved);
-  const sheet = workbook.Sheets[workbook.SheetNames[0]];
-  const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' });
-  const headers = Object.keys(rows[0] || {});
+  if (!/\.xlsx$/i.test(resolved)) throw new Error('filePath 仅支持 .xlsx;请先将旧版 .xls 另存为 .xlsx');
+  const table = await readWorksheetRows(resolved);
+  if (!table.length) throw new Error('Excel 中没有可读取的数据');
+  const headers = table[0].map(value => String(value ?? '').trim());
   const findColumn = names => headers.find(h => names.some(n => h.toLowerCase().includes(n.toLowerCase())));
   const phoneCol = findColumn(['手机号', '手机号码', '手机', '电话', 'phone', 'mobile', 'tel']);
   const nameCol = findColumn(['姓名', '名字', '客户姓名', '客户名', 'name', 'customer']);
   const greetingCol = findColumn(['验证消息', '好友申请', '申请内容', 'greeting', 'verifyText', 'message']);
   const groupNameCol = findColumn(['群名', '群名称', 'groupName', 'roomName']);
   if (!phoneCol) throw new Error('Excel 中未找到手机号列');
-  return rows.map(row => ({
-    phone: row[phoneCol],
-    name: nameCol ? row[nameCol] : undefined,
-    greeting: greetingCol ? row[greetingCol] : undefined,
-    groupName: groupNameCol ? row[groupNameCol] : undefined
+  const columnIndex = header => headers.indexOf(header);
+  return table.slice(1).filter(row => row.some(value => String(value ?? '').trim())).map(row => ({
+    phone: row[columnIndex(phoneCol)],
+    name: nameCol ? row[columnIndex(nameCol)] : undefined,
+    greeting: greetingCol ? row[columnIndex(greetingCol)] : undefined,
+    groupName: groupNameCol ? row[columnIndex(groupNameCol)] : undefined
   }));
 }
 
@@ -109,7 +104,7 @@ const qiweiBatchAddFriends = safeResult(async function qiweiBatchAddFriends(inpu
   const ctx = buildContext(input);
   requireGuid(ctx);
 
-  const { customers, invalidRemoved, duplicateRemoved, invalid } = normalizeCustomers(input);
+  const { customers, invalidRemoved, duplicateRemoved, invalid } = await normalizeCustomers(input);
   if (!customers.length) {
     const { errorResult } = require('../core/result-envelope');
     return errorResult('没有有效手机号', { summary: { invalidRemoved }, data: { invalid } });
@@ -313,7 +308,7 @@ const qiweiCheckFriendStatus = safeResult(async function qiweiCheckFriendStatus(
   const ctx = buildContext(input);
   requireGuid(ctx);
 
-  const normalized = normalizeCustomers(input);
+  const normalized = await normalizeCustomers(input);
   const externalUserIds = Array.isArray(input.externalUserIds)
     ? input.externalUserIds.map(String).filter(Boolean)
     : [];
@@ -433,5 +428,6 @@ module.exports = {
   qiweiCheckFriendStatus,
   qiweiGetCustomerProfile,
   cleanPhone,
-  normalizeCustomers
+  normalizeCustomers,
+  readCustomersFromExcel
 };

+ 18 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js

@@ -1,7 +1,7 @@
 const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const {
   buildContext,
   gatewayCall,
@@ -106,6 +106,13 @@ const qiweiPreviewTransferPackage = safeResult(async function qiweiPreviewTransf
   const fileName = `preview-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
+  const manifestPath = writeRunManifest(runDir, {
+    kind: 'customer-transfer-preview',
+    fromUserId,
+    toUserId,
+    itemCount: items.length,
+    files: [filePath]
+  });
 
   // 同时写入 latest
   const latestFile = path.join(transfersDir(), fileName);
@@ -115,7 +122,7 @@ const qiweiPreviewTransferPackage = safeResult(async function qiweiPreviewTransf
     assistantMessage: `交接包预览已生成:${items.length} 条记录。`,
     summary: { fromUserId, toUserId, itemCount: items.length, status: 'DRAFT' },
     data: { preview, filePath: path.relative(outputsRoot(), filePath) },
-    files: [filePath],
+    files: [filePath, manifestPath],
     nextActions: ['确认后调用 qiwei_execute_transfer 执行交接']
   });
 });
@@ -193,12 +200,20 @@ const qiweiExecuteTransfer = safeResult(async function qiweiExecuteTransfer(inpu
   const fileName = `execution-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
+  const manifestPath = writeRunManifest(runDir, {
+    kind: 'customer-transfer-execution',
+    fromUserId: preview.fromUserId,
+    toUserId: preview.toUserId,
+    success,
+    failed,
+    files: [filePath]
+  });
 
   return okResult({
     assistantMessage: `交接执行完成:成功 ${success},失败 ${failed}。`,
     summary: { success, failed, total: preview.items.length, removeOldBroker },
     data: { results, filePath: path.relative(outputsRoot(), filePath) },
-    files: [filePath]
+    files: [filePath, manifestPath]
   });
 });
 

+ 198 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-goal-management-run.js

@@ -0,0 +1,198 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { categoryDir, latestPath } = require('../core/output-paths');
+const { okResult, errorResult } = require('../core/result-envelope');
+
+const STORE_FILE = latestPath('goals', 'plans.json');
+const VALID_STATUS = new Set(['planned', 'in_progress', 'blocked', 'done', 'cancelled']);
+
+function now() { return new Date().toISOString(); }
+function makeId(prefix) { return `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`; }
+
+function readStore() {
+  try {
+    const parsed = JSON.parse(fs.readFileSync(STORE_FILE, 'utf8'));
+    parsed.version ||= 1;
+    parsed.plans ||= [];
+    return parsed;
+  } catch {
+    return { version: 1, plans: [] };
+  }
+}
+
+function writeJsonAtomic(filePath, value) {
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+
+function planFile(planId) { return path.join(categoryDir('goals'), `${planId}.json`); }
+
+function status(value, fallback = 'planned') {
+  const selected = String(value || fallback);
+  return VALID_STATUS.has(selected) ? selected : fallback;
+}
+
+function normalizeTask(input = {}, source = null) {
+  return {
+    id: input.id || makeId('task'),
+    title: String(input.title || '').trim(),
+    owner: String(input.owner || '').trim(),
+    deadline: String(input.deadline || '').trim(),
+    priority: String(input.priority || 'medium').trim(),
+    acceptance: String(input.acceptance || input.acceptanceCriteria || '').trim(),
+    status: status(input.status),
+    note: String(input.note || '').trim(),
+    blockedReason: String(input.blockedReason || '').trim(),
+    needsConfirmation: !input.owner || !input.deadline,
+    source,
+    createdAt: input.createdAt || now(),
+    updatedAt: now(),
+  };
+}
+
+function normalizeMilestone(input = {}, index = 0) {
+  return {
+    id: input.id || makeId('milestone'),
+    title: String(input.title || `里程碑 ${index + 1}`).trim(),
+    owner: String(input.owner || '').trim(),
+    deadline: String(input.deadline || '').trim(),
+    acceptance: String(input.acceptance || input.acceptanceCriteria || '').trim(),
+    status: status(input.status),
+    tasks: (input.tasks || []).map(task => normalizeTask(task)),
+  };
+}
+
+function calculate(plan) {
+  const tasks = (plan.milestones || []).flatMap(item => item.tasks || []);
+  const done = tasks.filter(item => item.status === 'done').length;
+  const blocked = tasks.filter(item => item.status === 'blocked').length;
+  const today = Date.now();
+  const overdue = tasks.filter(item => item.status !== 'done' && item.deadline && Date.parse(item.deadline) < today).length;
+  const progress = tasks.length ? Math.round((done / tasks.length) * 100) : 0;
+  return { milestoneCount: plan.milestones?.length || 0, taskCount: tasks.length, done, blocked, overdue, progress };
+}
+
+function persist(store, plan) {
+  plan.updatedAt = now();
+  plan.summary = calculate(plan);
+  writeJsonAtomic(STORE_FILE, store);
+  const filePath = planFile(plan.id);
+  writeJsonAtomic(filePath, plan);
+  return filePath;
+}
+
+async function qiweiGoalCreatePlan(input = {}) {
+  const title = String(input.title || '').trim();
+  const objective = String(input.objective || '').trim();
+  if (!title || !objective) return errorResult('创建目标计划需要 title 和 objective');
+  const store = readStore();
+  const plan = {
+    id: makeId('goal'),
+    title,
+    objective,
+    owner: String(input.owner || '').trim(),
+    deadline: String(input.deadline || '').trim(),
+    acceptance: String(input.acceptance || input.acceptanceCriteria || '').trim(),
+    status: status(input.status, 'in_progress'),
+    milestones: (input.milestones?.length ? input.milestones : [{ title: '待拆解', tasks: [] }]).map(normalizeMilestone),
+    createdAt: now(),
+    updatedAt: now(),
+  };
+  store.plans.push(plan);
+  const filePath = persist(store, plan);
+  return okResult({
+    assistantMessage: `已建立目标计划“${plan.title}”,包含 ${plan.summary.milestoneCount} 个里程碑、${plan.summary.taskCount} 项任务。`,
+    summary: { goalId: plan.id, ...plan.summary },
+    data: { plan },
+    files: [filePath],
+    nextActions: ['确认待确认的负责人和截止时间', '需要时同步为企业微信待办', '定期调用 qiwei_goal_get 查看进度'],
+  });
+}
+
+async function qiweiGoalGet(input = {}) {
+  const store = readStore();
+  if (!input.goalId) {
+    const plans = store.plans.map(plan => ({ id: plan.id, title: plan.title, owner: plan.owner, deadline: plan.deadline, status: plan.status, ...calculate(plan) }));
+    return okResult({
+      assistantMessage: `当前共有 ${plans.length} 个目标计划。`,
+      summary: { count: plans.length },
+      data: { plans },
+    });
+  }
+  const plan = store.plans.find(item => item.id === input.goalId);
+  if (!plan) return errorResult('目标计划不存在');
+  plan.summary = calculate(plan);
+  return okResult({
+    assistantMessage: `“${plan.title}”当前完成度 ${plan.summary.progress}%,阻塞 ${plan.summary.blocked} 项,逾期 ${plan.summary.overdue} 项。`,
+    summary: { goalId: plan.id, ...plan.summary },
+    data: { plan },
+    files: [planFile(plan.id)],
+  });
+}
+
+async function qiweiGoalUpdateTask(input = {}) {
+  if (!input.goalId || !input.taskId) return errorResult('更新任务需要 goalId 和 taskId');
+  const store = readStore();
+  const plan = store.plans.find(item => item.id === input.goalId);
+  if (!plan) return errorResult('目标计划不存在');
+  const task = plan.milestones.flatMap(item => item.tasks || []).find(item => item.id === input.taskId);
+  if (!task) return errorResult('目标任务不存在');
+  if (input.status !== undefined) task.status = status(input.status, task.status);
+  if (input.owner !== undefined) task.owner = String(input.owner || '').trim();
+  if (input.deadline !== undefined) task.deadline = String(input.deadline || '').trim();
+  if (input.note !== undefined) task.note = String(input.note || '').trim();
+  if (input.blockedReason !== undefined) task.blockedReason = String(input.blockedReason || '').trim();
+  task.needsConfirmation = !task.owner || !task.deadline;
+  task.updatedAt = now();
+  const filePath = persist(store, plan);
+  return okResult({
+    assistantMessage: `任务“${task.title}”已更新为 ${task.status};目标完成度 ${plan.summary.progress}%。`,
+    summary: { goalId: plan.id, taskId: task.id, taskStatus: task.status, ...plan.summary },
+    data: { task, planSummary: plan.summary },
+    files: [filePath],
+  });
+}
+
+async function qiweiGoalImportMeetingActions(input = {}) {
+  if (!input.goalId) return errorResult('导入会议待办需要 goalId');
+  if (!Array.isArray(input.actions) || !input.actions.length) return errorResult('请提供已确认的会议行动项 actions');
+  const store = readStore();
+  const plan = store.plans.find(item => item.id === input.goalId);
+  if (!plan) return errorResult('目标计划不存在');
+  const source = {
+    type: 'meeting',
+    title: String(input.meetingTitle || '会议行动项').trim(),
+    date: String(input.meetingDate || '').trim(),
+    url: String(input.sourceUrl || '').trim(),
+  };
+  const milestone = {
+    id: makeId('milestone'),
+    title: `会议行动项 · ${source.title}`,
+    owner: '',
+    deadline: '',
+    acceptance: '会议确认的行动项全部完成',
+    status: 'in_progress',
+    tasks: input.actions.map(action => normalizeTask(action, source)),
+  };
+  plan.milestones.push(milestone);
+  const filePath = persist(store, plan);
+  const needsConfirmation = milestone.tasks.filter(item => item.needsConfirmation).length;
+  return okResult({
+    assistantMessage: `已从“${source.title}”导入 ${milestone.tasks.length} 项行动,其中 ${needsConfirmation} 项仍需确认负责人或截止时间。`,
+    summary: { goalId: plan.id, imported: milestone.tasks.length, needsConfirmation, ...plan.summary },
+    data: { milestone, planSummary: plan.summary },
+    files: [filePath],
+    nextActions: needsConfirmation ? ['补齐待确认的负责人和截止时间', '确认后同步为企业微信待办'] : ['按需同步为企业微信待办'],
+  });
+}
+
+module.exports = {
+  qiweiGoalCreatePlan,
+  qiweiGoalGet,
+  qiweiGoalUpdateTask,
+  qiweiGoalImportMeetingActions,
+  __testing: { calculate, normalizeTask, readStore },
+};

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

@@ -391,7 +391,7 @@ async function qiweiLoginCheck(input = {}) {
     const detail = data.detail || {};
     if (statusCode === '2') {
       const guid = String(detail.guid || readQiweiGuid(input) || '').trim();
-      if (guid) {
+      if (guid && input.persistConfig !== false) {
         saveQiweiClientConfig({ guid, apiBase });
       }
 

+ 77 - 16
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js

@@ -1,7 +1,9 @@
 const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
+const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
+const { writeObjectRows } = require('../core/xlsx-io');
 const {
   buildContext,
   gatewayCall,
@@ -51,7 +53,53 @@ function portraitFilePath(externalUserId) {
   return path.join(portraitsDir(), `${externalUserId}.json`);
 }
 
+function withCustomerIntelligenceDb(callback) {
+  const db = new AgentWorkbenchDb(latestPath('messages', 'agent-workbench.db'), {
+    globalPaused: true,
+    defaultMode: 'review',
+    autoSendConfidence: 0.88,
+  });
+  try { return callback(db); }
+  finally { db.close(); }
+}
+
+function publicCanonicalProfile(profile = {}) {
+  const { __evidence, ...visible } = profile || {};
+  return visible;
+}
+
+function readCanonicalPortrait(externalUserId) {
+  return withCustomerIntelligenceDb(db => {
+    const conversation = db.getConversationByContactId(externalUserId);
+    if (!conversation) return null;
+    const record = db.getProfile(conversation.id);
+    if (!Object.keys(record.profile || {}).length && !(record.tags || []).length) return null;
+    return {
+      externalUserId,
+      portrait: publicCanonicalProfile(record.profile),
+      tags: record.tags || [],
+      source: 'customer-intelligence-db',
+      updatedAt: record.updatedAt,
+    };
+  });
+}
+
+function mergeCanonicalPortrait(externalUserId, portrait = {}, tags) {
+  return withCustomerIntelligenceDb(db => {
+    const conversation = db.ensureConversation(externalUserId, '');
+    const current = db.getProfile(conversation.id);
+    const patch = portrait?.portrait && typeof portrait.portrait === 'object' ? portrait.portrait : portrait;
+    return db.updateProfile(
+      conversation.id,
+      { ...current.profile, ...(patch || {}) },
+      tags === undefined ? current.tags : tags,
+    );
+  });
+}
+
 function readPortrait(externalUserId) {
+  const canonical = readCanonicalPortrait(externalUserId);
+  if (canonical) return canonical;
   const filePath = portraitFilePath(externalUserId);
   if (!fs.existsSync(filePath)) return null;
   try {
@@ -64,6 +112,7 @@ function readPortrait(externalUserId) {
 function writePortrait(externalUserId, portrait) {
   const filePath = portraitFilePath(externalUserId);
   fs.writeFileSync(filePath, JSON.stringify(portrait, null, 2), 'utf8');
+  mergeCanonicalPortrait(externalUserId, portrait);
   return filePath;
 }
 
@@ -73,6 +122,8 @@ function tagFilePath(externalUserId) {
 }
 
 function readTags(externalUserId) {
+  const canonical = readCanonicalPortrait(externalUserId);
+  if (canonical && Array.isArray(canonical.tags)) return canonical.tags;
   const filePath = tagFilePath(externalUserId);
   if (!fs.existsSync(filePath)) return [];
   try {
@@ -85,7 +136,9 @@ function readTags(externalUserId) {
 
 function writeTags(externalUserId, tags) {
   const filePath = tagFilePath(externalUserId);
-  fs.writeFileSync(filePath, JSON.stringify({ externalUserId, tags: [...new Set(tags)], updatedAt: new Date().toISOString() }, null, 2), 'utf8');
+  const uniqueTags = [...new Set(tags)];
+  fs.writeFileSync(filePath, JSON.stringify({ externalUserId, tags: uniqueTags, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
+  mergeCanonicalPortrait(externalUserId, {}, uniqueTags);
   return filePath;
 }
 
@@ -167,12 +220,18 @@ const qiweiPrepareCustomerPortrait = safeResult(async function qiweiPrepareCusto
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const filePath = path.join(runDir, `context-${externalUserId}.json`);
   fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+  const manifestPath = writeRunManifest(runDir, {
+    kind: 'customer-portrait-context',
+    externalUserId,
+    messageCount: messages.length,
+    files: [filePath]
+  });
 
   return okResult({
     assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文:共 ${messages.length} 条消息。`,
     summary: { externalUserId, messageCount: messages.length },
     data: { context, contextFile: path.relative(outputsRoot(), filePath) },
-    files: [filePath],
+    files: [filePath, manifestPath],
     nextActions: ['基于 context 分析后调用 qiwei_save_customer_portrait 保存']
   });
 });
@@ -203,12 +262,18 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const filePath = path.join(runDir, `context-${externalUserId}.json`);
   fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+  const manifestPath = writeRunManifest(runDir, {
+    kind: 'customer-portrait-context',
+    externalUserId,
+    messageCount: messages.length,
+    files: [filePath]
+  });
 
   return okResult({
     assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文,请 Agent 分析后调用 qiwei_save_customer_portrait 保存。`,
     summary: { externalUserId, messageCount: messages.length, requiresAgentAnalysis: true },
     data: { context, contextFile: path.relative(outputsRoot(), filePath), saveEndpoint: 'qiwei_save_customer_portrait' },
-    files: [filePath],
+    files: [filePath, manifestPath],
     nextActions: ['分析 context 后调用 qiwei_save_customer_portrait']
   });
 });
@@ -294,13 +359,6 @@ const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustom
 
   if (!externalUserIds.length) return errorResult('没有可导出的画像');
 
-  let XLSX;
-  try {
-    XLSX = require('xlsx');
-  } catch {
-    return errorResult('当前包尚未安装 xlsx,无法导出 Excel;请运行 npm install xlsx');
-  }
-
   const rows = [];
   for (const externalUserId of externalUserIds) {
     const data = readPortrait(externalUserId);
@@ -311,10 +369,7 @@ const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustom
   ensurePortraitsDir();
   const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
   const filePath = path.join(portraitsDir(), fileName);
-  const worksheet = XLSX.utils.json_to_sheet(rows);
-  const workbook = XLSX.utils.book_new();
-  XLSX.utils.book_append_sheet(workbook, worksheet, 'portraits');
-  XLSX.writeFile(workbook, filePath);
+  await writeObjectRows(filePath, 'portraits', rows);
 
   return okResult({
     assistantMessage: `已导出 ${rows.length} 条客户画像:${filePath}。`,
@@ -414,7 +469,13 @@ const qiweiSyncPersonalLabels = safeResult(async function qiweiSyncPersonalLabel
     currentSeq: 0,
     labelType: 2
   });
-  const labels = Array.isArray(data && data.labelList) ? data.labelList : [];
+  const labels = Array.isArray(data && data.labelList)
+    ? data.labelList.map(item => ({
+      ...item,
+      labelName: item.labelName || item.name || '',
+      labelSuperId: item.labelSuperId || item.groupId || ''
+    }))
+    : [];
 
   return okResult({
     assistantMessage: `已同步 ${labels.length} 个企微个人标签。`,

+ 69 - 288
claude-code/claude-code-qiwe-assistant/package-lock.json

@@ -1,26 +1,28 @@
 {
   "name": "claude-code-qiwei-assistant",
-  "version": "0.3.0",
+  "version": "0.4.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "claude-code-qiwei-assistant",
-      "version": "0.3.0",
+      "version": "0.4.0",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.12.1",
         "cross-spawn": "7.0.6",
-        "xlsx": "^0.18.5",
+        "read-excel-file": "^9.3.2",
+        "write-excel-file": "^4.1.1",
         "zod": "^3.24.1"
       },
+      "bin": {
+        "qiwei-assistant": "install.js"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=22.5.0"
       }
     },
     "node_modules/@hono/node-server": {
       "version": "1.19.14",
-      "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
-      "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
       "license": "MIT",
       "engines": {
         "node": ">=18.14.1"
@@ -31,8 +33,6 @@
     },
     "node_modules/@modelcontextprotocol/sdk": {
       "version": "1.29.0",
-      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
-      "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
       "license": "MIT",
       "dependencies": {
         "@hono/node-server": "^1.19.9",
@@ -71,8 +71,6 @@
     },
     "node_modules/accepts": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
-      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
       "license": "MIT",
       "dependencies": {
         "mime-types": "^3.0.0",
@@ -82,19 +80,8 @@
         "node": ">= 0.6"
       }
     },
-    "node_modules/adler-32": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
-      "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
     "node_modules/ajv": {
       "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
-      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
       "license": "MIT",
       "dependencies": {
         "fast-deep-equal": "^3.1.3",
@@ -109,8 +96,6 @@
     },
     "node_modules/ajv-formats": {
       "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
-      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
       "license": "MIT",
       "dependencies": {
         "ajv": "^8.0.0"
@@ -126,8 +111,6 @@
     },
     "node_modules/body-parser": {
       "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
-      "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
       "license": "MIT",
       "dependencies": {
         "bytes": "^3.1.2",
@@ -150,8 +133,6 @@
     },
     "node_modules/body-parser/node_modules/content-type": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
-      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
       "license": "MIT",
       "engines": {
         "node": ">=18"
@@ -163,8 +144,6 @@
     },
     "node_modules/bytes": {
       "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
-      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
@@ -172,8 +151,6 @@
     },
     "node_modules/call-bind-apply-helpers": {
       "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
-      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0",
@@ -185,8 +162,6 @@
     },
     "node_modules/call-bound": {
       "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
-      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
       "license": "MIT",
       "dependencies": {
         "call-bind-apply-helpers": "^1.0.2",
@@ -199,32 +174,8 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/cfb": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
-      "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "adler-32": "~1.3.0",
-        "crc-32": "~1.2.0"
-      },
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
-    "node_modules/codepage": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
-      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
     "node_modules/content-disposition": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
-      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
       "license": "MIT",
       "engines": {
         "node": ">=18"
@@ -236,8 +187,6 @@
     },
     "node_modules/content-type": {
       "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
-      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.6"
@@ -245,8 +194,6 @@
     },
     "node_modules/cookie": {
       "version": "0.7.2",
-      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
-      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.6"
@@ -254,8 +201,6 @@
     },
     "node_modules/cookie-signature": {
       "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
-      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
       "license": "MIT",
       "engines": {
         "node": ">=6.6.0"
@@ -263,8 +208,6 @@
     },
     "node_modules/cors": {
       "version": "2.8.6",
-      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
-      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
       "license": "MIT",
       "dependencies": {
         "object-assign": "^4",
@@ -278,22 +221,8 @@
         "url": "https://opencollective.com/express"
       }
     },
-    "node_modules/crc-32": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
-      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
-      "license": "Apache-2.0",
-      "bin": {
-        "crc32": "bin/crc32.njs"
-      },
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
-      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
-      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
       "license": "MIT",
       "dependencies": {
         "path-key": "^3.1.0",
@@ -306,8 +235,6 @@
     },
     "node_modules/debug": {
       "version": "4.4.3",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
-      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
       "license": "MIT",
       "dependencies": {
         "ms": "^2.1.3"
@@ -323,8 +250,6 @@
     },
     "node_modules/depd": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
-      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
@@ -332,8 +257,6 @@
     },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
-      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
       "license": "MIT",
       "dependencies": {
         "call-bind-apply-helpers": "^1.0.1",
@@ -346,14 +269,10 @@
     },
     "node_modules/ee-first": {
       "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
-      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
       "license": "MIT"
     },
     "node_modules/encodeurl": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
-      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
@@ -361,8 +280,6 @@
     },
     "node_modules/es-define-property": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
-      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.4"
@@ -370,8 +287,6 @@
     },
     "node_modules/es-errors": {
       "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
-      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.4"
@@ -379,8 +294,6 @@
     },
     "node_modules/es-object-atoms": {
       "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
-      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0"
@@ -391,14 +304,10 @@
     },
     "node_modules/escape-html": {
       "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
       "license": "MIT"
     },
     "node_modules/etag": {
       "version": "1.8.1",
-      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
-      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.6"
@@ -406,8 +315,6 @@
     },
     "node_modules/eventsource": {
       "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
-      "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
       "license": "MIT",
       "dependencies": {
         "eventsource-parser": "^3.0.1"
@@ -418,8 +325,6 @@
     },
     "node_modules/eventsource-parser": {
       "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
-      "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
       "license": "MIT",
       "engines": {
         "node": ">=18.0.0"
@@ -427,8 +332,6 @@
     },
     "node_modules/express": {
       "version": "5.2.1",
-      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
-      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
       "license": "MIT",
       "dependencies": {
         "accepts": "^2.0.0",
@@ -470,8 +373,6 @@
     },
     "node_modules/express-rate-limit": {
       "version": "8.5.2",
-      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
-      "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
       "license": "MIT",
       "dependencies": {
         "ip-address": "^10.2.0"
@@ -488,14 +389,10 @@
     },
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
-      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
       "license": "MIT"
     },
     "node_modules/fast-uri": {
       "version": "3.1.3",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
-      "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
       "funding": [
         {
           "type": "github",
@@ -508,10 +405,14 @@
       ],
       "license": "BSD-3-Clause"
     },
+    "node_modules/fflate": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+      "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+      "license": "MIT"
+    },
     "node_modules/finalhandler": {
       "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
-      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
       "license": "MIT",
       "dependencies": {
         "debug": "^4.4.0",
@@ -531,26 +432,13 @@
     },
     "node_modules/forwarded": {
       "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
-      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
     },
-    "node_modules/frac": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
-      "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
     "node_modules/fresh": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
-      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
@@ -558,8 +446,6 @@
     },
     "node_modules/function-bind": {
       "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
-      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
       "license": "MIT",
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
@@ -567,8 +453,6 @@
     },
     "node_modules/get-intrinsic": {
       "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
-      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
       "license": "MIT",
       "dependencies": {
         "call-bind-apply-helpers": "^1.0.2",
@@ -591,8 +475,6 @@
     },
     "node_modules/get-proto": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
-      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
       "license": "MIT",
       "dependencies": {
         "dunder-proto": "^1.0.1",
@@ -604,8 +486,6 @@
     },
     "node_modules/gopd": {
       "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
-      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.4"
@@ -614,10 +494,14 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "license": "ISC"
+    },
     "node_modules/has-symbols": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
-      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.4"
@@ -628,8 +512,6 @@
     },
     "node_modules/hasown": {
       "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
-      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
       "license": "MIT",
       "dependencies": {
         "function-bind": "^1.1.2"
@@ -640,8 +522,6 @@
     },
     "node_modules/hono": {
       "version": "4.12.27",
-      "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
-      "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
       "license": "MIT",
       "engines": {
         "node": ">=16.9.0"
@@ -649,8 +529,6 @@
     },
     "node_modules/http-errors": {
       "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
-      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
       "license": "MIT",
       "dependencies": {
         "depd": "~2.0.0",
@@ -669,8 +547,6 @@
     },
     "node_modules/iconv-lite": {
       "version": "0.7.2",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
-      "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
       "license": "MIT",
       "dependencies": {
         "safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -685,14 +561,10 @@
     },
     "node_modules/inherits": {
       "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
       "license": "ISC"
     },
     "node_modules/ip-address": {
       "version": "10.2.0",
-      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
-      "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
       "license": "MIT",
       "engines": {
         "node": ">= 12"
@@ -700,8 +572,6 @@
     },
     "node_modules/ipaddr.js": {
       "version": "1.9.1",
-      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
-      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.10"
@@ -709,20 +579,14 @@
     },
     "node_modules/is-promise": {
       "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
-      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
       "license": "MIT"
     },
     "node_modules/isexe": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
       "license": "ISC"
     },
     "node_modules/jose": {
       "version": "6.2.3",
-      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
-      "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
       "license": "MIT",
       "funding": {
         "url": "https://github.com/sponsors/panva"
@@ -730,20 +594,14 @@
     },
     "node_modules/json-schema-traverse": {
       "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
-      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
       "license": "MIT"
     },
     "node_modules/json-schema-typed": {
       "version": "8.0.2",
-      "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
-      "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
       "license": "BSD-2-Clause"
     },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
-      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.4"
@@ -751,8 +609,6 @@
     },
     "node_modules/media-typer": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
-      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
@@ -760,8 +616,6 @@
     },
     "node_modules/merge-descriptors": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
-      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
       "license": "MIT",
       "engines": {
         "node": ">=18"
@@ -772,8 +626,6 @@
     },
     "node_modules/mime-db": {
       "version": "1.54.0",
-      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
-      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.6"
@@ -781,8 +633,6 @@
     },
     "node_modules/mime-types": {
       "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
-      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
       "license": "MIT",
       "dependencies": {
         "mime-db": "^1.54.0"
@@ -797,23 +647,23 @@
     },
     "node_modules/ms": {
       "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
       "license": "MIT"
     },
     "node_modules/negotiator": {
       "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
-      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
     },
+    "node_modules/node-int64": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+      "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+      "license": "MIT"
+    },
     "node_modules/object-assign": {
       "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
       "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
@@ -821,8 +671,6 @@
     },
     "node_modules/object-inspect": {
       "version": "1.13.4",
-      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
-      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.4"
@@ -833,8 +681,6 @@
     },
     "node_modules/on-finished": {
       "version": "2.4.1",
-      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
-      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
       "license": "MIT",
       "dependencies": {
         "ee-first": "1.1.1"
@@ -845,8 +691,6 @@
     },
     "node_modules/once": {
       "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
       "license": "ISC",
       "dependencies": {
         "wrappy": "1"
@@ -854,8 +698,6 @@
     },
     "node_modules/parseurl": {
       "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
-      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
@@ -863,8 +705,6 @@
     },
     "node_modules/path-key": {
       "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
-      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
       "license": "MIT",
       "engines": {
         "node": ">=8"
@@ -872,8 +712,6 @@
     },
     "node_modules/path-to-regexp": {
       "version": "8.4.2",
-      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
-      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
       "license": "MIT",
       "funding": {
         "type": "opencollective",
@@ -882,8 +720,6 @@
     },
     "node_modules/pkce-challenge": {
       "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
-      "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
       "license": "MIT",
       "engines": {
         "node": ">=16.20.0"
@@ -891,8 +727,6 @@
     },
     "node_modules/proxy-addr": {
       "version": "2.0.7",
-      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
-      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
       "license": "MIT",
       "dependencies": {
         "forwarded": "0.2.0",
@@ -904,8 +738,6 @@
     },
     "node_modules/qs": {
       "version": "6.15.3",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
-      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
       "license": "BSD-3-Clause",
       "dependencies": {
         "es-define-property": "^1.0.1",
@@ -920,8 +752,6 @@
     },
     "node_modules/range-parser": {
       "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
-      "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.6"
@@ -933,8 +763,6 @@
     },
     "node_modules/raw-body": {
       "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
-      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
       "license": "MIT",
       "dependencies": {
         "bytes": "~3.1.2",
@@ -946,10 +774,22 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/read-excel-file": {
+      "version": "9.3.2",
+      "resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.3.2.tgz",
+      "integrity": "sha512-+zgqv/f6sll72omYA5kmlKf9W8ws7L+E/ZfhYpr8jJCFvOY5urGf4AnbwCIrtIUOFsT4LrpqYeMSleHQRJNnBg==",
+      "license": "MIT",
+      "dependencies": {
+        "fflate": "^0.8.3",
+        "saxen": "^11.0.2",
+        "unzipper-esm": "^0.13.2"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/require-from-string": {
       "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
-      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
       "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
@@ -957,8 +797,6 @@
     },
     "node_modules/router": {
       "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
-      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
       "license": "MIT",
       "dependencies": {
         "debug": "^4.4.0",
@@ -973,14 +811,19 @@
     },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
-      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
       "license": "MIT"
     },
+    "node_modules/saxen": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.0.tgz",
+      "integrity": "sha512-GOxBOAmiWVAytOHBuMlgFMZ4MAk+2Ny5QJpzM9I4IozfPLXq3FsDdIHNviTQGZGIx7G2mBkA+uySiJlYmSU1Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20.12"
+      }
+    },
     "node_modules/send": {
       "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
-      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
       "license": "MIT",
       "dependencies": {
         "debug": "^4.4.3",
@@ -1005,8 +848,6 @@
     },
     "node_modules/serve-static": {
       "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
-      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
       "license": "MIT",
       "dependencies": {
         "encodeurl": "^2.0.0",
@@ -1024,14 +865,10 @@
     },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
-      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
       "license": "ISC"
     },
     "node_modules/shebang-command": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
-      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
       "license": "MIT",
       "dependencies": {
         "shebang-regex": "^3.0.0"
@@ -1042,8 +879,6 @@
     },
     "node_modules/shebang-regex": {
       "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
-      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
       "license": "MIT",
       "engines": {
         "node": ">=8"
@@ -1051,8 +886,6 @@
     },
     "node_modules/side-channel": {
       "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
-      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0",
@@ -1070,8 +903,6 @@
     },
     "node_modules/side-channel-list": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
-      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0",
@@ -1086,8 +917,6 @@
     },
     "node_modules/side-channel-map": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
-      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
       "license": "MIT",
       "dependencies": {
         "call-bound": "^1.0.2",
@@ -1104,8 +933,6 @@
     },
     "node_modules/side-channel-weakmap": {
       "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
-      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
       "license": "MIT",
       "dependencies": {
         "call-bound": "^1.0.2",
@@ -1121,22 +948,8 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/ssf": {
-      "version": "0.11.2",
-      "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
-      "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "frac": "~1.1.2"
-      },
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
     "node_modules/statuses": {
       "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
-      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
@@ -1144,8 +957,6 @@
     },
     "node_modules/toidentifier": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
-      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
       "license": "MIT",
       "engines": {
         "node": ">=0.6"
@@ -1153,8 +964,6 @@
     },
     "node_modules/type-is": {
       "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
-      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
       "license": "MIT",
       "dependencies": {
         "content-type": "^2.0.0",
@@ -1171,8 +980,6 @@
     },
     "node_modules/type-is/node_modules/content-type": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
-      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
       "license": "MIT",
       "engines": {
         "node": ">=18"
@@ -1184,17 +991,26 @@
     },
     "node_modules/unpipe": {
       "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
-      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
       }
     },
+    "node_modules/unzipper-esm": {
+      "version": "0.13.2",
+      "resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz",
+      "integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==",
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.2",
+        "node-int64": "^0.4.0"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
     "node_modules/vary": {
       "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
-      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
       "license": "MIT",
       "engines": {
         "node": ">= 0.8"
@@ -1202,8 +1018,6 @@
     },
     "node_modules/which": {
       "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
-      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
       "license": "ISC",
       "dependencies": {
         "isexe": "^2.0.0"
@@ -1215,55 +1029,24 @@
         "node": ">= 8"
       }
     },
-    "node_modules/wmf": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
-      "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
-    "node_modules/word": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
-      "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=0.8"
-      }
-    },
     "node_modules/wrappy": {
       "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
       "license": "ISC"
     },
-    "node_modules/xlsx": {
-      "version": "0.18.5",
-      "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
-      "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
-      "license": "Apache-2.0",
+    "node_modules/write-excel-file": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/write-excel-file/-/write-excel-file-4.1.1.tgz",
+      "integrity": "sha512-MUnCnNtQrcZek832ZcU24uU0rSphFmKPD1DvIjXOlygVb93CV7Tme6H3jUTkxsMmjB2W7HIzERzjqTi5kui71A==",
+      "license": "MIT",
       "dependencies": {
-        "adler-32": "~1.3.0",
-        "cfb": "~1.2.1",
-        "codepage": "~1.15.0",
-        "crc-32": "~1.2.1",
-        "ssf": "~0.11.2",
-        "wmf": "~1.0.1",
-        "word": "~0.3.0"
-      },
-      "bin": {
-        "xlsx": "bin/xlsx.njs"
+        "fflate": "^0.8.2"
       },
       "engines": {
-        "node": ">=0.8"
+        "node": ">=18"
       }
     },
     "node_modules/zod": {
       "version": "3.25.76",
-      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
-      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
       "license": "MIT",
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
@@ -1271,8 +1054,6 @@
     },
     "node_modules/zod-to-json-schema": {
       "version": "3.25.2",
-      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
-      "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
       "license": "ISC",
       "peerDependencies": {
         "zod": "^3.25.28 || ^4"

Diferenças do arquivo suprimidas por serem muito extensas
+ 25 - 3
claude-code/claude-code-qiwe-assistant/package.json


+ 472 - 0
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -0,0 +1,472 @@
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { DatabaseSync } = require('node:sqlite');
+const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
+const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
+const { createCustomerTaskOfficialSync } = require('../mcp/src/core/customer-task-official-sync');
+const { evaluatePolledMessage } = require('../mcp/src/core/agent-poller-policy');
+const {
+  ClaudeCodeClient,
+  ClaudeCodeSessionStore,
+  buildClaudeSessionName,
+  enforceAuthoritativeGrounding,
+  extractExplicitCustomerIntelligence,
+  selectAuthoritativeHistory,
+} = require('../mcp/src/core/agent-runtime');
+const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide');
+const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
+
+const results = [];
+
+function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-'));
+  const db = new AgentWorkbenchDb(path.join(dir, 'test.db'), {
+    globalPaused: paused,
+    defaultMode,
+    autoSendConfidence: 0.88,
+  });
+  const sent = [];
+  const qiwei = {
+    isConfigured: () => true,
+    async sendText(toId, content) {
+      sent.push({ toId, content });
+      return { isSendSuccess: true };
+    },
+  };
+  const agent = {
+    async run(input) {
+      if (agentRun) return agentRun(input);
+      return {
+        content: '这是 Agent 基于知识检索生成的草稿',
+        confidence: 0.91,
+        intent: '购房咨询',
+        reason: '命中企业规则与 FAQ',
+        requiresHuman: false,
+        profileUpdates: { intent: '购房' },
+        citations: [{ id: 'faq.md#1', source: 'faq.md', heading: 'Agent 能做什么' }],
+        toolTrace: [{ tool: 'search_knowledge', args: { query: '购房咨询' }, result: [] }],
+      };
+    },
+  };
+  const config = {
+    agent: { apiKey: 'smoke-only', model: 'stub-model', provider: 'stub' },
+    qiwei: { allowedSenders: ['contact-1'] },
+  };
+  const service = new AgentWorkbenchService({ db, agent, qiwei, config });
+  return {
+    dir,
+    db,
+    sent,
+    service,
+    close() {
+      db.close();
+      fs.rmSync(dir, { recursive: true, force: true });
+    },
+  };
+}
+
+async function check(name, fn) {
+  await fn();
+  results.push({ name, status: 'passed' });
+}
+
+async function main() {
+  await check('Agent 企微传输统一走 Fmode 网关与登录专用端点', async () => {
+    const calls = [];
+    const originalFetch = global.fetch;
+    global.fetch = async (url, options = {}) => {
+      const parsedBody = options.body ? JSON.parse(options.body) : null;
+      calls.push({ url: String(url), options, body: parsedBody });
+      const loginStatus = String(url).includes('/login/status');
+      const payload = loginStatus
+        ? { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } }
+        : { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } };
+      return {
+        ok: true,
+        status: 200,
+        async text() { return JSON.stringify(payload); },
+      };
+    };
+    try {
+      const client = new FmodeQiweiClient({
+        authToken: 'test-fmode-token',
+        uid: 'uid-smoke',
+        guid: 'guid-smoke',
+        apiBase: 'https://gateway.example/api/qiwei',
+      });
+      const account = await client.checkLogin();
+      await client.syncMessages(8, 50);
+      await client.sendText('external-contact-1', '测试回复');
+      assert.equal(account.online, true);
+      assert.equal(account.nickname, '演示账号');
+      assert.match(calls[0].url, /\/login\/status\?uid=uid-smoke$/);
+      assert.equal(calls[0].options.method, 'GET');
+      assert.equal(calls[1].body.uid, 'uid-smoke');
+      assert.equal(calls[1].body.method, '/msg/syncMsg');
+      assert.equal(calls[1].body.params.guid, 'guid-smoke');
+      assert.equal(calls[2].body.method, '/msg/sendText');
+      assert.equal(calls[2].options.headers.Authorization, 'Bearer test-fmode-token');
+    } finally {
+      global.fetch = originalFetch;
+    }
+  });
+
+  await check('多企微账号使用独立工作台数据库和 Claude Session', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const accountA = { uid: 'account-a', guid: 'guid-a', nickname: '账号 A' };
+    const accountB = { uid: 'account-b', guid: 'guid-b', nickname: '账号 B' };
+    const keyA = __testing.accountRuntimeKey(accountA);
+    const keyB = __testing.accountRuntimeKey(accountB);
+    const configA = __testing.accountWorkbenchOverrides(accountA);
+    const configB = __testing.accountWorkbenchOverrides(accountB);
+    assert.notEqual(keyA, keyB);
+    assert.notEqual(configA.dbPath, configB.dbPath);
+    assert.notEqual(configA.agent.claudeSessionFile, configB.agent.claudeSessionFile);
+    assert.equal(configA.qiwei.uid, accountA.uid);
+    assert.equal(configB.qiwei.guid, accountB.guid);
+  });
+
+  await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => {
+    const ctx = setup();
+    try {
+      const first = await ctx.service.ingestInbound({ externalId: 'm1', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
+      const duplicate = await ctx.service.ingestInbound({ externalId: 'm1-copy', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
+      assert.equal(first.status, 'pending_review');
+      assert.equal(duplicate.status, 'duplicate_content');
+      assert.equal(ctx.db.listMessages(first.conversation.id).length, 1);
+    } finally { ctx.close(); }
+  });
+
+  await check('审核模式生成草稿但不自动外发', async () => {
+    const ctx = setup();
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: 'm2', contactId: 'contact-1', contactName: '王刚', content: '预算 150 万,想买三室' });
+      assert.equal(result.status, 'pending_review');
+      assert.equal(ctx.sent.length, 0);
+      assert.equal(ctx.db.getDraft(result.draft.id).status, 'pending');
+      assert.equal(result.draft.citations[0].source, 'faq.md');
+      assert.equal(result.draft.tool_trace[0].tool, 'search_knowledge');
+    } finally { ctx.close(); }
+  });
+
+  await check('批准草稿只发送一次,重复批准被拒绝', async () => {
+    const ctx = setup();
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: 'm3', contactId: 'contact-1', contactName: '王刚', content: '请给我一个建议' });
+      await ctx.service.approveDraft(result.draft.id, { content: '人工编辑后的回复', actor: 'human' });
+      await assert.rejects(() => ctx.service.approveDraft(result.draft.id, { actor: 'human' }), /不能重复发送/);
+      assert.deepEqual(ctx.sent, [{ toId: 'contact-1', content: '人工编辑后的回复' }]);
+      assert.equal(ctx.db.getDraft(result.draft.id).status, 'sent');
+    } finally { ctx.close(); }
+  });
+
+  await check('全局暂停与人工接管都抑制 Agent', async () => {
+    const ctx = setup({ paused: true });
+    try {
+      const paused = await ctx.service.ingestInbound({ externalId: 'm4', contactId: 'contact-1', contactName: '王刚', content: '暂停时消息' });
+      assert.equal(paused.status, 'paused');
+      ctx.service.setGlobal({ paused: false });
+      ctx.service.setConversationMode(paused.conversation.id, 'human');
+      const human = await ctx.service.ingestInbound({ externalId: 'm5', contactId: 'contact-1', contactName: '王刚', content: '人工接管时消息' });
+      assert.equal(human.status, 'human');
+      assert.equal(ctx.db.listDrafts().length, 0);
+      assert.equal(ctx.sent.length, 0);
+    } finally { ctx.close(); }
+  });
+
+  await check('Agent 上游失败只留审计,不生成伪回复、不外发', async () => {
+    const ctx = setup({ agentRun: async () => { throw new Error('Agent 上游暂时不可用(HTTP 522)'); } });
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: 'm6', contactId: 'contact-1', contactName: '王刚', content: '请推荐房源' });
+      assert.equal(result.status, 'agent_failed');
+      assert.equal(ctx.db.listDrafts().length, 0);
+      assert.equal(ctx.sent.length, 0);
+      assert.equal(ctx.db.latestAgentState(result.conversation.id).action, 'agent_failed');
+    } finally { ctx.close(); }
+  });
+
+  await check('非白名单联系人被忽略且不能人工发送', async () => {
+    const ctx = setup();
+    try {
+      const ignored = await ctx.service.ingestInbound({ externalId: 'm7', contactId: 'contact-2', contactName: '其他人', content: '你好' });
+      assert.equal(ignored.status, 'ignored_not_allowlisted');
+      assert.equal(ctx.db.listConversations().length, 0);
+      const allowed = ctx.db.ensureConversation('contact-1', '王刚');
+      ctx.db.db.prepare('UPDATE conversations SET contact_id=? WHERE id=?').run('contact-2', allowed.id);
+      await assert.rejects(() => ctx.service.manualSend(allowed.id, '测试'), /不在测试白名单/);
+      assert.equal(ctx.sent.length, 0);
+    } finally { ctx.close(); }
+  });
+
+  await check('项目主控关联下每个客户绑定独立 Claude Code Session', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-map-'));
+    try {
+      const filePath = path.join(dir, 'sessions.json');
+      const store = new ClaudeCodeSessionStore(filePath, {
+        projectId: 'project-smoke',
+        projectRoot: dir,
+        mainSessionId: '11111111-1111-4111-8111-111111111111',
+      });
+      const first = store.ensure('conversation-a', { customerName: '王刚', displayName: '企微客户-王刚-a001' });
+      const second = store.ensure('conversation-b', { customerName: '李女士', displayName: '企微客户-李女士-b002' });
+      assert.notEqual(first.id, second.id);
+      assert.equal(first.parentControllerSessionId, second.parentControllerSessionId);
+      assert.equal(first.projectId, 'project-smoke');
+      assert.equal(first.customerName, '王刚');
+      assert.equal(first.displayName, '企微客户-王刚-a001');
+      const persisted = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+      assert.equal(persisted.project.boundMainSessionId, '11111111-1111-4111-8111-111111111111');
+      assert.equal(Object.keys(persisted.sessions).length, 2);
+    } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+  });
+
+  await check('Claude Code 只采用本轮权威上下文并使用客户可识别会话名', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-prompt-boundary-'));
+    try {
+      const messages = [
+        { role: 'user', content: '我是两个两个买' },
+        { role: 'user', content: '加进去这个api服务就不用管了' },
+        { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
+        { role: 'user', content: '200万吧' },
+      ];
+      const authoritative = selectAuthoritativeHistory(messages);
+      assert.deepEqual(authoritative.map(item => item.content), [
+        '新北区有4套三室房,请问预算是多少?',
+        '200万吧',
+      ]);
+      const client = new ClaudeCodeClient({
+        claudeSessionFile: path.join(dir, 'sessions.json'),
+        claudeWorkdir: dir,
+      });
+      const prompt = client.buildPrompt(messages, { profile: { profile: {} } });
+      assert.match(prompt, /本轮有效会话/);
+      assert.match(prompt, /200万吧/);
+      assert.doesNotMatch(prompt, /两个两个买/);
+      assert.doesNotMatch(prompt, /api服务/);
+      const sessionName = buildClaudeSessionName({ conversation: { contact_name: '王刚' } }, 'conversation-a');
+      assert.match(sessionName, /^企微客户-王刚-[a-f0-9]{4}$/);
+      assert.doesNotMatch(sessionName, /conversation-a/);
+    } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+  });
+
+  await check('Session 残留原话被证据闸门拦截并降级为人工确认', async () => {
+    const history = [
+      { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
+      { role: 'user', content: '200万吧' },
+    ];
+    const guarded = enforceAuthoritativeGrounding({
+      reply: '您之前提到“两个两个买”,是想一次买两套吗?',
+      confidence: 0.9,
+      intent: '预算确认',
+      reason: '客户之前说“两个两个买”。',
+      requiresHuman: false,
+    }, history, { preferredRegion: '新北区', layout: '三室', budgetWan: 200, budgetType: '待确认' }, '200万吧');
+    assert.equal(guarded.requiresHuman, true);
+    assert(guarded.confidence <= 0.68);
+    assert.doesNotMatch(guarded.reply, /两个两个买|两套/);
+    assert.match(guarded.reply, /新北区/);
+
+    const intelligence = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {
+      profileUpdates: { purchaseQuantity: 2, budgetWan: 200 },
+      tasks: [{ type: 'purchase', title: '准备两套方案', evidence: '两个两个买' }],
+      alerts: [{ type: 'high_intent', severity: 'high', title: '两套购买', evidence: '两个两个买' }],
+    });
+    assert.equal(intelligence.profileUpdates.purchaseQuantity, undefined);
+    assert.equal(intelligence.profileUpdates.budgetWan, 200);
+    assert.doesNotMatch(JSON.stringify(intelligence), /两个两个买|两套购买|准备两套方案/);
+  });
+
+  await check('客户 Session 指引主动返回可识别名称和安全打开命令', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-guide-'));
+    try {
+      const sessionFile = path.join(dir, 'sessions.json');
+      fs.writeFileSync(sessionFile, JSON.stringify({
+        version: 1,
+        project: {},
+        sessions: {
+          'conversation-a': {
+            id: '22222222-2222-4222-8222-222222222222',
+            role: 'customer-agent',
+            initialized: true,
+            displayName: '企微客户-王刚-a001',
+          },
+        },
+      }), 'utf8');
+      const guide = getCustomerSessionGuide({ id: 'conversation-a', contact_name: '王刚' }, { sessionFile });
+      assert.equal(guide.ready, true);
+      assert.equal(guide.displayName, '企微客户-王刚-a001');
+      assert.match(guide.openCommand, /agent:session/);
+      assert.match(guide.openCommand, /王刚/);
+      assert.doesNotMatch(JSON.stringify(guide), /22222222/);
+      assert.equal(guide.productionSessionProtected, true);
+    } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+  });
+
+  await check('监听消息持续沉淀客户画像、内部待办和预警', async () => {
+    const housing = extractExplicitCustomerIntelligence('我想咨询一下新北区的三室房', {}, {});
+    assert.equal(housing.profileUpdates.preferredRegion, '新北区');
+    assert.equal(housing.profileUpdates.layout, '三室');
+    const explicit = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {});
+    assert.equal(explicit.profileUpdates.budgetWan, 200);
+    assert(explicit.tasks.some(item => item.type === 'recommendation'));
+    assert(explicit.alerts.some(item => item.type === 'high_intent'));
+    const purpose = extractExplicitCustomerIntelligence('自己住吧', { budgetWan: 200 }, {});
+    assert.equal(purpose.profileUpdates.purpose, '自住');
+
+    const ctx = setup({ agentRun: async () => ({
+      content: '好的,我再确认一下您的用途和时间计划。',
+      confidence: 0.82,
+      intent: '预算确认',
+      reason: '客户给出明确预算,需要补齐用途和时间。',
+      requiresHuman: false,
+      profileUpdates: { budgetWan: 200, budgetType: '待确认' },
+      tasks: [{ type: 'qualification', title: '确认用途与时间计划', owner: '待分配', dueAt: '', priority: 'high', reason: '关键信息待补齐', evidence: '200万吧' }],
+      alerts: [{ type: 'high_intent', severity: 'high', title: '预算已明确', detail: '可以进入需求收敛阶段', evidence: '200万吧', recommendedAction: '确认用途与时间' }],
+      citations: [],
+      toolTrace: [],
+    }) });
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: 'm-intel', contactId: 'contact-1', contactName: '王刚', content: '200万吧' });
+      assert.equal(result.status, 'pending_review');
+      const detail = ctx.service.conversationDetail(result.conversation.id);
+      assert.equal(detail.profile.profile.budgetWan, 200);
+      assert.equal(detail.tasks.length, 1);
+      assert.equal(detail.alerts.length, 1);
+      assert.equal(ctx.sent.length, 0);
+    } finally { ctx.close(); }
+  });
+
+  await check('监听重启后仍接收停机期间的白名单积压消息', async () => {
+    const candidate = evaluatePolledMessage({
+      msgType: 1,
+      senderId: 'contact-1',
+      timestamp: Math.floor(Date.now() / 1000) - 600,
+      msgData: { content: '自己住吧' },
+    }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
+    assert.equal(candidate.eligible, true);
+    assert.equal(candidate.content, '自己住吧');
+  });
+
+  await check('同一业务待办只保留一张卡并聚合多条依据', async () => {
+    const ctx = setup();
+    try {
+      const conversation = ctx.db.ensureConversation('contact-1', '王刚');
+      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }], 'message-a');
+      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '区域新北区' }], 'message-b');
+      const tasks = ctx.db.listCustomerTasks(conversation.id);
+      assert.equal(tasks.length, 1);
+      assert.deepEqual(JSON.parse(tasks[0].evidence_json).map(item => item.text), ['预算200万', '区域新北区']);
+    } finally { ctx.close(); }
+  });
+
+  await check('用途和购置时间补齐后资格确认待办自动完成', async () => {
+    let turn = 0;
+    const ctx = setup({ agentRun: async () => {
+      turn += 1;
+      return {
+        content: '信息已记录。', confidence: 0.8, intent: '需求确认', reason: '测试', requiresHuman: false,
+        profileUpdates: turn === 1 ? { budgetWan: 200 } : { purpose: '自住', timeline: '三个月内' },
+        tasks: turn === 1 ? [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }] : [],
+        alerts: [], citations: [], toolTrace: [],
+      };
+    } });
+    try {
+      const first = await ctx.service.ingestInbound({ externalId: 'profile-a', contactId: 'contact-1', contactName: '王刚', content: '预算200万' });
+      assert.equal(ctx.db.listCustomerTasks(first.conversation.id)[0].status, 'open');
+      await ctx.service.ingestInbound({ externalId: 'profile-b', contactId: 'contact-1', contactName: '王刚', content: '自住,计划三个月内购买' });
+      const qualification = ctx.db.listCustomerTasks(first.conversation.id).find(item => item.business_key === 'qualification:purpose_and_timeline');
+      assert.equal(qualification.status, 'done');
+      assert.equal(qualification.resolution_reason, 'profile_condition_resolved');
+    } finally { ctx.close(); }
+  });
+
+  await check('发送房源方案后重点方案待办自动完成', async () => {
+    const ctx = setup();
+    try {
+      const conversation = ctx.db.ensureConversation('contact-1', '王刚');
+      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', managedBy: 'rule', type: 'recommendation', title: '按已确认条件筛选并发送重点方案', evidence: '预算、区域、户型已明确' }], 'message-c');
+      await ctx.service.manualSend(conversation.id, '已经为您筛选了三套重点房源方案,请查收。');
+      const task = ctx.db.listCustomerTasks(conversation.id).find(item => item.business_key === 'recommendation:shortlist');
+      assert.equal(task.status, 'done');
+      assert.equal(task.resolution_reason, 'manual_recommendation_sent');
+    } finally { ctx.close(); }
+  });
+
+  await check('旧数据库导入时合并重复业务项且不丢依据', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-import-'));
+    const sourcePath = path.join(dir, 'legacy.db');
+    const targetPath = path.join(dir, 'target.db');
+    let source = new AgentWorkbenchDb(sourcePath, { defaultMode: 'review' });
+    const conversation = source.ensureConversation('legacy-contact', '历史客户');
+    source.close();
+    const raw = new DatabaseSync(sourcePath);
+    raw.exec('DROP INDEX IF EXISTS idx_customer_tasks_business_key');
+    const timestamp = new Date().toISOString();
+    const insert = raw.prepare(`INSERT INTO customer_tasks(id,conversation_id,fingerprint,business_key,managed_by,type,title,status,evidence,evidence_json,created_at,updated_at)
+      VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`);
+    insert.run('legacy-task-a', conversation.id, 'legacy-fp-a', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '预算200万', '[]', timestamp, timestamp);
+    insert.run('legacy-task-b', conversation.id, 'legacy-fp-b', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '区域新北区', '[]', timestamp, timestamp);
+    raw.close();
+    const target = new AgentWorkbenchDb(targetPath, { defaultMode: 'review' });
+    try {
+      const result = target.importCompatibleDatabase(sourcePath);
+      assert.equal(result.imported, true);
+      const tasks = target.listCustomerTasks(conversation.id);
+      assert.equal(tasks.length, 1);
+      assert.equal(tasks[0].business_key, 'qualification:purpose_and_timeline');
+      assert.equal(JSON.parse(tasks[0].evidence_json).length, 2);
+    } finally {
+      target.close();
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  await check('Claude Code 提示词读取统一待办和预警主账', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-prompt-'));
+    try {
+      const client = new ClaudeCodeClient({ claudeWorkdir: dir, claudeSessionFile: path.join(dir, 'sessions.json') });
+      const prompt = client.buildPrompt([{ role: 'user', content: '继续推荐' }], { customerIntelligence: {
+        tasks: [{ businessKey: 'recommendation:shortlist', title: '发送重点方案', status: 'open' }],
+        alerts: [{ businessKey: 'high_intent:core_demand_ready', title: '高意向', status: 'open' }],
+      } });
+      assert.match(prompt, /当前未完成待办/);
+      assert.match(prompt, /recommendation:shortlist/);
+      assert.match(prompt, /当前未解决预警/);
+      assert.match(prompt, /high_intent:core_demand_ready/);
+    } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+  });
+
+  await check('企微官方待办同步使用可注入 stub 并保持幂等', async () => {
+    const ctx = setup();
+    try {
+      const conversation = ctx.db.ensureConversation('contact-1', '王刚');
+      const [task] = ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', type: 'recommendation', title: '发送重点方案' }]);
+      let createCalls = 0;
+      const sync = createCustomerTaskOfficialSync({
+        db: ctx.db,
+        searchTodoUsers: async ({ keyword }) => ({ status: 'ok', data: { users: [{ id: 'internal-user-1', name: keyword, alias: '' }] } }),
+        createTodoKnowledge: async input => {
+          createCalls += 1;
+          assert.deepEqual(input.followerIds, ['internal-user-1']);
+          return { status: 'ok', summary: { todoId: 'official-todo-stub' }, data: { todo: { id: 'official-todo-stub' } } };
+        },
+      });
+      await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' });
+      await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' });
+      const updated = ctx.db.getCustomerTask(task.id);
+      assert.equal(createCalls, 1);
+      assert.equal(updated.official_todo_id, 'official-todo-stub');
+      assert.equal(updated.official_sync_status, 'synced');
+      assert.equal(updated.status, 'in_progress');
+    } finally { ctx.close(); }
+  });
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2)}\n`);
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+});

+ 76 - 0
claude-code/claude-code-qiwe-assistant/scripts/customer-journey-smoke-test.js

@@ -0,0 +1,76 @@
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-customer-journey-'));
+process.env.QIWEI_OUTPUTS_DIR = tempDir;
+process.env.QIWEI_AGENT_DB_PATH = path.join(tempDir, 'messages', 'agent-workbench.db');
+
+async function main() {
+  const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
+  const db = new AgentWorkbenchDb(process.env.QIWEI_AGENT_DB_PATH, { globalPaused: true, defaultMode: 'review' });
+  const conversation = db.ensureConversation('external-test-1', '测试客户');
+  const property = { id: 'P-101', community: '测试花园', district: '新北区', totalPrice: 188, layout: '三室', area: 96, decoration: '精装' };
+  db.upsertCustomerRecommendations(conversation.id, [property], { type: 'outbound-message', entityId: 'message-1', status: 'recommended', evidence: '已向客户推荐测试花园 188 万', createdAt: '2026-07-17T01:00:00Z' });
+  db.upsertCustomerRecommendations(conversation.id, [property], { type: 'outbound-message', entityId: 'message-1', status: 'recommended', evidence: '重复同步', createdAt: '2026-07-17T01:00:00Z' });
+  assert.equal(db.listCustomerRecommendations(conversation.id)[0].recommend_count, 1);
+  db.upsertCustomerRecommendations(conversation.id, [property], { type: 'outbound-message', entityId: 'message-2', status: 'recommended', evidence: '第二次推荐', createdAt: '2026-07-18T01:00:00Z' });
+  assert.equal(db.listCustomerRecommendations(conversation.id)[0].recommend_count, 2);
+  const recommendationId = db.listCustomerRecommendations(conversation.id)[0].id;
+  const feedback = db.updateCustomerRecommendation(conversation.id, recommendationId, { status: 'rejected', feedbackReason: '楼层不合适' });
+  assert.equal(feedback.status, 'rejected');
+  assert.equal(feedback.feedback_reason, '楼层不合适');
+  db.close();
+
+  const { __testing: agentTesting } = require('../mcp/src/dashboard/agent-service');
+  assert.equal(agentTesting.detectedPropertiesInMessage('推荐测试花园188万三室', [property]).length, 1);
+  assert.equal(agentTesting.detectedPropertiesInMessage('推荐测试花园,但价格待确认', [property]).length, 0);
+
+  let feedbackCall = null;
+  const { createCustomerMasterService } = require('../mcp/src/dashboard/customer-master-service');
+  const service = createCustomerMasterService({
+    projectionPath: path.join(tempDir, 'customers', 'index.json'),
+    getConversations: () => ({ data: { conversations: [{
+      id: conversation.id,
+      displayName: '测试客户',
+      maskedId: 'ex***-1',
+      source: 'live',
+      mode: 'review',
+      analysis: { completenessScore: 50 },
+      messages: [{ id: 'm1', role: 'customer', source: 'live', content: '预算 200 万,想看三室', timestamp: '2026-07-17T00:00:00Z' }],
+      customerIntelligence: {
+        profile: { preferredRegion: '新北区', budgetWan: 200, layout: '三室', budgetType: '待确认' },
+        profileEvidence: {},
+        tags: [],
+        tasks: [{ id: 't1', title: '确认用途与时间', status: 'open', evidence: '用途和时间未确认', updatedAt: '2026-07-17T00:10:00Z' }],
+        alerts: [],
+        recommendations: [{ id: recommendationId, propertyId: property.id, property, status: 'recommended', feedbackReason: '', recommendCount: 2, sources: [], firstRecommendedAt: '2026-07-17T01:00:00Z', lastRecommendedAt: '2026-07-18T01:00:00Z' }],
+        summary: { openTasks: 1, openAlerts: 0, highAlerts: 0, recommendationCount: 1, pendingFeedbackCount: 1 },
+      },
+      lastMessageAt: '2026-07-17T00:00:00Z',
+      updatedAt: '2026-07-17T00:00:00Z',
+    }] } }),
+    updateCustomerProfile: () => ({ status: 'ok' }),
+    updateCustomerRecommendation: (customerId, id, input) => { feedbackCall = { customerId, id, input }; return { status: 'ok', assistantMessage: '反馈已记录', summary: { status: input.status } }; },
+  });
+  const hub = service.hub();
+  const customer = hub.data.customers[0];
+  assert(customer.nextActions.some(item => item.id === 'confirm-purpose'));
+  assert(customer.nextActions.some(item => item.id === 'collect-property-feedback'));
+  assert(customer.timeline.some(item => item.type === 'property'));
+  assert(customer.timeline.some(item => item.type === 'inbound-message'));
+  assert.equal(customer.pendingFeedbackCount, 1);
+  const feedbackResult = service.updateRecommendation(conversation.id, recommendationId, { status: 'interested', feedbackReason: '小区合适' });
+  assert.equal(feedbackResult.status, 'ok');
+  assert.equal(feedbackCall.input.status, 'interested');
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, recommendationCount: customer.recommendationCount, nextActions: customer.nextActions.length, timelineEvents: customer.timeline.length }, null, 2)}\n`);
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+}).finally(() => {
+  try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
+});

+ 70 - 0
claude-code/claude-code-qiwe-assistant/scripts/customer-master-smoke-test.js

@@ -0,0 +1,70 @@
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-customer-master-'));
+process.env.QIWEI_OUTPUTS_DIR = tempDir;
+process.env.QIWEI_AGENT_DB_PATH = path.join(tempDir, 'messages', 'agent-workbench.db');
+
+async function main() {
+  let updateCall = null;
+  const conversation = {
+    id: 'conversation-live-1',
+    displayName: '测试客户',
+    maskedId: '16***34',
+    source: 'live',
+    mode: 'review',
+    analysis: { completenessScore: 50 },
+    messages: [{ id: 'm1', role: 'customer', content: '预算 200 万,想看三室', timestamp: '2026-07-17T01:00:00Z' }],
+    customerIntelligence: {
+      profile: { budgetWan: 200, layout: '三室', preferredRegion: '新北区' },
+      profileEvidence: { budgetWan: { text: '预算 200 万', sourceMessageId: 'm1', updatedAt: '2026-07-17T01:00:00Z' } },
+      profileUpdatedAt: '2026-07-17T01:00:00Z',
+      tags: ['高预算'],
+      tasks: [{ id: 't1', title: '确认用途', status: 'open', evidence: '用途尚未明确' }],
+      alerts: [{ id: 'a1', title: '需求已成形', status: 'open', severity: 'high', evidence: '预算和户型明确' }],
+      summary: { openTasks: 1, openAlerts: 1, highAlerts: 1 },
+    },
+    lastMessageAt: '2026-07-17T01:00:00Z',
+    updatedAt: '2026-07-17T01:00:00Z',
+  };
+  const { createCustomerMasterService } = require('../mcp/src/dashboard/customer-master-service');
+  const projectionPath = path.join(tempDir, 'customers', 'index.json');
+  const service = createCustomerMasterService({
+    projectionPath,
+    getConversations: () => ({ status: 'ok', data: { conversations: [conversation] } }),
+    updateCustomerProfile: (id, input) => {
+      updateCall = { id, input };
+      return { status: 'ok', assistantMessage: '主档已更新', summary: { changedFields: Object.keys(input.profile || {}) } };
+    },
+  });
+
+  const hub = service.hub();
+  assert.equal(hub.status, 'ok');
+  assert.equal(hub.summary.customerCount, 1);
+  assert.equal(hub.summary.profiledCount, 1);
+  assert.equal(hub.summary.priorityCount, 1);
+  assert.equal(hub.data.customers[0].source, '真实企微会话');
+  assert.equal(hub.data.customers[0].evidenceCoverage, 33);
+  assert.equal(hub.data.customers[0].openTaskCount, 1);
+  assert(fs.existsSync(projectionPath));
+  const projection = fs.readFileSync(projectionPath, 'utf8');
+  assert.doesNotMatch(projection, /预算 200 万,想看三室|预算 200 万/);
+  assert.doesNotMatch(projection, /externalUserId|contact_id/);
+
+  const updated = service.update('conversation-live-1', { profile: { intent: '自住' }, reason: '人工确认' });
+  assert.equal(updated.status, 'ok');
+  assert.equal(updateCall.id, 'conversation-live-1');
+  assert.equal(updateCall.input.profile.intent, '自住');
+  assert.equal(updateCall.input.reason, '人工确认');
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 13, customers: hub.summary.customerCount, evidenceProjectionSafe: true }, null, 2)}\n`);
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+}).finally(() => {
+  try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
+});

+ 29 - 0
claude-code/claude-code-qiwe-assistant/scripts/deadline-parser-smoke-test.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const assert = require('assert/strict');
+const { parseFlexibleDeadline, shanghaiCalendar } = require('../mcp/src/core/deadline-parser');
+
+function main() {
+  const now = new Date('2026-07-17T04:00:00.000Z');
+  const parse = value => parseFlexibleDeadline(value, { now });
+
+  assert.deepEqual(shanghaiCalendar(now), { year: 2026, month: 7, day: 17 });
+  assert.equal(parse('2026-07-20 18:00'), '2026-07-20 18:00');
+  assert.equal(parse('2026-07-20T18:00:00'), '2026-07-20 18:00');
+  assert.equal(parse('2026/07/20 18:00'), '2026-07-20 18:00');
+  assert.equal(parse('2026-07-20'), '2026-07-20 18:00');
+  assert.equal(parse('今天18点'), '2026-07-17 18:00');
+  assert.equal(parse('明天'), '2026-07-18 18:00');
+  assert.equal(parse('明天下午6点'), '2026-07-18 18:00');
+  assert.equal(parse('后天上午9点半'), '2026-07-19 09:30');
+  assert.equal(parse('3天后'), '2026-07-20 18:00');
+  assert.equal(parse('三天后晚上8点'), '2026-07-20 20:00');
+  assert.equal(parse('一周后'), '2026-07-24 18:00');
+  assert.equal(parse('7月21日中午12点'), '2026-07-21 12:00');
+  assert.throws(() => parse('2026-02-30'), /截止日期不存在/);
+  assert.throws(() => parse('尽快处理'), /无法识别截止时间/);
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', timezone: 'Asia/Shanghai', passed: 15 }, null, 2)}\n`);
+}
+
+main();

+ 78 - 0
claude-code/claude-code-qiwe-assistant/scripts/excel-smoke-test.js

@@ -0,0 +1,78 @@
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { readSheet } = require('read-excel-file/node');
+const writeXlsxFile = require('write-excel-file/node');
+
+async function assertWorkbook(filePath, expectedSheet, expectedRows) {
+  assert(fs.existsSync(filePath), `未生成 Excel 文件: ${filePath}`);
+  const rows = await readSheet(filePath, expectedSheet);
+  assert.equal(rows.length, expectedRows + 1);
+  assert(rows[0].length > 0, `工作表 ${expectedSheet} 缺少表头`);
+}
+
+async function main() {
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-excel-smoke-'));
+  process.env.QIWEI_OUTPUTS_DIR = path.join(tempRoot, 'outputs');
+
+  try {
+    const inputFile = path.join(tempRoot, 'customers.xlsx');
+    const inputRows = [
+      ['手机号', '姓名', '验证消息', '群名称'],
+      ['13800138000', '客户甲', '您好', '体验群'],
+      ['13800138000', '重复客户', '', ''],
+      ['not-a-phone', '无效客户', '', ''],
+    ].map(row => row.map(value => ({ value })));
+    await writeXlsxFile(inputRows, { sheet: 'customers' }).toFile(inputFile);
+
+    const { normalizeCustomers } = require('../mcp/src/tools/qiwei-customer-ops-run');
+    const imported = await normalizeCustomers({ filePath: inputFile });
+    assert.equal(imported.customers.length, 1);
+    assert.equal(imported.customers[0].phone, '13800138000');
+    assert.equal(imported.customers[0].name, '客户甲');
+    assert.equal(imported.duplicateRemoved, 1);
+    assert.equal(imported.invalidRemoved, 1);
+
+    const portraitDir = path.join(process.env.QIWEI_OUTPUTS_DIR, 'portraits');
+    const playbookDir = path.join(process.env.QIWEI_OUTPUTS_DIR, 'broker-playbooks');
+    fs.mkdirSync(portraitDir, { recursive: true });
+    fs.mkdirSync(playbookDir, { recursive: true });
+    fs.writeFileSync(path.join(portraitDir, 'customer-1.json'), JSON.stringify({
+      externalUserId: 'customer-1',
+      portrait: { intent: '咨询', confidence: 0.9 },
+      updatedAt: new Date().toISOString(),
+    }), 'utf8');
+    fs.writeFileSync(path.join(playbookDir, 'broker-1.json'), JSON.stringify({
+      brokerUserId: 'broker-1',
+      playbook: { opening: '先确认需求', followUp: '次日回访' },
+      updatedAt: new Date().toISOString(),
+    }), 'utf8');
+
+    const { qiweiExportCustomerPortraits } = require('../mcp/src/tools/qiwei-portrait-tags-run');
+    const { qiweiExportBrokerPlaybooks } = require('../mcp/src/tools/qiwei-broker-playbook-run');
+    const portraitResult = await qiweiExportCustomerPortraits({ externalUserIds: ['customer-1'] });
+    const playbookResult = await qiweiExportBrokerPlaybooks({ brokerUserIds: ['broker-1'] });
+    assert.equal(portraitResult.status, 'ok');
+    assert.equal(playbookResult.status, 'ok');
+    await assertWorkbook(portraitResult.files[0], 'portraits', 1);
+    await assertWorkbook(playbookResult.files[0], 'playbooks', 1);
+
+    process.stdout.write(`${JSON.stringify({
+      status: 'ok',
+      importedCustomers: imported.customers.length,
+      duplicateRemoved: imported.duplicateRemoved,
+      invalidRemoved: imported.invalidRemoved,
+      exportsVerified: 2,
+    }, null, 2)}\n`);
+  } finally {
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+});

+ 70 - 0
claude-code/claude-code-qiwe-assistant/scripts/goal-management-smoke-test.js

@@ -0,0 +1,70 @@
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-goal-smoke-'));
+process.env.QIWEI_OUTPUTS_DIR = tempDir;
+
+const {
+  qiweiGoalCreatePlan,
+  qiweiGoalGet,
+  qiweiGoalUpdateTask,
+  qiweiGoalImportMeetingActions,
+} = require('../mcp/src/tools/qiwei-goal-management-run');
+
+async function main() {
+  try {
+    const created = await qiweiGoalCreatePlan({
+      title: '企微智能办公上线',
+      objective: '让客户可以使用会议、目标推进和智能客服能力',
+      owner: '项目负责人',
+      deadline: '2026-08-31',
+      acceptance: '三类核心场景全部通过验收',
+      milestones: [{
+        title: '客户试用',
+        deadline: '2026-08-15',
+        tasks: [{ title: '完成客户培训', owner: '实施顾问', deadline: '2026-08-10', acceptance: '培训签到和反馈完成' }],
+      }],
+    });
+    assert.equal(created.status, 'ok');
+    assert.equal(created.summary.taskCount, 1);
+    assert.equal(created.summary.progress, 0);
+    assert.equal(created.files.length, 1);
+
+    const goalId = created.summary.goalId;
+    const taskId = created.data.plan.milestones[0].tasks[0].id;
+    const imported = await qiweiGoalImportMeetingActions({
+      goalId,
+      meetingTitle: '客户试用推进会',
+      meetingDate: '2026-07-16',
+      actions: [
+        { title: '整理试用账号', owner: '产品经理', deadline: '2026-07-20' },
+        { title: '确认第二轮演示时间' },
+      ],
+    });
+    assert.equal(imported.status, 'ok');
+    assert.equal(imported.summary.imported, 2);
+    assert.equal(imported.summary.needsConfirmation, 1);
+
+    const updated = await qiweiGoalUpdateTask({ goalId, taskId, status: 'done', note: '培训已完成' });
+    assert.equal(updated.status, 'ok');
+    assert.equal(updated.summary.done, 1);
+    assert.equal(updated.summary.progress, 33);
+
+    const detail = await qiweiGoalGet({ goalId });
+    assert.equal(detail.status, 'ok');
+    assert.equal(detail.data.plan.milestones.length, 2);
+    assert.equal(detail.summary.taskCount, 3);
+    assert(fs.existsSync(path.join(tempDir, 'goals', 'plans.json')));
+
+    process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 4, progress: detail.summary.progress }, null, 2)}\n`);
+  } finally {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+});

+ 182 - 0
claude-code/claude-code-qiwe-assistant/scripts/meeting-knowledge-smoke-test.js

@@ -0,0 +1,182 @@
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { createMeetingKnowledgeService } = require('../mcp/src/dashboard/meeting-knowledge-service');
+const { OpenAICompatibleClient, AnthropicCompatibleClient } = require('../mcp/src/core/agent-runtime');
+
+async function testEmptyToolCompatibility() {
+  const originalFetch = global.fetch;
+  const requests = [];
+  global.fetch = async (url, options) => {
+    requests.push({ url: String(url), body: JSON.parse(options.body) });
+    if (String(url).includes('/chat/completions')) {
+      return { ok: true, json: async () => ({ choices: [{ message: { content: '{}' } }] }) };
+    }
+    return { ok: true, json: async () => ({ content: [{ type: 'text', text: '{}' }] }) };
+  };
+  try {
+    await new OpenAICompatibleClient({ apiKey: 'test-only', baseUrl: 'https://model.test', model: 'test' }).complete([{ role: 'user', content: 'test' }], []);
+    await new AnthropicCompatibleClient({ apiKey: 'test-only', baseUrl: 'https://model.test', model: 'test' }).complete([{ role: 'user', content: 'test' }], []);
+    assert.equal(requests.length, 2);
+    assert.equal('tools' in requests[0].body, false);
+    assert.equal('tool_choice' in requests[0].body, false);
+    assert.equal('tools' in requests[1].body, false);
+  } finally {
+    global.fetch = originalFetch;
+  }
+}
+
+async function main() {
+  await testEmptyToolCompatibility();
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-meeting-knowledge-'));
+  const liveStore = path.join(tempRoot, 'live');
+  let round = 1;
+  const officialStatus = async () => ({
+    installed: true,
+    valid: true,
+    authorized: true,
+    ready: true,
+    installedVersion: '0.1.9',
+  });
+  const rpc = payload => ({ status: 'ok', data: { response: { jsonrpc: '2.0', id: 1, result: { content: [{ type: 'text', text: JSON.stringify(payload) }] } } } });
+  const officialCall = async ({ method, args }) => {
+    if (method === 'list_user_meetings') {
+      assert.match(args.begin_datetime, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
+      assert.match(args.end_datetime, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
+      return rpc({ meetingid_list: [round === 1 ? 'meeting-live-1' : 'meeting-live-2'] });
+    }
+    assert.equal(method, 'get_meeting_info');
+    const suffix = args.meetingid.endsWith('1') ? '一' : '二';
+    return rpc({
+          meetingid: args.meetingid,
+          title: `客户项目推进会${suffix}`,
+          meeting_start_datetime: '2026-07-16 14:00',
+          meeting_duration: 3600,
+          description: '王刚负责在 7 月 20 日前整理客户演示清单。',
+          location: '线上会议室',
+          status: 3,
+          meeting_type: 0,
+          password: 'must-not-be-saved',
+          host_key: 'host-secret-value',
+          attendees: {
+            member: [{ userid: 'wanggang', status: 1, phone_number: '13800000000' }],
+            tmp_external_user: [],
+          },
+          settings: { enable_waiting_room: true, credentials: 'private-value' },
+    });
+  };
+  const analyzer = async () => ({
+    status: 'completed',
+    summary: '会议资料明确要求王刚整理客户演示清单。',
+    topics: ['客户演示准备'],
+    decisions: [],
+    actionItems: [{
+      title: '整理客户演示清单',
+      owner: '王刚',
+      dueDate: '2026-07-20',
+      priority: 'high',
+      evidence: '会议描述明确写明负责人和截止日期。',
+      confidence: 0.98,
+    }],
+    suggestedActions: ['人工确认演示清单范围'],
+    risks: ['缺少会议转写,无法验证其他讨论内容'],
+    knowledgeTags: ['客户演示', '项目推进'],
+    analysisBasis: '仅基于企业微信官方会议元数据与会议描述。',
+    requiresReview: true,
+    analyzedAt: new Date().toISOString(),
+    error: null,
+  });
+
+  try {
+    const service = createMeetingKnowledgeService({
+      storeDir: liveStore,
+      officialStatus,
+      officialCall,
+      meetingCapability: async () => ({ available: true, reason: 'available', message: '会议权限可用' }),
+      analyzer,
+      initCommand: 'node qiwei-official-cli.js init',
+    });
+
+    const firstSync = await service.sync();
+    assert.equal(firstSync.status, 'ok');
+    assert.equal(firstSync.summary.live, true);
+    assert.equal(firstSync.summary.syncedCount, 1);
+    assert.equal(firstSync.data.meetings[0].sourceKind, 'official-cli-live');
+    const recordPath = firstSync.data.meetings[0].files.json;
+    const markdownPath = firstSync.data.meetings[0].files.markdown;
+    assert.ok(fs.existsSync(recordPath));
+    assert.ok(fs.existsSync(markdownPath));
+    const persisted = fs.readFileSync(recordPath, 'utf8');
+    assert.doesNotMatch(persisted, /must-not-be-saved|host-secret-value|13800000000|private-value/);
+    assert.doesNotMatch(persisted, /"password"|"host_key"|"phone_number"|"credentials"/);
+
+    const analyzed = await service.analyze('meeting-live-1');
+    assert.equal(analyzed.status, 'ok');
+    assert.equal(analyzed.data.meeting.analysis.actionItems[0].owner, '王刚');
+    assert.match(fs.readFileSync(markdownPath, 'utf8'), /整理客户演示清单/);
+
+    round = 2;
+    const secondSync = await service.sync();
+    assert.equal(secondSync.summary.syncedCount, 1);
+    assert.equal(secondSync.summary.meetingCount, 2);
+    assert.equal(secondSync.data.meetings.find(item => item.id === 'meeting-live-1').analysis.status, 'completed');
+
+    const hub = await service.hub();
+    assert.equal(hub.status, 'ok');
+    assert.equal(hub.summary.meetingCount, 2);
+    assert.equal(hub.summary.actionItemCount, 1);
+
+    const unauthorizedStore = path.join(tempRoot, 'unauthorized');
+    const unauthorized = createMeetingKnowledgeService({
+      storeDir: unauthorizedStore,
+      officialStatus: async () => ({ installed: true, valid: true, authorized: false, ready: false }),
+      officialCall: async () => { throw new Error('未授权时不应调用官方会议接口'); },
+      initCommand: 'node qiwei-official-cli.js init',
+    });
+    const blocked = await unauthorized.sync();
+    assert.equal(blocked.status, 'error');
+    assert.equal(blocked.summary.needsInitialization, true);
+    assert.equal(fs.existsSync(path.join(unauthorizedStore, 'index.json')), false);
+    const unauthorizedHub = await unauthorized.hub();
+    assert.equal(unauthorizedHub.data.meetings.length, 0);
+    assert.equal(unauthorizedHub.data.cli.ready, false);
+    assert.match(unauthorizedHub.data.cli.initCommand, /init/);
+
+    let blockedOfficialCalls = 0;
+    const policyBlocked = createMeetingKnowledgeService({
+      storeDir: path.join(tempRoot, 'policy-blocked'),
+      officialStatus,
+      officialCall: async () => { blockedOfficialCalls += 1; throw new Error('企业策略受限时不应继续请求会议列表'); },
+      meetingCapability: async () => ({ available: false, reason: 'enterprise-policy', message: '当前企业未开放会议 CLI' }),
+    });
+    const blockedHub = await policyBlocked.hub();
+    assert.equal(blockedHub.summary.cliReady, true);
+    assert.equal(blockedHub.summary.meetingReady, false);
+    assert.equal(blockedHub.data.cli.capabilityReason, 'enterprise-policy');
+    const policySync = await policyBlocked.sync();
+    assert.equal(policySync.status, 'error');
+    assert.equal(policySync.summary.needsMeetingCapability, true);
+    assert.equal(blockedOfficialCalls, 0);
+
+    process.stdout.write(`${JSON.stringify({
+      status: 'ok',
+      liveSyncContract: true,
+      accumulatedMeetings: hub.summary.meetingCount,
+      sensitiveFieldsRemoved: true,
+      aiActionItemsPersisted: true,
+      unauthorizedPathUsesNoMock: true,
+      emptyToolModelCallsSupported: true,
+      enterprisePolicyIsExplicit: true,
+      jsonRpcEnvelopeSupported: true,
+    }, null, 2)}\n`);
+  } finally {
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+});

+ 139 - 0
claude-code/claude-code-qiwe-assistant/scripts/official-office-knowledge-smoke-test.js

@@ -0,0 +1,139 @@
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const {
+  createDocumentKnowledgeService,
+  createTodoKnowledgeService,
+  unwrapOfficialResponse,
+} = require('../mcp/src/dashboard/official-office-knowledge-service');
+
+function rpc(payload) {
+  return {
+    status: 'ok',
+    data: {
+      response: {
+        jsonrpc: '2.0',
+        id: 1,
+        result: { isError: false, content: [{ type: 'text', text: JSON.stringify(payload) }] },
+      },
+    },
+  };
+}
+
+async function main() {
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-office-knowledge-'));
+  const readyStatus = async () => ({ installed: true, valid: true, authorized: true, ready: true, installedVersion: '0.1.9' });
+  const available = async () => ({ available: true, reason: 'available', message: '官方能力可用' });
+  let docPolls = 0;
+  const docCalls = [];
+  const docOfficialCall = async request => {
+    docCalls.push(request);
+    if (request.method === 'create_doc') return rpc({ errcode: 0, docid: 'doc-live-1', url: 'https://doc.weixin.qq.com/doc/demo' });
+    if (request.method === 'edit_doc_content') return rpc({ errcode: 0 });
+    if (request.method === 'get_doc_content') {
+      docPolls += 1;
+      if (docPolls % 2 === 1) return rpc({ errcode: 0, task_id: 'task-1', task_done: false });
+      return rpc({ errcode: 0, task_id: 'task-1', task_done: true, docid: 'doc-live-1', url: 'https://doc.weixin.qq.com/doc/demo', doc_name: '客户演示方案', content: '# 客户演示方案\n\n王刚负责在 7 月 20 日前整理演示清单。' });
+    }
+    throw new Error(`unexpected doc method ${request.method}`);
+  };
+  const analyzer = async () => ({
+    status: 'completed', summary: '文档明确了客户演示准备任务。', keyPoints: ['准备客户演示'], decisions: [],
+    actionItems: [{ title: '整理演示清单', owner: '王刚', dueDate: '2026-07-20', evidence: '文档原文明确说明', confidence: 0.98 }],
+    risks: [], knowledgeTags: ['客户演示'], analysisBasis: '仅基于企微文档原文。', requiresReview: true, analyzedAt: new Date().toISOString(), error: null,
+  });
+
+  try {
+    const docs = createDocumentKnowledgeService({ storeDir: path.join(tempRoot, 'docs'), officialStatus: readyStatus, officialCall: docOfficialCall, capability: available, analyzer, wait: async () => {} });
+    const created = await docs.createDocument({ title: '客户演示方案', content: '# 客户演示方案\n\n正文' });
+    assert.equal(created.status, 'ok');
+    assert.equal(created.summary.live, true);
+    assert.equal(created.data.document.sourceKind, 'official-cli-live');
+    assert.match(created.data.document.markdown, /王刚负责/);
+    assert.ok(fs.existsSync(created.data.document.files.json));
+    assert.ok(fs.existsSync(created.data.document.files.markdown));
+    assert.deepEqual(docCalls.slice(0, 2).map(item => item.method), ['create_doc', 'edit_doc_content']);
+    assert.ok(docCalls.filter(item => item.method === 'get_doc_content').length >= 2);
+
+    const analyzed = await docs.analyze('doc-live-1');
+    assert.equal(analyzed.status, 'ok');
+    assert.equal(analyzed.data.document.analysis.actionItems[0].owner, '王刚');
+    assert.match(fs.readFileSync(analyzed.data.document.files.markdown, 'utf8'), /整理演示清单/);
+    const docHub = await docs.hub();
+    assert.equal(docHub.summary.docReady, true);
+    assert.equal(docHub.summary.documentCount, 1);
+
+    const permissionDocs = createDocumentKnowledgeService({
+      storeDir: path.join(tempRoot, 'permission-docs'), officialStatus: readyStatus, capability: available, wait: async () => {},
+      officialCall: async request => {
+        if (request.method === 'create_doc') return rpc({ errcode: 0, docid: 'doc-permission-1', url: 'https://doc.weixin.qq.com/doc/permission' });
+        if (request.method === 'edit_doc_content') return rpc({ errcode: 0 });
+        return rpc({ errcode: 851008, errmsg: 'partial no authorization' });
+      },
+    });
+    const permissionCreated = await permissionDocs.createDocument({ title: '待读权限文档', content: '# 已成功写入' });
+    assert.equal(permissionCreated.status, 'ok');
+    assert.equal(permissionCreated.summary.readbackPending, true);
+    assert.equal(permissionCreated.data.document.readbackStatus, 'pending-permission');
+    assert.equal(permissionCreated.data.document.sourceKind, 'official-cli-live-created');
+
+    const todoCalls = [];
+    let todoListEmpty = false;
+    const todoOfficialCall = async request => {
+      todoCalls.push(request);
+      if (request.method === 'search_todo_userid') return rpc({ errcode: 0, user_list: [{ userid: 'wanggang', name: '王刚', alias: '刚sir' }] });
+      if (request.method === 'get_todo_list') return rpc({ errcode: 0, todo_list: todoListEmpty ? [] : [
+        { todo_id: 'todo-live-1', content: '整理客户资料', todo_status: 1, end_time: '2026-07-20 18:00:00', follower_list: { followers: [{ follower_id: 'wanggang' }] } },
+        { todo_id: 'todo-live-2', content: '确认演示时间', todo_status: 0, follower_list: { followers: [{ follower_id: 'wanggang' }] } },
+      ] });
+      if (request.method === 'get_todo_detail') return rpc({ errcode: 0, data_list: [
+        { todo_id: 'todo-live-3', content: '制作客户演示截图', todo_status: 1, end_time: '2026-07-21 18:00:00', follower_list: { followers: [{ follower_id: 'wanggang' }] } },
+      ] });
+      if (request.method === 'create_todo') return rpc({ errcode: 0, todo_id: 'todo-live-3' });
+      if (request.method === 'update_todo') return rpc({ errcode: 0 });
+      throw new Error(`unexpected todo method ${request.method}`);
+    };
+    const todos = createTodoKnowledgeService({ storeDir: path.join(tempRoot, 'todos'), officialStatus: readyStatus, officialCall: todoOfficialCall, capability: available });
+    const users = await todos.searchUsers({ keyword: '王刚' });
+    assert.equal(users.data.users[0].id, 'wanggang');
+    const synced = await todos.sync({ followerId: 'wanggang' });
+    assert.equal(synced.status, 'ok');
+    assert.equal(synced.summary.syncedCount, 2);
+    const todoCreated = await todos.create({ content: '制作客户演示截图', followerIds: ['wanggang'], endTime: '2026-07-21T18:00', remindType: 3 });
+    assert.equal(todoCreated.status, 'ok');
+    assert.equal(todoCreated.data.todo.id, 'todo-live-3');
+    const refreshed = await todos.refreshDetails({ todoIds: ['todo-live-3'] });
+    assert.equal(refreshed.summary.refreshedCount, 1);
+    todoListEmpty = true;
+    const preserved = await todos.sync({ followerId: 'wanggang' });
+    assert.equal(preserved.summary.preservedCount, 3);
+    const completed = await todos.complete('todo-live-3');
+    assert.equal(completed.status, 'ok');
+    assert.equal(todos.readIndex().todos.find(item => item.id === 'todo-live-3').status, 0);
+    assert.ok(fs.existsSync(path.join(tempRoot, 'todos', 'todos.md')));
+
+    const unwrapped = unwrapOfficialResponse(rpc({ errcode: 0, value: 'real' }));
+    assert.equal(unwrapped.value, 'real');
+
+    let blockedCalls = 0;
+    const blockedDocs = createDocumentKnowledgeService({
+      storeDir: path.join(tempRoot, 'blocked'), officialStatus: readyStatus,
+      officialCall: async () => { blockedCalls += 1; throw new Error('blocked capability must not call live API'); },
+      capability: async () => ({ available: false, reason: 'enterprise-policy', message: '文档能力未开放' }),
+    });
+    const blocked = await blockedDocs.importDocument({ docid: 'x' });
+    assert.equal(blocked.status, 'error');
+    assert.equal(blockedCalls, 0);
+
+    process.stdout.write(`${JSON.stringify({ status: 'ok', officialEnvelopeUnwrapped: true, documentCreateReadAnalyze: true, documentReadPermissionFallback: true, todoSearchSyncCreateComplete: true, blockedPathMakesNoLiveCall: true }, null, 2)}\n`);
+  } finally {
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+});

+ 196 - 0
claude-code/claude-code-qiwe-assistant/scripts/open-customer-session.js

@@ -0,0 +1,196 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { DatabaseSync } = require('node:sqlite');
+const spawn = require('cross-spawn');
+
+const PROJECT_ROOT = path.resolve(__dirname, '..');
+
+function loadEnvFile(filePath) {
+  if (!fs.existsSync(filePath)) return;
+  for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
+    const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+    if (!match || process.env[match[1]] !== undefined) continue;
+    let value = match[2].trim();
+    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+      value = value.slice(1, -1);
+    }
+    process.env[match[1]] = value;
+  }
+}
+
+function maskContact(value) {
+  const text = String(value || '');
+  if (text.length <= 4) return '***';
+  return `${text.slice(0, 2)}***${text.slice(-2)}`;
+}
+
+function safeLabel(value) {
+  return String(value || '企微客户')
+    .trim()
+    .replace(/[\\/:*?"<>|\r\n]+/g, '-')
+    .replace(/\s+/g, '-')
+    .replace(/-+/g, '-')
+    .replace(/^-|-$/g, '')
+    .slice(0, 24) || '企微客户';
+}
+
+function parseArgs(argv) {
+  const args = { list: false, dryRun: false, customer: '', index: 0 };
+  for (let i = 0; i < argv.length; i += 1) {
+    if (argv[i] === '--list') args.list = true;
+    else if (argv[i] === '--dry-run') args.dryRun = true;
+    else if (argv[i] === '--customer') args.customer = String(argv[++i] || '').trim();
+    else if (argv[i] === '--index') args.index = Number(argv[++i] || 0);
+    else if (argv[i] === '--help' || argv[i] === '-h') args.help = true;
+  }
+  return args;
+}
+
+function readCustomerSessions({ sessionFile, dbPath }) {
+  if (!fs.existsSync(sessionFile)) throw new Error('尚未创建客户 Claude Code Session');
+  if (!fs.existsSync(dbPath)) throw new Error('企微 Workbench 数据库不存在');
+  const state = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
+  const db = new DatabaseSync(dbPath, { readOnly: true });
+  try {
+    return Object.entries(state.sessions || {})
+      .filter(([, session]) => session.role === 'customer-agent')
+      .map(([conversationId, session]) => {
+        const conversation = db.prepare('SELECT contact_name, contact_id FROM conversations WHERE id=?').get(conversationId);
+        return {
+          conversationId,
+          session,
+          customerName: conversation?.contact_name || session.customerName || '未命名客户',
+          maskedContact: maskContact(conversation?.contact_id),
+          projectRoot: state.project?.projectRoot || PROJECT_ROOT,
+        };
+      })
+      .sort((a, b) => String(a.customerName).localeCompare(String(b.customerName), 'zh-CN'));
+  } finally {
+    db.close();
+  }
+}
+
+function printList(rows) {
+  if (!rows.length) {
+    process.stdout.write('当前没有客户 Claude Code Session。\n');
+    return;
+  }
+  process.stdout.write('可查看的客户 Claude Code Session:\n');
+  rows.forEach((row, index) => {
+    const status = row.session.initialized ? '已有对话' : '尚未初始化';
+    process.stdout.write(`${index + 1}. ${row.customerName}(${row.maskedContact})· ${status}\n`);
+  });
+}
+
+function selectSession(rows, args) {
+  if (args.index) return rows[args.index - 1] || null;
+  if (args.customer) {
+    const exact = rows.filter(row => row.customerName === args.customer);
+    if (exact.length === 1) return exact[0];
+    const fuzzy = rows.filter(row => row.customerName.includes(args.customer));
+    if (fuzzy.length === 1) return fuzzy[0];
+    return null;
+  }
+  return rows.length === 1 ? rows[0] : null;
+}
+
+function findTranscript(sessionId) {
+  const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
+  if (!fs.existsSync(projectsRoot)) return false;
+  const target = `${sessionId}.jsonl`;
+  const queue = [projectsRoot];
+  while (queue.length) {
+    const current = queue.shift();
+    for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+      const fullPath = path.join(current, entry.name);
+      if (entry.isDirectory()) queue.push(fullPath);
+      else if (entry.name === target) return true;
+    }
+  }
+  return false;
+}
+
+function resolveClaudeCommand() {
+  const candidates = [
+    process.env.CLAUDE_CODE_EXECUTABLE,
+    path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
+    process.platform === 'win32' ? 'claude.cmd' : 'claude',
+  ].filter(Boolean);
+  return candidates.find(candidate => !path.isAbsolute(candidate) || fs.existsSync(candidate)) || candidates.at(-1);
+}
+
+async function main() {
+  loadEnvFile(path.join(PROJECT_ROOT, '.env.local'));
+  const args = parseArgs(process.argv.slice(2));
+  if (args.help) {
+    process.stdout.write([
+      '查看客户 Claude Code Session:',
+      '  npm run agent:session:list',
+      '  npm run agent:session -- --customer <客户名称>',
+      '',
+      '打开时会自动 fork 一份审阅会话;查看和追问不会污染生产客户 Session。',
+      '',
+    ].join('\n'));
+    return;
+  }
+
+  const outputsRoot = path.resolve(process.env.QIWEI_OUTPUTS_DIR || path.join(PROJECT_ROOT, 'outputs'));
+  const sessionFile = path.resolve(process.env.CLAUDE_CODE_SESSION_FILE || path.join(outputsRoot, 'messages', 'claude-code-sessions.json'));
+  const dbPath = path.resolve(process.env.QIWEI_AGENT_DB_PATH || path.join(outputsRoot, 'messages', 'agent-workbench.db'));
+  const rows = readCustomerSessions({ sessionFile, dbPath });
+
+  if (args.list) {
+    printList(rows);
+    return;
+  }
+
+  const selected = selectSession(rows, args);
+  if (!selected) {
+    printList(rows);
+    throw new Error('没有唯一匹配的客户,请通过 --customer <客户名称> 或 --index <序号> 选择');
+  }
+  if (!selected.session.initialized) throw new Error('该客户 Session 尚未产生 Claude Code 对话');
+
+  const reviewName = `审阅-${safeLabel(selected.customerName)}`;
+  const transcriptFound = findTranscript(selected.session.id);
+  if (args.dryRun) {
+    process.stdout.write(`${JSON.stringify({
+      status: 'ok',
+      customer: selected.customerName,
+      maskedContact: selected.maskedContact,
+      transcriptFound,
+      openMode: 'forked-review',
+      productionSessionProtected: true,
+    }, null, 2)}\n`);
+    return;
+  }
+  if (!transcriptFound) throw new Error('没有找到该客户的本地 Claude Code 会话记录');
+
+  process.stdout.write(`正在打开 ${selected.customerName}(${selected.maskedContact})的 Claude Code 审阅会话。\n`);
+  process.stdout.write('系统会先 fork 审阅副本;你在其中查看或追问,不会污染生产客户 Session。\n');
+  const child = spawn(resolveClaudeCommand(), [
+    '--resume', selected.session.id,
+    '--fork-session',
+    '--name', reviewName,
+  ], {
+    cwd: fs.existsSync(selected.projectRoot) ? selected.projectRoot : PROJECT_ROOT,
+    stdio: 'inherit',
+  });
+  child.on('error', error => {
+    process.stderr.write(`无法打开 Claude Code:${error.message}\n`);
+    process.exitCode = 1;
+  });
+  child.on('exit', code => { process.exitCode = Number(code || 0); });
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    process.stderr.write(`${error.message}\n`);
+    process.exitCode = 1;
+  });
+}
+
+module.exports = { maskContact, parseArgs, readCustomerSessions, selectSession };

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

@@ -0,0 +1,84 @@
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const ROOT = path.resolve(__dirname, '..');
+
+function readJson(relativePath) {
+  return JSON.parse(fs.readFileSync(path.join(ROOT, relativePath), 'utf8').replace(/^\uFEFF/, ''));
+}
+
+function runNpmPackDryRun() {
+  const command = process.platform === 'win32' ? 'cmd.exe' : 'npm';
+  const args = process.platform === 'win32'
+    ? ['/d', '/s', '/c', 'npm', 'pack', '--dry-run', '--json']
+    : ['pack', '--dry-run', '--json'];
+  const result = spawnSync(command, args, { cwd: ROOT, encoding: 'utf8' });
+  if (result.status !== 0) throw new Error('npm pack --dry-run 执行失败');
+  return JSON.parse(result.stdout)[0];
+}
+
+function main() {
+  const pkg = readJson('package.json');
+  const lock = readJson('package-lock.json');
+  const manifest = readJson('skill-package-manifest.json');
+  const plugin = readJson('.claude-plugin/plugin.json');
+  const serverSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'server.js'), 'utf8');
+  const serverVersion = serverSource.match(/new McpServer\([\s\S]*?version:\s*['"]([^'"]+)/)?.[1];
+  const toolNames = [...serverSource.matchAll(/registerTool\(\s*['"]([^'"]+)['"]/g)].map(match => match[1]);
+  const skillDirs = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })
+    .filter(entry => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md')))
+    .map(entry => entry.name)
+    .sort();
+
+  assert.equal(pkg.version, lock.version);
+  assert.equal(pkg.version, lock.packages?.['']?.version);
+  assert.equal(pkg.version, manifest.version);
+  assert.equal(pkg.version, plugin.version);
+  assert.equal(pkg.version, serverVersion);
+  assert.equal(pkg.engines?.node, '>=22.5.0');
+  assert.equal(new Set(toolNames).size, toolNames.length, 'MCP 工具名不能重复');
+  assert.equal(toolNames.length, manifest.mcpToolCount, 'MCP 工具数量与 manifest 不一致');
+  assert.deepEqual(skillDirs, [...manifest.skills].sort(), 'Skill 目录与 manifest 不一致');
+
+  const pack = runNpmPackDryRun();
+  const packedPaths = new Set(pack.files.map(item => item.path.replace(/\\/g, '/')));
+  for (const required of [
+    '.claude-plugin/plugin.json',
+    '.env.example',
+    'install.js',
+    'mcp/src/server.js',
+    'mcp/src/tools/qiwei-agent-control-run.js',
+    'scripts/agent-console-smoke-test.js',
+    'skill-package-manifest.json',
+    'THIRD_PARTY_NOTICES.md',
+  ]) {
+    assert(packedPaths.has(required), `npm 包缺少 ${required}`);
+  }
+  const forbidden = [...packedPaths].filter(item =>
+    /(^|\/)(?:node_modules|outputs?|\.playwright-cli|coverage|dist)(\/|$)/i.test(item) ||
+    /(^|\/)\.env\.local$/i.test(item) ||
+    /\.(?:db|sqlite|sqlite3|log|tgz|zip)$/i.test(item)
+  );
+  assert.deepEqual(forbidden, [], `npm 包包含本地运行文件:${forbidden.join(', ')}`);
+
+  process.stdout.write(`${JSON.stringify({
+    status: 'ok',
+    version: pkg.version,
+    skillCount: skillDirs.length,
+    mcpToolCount: toolNames.length,
+    packageEntries: pack.entryCount,
+    packageSize: pack.size,
+    unpackedSize: pack.unpackedSize,
+    forbiddenFiles: forbidden.length,
+  }, null, 2)}\n`);
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`${error.message}\n`);
+  process.exit(1);
+}

+ 41 - 0
claude-code/claude-code-qiwe-assistant/scripts/release-check.js

@@ -0,0 +1,41 @@
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const ROOT = path.resolve(__dirname, '..');
+const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-release-check-'));
+const outputsDir = path.join(tempRoot, 'outputs');
+const npmCli = process.env.npm_execpath || path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
+const checks = [
+  'check',
+  'smoke',
+  'agent:smoke',
+  'library:smoke',
+  'excel:smoke',
+  'outputs:validate',
+  'install:check',
+  'package:smoke',
+];
+
+try {
+  for (const check of checks) {
+    const result = spawnSync(process.execPath, [npmCli, 'run', check], {
+      cwd: ROOT,
+      env: {
+        ...process.env,
+        QIWEI_OUTPUTS_DIR: outputsDir,
+        QIWEI_AGENT_DB_PATH: path.join(outputsDir, 'messages', 'agent-workbench.db'),
+      },
+      stdio: 'inherit',
+      windowsHide: true,
+    });
+    if (result.error) throw result.error;
+    if (result.status !== 0) process.exit(result.status || 1);
+  }
+  process.stdout.write(`\n发布检查通过:${checks.length} 组检查均已完成,测试输出已隔离。\n`);
+} finally {
+  fs.rmSync(tempRoot, { recursive: true, force: true });
+}

+ 9 - 8
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -3,6 +3,7 @@ const assert = require('assert');
 const http = require('http');
 const fs = require('fs');
 const path = require('path');
+const { outputsRoot } = require('../mcp/src/core/output-paths');
 const { loadCatalog, searchEndpoints, findEndpoint } = require('../mcp/src/core/api-catalog');
 const { searchQiweiApis, getQiweiApiDoc, callQiweiApi } = require('../mcp/src/tools/qiwei-api-catalog-run');
 const {
@@ -386,8 +387,8 @@ async function main() {
     assert.strictEqual(friendStatus.data.details[0].statusText, 'already_friend');
     assertProviderHidden(friendStatus);
 
-    const portraitFile = path.join(__dirname, '..', 'outputs', 'portraits', 'wx-u-1.json');
-    const tagFile = path.join(__dirname, '..', 'outputs', 'tags', 'wx-u-1.json');
+    const portraitFile = path.join(outputsRoot(), 'portraits', 'wx-u-1.json');
+    const tagFile = path.join(outputsRoot(), 'tags', 'wx-u-1.json');
     try { fs.unlinkSync(portraitFile); } catch {}
     try { fs.unlinkSync(tagFile); } catch {}
 
@@ -399,9 +400,9 @@ async function main() {
     assertProviderHidden(profile);
     console.log('[ok] customer-ops tools check friend status and query customer profile');
 
-    const groupMappingFile = path.join(__dirname, '..', 'outputs', 'groups', 'confirmed-mapping.json');
-    const keywordConfigFile = path.join(__dirname, '..', 'outputs', 'groups', 'customer-keywords.json');
-    const rejectedMappingFile = path.join(__dirname, '..', 'outputs', 'groups', 'rejected-mapping.json');
+    const groupMappingFile = path.join(outputsRoot(), 'groups', 'confirmed-mapping.json');
+    const keywordConfigFile = path.join(outputsRoot(), 'groups', 'customer-keywords.json');
+    const rejectedMappingFile = path.join(outputsRoot(), 'groups', 'rejected-mapping.json');
     try { fs.unlinkSync(groupMappingFile); } catch {}
     try { fs.unlinkSync(keywordConfigFile); } catch {}
     try { fs.unlinkSync(rejectedMappingFile); } catch {}
@@ -461,12 +462,12 @@ async function main() {
     assertProviderHidden(messages);
     console.log('[ok] group-management tools sync, list, analyze, configure, reject, confirm and sync messages');
 
-    const scanManifestFile = path.join(__dirname, '..', 'outputs', 'groups', 'group-scan-manifest.json');
+    const scanManifestFile = path.join(outputsRoot(), 'groups', 'group-scan-manifest.json');
     assert(fs.existsSync(scanManifestFile), 'group-scan-manifest.json should exist');
     assert(fs.existsSync(keywordConfigFile), 'customer-keywords.json should exist');
     assert(fs.existsSync(rejectedMappingFile), 'rejected-mapping.json should exist');
 
-    const messageFile = path.join(__dirname, '..', 'outputs', 'messages', 'room-r1-test-portrait.json');
+    const messageFile = path.join(outputsRoot(), 'messages', 'room-r1-test-portrait.json');
     fs.mkdirSync(path.dirname(messageFile), { recursive: true });
     fs.writeFileSync(messageFile, JSON.stringify([
       { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想买房,预算200万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) }
@@ -502,7 +503,7 @@ async function main() {
     try { fs.unlinkSync(messageFile); } catch {}
     console.log('[ok] portrait-tags tools prepare, update, save portrait and manage tags/labels');
 
-    const brokerMessageFile = path.join(__dirname, '..', 'outputs', 'messages', 'room-r2-test-playbook.json');
+    const brokerMessageFile = path.join(outputsRoot(), 'messages', 'room-r2-test-playbook.json');
     fs.writeFileSync(brokerMessageFile, JSON.stringify([
       { fromRoomId: 'r2', seq: 1, senderId: 'broker-1', senderName: '顾问', msgType: 1, content: '您好,这个户型很适合您,预算也匹配', msgUniqueIdentifier: 'bm-1', timestamp: Math.floor(Date.now() / 1000) }
     ]));

+ 76 - 0
claude-code/claude-code-qiwe-assistant/scripts/unified-task-smoke-test.js

@@ -0,0 +1,76 @@
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-unified-task-'));
+process.env.QIWEI_OUTPUTS_DIR = tempDir;
+process.env.QIWEI_AGENT_DB_PATH = path.join(tempDir, 'messages', 'agent-workbench.db');
+
+function write(relativePath, value) {
+  const filePath = path.join(tempDir, relativePath);
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
+}
+
+async function main() {
+  write(path.join('knowledge', 'todos', 'index.json'), {
+    todos: [
+      { id: 'official-linked', content: '已绑定客户任务', status: 1 },
+      { id: 'official-only', content: '独立官方待办', status: 1, endTime: '2027-01-02 10:00:00' },
+    ],
+  });
+  write(path.join('goals', 'plans.json'), {
+    plans: [{ id: 'goal-1', title: '演示计划', milestones: [{ id: 'm1', title: '准备阶段', tasks: [{ id: 'goal-task-1', title: '完成演示彩排', status: 'in_progress', owner: '测试成员' }] }] }],
+  });
+  write(path.join('knowledge', 'docs', 'index.json'), {
+    documents: [{ id: 'doc-1', title: '客户方案', analysis: { analyzedAt: '2026-07-17T00:00:00Z', actionItems: [{ title: '确认方案版本', owner: '', dueDate: '', evidence: '原文要求确认版本', confidence: 0.92 }] } }],
+  });
+  write(path.join('knowledge', 'meetings', 'index.json'), {
+    meetings: [{ id: 'meeting-1', title: '项目会', analysis: { analyzedAt: '2026-07-17T01:00:00Z', actionItems: [{ title: '整理会议结论', owner: '记录人', dueDate: '', priority: 'high', evidence: '会议描述中明确要求', confidence: 0.88 }] } }],
+  });
+
+  const { createUnifiedTaskService } = require('../mcp/src/dashboard/unified-task-service');
+  const service = createUnifiedTaskService({
+    outputsDir: tempDir,
+    getConversations: () => ({ data: { conversations: [{
+      id: 'conversation-1',
+      displayName: '白名单客户',
+      customerIntelligence: { tasks: [{ id: 'customer-task-1', title: '确认客户用途', status: 'open', priority: 'high', evidence: '客户尚未说明用途', officialTodoId: 'official-linked', officialSyncStatus: 'synced' }] },
+    }] } }),
+    updateCustomerTask: async () => ({ status: 'ok' }),
+    completeTodoKnowledge: async () => ({ status: 'ok' }),
+    qiweiGoalUpdateTask: async () => ({ status: 'ok' }),
+  });
+
+  const first = service.hub();
+  assert.equal(first.status, 'ok');
+  assert.equal(first.summary.total, 3);
+  assert.equal(first.summary.candidates, 2);
+  assert.equal(first.summary.deduplicatedOfficialCount, 1);
+  assert(first.data.tasks.some(task => task.source === 'customer' && task.actions.openConversation));
+  assert(!first.data.tasks.some(task => task.source === 'official' && task.id === 'official-linked'));
+
+  const candidate = first.data.tasks.find(task => task.source === 'document-candidate');
+  const promoted = service.createLocalTask({ title: candidate.title, evidence: candidate.evidence, source: candidate.source, sourceKey: candidate.id, sourceTitle: candidate.sourceTitle });
+  assert.equal(promoted.status, 'ok');
+  assert.equal(promoted.summary.created, true);
+
+  const second = service.hub();
+  assert.equal(second.summary.total, 4);
+  assert.equal(second.summary.candidates, 1);
+  assert.equal(second.summary.promotedCandidates, 1);
+  const local = second.data.tasks.find(task => task.source === 'local');
+  const updated = await service.update({ source: 'local', taskId: local.id, status: 'done' });
+  assert.equal(updated.status, 'ok');
+  assert.equal(service.hub().data.tasks.find(task => task.id === local.id).status, 'done');
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 12, formalTasks: second.summary.total, candidates: second.summary.candidates }, null, 2)}\n`);
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+}).finally(() => {
+  try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
+});

+ 4 - 1
claude-code/claude-code-qiwe-assistant/scripts/validate-output-standard.js

@@ -4,10 +4,11 @@ const path = require('path');
 const {
   PACKAGE_ROOT,
   OUTPUT_CATEGORIES,
+  OUTPUT_PERSISTENT_STORES,
   outputsRoot
 } = require('../mcp/src/core/output-paths');
 
-const DOCS_ALLOWED = new Set(['OUTPUT-STANDARD.md', 'specs', 'guides', 'generated']);
+const DOCS_ALLOWED = new Set(['OUTPUT-STANDARD.md', 'DASHBOARD-DEV-LOG.md', 'RELEASE.md', 'specs', 'guides', 'generated']);
 const RUN_DIR_RE = /^\d{6}-[a-z0-9\u4e00-\u9fa5-]+$/;
 const DATE_DIR_RE = /^\d{4}-\d{2}-\d{2}$/;
 
@@ -34,8 +35,10 @@ function checkOutputs() {
 }
 
 function checkCategory(dir, category) {
+  const persistentStores = new Set(OUTPUT_PERSISTENT_STORES[category] || []);
   for (const entry of listEntries(dir)) {
     if (!entry.isDirectory()) continue; // latest 模式文件
+    if (persistentStores.has(entry.name)) continue; // 注册的持续知识库
     if (!DATE_DIR_RE.test(entry.name)) {
       problems.push(`outputs/${category}/${entry.name} 不是 YYYY-MM-DD 日期目录`);
       continue;

+ 1 - 1
claude-code/claude-code-qiwe-assistant/scripts/wecom-cli-smoke-test.js

@@ -35,7 +35,7 @@ async function main() {
         "if (args.at(-1) === '--help') { console.log(`help:${args.slice(0, -1).join('.') || 'root'}`); process.exit(0); }",
         "const jsonIndex = args.indexOf('--json');",
         "const input = jsonIndex >= 0 ? JSON.parse(args[jsonIndex + 1]) : {};",
-        "console.log(JSON.stringify({ errcode: 0, category: args[0], method: args[1], input, bot_secret: 'server-only-secret', accessToken: 'server-only-token', providerName: 'private-provider', doc_url: 'https://docs.example.test/document/1' }));"
+        "console.log(JSON.stringify({ errcode: 0, category: args[0], method: args[1], input, bot_secret: 'test-only-secret', accessToken: 'test-only-token', providerName: 'private-provider', doc_url: 'https://docs.example.test/document/1' }));"
       ].join('\n')
     );
     fs.mkdirSync(configDir, { recursive: true });

+ 55 - 0
claude-code/claude-code-qiwe-assistant/scripts/workspace-library-smoke-test.js

@@ -0,0 +1,55 @@
+const assert = require('assert/strict');
+const service = require('../mcp/src/dashboard/workspace-library-service');
+
+function flatten(nodes = []) {
+  return nodes.flatMap(node => [node, ...(node.children ? flatten(node.children) : [])]);
+}
+
+function main() {
+  const registry = service.listSkillRegistry();
+  assert.equal(registry.status, 'ok');
+  assert.ok(registry.data.summary.packageCount >= 2);
+  assert.ok(registry.data.summary.skillCount >= 18);
+  assert.ok(registry.data.summary.uniqueCapabilityCount >= 16);
+
+  const tree = service.listKnowledgeTree();
+  assert.equal(tree.status, 'ok');
+  assert.ok(tree.data.summary.libraryCount >= 6);
+  assert.ok(tree.data.summary.fileCount >= 20);
+  assert.equal(tree.data.summary.propertyCount, 30);
+
+  const nodes = flatten(tree.data.roots);
+  const rules = nodes.find(node => node.name === 'rules.md');
+  assert.ok(rules, 'rules.md should exist');
+  const rulesFile = service.readKnowledgeFile(rules.id);
+  assert.equal(rulesFile.data.kind, 'markdown');
+  assert.match(rulesFile.data.content, /不编造房源/);
+
+  const properties = service.listProperties({ district: '新北区', maxPrice: 150, pageSize: 60 });
+  assert.ok(properties.data.items.length > 0);
+  assert.ok(properties.data.items.every(item => item.district.includes('新北区') && item.totalPrice <= 150));
+  const detail = service.getProperty(properties.data.items[0].id);
+  assert.ok(detail.data.property.community);
+
+  const firstSkill = registry.data.packages[0].skills[0];
+  const skill = service.getSkillDetail(firstSkill.id);
+  assert.equal(skill.data.id, firstSkill.id);
+  assert.ok(skill.data.content.includes('#'));
+
+  assert.throws(
+    () => service.readKnowledgeFile(`agent-knowledge~${Buffer.from('../.env.local').toString('base64url')}`),
+    /禁止访问/,
+  );
+
+  process.stdout.write(`${JSON.stringify({
+    status: 'ok',
+    packageCount: registry.data.summary.packageCount,
+    skillCount: registry.data.summary.skillCount,
+    libraryCount: tree.data.summary.libraryCount,
+    fileCount: tree.data.summary.fileCount,
+    propertyCount: tree.data.summary.propertyCount,
+    pathTraversalBlocked: true,
+  }, null, 2)}\n`);
+}
+
+main();

+ 44 - 0
claude-code/claude-code-qiwe-assistant/skill-package-manifest.json

@@ -0,0 +1,44 @@
+{
+  "name": "claude-code-qiwei-assistant",
+  "version": "0.4.0",
+  "status": "release-ready-not-published",
+  "plugin": "qiwei-assistant",
+  "description": "企业微信智能办公技能包:日常办公、目标推进、智能客服、CRM 客户管理和底层 Skills/API。",
+  "capabilities": {
+    "dailyOffice": ["会议", "文档", "日程", "待办", "本地知识库"],
+    "goalManagement": ["大目标拆解", "里程碑", "会议行动项", "进度/阻塞/逾期跟踪", "企微待办衔接"],
+    "customerService": ["真实私信监听", "客户独立 Claude Code Session", "Agent 草稿", "人工审核", "人工接管", "安全会话审阅"],
+    "crm": ["客户档案", "客户群", "群消息", "画像", "标签", "顾问 Playbook", "客户交接"],
+    "foundation": ["Fmode 网关", "官方 CLI", "104 个接口清单", "MCP 工具", "Webhook/Relay", "白名单与审计"]
+  },
+  "skills": [
+    "qiwei-api-catalog",
+    "qiwei-broker-playbook",
+    "qiwei-capability-router",
+    "qiwei-customer-ops",
+    "qiwei-customer-transfer",
+    "qiwei-dashboard",
+    "qiwei-goal-management",
+    "qiwei-group-management",
+    "qiwei-login",
+    "qiwei-official-doc",
+    "qiwei-official-meeting",
+    "qiwei-official-schedule",
+    "qiwei-official-todo",
+    "qiwei-portrait-tags",
+    "qiwei-real-estate-auto-reply",
+    "qiwei-voice",
+    "qiwei-webhook-relay"
+  ],
+  "mcpToolCount": 75,
+  "apiCatalogCount": 104,
+  "installCommand": "node install.js workspace <客户项目目录> --smoke",
+  "workspaceInstallTarget": ".claude/plugins/qiwei-assistant",
+  "dashboardUrl": "http://127.0.0.1:4320/",
+  "sessionReviewCommand": "npm run agent:session -- --customer <客户名称>",
+  "security": [
+    "安装包不复制 .env.local、真实 outputs、Playwright 会话或 node_modules",
+    "客户 Session 原始 ID 不进入用户提示或 Dashboard",
+    "默认 review 模式,真实发送继续受白名单和人工审核约束"
+  ]
+}

+ 17 - 3
claude-code/claude-code-qiwe-assistant/skills/qiwei-capability-router/SKILL.md

@@ -1,23 +1,33 @@
 ---
 name: qiwei-capability-router
-description: 在 Fmode 网关转发的企业微信接口与官方 CLI 通道之间选择正确能力。文档、会议、日程、待办优先使用官方 CLI;客户、客户群、朋友圈、标签、个人企微设备登录等继续使用原有企业微信接口工具
+description: 在真实企微会话、本地 Agent 工作台、Fmode 网关与官方 CLI 之间选择正确能力。CLI 是文档、会议和官方待办的可选官方通道,不是系统边界;客户跟进、知识分析和统一任务可在本地持续运行
 ---
 
-# 企业微信通道能力路由
+# 企业微信通道能力路由
 
-本技能包包含两套相互独立的企业微信通道,不混用认证信息
+本技能包包含真实消息、内部任务中枢、Fmode 网关和官方 CLI 等相互独立的通道,不混用认证信息,也不因某个官方分类未开放而中断其他能力
 
 ## 路由规则
 
 | 用户意图 | 使用通道 |
 |---|---|
+| 外部联系人真实消息、AI 草稿、人工审核/暂停/接管 | 本地 Agent 工作台 + Fmode 真实消息链路 |
+| 统一查看客户跟进、目标、文档/会议行动项 | 4320 统一任务中心;正式同步企微时再按需使用官方待办 |
 | 普通文档、在线表格、智能表格、智能文档 | 企业微信官方 CLI |
 | 会议、日程、待办 | 企业微信官方 CLI |
+| 大目标拆解、里程碑、会议行动项和进度复盘 | `qiwei-goal-management` 本地台账;确认后衔接官方日程/待办 |
 | 客户、外部联系人、客户群、朋友圈、标签 | Fmode 网关企业微信接口工具 `qiwei_api_*` |
 | 个人企微设备扫码登录、掉线恢复 | Fmode 网关登录工具 `qiwei_login_*` |
 | 订阅、席位、余额 | Fmode 网关订阅工具 `qiwei_subscription_*` |
 | 通讯录、消息 | 根据用户指定的身份和授权范围选择;不确定时先询问 |
 
+## 本地任务中枢
+
+- 客户会话产生的任务保存在本地 SQLite,保留会话、证据、状态和官方待办绑定关系;
+- 文档和会议 AI 行动项默认只是候选,人工确认后才能转为正式内部任务;
+- 对外部联系人只提供“转到智能会话”,由 Agent 结合上下文拟定回复,不能把内部任务标题直接发给客户;
+- 官方成员搜索或某个 CLI 分类不可用时,本地任务仍可创建、推进和复盘。
+
 ## 官方 CLI 通道
 
 1. 调用 `qiwei_official_status`;
@@ -26,6 +36,10 @@ description: 在 Fmode 网关转发的企业微信接口与官方 CLI 通道之
 4. 用 `qiwei_official_help` 核对方法参数;
 5. 用 `qiwei_official_call` 执行。
 
+日程使用 `qiwei-official-schedule`,待办使用 `qiwei-official-todo`。需要从会议推进到目标时,先用 `qiwei-goal-management` 保存里程碑和行动项,再按用户确认结果同步正式企微待办。
+
+用户询问某个客户由哪个 Claude Code Session 处理、会话在哪里或怎么打开时,调用 `qiwei_agent_session_guide`。只返回客户可识别会话名和安全打开命令,不展示原始 Session ID。
+
 ## Fmode 网关接口通道
 
 原有流程保持不变:

+ 24 - 2
claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md

@@ -3,7 +3,27 @@ name: qiwei-customer-ops
 description: 迁移自 Qiwei 项目的客户群运营能力:批量搜索并添加企微好友、检查好友申请状态、查询客户档案、自动创建客户服务群、设置群名、邀请协作成员、发送欢迎语。所有企微调用必须通过 Fmode 网关,并以 mcp/catalog/qiwei-endpoints.json 的 method 与参数为准;不替换 qiwei-login、qiwei-api-catalog、订阅和官方 CLI 既有流程。
 ---
 
-# 企微客户群运营
+# 企微客户管理与运营
+
+## 客户主档
+
+4320 Dashboard 的“客户管理”首先展示真实企微会话沉淀的客户主档:
+
+- 权威画像、标签、任务和预警保存在 `outputs/messages/agent-workbench.db`;
+- Agent 从真实客户消息提取画像字段时,自动记录字段来源消息和更新时间;
+- `qiwei-portrait-tags` 保存的画像与标签也回写同一主账,不另建冲突档案;
+- 客户管理页可人工核对区域、预算、户型、用途、购置时间和标签,修改只更新内部主档,不向客户发送消息;
+- `outputs/customers/index.json` 是脱敏查询投影,不保存联系人原始 ID、机器人凭据或客户原话。
+
+客户主档同时提供三类经营能力:
+
+1. **客户时间线**:按时间统一展示真实消息、画像更新、内部任务、预警、房源推荐和反馈;
+2. **下一步行动**:依据画像缺口、待办、预警和推荐反馈生成内部建议,必须标明依据,不自动外发;
+3. **房源推荐历史**:记录房源快照、推荐次数和人工确认的感兴趣/不合适/带看反馈。历史消息回填只在“小区名 + 明确价格”同时匹配时成立,不能猜客户态度。
+
+客户会话产生的内部任务可以从客户主档跳转到智能会话继续处理,但不能把内部任务或画像字段原样发送给客户。
+
+房源反馈由工作人员点击确认后写入主账和画像反馈记录;“Agent 候选”“已推荐”“客户感兴趣”是不同状态,不能混用。
 
 ## 使用边界
 
@@ -15,6 +35,8 @@ description: 迁移自 Qiwei 项目的客户群运营能力:批量搜索并添
 - 按外部联系人 `userId`/成员列表创建服务群;
 - 可选设置群名、邀请协作成员、发送欢迎语。
 
+以上网关动作位于客户主档之后;查看和维护已沉淀画像不要求重新调用网关。
+
 不要在这里重新实现登录、订阅、设备恢复或通用 API 检索流程。遇到未登录、订阅不足、token 缺失时,转用已有工具:
 
 - `qiwei_login_status`
@@ -53,7 +75,7 @@ description: 迁移自 Qiwei 项目的客户群运营能力:批量搜索并添
 }
 ```
 
-也可以传 `phones`,或安装依赖后传 `filePath` 读取 Excel。Excel 至少需要手机号列。
+也可以传 `phones`,或传 `.xlsx` 格式的 `filePath` 读取 Excel。Excel 至少需要手机号列;旧版 `.xls` 请先另存为 `.xlsx`
 
 ### 检查好友申请状态
 

+ 59 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-dashboard
-description: 启动并管理 Qiwei Dashboard(本地 Web 操作界面)。必须在项目根目录 claude-code/claude-code-qiwe-assistant 下运行 npm run dashboard,确保正确读取 .env.local 中的 QIWEI_AUTH_TOKEN、QIWEI_UID 和 QIWEI_API_BASE
+description: 启动并管理 Qiwei Dashboard(本地 Web 操作界面),包括真实企微 Agent、人工监管、统一技能中心和本地知识库。用于启动 4320 工作台、检查运行状态、浏览多个企微技能包、预览 Markdown/JSON/CSV/代码文件或查看房源列表与详情;必须从项目根目录运行 npm run dashboard
 ---
 
 # Qiwei Dashboard
@@ -12,6 +12,8 @@ description: 启动并管理 Qiwei Dashboard(本地 Web 操作界面)。必
 - 启动本地 Dashboard Web 服务;
 - 确认服务健康、登录状态和订阅状态;
 - 在登录失效时引导用户恢复登录;
+- 浏览已接入的源码版、OpenClaw、Agent Workbench 和房源匹配能力;
+- 以只读方式预览 Agent 规则、技能说明、房源数据和运行输出;
 - 告诉用户访问地址 `http://127.0.0.1:4320/`。
 
 业务操作(群管理、客户运营、画像标签等)由用户在浏览器里完成,或通过其他专门 skill 调用 MCP 工具完成。本 skill 不替代登录、订阅、业务工具 skill。
@@ -31,12 +33,68 @@ cd claude-code/claude-code-qiwe-assistant
 npm run dashboard
 ```
 
+在 Claude Code 项目主控会话中,优先直接调用:
+
+1. `qiwei_agent_dashboard_start`:启动或确认 Dashboard;
+2. `qiwei_agent_bind_controller`:自动检测失败时显式绑定项目主控 Session;
+3. `qiwei_agent_status`:读取账号、监听、Agent 和会话状态;
+4. `qiwei_agent_listener`:启动或停止真实消息监听;
+5. `qiwei_agent_set_global`:设置 paused、review、auto 或 human;
+6. `qiwei_agent_list_conversations`:读取客户会话和待审核草稿;
+7. `qiwei_agent_inbox`:读取前端、监听器、Agent 与人工操作事件;
+8. `qiwei_agent_generate_draft`:让客户专属 Claude Code Session 生成待审核草稿,不发送。
+9. `qiwei_agent_session_guide`:主动说明某客户对应的 Claude Code 会话名、所在位置和安全打开命令。
+
+## 项目主控与客户会话
+
+- 把客户初始化并安装技能包的文件夹视为一个独立项目边界。
+- 项目主控 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。
+
+### 查看客户 Claude Code Session
+
+先列出可识别的客户会话,不要直接读取或输出原始 Session ID:
+
+```bash
+npm run agent:session:list
+```
+
+按客户名称打开对应会话:
+
+```bash
+npm run agent:session -- --customer 王刚
+```
+
+该命令会在 Fmode Studio 当前项目终端中 resume 客户 Session,并自动 `fork-session` 为审阅副本。审阅副本保留完整历史、模型思考和工具记录;在其中查看或追问不会污染生产客户 Session。不要手工打开或传播 `outputs/messages/claude-code-sessions.json` 中的原始 ID。
+
 服务启动后会输出:
 
 ```text
 Qiwei Dashboard 已启动:http://127.0.0.1:4320/
 ```
 
+## 技能中心与知识库
+
+- 打开 `http://127.0.0.1:4320/#skills` 查看统一技能目录、按关键词搜索能力并切换来源包。
+- 打开 `http://127.0.0.1:4320/#knowledge` 以文件夹树浏览本地知识文件;点击 `properties.json` 进入房源筛选、卡片列表和单套详情。
+- 知识目录由 `knowledge-base/catalog.json` 配置;新增受支持的本地目录后重载页面即可扫描,不要把 Token、`.env.local` 或其他凭据目录加入目录表。
+- 默认只读预览 `.md`、`.json`、`.csv`、`.txt` 和 `.js`;禁止通过文件节点访问配置目录之外的路径。
+- 知识库运行输出放入 `outputs/knowledge/`,不要直接写入外部技能包的源码目录。
+
+可用的只读接口:
+
+```text
+GET /api/skills
+GET /api/knowledge/tree
+GET /api/knowledge/file?id=<node-id>
+GET /api/knowledge/properties
+GET /api/knowledge/properties/<property-id>
+```
+
 ## 端口与环境变量
 
 - 默认端口:`4320`

+ 37 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-goal-management/SKILL.md

@@ -0,0 +1,37 @@
+---
+name: qiwei-goal-management
+description: 把大计划拆成里程碑和可执行任务,从会议纪要或聊天内容提炼负责人、截止时间与验收标准,保存目标推进台账并持续更新进度。适用于项目目标、季度计划、会议行动项、任务分解、进度复盘、临期与阻塞检查,以及同步企业微信待办。
+---
+
+# 企微目标推进
+
+## 建立计划
+
+1. 明确目标、业务结果、负责人、截止日期和验收口径。
+2. 把目标拆成 2—6 个里程碑;每个里程碑拆成可在数日内完成的任务。
+3. 每个任务至少包含标题、负责人、截止时间和完成标准;不明确项标记为待确认。
+4. 展示计划草案,经用户确认后调用 `qiwei_goal_create_plan` 保存本地推进台账。
+5. 保存后任务会进入 4320“知识库 → 统一任务中心”;需要正式进入企微时,再调用 `qiwei-official-todo` 创建待办或用 `qiwei-official-schedule` 安排检查点。官方通道不可用不影响本地推进。
+
+## 从会议提炼待办
+
+1. 从会议纪要、文档或聊天中提取明确承诺,不把讨论建议直接当成任务。
+2. 为每项行动补齐负责人、截止时间、优先级和验收标准;缺失信息先询问。
+3. 让用户确认行动项清单。
+4. 调用 `qiwei_goal_import_meeting_actions` 写入对应目标。
+5. 用户要求同步企微时,再调用官方待办能力;本地台账与企微待办都保留来源会议。
+
+## 持续推进
+
+- 调用 `qiwei_goal_get` 查看全部计划或单个计划的完成率、临期、逾期和阻塞项。
+- 调用 `qiwei_goal_update_task` 更新任务状态、完成说明和阻塞原因。
+- 在统一任务中心中与客户会话任务、官方待办、文档和会议候选一起查看;同一客户任务绑定官方待办后按 `todo_id` 去重,避免重复统计。
+- 每次复盘优先报告:目标结果、里程碑完成率、下一步、阻塞项和需要谁决策。
+- 不因一次会议自动把目标标记为完成;只有满足验收标准时才能完成。
+
+## 安全边界
+
+- 本地目标台账写入 `outputs/goals/`,不包含 Token、原始 Session ID 或机器人凭据。
+- 正式创建企微待办、修改参与人、删除事项前遵循对应官方技能的确认规则。
+- 从模糊语句推导出的负责人或期限必须标记待确认,不得伪造成明确承诺。
+- 外部联系人相关任务是内部执行项,只能转到智能会话生成有上下文的草稿,不能把任务原文直接发送给客户。

+ 4 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-goal-management/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "企微目标推进"
+  short_description: "拆解大目标、提炼会议行动项,并持续跟踪里程碑和任务进度"
+  default_prompt: "把这个大计划拆成里程碑和任务,从会议中提炼待办并建立推进台账。"

+ 13 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-official-doc/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-official-doc
-description: 通过企业微信官方 CLI 创建普通文档、读取 Markdown 内容和覆写文档。适用于企业微信文档或 doc.weixin.qq.com/doc/ 链接;在线表格、智能表格和智能文档应路由到后续对应技能
+description: 通过企业微信官方 CLI 创建普通文档、读取或覆写 Markdown,并将真实企微文档沉淀到本地知识库后逐篇生成 AI 摘要、要点、决策和待办候选。适用于创建企微文档、读取 doc.weixin.qq.com/doc/ 链接、文档知识归档和 AI 文档分析;在线表格、智能表格和智能文档应路由到对应方法
 ---
 
 # 企业微信官方普通文档
@@ -88,9 +88,21 @@ description: 通过企业微信官方 CLI 创建普通文档、读取 Markdown 
 }
 ```
 
+## 文档知识沉淀
+
+在 4320 Dashboard 的“知识库 → 企微文档知识沉淀 → 文档工作台”中执行:
+
+1. 创建普通文档并写入 Markdown,或用 docid/链接读取已有文档;
+2. 对 `get_doc_content` 返回的异步任务持续使用同一 `task_id`,完成后再保存;
+3. 将真实文档原文、docid、链接和同步时间写入 `outputs/knowledge/docs/`;
+4. AI 分析与读取分开,逐篇触发摘要、关键要点、明确决策、风险和有原文证据的待办候选;
+5. AI 失败时保留真实原文并标记失败,不补写伪结论。
+6. 有原文证据的行动项进入“统一任务中心”的 AI 候选区;只有人工点击确认后才转为正式内部任务,不自动创建企微待办。
+
 ## 错误处理
 
 - `851002 incompatible doc type`:重新识别 URL 类型,不要继续用普通文档方法;
 - 未初始化:提示官方 CLI 初始化,不调用原有 `qiwei_login_*`;
 - 方法或 schema 变化:重新调用 `qiwei_official_help`;
 - 不展示机器人 Secret、Authorization 或本地加密配置内容。
+- 文档读取权限暂缺时保留已确认成功的创建/写入结果,并允许在工作台重新读取复核;不能把本地写入内容伪装成官方读回结果。

+ 17 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-official-meeting/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-official-meeting
-description: 通过企业微信官方 CLI 创建、查询、取消会议及维护受邀成员。适用于预约会议、查看会议列表或详情、取消会议、添加或移除参会人;不使用原有个人企微设备接口
+description: 通过企业微信官方 CLI 创建、查询、取消会议、维护受邀成员,并将真实会议同步到本地知识库后生成 AI 摘要、议题、决策和待办候选。适用于预约或管理会议、查看会议列表或详情、同步会议知识、分析会议信息和沉淀待办;不使用原有个人企微设备接口,不用 Mock 数据代替真实会议
 ---
 
 # 企业微信官方会议
@@ -14,6 +14,8 @@ description: 通过企业微信官方 CLI 创建、查询、取消会议及维
 3. 未初始化时停止业务调用,把返回的初始化命令交给用户完成一次扫码;
 4. 初始化完成后再继续。
 
+当前官方能力范围按企业规模区分:10 人及以下企业可使用会议 CLI;10 人以上企业的授权机器人目前只开放文档和待办 CLI。扫码成功只代表身份授权完成,不代表会议分类一定可用。调用会议前检查分类权限;遇到“当前企业暂不支持授权机器人会议权限”时停止重试,明确提示更换 10 人及以下测试企业,不能用 Mock 会议掩盖限制。
+
 ## 支持的方法
 
 | 意图 | category | method |
@@ -67,6 +69,20 @@ description: 通过企业微信官方 CLI 创建、查询、取消会议及维
 - 用户按名称查会议时,先取列表,再逐个读取详情匹配标题和时间;
 - 涉及分页时使用 `next_cursor` 继续查询。
 
+## 沉淀会议知识
+
+在 4320 Dashboard 的“知识库 → 企微会议知识沉淀 → 会议工作台”中执行:
+
+1. 先确认官方 CLI 已安装、版本有效并完成扫码授权;未授权时停止同步,不生成演示会议;
+2. 在当日前后 30 天内选择时间范围,通过 `list_user_meetings` 获取真实会议 ID;
+3. 对每个 ID 调用 `get_meeting_info`,递归移除密码、主持人密钥、手机号、Token 和凭据后保存 JSON 与 Markdown;
+4. 同步完成后再逐场触发 AI 分析,不在同步阶段批量调用模型;
+5. 仅根据官方会议元数据和描述提取摘要、议题、明确决策、风险、知识标签及有证据的待办候选;
+6. 把建议动作放入建议区,不冒充已确认任务;所有 AI 结果标记为需要人工复核。
+7. 有证据的待办候选汇总到“统一任务中心”,人工确认后才形成正式内部任务;会议 CLI 不可用时仍可使用本地任务、文档和真实客户会话能力。
+
+会议知识默认写入 `outputs/knowledge/meetings/`。若缺少录音、转写或人工纪要,明确说明分析边界,不补写未发生的讨论、决定或承诺。AI 调用失败时保留真实会议资料并标记失败,不生成替代性伪总结。
+
 ## 取消和成员更新
 
 - 取消前先读取会议详情,展示标题和开始时间并要求确认;

+ 29 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-official-schedule/SKILL.md

@@ -0,0 +1,29 @@
+---
+name: qiwei-official-schedule
+description: 通过企业微信官方 CLI 创建、查询、修改和删除日程,管理参与人并查询多人空闲时间。适用于用户提出安排日程、查空档、改期、取消日程、邀请同事或协调多人时间;使用 qiwei_official_* MCP 工具,不使用个人企微设备接口。
+---
+
+# 企业微信官方日程
+
+## 前置检查
+
+1. 调用 `qiwei_official_status`。
+2. CLI 未安装时调用 `qiwei_official_prepare`。
+3. 未初始化时停止业务调用,把返回的初始化命令交给用户完成一次授权。
+4. 每次首次使用或参数不确定时,调用 `qiwei_official_help` 读取 `schedule` 的当前方法与 schema。
+
+## 执行流程
+
+1. 明确标题、开始和结束时间、时区、地点、参与人及提醒方式。
+2. 只有姓名时,先通过官方通讯录能力匹配成员;同名时让用户确认。
+3. 多人约时间时,先查询参与人的空闲时间,再提出 2—3 个候选时段。
+4. 创建或修改前复述时间、参与人和地点。
+5. 通过 `qiwei_official_call` 调用 `schedule` 类别的当前方法。
+6. 返回日程名称、时间、参与人和可访问链接;不要输出机器人凭据。
+
+## 修改与删除
+
+- 修改前先读取现有日程,展示变化摘要。
+- 删除或取消属于破坏性操作,必须取得明确确认。
+- 时区不明确时不要猜测;默认建议使用用户当前时区并请其确认。
+- 官方 CLI 失败不转用个人企微设备接口,保留原请求并提示完成官方授权。

+ 4 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-official-schedule/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "企业微信日程"
+  short_description: "创建、查询、修改企业微信日程,并协调参与人的空闲时间安排"
+  default_prompt: "使用企业微信官方 CLI 帮我安排、查询或调整团队日程。"

+ 48 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-official-todo/SKILL.md

@@ -0,0 +1,48 @@
+---
+name: qiwei-official-todo
+description: 通过企业微信官方 CLI 搜索成员并创建、同步、更新、完成或删除由当前机器人创建的待办,维护参与人、截止时间、提醒和处理状态。适用于记待办、分配任务、查看机器人待办、催办、改截止时间、完成事项,以及在 4320 Dashboard 展示真实企微待办。
+---
+
+# 企业微信官方待办
+
+## 前置检查
+
+1. 调用 `qiwei_official_status`。
+2. CLI 未安装时调用 `qiwei_official_prepare`。
+3. 未初始化时停止业务调用,把初始化命令交给用户完成授权。
+4. 调用 `qiwei_official_help` 读取 `todo` 类别的当前方法与参数,不猜测固定 schema。
+
+## 创建待办
+
+1. 把事项整理为明确的动词开头标题。
+2. 确认负责人、截止时间、优先级、验收标准和来源。
+3. 只有姓名时先通过官方通讯录匹配成员;同名必须确认。
+4. 用户一次给出多个事项时,先展示结构化清单,经确认后批量创建。
+5. 使用 `qiwei_official_call` 执行,并返回待办标题、负责人、截止时间和链接。
+
+## 查询与推进
+
+- 查询时按未完成、临期、逾期和已完成分组。
+- 修改负责人、截止时间或状态前先读取当前值。
+- 完成待办时记录完成说明或交付物链接。
+- 删除待办必须明确确认;“完成”与“删除”不可混用。
+- 从会议纪要提取待办时,与 `qiwei-goal-management` 配合:先提取并确认,再创建正式企微待办。
+
+## Dashboard 工作流
+
+在“知识库 → 企微官方待办中心 → 待办中心”中执行:
+
+1. 先用 `search_todo_userid` 按姓名或别名查询参与人 userid,不猜测人员标识;
+2. 用参与人 userid 调用 `get_todo_list`,单页最多 20 条;
+3. 创建待办时明确内容、参与人、截止时间和提醒方式;
+4. 完成待办时调用 `update_todo` 将 `todo_status` 设为 `0`,不把完成等同于删除;
+5. 将真实结果写入 `outputs/knowledge/todos/`,不生成 Mock 待办。
+
+官方边界:列表、详情、更新和删除只适用于当前机器人创建的待办。不要把其他来源任务伪装为企微官方待办。
+
+## 与统一任务中心协同
+
+- 内部成员任务在姓名能可靠解析为 userid 且用户确认后,才同步为真实企微官方待办;
+- 外部联系人没有官方内部成员 userid,相关事项保留为本地客户跟进任务,并通过“转到智能会话”继续沟通;
+- 客户任务绑定 `todo_id` 后只计为一项任务,完成本地任务时同步完成官方待办;
+- 成员搜索失败或官方通道不可用时,不猜 userid,也不阻塞本地任务推进。

+ 4 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-official-todo/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "企业微信待办"
+  short_description: "创建、分配、查询和推进企业微信待办,并维护负责人和截止时间"
+  default_prompt: "把这些事项整理成企业微信待办,并帮助我持续推进。"

+ 6 - 2
claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-portrait-tags
-description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更新/保存/批量/导出客户画像、本地客户标签管理、企微个人标签同步与增删改及应用。画像和标签数据保存在 outputs/portraits/ 和 outputs/tags/
+description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更新/保存/批量/导出客户画像、本地客户标签管理、企微个人标签同步与增删改及应用。画像与标签优先回写统一客户主账,并在客户管理页集中展示
 ---
 
 # 客户画像与标签
@@ -18,11 +18,15 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 
 ## 数据存储
 
+- 权威客户主账:`outputs/messages/agent-workbench.db` 的 `customer_profiles`;
+- 客户管理脱敏投影:`outputs/customers/index.json`;
 - 画像文件:`outputs/portraits/<externalUserId>.json`
 - 画像上下文:`outputs/portraits/<timestamp>/context-<externalUserId>.json`
 - 画像导出:`outputs/portraits/export-<timestamp>.xlsx`
 - 本地标签:`outputs/tags/<externalUserId>.json`
 
+当 externalUserId 已对应真实企微会话时,画像文件和本地标签写入后必须同步合并到权威客户主账。读取时优先返回主账内容,文件仅作为兼容输出和导出来源。
+
 ## 标准流程
 
 ### 准备客户画像
@@ -93,7 +97,7 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 
 ## 迁移说明
 
-原 Qiwei 项目中的 `CustomerPortrait`、`Customer.tags`、`QiwePersonalLabel` SQLite 表改为文件化
+原 Qiwei 项目中的 `CustomerPortrait` 和 `Customer.tags` 已合并到 Agent Workbench 客户主账,同时保留文件化兼容输出
 
 - 画像分析 → `outputs/portraits/`
 - 本地标签 → `outputs/tags/`

+ 56 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md

@@ -0,0 +1,56 @@
+---
+name: qiwei-real-estate-auto-reply
+description: 管理企业微信房产 Agent、客户消息监听、需求画像、房源匹配、回复草稿和人工接管。适用于小牛看房演示、白名单测试、启动或停止 4320 智能会话;企微收发统一通过 Fmode 网关。
+---
+
+# 房产 AI 智能会话
+
+## 前置检查
+
+1. 调用 `qiwei_subscription_status` 和 `qiwei_login_status`,确认订阅有效且设备在线。
+2. 配置 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS` 测试联系人白名单;白名单为空时必须拒绝监听。
+3. 默认使用技能包内置的脱敏房源案例;客户数据可通过 `QIWEI_AGENT_PROPERTY_DATA_FILE` 覆盖。
+4. 推荐设置 `AGENT_PROVIDER=claude-code`,复用客户项目中的 Claude Code/Fmode 模型配置。
+5. 默认使用 `review`:生成待审核草稿,不自动发送。
+
+## 启动与接管
+
+在技能包根目录启动工作台:
+
+```bash
+npm run dashboard
+```
+
+然后打开 `http://127.0.0.1:4320/#agent`,点击“启动 AI 监听”;也可以由项目主控会话调用 `qiwei_agent_listener` 并传入 `running=true`。关闭监听或切换“人工接管”后,Agent 不再自动处理该会话,由人工回复。
+
+客户消息同步和发送统一通过 Fmode 网关执行 `/msg/syncMsg`、`/msg/sendText`;登录状态使用 Fmode 专用 `/login/status`,不读取、不保存底层企微 Token,也不访问本地直连接口。
+
+## 客户 Session
+
+每个白名单客户绑定独立 Claude Code Session,后续通过 resume 延续上下文,不复用操作者的日常会话。
+
+排查客户判断时先运行:
+
+```bash
+npm run agent:session:list
+npm run agent:session -- --customer <客户名称>
+```
+
+打开的是 fork 后的审阅副本,可查看完整提示、模型思考和工具轨迹,不会改写生产客户 Session。不要复制、展示或手工拼接原始 Session ID。
+
+## 画像、待办和推荐联动
+
+- 只把客户本轮真实原话视为权威证据;语音转写残缺、无关测试消息不得直接写入画像。
+- 更新区域、预算、户型、用途或购置时间时,同时写入统一客户主账和字段证据。
+- 同一业务待办使用稳定业务键聚合多条依据,避免重复卡片。
+- `search_properties` 结果先记录为“Agent 候选”;方案实际发送后才标记“已推荐”。
+- 兴趣、拒绝原因和带看状态必须来自真实回复或人工确认,下一轮匹配应避开已明确不合适的房源。
+
+## 安全约束
+
+- 不处理或发送给非白名单联系人。
+- 全局暂停、人工模式或审核未通过时不得自动外发。
+- 不在代码、日志、回复或 Skill 文件中输出 Fmode token、AI 密钥、设备标识或 Relay 私钥。
+- 先用测试企微与脱敏案例验收,再扩大联系人范围。
+- 同一账号只运行一个消息消费者,避免轮询与 Relay 重复处理。
+

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff