ppxans-harness 2.4.0 → 3.2.0

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 (186) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +218 -265
  3. package/bin/ppx-channels.js +2 -2
  4. package/bin/ppx-serve.js +5 -5
  5. package/bin/ppx-setup.js +124 -0
  6. package/bin/ppx-web.js +140 -0
  7. package/bin/ppx.js +2 -2
  8. package/config/identity.md +6 -6
  9. package/config/ishiki.md +16 -16
  10. package/config/ppx.json +15 -151
  11. package/config/ppx.json.example +143 -0
  12. package/package.json +17 -10
  13. package/skills/.usage.json +6 -0
  14. package/skills/agent-professional-training/SKILL.md +94 -0
  15. package/skills/brainstorm/SKILL.md +24 -0
  16. package/skills/cupid-lover-comms/SKILL.md +37 -0
  17. package/skills/debug/SKILL.md +26 -0
  18. package/skills/plan/SKILL.md +25 -0
  19. package/skills/ponytail/SKILL.md +25 -0
  20. package/skills/ppx-memory/SKILL.md +91 -0
  21. package/skills/ppx-memory/scripts/cli.js +192 -0
  22. package/skills/ppx-memory/scripts/experience.js +133 -0
  23. package/skills/ppx-memory/scripts/fact-store.js +842 -0
  24. package/skills/ppx-memory/scripts/l0.js +52 -0
  25. package/skills/ppx-memory/scripts/l2.js +146 -0
  26. package/skills/ppx-memory/scripts/l3.js +112 -0
  27. package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
  28. package/skills/ppx-memory/scripts/pii.js +42 -0
  29. package/skills/ppx-memory/scripts/schema.js +80 -0
  30. package/skills/ppx-memory/scripts/session.js +398 -0
  31. package/skills/ppx-memory/scripts/similarity.js +43 -0
  32. package/skills/ppx-memory/scripts/store.js +116 -0
  33. package/skills/ppx-memory/scripts/wal.js +38 -0
  34. package/skills/ppx-selfheal/SKILL.md +24 -0
  35. package/skills/ppx-selfheal/scripts/cli.js +80 -0
  36. package/skills/ppx-selfheal/scripts/healer.js +184 -0
  37. package/skills/ppx-selfheal/scripts/logger.js +17 -0
  38. package/skills/ppx-selfheal/scripts/store.js +116 -0
  39. package/skills/prompt-depth-kit/SKILL.md +28 -0
  40. package/skills/session-naming/SKILL.md +36 -0
  41. package/skills/verify/SKILL.md +25 -0
  42. package/src/agent/index.js +1340 -717
  43. package/src/agent/prompts.js +47 -3
  44. package/src/aml-server.js +197 -151
  45. package/src/ans/eviction.js +123 -143
  46. package/src/ans/guard.js +159 -120
  47. package/src/ans/lifecycle.js +96 -93
  48. package/src/ans/proactive.js +112 -129
  49. package/src/ans/reward.js +95 -111
  50. package/src/ans/values.js +15 -15
  51. package/src/audit/audit-chain.js +43 -7
  52. package/src/audit/verifier.js +157 -120
  53. package/src/bus/circuit-breaker.js +9 -1
  54. package/src/bus/runtime-bus.js +107 -93
  55. package/src/channels/base.js +57 -34
  56. package/src/channels/feishu.js +118 -126
  57. package/src/channels/http.js +1090 -592
  58. package/src/channels/index.js +111 -110
  59. package/src/channels/log.js +29 -29
  60. package/src/channels/wechat-crypto.js +73 -74
  61. package/src/channels/wechat.js +191 -197
  62. package/src/channels/workspace.js +94 -0
  63. package/src/channels-cli.js +126 -124
  64. package/src/cli.js +129 -120
  65. package/src/commands/index.js +142 -0
  66. package/src/config/channels.js +137 -170
  67. package/src/config/index.js +277 -224
  68. package/src/config/placeholder.js +31 -0
  69. package/src/config/providers.js +148 -188
  70. package/src/config/settings.js +156 -182
  71. package/src/core/policy.js +69 -18
  72. package/src/core/trace.js +8 -6
  73. package/src/edit/editblock.js +266 -0
  74. package/src/edit/snapshot.js +67 -0
  75. package/src/evidence/index.js +153 -0
  76. package/src/evolve/playbook.js +9 -10
  77. package/src/hooks/index.js +113 -0
  78. package/src/llm/client.js +188 -446
  79. package/src/llm/dsml.js +74 -74
  80. package/src/llm/embedder.js +41 -35
  81. package/src/llm/fence.js +52 -105
  82. package/src/llm/index.js +4 -4
  83. package/src/llm/local-embedder.js +94 -0
  84. package/src/llm/presets.js +113 -0
  85. package/src/llm/pricing.js +93 -0
  86. package/src/llm/retry.js +73 -73
  87. package/src/llm/router.js +92 -97
  88. package/src/mcp/admin.js +326 -0
  89. package/src/mcp/client.js +487 -375
  90. package/src/mcp/http.js +203 -0
  91. package/src/mcp/index.js +116 -116
  92. package/src/mcp/server.js +392 -0
  93. package/src/mcp/tasks.js +133 -0
  94. package/src/memory/asset-hub.js +6 -11
  95. package/src/memory/canvas.js +2 -5
  96. package/src/memory/compaction.js +28 -28
  97. package/src/memory/experience.js +133 -122
  98. package/src/memory/fact-store.js +914 -698
  99. package/src/memory/failure-episode.js +20 -11
  100. package/src/memory/fork.js +17 -8
  101. package/src/memory/index.js +8 -6
  102. package/src/memory/l0.js +53 -52
  103. package/src/memory/l2.js +145 -130
  104. package/src/memory/l3.js +111 -111
  105. package/src/memory/legion-board.js +71 -0
  106. package/src/memory/memory-ticker.js +239 -240
  107. package/src/memory/session.js +398 -397
  108. package/src/memory/sqlite-store.js +581 -0
  109. package/src/mode/blackboard.js +49 -49
  110. package/src/mode/graph.js +42 -41
  111. package/src/mode/index.js +64 -64
  112. package/src/mode/legion.js +54 -51
  113. package/src/mode/plan-exec.js +50 -50
  114. package/src/mode/router.js +28 -40
  115. package/src/orchestrator/agent-worker.js +69 -69
  116. package/src/orchestrator/dag.js +90 -83
  117. package/src/orchestrator/experts.js +76 -0
  118. package/src/orchestrator/index.js +1 -1
  119. package/src/orchestrator/legion.js +179 -187
  120. package/src/orchestrator/supervisor.js +6 -8
  121. package/src/permissions/index.js +378 -0
  122. package/src/persona/index.js +28 -29
  123. package/src/plugin/builtin.js +313 -212
  124. package/src/plugin/context.js +80 -79
  125. package/src/plugin/index.js +62 -62
  126. package/src/plugin/v3.js +73 -0
  127. package/src/protocol/index.js +148 -0
  128. package/src/repomap/index.js +309 -0
  129. package/src/review/index.js +393 -0
  130. package/src/seam/registry.js +3 -0
  131. package/src/seam/shell.js +55 -55
  132. package/src/security/injection.js +79 -0
  133. package/src/selfheal/evolve.js +67 -67
  134. package/src/selfheal/healer.js +184 -167
  135. package/src/selfheal/run.js +9 -9
  136. package/src/server.js +63 -60
  137. package/src/services/diagnose.js +180 -0
  138. package/src/services/learning-service.js +9 -0
  139. package/src/services/memory-health.js +34 -6
  140. package/src/services/memory-service.js +42 -9
  141. package/src/services/triage.js +138 -0
  142. package/src/session/parts.js +76 -0
  143. package/src/session/projection.js +73 -0
  144. package/src/session/rollout.js +54 -0
  145. package/src/session/turn.js +137 -0
  146. package/src/skills/lint.js +72 -0
  147. package/src/skills/loader.js +231 -150
  148. package/src/skills/search.js +58 -0
  149. package/src/skills/verify.js +95 -100
  150. package/src/tools/advanced.js +388 -352
  151. package/src/tools/builtin.js +384 -297
  152. package/src/tools/catalog.js +283 -159
  153. package/src/tools/command-guard.js +112 -112
  154. package/src/tools/custom.js +47 -47
  155. package/src/tools/delegate.js +383 -297
  156. package/src/tools/document.js +254 -253
  157. package/src/tools/git.js +151 -0
  158. package/src/tools/governance.js +47 -20
  159. package/src/tools/index.js +16 -11
  160. package/src/tools/methods.js +178 -178
  161. package/src/tools/ocr.js +59 -59
  162. package/src/tools/sandbox-worker.js +40 -0
  163. package/src/tools/sandbox.js +92 -0
  164. package/src/tools/seam.js +162 -125
  165. package/src/tools/selfmod.js +196 -176
  166. package/src/tools/v3.js +225 -0
  167. package/src/tools/vad.js +176 -0
  168. package/src/tools/voice.js +238 -0
  169. package/src/utils/async.js +14 -0
  170. package/src/utils/config-file.js +53 -0
  171. package/src/utils/crashguard.js +88 -0
  172. package/src/utils/http.js +53 -0
  173. package/src/utils/id.js +8 -0
  174. package/src/utils/json-state.js +33 -0
  175. package/src/utils/logger.js +17 -17
  176. package/src/utils/ndjson.js +25 -0
  177. package/src/utils/pii.js +42 -42
  178. package/src/utils/rate-limit.js +50 -0
  179. package/src/utils/schema.js +80 -0
  180. package/src/utils/similarity.js +43 -0
  181. package/src/utils/store.js +170 -108
  182. package/src/utils/text.js +15 -15
  183. package/src/utils/trace.js +153 -153
  184. package/src/utils/wal.js +39 -0
  185. package/src/utils/winutf8.js +16 -15
  186. package/src/wiki/index.js +170 -0
