ppxans-harness 2.4.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 (106) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +265 -0
  3. package/bin/ppx-channels.js +3 -0
  4. package/bin/ppx-serve.js +6 -0
  5. package/bin/ppx.js +3 -0
  6. package/config/identity.md +6 -0
  7. package/config/ishiki.md +16 -0
  8. package/config/ppx.json +151 -0
  9. package/package.json +69 -0
  10. package/src/agent/context.js +179 -0
  11. package/src/agent/index.js +717 -0
  12. package/src/agent/prompts.js +107 -0
  13. package/src/aml-server.js +151 -0
  14. package/src/ans/eviction.js +144 -0
  15. package/src/ans/guard.js +120 -0
  16. package/src/ans/lifecycle.js +93 -0
  17. package/src/ans/proactive.js +129 -0
  18. package/src/ans/reward.js +112 -0
  19. package/src/ans/values.js +15 -0
  20. package/src/audit/audit-chain.js +167 -0
  21. package/src/audit/verifier.js +120 -0
  22. package/src/bus/circuit-breaker.js +115 -0
  23. package/src/bus/runtime-bus.js +94 -0
  24. package/src/channels/base.js +35 -0
  25. package/src/channels/feishu.js +127 -0
  26. package/src/channels/http.js +592 -0
  27. package/src/channels/index.js +110 -0
  28. package/src/channels/log.js +29 -0
  29. package/src/channels/wechat-crypto.js +74 -0
  30. package/src/channels/wechat.js +197 -0
  31. package/src/channels-cli.js +124 -0
  32. package/src/cli.js +120 -0
  33. package/src/config/channels.js +170 -0
  34. package/src/config/index.js +224 -0
  35. package/src/config/providers.js +189 -0
  36. package/src/config/settings.js +182 -0
  37. package/src/core/policy.js +272 -0
  38. package/src/core/trace.js +89 -0
  39. package/src/evolve/playbook.js +194 -0
  40. package/src/llm/client.js +446 -0
  41. package/src/llm/dsml.js +74 -0
  42. package/src/llm/embedder.js +35 -0
  43. package/src/llm/fence.js +105 -0
  44. package/src/llm/index.js +4 -0
  45. package/src/llm/retry.js +73 -0
  46. package/src/llm/router.js +98 -0
  47. package/src/mcp/client.js +375 -0
  48. package/src/mcp/index.js +116 -0
  49. package/src/memory/asset-hub.js +131 -0
  50. package/src/memory/canvas.js +131 -0
  51. package/src/memory/compaction.js +28 -0
  52. package/src/memory/experience.js +122 -0
  53. package/src/memory/fact-store.js +699 -0
  54. package/src/memory/failure-episode.js +99 -0
  55. package/src/memory/fork.js +83 -0
  56. package/src/memory/index.js +7 -0
  57. package/src/memory/l0.js +52 -0
  58. package/src/memory/l2.js +131 -0
  59. package/src/memory/l3.js +112 -0
  60. package/src/memory/memory-ticker.js +240 -0
  61. package/src/memory/session.js +398 -0
  62. package/src/mode/blackboard.js +49 -0
  63. package/src/mode/graph.js +41 -0
  64. package/src/mode/index.js +64 -0
  65. package/src/mode/legion.js +51 -0
  66. package/src/mode/plan-exec.js +50 -0
  67. package/src/mode/router.js +40 -0
  68. package/src/orchestrator/agent-worker.js +70 -0
  69. package/src/orchestrator/dag.js +83 -0
  70. package/src/orchestrator/index.js +2 -0
  71. package/src/orchestrator/legion.js +188 -0
  72. package/src/orchestrator/supervisor.js +177 -0
  73. package/src/persona/index.js +29 -0
  74. package/src/plugin/builtin.js +212 -0
  75. package/src/plugin/context.js +79 -0
  76. package/src/plugin/index.js +62 -0
  77. package/src/seam/registry.js +98 -0
  78. package/src/seam/shell.js +55 -0
  79. package/src/selfheal/evolve.js +68 -0
  80. package/src/selfheal/healer.js +167 -0
  81. package/src/selfheal/run.js +9 -0
  82. package/src/server.js +60 -0
  83. package/src/services/learning-service.js +177 -0
  84. package/src/services/memory-health.js +99 -0
  85. package/src/services/memory-service.js +160 -0
  86. package/src/skills/loader.js +150 -0
  87. package/src/skills/verify.js +100 -0
  88. package/src/tools/advanced.js +353 -0
  89. package/src/tools/builtin.js +298 -0
  90. package/src/tools/catalog.js +159 -0
  91. package/src/tools/command-guard.js +112 -0
  92. package/src/tools/custom.js +47 -0
  93. package/src/tools/delegate.js +297 -0
  94. package/src/tools/document.js +253 -0
  95. package/src/tools/governance.js +260 -0
  96. package/src/tools/index.js +11 -0
  97. package/src/tools/methods.js +178 -0
  98. package/src/tools/ocr.js +59 -0
  99. package/src/tools/seam.js +125 -0
  100. package/src/tools/selfmod.js +176 -0
  101. package/src/utils/logger.js +17 -0
  102. package/src/utils/pii.js +42 -0
  103. package/src/utils/store.js +108 -0
  104. package/src/utils/text.js +16 -0
  105. package/src/utils/trace.js +153 -0
  106. package/src/utils/winutf8.js +15 -0
