weflow-cli 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +105 -0
- package/CHANGELOG.md +121 -0
- package/CONTRIBUTING.md +45 -0
- package/LICENSE +21 -21
- package/OPERATIONS.md +272 -0
- package/README.en.md +281 -0
- package/README.md +334 -149
- package/SECURITY.md +47 -0
- package/bin/weflow-cli.ts +4050 -581
- package/dist/bin/weflow-cli.js +4310 -588
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +178 -236
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +15 -1
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +177 -19
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/core/keyService.d.ts +8 -0
- package/dist/src/core/keyService.d.ts.map +1 -1
- package/dist/src/core/keyService.js +127 -22
- package/dist/src/core/keyService.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +59 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +201 -65
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +18 -1
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +3 -6
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/services/assistantDaemon.d.ts +19 -0
- package/dist/src/services/assistantDaemon.d.ts.map +1 -0
- package/dist/src/services/assistantDaemon.js +117 -0
- package/dist/src/services/assistantDaemon.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +36 -0
- package/dist/src/services/assistantMemory.d.ts.map +1 -0
- package/dist/src/services/assistantMemory.js +182 -0
- package/dist/src/services/assistantMemory.js.map +1 -0
- package/dist/src/services/assistantPrivacy.d.ts +25 -0
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -0
- package/dist/src/services/assistantPrivacy.js +77 -0
- package/dist/src/services/assistantPrivacy.js.map +1 -0
- package/dist/src/services/assistantRouting.d.ts +18 -0
- package/dist/src/services/assistantRouting.d.ts.map +1 -0
- package/dist/src/services/assistantRouting.js +68 -0
- package/dist/src/services/assistantRouting.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +37 -0
- package/dist/src/services/assistantService.d.ts.map +1 -0
- package/dist/src/services/assistantService.js +284 -0
- package/dist/src/services/assistantService.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +24 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -0
- package/dist/src/services/assistantTools.js +582 -0
- package/dist/src/services/assistantTools.js.map +1 -0
- package/dist/src/services/chatService.d.ts +44 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +105 -4
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +55 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +147 -6
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/evidenceService.d.ts +30 -0
- package/dist/src/services/evidenceService.d.ts.map +1 -0
- package/dist/src/services/evidenceService.js +98 -0
- package/dist/src/services/evidenceService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +13 -20
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +113 -47
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/initKeyService.d.ts +19 -0
- package/dist/src/services/initKeyService.d.ts.map +1 -0
- package/dist/src/services/initKeyService.js +80 -0
- package/dist/src/services/initKeyService.js.map +1 -0
- package/dist/src/services/messageContract.d.ts +23 -0
- package/dist/src/services/messageContract.d.ts.map +1 -0
- package/dist/src/services/messageContract.js +49 -0
- package/dist/src/services/messageContract.js.map +1 -0
- package/dist/src/services/messageQuery.d.ts +7 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -0
- package/dist/src/services/messageQuery.js +30 -0
- package/dist/src/services/messageQuery.js.map +1 -0
- package/dist/src/services/wechat-formatter.js +14 -14
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +31 -3
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/services/whitelistService.d.ts +52 -4
- package/dist/src/services/whitelistService.d.ts.map +1 -1
- package/dist/src/services/whitelistService.js +183 -17
- package/dist/src/services/whitelistService.js.map +1 -1
- package/dist/src/types.d.ts +20 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -0
- package/dist/src/utils/dateRange.js +48 -0
- package/dist/src/utils/dateRange.js.map +1 -0
- package/dist/src/utils/mcpSecurity.d.ts +5 -0
- package/dist/src/utils/mcpSecurity.d.ts.map +1 -0
- package/dist/src/utils/mcpSecurity.js +39 -0
- package/dist/src/utils/mcpSecurity.js.map +1 -0
- package/dist/src/utils/packageRoot.d.ts +2 -0
- package/dist/src/utils/packageRoot.d.ts.map +1 -0
- package/dist/src/utils/packageRoot.js +16 -0
- package/dist/src/utils/packageRoot.js.map +1 -0
- package/dist/src/utils/python.d.ts +2 -0
- package/dist/src/utils/python.d.ts.map +1 -0
- package/dist/src/utils/python.js +72 -0
- package/dist/src/utils/python.js.map +1 -0
- package/dist/src/utils/pythonProcessEnv.d.ts +3 -0
- package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -0
- package/dist/src/utils/pythonProcessEnv.js +46 -0
- package/dist/src/utils/pythonProcessEnv.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +4 -1
- package/dist/src/utils/talkerUtils.d.ts.map +1 -1
- package/dist/src/utils/talkerUtils.js +9 -9
- package/dist/src/utils/talkerUtils.js.map +1 -1
- package/dist/src/utils/wechatEmoji.d.ts +5 -0
- package/dist/src/utils/wechatEmoji.d.ts.map +1 -0
- package/dist/src/utils/wechatEmoji.js +35 -0
- package/dist/src/utils/wechatEmoji.js.map +1 -0
- package/docs/AI_INTERFACE.md +189 -0
- package/docs/BRANCHES.md +87 -0
- package/docs/DATA_CONTRACT.md +74 -0
- package/docs/DECISIONS.md +270 -0
- package/docs/DEEPSEEK_V4_FLASH/346/226/275/345/267/245/346/226/207/346/241/243.md +234 -0
- package/docs/EVIDENCE_GUIDE.md +96 -0
- package/docs/MCP.md +95 -0
- package/docs/PARTNERS.md +41 -0
- package/docs/PROJECT_STATE.md +96 -0
- package/docs/RELEASING.md +124 -0
- package/docs/ROADMAP.md +191 -0
- package/docs/SETUP.md +103 -0
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/docs/images/weflow-architecture.png +0 -0
- package/docs/images/weflow-architecture.svg +65 -0
- package/mcp-server/index.ts +169 -269
- package/package.json +81 -72
- package/requirements-3x.txt +4 -0
- package/requirements-voice.txt +15 -0
- package/requirements.txt +9 -0
- package/resources/js/marked.min.js +69 -0
- package/resources/wechat-emoji/666.png +0 -0
- package/resources/wechat-emoji/Awesome.png +0 -0
- package/resources/wechat-emoji/Concerned.png +0 -0
- package/resources/wechat-emoji/Cry.png +0 -0
- package/resources/wechat-emoji/Emm.png +0 -0
- package/resources/wechat-emoji/Facepalm.png +0 -0
- package/resources/wechat-emoji/Grin.png +0 -0
- package/resources/wechat-emoji/OK.png +0 -0
- package/resources/wechat-emoji/Respect.png +0 -0
- package/resources/wechat-emoji/Sick.png +0 -0
- package/resources/wechat-emoji/Sleep.png +0 -0
- package/resources/wechat-emoji/Smile.png +0 -0
- package/resources/wechat-emoji//344/272/262/344/272/262.png +0 -0
- package/resources/wechat-emoji//344/276/277/344/276/277.png +0 -0
- package/resources/wechat-emoji//345/201/267/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/202/262/346/205/242.png +0 -0
- package/resources/wechat-emoji//345/206/215/350/247/201.png +0 -0
- package/resources/wechat-emoji//345/207/213/350/260/242.png +0 -0
- package/resources/wechat-emoji//345/212/240/346/262/271.png +0 -0
- package/resources/wechat-emoji//345/213/276/345/274/225.png +0 -0
- package/resources/wechat-emoji//345/217/221/345/221/206.png +0 -0
- package/resources/wechat-emoji//345/217/221/346/200/222.png +0 -0
- package/resources/wechat-emoji//345/217/221/346/212/226.png +0 -0
- package/resources/wechat-emoji//345/217/257/346/200/234.png +0 -0
- package/resources/wechat-emoji//345/217/263/345/223/274/345/223/274.png +0 -0
- package/resources/wechat-emoji//345/217/271/346/260/224.png +0 -0
- package/resources/wechat-emoji//345/220/203/347/223/234.png +0 -0
- package/resources/wechat-emoji//345/220/210/345/215/201.png +0 -0
- package/resources/wechat-emoji//345/220/220.png +0 -0
- package/resources/wechat-emoji//345/221/262/347/211/231.png +0 -0
- package/resources/wechat-emoji//345/222/222/351/252/202.png +0 -0
- package/resources/wechat-emoji//345/222/226/345/225/241.png +0 -0
- package/resources/wechat-emoji//345/223/207.png +0 -0
- package/resources/wechat-emoji//345/225/244/351/205/222.png +0 -0
- package/resources/wechat-emoji//345/230/230.png +0 -0
- package/resources/wechat-emoji//345/230/264/345/224/207.png +0 -0
- package/resources/wechat-emoji//345/230/277/345/223/210.png +0 -0
- package/resources/wechat-emoji//345/233/247.png +0 -0
- package/resources/wechat-emoji//345/233/260.png +0 -0
- package/resources/wechat-emoji//345/235/217/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/244/247/345/223/255.png +0 -0
- package/resources/wechat-emoji//345/244/251/345/225/212.png +0 -0
- package/resources/wechat-emoji//345/244/252/351/230/263.png +0 -0
- package/resources/wechat-emoji//345/244/261/346/234/233.png +0 -0
- package/resources/wechat-emoji//345/245/270/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/245/275/347/232/204.png +0 -0
- package/resources/wechat-emoji//345/247/224/345/261/210.png +0 -0
- package/resources/wechat-emoji//345/256/263/347/276/236.png +0 -0
- package/resources/wechat-emoji//345/260/264/345/260/254.png +0 -0
- package/resources/wechat-emoji//345/272/206/347/245/235.png +0 -0
- package/resources/wechat-emoji//345/274/261.png +0 -0
- package/resources/wechat-emoji//345/274/272.png +0 -0
- package/resources/wechat-emoji//345/276/227/346/204/217.png +0 -0
- package/resources/wechat-emoji//345/276/256/347/254/221.png +0 -0
- package/resources/wechat-emoji//345/277/203/347/242/216.png +0 -0
- package/resources/wechat-emoji//345/277/253/345/223/255/344/272/206.png +0 -0
- package/resources/wechat-emoji//346/201/220/346/203/247.png +0 -0
- package/resources/wechat-emoji//346/202/240/351/227/262.png +0 -0
- package/resources/wechat-emoji//346/203/212/346/201/220.png +0 -0
- package/resources/wechat-emoji//346/203/212/350/256/266.png +0 -0
- package/resources/wechat-emoji//346/204/211/345/277/253.png +0 -0
- package/resources/wechat-emoji//346/206/250/347/254/221.png +0 -0
- package/resources/wechat-emoji//346/211/223/350/204/270.png +0 -0
- package/resources/wechat-emoji//346/212/223/347/213/202.png +0 -0
- package/resources/wechat-emoji//346/212/240/351/274/273.png +0 -0
- package/resources/wechat-emoji//346/212/261/346/213/263.png +0 -0
- package/resources/wechat-emoji//346/213/245/346/212/261.png +0 -0
- package/resources/wechat-emoji//346/213/263/345/244/264.png +0 -0
- package/resources/wechat-emoji//346/215/202/350/204/270.png +0 -0
- package/resources/wechat-emoji//346/217/241/346/211/213.png +0 -0
- package/resources/wechat-emoji//346/222/207/345/230/264.png +0 -0
- package/resources/wechat-emoji//346/223/246/346/261/227.png +0 -0
- package/resources/wechat-emoji//346/225/262/346/211/223.png +0 -0
- package/resources/wechat-emoji//346/227/240/350/257/255.png +0 -0
- package/resources/wechat-emoji//346/227/272/346/237/264.png +0 -0
- package/resources/wechat-emoji//346/231/225.png +0 -0
- package/resources/wechat-emoji//346/234/210/344/272/256.png +0 -0
- package/resources/wechat-emoji//346/234/272/346/231/272.png +0 -0
- package/resources/wechat-emoji//346/261/227.png +0 -0
- package/resources/wechat-emoji//346/265/201/346/263/252.png +0 -0
- package/resources/wechat-emoji//347/202/270/345/274/271.png +0 -0
- package/resources/wechat-emoji//347/203/237/350/212/261.png +0 -0
- package/resources/wechat-emoji//347/210/206/347/253/271.png +0 -0
- package/resources/wechat-emoji//347/210/261/345/277/203.png +0 -0
- package/resources/wechat-emoji//347/214/252/345/244/264.png +0 -0
- package/resources/wechat-emoji//347/216/253/347/221/260.png +0 -0
- package/resources/wechat-emoji//347/224/237/347/227/205.png +0 -0
- package/resources/wechat-emoji//347/226/221/351/227/256.png +0 -0
- package/resources/wechat-emoji//347/231/274.png +0 -0
- package/resources/wechat-emoji//347/231/275/347/234/274.png +0 -0
- package/resources/wechat-emoji//347/232/261/347/234/211.png +0 -0
- package/resources/wechat-emoji//347/235/241.png +0 -0
- package/resources/wechat-emoji//347/240/264/346/266/225/344/270/272/347/254/221.png +0 -0
- package/resources/wechat-emoji//347/244/274/347/211/251.png +0 -0
- package/resources/wechat-emoji//347/244/276/344/274/232/347/244/276/344/274/232.png +0 -0
- package/resources/wechat-emoji//347/246/217.png +0 -0
- package/resources/wechat-emoji//347/254/221/350/204/270.png +0 -0
- package/resources/wechat-emoji//347/272/242/345/214/205.png +0 -0
- package/resources/wechat-emoji//347/277/273/347/231/275/347/234/274.png +0 -0
- package/resources/wechat-emoji//350/200/266.png +0 -0
- package/resources/wechat-emoji//350/203/234/345/210/251.png +0 -0
- package/resources/wechat-emoji//350/204/270/347/272/242.png +0 -0
- package/resources/wechat-emoji//350/211/262.png +0 -0
- package/resources/wechat-emoji//350/213/246/346/266/251.png +0 -0
- package/resources/wechat-emoji//350/217/234/345/210/200.png +0 -0
- package/resources/wechat-emoji//350/233/213/347/263/225.png +0 -0
- package/resources/wechat-emoji//350/241/260.png +0 -0
- package/resources/wechat-emoji//350/243/202/345/274/200.png +0 -0
- package/resources/wechat-emoji//350/256/251/346/210/221/347/234/213/347/234/213.png +0 -0
- package/resources/wechat-emoji//350/260/203/347/232/256.png +0 -0
- package/resources/wechat-emoji//350/267/263/350/267/263.png +0 -0
- package/resources/wechat-emoji//350/275/254/345/234/210.png +0 -0
- package/resources/wechat-emoji//351/204/231/350/247/206.png +0 -0
- package/resources/wechat-emoji//351/227/255/345/230/264.png +0 -0
- package/resources/wechat-emoji//351/230/264/351/231/251.png +0 -0
- package/resources/wechat-emoji//351/232/276/350/277/207.png +0 -0
- package/resources/wechat-emoji//351/252/267/351/253/205.png +0 -0
- package/resources/wechat-emoji//351/274/223/346/216/214.png +0 -0
- package/scripts/_batch_link_sources.py +73 -73
- package/scripts/_utils.py +29 -0
- package/scripts/annual_report.py +796 -796
- package/scripts/auto_tag.py +128 -128
- package/scripts/biz_daily.py +210 -53
- package/scripts/chat_report.py +10 -0
- package/scripts/chat_stats.py +702 -702
- package/scripts/classify_daily.py +17 -11
- package/scripts/create_reading_notes.py +370 -370
- package/scripts/daily_stats.py +132 -0
- package/scripts/enrich_backlinks.py +163 -163
- package/scripts/export_chat_html.py +1704 -107
- package/scripts/extract_todos.py +445 -404
- package/scripts/fav_server.py +119 -12
- package/scripts/fix_topics.py +111 -111
- package/scripts/generate_ai_report.py +1 -1
- package/scripts/generate_html.py +597 -277
- package/scripts/inspection_records.py +354 -0
- package/scripts/mcp_bridge.py +398 -398
- package/scripts/nt_decrypt.py +702 -48
- package/scripts/pipeline.py +27 -19
- package/scripts/promote_all.py +312 -310
- package/scripts/promote_ideas.py +299 -297
- package/scripts/rag_chat.py +194 -191
- package/scripts/semantic_search.py +435 -431
- package/scripts/sync_fav.py +201 -146
- package/scripts/sync_weread.py +192 -192
- package/scripts/vault_rag.py +140 -139
- package/scripts/vault_search.py +141 -141
- package/scripts/wechat_emoji.py +132 -0
- package/scripts/wechat_emoticon.py +433 -0
- package/scripts/wechat_image.py +258 -0
- package/scripts/wechat_voice.py +327 -0
- package/src/core/dbPathService.ts +163 -18
- package/src/core/keyService.ts +137 -25
- package/src/core/ntCore.ts +215 -62
- package/src/core/sqlcipherCore.ts +17 -1
- package/src/core/wcdbCore.ts +3 -7
- package/src/services/assistantDaemon.ts +111 -0
- package/src/services/assistantMemory.ts +193 -0
- package/src/services/assistantPrivacy.ts +87 -0
- package/src/services/assistantRouting.ts +92 -0
- package/src/services/assistantService.ts +295 -0
- package/src/services/assistantTools.ts +549 -0
- package/src/services/chatService.ts +133 -4
- package/src/services/configService.ts +194 -6
- package/src/services/evidenceService.ts +129 -0
- package/src/services/exportService.ts +113 -48
- package/src/services/initKeyService.ts +83 -0
- package/src/services/messageContract.ts +77 -0
- package/src/services/messageQuery.ts +38 -0
- package/src/services/wechat-formatter.ts +348 -348
- package/src/services/wechatMessageService.ts +25 -3
- package/src/services/wereadService.ts +308 -308
- package/src/services/whitelistService.ts +209 -22
- package/src/types.ts +20 -0
- package/src/utils/dateRange.ts +58 -0
- package/src/utils/mcpSecurity.ts +39 -0
- package/src/utils/packageRoot.ts +14 -0
- package/src/utils/python.ts +73 -0
- package/src/utils/pythonProcessEnv.ts +45 -0
- package/src/utils/talkerUtils.ts +14 -2
- package/dist/src/utils/errors.d.ts +0 -25
- package/dist/src/utils/errors.d.ts.map +0 -1
- package/dist/src/utils/errors.js +0 -43
- package/dist/src/utils/errors.js.map +0 -1
- package/dist/src/utils/pythonRunner.d.ts +0 -27
- package/dist/src/utils/pythonRunner.d.ts.map +0 -1
- package/dist/src/utils/pythonRunner.js +0 -62
- package/dist/src/utils/pythonRunner.js.map +0 -1
- package/src/utils/errors.ts +0 -42
- package/src/utils/pythonRunner.ts +0 -81
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 三层记忆 (参考 LangGraph checkpointer + Mem0 范式):
|
|
3
|
+
* L1 工作记忆 最近 N 轮原文 (滑入滑出)
|
|
4
|
+
* L2 滚动摘要 滑出的旧轮次压缩成摘要, 跨窗口保留脉络
|
|
5
|
+
* L3 长期事实 从对话中提取的持久信息 (偏好/项目/关系), 注入系统提示
|
|
6
|
+
* 持久化: ~/.weflow-cli/assistant_memory.json
|
|
7
|
+
*/
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'
|
|
10
|
+
import os from 'os'
|
|
11
|
+
|
|
12
|
+
const MEMORY_FILE = join(os.homedir(), '.weflow-cli', 'assistant_memory.json')
|
|
13
|
+
|
|
14
|
+
const WORKING_MAX = 16 // 超过则最旧一半转摘要
|
|
15
|
+
const SUMMARY_MAX = 800
|
|
16
|
+
const FACTS_MAX = 30
|
|
17
|
+
const FACT_EXTRACT_EVERY = 6
|
|
18
|
+
|
|
19
|
+
export interface ChatTurn { role: 'user' | 'assistant'; content: string }
|
|
20
|
+
export interface Fact { content: string; ts: number }
|
|
21
|
+
|
|
22
|
+
interface UserMemory {
|
|
23
|
+
working: ChatTurn[]
|
|
24
|
+
summary: string
|
|
25
|
+
facts: Fact[]
|
|
26
|
+
turnCount: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** LLM 调用器抽象 (由 assistantService 注入, 避免循环依赖) */
|
|
30
|
+
export type LlmCaller = (messages: ChatTurn[], maxTokens?: number) => Promise<string>
|
|
31
|
+
|
|
32
|
+
export class AssistantMemory {
|
|
33
|
+
private states: Record<string, UserMemory> = {}
|
|
34
|
+
/** 本进程修改过的用户 (save 时只回写这些用户, 其余保留磁盘最新值) */
|
|
35
|
+
private dirtyUsers = new Set<string>()
|
|
36
|
+
|
|
37
|
+
constructor() { this.load() }
|
|
38
|
+
|
|
39
|
+
private normalize(s: any): UserMemory {
|
|
40
|
+
return {
|
|
41
|
+
working: Array.isArray(s.working) ? s.working : [],
|
|
42
|
+
summary: typeof s.summary === 'string' ? s.summary : '',
|
|
43
|
+
facts: Array.isArray(s.facts) ? s.facts : [],
|
|
44
|
+
turnCount: s.turnCount || 0,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private load(): void {
|
|
49
|
+
try {
|
|
50
|
+
if (existsSync(MEMORY_FILE)) {
|
|
51
|
+
const raw = JSON.parse(readFileSync(MEMORY_FILE, 'utf8'))
|
|
52
|
+
for (const [uid, s] of Object.entries<any>(raw)) {
|
|
53
|
+
this.states[uid] = this.normalize(s)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch { this.states = {} }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
save(): void {
|
|
60
|
+
try {
|
|
61
|
+
// 读改写合并: MCP server / assistant 守护进程并发写同一文件,
|
|
62
|
+
// 只回写本进程修改过的用户, 防止旧快照覆盖其他进程刚写入的数据 (同 configService 策略)
|
|
63
|
+
const merged: Record<string, UserMemory> = {}
|
|
64
|
+
try {
|
|
65
|
+
if (existsSync(MEMORY_FILE)) {
|
|
66
|
+
const disk = JSON.parse(readFileSync(MEMORY_FILE, 'utf8'))
|
|
67
|
+
for (const [uid, s] of Object.entries<any>(disk)) {
|
|
68
|
+
if (this.dirtyUsers.has(uid)) continue
|
|
69
|
+
merged[uid] = this.normalize(s)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch { /* 磁盘损坏 → 只落内存态 */ }
|
|
73
|
+
for (const uid of this.dirtyUsers) {
|
|
74
|
+
if (this.states[uid]) merged[uid] = this.states[uid]
|
|
75
|
+
}
|
|
76
|
+
const dir = join(os.homedir(), '.weflow-cli')
|
|
77
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
78
|
+
const tmp = `${MEMORY_FILE}.tmp`
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(merged), 'utf8')
|
|
80
|
+
renameSync(tmp, MEMORY_FILE)
|
|
81
|
+
this.states = merged
|
|
82
|
+
this.dirtyUsers.clear()
|
|
83
|
+
} catch { /* 持久化失败不阻断对话 */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private state(userId: string): UserMemory {
|
|
87
|
+
if (!this.states[userId]) {
|
|
88
|
+
this.states[userId] = { working: [], summary: '', facts: [], turnCount: 0 }
|
|
89
|
+
}
|
|
90
|
+
return this.states[userId]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
reset(userId: string): void {
|
|
94
|
+
delete this.states[userId]
|
|
95
|
+
this.dirtyUsers.add(userId)
|
|
96
|
+
this.save()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
addTurn(userId: string, role: 'user' | 'assistant', content: string): void {
|
|
100
|
+
this.dirtyUsers.add(userId)
|
|
101
|
+
const s = this.state(userId)
|
|
102
|
+
s.working.push({ role, content })
|
|
103
|
+
if (role === 'user') s.turnCount++
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** L1 窗口 (喂给 LLM 的近期原文) */
|
|
107
|
+
workingWindow(userId: string): ChatTurn[] {
|
|
108
|
+
return this.state(userId).working
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
summary(userId: string): string { return this.state(userId).summary }
|
|
112
|
+
facts(userId: string): Fact[] { return this.state(userId).facts }
|
|
113
|
+
userCount(): number { return Object.keys(this.states).length }
|
|
114
|
+
|
|
115
|
+
/** L2: 窗口溢出时把最旧一半压进摘要 */
|
|
116
|
+
async compressIfNeeded(userId: string, llm: LlmCaller): Promise<boolean> {
|
|
117
|
+
const s = this.state(userId)
|
|
118
|
+
if (s.working.length <= WORKING_MAX) return false
|
|
119
|
+
this.dirtyUsers.add(userId)
|
|
120
|
+
|
|
121
|
+
const out = s.working.slice(0, Math.floor(s.working.length / 2))
|
|
122
|
+
s.working = s.working.slice(out.length)
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const prompt = `将以下对话压缩为摘要,与已有摘要合并。保留:关键事实、决定、人名、时间、待办。输出不超过500字,直接输出摘要正文,不要任何前后缀。
|
|
126
|
+
|
|
127
|
+
[已有摘要]
|
|
128
|
+
${s.summary || '(无)'}
|
|
129
|
+
|
|
130
|
+
[新增对话]
|
|
131
|
+
${out.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.content}`).join('\n')}`
|
|
132
|
+
const merged = await llm([{ role: 'user', content: prompt }], 600)
|
|
133
|
+
s.summary = merged.trim().slice(0, SUMMARY_MAX)
|
|
134
|
+
} catch {
|
|
135
|
+
// LLM 失败时降级: 粗暴截断拼接, 保住不丢
|
|
136
|
+
const brief = out.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.content.slice(0, 40)}`).join(' / ')
|
|
137
|
+
s.summary = `${s.summary} ${brief}`.slice(-SUMMARY_MAX)
|
|
138
|
+
}
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** L3: 定期从最近对话提取持久事实 */
|
|
143
|
+
async extractFactsIfNeeded(userId: string, llm: LlmCaller): Promise<number> {
|
|
144
|
+
const s = this.state(userId)
|
|
145
|
+
if (s.turnCount === 0 || s.turnCount % FACT_EXTRACT_EVERY !== 0) return 0
|
|
146
|
+
const recent = s.working.slice(-FACT_EXTRACT_EVERY)
|
|
147
|
+
if (!recent.length) return 0
|
|
148
|
+
this.dirtyUsers.add(userId)
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const prompt = `从对话中提取关于用户的持久事实(背景/偏好/项目/人际关系/惯例)。每条不超过40字。只输出 JSON 字符串数组,最多3条,没有则输出 []。不要提取临时性内容。
|
|
152
|
+
|
|
153
|
+
[对话]
|
|
154
|
+
${recent.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.content}`).join('\n')}`
|
|
155
|
+
const raw = await llm([{ role: 'user', content: prompt }], 300)
|
|
156
|
+
const m = raw.match(/\[[\s\S]*\]/)
|
|
157
|
+
if (!m) return 0
|
|
158
|
+
const items: unknown[] = JSON.parse(m[0])
|
|
159
|
+
let added = 0
|
|
160
|
+
for (const item of items) {
|
|
161
|
+
if (typeof item !== 'string' || !item.trim()) continue
|
|
162
|
+
const fact = item.trim().slice(0, 60)
|
|
163
|
+
// 去重: 与既有事实包含关系判断
|
|
164
|
+
if (s.facts.some(f => f.content.includes(fact) || fact.includes(f.content))) continue
|
|
165
|
+
s.facts.push({ content: fact, ts: Date.now() })
|
|
166
|
+
added++
|
|
167
|
+
}
|
|
168
|
+
if (s.facts.length > FACTS_MAX) s.facts = s.facts.slice(-FACTS_MAX)
|
|
169
|
+
return added
|
|
170
|
+
} catch {
|
|
171
|
+
return 0
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** L3 检索: 关键词命中 (为未来向量检索预留同一接口) */
|
|
176
|
+
searchFacts(userId: string, keyword: string): Fact[] {
|
|
177
|
+
const kw = keyword.trim()
|
|
178
|
+
if (!kw) return []
|
|
179
|
+
return this.state(userId).facts.filter(f =>
|
|
180
|
+
f.content.includes(kw) || kw.split(/\s+/).some(w => w.length >= 2 && f.content.includes(w)))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** L3 写入 (save_memory 工具用, 立即生效) */
|
|
184
|
+
addFact(userId: string, content: string): boolean {
|
|
185
|
+
this.dirtyUsers.add(userId)
|
|
186
|
+
const s = this.state(userId)
|
|
187
|
+
if (s.facts.some(f => f.content.includes(content) || content.includes(f.content))) return false
|
|
188
|
+
s.facts.push({ content, ts: Date.now() })
|
|
189
|
+
if (s.facts.length > FACTS_MAX) s.facts = s.facts.slice(-FACTS_MAX)
|
|
190
|
+
this.save()
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 隐私关卡 — 本地/云端数据出境的唯一检查点。
|
|
3
|
+
*
|
|
4
|
+
* 原则:
|
|
5
|
+
* - 数据库查询、记忆存储、工具执行全部在本机 (可信区)
|
|
6
|
+
* - 只有经 redact() 处理后的文本才允许发往云端 LLM
|
|
7
|
+
* - 本地推理引擎 (Ollama 等) 不出境, 跳过脱敏以保留完整能力
|
|
8
|
+
*
|
|
9
|
+
* 模式 (config: assistantPrivacy):
|
|
10
|
+
* - open 不脱敏 (自担风险)
|
|
11
|
+
* - balanced 默认; 工具输出中的 PII (电话/证件/邮箱/密钥/链接) 打码
|
|
12
|
+
* - strict balanced 基础上, 第三方聊天正文不出境 (仅保留时间/方向/类型)
|
|
13
|
+
*/
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
import { existsSync, mkdirSync, appendFileSync } from 'fs'
|
|
16
|
+
import os from 'os'
|
|
17
|
+
import { configService } from './configService.js'
|
|
18
|
+
|
|
19
|
+
export type PrivacyMode = 'open' | 'balanced' | 'strict'
|
|
20
|
+
|
|
21
|
+
const AUDIT_FILE = join(os.homedir(), '.weflow-cli', 'assistant_audit.log')
|
|
22
|
+
|
|
23
|
+
interface Rule { re: RegExp; mask: string }
|
|
24
|
+
|
|
25
|
+
const PII_RULES: Rule[] = [
|
|
26
|
+
{ re: /(?<!\d)1[3-9]\d{9}(?!\d)/g, mask: '[电话]' },
|
|
27
|
+
{ re: /(?<!\d)\d{17}[\dXx](?!\d)/g, mask: '[证件号]' },
|
|
28
|
+
{ re: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, mask: '[邮箱]' },
|
|
29
|
+
{ re: /sk-[a-zA-Z0-9]{16,}/g, mask: '[密钥]' },
|
|
30
|
+
{ re: /https?:\/\/\S+/g, mask: '[链接]' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export function redactText(text: string, mode: PrivacyMode, localInference: boolean): { safe: string; redactions: number } {
|
|
34
|
+
if (mode === 'open' || localInference) {
|
|
35
|
+
return { safe: text, redactions: 0 }
|
|
36
|
+
}
|
|
37
|
+
let count = 0
|
|
38
|
+
let safe = text
|
|
39
|
+
for (const { re, mask } of PII_RULES) {
|
|
40
|
+
safe = safe.replace(re, () => { count++; return mask })
|
|
41
|
+
}
|
|
42
|
+
return { safe, redactions: count }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function maskMessageBodyText(body: string, mode: PrivacyMode, localInference: boolean): string {
|
|
46
|
+
if (mode !== 'strict' || localInference) return body
|
|
47
|
+
return `[内容${body.length}字已按严格模式屏蔽]`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class PrivacyGate {
|
|
51
|
+
mode(): PrivacyMode {
|
|
52
|
+
const m = configService.get('assistantPrivacy')
|
|
53
|
+
return m === 'open' || m === 'strict' ? m : 'balanced'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 本地引擎数据不出机器, 无需脱敏 */
|
|
57
|
+
isLocalInference(): boolean {
|
|
58
|
+
const engine = configService.get('aiEngine')
|
|
59
|
+
return engine === 'ollama' || engine === 'lmstudio' || engine === 'local'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 出境前脱敏。仅用于「工具从数据库挖出的第三方数据」;
|
|
64
|
+
* 用户自己输入的话按原意发送 (用户对自己的话有处置权)。
|
|
65
|
+
*/
|
|
66
|
+
redact(text: string): { safe: string; redactions: number } {
|
|
67
|
+
return redactText(text, this.mode(), this.isLocalInference())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** strict 模式: 第三方聊天正文不出境, 只保留元数据形态 */
|
|
71
|
+
maskMessageBody(body: string): string {
|
|
72
|
+
return maskMessageBodyText(body, this.mode(), this.isLocalInference())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 审计日志 — 只记事件与字节量, 绝不记内容 */
|
|
76
|
+
audit(event: string, bytes = 0, extra = ''): void {
|
|
77
|
+
try {
|
|
78
|
+
const dir = join(os.homedir(), '.weflow-cli')
|
|
79
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
80
|
+
const line = `[${new Date().toLocaleString('zh-CN')}] ${event}` +
|
|
81
|
+
(bytes ? ` ${bytes}B` : '') + (extra ? ` ${extra}` : '')
|
|
82
|
+
appendFileSync(AUDIT_FILE, line + '\n', 'utf8')
|
|
83
|
+
} catch { /* 审计失败不阻断主流程 */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const privacyGate = new PrivacyGate()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { WechatInboundMessage } from '../types.js'
|
|
2
|
+
|
|
3
|
+
export type AssistantAccessReason =
|
|
4
|
+
| 'allowed'
|
|
5
|
+
| 'direct-not-whitelisted'
|
|
6
|
+
| 'group-not-whitelisted'
|
|
7
|
+
| 'group-sender-missing'
|
|
8
|
+
| 'group-sender-not-whitelisted'
|
|
9
|
+
| 'group-mention-required'
|
|
10
|
+
|
|
11
|
+
export interface AssistantAccessPolicy {
|
|
12
|
+
directWhitelist: string
|
|
13
|
+
groupWhitelist: string
|
|
14
|
+
requireGroupMention: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AssistantAccessDecision {
|
|
18
|
+
allowed: boolean
|
|
19
|
+
reason: AssistantAccessReason
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseList(value: string): Set<string> {
|
|
23
|
+
return new Set(value.split(/[,;\s]+/).map(item => item.trim()).filter(Boolean))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function evaluateAssistantAccess(
|
|
27
|
+
message: Pick<WechatInboundMessage, 'conversationType' | 'conversationId' | 'senderId' | 'mentionedBot'>,
|
|
28
|
+
policy: AssistantAccessPolicy,
|
|
29
|
+
): AssistantAccessDecision {
|
|
30
|
+
const directWhitelist = parseList(policy.directWhitelist)
|
|
31
|
+
if (message.conversationType !== 'group') {
|
|
32
|
+
return directWhitelist.has(message.senderId)
|
|
33
|
+
? { allowed: true, reason: 'allowed' }
|
|
34
|
+
: { allowed: false, reason: 'direct-not-whitelisted' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!parseList(policy.groupWhitelist).has(message.conversationId)) {
|
|
38
|
+
return { allowed: false, reason: 'group-not-whitelisted' }
|
|
39
|
+
}
|
|
40
|
+
if (!message.senderId) return { allowed: false, reason: 'group-sender-missing' }
|
|
41
|
+
if (!directWhitelist.has(message.senderId)) {
|
|
42
|
+
return { allowed: false, reason: 'group-sender-not-whitelisted' }
|
|
43
|
+
}
|
|
44
|
+
if (policy.requireGroupMention && !message.mentionedBot) {
|
|
45
|
+
return { allowed: false, reason: 'group-mention-required' }
|
|
46
|
+
}
|
|
47
|
+
return { allowed: true, reason: 'allowed' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function firstString(record: Record<string, any>, keys: string[]): string {
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
const value = record[key]
|
|
53
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
54
|
+
}
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isBotMentioned(record: Record<string, any>, botId: string): boolean {
|
|
59
|
+
if (record.is_at_bot === true || record.is_mentioned === true || record.mentioned_bot === true) return true
|
|
60
|
+
if (!botId) return false
|
|
61
|
+
for (const key of ['at_user_ids', 'mentioned_user_ids', 'mention_list']) {
|
|
62
|
+
const value = record[key]
|
|
63
|
+
if (Array.isArray(value) && value.some(item => String(item) === botId)) return true
|
|
64
|
+
}
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Accept group routing only when the upstream payload explicitly identifies a group.
|
|
70
|
+
* Unknown payloads remain direct messages so a platform change cannot silently enable groups.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveInboundRouting(record: Record<string, any>, botId = ''): Pick<WechatInboundMessage, 'conversationType' | 'conversationId' | 'senderId' | 'mentionedBot'> {
|
|
73
|
+
const fromUserId = firstString(record, ['from_user_id', 'fromUserId'])
|
|
74
|
+
const groupId = firstString(record, ['chatroom_id', 'chat_room_id', 'group_id', 'groupId', 'room_id', 'roomId'])
|
|
75
|
+
|| (fromUserId.endsWith('@chatroom') ? fromUserId : '')
|
|
76
|
+
if (!groupId) {
|
|
77
|
+
return {
|
|
78
|
+
conversationType: 'direct',
|
|
79
|
+
conversationId: fromUserId,
|
|
80
|
+
senderId: fromUserId,
|
|
81
|
+
mentionedBot: false,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const explicitSender = firstString(record, ['sender_id', 'sender_user_id', 'member_id', 'from_member_id'])
|
|
86
|
+
return {
|
|
87
|
+
conversationType: 'group',
|
|
88
|
+
conversationId: groupId,
|
|
89
|
+
senderId: explicitSender || (fromUserId !== groupId ? fromUserId : ''),
|
|
90
|
+
mentionedBot: isBotMentioned(record, botId),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 第二大脑助手 — 持久化 Agent (主流框架标准架构)。
|
|
3
|
+
*
|
|
4
|
+
* 架构 (参考 LangGraph Agent Loop + Mem0 三层记忆):
|
|
5
|
+
* 微信(iLink) ⇄ 常驻进程
|
|
6
|
+
* ├─ ReAct 循环: LLM 决策 → 本地工具执行 → 观察回填 → 直到产出回答
|
|
7
|
+
* ├─ 三层记忆: L1 工作窗口 / L2 滚动摘要 / L3 长期事实 (本地 JSON)
|
|
8
|
+
* └─ 隐私关卡: 工具结果脱敏后才出境到云端 LLM (本地引擎则完全不出境)
|
|
9
|
+
*/
|
|
10
|
+
import { WechatMessageService } from './wechatMessageService.js'
|
|
11
|
+
import { configService } from './configService.js'
|
|
12
|
+
import { AssistantMemory, type ChatTurn } from './assistantMemory.js'
|
|
13
|
+
import { privacyGate } from './assistantPrivacy.js'
|
|
14
|
+
import { TOOL_DEFS, executeTool } from './assistantTools.js'
|
|
15
|
+
import { appendLog } from './assistantDaemon.js'
|
|
16
|
+
import type { Message, WechatInboundMessage } from '../types.js'
|
|
17
|
+
import { buildEvidenceReviewInput } from './evidenceService.js'
|
|
18
|
+
import { evaluateAssistantAccess } from './assistantRouting.js'
|
|
19
|
+
|
|
20
|
+
const MAX_TOOL_ROUNDS = 6
|
|
21
|
+
/** 每日 LLM 处理上限 (护栏: 防 bug 死循环/异常流量烧钱; 0 = 不限制) */
|
|
22
|
+
const DAILY_LIMIT = 100
|
|
23
|
+
|
|
24
|
+
const BASE_PROMPT = `你是"第二大脑", 运行在用户自己的电脑上, 通过微信与用户对话。
|
|
25
|
+
你可以调用工具查询用户本地微信数据(会话/聊天记录/收藏), 以及读写关于用户的长期记忆。
|
|
26
|
+
|
|
27
|
+
行为准则:
|
|
28
|
+
- 涉及用户数据的问题, 先调工具查证再回答, 不要编造
|
|
29
|
+
- 用户让你记住某事时, 用 save_memory 工具保存
|
|
30
|
+
- 回复用微信聊天风格, 简洁, 不用 markdown 符号
|
|
31
|
+
- 数据不足时直说需要什么, 不要瞎猜
|
|
32
|
+
- 回复控制在 300 字内, 列表类可放宽`
|
|
33
|
+
|
|
34
|
+
interface ApiMessage {
|
|
35
|
+
role: 'system' | 'user' | 'assistant' | 'tool'
|
|
36
|
+
content: string
|
|
37
|
+
tool_calls?: any[]
|
|
38
|
+
tool_call_id?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AssistantService {
|
|
42
|
+
private svc: WechatMessageService | null = null
|
|
43
|
+
private memory = new AssistantMemory()
|
|
44
|
+
private running = false
|
|
45
|
+
/** 消息串行队列: 保证 handleMessage 不并发交错 (记忆窗口一致性) */
|
|
46
|
+
private queue: Promise<void> = Promise.resolve()
|
|
47
|
+
/** 每日用量计数 (内存态, 重启重置 — 配额护栏防烧钱, 无需持久精确) */
|
|
48
|
+
private dailyCount = 0
|
|
49
|
+
private dailyDate = new Date().toDateString()
|
|
50
|
+
|
|
51
|
+
private engineConfig(): { url: string; model: string; key: string | null; local: boolean } {
|
|
52
|
+
const engine = String(configService.get('aiEngine') || 'deepseek')
|
|
53
|
+
if (engine === 'ollama' || engine === 'lmstudio') {
|
|
54
|
+
const port = engine === 'lmstudio' ? 1234 : 11434
|
|
55
|
+
const model = String(configService.get('localModel') || 'llama3')
|
|
56
|
+
return { url: `http://localhost:${port}/v1`, model, key: null, local: true }
|
|
57
|
+
}
|
|
58
|
+
// 自定义 OpenAI 兼容端点 (中转站) 优先
|
|
59
|
+
const baseUrl = String(configService.get('aiBaseUrl') || '').trim().replace(/\/+$/, '')
|
|
60
|
+
if (baseUrl) {
|
|
61
|
+
const model = String(configService.get('aiModel') || 'deepseek-chat')
|
|
62
|
+
return { url: baseUrl, model, key: configService.get('deepseekApiKey'), local: false }
|
|
63
|
+
}
|
|
64
|
+
return { url: 'https://api.deepseek.com/v1', model: 'deepseek-chat', key: configService.get('deepseekApiKey'), local: false }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Direct messages require an explicit sender allowlist. Groups are disabled
|
|
69
|
+
* until the upstream provides an explicit group ID, and then require all
|
|
70
|
+
* three controls: group allowlist, sender allowlist, and an @ mention.
|
|
71
|
+
*/
|
|
72
|
+
private accessDecision(message: WechatInboundMessage): ReturnType<typeof evaluateAssistantAccess> {
|
|
73
|
+
return evaluateAssistantAccess(message, {
|
|
74
|
+
directWhitelist: String(configService.get('assistantWhitelist') || ''),
|
|
75
|
+
groupWhitelist: String(configService.get('assistantGroupWhitelist') || ''),
|
|
76
|
+
requireGroupMention: configService.get('assistantGroupRequireMention') !== 'false',
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private dailyQuotaLeft(): number {
|
|
81
|
+
const today = new Date().toDateString()
|
|
82
|
+
if (today !== this.dailyDate) { this.dailyDate = today; this.dailyCount = 0 }
|
|
83
|
+
return DAILY_LIMIT - this.dailyCount
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 底层 LLM 调用 (含出境审计) */
|
|
87
|
+
private async callLLM(messages: ApiMessage[], tools?: any[], maxTokens = 800): Promise<any> {
|
|
88
|
+
const { url, model, key, local } = this.engineConfig()
|
|
89
|
+
if (!key && !local) {
|
|
90
|
+
throw new Error('未配置 LLM (config set deepseekApiKey 或切换 ollama)')
|
|
91
|
+
}
|
|
92
|
+
const body: Record<string, unknown> = { model, messages, max_tokens: maxTokens, temperature: 0.4 }
|
|
93
|
+
if (tools?.length) { body.tools = tools; body.tool_choice = 'auto' }
|
|
94
|
+
|
|
95
|
+
const res = await fetch(`${url}/chat/completions`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
signal: AbortSignal.timeout(120_000),
|
|
103
|
+
})
|
|
104
|
+
privacyGate.audit('CLOUD_CALL', JSON.stringify(body).length,
|
|
105
|
+
local ? 'local' : `${url.split('//')[1].split('/')[0]}`)
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const t = await res.text().catch(() => '')
|
|
108
|
+
throw new Error(`LLM ${res.status}: ${t.slice(0, 120)}`)
|
|
109
|
+
}
|
|
110
|
+
return res.json()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 在明确授权后分析本地聊天,输出证据线索而非法律结论。 */
|
|
114
|
+
async reviewEvidence(talker: string, messages: Message[], allowCloud = false): Promise<{ text: string; localInference: boolean; redactions: number }> {
|
|
115
|
+
const { local } = this.engineConfig()
|
|
116
|
+
if (!local && !allowCloud) {
|
|
117
|
+
throw new Error('默认禁止将聊天正文发送到云端;请切换本地模型,或明确使用 --allow-cloud')
|
|
118
|
+
}
|
|
119
|
+
const input = buildEvidenceReviewInput(talker, messages, privacyGate.mode(), local)
|
|
120
|
+
const response = await this.callLLM([
|
|
121
|
+
{
|
|
122
|
+
role: 'system',
|
|
123
|
+
content: `你是电子数据整理助手,不是律师。只根据提供的聊天原文进行线索整理,不判断“必然违法”、不判断法院必然采信,也不编造法律条文或事实。
|
|
124
|
+
请用中文输出以下结构:
|
|
125
|
+
## 疑似争议线索
|
|
126
|
+
逐项引用消息ID,区分原文事实和你的待核查推测。
|
|
127
|
+
## 可能涉及的法律主题
|
|
128
|
+
只写宽泛主题,例如合同履行、借贷、劳动争议、名誉侵权、隐私或个人信息;不作定性结论。
|
|
129
|
+
## 还需要核验的材料
|
|
130
|
+
列出原设备、完整上下文、转账/合同/平台记录、身份归属等缺口。
|
|
131
|
+
## 保全建议
|
|
132
|
+
提醒保留原始设备、原始数据、完整对话和导出记录。
|
|
133
|
+
如果信息不足,明确写“信息不足”,不要补全。${local ? '' : '\n当前不是本地推理:不要复述已被隐私模式屏蔽的聊天正文。'}`,
|
|
134
|
+
},
|
|
135
|
+
{ role: 'user', content: input.transcript },
|
|
136
|
+
], undefined, 1800)
|
|
137
|
+
return {
|
|
138
|
+
text: (response.choices?.[0]?.message?.content || '信息不足,未生成分析。').trim(),
|
|
139
|
+
localInference: local,
|
|
140
|
+
redactions: input.redactions,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 组装系统提示: 基础人格 + L2 摘要 + L3 事实 */
|
|
145
|
+
private buildSystemPrompt(userId: string): string {
|
|
146
|
+
const parts = [BASE_PROMPT]
|
|
147
|
+
const summary = this.memory.summary(userId)
|
|
148
|
+
if (summary) parts.push(`\n[此前对话摘要]\n${summary}`)
|
|
149
|
+
const facts = this.memory.facts(userId)
|
|
150
|
+
if (facts.length) {
|
|
151
|
+
parts.push(`\n[关于用户的长期记忆]\n${facts.map(f => `· ${f.content}`).join('\n')}`)
|
|
152
|
+
}
|
|
153
|
+
return parts.join('\n')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 单条消息处理: 指令路由 → ReAct 循环 → 记忆更新 */
|
|
157
|
+
async handleMessage(userId: string, text: string, kind: string): Promise<string> {
|
|
158
|
+
if (kind !== 'text') return '目前只支持文字消息哦'
|
|
159
|
+
|
|
160
|
+
const t = text.trim()
|
|
161
|
+
|
|
162
|
+
if (t === '帮助' || t.toLowerCase() === 'help') {
|
|
163
|
+
return ['🧠 第二大脑 Agent', '',
|
|
164
|
+
'直接用自然语言问, 我会自动查本地数据回答:',
|
|
165
|
+
'「我最近都在忙什么?」',
|
|
166
|
+
'「总结我和XX的聊天」',
|
|
167
|
+
'「收藏里有哪些AI文章?」',
|
|
168
|
+
'「记住: 我的项目叫weflow-cli」',
|
|
169
|
+
'', '记忆: 三层 (窗口/摘要/长期事实), 重启不丢',
|
|
170
|
+
'隐私: 数据库不出本机, 出境内容自动脱敏',
|
|
171
|
+
'', '指令: 记忆 | 清空记忆'].join('\n')
|
|
172
|
+
}
|
|
173
|
+
if (t === '清空记忆' || t === '重置') {
|
|
174
|
+
this.memory.reset(userId)
|
|
175
|
+
privacyGate.audit('MEMORY_RESET', 0, userId.slice(0, 8))
|
|
176
|
+
return '✓ 对话记忆已清空, 重新开始'
|
|
177
|
+
}
|
|
178
|
+
if (t === '记忆') {
|
|
179
|
+
const facts = this.memory.facts(userId)
|
|
180
|
+
const s = this.memory.summary(userId)
|
|
181
|
+
const lines = [`工作窗口: ${this.memory.workingWindow(userId).length} 条`]
|
|
182
|
+
lines.push(`滚动摘要: ${s ? `${s.length}字` : '无'}`)
|
|
183
|
+
lines.push(`长期事实: ${facts.length} 条`)
|
|
184
|
+
if (facts.length) lines.push(facts.slice(-5).map(f => `· ${f.content}`).join('\n'))
|
|
185
|
+
lines.push(`隐私模式: ${privacyGate.mode()}${privacyGate.isLocalInference() ? ' (本地推理, 不出境)' : ''}`)
|
|
186
|
+
lines.push(`今日用量: ${this.dailyCount}/${DAILY_LIMIT}`)
|
|
187
|
+
return lines.join('\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// === ReAct 主循环 ===
|
|
191
|
+
this.memory.addTurn(userId, 'user', t)
|
|
192
|
+
const messages: ApiMessage[] = [
|
|
193
|
+
{ role: 'system', content: this.buildSystemPrompt(userId) },
|
|
194
|
+
...this.memory.workingWindow(userId).map(turn => ({ role: turn.role, content: turn.content })),
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
let reply = ''
|
|
198
|
+
let toolCalls = 0
|
|
199
|
+
try {
|
|
200
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
201
|
+
const data = await this.callLLM(messages, TOOL_DEFS)
|
|
202
|
+
const msg = data.choices?.[0]?.message
|
|
203
|
+
if (!msg) throw new Error('LLM 返回为空')
|
|
204
|
+
|
|
205
|
+
if (msg.tool_calls?.length) {
|
|
206
|
+
messages.push({ role: 'assistant', content: msg.content || '', tool_calls: msg.tool_calls })
|
|
207
|
+
for (const tc of msg.tool_calls) {
|
|
208
|
+
toolCalls++
|
|
209
|
+
let args: Record<string, any> = {}
|
|
210
|
+
try { args = JSON.parse(tc.function?.arguments || '{}') } catch { /* 参数容错 */ }
|
|
211
|
+
const raw = await executeTool(tc.function?.name || '', args, { userId, memory: this.memory })
|
|
212
|
+
const { safe, redactions } = privacyGate.redact(raw)
|
|
213
|
+
privacyGate.audit(`TOOL:${tc.function?.name}`, raw.length, redactions ? `redacted=${redactions}` : '')
|
|
214
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: safe })
|
|
215
|
+
}
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
reply = (msg.content || '').trim() || '(空回复)'
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
if (!reply) reply = '(这轮处理太复杂了, 换个问法试试?)'
|
|
222
|
+
} catch (e: any) {
|
|
223
|
+
reply = `❌ 大脑暂时离线: ${e.message?.slice(0, 100)}\n(本地指令仍可用: 发「帮助」)`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// === 记忆更新 (L2 压缩 + L3 提取) ===
|
|
227
|
+
this.memory.addTurn(userId, 'assistant', reply)
|
|
228
|
+
const llm = (msgs: ChatTurn[], maxTokens?: number) =>
|
|
229
|
+
this.callLLM(msgs as ApiMessage[], undefined, maxTokens).then(
|
|
230
|
+
(d: any) => d.choices?.[0]?.message?.content || '')
|
|
231
|
+
try { await this.memory.compressIfNeeded(userId, llm) } catch { /* 压缩失败不阻断 */ }
|
|
232
|
+
try { await this.memory.extractFactsIfNeeded(userId, llm) } catch { /* 提取失败不阻断 */ }
|
|
233
|
+
this.memory.save()
|
|
234
|
+
privacyGate.audit('TURN_DONE', reply.length, `tools=${toolCalls}`)
|
|
235
|
+
return reply
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 启动常驻监听 */
|
|
239
|
+
async start(onLog?: (line: string) => void): Promise<void> {
|
|
240
|
+
const token = configService.get('wechatOcToken')
|
|
241
|
+
if (!token) throw new Error('未登录消息通道, 先运行 weflow-cli login-wechat')
|
|
242
|
+
|
|
243
|
+
this.svc = new WechatMessageService({ token })
|
|
244
|
+
this.running = true
|
|
245
|
+
const { local } = this.engineConfig()
|
|
246
|
+
onLog?.(`助手已启动 (bot: ${configService.get('wechatOcAccountId')}, ` +
|
|
247
|
+
`引擎: ${local ? '本地' : '云端'}, 记忆用户数: ${this.memory.userCount()})`)
|
|
248
|
+
|
|
249
|
+
this.svc.onMessage((msg) => {
|
|
250
|
+
// 串行入队: 并发到达的消息按顺序处理, 记忆窗口不交错
|
|
251
|
+
this.queue = this.queue.then(async () => {
|
|
252
|
+
if (!this.running) return
|
|
253
|
+
const access = this.accessDecision(msg)
|
|
254
|
+
const sessionId = msg.conversationId
|
|
255
|
+
|
|
256
|
+
if (!access.allowed) {
|
|
257
|
+
privacyGate.audit(`DENY_${access.reason.toUpperCase().replace(/-/g, '_')}`, 0, sessionId.slice(0, 12))
|
|
258
|
+
onLog?.(` → 拒绝: ${sessionId.slice(0, 12)}… ${access.reason} (未回复, 不耗 LLM)`)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
if (msg.messageKind !== 'text') {
|
|
262
|
+
onLog?.(` → 忽略非文本消息 (${msg.messageKind})`)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (this.dailyQuotaLeft() <= 0) {
|
|
266
|
+
privacyGate.audit('DENY_DAILY_LIMIT', this.dailyCount)
|
|
267
|
+
appendLog(`[配额] 今日 ${DAILY_LIMIT} 条上限已用尽, 拒绝: ${msg.messageStr.slice(0, 30)}`)
|
|
268
|
+
await this.svc!.sendText(sessionId, '今日额度已用完, 明天再来吧').catch(() => {})
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
onLog?.(`[${new Date().toLocaleTimeString('zh-CN')}] ${sessionId.slice(0, 12)}…: ${msg.messageStr.slice(0, 40)}`)
|
|
273
|
+
try {
|
|
274
|
+
const reply = await this.handleMessage(sessionId, msg.messageStr, msg.messageKind)
|
|
275
|
+
this.dailyCount++
|
|
276
|
+
const ok = await this.svc!.sendText(sessionId, reply)
|
|
277
|
+
onLog?.(` → ${ok ? `已回复 (${reply.length}字, 今日 ${this.dailyCount}/${DAILY_LIMIT})` : '回复失败'}`)
|
|
278
|
+
} catch (e: any) {
|
|
279
|
+
onLog?.(` → 处理异常: ${e.message}`)
|
|
280
|
+
appendLog(`[${new Date().toLocaleTimeString('zh-CN')}] 异常: ${e.message}`)
|
|
281
|
+
}
|
|
282
|
+
}).catch((e: any) => appendLog(`队列异常: ${e.message}`))
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
await this.svc.startPolling()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
stop(): void {
|
|
289
|
+
this.running = false
|
|
290
|
+
this.svc?.stop()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
isRunning(): boolean { return this.running }
|
|
294
|
+
memoryUserCount(): number { return this.memory.userCount() }
|
|
295
|
+
}
|