weflow-cli 1.7.0 → 1.8.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.
Files changed (200) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +2108 -2
  3. package/OPERATIONS.md +810 -1
  4. package/README.en.md +96 -10
  5. package/README.md +34 -9
  6. package/bin/weflow-cli.ts +937 -28
  7. package/dist/bin/weflow-cli.js +955 -28
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +25 -6
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +599 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +217 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1116 -59
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/services/wereadService.d.ts +33 -6
  100. package/dist/src/services/wereadService.d.ts.map +1 -1
  101. package/dist/src/services/wereadService.js +27 -2
  102. package/dist/src/services/wereadService.js.map +1 -1
  103. package/dist/src/utils/atomicWrite.d.ts +4 -0
  104. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  105. package/dist/src/utils/atomicWrite.js +60 -0
  106. package/dist/src/utils/atomicWrite.js.map +1 -0
  107. package/dist/src/utils/dateRange.d.ts +15 -0
  108. package/dist/src/utils/dateRange.d.ts.map +1 -1
  109. package/dist/src/utils/dateRange.js +47 -0
  110. package/dist/src/utils/dateRange.js.map +1 -1
  111. package/dist/src/utils/text.d.ts +18 -0
  112. package/dist/src/utils/text.d.ts.map +1 -0
  113. package/dist/src/utils/text.js +20 -0
  114. package/dist/src/utils/text.js.map +1 -0
  115. package/docs/AI_INTERFACE.md +2 -0
  116. package/docs/BRANCHES.md +13 -4
  117. package/docs/DECISIONS.md +1424 -0
  118. package/docs/EXTENDING.md +187 -0
  119. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  120. package/docs/MCP.md +23 -4
  121. package/docs/PROJECT_STATE.md +210 -9
  122. package/docs/SETUP.md +22 -0
  123. package/docs/SYNC_CONTRACT.md +31 -3
  124. package/mcp-server/index.ts +681 -657
  125. package/package.json +84 -83
  126. package/resources/panel/ball-position.cjs +153 -0
  127. package/resources/panel/index.html +46 -0
  128. package/resources/panel/main.cjs +483 -0
  129. package/resources/panel/mascot-happy.png +0 -0
  130. package/resources/panel/mascot-sorry.png +0 -0
  131. package/resources/panel/mascot-thinking.png +0 -0
  132. package/resources/panel/mascot-tired.png +0 -0
  133. package/resources/panel/mascot.png +0 -0
  134. package/resources/panel/package.json +7 -0
  135. package/resources/panel/panel.css +415 -0
  136. package/resources/panel/preload.cjs +79 -0
  137. package/resources/panel/quick-menu.cjs +197 -0
  138. package/resources/panel/renderer.js +470 -0
  139. package/resources/panel/tray-menu.cjs +40 -0
  140. package/resources/panel/tray.png +0 -0
  141. package/scripts/_utils.py +425 -54
  142. package/scripts/annual_report.py +4 -2
  143. package/scripts/article_notes.py +423 -0
  144. package/scripts/backfill_articles.py +438 -0
  145. package/scripts/biz_daily.py +590 -105
  146. package/scripts/chat_notes.py +470 -0
  147. package/scripts/classify_daily.py +10 -18
  148. package/scripts/compile_wiki.py +1060 -46
  149. package/scripts/create_reading_notes.py +199 -14
  150. package/scripts/decide.py +253 -0
  151. package/scripts/draft_reply.py +671 -0
  152. package/scripts/export_chat_html.py +35 -33
  153. package/scripts/extract_todos.py +31 -5
  154. package/scripts/fav_notes.py +318 -0
  155. package/scripts/fix_topics.py +2 -1
  156. package/scripts/generate_ai_report.py +214 -9
  157. package/scripts/generate_html.py +18 -3
  158. package/scripts/generate_review.py +2 -2
  159. package/scripts/jev_client.py +294 -0
  160. package/scripts/jev_probe.py +440 -0
  161. package/scripts/nt_common.py +82 -0
  162. package/scripts/nt_decrypt.py +1755 -1460
  163. package/scripts/pipeline.py +27 -2
  164. package/scripts/promote_ideas.py +19 -4
  165. package/scripts/quality_eval.py +707 -0
  166. package/scripts/rag_chat.py +4 -4
  167. package/scripts/read_image.py +187 -0
  168. package/scripts/reply_debt.py +488 -0
  169. package/scripts/route_cards.py +453 -0
  170. package/scripts/semantic_search.py +208 -49
  171. package/scripts/user_notes.py +315 -0
  172. package/scripts/vault_rag.py +13 -7
  173. package/scripts/vault_search.py +21 -4
  174. package/scripts/wiki_lint.py +429 -0
  175. package/src/core/appMsgFormat.ts +117 -0
  176. package/src/core/ntCore.ts +27 -2
  177. package/src/core/sqlcipherCore.ts +5 -61
  178. package/src/core/wcdbCore.ts +6 -2
  179. package/src/panel/client.ts +49 -0
  180. package/src/panel/endpoint.ts +144 -0
  181. package/src/panel/launch.ts +101 -0
  182. package/src/panel/server.ts +424 -0
  183. package/src/panel/userId.ts +72 -0
  184. package/src/services/assistantDaemon.ts +57 -9
  185. package/src/services/assistantEval.ts +611 -0
  186. package/src/services/assistantMemory.ts +413 -57
  187. package/src/services/assistantPrivacy.ts +25 -6
  188. package/src/services/assistantRouter.ts +191 -0
  189. package/src/services/assistantService.ts +681 -74
  190. package/src/services/assistantTools.ts +1149 -58
  191. package/src/services/assistantTrace.ts +175 -0
  192. package/src/services/chatService.ts +6 -2
  193. package/src/services/configService.ts +403 -385
  194. package/src/services/pythonBridge.ts +139 -0
  195. package/src/services/syncService.ts +37 -9
  196. package/src/services/wechatMessageService.ts +73 -7
  197. package/src/services/wereadService.ts +43 -7
  198. package/src/utils/atomicWrite.ts +56 -0
  199. package/src/utils/dateRange.ts +46 -0
  200. package/src/utils/text.ts +20 -0
