ppxans-harness 2.6.0 → 3.2.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 (189) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +195 -195
  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 +6 -124
  11. package/config/ppx.json.example +143 -0
  12. package/package.json +16 -6
  13. package/skills/.usage.json +6 -0
  14. package/skills/agent-professional-training/SKILL.md +94 -0
  15. package/skills/agent-professional-training/trajectories/2026-09-11-dpo-pairs.jsonl +2 -0
  16. package/skills/agent-professional-training/trajectories/2026-09-11-round2-trajectory.json +15 -0
  17. package/skills/agent-professional-training/trajectories/2026-09-13-first-run-trajectory.json +17 -0
  18. package/skills/brainstorm/SKILL.md +24 -0
  19. package/skills/cupid-lover-comms/SKILL.md +37 -0
  20. package/skills/debug/SKILL.md +26 -0
  21. package/skills/plan/SKILL.md +25 -0
  22. package/skills/ponytail/SKILL.md +25 -0
  23. package/skills/ppx-memory/SKILL.md +91 -0
  24. package/skills/ppx-memory/scripts/cli.js +192 -0
  25. package/skills/ppx-memory/scripts/experience.js +133 -0
  26. package/skills/ppx-memory/scripts/fact-store.js +842 -0
  27. package/skills/ppx-memory/scripts/l0.js +52 -0
  28. package/skills/ppx-memory/scripts/l2.js +146 -0
  29. package/skills/ppx-memory/scripts/l3.js +112 -0
  30. package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
  31. package/skills/ppx-memory/scripts/pii.js +42 -0
  32. package/skills/ppx-memory/scripts/schema.js +80 -0
  33. package/skills/ppx-memory/scripts/session.js +398 -0
  34. package/skills/ppx-memory/scripts/similarity.js +43 -0
  35. package/skills/ppx-memory/scripts/store.js +116 -0
  36. package/skills/ppx-memory/scripts/wal.js +38 -0
  37. package/skills/ppx-selfheal/SKILL.md +24 -0
  38. package/skills/ppx-selfheal/scripts/cli.js +80 -0
  39. package/skills/ppx-selfheal/scripts/healer.js +184 -0
  40. package/skills/ppx-selfheal/scripts/logger.js +17 -0
  41. package/skills/ppx-selfheal/scripts/store.js +116 -0
  42. package/skills/prompt-depth-kit/SKILL.md +28 -0
  43. package/skills/session-naming/SKILL.md +36 -0
  44. package/skills/verify/SKILL.md +25 -0
  45. package/src/agent/index.js +1352 -717
  46. package/src/agent/prompts.js +46 -2
  47. package/src/aml-server.js +197 -151
  48. package/src/ans/eviction.js +123 -143
  49. package/src/ans/guard.js +159 -120
  50. package/src/ans/lifecycle.js +96 -93
  51. package/src/ans/proactive.js +112 -129
  52. package/src/ans/reward.js +95 -111
  53. package/src/ans/values.js +15 -15
  54. package/src/audit/audit-chain.js +43 -7
  55. package/src/audit/verifier.js +157 -120
  56. package/src/bus/circuit-breaker.js +9 -1
  57. package/src/bus/runtime-bus.js +107 -93
  58. package/src/channels/base.js +57 -34
  59. package/src/channels/feishu.js +118 -126
  60. package/src/channels/http.js +1090 -651
  61. package/src/channels/index.js +111 -110
  62. package/src/channels/log.js +29 -29
  63. package/src/channels/wechat-crypto.js +73 -74
  64. package/src/channels/wechat.js +191 -197
  65. package/src/channels/workspace.js +94 -0
  66. package/src/channels-cli.js +126 -124
  67. package/src/cli.js +129 -120
  68. package/src/commands/index.js +142 -0
  69. package/src/config/channels.js +137 -170
  70. package/src/config/index.js +277 -224
  71. package/src/config/placeholder.js +31 -0
  72. package/src/config/providers.js +148 -182
  73. package/src/config/settings.js +156 -182
  74. package/src/core/policy.js +69 -18
  75. package/src/core/trace.js +8 -6
  76. package/src/edit/editblock.js +266 -0
  77. package/src/edit/snapshot.js +67 -0
  78. package/src/evidence/index.js +153 -0
  79. package/src/evolve/playbook.js +9 -10
  80. package/src/hooks/index.js +113 -0
  81. package/src/llm/client.js +4 -3
  82. package/src/llm/dsml.js +74 -74
  83. package/src/llm/embedder.js +41 -35
  84. package/src/llm/fence.js +52 -52
  85. package/src/llm/index.js +4 -4
  86. package/src/llm/local-embedder.js +94 -0
  87. package/src/llm/presets.js +113 -0
  88. package/src/llm/pricing.js +93 -0
  89. package/src/llm/retry.js +73 -73
  90. package/src/llm/router.js +92 -89
  91. package/src/mcp/admin.js +44 -10
  92. package/src/mcp/client.js +487 -477
  93. package/src/mcp/http.js +23 -37
  94. package/src/mcp/index.js +116 -116
  95. package/src/mcp/server.js +23 -18
  96. package/src/mcp/tasks.js +9 -14
  97. package/src/memory/asset-hub.js +6 -11
  98. package/src/memory/canvas.js +2 -5
  99. package/src/memory/compaction.js +28 -28
  100. package/src/memory/experience.js +133 -122
  101. package/src/memory/fact-store.js +914 -698
  102. package/src/memory/failure-episode.js +20 -11
  103. package/src/memory/fork.js +17 -8
  104. package/src/memory/index.js +8 -6
  105. package/src/memory/l0.js +53 -52
  106. package/src/memory/l2.js +145 -130
  107. package/src/memory/l3.js +111 -111
  108. package/src/memory/legion-board.js +71 -0
  109. package/src/memory/memory-ticker.js +239 -240
  110. package/src/memory/session.js +398 -397
  111. package/src/memory/sqlite-store.js +581 -0
  112. package/src/mode/blackboard.js +49 -49
  113. package/src/mode/graph.js +42 -41
  114. package/src/mode/index.js +64 -64
  115. package/src/mode/legion.js +54 -51
  116. package/src/mode/plan-exec.js +50 -50
  117. package/src/mode/router.js +28 -40
  118. package/src/orchestrator/agent-worker.js +69 -69
  119. package/src/orchestrator/dag.js +90 -83
  120. package/src/orchestrator/experts.js +76 -0
  121. package/src/orchestrator/index.js +1 -1
  122. package/src/orchestrator/legion.js +179 -187
  123. package/src/orchestrator/supervisor.js +6 -8
  124. package/src/permissions/index.js +378 -0
  125. package/src/persona/index.js +28 -29
  126. package/src/plugin/builtin.js +313 -215
  127. package/src/plugin/context.js +80 -79
  128. package/src/plugin/index.js +62 -62
  129. package/src/plugin/v3.js +73 -0
  130. package/src/protocol/index.js +148 -0
  131. package/src/repomap/index.js +309 -0
  132. package/src/review/index.js +393 -0
  133. package/src/seam/registry.js +3 -0
  134. package/src/seam/shell.js +55 -55
  135. package/src/security/injection.js +79 -0
  136. package/src/selfheal/evolve.js +67 -67
  137. package/src/selfheal/healer.js +184 -167
  138. package/src/selfheal/run.js +9 -9
  139. package/src/server.js +63 -60
  140. package/src/services/diagnose.js +180 -0
  141. package/src/services/learning-service.js +9 -0
  142. package/src/services/memory-health.js +34 -6
  143. package/src/services/memory-service.js +42 -9
  144. package/src/services/triage.js +138 -0
  145. package/src/session/parts.js +76 -0
  146. package/src/session/projection.js +73 -0
  147. package/src/session/rollout.js +54 -0
  148. package/src/session/turn.js +137 -0
  149. package/src/skills/lint.js +72 -0
  150. package/src/skills/loader.js +231 -150
  151. package/src/skills/search.js +58 -0
  152. package/src/skills/verify.js +95 -100
  153. package/src/tools/advanced.js +388 -352
  154. package/src/tools/builtin.js +384 -297
  155. package/src/tools/catalog.js +283 -159
  156. package/src/tools/command-guard.js +112 -112
  157. package/src/tools/custom.js +47 -47
  158. package/src/tools/delegate.js +383 -297
  159. package/src/tools/document.js +254 -253
  160. package/src/tools/git.js +151 -0
  161. package/src/tools/governance.js +47 -20
  162. package/src/tools/index.js +16 -11
  163. package/src/tools/methods.js +178 -178
  164. package/src/tools/ocr.js +59 -59
  165. package/src/tools/sandbox-worker.js +40 -0
  166. package/src/tools/sandbox.js +92 -0
  167. package/src/tools/seam.js +162 -125
  168. package/src/tools/selfmod.js +196 -176
  169. package/src/tools/v3.js +225 -0
  170. package/src/tools/vad.js +176 -0
  171. package/src/tools/voice.js +238 -0
  172. package/src/utils/async.js +14 -0
  173. package/src/utils/config-file.js +53 -0
  174. package/src/utils/crashguard.js +88 -0
  175. package/src/utils/http.js +53 -0
  176. package/src/utils/id.js +8 -0
  177. package/src/utils/json-state.js +33 -0
  178. package/src/utils/logger.js +17 -17
  179. package/src/utils/ndjson.js +25 -0
  180. package/src/utils/pii.js +42 -42
  181. package/src/utils/rate-limit.js +50 -0
  182. package/src/utils/schema.js +80 -0
  183. package/src/utils/similarity.js +43 -0
  184. package/src/utils/store.js +170 -108
  185. package/src/utils/text.js +15 -15
  186. package/src/utils/trace.js +153 -153
  187. package/src/utils/wal.js +39 -0
  188. package/src/utils/winutf8.js +16 -15
  189. package/src/wiki/index.js +170 -0
