ppxans-harness 2.6.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 -262
  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/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 +46 -2
  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 -651
  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 -182
  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 +4 -3
  79. package/src/llm/dsml.js +74 -74
  80. package/src/llm/embedder.js +41 -35
  81. package/src/llm/fence.js +52 -52
  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 -89
  88. package/src/mcp/admin.js +44 -10
  89. package/src/mcp/client.js +487 -477
  90. package/src/mcp/http.js +23 -37
  91. package/src/mcp/index.js +116 -116
  92. package/src/mcp/server.js +23 -18
  93. package/src/mcp/tasks.js +9 -14
  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 -215
  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
@@ -0,0 +1,52 @@
1
+ // src/memory/l0.js - L0 原始对话记录 (腾讯风格)
2
+ // 架构: 会话事件日志 (SessionStore) 为唯一事实源, L0 是其只读派生视图
3
+ // 不再独立维护 l0/*.jsonl -- 对话原文由 session 事件日志全量保存, 消除 today/l0/facts 三处重复
4
+ // 过滤噪音: 短消息/命令/注入标签 (保留, 用于判断"是否值得记录")
5
+ import path from "node:path";
6
+ import { ensureDir, logicalDay } from "./store.js";
7
+ import { SessionStore, EVENTS } from "./session.js";
8
+
9
+ // 短消息/命令/噪音过滤
10
+ function shouldCapture(content) {
11
+ const c = String(content || "").trim();
12
+ if (!c) return false;
13
+ if (c.length < 2) return false; // 太短
14
+ if (/^(\/|!|\.)/.test(c)) return false; // 命令
15
+ if (c.length < 3) return false; // 太短(含2字寒暄)
16
+ if (["你好","您好","在吗","哈喽","拜拜","再见","晚安","早上好","下午好","晚上好"].includes(c)) return false; // 纯寒暄
17
+ if (/\b(hi|hello|ok|thanks)\b/i.test(c) && c.length < 8) return false;
18
+ return true;
19
+ }
20
+
21
+ export class L0Recorder {
22
+ // 兼容两种构造: (sessionStore[, dataDir]) 或 (dataDir) -- 后者内部新建 SessionStore
23
+ constructor(sessionStoreOrDir, dataDir) {
24
+ if (sessionStoreOrDir && typeof sessionStoreOrDir.eventsByDay === "function") {
25
+ this.sessionStore = sessionStoreOrDir;
26
+ this.dir = dataDir ? path.join(dataDir, "memory", "l0") : null;
27
+ } else {
28
+ const dir = sessionStoreOrDir;
29
+ this.sessionStore = new SessionStore(dir);
30
+ this.dir = path.join(dir, "memory", "l0");
31
+ }
32
+ if (this.dir) { try { ensureDir(this.dir); } catch {} }
33
+ }
34
+
35
+ // 记录: 代理到 session 事件日志 (session 是唯一事实源, 不再独立写 JSONL)
36
+ record({ role, content, sessionKey = "default" } = {}) {
37
+ if (!shouldCapture(content)) return null;
38
+ const type = role === "assistant" ? EVENTS.ASSISTANT : EVENTS.USER;
39
+ return this.sessionStore.append(sessionKey, type, { content: String(content).slice(0, 4000) });
40
+ }
41
+
42
+ // 读取某天的对话 (从 session 事件按天派生, 最近 N 条)
43
+ read(day = logicalDay(), limit = 50) {
44
+ return this.sessionStore.eventsByDay(day)
45
+ .map((r) => ({ role: r.role, content: r.content, sessionKey: r.sessionKey, timestamp: r.timestamp }))
46
+ .slice(-limit);
47
+ }
48
+
49
+ count() {
50
+ return this.sessionStore.eventsByDay(logicalDay()).length;
51
+ }
52
+ }
@@ -0,0 +1,146 @@
1
+ // src/memory/l2.js - L2 场景记忆 (腾讯风格 scene extraction)
2
+ // 把相关记忆归档成场景: { name, keywords, facts[], lastUpdated }
3
+ // 零依赖: 用关键词聚类 + 时间窗聚合
4
+ import path from "node:path";
5
+ import { ensureDir, readJson, writeJson, logicalDay, withFileLock } from "./store.js";
6
+ import { migrateData, writeSchema } from "./schema.js";
7
+
8
+ // scenes.json 当前 schema 版本 (纯数组基线 = 1); 未来数据结构变更时 +1 并注册迁移
9
+ export const SCENES_SCHEMA_VERSION = 1;
10
+
11
+ // 中文简单分词: 提取有意义的词 (2字以上连续片段 + 已知高频概念)
12
+ const STOP = new Set(["这个","那个","我们","你们","他们","什么","怎么","可以","一个","就是","知道","没有","如果","因为","所以","但是","然后","现在","今天","昨天","明天","已经","还有","所有","这样","那样","自己","的时候","一下","一点","一些","这些","那些","东西","事情","问题","觉得","应该","需要","开始","继续","大家","真的","只是","可能","不是","都是","一直","非常","其实","最后","主要","联系","关系"]);
13
+
14
+ function tokenize(text) {
15
+ const clean = String(text || "").replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, " ");
16
+ const words = clean.split(/\s+/).filter(Boolean);
17
+ const cjk = clean.match(/[\u4e00-\u9fa5]{2,4}/g) || [];
18
+ return [...new Set([...words, ...cjk.map((w) => w.toLowerCase())].filter((w) => !STOP.has(w) && w.length >= 2))];
19
+ }
20
+
21
+ export class SceneStore {
22
+ constructor(dataDir) {
23
+ this.dir = path.join(dataDir, "memory", "l2");
24
+ ensureDir(this.dir);
25
+ this.file = path.join(this.dir, "scenes.json");
26
+ this.scenes = readJson(this.file, []);
27
+ // schema 版本迁移 (旁挂 .schema 文件; 数据文件保持纯数组)
28
+ const mig = migrateData({
29
+ file: this.file,
30
+ name: "scenes",
31
+ data: this.scenes,
32
+ currentVersion: SCENES_SCHEMA_VERSION,
33
+ });
34
+ this.scenes = mig.data;
35
+ }
36
+
37
+ // 关键词命中数最高的场景。命中数 0 不会成为候选, 故返回的 scene 非空 ⟺ 至少命中 1 个关键词。
38
+ _bestScene(tokens) {
39
+ let best = null, bestScore = 0;
40
+ for (const s of this.scenes) {
41
+ let score = 0;
42
+ for (const t of tokens) if ((s.keywords || []).includes(t)) score++;
43
+ if (score > bestScore) { bestScore = score; best = s; }
44
+ }
45
+ return { scene: best, score: bestScore };
46
+ }
47
+
48
+ // 把一条事实归入最匹配的场景 (或新建)
49
+ assign(fact) {
50
+ const tokens = tokenize(fact.content);
51
+ if (!tokens.length) return null;
52
+
53
+ let best = this._bestScene(tokens).scene;
54
+
55
+ if (best) {
56
+ best.facts.push({ id: fact.id, content: fact.content, ts: fact.created });
57
+ if (best.facts.length > 50) best.facts = best.facts.slice(-50);
58
+ best.lastUpdated = logicalDay();
59
+ // 合并新关键词
60
+ for (const t of tokens) if (!best.keywords.includes(t)) best.keywords.push(t);
61
+ if (best.keywords.length > 30) best.keywords = best.keywords.slice(-30);
62
+ } else {
63
+ best = {
64
+ id: "s_" + Math.random().toString(36).slice(2, 8),
65
+ name: tokens.slice(0, 3).join("·"),
66
+ keywords: tokens.slice(0, 10),
67
+ facts: [{ id: fact.id, content: fact.content, ts: fact.created }],
68
+ mode: "auto",
69
+ description: tokens.slice(1, 4).join("、") || "自动场景",
70
+ canHelp: "基于该话题的对话与记忆提供帮助",
71
+ created: logicalDay(),
72
+ lastUpdated: logicalDay(),
73
+ };
74
+ this.scenes.push(best);
75
+ }
76
+ // v1.0.9: 写盘加文件锁 (防军团多进程共享 dataDir 时写交错)
77
+ return withFileLock(this.file, () => {
78
+ writeJson(this.file, this.scenes);
79
+ writeSchema(this.file, "scenes", SCENES_SCHEMA_VERSION);
80
+ return best;
81
+ });
82
+ }
83
+
84
+ // 手动创建场景 (用户设定人设/能力, 类似灵魂文件)
85
+ create({ name, description, canHelp, keywords = [] }) {
86
+ const scene = {
87
+ id: "s_" + Math.random().toString(36).slice(2, 8),
88
+ name: String(name || "").slice(0, 50),
89
+ keywords: keywords.slice(0, 15),
90
+ facts: [],
91
+ mode: "manual",
92
+ description: String(description || "").slice(0, 300),
93
+ canHelp: String(canHelp || "").slice(0, 300),
94
+ created: logicalDay(),
95
+ lastUpdated: logicalDay(),
96
+ };
97
+ this.scenes.push(scene);
98
+ this._save();
99
+ return scene;
100
+ }
101
+
102
+ // 列出所有场景 (含介绍)
103
+ listWithDesc() {
104
+ return this.scenes.map((s) => ({
105
+ id: s.id, name: s.name, mode: s.mode || "auto",
106
+ description: s.description || "", canHelp: s.canHelp || "",
107
+ facts: (s.facts || []).length, lastUpdated: s.lastUpdated,
108
+ }));
109
+ }
110
+
111
+ // 按文本匹配激活场景 (关键词命中)
112
+ findMatch(text) {
113
+ const tokens = tokenize(text);
114
+ if (!tokens.length) return null;
115
+ return this._bestScene(tokens).scene;
116
+ }
117
+
118
+ // 激活场景的上下文块 (人设 + 能力)
119
+ activeContext(text) {
120
+ const s = this.findMatch(text);
121
+ if (!s) return "";
122
+ return [
123
+ `【当前场景:${s.name}】`,
124
+ s.description ? `场景介绍: ${s.description}` : "",
125
+ s.canHelp ? `你可以帮用户: ${s.canHelp}` : "",
126
+ s.mode === "manual" ? "(用户手动设定, 请遵循此场景行为)" : "",
127
+ ].filter(Boolean).join("\n");
128
+ } // 按记忆 id 找回场景
129
+ findByFactId(id) {
130
+ return this.scenes.find((s) => s.facts.some((f) => f.id === id));
131
+ }
132
+
133
+ context(limit = 5) {
134
+ return this.scenes.slice(-limit).map((s) =>
135
+ `【场景:${s.name}】\n${s.facts.slice(-5).map((f) => ` - ${f.content}`).join("\n")}`
136
+ ).join("\n");
137
+ }
138
+
139
+ count() { return this.scenes.length; }
140
+
141
+ // v1.0.9: _save 加锁 (create/scene_describe 等写盘路径)
142
+ _save() { withFileLock(this.file, () => {
143
+ writeJson(this.file, this.scenes);
144
+ writeSchema(this.file, "scenes", SCENES_SCHEMA_VERSION);
145
+ }); }
146
+ }
@@ -0,0 +1,112 @@
1
+ // src/memory/l3.js - L3 核心画像 (腾讯风格 persona generation)
2
+ // 从记忆提炼: 用户画像 (user.persona.md) + agent 人格 (agent.persona.md)
3
+ // 零依赖: 高频词统计 + 主题聚合, 输出结构化画像
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { ensureDir, readText, writeText, logicalDay } from "./store.js";
7
+
8
+ const STOP = new Set(["这个","那个","我们","你们","他们","什么","怎么","可以","一个","就是","知道","没有","如果","因为","所以","但是","然后","现在","今天","昨天","明天","已经","还有","所有","这样","那样","自己","的东西","的事情","一下","一点","一些","这些","那些","东西","事情","问题","觉得","应该","需要","开始","继续","大家","真的","只是","可能","不是","都是","一直","非常","其实","最后","主要","联系","关系","喜欢","讨厌","不要","想要","认为"]);
9
+
10
+ export class PersonaStore {
11
+ constructor(dataDir, { userName = "兄弟" } = {}) {
12
+ this.dir = path.join(dataDir, "memory", "l3");
13
+ ensureDir(this.dir);
14
+ this.userFile = path.join(this.dir, "user.persona.md");
15
+ this.agentFile = path.join(this.dir, "agent.persona.md");
16
+ this.userName = userName;
17
+ }
18
+
19
+ // 从一批事实提炼用户画像
20
+ buildUserPersona(facts, { force = false } = {}) {
21
+ if (!force && this._exists(this.userFile)) return this._read(this.userFile);
22
+ // 聚焦用户相关的记忆 (来源: 对话 / LLM提炼 / 主动记 / 手动 / 用户分享)
23
+ // 注: 事实的来源标记在 source 字段 (type 恒为 general), 之前按 type 过滤永远为空 -> 修正为按 source
24
+ const USER_SOURCES = ["conversation", "extract", "agent-self", "manual", "user-shared"];
25
+ const userFacts = facts.filter((f) => USER_SOURCES.includes(f.source) || USER_SOURCES.includes(f.type));
26
+ const interests = this._topTopics(userFacts);
27
+ // 记忆概要: 内容去重后取最近 10 条 (防 LLM 提炼变体/重复记忆稀释画像)
28
+ const uniq = this._uniqByContent(userFacts).slice(-10);
29
+ const md = `# ${this.userName} 的用户画像
30
+
31
+ > 由皮皮虾 L3 画像引擎生成 | 更新: ${logicalDay()}
32
+
33
+ ## 关注主题
34
+ ${interests.length ? interests.map(([w, n]) => `- ${w} (出现${n}次)`).join("\n") : "- 暂无足够数据"}
35
+
36
+ ## 记忆概要
37
+ ${uniq.map((f) => `- ${f.content}`).join("\n") || "- 暂无"}
38
+
39
+ ## 画像版本
40
+ - 生成时间: ${logicalDay()}
41
+ - 数据来源: 对话记忆 + 用户主动分享
42
+ `;
43
+ writeText(this.userFile, md);
44
+ return md;
45
+ }
46
+
47
+ // 提炼 agent 自身人格 (从经验/工具使用学习)
48
+ buildAgentPersona(lessons, { force = false } = {}) {
49
+ if (!force && this._exists(this.agentFile)) return this._read(this.agentFile);
50
+ // 学到的经验: 内容去重后取最近 10 条 (防重复经验污染自我画像)
51
+ const uniq = this._uniqByContent(lessons, (l) => l.lesson).slice(-10);
52
+ const md = `# 皮皮虾 自我画像
53
+
54
+ > 从经验库自动学习 | 更新: ${logicalDay()}
55
+
56
+ ## 学到的经验
57
+ ${uniq.map((l) => `- ${l.lesson}`).join("\n") || "- 暂无"}
58
+
59
+ ## 能力画像
60
+ - 工具: 文件操作 / 命令执行 / 搜索 / HTTP / 定时任务
61
+ - 记忆: 四层架构 (L0对话→L1原子→L2场景→L3画像)
62
+ - 自愈: 崩溃恢复 / 数据修复
63
+ - 军团: 多进程并行协作
64
+ `;
65
+ writeText(this.agentFile, md);
66
+ return md;
67
+ }
68
+
69
+ // 内容去重: 按归一化内容 (去空白折叠) 过滤, 保留首次出现的条目
70
+ _uniqByContent(items, pick = (x) => x.content) {
71
+ const seen = new Set();
72
+ const out = [];
73
+ for (const it of items || []) {
74
+ const key = String(pick(it) || "").trim().replace(/\s+/g, " ");
75
+ if (!key || seen.has(key)) continue;
76
+ seen.add(key);
77
+ out.push(it);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // 读取已生成的画像 (供 agent._context 注入; 未生成返回 "")
83
+ userPersona() { return this._read(this.userFile); }
84
+ agentPersona() { return this._read(this.agentFile); }
85
+
86
+ // 可观测: L3 画像更新时间 (无文件返回 null), 供 agent.stats() 聚合
87
+ stats() {
88
+ return {
89
+ user_updated: this._mtime(this.userFile),
90
+ agent_updated: this._mtime(this.agentFile),
91
+ };
92
+ }
93
+
94
+ _mtime(f) {
95
+ try { return new Date(fs.statSync(f).mtime).toISOString().slice(0, 10); } catch { return null; }
96
+ }
97
+
98
+ _topTopics(facts) {
99
+ const freq = new Map();
100
+ for (const f of facts) {
101
+ const words = String(f.content).match(/[\u4e00-\u9fa5]{2,4}/g) || [];
102
+ for (const w of words) {
103
+ if (STOP.has(w)) continue;
104
+ freq.set(w, (freq.get(w) || 0) + 1);
105
+ }
106
+ }
107
+ return [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
108
+ }
109
+
110
+ _exists(f) { try { return fs.existsSync(f); } catch { return false; } }
111
+ _read(f) { return readText(f, ""); }
112
+ }
@@ -0,0 +1,238 @@
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 "./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
+ this.state = readJson(this.stateFile, { day: null, turnCount: 0 });
43
+ }
44
+
45
+ _saveState() {
46
+ writeJson(this.stateFile, this.state);
47
+ }
48
+
49
+ _rollDay() {
50
+ const today = logicalDay();
51
+ if (this.state.day !== today) {
52
+ if (this.state.day) this._compileDaily();
53
+ this.state.day = today;
54
+ this._saveState();
55
+ }
56
+ }
57
+
58
+ // 跨天: 把上一日事件归档到 daily/ 并滚入 longterm (从 session 派生, 非 today.md)
59
+ _compileDaily() {
60
+ const day = this.state.day;
61
+ const lines = _renderLines(this.sessionStore, day);
62
+ const dailyFile = path.join(this.dir, "daily", `${day}.md`);
63
+ writeText(dailyFile, `# ${day}
64
+
65
+ ${lines.join("\n")}\n`);
66
+ if (lines.length) {
67
+ let longterm = readText(this.longtermMd) || "";
68
+ longterm += `\n## ${day}\n${lines.join("\n")}\n`;
69
+ writeText(this.longtermMd, longterm);
70
+ }
71
+ }
72
+
73
+ async recordTurn(user, assistant) {
74
+ this._rollDay();
75
+ this.state.turnCount += 1;
76
+ this._saveState();
77
+ // 对话原文已由 session 事件日志保存; 今日视图由 session 派生
78
+ if (this.state.turnCount % TURNS_PER_SUMMARY === 0) this._compileDaily_Rolling();
79
+ await this._compactIfNeeded();
80
+ // P1#9: 若配 LLM 提炼器且本次对话含高信号, 走结构化提炼; 否则退回启发式
81
+ if (this.extractor && _hasSignal(user, assistant)) {
82
+ try {
83
+ // 感知式提炼: 先检索与本次对话相关的已有记忆 (同主题 top 3), 喂给提炼器从源头去重
84
+ const related = String(user || "").trim()
85
+ ? this.factStore.query(user, { limit: 3 }).filter((f) => !(f.hits && f.hits > 5)) // 高命中已稳定, 不再重复提炼
86
+ : [];
87
+ const facts = await this.extractor(String(user || ""), String(assistant || ""), related);
88
+ if (facts && facts.length) {
89
+ // similarThreshold=0.6: LLM 提炼的字面变体 (同义不同词) 与已有事实语义相似时命中加分,
90
+ // 防「三件套」这类反复提炼的变体污染记忆库
91
+ for (const f of facts) this.factStore.add(f, { source: "extract", similarThreshold: 0.6 });
92
+ return;
93
+ }
94
+ } catch {}
95
+ }
96
+ if (user) this.factStore.addMemory(user);
97
+ }
98
+
99
+ // P1#9: 注入 LLM 结构化提炼器 (agent 调 setExtractor)
100
+ setExtractor(fn) { this.extractor = typeof fn === "function" ? fn : null; }
101
+
102
+ // 每 N 轮: 把今日新增事件滚动归档进 longterm (从 session 派生)
103
+ // v1.2.0 fix: 用游标(lastRolledDay/lastRolledSeq)只追加"本次滚动之后"的新事件,
104
+ // 不再每次把今日全文重复追加 —— 否则同一段对话会在 longterm 中反复出现, 加剧重复回话。
105
+ _compileDaily_Rolling() {
106
+ const today = logicalDay();
107
+ const st = this.state;
108
+ if (st.lastRolledDay !== today) { st.lastRolledDay = today; st.lastRolledSeq = 0; } // 跨天重置游标
109
+ const afterSeq = st.lastRolledSeq || 0;
110
+ const rows = [];
111
+ let maxSeq = afterSeq;
112
+ const dayEvents = this.sessionStore && typeof this.sessionStore.replayDay === "function"
113
+ ? this.sessionStore.replayDay(today)
114
+ : [];
115
+ for (const e of dayEvents) {
116
+ if (e.seq <= afterSeq) continue;
117
+ const who = e.type === "user/message" ? "用户" : "皮皮虾";
118
+ rows.push(`- [${new Date(e.ts).toISOString()}] ${who}: ${String(e.data?.content || "").slice(0, 200)}`);
119
+ if (e.seq > maxSeq) maxSeq = e.seq;
120
+ }
121
+ if (rows.length) {
122
+ let longterm = readText(this.longtermMd) || "";
123
+ longterm += `\n## ${today} (滚动)\n${rows.join("\n")}\n`;
124
+ writeText(this.longtermMd, longterm);
125
+ }
126
+ st.lastRolledSeq = maxSeq;
127
+ this._saveState();
128
+ }
129
+
130
+ // 滚动压缩: 今日事件超量时, 把最旧对话聚合压缩进 longterm, 只留近期
131
+ // v1.0.7 节流: 压缩后 COMPACT_MIN_INTERVAL_MS 内不重复, 防每轮对话都付 LLM 压缩成本
132
+ async _compactIfNeeded() {
133
+ const now = Date.now();
134
+ if (now - (this._lastCompactAt || 0) < COMPACT_MIN_INTERVAL_MS) return;
135
+ const lines = _renderLines(this.sessionStore, logicalDay());
136
+ if (lines.length < COMPACT_THRESHOLD) return;
137
+ const compactedLines = lines.slice(0, -COMPACT_KEEP);
138
+ const userMsgs = compactedLines
139
+ .filter((li) => li.indexOf("用户:") !== -1)
140
+ .map((li) => li.split("用户:")[1].trim())
141
+ .filter(Boolean);
142
+ let summary;
143
+ if (this.summarizer && compactedLines.length) {
144
+ try {
145
+ const raw = compactedLines.slice(0, 60).join("\n");
146
+ const s = await this.summarizer(raw);
147
+ summary = "[" + logicalDay() + " llm-summary] " + (s || "(空)");
148
+ } catch {
149
+ summary = "[" + logicalDay() + " thin] archived " + compactedLines.length + " lines (llm fail)";
150
+ }
151
+ } else if (userMsgs.length) {
152
+ summary = "[" + logicalDay() + " thin] " + userMsgs.length + " rounds archived: " + userMsgs.slice(0, 12).join(" | ") + (userMsgs.length > 12 ? " | ..." : "");
153
+ } else {
154
+ summary = "[" + logicalDay() + " thin] archived " + compactedLines.length + " lines";
155
+ }
156
+ let longterm = readText(this.longtermMd) || "";
157
+ longterm += "\n## " + logicalDay() + " (rollup)\n" + summary + "\n";
158
+ writeText(this.longtermMd, longterm);
159
+ this._lastCompactAt = now; // 节流: 压缩完成后记录, 60s 内不再压缩
160
+ }
161
+
162
+ context(userMsg) {
163
+ const todayCount = this.sessionStore ? _renderLines(this.sessionStore, logicalDay()).length : 0;
164
+ const rawLongterm = readText(this.longtermMd) || "";
165
+ const longterm = _longtermExcludingToday(rawLongterm, logicalDay()).slice(-3000);
166
+ const topFacts = this.factsTop(userMsg);
167
+ // v1.2.0 fix: 今日对话原文由会话历史(history)承载, 不再逐行重复注入到 system,
168
+ // 避免模型在 system 的"今日记忆"里看到与 history 相同的对话而重复回话。
169
+ const todayNote = todayCount
170
+ ? `今日已进行 ${todayCount} 轮对话(完整内容已随会话历史提供)。`
171
+ : "今日暂无对话。";
172
+ return `
173
+ # 今日对话
174
+ ${todayNote}
175
+
176
+ # 长期记忆 (最近)
177
+ ${longterm || "(暂无)"}
178
+
179
+ # 关键事实
180
+ ${topFacts || "(暂无)"}
181
+ `;
182
+ }
183
+
184
+ // 关键事实: 按当前问题语义检索优先, 不足 8 条用衰减分补齐 (去重)。
185
+ // 让每轮自动注入的记忆与当前问题相关, 而非纯衰减取 top (v0.8.1 语义注入)
186
+ factsTop(userMsg) {
187
+ try {
188
+ const q = String(userMsg || "").trim();
189
+ const semantic = q ? this.factStore.query(q, { limit: 8 }) : [];
190
+ const decay = this.factStore.query("", { limit: 8 });
191
+ const seen = new Set();
192
+ const merged = [];
193
+ for (const f of [...semantic, ...decay]) {
194
+ if (seen.has(f.id)) continue;
195
+ seen.add(f.id);
196
+ merged.push(f);
197
+ if (merged.length >= 8) break;
198
+ }
199
+ return merged.map((f) => `- [${f.score}] ${f.content}`).join("\n");
200
+ } catch { return ""; }
201
+ }
202
+
203
+ // 可观测: longterm 大小 + 今日事件数, 供 agent.stats() 聚合
204
+ stats() {
205
+ let longtermBytes = 0;
206
+ try { longtermBytes = fs.statSync(this.longtermMd).size; } catch {}
207
+ return {
208
+ longterm_bytes: longtermBytes,
209
+ events_today: _renderLines(this.sessionStore, logicalDay()).length,
210
+ };
211
+ }
212
+ }
213
+
214
+ // 读取长期记忆, 并剔除"今天"的段落 —— 当日对话已由会话历史(history)承载,
215
+ // 若 longterm 里今天滚动块也注入, 会与 history 重复, 是"重复回话"的诱因。
216
+ function _longtermExcludingToday(text, today) {
217
+ const out = [];
218
+ let inToday = false;
219
+ for (const l of String(text || "").split("\n")) {
220
+ const m = l.match(/^##\s+(\d{4}-\d{2}-\d{2})/);
221
+ if (m) { inToday = (m[1] === today); continue; }
222
+ if (!inToday) out.push(l);
223
+ }
224
+ return out.join("\n").trim();
225
+ }
226
+
227
+ // P1#9: 记忆信号预筛 - 命中关键词或长度信号才触发 LLM 提炼 (省成本)
228
+ function _hasSignal(user, assistant) {
229
+ const u = String(user || "");
230
+ const a = String(assistant || "");
231
+ const text = (u + " " + a).slice(0, 500);
232
+ if (text.length < 8) return false;
233
+ const SIGNAL = /(我是|我喜欢|我讨厌|我记得|请记住|记住|偏好|习惯|目标是|我需要|我要|想买|喜欢|不喜欢|重要|关键|待办|计划|打算|希望|擅长|不擅长|生日|地址|电话|邮箱|账号|密码|股票|基金|仓位|止损|止盈|策略|工作|项目|公司|同事|老板|朋友|家人|对象|结婚|买房|买车|考试|学习)/;
234
+ const isGreeting = /^(你好|在吗|谢谢|好的|嗯|ok|hi|hello|再见|拜拜|哈喽)[!。??]*$/i.test(u.trim());
235
+ // 收紧: 无信号关键词时, 仅对真正信息密集的长对话触发 LLM 提炼, 避免普通闲聊累积成本 [复审 P1#9]
236
+ return !isGreeting && (SIGNAL.test(text) || text.length > 200);
237
+ }
238
+
@@ -0,0 +1,42 @@
1
+ // src/utils/pii.js - PII / 凭证检测与脱敏 (架构参考 openhanako pii-guard)
2
+ const HARD_PATTERNS = [
3
+ { name: "api_key", regex: /\b(sk-[a-zA-Z0-9]{20,}|AKIA[A-Z0-9]{16}|gsk_[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}|glpat-[a-zA-Z0-9_-]{20,}|xoxb-[a-zA-Z0-9-]+)\b/g },
4
+ // v1.0.8: 放宽 inline_secret 值域 (含 :/# 等) + 8 位起, 短密钥不漏检 (原 16+ 位且不含 :#)
5
+ // P0 (2026-09-15): key 后允许可选闭合引号 —— 原版只匹配 key=value / key: value,
6
+ // JSON 序列化形式 "key":"value" (key 后先有闭合引号) 完全漏检, 已修。
7
+ { name: "inline_secret", regex: /\b(api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|password|bearer)["']?\s*[:=]\s*["']?([a-zA-Z0-9_/+=\-.:#]{8,})["']?/gi },
8
+ { name: "private_key", regex: /-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
9
+ { name: "credit_card", regex: /\b(?:\d{4}[- ]?){3}\d{4}\b/g },
10
+ { name: "id_card", regex: /\b\d{6}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b/g },
11
+ { name: "ssn", regex: /\b\d{3}-\d{2}-\d{4}\b/g },
12
+ // v1.0.8: 邮箱 + 中国大陆手机号 (11 位, 1[3-9] 开头)
13
+ { name: "email", regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
14
+ { name: "phone", regex: /\b1[3-9]\d{9}\b/g },
15
+ // v1.6.0-dev: URL query 里的敏感参数值 (token/key/secret/sign 等) — 最常见的凭证泄漏渠道, 此前未覆盖
16
+ // 仅匹配参数名白名单, 避免误伤合法 URL 参数; 保留参数名, 只替换值
17
+ { name: "url_secret", regex: /([?&](?:token|key|secret|api[_-]?key|access[_-]?token|auth|sign|sig|password)=)[^&#\s"']*/gi, redact: (m, pre) => pre + "[REDACTED]" },
18
+ ];
19
+
20
+ export function scrubPII(text) {
21
+ if (!text) return { cleaned: text, detected: [] };
22
+ const detected = [];
23
+ let cleaned = text;
24
+ for (const { name, regex, redact } of HARD_PATTERNS) {
25
+ regex.lastIndex = 0;
26
+ if (regex.test(cleaned)) {
27
+ detected.push(name);
28
+ regex.lastIndex = 0;
29
+ cleaned = cleaned.replace(regex, redact ? redact : "[REDACTED]");
30
+ }
31
+ }
32
+ return { cleaned, detected };
33
+ }
34
+
35
+ export function hasPII(text) {
36
+ if (!text) return false;
37
+ for (const { regex } of HARD_PATTERNS) {
38
+ regex.lastIndex = 0;
39
+ if (regex.test(text)) return true;
40
+ }
41
+ return false;
42
+ }
@@ -0,0 +1,80 @@
1
+ // src/utils/schema.js - 数据文件 schema 版本 + 迁移钩子
2
+ // 目标: 记忆数据文件 (facts.json / scenes.json) 保持纯数组格式不变 (现有读取者 healer/测试全部无感),
3
+ // 版本号写在旁挂的 <file>.schema 小文件中; 数据结构升级时注册迁移函数, 启动自动迁移。
4
+ // 设计:
5
+ // - 无 .schema 文件的历史数据一律视为 v1 (基线, 纯数组格式)
6
+ // - registerMigration(name, from, to, fn) 注册迁移链, 不允许跳级, 不允许覆盖
7
+ // - migrateData() 读当前版本 -> 沿迁移链推进 -> 原子写回数据 + 更新 .schema 文件
8
+ // - 崩溃安全: 迁移在原子写回后版本才推进, 中断重跑幂等
9
+ import path from "node:path";
10
+ import { atomicWrite, ensureDir, readJson } from "./store.js";
11
+
12
+ // 基线版本: 无 .schema 文件 (历史纯数组格式) 一律视为 v1
13
+ export const SCHEMA_BASE_VERSION = 1;
14
+
15
+ // name -> Map<fromVersion, { to, fn }>
16
+ const MIGRATIONS = new Map();
17
+
18
+ // 注册迁移: from 版本 -> to 版本 (to 必须 > from)
19
+ // fn(data, { from, to }) => 迁移后的 data
20
+ export function registerMigration(name, from, to, fn) {
21
+ if (!Number.isInteger(from) || !Number.isInteger(to) || to <= from) {
22
+ throw new Error(`非法迁移版本: ${name} ${from}->${to}`);
23
+ }
24
+ if (typeof fn !== "function") throw new Error(`迁移函数缺失: ${name} v${from}`);
25
+ let chain = MIGRATIONS.get(name);
26
+ if (!chain) { chain = new Map(); MIGRATIONS.set(name, chain); }
27
+ if (chain.has(from)) throw new Error(`迁移冲突: ${name} v${from} 已注册`);
28
+ chain.set(from, { to, fn });
29
+ return { name, from, to };
30
+ }
31
+
32
+ function schemaFileOf(file) {
33
+ return file + ".schema";
34
+ }
35
+
36
+ // 读当前数据文件版本 (无 .schema 文件视为基线 v1)
37
+ export function readSchema(file) {
38
+ const meta = readJson(schemaFileOf(file), null);
39
+ if (!meta || !Number.isInteger(meta.version)) return SCHEMA_BASE_VERSION;
40
+ return meta.version;
41
+ }
42
+
43
+ // 写版本 (原子)
44
+ export function writeSchema(file, name, version) {
45
+ const p = schemaFileOf(file);
46
+ ensureDir(path.dirname(p));
47
+ atomicWrite(p, JSON.stringify({ name, version, updatedAt: new Date().toISOString() }, null, 2));
48
+ }
49
+
50
+ // 把数据文件迁移到 currentVersion: 沿注册链逐级推进, 无迁移函数则安全跳过 (版本直接标记到目标, 数据不动)。
51
+ // 返回 { from(初始版本), to(最终版本), applied, data } —— applied 为实际执行的迁移步骤列表
52
+ export function migrateData({ file, name, data, currentVersion, logger = null }) {
53
+ const chain = MIGRATIONS.get(name) || new Map();
54
+ const start = readSchema(file);
55
+ let from = start;
56
+ let d = data;
57
+ const applied = [];
58
+ let guard = 0;
59
+ while (from < currentVersion) {
60
+ if (++guard > 20) throw new Error(`迁移链疑似死循环: ${name} v${from}`);
61
+ const step = chain.get(from);
62
+ if (!step) {
63
+ // 没有迁移函数但仍落后于目标: 记录说明并直接标记版本 (数据保持原样)
64
+ if (logger) logger(`schema ${name}: v${from} -> v${currentVersion} 无迁移函数, 数据保持原样`);
65
+ applied.push({ from, to: currentVersion, note: "no-migration" });
66
+ from = currentVersion;
67
+ break;
68
+ }
69
+ if (step.to > currentVersion) throw new Error(`迁移越界: ${name} v${from}->v${step.to} 超过目标 v${currentVersion}`);
70
+ d = step.fn(d, { from, to: step.to });
71
+ applied.push({ from, to: step.to });
72
+ from = step.to;
73
+ }
74
+ if (applied.length) {
75
+ // 迁移产生新数据: 原子写回 (rename 覆盖, 崩溃安全), 版本随 .schema 文件推进
76
+ atomicWrite(file, JSON.stringify(d, null, 2));
77
+ }
78
+ writeSchema(file, name, from);
79
+ return { from: start, to: from, applied, data: d };
80
+ }