@@ -1,23 +1,73 @@
1
1
  /**
2
- * 三层记忆 (参考 LangGraph checkpointer + Mem0 范式):
3
- * L1 工作记忆 最近 N 轮原文 (滑入滑出)
4
- * L2 滚动摘要 滑出的旧轮次压缩成摘要, 跨窗口保留脉络
5
- * L3 长期事实 从对话中提取的持久信息 (偏好/项目/关系), 注入系统提示
6
- * 持久化: ~/.weflow-cli/assistant_memory.json
2
+ * 三层记忆 (参考 LangGraph checkpointer + Mem0 范式,工程细节对照 deepseek-harness 的压缩纪律):
3
+ * L1 工作记忆 最近若干轮原文 (滑入滑出,按**预算比率**与条数两道闸)
4
+ * L2 滚动摘要 滑出的旧轮次压缩成摘要,跨窗口保留脉络
5
+ * L3 长期事实 从对话中提取的持久信息 (偏好/项目/关系),带溯源,注入系统提示
6
+ * 持久化: ~/.weflow-cli/assistant_memory.json(带 `version`,见下)
7
+ *
8
+ * ## 文件格式与版本
9
+ *
10
+ * ```json
11
+ * { "version": 1,
12
+ * "users": { "<会话id>": {
13
+ * "working": [{"role":"user","content":"…"}],
14
+ * "summary": "…",
15
+ * "facts": [{"content":"…","ts":0,"sourceTurn":6,"sourceQuote":"…","usedAt":0}],
16
+ * "turnCount": 12 } } }
17
+ * ```
18
+ *
19
+ * **什么算结构变更、要 bump 版本**(这条判据本身就是格式的一部分):
20
+ * 字段改名/删除、字段含义或单位变了、键空间变了(例如 `users` 这一层)。**只加可选字段不算**——
21
+ * 读者忽略不认识的字段即可。当前 `MEMORY_FORMAT_VERSION = 1`。
22
+ *
23
+ * **读不出来时怎么办**:v0(没有 `version`、顶层直接是会话 id,历史遗留)**迁移**到 v1;
24
+ * 版本等于当前值的正常读;**其它一律拒绝**(不猜、不迁移),并把原文件**改名留档**再以空状态启动。
25
+ * 静默丢弃用户记忆是不能接受的,所以留档这一步是强制的,`loadIssue` 会把话说出来。
7
26
  */
8
27
  import { join } from 'path'
28
+ import { writeFileAtomic } from '../utils/atomicWrite.js'
9
29
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'
10
30
  import os from 'os'
31
+ import { privacyGate } from './assistantPrivacy.js'
11
32
 
12
33
  const MEMORY_FILE = join(os.homedir(), '.weflow-cli', 'assistant_memory.json')
13
34
 
14
- const WORKING_MAX = 16 // 超过则最旧一半转摘要
15
- const SUMMARY_MAX = 800
16
- const FACTS_MAX = 30
17
- const FACT_EXTRACT_EVERY = 6
35
+ export const MEMORY_FORMAT_VERSION = 1
36
+
37
+ /** 输入预算(字符)。中文约 1 token/字,按 24k 字留足余量给系统提示与工具结果 */
38
+ export const CONTEXT_BUDGET_CHARS = 24000
39
+ /** 窗口占用达预算这个比例就压缩 */
40
+ export const WORKING_TRIGGER_RATIO = 0.8
41
+ /** 压完最多保留预算的这个比例(按字符) */
42
+ export const WORKING_RETAIN_RATIO = 0.16
43
+ /** 保留的条数下限:预算算出来太少时按这个来,别把小窗口压空 */
44
+ export const WORKING_MIN_TURNS = 6
45
+ /** 条数上限:与预算闸并行,谁先到谁生效 */
46
+ export const WORKING_MAX = 16
47
+ export const SUMMARY_MAX = 800
48
+ export const FACTS_MAX = 30
49
+ export const FACT_EXTRACT_EVERY = 6
50
+ /** 每次注入事实的字符预算:30 条 × 72 字 ≈ 2.1KB 全塞进去是纯噪声,按相关度取一部分 */
51
+ export const FACTS_INJECT_BUDGET_CHARS = 1200
52
+ /** 相关度低于这个值就不进上下文:**预算空着也不填无关事实** */
53
+ export const FACT_RELEVANCE_FLOOR = 0.2
54
+ /** 一条都不相关时至少给最近这么多条——「记忆」本身也是上下文 */
55
+ export const FACTS_INJECT_MIN_RECENT = 8
56
+ /** 两条事实的包含关系达到这个比例才当"同一条"(长的、更具体的那条胜出) */
57
+ const FACT_SAME_RATIO = 0.5
18
58
 