@@ -1,398 +1,399 @@
1
- // src/memory/session.js - 会话事件日志 (吸收 dsh "会话即唯一事实源")
2
- // 新架构: 不可变 append-only 事件日志, 每条 = {seq, ts, type, data}
3
- // - 模型可见历史 = deriveMessages() 从日志投影 (无可变状态, 仅从日志重建)
4
- // - replay() 回放完整事件流 | fork() 从边界派生新会话
5
- // - 兼容旧 get/set/has/delete 接口 (agent 无需全量改动)
6
- // 吸收自 DeepSeek Harness: "model-visible means logged" (能进模型的必须能从日志重建)
7
- //
8
- // v1.1.x 第九轮 review P2: default 主会话按天分片 (default-YYYY-MM-DD.jsonl),
9
- // 单文件不再无限增长。设计要点:
10
- // - 仅 key === "default" 走按天分片; 非 default 会话保持单文件 (不扩散改动面)
11
- // - 命名: default-YYYY-MM-DD.jsonl (取事件 ts 所在本地自然日, 与 eventsByDay/logicalDay 一致)
12
- // - 兼容旧文件: 若历史遗留 default.jsonl, 读取时纳入合并, 不丢历史
13
- // - seq 跨天连续递增: 同一 default 会话所有分片共用一个 seq 序列, 不从 1 重头数
14
- // (否则 compaction 的 upToSeq / fork / replay 会错乱)
15
- // - 所有读取路径把多天分片合并成按 seq(ts) 升序的单一事件流
16
- import fs from "node:fs";
17
- import path from "node:path";
18
- import { ensureDir } from "../utils/store.js";
19
-
20
- // 事件类型集 (对齐 dsh 事件域: user/assistant/tool/system)
21
- export const EVENTS = {
22
- USER: "user/message",
23
- ASSISTANT: "assistant/message",
24
- SYSTEM: "system",
25
- TOOL_CALL: "tool/call",
26
- TOOL_RESULT: "tool/result",
27
- COMPACTION: "compaction/summary",
28
- };
29
-
30
- // default 会话按天分片: default-YYYY-MM-DD.jsonl
31
- const DEFAULT_SHARD_RE = /^default-\d{4}-\d{2}-\d{2}\.jsonl$/;
32
-
33
- export class SessionStore {
34
- constructor(dataDir) {
35
- this.dir = path.join(dataDir, "sessions");
36
- ensureDir(this.dir);
37
- this._logs = new Map(); // key -> event[] (不可变, append-only)
38
- this._nextSeq = new Map(); // key -> 下一个 seq
39
- this._flushedSeq = new Map(); // key -> 已落盘的最大 seq (增量追加用)
40
- this._loadAll();
41
- // --- 派生缓存 (v1.1.1 性能优化) ---
42
- // eventsByDay / deriveCompacted 在每一轮/每一工具轮都被调用, 旧实现每次全量扫描 O(T)。
43
- // 事件日志是 append-only 不可变, 一个 key 的 events 数组只增不改(set/rename/fork 会换新数组)。
44
- // · deriveCompacted: 以"数组引用标识"做增量缓存——数组没换就只把尾部新事件追加进 msgs, 命中 O(Δ);
45
- // 数组被替换(重命名/重建)则重算。compaction 事件追加时检测到也整体重算(它会重投影历史)。
46
- // · eventsByDay: 版本门控的全库按天缓存, 版本变化时整库重扫一次(每轮兜底, 仍远优于每次 O(T))。
47
- this._version = 0; // 全局版本: 任何 write 递增
48
- this._dayCache = new Map(); // day -> 该天结果 (当期 version)
49
- this._dayCacheAt = 0; // 生成该缓存时的 _version
50
- // deriveCompacted 增量缓存: key -> { arrRef, msgs, lastCompSeq }
51
- this._compactedCache = new Map();
52
- }
53
-
54
- _bump() { this._version += 1; }
55
-
56
- _safe(key) { return String(key || "default").replace(/[^\w.-]/g, "_"); }
57
- // 旧单文件路径 (仅非 default 会话使用)
58
- _file(key) { return path.join(this.dir, this._safe(key) + ".jsonl"); }
59
-
60
- _log(key) {
61
- const k = this._safe(key);
62
- if (!this._logs.has(k)) { this._logs.set(k, []); this._nextSeq.set(k, 0); }
63
- return this._logs.get(k);
64
- }
65
-
66
- // 判断文件名是否为 default 的日分片
67
- _isShard(fname) { return DEFAULT_SHARD_RE.test(fname); }
68
- // default 分片文件路径 (day 形如 YYYY-MM-DD)
69
- _shardFile(day) { return path.join(this.dir, `default-${day}.jsonl`); }
70
- // 事件 ts 归属的本地自然日 (与 eventsByDay/logicalDay 一致, 避免时区错位)
71
- _dayOf(ts) {
72
- const d = new Date(ts);
73
- const m = String(d.getMonth() + 1).padStart(2, "0");
74
- const day = String(d.getDate()).padStart(2, "0");
75
- return `${d.getFullYear()}-${m}-${day}`;
76
- }
77
- // 列出 default 相关的所有文件 (旧单文件 + 各日分片)
78
- _defaultFiles() {
79
- let files = [];
80
- try {
81
- files = fs.readdirSync(this.dir)
82
- .filter((f) => f.endsWith(".jsonl") && (f === "default.jsonl" || this._isShard(f)));
83
- } catch {}
84
- return files.map((f) => path.join(this.dir, f));
85
- }
86
- _removeDefaultFiles() {
87
- for (const f of this._defaultFiles()) { try { fs.rmSync(f, { force: true }); } catch {} }
88
- }
89
-
90
- // 读取单个 jsonl 文件的事件序列
91
- _readEventsFromFile(file) {
92
- const events = [];
93
- try {
94
- for (const l of fs.readFileSync(file, "utf8").split("\n").filter(Boolean)) {
95
- try { const e = JSON.parse(l); if (e && e.seq && e.type && e.data) events.push(e); } catch {}
96
- }
97
- } catch {}
98
- return events;
99
- }
100
-
101
- _loadAll() {
102
- let files = [];
103
- try { files = fs.readdirSync(this.dir).filter((f) => f.endsWith(".jsonl")); } catch { return; }
104
- for (const f of files) {
105
- // default 的旧单文件 + 日分片 → 归并到同一个 key="default"
106
- if (f === "default.jsonl" || this._isShard(f)) {
107
- const events = this._readEventsFromFile(path.join(this.dir, f));
108
- if (events.length) {
109
- const k = "default";
110
- const cur = this._logs.get(k) || [];
111
- this._logs.set(k, cur.concat(events));
112
- }
113
- continue;
114
- }
115
- const k = f.replace(/\.jsonl$/, "");
116
- const events = this._readEventsFromFile(path.join(this.dir, f));
117
- if (events.length) {
118
- events.sort((a, b) => a.seq - b.seq);
119
- this._logs.set(k, events);
120
- this._nextSeq.set(k, events[events.length - 1].seq);
121
- this._flushedSeq.set(k, events[events.length - 1].seq);
122
- }
123
- }
124
- // default: 跨文件合并后按 seq(ts 兜底) 升序 + 统一 next/flushed seq (跨天连续递增)
125
- if (this._logs.has("default")) {
126
- const evs = this._logs.get("default");
127
- evs.sort((a, b) => a.seq - b.seq || a.ts - b.ts);
128
- this._nextSeq.set("default", evs[evs.length - 1].seq);
129
- this._flushedSeq.set("default", evs[evs.length - 1].seq);
130
- }
131
- }
132
-
133
- // 追加事件 (唯一写入路径, append-only, 永不重写/裁剪日志文件)
134
- // ts 可选: 测试/回溯时可注入固定时间戳; 缺省用 Date.now() (向后兼容)
135
- // 追加事件 (唯一写入路径, append-only, 永不重写/裁剪日志文件)
136
- // ts 可选: 测试/回溯时可注入固定时间戳; 缺省用 Date.now() (向后兼容)
137
- // opts.skipFlush: true 时延后落盘 (调用方需随后显式 flush(key) 或下次 append), 用于一次性批量写入
138
- append(key, type, data, ts, { skipFlush = false } = {}) {
139
- const k = this._safe(key);
140
- const seq = (this._nextSeq.get(k) || 0) + 1;
141
- const ev = { seq, ts: Number.isFinite(ts) ? ts : Date.now(), type, data };
142
- this._log(k).push(ev);
143
- this._nextSeq.set(k, seq);
144
- if (!skipFlush) this._flush(k);
145
- this._bump(); // 使派生缓存失效
146
- return ev;
147
- }
148
-
149
- // 显式落盘: 把 skipFlush 延后的待写事件写入磁盘 (供批量写入后调用)
150
- flush(key) { this._flush(this._safe(key)); }
151
-
152
- // 从日志投影模型可见历史 (user/assistant), 无可变状态
153
- deriveMessages(key) {
154
- return this._log(this._safe(key))
155
- .filter((e) => e.type === EVENTS.USER || e.type === EVENTS.ASSISTANT)
156
- .map((e) => ({ role: e.type === EVENTS.USER ? "user" : "assistant", content: e.data?.content }));
157
- }
158
-
159
- // 投影「压缩后」的模型可见历史: 最后一条 compaction 事件之前 (seq <= upToSeq) 的消息被摘要替换
160
- // 吸收 OpenClaw compaction: 摘要作为单一 surface node 替换被压缩区间, 日志本身不可变
161
- // v1.1.1: 增量缓存——数组引用没变时只 O(Δ) 追加尾部消息; 数组被替换或追加 compaction 时整体重算。
162
- deriveCompacted(key) {
163
- const k = this._safe(key);
164
- const evs = this._log(k);
165
- const cached = this._compactedCache.get(k);
166
- // 数组引用未变 (append-only 稳定), 且无新 compaction: 只把尾部新 user/assistant 追加进 msgs
167
- if (cached && cached.arrRef === evs) {
168
- if (cached.consumed === evs.length) return cached.msgs; // 无新增, 直接命中
169
- // 检查新增区间是否含 compaction (compaction 会重投影历史, 需整体重算)
170
- for (let i = cached.consumed; i < evs.length; i++) {
171
- if (evs[i].type === EVENTS.COMPACTION) return this._deriveCompactedFrom(evs, k);
172
- }
173
- // 只有 user/assistant 追加: 把可见的尾部消息追加进 msgs
174
- for (let i = cached.consumed; i < evs.length; i++) {
175
- const e = evs[i];
176
- if (e.type === EVENTS.USER || e.type === EVENTS.ASSISTANT) {
177
- cached.msgs.push({ role: e.type === EVENTS.USER ? "user" : "assistant", content: e.data?.content });
178
- }
179
- }
180
- cached.consumed = evs.length;
181
- return cached.msgs;
182
- }
183
- // 无缓存 / 数组被替换 (set/rename/fork/delete): 整体重算
184
- return this._deriveCompactedFrom(evs, k);
185
- }
186
-
187
- _deriveCompactedFrom(evs, k) {
188
- let lastComp = null;
189
- for (const e of evs) if (e.type === EVENTS.COMPACTION) lastComp = e;
190
- const upToSeq = lastComp?.data?.upToSeq || 0;
191
- const msgs = [];
192
- let consumed = 0;
193
- if (lastComp?.data?.summary) {
194
- msgs.push({ role: "system", content: String(lastComp.data.summary) });
195
- }
196
- for (const e of evs) {
197
- consumed++;
198
- if (e.seq <= upToSeq) continue;
199
- if (e.type === EVENTS.USER || e.type === EVENTS.ASSISTANT) {
200
- msgs.push({ role: e.type === EVENTS.USER ? "user" : "assistant", content: e.data?.content });
201
- }
202
- }
203
- this._compactedCache.set(k, { arrRef: evs, msgs, consumed, lastCompSeq: lastComp?.seq || 0 });
204
- return msgs;
205
- }
206
-
207
- // 完整事件流 (回放/审计/轨迹)
208
- replay(key) { return [...this._log(this._safe(key))]; }
209
-
210
- // L0 只读代理 / 按天审计: 遍历所有会话, 派生某天的对话事件 (消除 l0/*.jsonl 重复)
211
- // v1.1.1: 全库按天缓存, 版本未变则零成本; 保持"本地自然日"语义 (与 _dayOf/logicalDay 一致, 避免时区错位)
212
- eventsByDay(day) {
213
- const dayStr = String(day).slice(0, 10);
214
- const dayMs = new Date(dayStr + "T00:00:00").getTime();
215
- if (Number.isNaN(dayMs)) return [];
216
- // 缓存: 版本未变时按 day 命中 (append 后 _bump 使整个缓存失效, 下轮重扫一次)
217
- if (this._dayCacheAt === this._version && this._dayCache.size) {
218
- const hit = this._dayCache.get(dayStr);
219
- if (hit) return hit;
220
- }
221
- // 全库扫描一次, 按"事件 ts 的本地自然日"聚合缓存所有天 → 之后所有 eventsByDay 调用都命中
222
- const byDay = new Map();
223
- for (const [key, events] of this._logs) {
224
- for (const e of events) {
225
- if (e.type !== EVENTS.USER && e.type !== EVENTS.ASSISTANT) continue;
226
- const d = this._dayOf(e.ts);
227
- let arr = byDay.get(d);
228
- if (!arr) { arr = []; byDay.set(d, arr); }
229
- arr.push({ sessionKey: key, role: e.type === EVENTS.USER ? "user" : "assistant", content: e.data?.content, timestamp: e.ts });
230
- }
231
- }
232
- this._dayCache = byDay;
233
- this._dayCacheAt = this._version;
234
- const out = byDay.get(dayStr) || [];
235
- out.sort((a, b) => a.timestamp - b.timestamp);
236
- return out;
237
- }
238
-
239
- // 返回某自然日的对话事件(带 seq/type/data), 供 memory-ticker 按 seq 去重滚动归档
240
- // (防止 _compileDaily_Rolling 每次把今日全文再次追加, 造成 longterm 内容重复累积)
241
- replayDay(day) {
242
- const dayStr = String(day).slice(0, 10);
243
- const out = [];
244
- for (const [, events] of this._logs) {
245
- for (const e of events) {
246
- if (e.type !== EVENTS.USER && e.type !== EVENTS.ASSISTANT) continue;
247
- if (this._dayOf(e.ts) !== dayStr) continue;
248
- out.push(e);
249
- }
250
- }
251
- out.sort((a, b) => a.seq - b.seq);
252
- return out;
253
- }
254
-
255
- // fork: 从 boundarySeq 及更早派生新会话 (吸收 dsh fork 语义, 保留不可变源)
256
- fork(fromKey, boundarySeq, toKey) {
257
- const keep = this._log(this._safe(fromKey)).filter((e) => e.seq <= boundarySeq);
258
- const k = this._safe(toKey);
259
- // 目标为 default 时先清掉旧分片, 再按边界重建 (避免 keep 追加到已有同 seq 行造成重复)
260
- if (k === "default") this._removeDefaultFiles();
261
- this._logs.set(k, [...keep]);
262
- this._nextSeq.set(k, keep.length ? keep[keep.length - 1].seq : 0);
263
- this._flushedSeq.delete(k);
264
- this._flush(k);
265
- this._bump();
266
- return keep;
267
- }
268
-
269
- // count: 事件条数
270
- count(key) { return this._log(this._safe(key)).length; }
271
-
272
- // 列出所有会话: {key, count, lastTs, title} (title 取首条 user 消息前 20 字)
273
- // 供 Web UI 多会话管理 (P1#6)
274
- list() {
275
- const out = [];
276
- for (const [key, events] of this._logs) {
277
- if (!events.length) continue;
278
- const firstUser = events.find((e) => e.type === EVENTS.USER);
279
- const title = firstUser?.data?.content ? String(firstUser.data.content).slice(0, 20) : key;
280
- out.push({
281
- key,
282
- count: events.length,
283
- lastTs: events[events.length - 1].ts,
284
- title,
285
- });
286
- }
287
- out.sort((a, b) => (b.lastTs || 0) - (a.lastTs || 0));
288
- return out;
289
- }
290
-
291
- // ---- 兼容旧接口 (agent/CLI 仍可用) ----
292
- get(key) { return this.deriveMessages(key); }
293
- set(key, history) {
294
- const k = this._safe(key);
295
- // default 为分片存储: 清掉内存前先删旧分片, 保证重建后磁盘与内存一致 (无残留旧文件)
296
- if (k === "default") this._removeDefaultFiles();
297
- this._logs.delete(k); this._nextSeq.set(k, 0); this._flushedSeq.delete(k);
298
- for (const m of (history || [])) this.append(k, m.role === "user" ? EVENTS.USER : EVENTS.ASSISTANT, { content: m.content });
299
- return history;
300
- }
301
- has(key) { return this._logs.has(this._safe(key)); }
302
- // 重命名会话: 复制事件到新 key 并删除旧 key (保留 seq 顺序) [P1#6]
303
- rename(fromKey, toKey) {
304
- const f = this._safe(fromKey), t = this._safe(toKey);
305
- if (f === t) return false;
306
- if (!this._logs.has(f)) return false;
307
- if (this._logs.has(t)) return false; // 目标已存在, 拒绝覆盖防数据丢失 [复审 P1]
308
- const events = this._logs.get(f);
309
- this._logs.set(t, [...events]);
310
- const lastSeq = events.length ? events[events.length - 1].seq : 0;
311
- this._nextSeq.set(t, lastSeq);
312
- this._flushedSeq.delete(t);
313
- this._flush(t);
314
- this.delete(f);
315
- this._bump();
316
- return true;
317
- }
318
- delete(key) {
319
- const k = this._safe(key);
320
- this._logs.delete(k); this._nextSeq.delete(k); this._flushedSeq.delete(k);
321
- try {
322
- if (k === "default") {
323
- // 删除 default 所有分片 (旧单文件 + 各日 shard)
324
- this._removeDefaultFiles();
325
- } else {
326
- fs.rmSync(this._file(k), { force: true });
327
- }
328
- } catch {}
329
- this._bump();
330
- }
331
-
332
- // 清理过期会话: 删除超过 maxAgeDays 天未活跃的非 default 会话文件
333
- // 返回删除的会话 key 列表。default 主会话始终保留 (防误删主对话历史)
334
- // 分片说明: default 的各日分片在 _logs 中只对应一个 key="default", 不会被误当独立会话删除
335
- pruneOld({ maxAgeDays = 30, keep = [] } = {}) {
336
- const now = Date.now();
337
- const cutoff = now - maxAgeDays * 86400000;
338
- const keepSet = new Set([...keep, "default"].map((k) => this._safe(k)));
339
- const removed = [];
340
- for (const [key, events] of this._logs) {
341
- if (keepSet.has(key)) continue;
342
- const lastTs = events.length ? events[events.length - 1].ts : 0;
343
- if (!lastTs || lastTs < cutoff) {
344
- this.delete(key);
345
- removed.push(key);
346
- }
347
- }
348
- return removed;
349
- }
350
-
351
- // 增量落盘: 只追加 flushedSeq 之后的新事件 (P1#5, 消除大会话全量重写)
352
- // default 按天落盘: 只把当天分片写到对应 default-YYYY-MM-DD.jsonl, 非当前天文件不再改写
353
- _flush(key) {
354
- const k = this._safe(key);
355
- const evs = this._logs.get(k) || [];
356
- if (!evs.length) return;
357
- const flushed = this._flushedSeq.get(k) || 0;
358
- const pending = evs.filter((e) => e.seq > flushed);
359
- if (!pending.length) return;
360
- try {
361
- if (k === "default") this._flushDaily(pending);
362
- else this._flushLegacy(k, pending, flushed);
363
- this._flushedSeq.set(k, evs[evs.length - 1].seq);
364
- } catch (e) {
365
- // v1.0.9: 落盘失败不再静默 (磁盘满/权限丢失消息不可见), 至少留日志
366
- try { console.warn(`[session] 会话 ${k} 落盘失败: ${e.message}`); } catch {}
367
- }
368
- }
369
-
370
- // 非 default 会话: 原单文件增量写 (首次全量覆盖重建, 之后追加)
371
- _flushLegacy(k, pending, flushed) {
372
- const line = pending.map((e) => JSON.stringify(e)).join("\n") + "\n";
373
- if (flushed === 0) {
374
- fs.writeFileSync(this._file(k), line, "utf8");
375
- } else {
376
- fs.appendFileSync(this._file(k), line, "utf8");
377
- }
378
- }
379
-
380
- // default 会话: 按事件 ts 归属的天分片增量写
381
- // - 按天分组: 每个文件只追加自己那天的行
382
- // - 当天文件已有则追加, 首次创建则写入新文件
383
- _flushDaily(pending) {
384
- const byDay = new Map();
385
- for (const e of pending) {
386
- const d = this._dayOf(e.ts);
387
- let g = byDay.get(d);
388
- if (!g) { g = []; byDay.set(d, g); }
389
- g.push(e);
390
- }
391
- for (const [d, group] of byDay) {
392
- const file = this._shardFile(d);
393
- const line = group.map((e) => JSON.stringify(e)).join("\n") + "\n";
394
- if (fs.existsSync(file)) fs.appendFileSync(file, line, "utf8");
395
- else fs.writeFileSync(file, line, "utf8"); // 新的一天文件首次写入
396
- }
397
- }
1
+ // src/memory/session.js - 会话事件日志 (吸收 dsh "会话即唯一事实源")
2
+ // 新架构: 不可变 append-only 事件日志, 每条 = {seq, ts, type, data}
3
+ // - 模型可见历史 = deriveMessages() 从日志投影 (无可变状态, 仅从日志重建)
4
+ // - replay() 回放完整事件流 | fork() 从边界派生新会话
5
+ // - 兼容旧 get/set/has/delete 接口 (agent 无需全量改动)
6
+ // 吸收自 DeepSeek Harness: "model-visible means logged" (能进模型的必须能从日志重建)
7
+ //
8
+ // v1.1.x 第九轮 review P2: default 主会话按天分片 (default-YYYY-MM-DD.jsonl),
9
+ // 单文件不再无限增长。设计要点:
10
+ // - 仅 key === "default" 走按天分片; 非 default 会话保持单文件 (不扩散改动面)
11
+ // - 命名: default-YYYY-MM-DD.jsonl (取事件 ts 所在本地自然日, 与 eventsByDay/logicalDay 一致)
12
+ // - 兼容旧文件: 若历史遗留 default.jsonl, 读取时纳入合并, 不丢历史
13
+ // - seq 跨天连续递增: 同一 default 会话所有分片共用一个 seq 序列, 不从 1 重头数
14
+ // (否则 compaction 的 upToSeq / fork / replay 会错乱)
15
+ // - 所有读取路径把多天分片合并成按 seq(ts) 升序的单一事件流
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { ensureDir, logicalDay } from "../utils/store.js";
19
+ import { debug } from "../utils/logger.js";
20
+
21
+ // 事件类型集 (对齐 dsh 事件域: user/assistant/tool/system)
22
+ export const EVENTS = {
23
+ USER: "user/message",
24
+ ASSISTANT: "assistant/message",
25
+ SYSTEM: "system",
26
+ TOOL_CALL: "tool/call",
27
+ TOOL_RESULT: "tool/result",
28
+ COMPACTION: "compaction/summary",
29
+ };
30
+
31
+ // default 会话按天分片: default-YYYY-MM-DD.jsonl
32
+ const DEFAULT_SHARD_RE = /^default-\d{4}-\d{2}-\d{2}\.jsonl$/;
33
+
34
+ export class SessionStore {
35
+ constructor(dataDir) {
36
+ this.dir = path.join(dataDir, "sessions");
37
+ ensureDir(this.dir);
38
+ this._logs = new Map(); // key -> event[] (不可变, append-only)
39
+ this._nextSeq = new Map(); // key -> 下一个 seq
40
+ this._flushedSeq = new Map(); // key -> 已落盘的最大 seq (增量追加用)
41
+ this._loadAll();
42
+ // --- 派生缓存 (v1.1.1 性能优化) ---
43
+ // eventsByDay / deriveCompacted 在每一轮/每一工具轮都被调用, 旧实现每次全量扫描 O(T)。
44
+ // 事件日志是 append-only 不可变, 一个 key 的 events 数组只增不改(set/rename/fork 会换新数组)。
45
+ // · deriveCompacted: 以"数组引用标识"做增量缓存——数组没换就只把尾部新事件追加进 msgs, 命中 O(Δ);
46
+ // 数组被替换(重命名/重建)则重算。compaction 事件追加时检测到也整体重算(它会重投影历史)。
47
+ // · eventsByDay: 版本门控的全库按天缓存, 版本变化时整库重扫一次(每轮兜底, 仍远优于每次 O(T))。
48
+ this._version = 0; // 全局版本: 任何 write 递增
49
+ this._dayCache = new Map(); // day -> 该天结果 (当期 version)
50
+ this._dayCacheAt = 0; // 生成该缓存时的 _version
51
+ // deriveCompacted 增量缓存: key -> { arrRef, msgs, lastCompSeq }
52
+ this._compactedCache = new Map();
53
+ }
54
+
55
+ _bump() { this._version += 1; }
56
+
57
+ _safe(key) { return String(key || "default").replace(/[^\w.-]/g, "_"); }
58
+ // 旧单文件路径 (仅非 default 会话使用)
59
+ _file(key) { return path.join(this.dir, this._safe(key) + ".jsonl"); }
60
+
61
+ _log(key) {
62
+ const k = this._safe(key);
63
+ if (!this._logs.has(k)) { this._logs.set(k, []); this._nextSeq.set(k, 0); }
64
+ return this._logs.get(k);
65
+ }
66
+
67
+ // 判断文件名是否为 default 的日分片
68
+ _isShard(fname) { return DEFAULT_SHARD_RE.test(fname); }
69
+ // default 分片文件路径 (day 形如 YYYY-MM-DD)
70
+ _shardFile(day) { return path.join(this.dir, `default-${day}.jsonl`); }
71
+ // 事件 ts 归属的本地自然日 (复用 utils/store.logicalDay, 与 eventsByDay 一致, 避免时区错位;
72
+ // 2026-09-18 重构: 原先这里手写了一份与 logicalDay 相同的实现, 双份易漂移)
73
+ _dayOf(ts) {
74
+ return logicalDay(new Date(ts));
75
+ }
76
+ // 列出 default 相关的所有文件 (旧单文件 + 各日分片)
77
+ _defaultFiles() {
78
+ let files = [];
79
+ try {
80
+ files = fs.readdirSync(this.dir)
81
+ .filter((f) => f.endsWith(".jsonl") && (f === "default.jsonl" || this._isShard(f)));
82
+ } catch (e) { debug(`[memory/session] 已忽略异常: ${e && e.message ? e.message : e}`); }
83
+ return files.map((f) => path.join(this.dir, f));
84
+ }
85
+ _removeDefaultFiles() {
86
+ for (const f of this._defaultFiles()) { try { fs.rmSync(f, { force: true }); } catch (e) { debug(`[memory/session] 已忽略异常: ${e && e.message ? e.message : e}`); } }
87
+ }
88
+
89
+ // 读取单个 jsonl 文件的事件序列
90
+ _readEventsFromFile(file) {
91
+ const events = [];
92
+ try {
93
+ for (const l of fs.readFileSync(file, "utf8").split("\n").filter(Boolean)) {
94
+ try { const e = JSON.parse(l); if (e && e.seq && e.type && e.data) events.push(e); } catch (e) { debug(`[memory/session] 已忽略异常: ${e && e.message ? e.message : e}`); }
95
+ }
96
+ } catch (e) { debug(`[memory/session] 已忽略异常: ${e && e.message ? e.message : e}`); }
97
+ return events;
98
+ }
99
+
100
+ _loadAll() {
101
+ let files = [];
102
+ try { files = fs.readdirSync(this.dir).filter((f) => f.endsWith(".jsonl")); } catch { return; }
103
+ for (const f of files) {
104
+ // default 的旧单文件 + 日分片 → 归并到同一个 key="default"
105
+ if (f === "default.jsonl" || this._isShard(f)) {
106
+ const events = this._readEventsFromFile(path.join(this.dir, f));
107
+ if (events.length) {
108
+ const k = "default";
109
+ const cur = this._logs.get(k) || [];
110
+ this._logs.set(k, cur.concat(events));
111
+ }
112
+ continue;
113
+ }
114
+ const k = f.replace(/\.jsonl$/, "");
115
+ const events = this._readEventsFromFile(path.join(this.dir, f));
116
+ if (events.length) {
117
+ events.sort((a, b) => a.seq - b.seq);
118
+ this._logs.set(k, events);
119
+ this._nextSeq.set(k, events[events.length - 1].seq);
120
+ this._flushedSeq.set(k, events[events.length - 1].seq);
121
+ }
122
+ }
123
+ // default: 跨文件合并后按 seq(ts 兜底) 升序 + 统一 next/flushed seq (跨天连续递增)
124
+ if (this._logs.has("default")) {
125
+ const evs = this._logs.get("default");
126
+ evs.sort((a, b) => a.seq - b.seq || a.ts - b.ts);
127
+ this._nextSeq.set("default", evs[evs.length - 1].seq);
128
+ this._flushedSeq.set("default", evs[evs.length - 1].seq);
129
+ }
130
+ }
131
+
132
+ // 追加事件 (唯一写入路径, append-only, 永不重写/裁剪日志文件)
133
+ // ts 可选: 测试/回溯时可注入固定时间戳; 缺省用 Date.now() (向后兼容)
134
+ // 追加事件 (唯一写入路径, append-only, 永不重写/裁剪日志文件)
135
+ // ts 可选: 测试/回溯时可注入固定时间戳; 缺省用 Date.now() (向后兼容)
136
+ // opts.skipFlush: true 时延后落盘 (调用方需随后显式 flush(key) 或下次 append), 用于一次性批量写入
137
+ append(key, type, data, ts, { skipFlush = false } = {}) {
138
+ const k = this._safe(key);
139
+ const seq = (this._nextSeq.get(k) || 0) + 1;
140
+ const ev = { seq, ts: Number.isFinite(ts) ? ts : Date.now(), type, data };
141
+ this._log(k).push(ev);
142
+ this._nextSeq.set(k, seq);
143
+ if (!skipFlush) this._flush(k);
144
+ this._bump(); // 使派生缓存失效
145
+ return ev;
146
+ }
147
+
148
+ // 显式落盘: 把 skipFlush 延后的待写事件写入磁盘 (供批量写入后调用)
149
+ flush(key) { this._flush(this._safe(key)); }
150
+
151
+ // 从日志投影模型可见历史 (user/assistant), 无可变状态
152
+ deriveMessages(key) {
153
+ return this._log(this._safe(key))
154
+ .filter((e) => e.type === EVENTS.USER || e.type === EVENTS.ASSISTANT)
155
+ .map((e) => ({ role: e.type === EVENTS.USER ? "user" : "assistant", content: e.data?.content }));
156
+ }
157
+
158
+ // 投影「压缩后」的模型可见历史: 最后一条 compaction 事件之前 (seq <= upToSeq) 的消息被摘要替换
159
+ // 吸收 OpenClaw compaction: 摘要作为单一 surface node 替换被压缩区间, 日志本身不可变
160
+ // v1.1.1: 增量缓存——数组引用没变时只 O(Δ) 追加尾部消息; 数组被替换或追加 compaction 时整体重算。
161
+ deriveCompacted(key) {
162
+ const k = this._safe(key);
163
+ const evs = this._log(k);
164
+ const cached = this._compactedCache.get(k);
165
+ // 数组引用未变 (append-only 稳定), 且无新 compaction: 只把尾部新 user/assistant 追加进 msgs
166
+ if (cached && cached.arrRef === evs) {
167
+ if (cached.consumed === evs.length) return cached.msgs; // 无新增, 直接命中
168
+ // 检查新增区间是否含 compaction (compaction 会重投影历史, 需整体重算)
169
+ for (let i = cached.consumed; i < evs.length; i++) {
170
+ if (evs[i].type === EVENTS.COMPACTION) return this._deriveCompactedFrom(evs, k);
171
+ }
172
+ // 只有 user/assistant 追加: 把可见的尾部消息追加进 msgs
173
+ for (let i = cached.consumed; i < evs.length; i++) {
174
+ const e = evs[i];
175
+ if (e.type === EVENTS.USER || e.type === EVENTS.ASSISTANT) {
176
+ cached.msgs.push({ role: e.type === EVENTS.USER ? "user" : "assistant", content: e.data?.content });
177
+ }
178
+ }
179
+ cached.consumed = evs.length;
180
+ return cached.msgs;
181
+ }
182
+ // 无缓存 / 数组被替换 (set/rename/fork/delete): 整体重算
183
+ return this._deriveCompactedFrom(evs, k);
184
+ }
185
+
186
+ _deriveCompactedFrom(evs, k) {
187
+ let lastComp = null;
188
+ for (const e of evs) if (e.type === EVENTS.COMPACTION) lastComp = e;
189
+ const upToSeq = lastComp?.data?.upToSeq || 0;
190
+ const msgs = [];
191
+ let consumed = 0;
192
+ if (lastComp?.data?.summary) {
193
+ msgs.push({ role: "system", content: String(lastComp.data.summary) });
194
+ }
195
+ for (const e of evs) {
196
+ consumed++;
197
+ if (e.seq <= upToSeq) continue;
198
+ if (e.type === EVENTS.USER || e.type === EVENTS.ASSISTANT) {
199
+ msgs.push({ role: e.type === EVENTS.USER ? "user" : "assistant", content: e.data?.content });
200
+ }
201
+ }
202
+ this._compactedCache.set(k, { arrRef: evs, msgs, consumed, lastCompSeq: lastComp?.seq || 0 });
203
+ return msgs;
204
+ }
205
+
206
+ // 完整事件流 (回放/审计/轨迹)
207
+ replay(key) { return [...this._log(this._safe(key))]; }
208
+
209
+ // L0 只读代理 / 按天审计: 遍历所有会话, 派生某天的对话事件 (消除 l0/*.jsonl 重复)
210
+ // v1.1.1: 全库按天缓存, 版本未变则零成本; 保持"本地自然日"语义 (与 _dayOf/logicalDay 一致, 避免时区错位)
211
+ eventsByDay(day) {
212
+ const dayStr = String(day).slice(0, 10);
213
+ const dayMs = new Date(dayStr + "T00:00:00").getTime();
214
+ if (Number.isNaN(dayMs)) return [];
215
+ // 缓存: 版本未变时按 day 命中 (append 后 _bump 使整个缓存失效, 下轮重扫一次)
216
+ if (this._dayCacheAt === this._version && this._dayCache.size) {
217
+ const hit = this._dayCache.get(dayStr);
218
+ if (hit) return hit;
219
+ }
220
+ // 全库扫描一次, 按"事件 ts 的本地自然日"聚合缓存所有天 → 之后所有 eventsByDay 调用都命中
221
+ const byDay = new Map();
222
+ for (const [key, events] of this._logs) {
223
+ for (const e of events) {
224
+ if (e.type !== EVENTS.USER && e.type !== EVENTS.ASSISTANT) continue;
225
+ const d = this._dayOf(e.ts);
226
+ let arr = byDay.get(d);
227
+ if (!arr) { arr = []; byDay.set(d, arr); }
228
+ arr.push({ sessionKey: key, role: e.type === EVENTS.USER ? "user" : "assistant", content: e.data?.content, timestamp: e.ts });
229
+ }
230
+ }
231
+ this._dayCache = byDay;
232
+ this._dayCacheAt = this._version;
233
+ // 2026-09-18 修复: 每一天的缓存数组都必须在入缓存前排好时间序 ——
234
+ // 原实现只对当次请求的 day 排序, 其余天命中缓存时返回的是事件追加顺序 (乱序)
235
+ for (const arr of byDay.values()) arr.sort((a, b) => a.timestamp - b.timestamp);
236
+ const out = byDay.get(dayStr) || [];
237
+ return out;
238
+ }
239
+
240
+ // 返回某自然日的对话事件(带 seq/type/data), 供 memory-ticker 按 seq 去重滚动归档
241
+ // (防止 _compileDaily_Rolling 每次把今日全文再次追加, 造成 longterm 内容重复累积)
242
+ replayDay(day) {
243
+ const dayStr = String(day).slice(0, 10);
244
+ const out = [];
245
+ for (const [, events] of this._logs) {
246
+ for (const e of events) {
247
+ if (e.type !== EVENTS.USER && e.type !== EVENTS.ASSISTANT) continue;
248
+ if (this._dayOf(e.ts) !== dayStr) continue;
249
+ out.push(e);
250
+ }
251
+ }
252
+ out.sort((a, b) => a.seq - b.seq);
253
+ return out;
254
+ }
255
+
256
+ // fork: 从 boundarySeq 及更早派生新会话 (吸收 dsh fork 语义, 保留不可变源)
257
+ fork(fromKey, boundarySeq, toKey) {
258
+ const keep = this._log(this._safe(fromKey)).filter((e) => e.seq <= boundarySeq);
259
+ const k = this._safe(toKey);
260
+ // 目标为 default 时先清掉旧分片, 再按边界重建 (避免 keep 追加到已有同 seq 行造成重复)
261
+ if (k === "default") this._removeDefaultFiles();
262
+ this._logs.set(k, [...keep]);
263
+ this._nextSeq.set(k, keep.length ? keep[keep.length - 1].seq : 0);
264
+ this._flushedSeq.delete(k);
265
+ this._flush(k);
266
+ this._bump();
267
+ return keep;
268
+ }
269
+
270
+ // count: 事件条数
271
+ count(key) { return this._log(this._safe(key)).length; }
272
+
273
+ // 列出所有会话: {key, count, lastTs, title} (title 取首条 user 消息前 20 字)
274
+ // 供 Web UI 多会话管理 (P1#6)
275
+ list() {
276
+ const out = [];
277
+ for (const [key, events] of this._logs) {
278
+ if (!events.length) continue;
279
+ const firstUser = events.find((e) => e.type === EVENTS.USER);
280
+ const title = firstUser?.data?.content ? String(firstUser.data.content).slice(0, 20) : key;
281
+ out.push({
282
+ key,
283
+ count: events.length,
284
+ lastTs: events[events.length - 1].ts,
285
+ title,
286
+ });
287
+ }
288
+ out.sort((a, b) => (b.lastTs || 0) - (a.lastTs || 0));
289
+ return out;
290
+ }
291
+
292
+ // ---- 兼容旧接口 (agent/CLI 仍可用) ----
293
+ get(key) { return this.deriveMessages(key); }
294
+ set(key, history) {
295
+ const k = this._safe(key);
296
+ // default 为分片存储: 清掉内存前先删旧分片, 保证重建后磁盘与内存一致 (无残留旧文件)
297
+ if (k === "default") this._removeDefaultFiles();
298
+ this._logs.delete(k); this._nextSeq.set(k, 0); this._flushedSeq.delete(k);
299
+ for (const m of (history || [])) this.append(k, m.role === "user" ? EVENTS.USER : EVENTS.ASSISTANT, { content: m.content });
300
+ return history;
301
+ }
302
+ has(key) { return this._logs.has(this._safe(key)); }
303
+ // 重命名会话: 复制事件到新 key 并删除旧 key (保留 seq 顺序) [P1#6]
304
+ rename(fromKey, toKey) {
305
+ const f = this._safe(fromKey), t = this._safe(toKey);
306
+ if (f === t) return false;
307
+ if (!this._logs.has(f)) return false;
308
+ if (this._logs.has(t)) return false; // 目标已存在, 拒绝覆盖防数据丢失 [复审 P1]
309
+ const events = this._logs.get(f);
310
+ this._logs.set(t, [...events]);
311
+ const lastSeq = events.length ? events[events.length - 1].seq : 0;
312
+ this._nextSeq.set(t, lastSeq);
313
+ this._flushedSeq.delete(t);
314
+ this._flush(t);
315
+ this.delete(f);
316
+ this._bump();
317
+ return true;
318
+ }
319
+ delete(key) {
320
+ const k = this._safe(key);
321
+ this._logs.delete(k); this._nextSeq.delete(k); this._flushedSeq.delete(k);
322
+ try {
323
+ if (k === "default") {
324
+ // 删除 default 所有分片 (旧单文件 + 各日 shard)
325
+ this._removeDefaultFiles();
326
+ } else {
327
+ fs.rmSync(this._file(k), { force: true });
328
+ }
329
+ } catch (e) { debug(`[memory/session] 已忽略异常: ${e && e.message ? e.message : e}`); }
330
+ this._bump();
331
+ }
332
+
333
+ // 清理过期会话: 删除超过 maxAgeDays 天未活跃的非 default 会话文件
334
+ // 返回删除的会话 key 列表。default 主会话始终保留 (防误删主对话历史)
335
+ // 分片说明: default 的各日分片在 _logs 中只对应一个 key="default", 不会被误当独立会话删除
336
+ pruneOld({ maxAgeDays = 30, keep = [] } = {}) {
337
+ const now = Date.now();
338
+ const cutoff = now - maxAgeDays * 86400000;
339
+ const keepSet = new Set([...keep, "default"].map((k) => this._safe(k)));
340
+ const removed = [];
341
+ for (const [key, events] of this._logs) {
342
+ if (keepSet.has(key)) continue;
343
+ const lastTs = events.length ? events[events.length - 1].ts : 0;
344
+ if (!lastTs || lastTs < cutoff) {
345
+ this.delete(key);
346
+ removed.push(key);
347
+ }
348
+ }
349
+ return removed;
350
+ }
351
+
352
+ // 增量落盘: 只追加 flushedSeq 之后的新事件 (P1#5, 消除大会话全量重写)
353
+ // default 按天落盘: 只把当天分片写到对应 default-YYYY-MM-DD.jsonl, 非当前天文件不再改写
354
+ _flush(key) {
355
+ const k = this._safe(key);
356
+ const evs = this._logs.get(k) || [];
357
+ if (!evs.length) return;
358
+ const flushed = this._flushedSeq.get(k) || 0;
359
+ const pending = evs.filter((e) => e.seq > flushed);
360
+ if (!pending.length) return;
361
+ try {
362
+ if (k === "default") this._flushDaily(pending);
363
+ else this._flushLegacy(k, pending, flushed);
364
+ this._flushedSeq.set(k, evs[evs.length - 1].seq);
365
+ } catch (e) {
366
+ // v1.0.9: 落盘失败不再静默 (磁盘满/权限丢失消息不可见), 至少留日志
367
+ try { console.warn(`[session] 会话 ${k} 落盘失败: ${e.message}`); } catch (e) { debug(`[memory/session] 已忽略异常: ${e && e.message ? e.message : e}`); }
368
+ }
369
+ }
370
+
371
+ // 非 default 会话: 原单文件增量写 (首次全量覆盖重建, 之后追加)
372
+ _flushLegacy(k, pending, flushed) {
373
+ const line = pending.map((e) => JSON.stringify(e)).join("\n") + "\n";
374
+ if (flushed === 0) {
375
+ fs.writeFileSync(this._file(k), line, "utf8");
376
+ } else {
377
+ fs.appendFileSync(this._file(k), line, "utf8");
378
+ }
379
+ }
380
+
381
+ // default 会话: 按事件 ts 归属的天分片增量写
382
+ // - 按天分组: 每个文件只追加自己那天的行
383
+ // - 当天文件已有则追加, 首次创建则写入新文件
384
+ _flushDaily(pending) {
385
+ const byDay = new Map();
386
+ for (const e of pending) {
387
+ const d = this._dayOf(e.ts);
388
+ let g = byDay.get(d);
389
+ if (!g) { g = []; byDay.set(d, g); }
390
+ g.push(e);
391
+ }
392
+ for (const [d, group] of byDay) {
393
+ const file = this._shardFile(d);
394
+ const line = group.map((e) => JSON.stringify(e)).join("\n") + "\n";
395
+ if (fs.existsSync(file)) fs.appendFileSync(file, line, "utf8");
396
+ else fs.writeFileSync(file, line, "utf8"); // 新的一天文件首次写入
397
+ }
398
+ }
398
399
  }