@@ -0,0 +1,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, 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
+
@@ -0,0 +1,398 @@
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
+ }
398
+ }
@@ -0,0 +1,49 @@
1
+ // src/mode/blackboard.js - Blackboard 黑板模式
2
+ // 共享黑板: 多个专家轮流读黑板 → 贡献 → 写黑板, 最后汇总。
3
+ // 适合: 需要共享上下文的协作、增量求解、无固定顺序的问题。
4
+ // 解耦: 专家间不直接通信, 只通过黑板读写。
5
+
6
+ // 黑板: 共享状态容器 (内存版, 单进程内共享)
7
+ export class Board {
8
+ constructor() { this._data = new Map(); }
9
+ set(key, value) { this._data.set(key, String(value)); return value; }
10
+ get(key) { return this._data.get(key); }
11
+ keys() { return [...this._data.keys()]; }
12
+ snapshot() { return Object.fromEntries(this._data); }
13
+ toString() { return JSON.stringify(this.snapshot(), null, 2); }
14
+ }
15
+
16
+ // 默认专家团队: 分析 → 执行 → 审查
17
+ const DEFAULT_EXPERTS = [
18
+ { name: "分析师", system: "你是分析师。分析任务, 输出关键要点、约束和风险。" },
19
+ { name: "执行者", system: "你是执行者。基于黑板上的分析结果, 给出具体可执行的方案步骤。" },
20
+ { name: "审查者", system: "你是审查者。审查执行方案, 指出问题并给出最终建议。" },
21
+ ];
22
+
23
+ export async function blackboardExecutor(agent, userMsg, { sessionKey = "default", experts = null } = {}) {
24
+ if (!agent.llm) {
25
+ return (await agent._localIntent(userMsg)) || "[皮皮虾] 未配置模型 provider (配置见 docs/QUICKSTART.md 第 3 节)。";
26
+ }
27
+ const board = new Board();
28
+ board.set("task", String(userMsg));
29
+
30
+ // v1.0.8: 空数组回退默认专家 (原 [] 时 last 为 undefined 静默返回空)
31
+ const team = (Array.isArray(experts) && experts.length) ? experts : DEFAULT_EXPERTS;
32
+ for (const ex of team) {
33
+ let r;
34
+ try {
35
+ const resp = await agent.llm.chat([
36
+ { role: "system", content: `${ex.system}\n\n[黑板当前状态]\n${board.toString()}` },
37
+ { role: "user", content: "请基于黑板状态完成你的职责, 输出结果。" },
38
+ ]);
39
+ r = resp.content;
40
+ } catch (e) {
41
+ r = `(${ex.name} 失败: ${e.message})`;
42
+ }
43
+ board.set(ex.name, r);
44
+ }
45
+
46
+ // 汇总: 最后一个专家(审查者)的输出作为最终答案
47
+ const last = team[team.length - 1];
48
+ return board.get(last.name) || "(黑板无结果)";
49
+ }