19
59
  export interface ChatTurn { role: 'user' | 'assistant'; content: string }
20
- export interface Fact { content: string; ts: number }
60
+
61
+ export interface Fact {
62
+ content: string
63
+ ts: number
64
+ /** 从第几个用户轮抽出来的;与 `sourceQuote` 一起,为"这条记忆对不对"留核对依据 */
65
+ sourceTurn?: number
66
+ /** 触发这条事实的那句用户话(截断) */
67
+ sourceQuote?: string
68
+ /** 最近一次被检索/注入的时间。用来排序与识别陈旧——只增不减的记忆最容易在这上面腐化 */
69
+ usedAt?: number
70
+ }
21
71
 
22
72
  interface UserMemory {
23
73
  working: ChatTurn[]
@@ -29,31 +79,112 @@ interface UserMemory {
29
79
  /** LLM 调用器抽象 (由 assistantService 注入, 避免循环依赖) */
30
80
  export type LlmCaller = (messages: ChatTurn[], maxTokens?: number) => Promise<string>
31
81
 
82
+ function chars(turns: ChatTurn[]): number {
83
+ return turns.reduce((n, t) => n + (t.content?.length ?? 0), 0)
84
+ }
85
+
86
+ /** 事实的归一化形式:去掉空白与常见标点,再小写。只用于**比较**,不用于存储 */
87
+ export function normalizeFact(text: string): string {
88
+ return String(text ?? '')
89
+ .replace(/\s+/g, '')
90
+ .replace(/[,。、;:!?,.;:!?()()【】[\]{}"'「」『』·—~~]/g, '')
91
+ .toLowerCase()
92
+ }
93
+
32
94
  export class AssistantMemory {
33
95
  private states: Record<string, UserMemory> = {}
34
96
  /** 本进程修改过的用户 (save 时只回写这些用户, 其余保留磁盘最新值) */
35
97
  private dirtyUsers = new Set<string>()
98
+ /** 加载时出了什么值得说出来的事(版本不认识 → 已留档;文件坏了 → 已留档)。空串=没事 */
99
+ private loadIssue = ''
100
+ /** 上一次落盘失败的原因;成功一次就清空。见 `save()` 里那段注释 */
101
+ private saveIssue: string | null = null
36
102
 
37
103
  constructor() { this.load() }
38
104
 
39
- private normalize(s: any): UserMemory {
105
+ /** 加载期的问题。调用方(服务启动时)应当把它**打出来**,而不是让用户面对一份空记忆 */
106
+ get problem(): string { return this.loadIssue }
107
+
108
+ /** 上一次落盘失败的原因(成功过就是 null)。存在不等于"这次没存上"——错误是累积前的最后一次 */
109
+ get lastSaveError(): string | null { return this.saveIssue }
110
+
111
+ private normalizeUser(s: any): UserMemory {
112
+ const facts: Fact[] = Array.isArray(s?.facts)
113
+ ? s.facts
114
+ .filter((f: any) => f && typeof f.content === 'string' && f.content.trim())
115
+ .map((f: any) => ({
116
+ content: String(f.content),
117
+ ts: Number(f.ts) || 0,
118
+ ...(Number.isFinite(Number(f.sourceTurn)) ? { sourceTurn: Number(f.sourceTurn) } : {}),
119
+ ...(typeof f.sourceQuote === 'string' && f.sourceQuote ? { sourceQuote: f.sourceQuote } : {}),
120
+ ...(Number.isFinite(Number(f.usedAt)) ? { usedAt: Number(f.usedAt) } : {}),
121
+ }))
122
+ : []
40
123
  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,
124
+ working: Array.isArray(s?.working)
125
+ ? s.working.filter((t: any) => t && typeof t.content === 'string')
126
+ : [],
127
+ summary: typeof s?.summary === 'string' ? s.summary : '',
128
+ facts,
129
+ turnCount: Number(s?.turnCount) || 0,
130
+ }
131
+ }
132
+
133
+ /** 把读不出来的文件改名留档——**不许静默丢弃** */
134
+ private quarantine(reason: string): void {
135
+ const target = `${MEMORY_FILE}.unreadable-${new Date().toISOString().replace(/[:.]/g, '-')}`
136
+ try {
137
+ renameSync(MEMORY_FILE, target)
138
+ this.loadIssue = `${reason};原文件已留档为 ${target.split(/[\\/]/).pop()}`
139
+ } catch (error: any) {
140
+ this.loadIssue = `${reason};留档也失败了(${error?.message ?? error}),未改动原文件`
45
141
  }
142
+ this.states = {}
46
143
  }
47
144
 
48
145
  private load(): void {
146
+ if (!existsSync(MEMORY_FILE)) return
147
+ let raw: any
49
148
  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
- }
149
+ raw = JSON.parse(readFileSync(MEMORY_FILE, 'utf8'))
150
+ } catch (error: any) {
151
+ // 坏 JSON 也留档:它可能是用户唯一的一份记忆,不能就地丢弃
152
+ this.quarantine(`记忆文件无法解析(${error?.message?.slice(0, 80) ?? error})`)
153
+ return
154
+ }
155
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
156
+ this.quarantine('记忆文件不是一个对象')
157
+ return
158
+ }
159
+
160
+ // v0:没有 version,顶层就是会话 id(历史遗留)→ 迁移到 v1
161
+ if (raw.version === undefined) {
162
+ for (const [uid, state] of Object.entries<any>(raw)) {
163
+ if (typeof state !== 'object' || state === null) continue
164
+ this.states[uid] = this.normalizeUser(state)
165
+ }
166
+ if (Object.keys(this.states).length) {
167
+ this.loadIssue = `记忆文件是 v0 格式,已迁移到 v${MEMORY_FORMAT_VERSION}(下次保存时落盘)`
168
+ for (const uid of Object.keys(this.states)) this.dirtyUsers.add(uid)
55
169
  }
56
- } catch { this.states = {} }
170
+ return
171
+ }
172
+
173
+ if (raw.version !== MEMORY_FORMAT_VERSION) {
174
+ // 不猜、不迁移:版本比我们新(或看不懂)时,宁可留档重来,也不把对方的字段读歪
175
+ this.quarantine(`记忆文件版本是 ${JSON.stringify(raw.version)},本程序只认 ${MEMORY_FORMAT_VERSION}`)
176
+ return
177
+ }
178
+
179
+ const users = raw.users
180
+ if (users === undefined) return
181
+ if (typeof users !== 'object' || users === null || Array.isArray(users)) {
182
+ this.quarantine('记忆文件的 users 字段不是一个对象')
183
+ return
184
+ }
185
+ for (const [uid, state] of Object.entries<any>(users)) {
186
+ this.states[uid] = this.normalizeUser(state)
187
+ }
57
188
  }
58
189
 
59
190
  save(): void {
@@ -64,23 +195,38 @@ export class AssistantMemory {
64
195
  try {
65
196
  if (existsSync(MEMORY_FILE)) {
66
197
  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)
198
+ const diskUsers = disk?.version === MEMORY_FORMAT_VERSION ? disk.users
199
+ : disk?.version === undefined ? disk : null
200
+ if (diskUsers && typeof diskUsers === 'object') {
201
+ for (const [uid, s] of Object.entries<any>(diskUsers)) {
202
+ if (this.dirtyUsers.has(uid)) continue
203
+ merged[uid] = this.normalizeUser(s)
204
+ }
70
205
  }
71
206
  }
72
- } catch { /* 磁盘损坏 → 只落内存态 */ }
207
+ } catch { /* 磁盘损坏 → 只落内存态(上面 load 已经留过档了) */ }
73
208
  for (const uid of this.dirtyUsers) {
74
209
  if (this.states[uid]) merged[uid] = this.states[uid]
75
210
  }
76
211
  const dir = join(os.homedir(), '.weflow-cli')
77
212
  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)