@@ -1,240 +1,239 @@
1
- // src/memory/memory-ticker.js - 记忆水位线 (参考 openhanako v4)
2
- // 架构: 会话事件日志 (SessionStore) 为唯一事实源; 今日视图由它派生
3
- // 不再独立维护 today.md 对话原文 (原 l0/session/today 三处重复, 已收敛到 session)
4
- // 今日视图 -> 滚动压缩 -> longterm.md (长期记忆)
5
- import fs from "node:fs";
6
- import path from "node:path";
7
- import { ensureDir, readText, writeText, logicalDay } from "../utils/store.js";
8
-
9
- const TURNS_PER_SUMMARY = 10;
10
- const COMPACT_THRESHOLD = 50; // 今日事件超此条数触发滚动压缩
11
- const COMPACT_KEEP = 20; // 压缩后保留的近期条数
12
- const COMPACT_MIN_INTERVAL_MS = 60000; // 压缩节流: 压缩后 60s 内不重复 (防每轮对话重复付 LLM 压缩成本, v1.0.7)
13
-
14
- // 派生今日视图行: 从 session 事件渲染 (role -> 中文说话人)
15
- function _renderLines(sessionStore, day) {
16
- if (!sessionStore) return [];
17
- return sessionStore.eventsByDay(day).map((r) => {
18
- const who = r.role === "user" ? "用户" : "皮皮虾";
19
- return `- [${new Date(r.timestamp).toISOString()}] ${who}: ${String(r.content).slice(0, 200)}`;
20
- });
21
- }
22
-
23
- export class MemoryTicker {
24
- constructor(dataDir, factStore, summarizer = null, sessionStore = null) {
25
- this.summarizer = summarizer;
26
- this.extractor = null; // P1#9: LLM 结构化提炼器 (agent 注入), 提取关键事实/偏好/待办
27
- this.dir = path.join(dataDir, "memory");
28
- ensureDir(this.dir);
29
- ensureDir(path.join(this.dir, "daily"));
30
- this.factStore = factStore;
31
- this.sessionStore = sessionStore; // 唯一事实源 (今日视图由它派生)
32
- this.todayView = path.join(this.dir, "today.md"); // 兼容路径 (不再作为写入真相)
33
- this.longtermMd = path.join(this.dir, "longterm.md");
34
- this.stateFile = path.join(this.dir, "daily-state.json");
35
- this.state = { day: null, turnCount: 0 };
36
- this._lastCompactAt = 0; // 压缩节流时间戳 (v1.0.7)
37
- this._loadState();
38
- this._rollDay();
39
- }
40
-
41
- _loadState() {
42
- try {
43
- if (fs.existsSync(this.stateFile)) this.state = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
44
- } catch { this.state = { day: null, turnCount: 0 }; }
45
- }
46
-
47
- _saveState() {
48
- writeText(this.stateFile, JSON.stringify(this.state, null, 2));
49
- }
50
-
51
- _rollDay() {
52
- const today = logicalDay();
53
- if (this.state.day !== today) {
54
- if (this.state.day) this._compileDaily();
55
- this.state.day = today;
56
- this._saveState();
57
- }
58
- }
59
-
60
- // 跨天: 把上一日事件归档到 daily/ 并滚入 longterm (从 session 派生, 非 today.md)
61
- _compileDaily() {
62
- const day = this.state.day;
63
- const lines = _renderLines(this.sessionStore, day);
64
- const dailyFile = path.join(this.dir, "daily", `${day}.md`);
65
- writeText(dailyFile, `# ${day}
66
-
67
- ${lines.join("\n")}\n`);
68
- if (lines.length) {
69
- let longterm = readText(this.longtermMd) || "";
70
- longterm += `\n## ${day}\n${lines.join("\n")}\n`;
71
- writeText(this.longtermMd, longterm);
72
- }
73
- }
74
-
75
- async recordTurn(user, assistant) {
76
- this._rollDay();
77
- this.state.turnCount += 1;
78
- this._saveState();
79
- // 对话原文已由 session 事件日志保存; 今日视图由 session 派生
80
- if (this.state.turnCount % TURNS_PER_SUMMARY === 0) this._compileDaily_Rolling();
81
- await this._compactIfNeeded();
82
- // P1#9: 若配 LLM 提炼器且本次对话含高信号, 走结构化提炼; 否则退回启发式
83
- if (this.extractor && _hasSignal(user, assistant)) {
84
- try {
85
- // 感知式提炼: 先检索与本次对话相关的已有记忆 (同主题 top 3), 喂给提炼器从源头去重
86
- const related = String(user || "").trim()
87
- ? this.factStore.query(user, { limit: 3 }).filter((f) => !(f.hits && f.hits > 5)) // 高命中已稳定, 不再重复提炼
88
- : [];
89
- const facts = await this.extractor(String(user || ""), String(assistant || ""), related);
90
- if (facts && facts.length) {
91
- // similarThreshold=0.6: LLM 提炼的字面变体 (同义不同词) 与已有事实语义相似时命中加分,
92
- // 防「三件套」这类反复提炼的变体污染记忆库
93
- for (const f of facts) this.factStore.add(f, { source: "extract", similarThreshold: 0.6 });
94
- return;
95
- }
96
- } catch {}
97
- }
98
- if (user) this.factStore.addMemory(user);
99
- }
100
-
101
- // P1#9: 注入 LLM 结构化提炼器 (agent 调 setExtractor)
102
- setExtractor(fn) { this.extractor = typeof fn === "function" ? fn : null; }
103
-
104
- // 每 N 轮: 把今日新增事件滚动归档进 longterm (从 session 派生)
105
- // v1.2.0 fix: 用游标(lastRolledDay/lastRolledSeq)只追加"本次滚动之后"的新事件,
106
- // 不再每次把今日全文重复追加 —— 否则同一段对话会在 longterm 中反复出现, 加剧重复回话。
107
- _compileDaily_Rolling() {
108
- const today = logicalDay();
109
- const st = this.state;
110
- if (st.lastRolledDay !== today) { st.lastRolledDay = today; st.lastRolledSeq = 0; } // 跨天重置游标
111
- const afterSeq = st.lastRolledSeq || 0;
112
- const rows = [];
113
- let maxSeq = afterSeq;
114
- const dayEvents = this.sessionStore && typeof this.sessionStore.replayDay === "function"
115
- ? this.sessionStore.replayDay(today)
116
- : [];
117
- for (const e of dayEvents) {
118
- if (e.seq <= afterSeq) continue;
119
- const who = e.type === "user/message" ? "用户" : "皮皮虾";
120
- rows.push(`- [${new Date(e.ts).toISOString()}] ${who}: ${String(e.data?.content || "").slice(0, 200)}`);
121
- if (e.seq > maxSeq) maxSeq = e.seq;
122
- }
123
- if (rows.length) {
124
- let longterm = readText(this.longtermMd) || "";
125
- longterm += `\n## ${today} (滚动)\n${rows.join("\n")}\n`;
126
- writeText(this.longtermMd, longterm);
127
- }
128
- st.lastRolledSeq = maxSeq;
129
- this._saveState();
130
- }
131
-
132
- // 滚动压缩: 今日事件超量时, 把最旧对话聚合压缩进 longterm, 只留近期
133
- // v1.0.7 节流: 压缩后 COMPACT_MIN_INTERVAL_MS 内不重复, 防每轮对话都付 LLM 压缩成本
134
- async _compactIfNeeded() {
135
- const now = Date.now();
136
- if (now - (this._lastCompactAt || 0) < COMPACT_MIN_INTERVAL_MS) return;
137
- const lines = _renderLines(this.sessionStore, logicalDay());
138
- if (lines.length < COMPACT_THRESHOLD) return;
139
- const compactedLines = lines.slice(0, -COMPACT_KEEP);
140
- const userMsgs = compactedLines
141
- .filter((li) => li.indexOf("用户:") !== -1)
142
- .map((li) => li.split("用户:")[1].trim())
143
- .filter(Boolean);
144
- let summary;
145
- if (this.summarizer && compactedLines.length) {
146
- try {
147
- const raw = compactedLines.slice(0, 60).join("\n");
148
- const s = await this.summarizer(raw);
149
- summary = "[" + logicalDay() + " llm-summary] " + (s || "(空)");
150
- } catch {
151
- summary = "[" + logicalDay() + " thin] archived " + compactedLines.length + " lines (llm fail)";
152
- }
153
- } else if (userMsgs.length) {
154
- summary = "[" + logicalDay() + " thin] " + userMsgs.length + " rounds archived: " + userMsgs.slice(0, 12).join(" | ") + (userMsgs.length > 12 ? " | ..." : "");
155
- } else {
156
- summary = "[" + logicalDay() + " thin] archived " + compactedLines.length + " lines";
157
- }
158
- let longterm = readText(this.longtermMd) || "";
159
- longterm += "\n## " + logicalDay() + " (rollup)\n" + summary + "\n";
160
- writeText(this.longtermMd, longterm);
161
- this._lastCompactAt = now; // 节流: 压缩完成后记录, 60s 内不再压缩
162
- }
163
-
164
- context(userMsg) {
165
- const todayCount = this.sessionStore ? _renderLines(this.sessionStore, logicalDay()).length : 0;
166
- const rawLongterm = readText(this.longtermMd) || "";
167
- const longterm = _longtermExcludingToday(rawLongterm, logicalDay()).slice(-3000);
168
- const topFacts = this.factsTop(userMsg);
169
- // v1.2.0 fix: 今日对话原文由会话历史(history)承载, 不再逐行重复注入到 system,
170
- // 避免模型在 system 的"今日记忆"里看到与 history 相同的对话而重复回话。
171
- const todayNote = todayCount
172
- ? `今日已进行 ${todayCount} 轮对话(完整内容已随会话历史提供)。`
173
- : "今日暂无对话。";
174
- return `
175
- # 今日对话
176
- ${todayNote}
177
-
178
- # 长期记忆 (最近)
179
- ${longterm || "(暂无)"}
180
-
181
- # 关键事实
182
- ${topFacts || "(暂无)"}
183
- `;
184
- }
185
-
186
- // 关键事实: 按当前问题语义检索优先, 不足 8 条用衰减分补齐 (去重)。
187
- // 让每轮自动注入的记忆与当前问题相关, 而非纯衰减取 top (v0.8.1 语义注入)
188
- factsTop(userMsg) {
189
- try {
190
- const q = String(userMsg || "").trim();
191
- const semantic = q ? this.factStore.query(q, { limit: 8 }) : [];
192
- const decay = this.factStore.query("", { limit: 8 });
193
- const seen = new Set();
194
- const merged = [];
195
- for (const f of [...semantic, ...decay]) {
196
- if (seen.has(f.id)) continue;
197
- seen.add(f.id);
198
- merged.push(f);
199
- if (merged.length >= 8) break;
200
- }
201
- return merged.map((f) => `- [${f.score}] ${f.content}`).join("\n");
202
- } catch { return ""; }
203
- }
204
-
205
- // 可观测: longterm 大小 + 今日事件数, 供 agent.stats() 聚合
206
- stats() {
207
- let longtermBytes = 0;
208
- try { longtermBytes = fs.statSync(this.longtermMd).size; } catch {}
209
- return {
210
- longterm_bytes: longtermBytes,
211
- events_today: _renderLines(this.sessionStore, logicalDay()).length,
212
- };
213
- }
214
- }
215
-
216
- // 读取长期记忆, 并剔除"今天"的段落 —— 当日对话已由会话历史(history)承载,
217
- // 若 longterm 里今天滚动块也注入, 会与 history 重复, 是"重复回话"的诱因。
218
- function _longtermExcludingToday(text, today) {
219
- const out = [];
220
- let inToday = false;
221
- for (const l of String(text || "").split("\n")) {
222
- const m = l.match(/^##\s+(\d{4}-\d{2}-\d{2})/);
223
- if (m) { inToday = (m[1] === today); continue; }
224
- if (!inToday) out.push(l);
225
- }
226
- return out.join("\n").trim();
227
- }
228
-
229
- // P1#9: 记忆信号预筛 - 命中关键词或长度信号才触发 LLM 提炼 (省成本)
230
- function _hasSignal(user, assistant) {
231
- const u = String(user || "");
232
- const a = String(assistant || "");
233
- const text = (u + " " + a).slice(0, 500);
234
- if (text.length < 8) return false;
235
- const SIGNAL = /(我是|我喜欢|我讨厌|我记得|请记住|记住|偏好|习惯|目标是|我需要|我要|想买|喜欢|不喜欢|重要|关键|待办|计划|打算|希望|擅长|不擅长|生日|地址|电话|邮箱|账号|密码|股票|基金|仓位|止损|止盈|策略|工作|项目|公司|同事|老板|朋友|家人|对象|结婚|买房|买车|考试|学习)/;
236
- const isGreeting = /^(你好|在吗|谢谢|好的|嗯|ok|hi|hello|再见|拜拜|哈喽)[!。??]*$/i.test(u.trim());
237
- // 收紧: 无信号关键词时, 仅对真正信息密集的长对话触发 LLM 提炼, 避免普通闲聊累积成本 [复审 P1#9]
238
- return !isGreeting && (SIGNAL.test(text) || text.length > 200);
239
- }
240
-
1
+ // src/memory/memory-ticker.js - 记忆水位线 (参考 openhanako v4)
2
+ // 架构: 会话事件日志 (SessionStore) 为唯一事实源; 今日视图由它派生
3
+ // 不再独立维护 today.md 对话原文 (原 l0/session/today 三处重复, 已收敛到 session)
4
+ // 今日视图 -> 滚动压缩 -> longterm.md (长期记忆)
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { ensureDir, readText, writeText, readJson, writeJson, logicalDay } from "../utils/store.js";
8
+ import { debug } from "../utils/logger.js";
9
+
10
+ const TURNS_PER_SUMMARY = 10;
11
+ const COMPACT_THRESHOLD = 50; // 今日事件超此条数触发滚动压缩
12
+ const COMPACT_KEEP = 20; // 压缩后保留的近期条数
13
+ const COMPACT_MIN_INTERVAL_MS = 60000; // 压缩节流: 压缩后 60s 内不重复 (防每轮对话重复付 LLM 压缩成本, v1.0.7)
14
+
15
+ // 派生今日视图行: 从 session 事件渲染 (role -> 中文说话人)
16
+ function _renderLines(sessionStore, day) {
17
+ if (!sessionStore) return [];
18
+ return sessionStore.eventsByDay(day).map((r) => {
19
+ const who = r.role === "user" ? "用户" : "皮皮虾";
20
+ return `- [${new Date(r.timestamp).toISOString()}] ${who}: ${String(r.content).slice(0, 200)}`;
21
+ });
22
+ }
23
+
24
+ export class MemoryTicker {
25
+ constructor(dataDir, factStore, summarizer = null, sessionStore = null) {
26
+ this.summarizer = summarizer;
27
+ this.extractor = null; // P1#9: LLM 结构化提炼器 (agent 注入), 提取关键事实/偏好/待办
28
+ this.dir = path.join(dataDir, "memory");
29
+ ensureDir(this.dir);
30
+ ensureDir(path.join(this.dir, "daily"));
31
+ this.factStore = factStore;
32
+ this.sessionStore = sessionStore; // 唯一事实源 (今日视图由它派生)
33
+ this.todayView = path.join(this.dir, "today.md"); // 兼容路径 (不再作为写入真相)
34
+ this.longtermMd = path.join(this.dir, "longterm.md");
35
+ this.stateFile = path.join(this.dir, "daily-state.json");
36
+ this.state = { day: null, turnCount: 0 };
37
+ this._lastCompactAt = 0; // 压缩节流时间戳 (v1.0.7)
38
+ this._loadState();
39
+ this._rollDay();
40
+ }
41
+
42
+ _loadState() {
43
+ this.state = readJson(this.stateFile, { day: null, turnCount: 0 });
44
+ }
45
+
46
+ _saveState() {
47
+ writeJson(this.stateFile, this.state);
48
+ }
49
+
50
+ _rollDay() {
51
+ const today = logicalDay();
52
+ if (this.state.day !== today) {
53
+ if (this.state.day) this._compileDaily();
54
+ this.state.day = today;
55
+ this._saveState();
56
+ }
57
+ }
58
+
59
+ // 跨天: 把上一日事件归档到 daily/ 并滚入 longterm (从 session 派生, 非 today.md)
60
+ _compileDaily() {
61
+ const day = this.state.day;
62
+ const lines = _renderLines(this.sessionStore, day);
63
+ const dailyFile = path.join(this.dir, "daily", `${day}.md`);
64
+ writeText(dailyFile, `# ${day}
65
+
66
+ ${lines.join("\n")}\n`);
67
+ if (lines.length) {
68
+ let longterm = readText(this.longtermMd) || "";
69
+ longterm += `\n## ${day}\n${lines.join("\n")}\n`;
70
+ writeText(this.longtermMd, longterm);
71
+ }
72
+ }
73
+
74
+ async recordTurn(user, assistant) {
75
+ this._rollDay();
76
+ this.state.turnCount += 1;
77
+ this._saveState();
78
+ // 对话原文已由 session 事件日志保存; 今日视图由 session 派生
79
+ if (this.state.turnCount % TURNS_PER_SUMMARY === 0) this._compileDaily_Rolling();
80
+ await this._compactIfNeeded();
81
+ // P1#9: 若配 LLM 提炼器且本次对话含高信号, 走结构化提炼; 否则退回启发式
82
+ if (this.extractor && _hasSignal(user, assistant)) {
83
+ try {
84
+ // 感知式提炼: 先检索与本次对话相关的已有记忆 (同主题 top 3), 喂给提炼器从源头去重
85
+ const related = String(user || "").trim()
86
+ ? this.factStore.query(user, { limit: 3 }).filter((f) => !(f.hits && f.hits > 5)) // 高命中已稳定, 不再重复提炼
87
+ : [];
88
+ const facts = await this.extractor(String(user || ""), String(assistant || ""), related);
89
+ if (facts && facts.length) {
90
+ // similarThreshold=0.6: LLM 提炼的字面变体 (同义不同词) 与已有事实语义相似时命中加分,
91
+ // 防「三件套」这类反复提炼的变体污染记忆库
92
+ for (const f of facts) this.factStore.add(f, { source: "extract", similarThreshold: 0.6 });
93
+ return;
94
+ }
95
+ } catch (e) { debug(`[memory/memory-ticker] 已忽略异常: ${e && e.message ? e.message : e}`); }
96
+ }
97
+ if (user) this.factStore.addMemory(user);
98
+ }
99
+
100
+ // P1#9: 注入 LLM 结构化提炼器 (agent 调 setExtractor)
101
+ setExtractor(fn) { this.extractor = typeof fn === "function" ? fn : null; }
102
+
103
+ // 每 N 轮: 把今日新增事件滚动归档进 longterm (从 session 派生)
104
+ // v1.2.0 fix: 用游标(lastRolledDay/lastRolledSeq)只追加"本次滚动之后"的新事件,
105
+ // 不再每次把今日全文重复追加 —— 否则同一段对话会在 longterm 中反复出现, 加剧重复回话。
106
+ _compileDaily_Rolling() {
107
+ const today = logicalDay();
108
+ const st = this.state;
109
+ if (st.lastRolledDay !== today) { st.lastRolledDay = today; st.lastRolledSeq = 0; } // 跨天重置游标
110
+ const afterSeq = st.lastRolledSeq || 0;
111
+ const rows = [];
112
+ let maxSeq = afterSeq;
113
+ const dayEvents = this.sessionStore && typeof this.sessionStore.replayDay === "function"
114
+ ? this.sessionStore.replayDay(today)
115
+ : [];
116
+ for (const e of dayEvents) {
117
+ if (e.seq <= afterSeq) continue;
118
+ const who = e.type === "user/message" ? "用户" : "皮皮虾";
119
+ rows.push(`- [${new Date(e.ts).toISOString()}] ${who}: ${String(e.data?.content || "").slice(0, 200)}`);
120
+ if (e.seq > maxSeq) maxSeq = e.seq;
121
+ }
122
+ if (rows.length) {
123
+ let longterm = readText(this.longtermMd) || "";
124
+ longterm += `\n## ${today} (滚动)\n${rows.join("\n")}\n`;
125
+ writeText(this.longtermMd, longterm);
126
+ }
127
+ st.lastRolledSeq = maxSeq;
128
+ this._saveState();
129
+ }
130
+
131
+ // 滚动压缩: 今日事件超量时, 把最旧对话聚合压缩进 longterm, 只留近期
132
+ // v1.0.7 节流: 压缩后 COMPACT_MIN_INTERVAL_MS 内不重复, 防每轮对话都付 LLM 压缩成本
133
+ async _compactIfNeeded() {
134
+ const now = Date.now();
135
+ if (now - (this._lastCompactAt || 0) < COMPACT_MIN_INTERVAL_MS) return;
136
+ const lines = _renderLines(this.sessionStore, logicalDay());
137
+ if (lines.length < COMPACT_THRESHOLD) return;
138
+ const compactedLines = lines.slice(0, -COMPACT_KEEP);
139
+ const userMsgs = compactedLines
140
+ .filter((li) => li.indexOf("用户:") !== -1)
141
+ .map((li) => li.split("用户:")[1].trim())
142
+ .filter(Boolean);
143
+ let summary;
144
+ if (this.summarizer && compactedLines.length) {
145
+ try {
146
+ const raw = compactedLines.slice(0, 60).join("\n");
147
+ const s = await this.summarizer(raw);
148
+ summary = "[" + logicalDay() + " llm-summary] " + (s || "(空)");
149
+ } catch {
150
+ summary = "[" + logicalDay() + " thin] archived " + compactedLines.length + " lines (llm fail)";
151
+ }
152
+ } else if (userMsgs.length) {
153
+ summary = "[" + logicalDay() + " thin] " + userMsgs.length + " rounds archived: " + userMsgs.slice(0, 12).join(" | ") + (userMsgs.length > 12 ? " | ..." : "");
154
+ } else {
155
+ summary = "[" + logicalDay() + " thin] archived " + compactedLines.length + " lines";
156
+ }
157
+ let longterm = readText(this.longtermMd) || "";
158
+ longterm += "\n## " + logicalDay() + " (rollup)\n" + summary + "\n";
159
+ writeText(this.longtermMd, longterm);
160
+ this._lastCompactAt = now; // 节流: 压缩完成后记录, 60s 内不再压缩
161
+ }
162
+
163
+ context(userMsg) {
164
+ const todayCount = this.sessionStore ? _renderLines(this.sessionStore, logicalDay()).length : 0;
165
+ const rawLongterm = readText(this.longtermMd) || "";
166
+ const longterm = _longtermExcludingToday(rawLongterm, logicalDay()).slice(-3000);
167
+ const topFacts = this.factsTop(userMsg);
168
+ // v1.2.0 fix: 今日对话原文由会话历史(history)承载, 不再逐行重复注入到 system,
169
+ // 避免模型在 system 的"今日记忆"里看到与 history 相同的对话而重复回话。
170
+ const todayNote = todayCount
171
+ ? `今日已进行 ${todayCount} 轮对话(完整内容已随会话历史提供)。`
172
+ : "今日暂无对话。";
173
+ return `
174
+ # 今日对话
175
+ ${todayNote}
176
+
177
+ # 长期记忆 (最近)
178
+ ${longterm || "(暂无)"}
179
+
180
+ # 关键事实
181
+ ${topFacts || "(暂无)"}
182
+ `;
183
+ }
184
+
185
+ // 关键事实: 按当前问题语义检索优先, 不足 8 条用衰减分补齐 (去重)。
186
+ // 让每轮自动注入的记忆与当前问题相关, 而非纯衰减取 top (v0.8.1 语义注入)
187
+ factsTop(userMsg) {
188
+ try {
189
+ const q = String(userMsg || "").trim();
190
+ const semantic = q ? this.factStore.query(q, { limit: 8 }) : [];
191
+ const decay = this.factStore.query("", { limit: 8 });
192
+ const seen = new Set();
193
+ const merged = [];
194
+ for (const f of [...semantic, ...decay]) {
195
+ if (seen.has(f.id)) continue;
196
+ seen.add(f.id);
197
+ merged.push(f);
198
+ if (merged.length >= 8) break;
199
+ }
200
+ return merged.map((f) => `- [${f.score}] ${f.content}`).join("\n");
201
+ } catch { return ""; }
202
+ }
203
+
204
+ // 可观测: longterm 大小 + 今日事件数, 供 agent.stats() 聚合
205
+ stats() {
206
+ let longtermBytes = 0;
207
+ try { longtermBytes = fs.statSync(this.longtermMd).size; } catch (e) { debug(`[memory/memory-ticker] 已忽略异常: ${e && e.message ? e.message : e}`); }
208
+ return {
209
+ longterm_bytes: longtermBytes,
210
+ events_today: _renderLines(this.sessionStore, logicalDay()).length,
211
+ };
212
+ }
213
+ }
214
+
215
+ // 读取长期记忆, 并剔除"今天"的段落 —— 当日对话已由会话历史(history)承载,
216
+ // 若 longterm 里今天滚动块也注入, 会与 history 重复, 是"重复回话"的诱因。
217
+ function _longtermExcludingToday(text, today) {
218
+ const out = [];
219
+ let inToday = false;
220
+ for (const l of String(text || "").split("\n")) {
221
+ const m = l.match(/^##\s+(\d{4}-\d{2}-\d{2})/);
222
+ if (m) { inToday = (m[1] === today); continue; }
223
+ if (!inToday) out.push(l);
224
+ }
225
+ return out.join("\n").trim();
226
+ }
227
+
228
+ // P1#9: 记忆信号预筛 - 命中关键词或长度信号才触发 LLM 提炼 (省成本)
229
+ function _hasSignal(user, assistant) {
230
+ const u = String(user || "");
231
+ const a = String(assistant || "");
232
+ const text = (u + " " + a).slice(0, 500);
233
+ if (text.length < 8) return false;
234
+ const SIGNAL = /(我是|我喜欢|我讨厌|我记得|请记住|记住|偏好|习惯|目标是|我需要|我要|想买|喜欢|不喜欢|重要|关键|待办|计划|打算|希望|擅长|不擅长|生日|地址|电话|邮箱|账号|密码|股票|基金|仓位|止损|止盈|策略|工作|项目|公司|同事|老板|朋友|家人|对象|结婚|买房|买车|考试|学习)/;
235
+ const isGreeting = /^(你好|在吗|谢谢|好的|嗯|ok|hi|hello|再见|拜拜|哈喽)[!。??]*$/i.test(u.trim());
236
+ // 收紧: 无信号关键词时, 仅对真正信息密集的长对话触发 LLM 提炼, 避免普通闲聊累积成本 [复审 P1#9]
237
+ return !isGreeting && (SIGNAL.test(text) || text.length > 200);
238
+ }
239
+