213
+ const payload = { version: MEMORY_FORMAT_VERSION, users: merged }
214
+ // 走 writeFileAtomic:Windows 上 rename 覆盖被别的句柄打开的文件会 EPERM,
215
+ // 直接 rename 的话这里会**静默失败**(本条就是这个 bug 的现场)
216
+ writeFileAtomic(MEMORY_FILE, JSON.stringify(payload, null, 1))
81
217
  this.states = merged
82
218
  this.dirtyUsers.clear()
83
- } catch { /* 持久化失败不阻断对话 */ }
219
+ this.saveIssue = null
220
+ } catch (error: any) {
221
+ // **不抛**是对的:磁盘打嗝不该毁掉这次对话(内存态还在,dirty 没清,下一次还会试)。
222
+ // 但**不吭声是错的**:用户说"记住",写失败,这条记忆就是没了——而没有任何地方留下痕迹。
223
+ // 这里此前整个是一个空 catch,连审计都没有。实测撞到过:一次临时目录上的写失败让两条
224
+ // "记忆能跨重启"的测试失败,系统里却没有任何记录指向它。
225
+ //
226
+ // 审计只记错误码,不记消息本身(消息里可能带路径);要给人看的在 `lastSaveError`。
227
+ this.saveIssue = String(error?.message ?? error).slice(0, 120)
228
+ privacyGate.audit('MEMORY_SAVE_FAILED', 0, `code=${error?.code ?? error?.name ?? 'unknown'}`)
229
+ }
84
230
  }
85
231
 
86
232
  private state(userId: string): UserMemory {
@@ -112,24 +258,60 @@ export class AssistantMemory {
112
258
  facts(userId: string): Fact[] { return this.state(userId).facts }
113
259
  userCount(): number { return Object.keys(this.states).length }
114
260
 
115
- /** L2: 窗口溢出时把最旧一半压进摘要 */
261
+ /** 是哪道闸要压:条数(短轮次堆多了)还是预算(长轮次把窗口撑爆了) */
262
+ compressionGate(userId: string): 'count' | 'budget' | 'both' | null {
263
+ const s = this.state(userId)
264
+ const byCount = s.working.length > WORKING_MAX
265
+ const byBudget = chars(s.working) > CONTEXT_BUDGET_CHARS * WORKING_TRIGGER_RATIO
266
+ if (byCount && byBudget) return 'both'
267
+ if (byCount) return 'count'
268
+ if (byBudget) return 'budget'
269
+ return null
270
+ }
271
+
272
+ needsCompression(userId: string): boolean { return this.compressionGate(userId) !== null }
273
+
274
+ /** 压完保留多少条。**两道闸的保留规则不同,这是有意的**:
275
+ *
276
+ * - 条数闸(一堆短轮次堆到 16 条):保留最近的一半,下限 `WORKING_MIN_TURNS`——这里条数才是
277
+ * 问题所在,留一半正好把窗口减半;
278
+ * - 预算闸(几条长轮次就把预算撑爆):按**字符**保留预算的 16%,条数只保底 1 条。
279
+ * 这两条约束会互斥——6000 字的轮次保 6 条就是 36000 字,远超 24k 预算。**字符上限优先**,
280
+ * 因为它是硬预算;条数下限在小窗口下才有意义。
281
+ * - 两道同时触发:取两者中**更小的**(更保守)。
282
+ *
283
+ * 无论哪条,都卡在 `[1, 窗口长度-1]`:算出"保留 6 条"而窗口只有 5 条时,`slice(-1)` 会只留
284
+ * 1 条,下限形同虚设(实测撞出来的);也至少要压出去一条,否则"压了"却没动。
285
+ */
286
+ private retainCount(turns: ChatTurn[], gate: 'count' | 'budget' | 'both'): number {
287
+ const cap = CONTEXT_BUDGET_CHARS * WORKING_RETAIN_RATIO
288
+ let used = 0
289
+ let byChars = 0
290
+ for (let i = turns.length - 1; i >= 0; i--) {
291
+ const size = turns[i].content?.length ?? 0
292
+ if (byChars > 0 && used + size > cap) break
293
+ used += size
294
+ byChars++
295
+ if (used >= cap) break
296
+ }
297
+ const countKeep = Math.max(WORKING_MIN_TURNS, Math.floor(turns.length / 2))
298
+ const budgetKeep = Math.max(1, byChars)
299
+ const want = gate === 'count' ? countKeep : gate === 'budget' ? budgetKeep : Math.min(countKeep, budgetKeep)
300
+ return Math.min(Math.max(want, 1), turns.length - 1)
301
+ }
302
+
303
+ /** L2: 窗口该压时,把最旧的那些压进摘要 */
116
304
  async compressIfNeeded(userId: string, llm: LlmCaller): Promise<boolean> {
117
305
  const s = this.state(userId)
118
- if (s.working.length <= WORKING_MAX) return false
306
+ if (!this.needsCompression(userId)) return false
119
307
  this.dirtyUsers.add(userId)
120
308
 
121
- const out = s.working.slice(0, Math.floor(s.working.length / 2))
122
- s.working = s.working.slice(out.length)
309
+ const keep = this.retainCount(s.working, this.compressionGate(userId) ?? 'count')
310
+ const out = s.working.slice(0, s.working.length - keep)
311
+ s.working = s.working.slice(s.working.length - keep)
123
312
 
124
313
  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)
314
+ const merged = await llm([{ role: 'user', content: buildSummaryPrompt(s.summary, out) }], 900)
133
315
  s.summary = merged.trim().slice(0, SUMMARY_MAX)
134
316
  } catch {
135
317
  // LLM 失败时降级: 粗暴截断拼接, 保住不丢
@@ -147,23 +329,20 @@ ${out.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.content}`).join(
147
329
  if (!recent.length) return 0
148
330
  this.dirtyUsers.add(userId)
149
331
 
150
- try {
151
- const prompt = `从对话中提取关于用户的持久事实(背景/偏好/项目/人际关系/惯例)。每条不超过40字。只输出 JSON 字符串数组,最多3条,没有则输出 []。不要提取临时性内容。
332
+ // 溯源:这一批事实是从"第几个用户轮"抽出来的,触发的那句用户话也记下来
333
+ const lastUser = [...recent].reverse().find(t => t.role === 'user')
334
+ const sourceTurn = s.turnCount
335
+ const sourceQuote = (lastUser?.content ?? '').replace(/\s+/g, ' ').slice(0, 40)
152
336
 
153
- [对话]
154
- ${recent.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.content}`).join('\n')}`
155
- const raw = await llm([{ role: 'user', content: prompt }], 300)
337
+ try {
338
+ const raw = await llm([{ role: 'user', content: buildFactPrompt(recent) }], 300)
156
339
  const m = raw.match(/\[[\s\S]*\]/)
157
340
  if (!m) return 0
158
341
  const items: unknown[] = JSON.parse(m[0])
159
342
  let added = 0
160
343
  for (const item of items) {
161
344
  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++
345
+ if (this.rememberFact(s, item.trim().slice(0, 60), { sourceTurn, sourceQuote })) added++
167
346
  }
168
347
  if (s.facts.length > FACTS_MAX) s.facts = s.facts.slice(-FACTS_MAX)
169
348
  return added
@@ -172,22 +351,199 @@ ${recent.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.content}`).jo
172
351
  }
173
352
  }
174
353
 
175
- /** L3 检索: 关键词命中 (为未来向量检索预留同一接口) */
354
+ /** L3 检索: 关键词命中;命中即记 `usedAt`(谁被用过是排序与"陈旧"判断的依据) */
176
355
  searchFacts(userId: string, keyword: string): Fact[] {
177
356
  const kw = keyword.trim()
178
357
  if (!kw) return []
179
- return this.state(userId).facts.filter(f =>
358
+ const s = this.state(userId)
359
+ const hits = s.facts.filter(f =>
180
360
  f.content.includes(kw) || kw.split(/\s+/).some(w => w.length >= 2 && f.content.includes(w)))
361
+ if (hits.length) {
362
+ const now = Date.now()
363
+ for (const f of hits) f.usedAt = now
364
+ this.dirtyUsers.add(userId)
365
+ }
366
+ return hits
181
367
  }
182
368
 
183
369
  /** L3 写入 (save_memory 工具用, 立即生效) */
184
- addFact(userId: string, content: string): boolean {
370
+ addFact(userId: string, content: string, source?: { sourceTurn?: number; sourceQuote?: string }): boolean {
185
371
  this.dirtyUsers.add(userId)
186
372
  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)
373
+ const added = this.rememberFact(s, content, source)
374
+ if (added && s.facts.length > FACTS_MAX) s.facts = s.facts.slice(-FACTS_MAX)
190
375
  this.save()
376
+ return added
377
+ }
378
+
379
+ /**
380
+ * 记忆一条事实,返回**是否新增或更新**。三种判定:
381
+ *
382
+ * - 归一化后相同 → 重复,什么都不做;
383
+ * - 一条包含另一条 → 取**更长的那条**(更具体),短的那条被顶掉;
384
+ * - 其它 → 新增。
385
+ *
386
+ * 包含式去重的固有模糊写在测试里:两个恰好互为前缀的短事实(`事实1` 与 `事实10`),
387
+ * 长的那条会顶掉短的。真实事实极少这样,而"更具体的胜出"在真实场景里是对的。
388
+ */
389
+ private rememberFact(s: UserMemory, content: string, source?: { sourceTurn?: number; sourceQuote?: string }): boolean {
390
+ const text = content.trim()
391
+ if (!text) return false
392
+ const norm = normalizeFact(text)
393
+ for (let i = 0; i < s.facts.length; i++) {
394
+ const existing = s.facts[i]
395
+ const other = normalizeFact(existing.content)
396
+ if (norm === other) {
397
+ // 同一条:把溯源补全(旧的可能是没有溯源的版本),不动时间戳
398
+ if (source?.sourceTurn !== undefined && existing.sourceTurn === undefined) {
399
+ existing.sourceTurn = source.sourceTurn
400
+ existing.sourceQuote = source.sourceQuote
401
+ }
402
+ return false
403
+ }
404
+ const contains = other.includes(norm) || norm.includes(other)
405
+ if (!contains) continue
406
+ const ratio = Math.min(norm.length, other.length) / Math.max(norm.length, other.length)
407
+ if (ratio < FACT_SAME_RATIO) continue // 只是碰巧包含,算两条
408
+ if (norm.length > other.length) {
409
+ // 新的更具体 → 顶掉旧的,保留它最早的时间戳
410
+ s.facts[i] = { content: text, ts: existing.ts || Date.now(), ...(source ?? {}) }
411
+ return true
412
+ }
413
+ return false // 旧的已经更具体,不动
414
+ }
415
+ s.facts.push({ content: text, ts: Date.now(), ...(source ?? {}) })
191
416
  return true
192
417
  }
193
418
  }
419
+
420
+ /** L2 的压缩提示词。**固定节骨架 + 合并律**:空节写 `(none)`,永不删节。
421
+ *
422
+ * 为什么固定成节:自由摘要最容易在压缩时悄悄丢信息(丢了自己也不知道)。固定节至少让
423
+ * 每一次压缩都必须回答"待办有没有、错误有没有、下一步有没有"。
424
+ * 为什么写合并律:滚动摘要腐化的正解是"保留仍然成立的、丢掉过期的、合成一份",并明确
425
+ * **禁止逐字复制前任**——否则摘要会一层层掺水。
426
+ */
427
+ export function buildSummaryPrompt(previous: string, turnsOut: ChatTurn[]): string {
428
+ const transcript = turnsOut.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.content}`).join('\n')
429
+ return `把下面的对话压缩进已有摘要。**输出必须是这 8 个节,一节不删**;某一节没有内容就写 (none):
430
+
431
+ ## 用户诉求
432
+ ## 技术要点
433
+ ## 涉及的文件与命令
434
+ ## 错误与修复
435
+ ## 待办
436
+ ## 当前进展
437
+ ## 下一步
438
+ ## 关键上下文
439
+
440
+ 合并规则:保留**仍然成立**的事实,丢掉已经过期的,合成**一份**摘要。
441
+ 不许逐字复制旧摘要,也不许把已经解决的错误当成待办留着。
442
+ 总长不超过 800 字,直接输出摘要正文,不要任何前后缀。
443
+
444
+ [已有摘要]
445
+ ${previous || '(无)'}
446
+
447
+ [新增对话]
448
+ ${transcript}`
449
+ }
450
+
451
+ /** L3 的抽取提示词。要求可核对:每条事实都能在对话里指出来源 */
452
+ export function buildFactPrompt(recent: ChatTurn[]): string {
453
+ const transcript = recent.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.content}`).join('\n')
454
+ return `从对话中提取关于用户的持久事实(背景/偏好/项目/人际关系/惯例)。每条不超过40字。
455
+ 只提取**用户自己说过的、长期成立的**信息;助手自己的推测不算。
456
+ 只输出 JSON 字符串数组,最多3条,没有则输出 []。不要提取临时性内容。
457
+
458
+ [对话]
459
+ ${transcript}`
460
+ }
461
+
462
+ /** 字符二元组集合——中文没有词边界,用二元组算重合比"包含"稳,也不用引模型 */
463
+ function bigrams(text: string): Set<string> {
464
+ const norm = normalizeFact(text)
465
+ const out = new Set<string>()
466
+ for (let i = 0; i + 1 < norm.length; i++) out.add(norm.slice(i, i + 2))
467
+ return out
468
+ }
469
+
470
+ function overlap(a: Set<string>, b: Set<string>): number {
471
+ if (!a.size || !b.size) return 0
472
+ let hit = 0
473
+ for (const g of a) if (b.has(g)) hit++
474
+ return hit / Math.min(a.size, b.size)
475
+ }
476
+
477
+ /**
478
+ * 该把哪几条事实放进这次请求:**先相关、再近期用过、最后按时间**,总量受字符预算约束。
479
+ *
480
+ * 为什么不全量注入:30 条 × ~72 字 ≈ 2.1KB **每轮**都发,而且与当前问题无关的那些是纯噪声——
481
+ * 噪声不只是花钱,它是"模型被无关信息带偏"的来源之一。
482
+ *
483
+ * **相关度决定"谁进",展示保持时序**:入选的按时间先后排列(读起来像一份清单,而不是一堆碎片),
484
+ * 没进的按相关度+近期用过来决定顺序。
485
+ *
486
+ * 返回未被选中的条数,调用方要**如实说出来**(`另有 N 条未列出`)。谎报"全部记忆如下"比少给
487
+ * 几条更糟:模型会以为自己看到了全部。
488
+ */
489
+ export function selectFactsForInjection(facts: Fact[], question: string,
490
+ budgetChars = FACTS_INJECT_BUDGET_CHARS): { selected: Fact[]; withheld: number } {
491
+ if (!facts.length) return { selected: [], withheld: 0 }
492
+ const q = bigrams(question || '')
493
+ const qText = normalizeFact(question || '')
494
+
495
+ const scored = facts.map((f, index) => {
496
+ const fText = normalizeFact(f.content)
497
+ // 直接包含(整串或关键片段)算强相关;否则用二元组重合
498
+ const direct = qText.length >= 2 && (fText.includes(qText) || qText.includes(fText))
499
+ const sim = direct ? 1 : overlap(q, bigrams(f.content))
500
+ // sim 单独留着:闸门按它判,排序才用 rank(后者含「最近用过」与时间的权重)
501
+ return { fact: f, index, sim, rank: sim * 10 + (f.usedAt ? 0.5 : 0) + (f.ts ? 0.1 : 0) }
502
+ })
503
+
504
+ scored.sort((a, b) => b.rank - a.rank || a.index - b.index)
505
+
506
+ // **先按相关度筛,再按预算裁**——这个顺序是要紧的:预算够大时不该拿无关事实去填空,
507
+ // 否则"按相关度注入"只是排了个序而已(实测:30 条短事实全装得下,等于全量注入)。
508
+ const relevant = scored.filter(item => item.sim >= FACT_RELEVANCE_FLOOR)
509
+ // 兜底要取**最近**的几条(按原始下标=时间顺序),不是 rank 排在前面的那些:
510
+ // 一条都不相关时 rank 全都一样,按 rank 取到的是最旧的那批,正好反了。
511
+ const oldestToKeep = Math.max(0, facts.length - FACTS_INJECT_MIN_RECENT)
512
+ const pool = relevant.length
513
+ ? relevant
514
+ : scored.filter(item => item.index >= oldestToKeep)
515
+
516
+ const selected: Fact[] = []
517
+ let used = 0
518
+ for (const item of pool) {
519
+ const size = item.fact.content.length + 2
520
+ if (selected.length && used + size > budgetChars) break
521
+ selected.push(item.fact)
522
+ used += size
523
+ }
524
+ // 保持原来的顺序(时间先后),读起来才像一份清单而不是一堆碎片
525
+ selected.sort((a, b) => facts.indexOf(a) - facts.indexOf(b))
526
+ return { selected, withheld: facts.length - selected.length }
527
+ }
528
+
529
+ const FRAME_OPEN = 'weflow-local-data'
530
+ const FRAME_CLOSE = '/' + FRAME_OPEN
531
+
532
+ /**
533
+ * 把一段**本地数据**包进框里再进提示词,并转义内容里出现的框标签。
534
+ *
535
+ * 防的是"框欺骗":聊天正文、文章正文、事实里完全可能写着 `</weflow-local-data>` 再跟一段
536
+ * 像系统指令的话——不转义就等于让数据自己把框关上、直接以系统身份说话。转义后它只能是数据。
537
+ * (这不等于防住提示词注入本身:数据里仍可以有指令性文字;我们只保证它**关不掉这个框**。)
538
+ */
539
+ export function frameLocalData(label: string, body: string): string {
540
+ // 用 split/join 而不是正则:这里只需要"把框标签的尖括号改掉一个字",正则只会给它引入转义问题。
541
+ // 开标签与闭标签都要挡:数据里出现任一形式,都不能让它把框关上。
542
+ const safe = String(body ?? '')
543
+ .split('<' + FRAME_OPEN).join('<‹' + FRAME_OPEN)
544
+ .split('<' + FRAME_CLOSE).join('<‹' + FRAME_CLOSE)
545
+ const crlf = String.fromCharCode(10)
546
+ return '<' + FRAME_OPEN + ' source="' + label + '">' + crlf + safe + crlf + '<' + FRAME_CLOSE + '>'
547
+ }
548
+
549
+
@@ -7,9 +7,14 @@
7
7
  * - 本地推理引擎 (Ollama 等) 不出境, 跳过脱敏以保留完整能力
8
8
  *
9
9
  * 模式 (config: assistantPrivacy):
10
- * - open 不脱敏 (自担风险)
11
- * - balanced 默认; 工具输出中的 PII (电话/证件/邮箱/密钥/链接) 打码
10
+ * - open 不脱敏 (自担风险)
11
+ * - balanced 工具输出中的 PII (电话/证件/邮箱/密钥/链接) 打码
12
12
  * - strict balanced 基础上, 第三方聊天正文不出境 (仅保留时间/方向/类型)
13
+ *
14
+ * **实测(2026-09-28):新装用户的默认是 `strict`,不是 `balanced`。** 这个键由
15
+ * `configService` 出厂就写成 `'strict'`(`configService.ts` 的默认表与迁移那行都是),
16
+ * 下面 `mode()` 里的 `balanced` 只是**值缺失或不认识时**的兜底。原注释写成「balanced 默认」,
17
+ * 与出厂值矛盾——README 两版跟着写成「strict 可加强」,读起来像默认更弱。两处都已按实际改写。
13
18
  */
14
19
  import { join } from 'path'
15
20
  import { existsSync, mkdirSync, appendFileSync } from 'fs'
@@ -42,9 +47,23 @@ export function redactText(text: string, mode: PrivacyMode, localInference: bool
42
47
  return { safe, redactions: count }
43
48
  }
44
49
 
45
- export function maskMessageBodyText(body: string, mode: PrivacyMode, localInference: boolean): string {
50
+ /**
51
+ * strict 模式:**保留类型标签、丢掉载荷**。
52
+ *
53
+ * 契约是"仅保留时间/方向/类型",而类型标签正是那个"类型":非文本消息的正文本来就是
54
+ * `[图片]` / `[文件] Base.csv` / `[引用] 回复 | 引:原文` 这种形态,整条遮掉会把
55
+ * "这是一张图片"一起抹掉——模型从此分不出图片和文本消息,而它**一个字都没有多知道**。
56
+ * 载荷(文件名、被引原文、撤回提示里的名字)照旧不出境。
57
+ *
58
+ * `isText` 由调用方给:只有读取器说的"非文本消息"才配当作标签。用户自己敲的
59
+ * `[图片] 这是我拍的` 是正文,按正文遮——否则就是一个把用户的话放出去的口子。
60
+ */
61
+ export function maskMessageBodyText(body: string, mode: PrivacyMode,
62
+ localInference: boolean, isText = true): string {
46
63
  if (mode !== 'strict' || localInference) return body
47
- return `[内容${body.length}字已按严格模式屏蔽]`
64
+ if (isText) return `[内容${body.length}字已按严格模式屏蔽]`
65
+ const label = body.match(/^\[[^\]]{1,20}\]/)?.[0]
66
+ return label ? `${label}(内容已按严格模式屏蔽)` : `[内容${body.length}字已按严格模式屏蔽]`
48
67
  }
49
68
 
50
69
  export class PrivacyGate {
@@ -68,8 +87,8 @@ export class PrivacyGate {
68
87
  }
69
88
 
70
89
  /** strict 模式: 第三方聊天正文不出境, 只保留元数据形态 */
71
- maskMessageBody(body: string): string {
72
- return maskMessageBodyText(body, this.mode(), this.isLocalInference())
90
+ maskMessageBody(body: string, opts: { isText?: boolean } = {}): string {
91
+ return maskMessageBodyText(body, this.mode(), this.isLocalInference(), opts.isText !== false)
73
92
  }
74
93
 
75
94
  /** 审计日志 — 只记事件与字节量, 绝不记内容 */