ppxans-harness 2.4.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +218 -265
- package/bin/ppx-channels.js +2 -2
- package/bin/ppx-serve.js +5 -5
- package/bin/ppx-setup.js +124 -0
- package/bin/ppx-web.js +140 -0
- package/bin/ppx.js +2 -2
- package/config/identity.md +6 -6
- package/config/ishiki.md +16 -16
- package/config/ppx.json +15 -151
- package/config/ppx.json.example +143 -0
- package/package.json +17 -10
- package/skills/.usage.json +6 -0
- package/skills/agent-professional-training/SKILL.md +94 -0
- package/skills/brainstorm/SKILL.md +24 -0
- package/skills/cupid-lover-comms/SKILL.md +37 -0
- package/skills/debug/SKILL.md +26 -0
- package/skills/plan/SKILL.md +25 -0
- package/skills/ponytail/SKILL.md +25 -0
- package/skills/ppx-memory/SKILL.md +91 -0
- package/skills/ppx-memory/scripts/cli.js +192 -0
- package/skills/ppx-memory/scripts/experience.js +133 -0
- package/skills/ppx-memory/scripts/fact-store.js +842 -0
- package/skills/ppx-memory/scripts/l0.js +52 -0
- package/skills/ppx-memory/scripts/l2.js +146 -0
- package/skills/ppx-memory/scripts/l3.js +112 -0
- package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
- package/skills/ppx-memory/scripts/pii.js +42 -0
- package/skills/ppx-memory/scripts/schema.js +80 -0
- package/skills/ppx-memory/scripts/session.js +398 -0
- package/skills/ppx-memory/scripts/similarity.js +43 -0
- package/skills/ppx-memory/scripts/store.js +116 -0
- package/skills/ppx-memory/scripts/wal.js +38 -0
- package/skills/ppx-selfheal/SKILL.md +24 -0
- package/skills/ppx-selfheal/scripts/cli.js +80 -0
- package/skills/ppx-selfheal/scripts/healer.js +184 -0
- package/skills/ppx-selfheal/scripts/logger.js +17 -0
- package/skills/ppx-selfheal/scripts/store.js +116 -0
- package/skills/prompt-depth-kit/SKILL.md +28 -0
- package/skills/session-naming/SKILL.md +36 -0
- package/skills/verify/SKILL.md +25 -0
- package/src/agent/index.js +1340 -717
- package/src/agent/prompts.js +47 -3
- package/src/aml-server.js +197 -151
- package/src/ans/eviction.js +123 -143
- package/src/ans/guard.js +159 -120
- package/src/ans/lifecycle.js +96 -93
- package/src/ans/proactive.js +112 -129
- package/src/ans/reward.js +95 -111
- package/src/ans/values.js +15 -15
- package/src/audit/audit-chain.js +43 -7
- package/src/audit/verifier.js +157 -120
- package/src/bus/circuit-breaker.js +9 -1
- package/src/bus/runtime-bus.js +107 -93
- package/src/channels/base.js +57 -34
- package/src/channels/feishu.js +118 -126
- package/src/channels/http.js +1090 -592
- package/src/channels/index.js +111 -110
- package/src/channels/log.js +29 -29
- package/src/channels/wechat-crypto.js +73 -74
- package/src/channels/wechat.js +191 -197
- package/src/channels/workspace.js +94 -0
- package/src/channels-cli.js +126 -124
- package/src/cli.js +129 -120
- package/src/commands/index.js +142 -0
- package/src/config/channels.js +137 -170
- package/src/config/index.js +277 -224
- package/src/config/placeholder.js +31 -0
- package/src/config/providers.js +148 -188
- package/src/config/settings.js +156 -182
- package/src/core/policy.js +69 -18
- package/src/core/trace.js +8 -6
- package/src/edit/editblock.js +266 -0
- package/src/edit/snapshot.js +67 -0
- package/src/evidence/index.js +153 -0
- package/src/evolve/playbook.js +9 -10
- package/src/hooks/index.js +113 -0
- package/src/llm/client.js +188 -446
- package/src/llm/dsml.js +74 -74
- package/src/llm/embedder.js +41 -35
- package/src/llm/fence.js +52 -105
- package/src/llm/index.js +4 -4
- package/src/llm/local-embedder.js +94 -0
- package/src/llm/presets.js +113 -0
- package/src/llm/pricing.js +93 -0
- package/src/llm/retry.js +73 -73
- package/src/llm/router.js +92 -97
- package/src/mcp/admin.js +326 -0
- package/src/mcp/client.js +487 -375
- package/src/mcp/http.js +203 -0
- package/src/mcp/index.js +116 -116
- package/src/mcp/server.js +392 -0
- package/src/mcp/tasks.js +133 -0
- package/src/memory/asset-hub.js +6 -11
- package/src/memory/canvas.js +2 -5
- package/src/memory/compaction.js +28 -28
- package/src/memory/experience.js +133 -122
- package/src/memory/fact-store.js +914 -698
- package/src/memory/failure-episode.js +20 -11
- package/src/memory/fork.js +17 -8
- package/src/memory/index.js +8 -6
- package/src/memory/l0.js +53 -52
- package/src/memory/l2.js +145 -130
- package/src/memory/l3.js +111 -111
- package/src/memory/legion-board.js +71 -0
- package/src/memory/memory-ticker.js +239 -240
- package/src/memory/session.js +398 -397
- package/src/memory/sqlite-store.js +581 -0
- package/src/mode/blackboard.js +49 -49
- package/src/mode/graph.js +42 -41
- package/src/mode/index.js +64 -64
- package/src/mode/legion.js +54 -51
- package/src/mode/plan-exec.js +50 -50
- package/src/mode/router.js +28 -40
- package/src/orchestrator/agent-worker.js +69 -69
- package/src/orchestrator/dag.js +90 -83
- package/src/orchestrator/experts.js +76 -0
- package/src/orchestrator/index.js +1 -1
- package/src/orchestrator/legion.js +179 -187
- package/src/orchestrator/supervisor.js +6 -8
- package/src/permissions/index.js +378 -0
- package/src/persona/index.js +28 -29
- package/src/plugin/builtin.js +313 -212
- package/src/plugin/context.js +80 -79
- package/src/plugin/index.js +62 -62
- package/src/plugin/v3.js +73 -0
- package/src/protocol/index.js +148 -0
- package/src/repomap/index.js +309 -0
- package/src/review/index.js +393 -0
- package/src/seam/registry.js +3 -0
- package/src/seam/shell.js +55 -55
- package/src/security/injection.js +79 -0
- package/src/selfheal/evolve.js +67 -67
- package/src/selfheal/healer.js +184 -167
- package/src/selfheal/run.js +9 -9
- package/src/server.js +63 -60
- package/src/services/diagnose.js +180 -0
- package/src/services/learning-service.js +9 -0
- package/src/services/memory-health.js +34 -6
- package/src/services/memory-service.js +42 -9
- package/src/services/triage.js +138 -0
- package/src/session/parts.js +76 -0
- package/src/session/projection.js +73 -0
- package/src/session/rollout.js +54 -0
- package/src/session/turn.js +137 -0
- package/src/skills/lint.js +72 -0
- package/src/skills/loader.js +231 -150
- package/src/skills/search.js +58 -0
- package/src/skills/verify.js +95 -100
- package/src/tools/advanced.js +388 -352
- package/src/tools/builtin.js +384 -297
- package/src/tools/catalog.js +283 -159
- package/src/tools/command-guard.js +112 -112
- package/src/tools/custom.js +47 -47
- package/src/tools/delegate.js +383 -297
- package/src/tools/document.js +254 -253
- package/src/tools/git.js +151 -0
- package/src/tools/governance.js +47 -20
- package/src/tools/index.js +16 -11
- package/src/tools/methods.js +178 -178
- package/src/tools/ocr.js +59 -59
- package/src/tools/sandbox-worker.js +40 -0
- package/src/tools/sandbox.js +92 -0
- package/src/tools/seam.js +162 -125
- package/src/tools/selfmod.js +196 -176
- package/src/tools/v3.js +225 -0
- package/src/tools/vad.js +176 -0
- package/src/tools/voice.js +238 -0
- package/src/utils/async.js +14 -0
- package/src/utils/config-file.js +53 -0
- package/src/utils/crashguard.js +88 -0
- package/src/utils/http.js +53 -0
- package/src/utils/id.js +8 -0
- package/src/utils/json-state.js +33 -0
- package/src/utils/logger.js +17 -17
- package/src/utils/ndjson.js +25 -0
- package/src/utils/pii.js +42 -42
- package/src/utils/rate-limit.js +50 -0
- package/src/utils/schema.js +80 -0
- package/src/utils/similarity.js +43 -0
- package/src/utils/store.js +170 -108
- package/src/utils/text.js +15 -15
- package/src/utils/trace.js +153 -153
- package/src/utils/wal.js +39 -0
- package/src/utils/winutf8.js +16 -15
- package/src/wiki/index.js +170 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
// src/memory/fact-store.js - 记忆存储 (高斯衰减遗忘)
|
|
2
|
+
// 架构参考 openhanako: 每条记忆有 importance, 高斯衰减, 命中加分
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { ensureDir, readJson, writeJson, nowISO, withFileLock } from "./store.js";
|
|
6
|
+
import { migrateData, writeSchema } from "./schema.js";
|
|
7
|
+
import { setJaccard, setOverlap } from "./similarity.js";
|
|
8
|
+
import { walFileOf, appendWal, readWal, truncateWal } from "./wal.js";
|
|
9
|
+
|
|
10
|
+
// 记忆动词前缀: 去重时剔除, 让"记住:X"与"X"视为同一条 (防 LLM 提炼版与原文冗余)
|
|
11
|
+
const MEMORY_VERB_PREFIXES = [
|
|
12
|
+
/^(请记住|请记得|记得要|记住要|要记住|请牢记|别忘了|记住|记得|用户说|用户提到|提醒你)[::\s,,、]*/,
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// 记忆层级 (吸收自 ppx-v2 的 L4 程序性记忆): L1=事实/用户记忆, L4=程序性记忆(技能/流程/方法论)
|
|
16
|
+
// L4 衰减远慢于 L1 —— 技能与流程应当长期留存, 而非像闲聊事实一样快速遗忘
|
|
17
|
+
export const L4_DECAY_PER_DAY = 0.005;
|
|
18
|
+
export const LAYER_L1 = 1;
|
|
19
|
+
export const LAYER_L4 = 4;
|
|
20
|
+
// facts.json 当前 schema 版本 (纯数组基线 = 1); 未来数据结构变更时 +1 并注册迁移 (见 src/utils/schema.js)
|
|
21
|
+
export const FACTS_SCHEMA_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
export class FactStore {
|
|
24
|
+
constructor(dataDir, opts = {}) {
|
|
25
|
+
this.dir = path.join(dataDir, "memory");
|
|
26
|
+
ensureDir(this.dir);
|
|
27
|
+
this.file = path.join(this.dir, "facts.json");
|
|
28
|
+
// v1.0.9: 兼容 snake 配置键 (config.memory 是 snake_case, 原只认 camel 导致衰减/容量配置全部死键不生效)
|
|
29
|
+
const SNAKE_TO_CAMEL = {
|
|
30
|
+
decay_per_day: "decayPerDay", hit_bonus: "hitBonus", base_importance: "baseImportance",
|
|
31
|
+
forget_speed: "forgetSpeed", max_facts: "maxFacts",
|
|
32
|
+
};
|
|
33
|
+
const normOpts = {};
|
|
34
|
+
for (const [k, v] of Object.entries(opts || {})) normOpts[SNAKE_TO_CAMEL[k] || k] = v;
|
|
35
|
+
this.opts = {
|
|
36
|
+
decayPerDay: 0.02, // lambda
|
|
37
|
+
hitBonus: 5,
|
|
38
|
+
baseImportance: 10,
|
|
39
|
+
forgetSpeed: 1.0,
|
|
40
|
+
maxFacts: 1000, // L1 事实总量上限, 超限按「衰减分×重要性」裁剪最弱 (0/负数=不裁剪)
|
|
41
|
+
...normOpts,
|
|
42
|
+
};
|
|
43
|
+
// WAL 增量落盘: 默认关闭 (= 旧行为每次变更全量原子写); 开启后变更走追加日志 (facts.json.wal),
|
|
44
|
+
// 达阈值 (walThreshold) 才 compact 全量写, 高频写场景显著减少磁盘写放大。
|
|
45
|
+
// 数据文件本身保持纯数组格式, 读取方无感; 崩溃时启动重放 WAL 恢复。
|
|
46
|
+
this.wal = !!this.opts.wal;
|
|
47
|
+
this.walThreshold = Math.max(1, Number(this.opts.walThreshold) || 50);
|
|
48
|
+
this.walFile = walFileOf(this.file);
|
|
49
|
+
this._walPending = 0;
|
|
50
|
+
this.facts = readJson(this.file, []);
|
|
51
|
+
// schema 版本迁移 (旁挂 .schema 文件; 数据文件保持纯数组, healer/外部读取者无感)
|
|
52
|
+
const mig = migrateData({
|
|
53
|
+
file: this.file,
|
|
54
|
+
name: "facts",
|
|
55
|
+
data: this.facts,
|
|
56
|
+
currentVersion: FACTS_SCHEMA_VERSION,
|
|
57
|
+
});
|
|
58
|
+
this.facts = mig.data;
|
|
59
|
+
// 倒排索引: token -> Set<factId>, 检索 O(n) -> O(候选)
|
|
60
|
+
this._index = new Map();
|
|
61
|
+
// BM25 作用域统计缓存 (facts 内容不可变, add 时失效即可), 避免每查询 O(N) 重算
|
|
62
|
+
this._statsCache = new Map();
|
|
63
|
+
// 可插拔 embedder (dense 语义检索, 默认 null = 纯 BM25); _embedCache 内存缓存不落盘
|
|
64
|
+
this.embedder = null;
|
|
65
|
+
this._embedCache = new Map();
|
|
66
|
+
this._embedCacheMax = 1000; // LRU 上限: 超过淘汰最旧插入项, 防长跑会话内存无界增长
|
|
67
|
+
for (const fact of this.facts) this._indexFact(fact);
|
|
68
|
+
if (this.wal) this._replayWal(); // 重放 WAL 增量 (崩溃恢复: 快照 + 追加日志 = 完整状态)
|
|
69
|
+
this.save();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 全量落盘。非 WAL 模式: 直接原子写 (兼容旧行为, 调用方通常在锁内)。
|
|
73
|
+
// WAL 模式: 加锁 flush (全量写 + 清 WAL), 与追加互斥防丢事件。
|
|
74
|
+
save() {
|
|
75
|
+
if (!this.wal) {
|
|
76
|
+
writeJson(this.file, this.facts);
|
|
77
|
+
writeSchema(this.file, "facts", FACTS_SCHEMA_VERSION);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.flush();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 公开 flush: 加锁全量落盘 + 清 WAL (外部显式落盘点)
|
|
84
|
+
flush() {
|
|
85
|
+
withFileLock(this.file, () => this._flushLocked());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 锁内重读最新状态。WAL 模式下磁盘快照滞后, 必须重放 WAL 增量才是完整状态
|
|
89
|
+
// (非 WAL 模式: 每次变更已落盘, 磁盘即最新, 保持旧行为)
|
|
90
|
+
_reload() {
|
|
91
|
+
this.facts = readJson(this.file, []);
|
|
92
|
+
if (this.wal) this.facts = this._applyWalTo(this.facts);
|
|
93
|
+
this.rebuildIndex();
|
|
94
|
+
return this.facts;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 把 WAL 增量事件按序应用到磁盘快照 (幂等: upsert 按 id 覆盖, remove 删 id, replace 整体替换)
|
|
98
|
+
_applyWalTo(diskFacts) {
|
|
99
|
+
const events = readWal(this.walFile);
|
|
100
|
+
if (!events.length) return diskFacts;
|
|
101
|
+
const byId = new Map(diskFacts.map((f) => [f.id, f]));
|
|
102
|
+
for (const evt of events) {
|
|
103
|
+
if (evt.op === "upsert" && evt.fact && evt.fact.id) byId.set(evt.fact.id, evt.fact);
|
|
104
|
+
else if (evt.op === "replace" && Array.isArray(evt.facts)) { byId.clear(); for (const f of evt.facts) byId.set(f.id, f); }
|
|
105
|
+
else if (evt.op === "remove" && Array.isArray(evt.ids)) for (const id of evt.ids) byId.delete(id);
|
|
106
|
+
}
|
|
107
|
+
return [...byId.values()];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 锁内全量落盘 (调用方必须已持有文件锁)。
|
|
111
|
+
// 非 WAL 模式: 内存即真相, 直接原子写 (每次变更已落盘, 无滞后快照可合并)。
|
|
112
|
+
// WAL 模式: 合并磁盘快照+WAL+内存 (内存优先) 后原子写 + 清 WAL, 防丢其他进程的增量事件。
|
|
113
|
+
_flushLocked() {
|
|
114
|
+
if (!this.wal) {
|
|
115
|
+
writeJson(this.file, this.facts);
|
|
116
|
+
writeSchema(this.file, "facts", FACTS_SCHEMA_VERSION);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const disk = readJson(this.file, []);
|
|
120
|
+
const merged = this._applyWalTo(disk);
|
|
121
|
+
const byId = new Map(merged.map((f) => [f.id, f]));
|
|
122
|
+
for (const f of this.facts) byId.set(f.id, f); // 内存优先: 本进程全部变更
|
|
123
|
+
this.facts = [...byId.values()];
|
|
124
|
+
this.rebuildIndex(); // facts 可能被合并变更, 同步重建索引
|
|
125
|
+
writeJson(this.file, this.facts);
|
|
126
|
+
truncateWal(this.walFile);
|
|
127
|
+
this._walPending = 0;
|
|
128
|
+
writeSchema(this.file, "facts", FACTS_SCHEMA_VERSION);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 单条事件变更记录 (调用方必须已持有文件锁):
|
|
132
|
+
// 非 WAL: 立即全量落盘 (旧行为); WAL: 追加事件, 达阈值自动 compact
|
|
133
|
+
// 四个变体 (_change/_markMutated/_markRemoved/_markReplace) 共享此追加逻辑 (2026-09-18 重构收敛)
|
|
134
|
+
_walAppend(evt, count = 1) {
|
|
135
|
+
appendWal(this.walFile, evt);
|
|
136
|
+
this._walPending += count;
|
|
137
|
+
if (this._walPending >= this.walThreshold) this._flushLocked();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_change(evt) {
|
|
141
|
+
if (!this.wal) { this._flushLocked(); return; }
|
|
142
|
+
this._walAppend(evt);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// upsert 变体 (携带完整对象快照, 重放幂等)
|
|
146
|
+
_markMutated(...facts) {
|
|
147
|
+
if (!this.wal) { this._flushLocked(); return; }
|
|
148
|
+
for (const f of facts) this._walAppend({ op: "upsert", fact: f }, 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// remove 变体 (批量删除 id)
|
|
152
|
+
_markRemoved(ids) {
|
|
153
|
+
if (!this.wal) { this._flushLocked(); return; }
|
|
154
|
+
this._walAppend({ op: "remove", ids });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// replace 变体 (整体替换, 供 importAll replace)
|
|
158
|
+
_markReplace(facts) {
|
|
159
|
+
if (!this.wal) { this._flushLocked(); return; }
|
|
160
|
+
this._walAppend({ op: "replace", facts });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 启动重放: WAL 增量按序应用到内存 (幂等); 磁盘落盘由构造末尾 save() 统一完成
|
|
164
|
+
_replayWal() {
|
|
165
|
+
this.facts = this._applyWalTo(this.facts);
|
|
166
|
+
this.rebuildIndex();
|
|
167
|
+
this._walPending = 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 高斯衰减: score = score * exp(-lambda * t^2), t = days since last access
|
|
171
|
+
// layer 感知 (吸收自 ppx-v2 五层记忆): L4 程序性记忆衰减极慢 (lambda 0.005), 技能/流程应当长期留存
|
|
172
|
+
_decay(score, days, layer = 1) {
|
|
173
|
+
if (days <= 0) return score;
|
|
174
|
+
const base = Number(layer) === 4 ? L4_DECAY_PER_DAY : this.opts.decayPerDay;
|
|
175
|
+
const lambda = base * this.opts.forgetSpeed;
|
|
176
|
+
return score * Math.exp(-lambda * days * days);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 该条记忆的实际衰减率 (按 layer)
|
|
180
|
+
_lambdaOf(f) {
|
|
181
|
+
const base = Number(f?.layer) === 4 ? L4_DECAY_PER_DAY : this.opts.decayPerDay;
|
|
182
|
+
return base * this.opts.forgetSpeed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 有效记忆集: 排除软删 (status='deleted') 与版本链归档 (status='archived')
|
|
186
|
+
// 软删是 ppx-v2 记忆治理的核心语义: 遗忘可回滚, 不是不可逆的物理删除
|
|
187
|
+
_live(scope = null) {
|
|
188
|
+
const base = scope == null ? this.facts : this.facts.filter((f) => f.scope === scope);
|
|
189
|
+
return base.filter((f) => f.status !== "deleted" && f.status !== "archived");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_nowDays() {
|
|
193
|
+
return Date.now() / 86400000;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 内容归一化: 去首尾空白 + 折叠连续空白, 用于去重比对
|
|
197
|
+
_norm(s) {
|
|
198
|
+
return String(s || "").trim().replace(/\s+/g, " ");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 查重键: 在 _norm 基础上去掉"记忆动词前缀"和尾部标点, 让
|
|
202
|
+
// 原文「记住:老板的生日是 10 月 1 日」与 LLM 提炼的「老板的生日是 10 月 1 日」判定为同一条, 防冗余
|
|
203
|
+
// 只去记忆类动词, 不碰普通句子, 避免误合并
|
|
204
|
+
_normKey(s) {
|
|
205
|
+
let k = this._norm(s);
|
|
206
|
+
for (const re of MEMORY_VERB_PREFIXES) k = k.replace(re, "");
|
|
207
|
+
return k.replace(/[。!?!?;;,,]+$/, "");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 导入条目 → 完整事实对象 (importAll merge/replace 两分支共用, 2026-09-18 重构去重);
|
|
211
|
+
// content 归一化后为空返回 null。补全对象字段 —— 缺失 lastAccess/importance 会让
|
|
212
|
+
// 衰减/recency 计算出 NaN, 检索永远返回空 (v2.7.0 修复语义保持)。
|
|
213
|
+
_normalizeFact(it) {
|
|
214
|
+
const norm = this._norm(it?.content);
|
|
215
|
+
if (!norm) return null;
|
|
216
|
+
const now = nowISO();
|
|
217
|
+
return {
|
|
218
|
+
id: it.id || cryptoRandomId(),
|
|
219
|
+
content: norm,
|
|
220
|
+
type: it.type || "general",
|
|
221
|
+
source: it.source || "import",
|
|
222
|
+
importance: it.importance ?? this.opts.baseImportance,
|
|
223
|
+
score: it.score ?? (it.importance ?? this.opts.baseImportance),
|
|
224
|
+
created: it.created || now,
|
|
225
|
+
lastAccess: it.lastAccess || now,
|
|
226
|
+
hits: it.hits || 0,
|
|
227
|
+
scope: it.scope ?? null,
|
|
228
|
+
layer: Number(it.layer) === LAYER_L4 ? LAYER_L4 : LAYER_L1,
|
|
229
|
+
status: it.status === "deleted" ? "deleted" : "active",
|
|
230
|
+
prevId: it.prevId ?? null,
|
|
231
|
+
...(it.ttlDays ? { ttlDays: Number(it.ttlDays) } : {}),
|
|
232
|
+
...(it.meta ? { meta: it.meta } : {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 软删公共实现 (forget / sweepExpired / clearLayer 共用): 标记 status='deleted' + 时间/原因
|
|
237
|
+
_softDelete(f, reason) {
|
|
238
|
+
f.status = "deleted";
|
|
239
|
+
f.deletedAt = nowISO();
|
|
240
|
+
f.deleteReason = reason ? String(reason).slice(0, 200) : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
add(content, { importance = this.opts.baseImportance, type = "general", source = "manual", dedupe = true, scope = null, meta = null, similarThreshold = 0, layer = LAYER_L1, ttlDays = null } = {}) {
|
|
244
|
+
const norm = this._norm(content);
|
|
245
|
+
if (!norm) return null;
|
|
246
|
+
// 跨进程/多 agent 共享 dataDir 时的写保护: 锁内读-改-写, 防并发覆盖丢更新 (与 Experience 对称)
|
|
247
|
+
// add 是唯一写入口, 锁内重读磁盘最新 facts (防基于过期内存操作), 操作后落盘
|
|
248
|
+
return withFileLock(this.file, () => {
|
|
249
|
+
// 锁内重读: 拿最新状态再操作 (WAL 模式 = 磁盘快照 + 重放 WAL, 防内存回退丢未 flush 变更)
|
|
250
|
+
this._reload();
|
|
251
|
+
const now = nowISO();
|
|
252
|
+
if (dedupe) {
|
|
253
|
+
// 内容去重: 归一化后相同 (含"记住:"等前缀差异) 已存在则命中加分, 不新增
|
|
254
|
+
// 排除软删与归档记忆 (2026-09-18 修复: 原只排除 deleted, archived 旧版本也能命中,
|
|
255
|
+
// 命中加分落在不可见副本上, 活跃记忆不新增)
|
|
256
|
+
const normKey = this._normKey(content);
|
|
257
|
+
const existing = this.facts.find((f) => f.status !== "deleted" && f.status !== "archived" && this._normKey(f.content) === normKey);
|
|
258
|
+
if (existing) {
|
|
259
|
+
existing.hits += 1;
|
|
260
|
+
existing.lastAccess = now;
|
|
261
|
+
existing.score += this.opts.hitBonus;
|
|
262
|
+
this._markMutated(existing);
|
|
263
|
+
return existing;
|
|
264
|
+
}
|
|
265
|
+
// 语义相似去重: similarThreshold>0 时, 与现有事实相似度达标则命中加分
|
|
266
|
+
// 双保险: 先 Jaccard (模板类变体), 未命中再 overlap (词序变化大的松散变体)
|
|
267
|
+
if (similarThreshold > 0) {
|
|
268
|
+
const similar = this.findSimilar(norm, { threshold: similarThreshold, scope })
|
|
269
|
+
|| this.findSimilar(norm, { threshold: similarThreshold, scope, method: "overlap" });
|
|
270
|
+
if (similar) {
|
|
271
|
+
similar.hits += 1;
|
|
272
|
+
similar.lastAccess = now;
|
|
273
|
+
similar.score += this.opts.hitBonus;
|
|
274
|
+
this._markMutated(similar);
|
|
275
|
+
return similar;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const fact = {
|
|
280
|
+
id: cryptoRandomId(),
|
|
281
|
+
content: norm,
|
|
282
|
+
type,
|
|
283
|
+
source,
|
|
284
|
+
importance,
|
|
285
|
+
score: importance,
|
|
286
|
+
created: now,
|
|
287
|
+
lastAccess: now,
|
|
288
|
+
hits: 0,
|
|
289
|
+
scope,
|
|
290
|
+
// 记忆治理字段 (吸收自 ppx-v2 mem-store): 层级 / 状态 / 版本链 / TTL
|
|
291
|
+
layer: Number(layer) === LAYER_L4 ? LAYER_L4 : LAYER_L1,
|
|
292
|
+
status: "active",
|
|
293
|
+
prevId: null,
|
|
294
|
+
...(ttlDays ? { ttlDays: Number(ttlDays) } : {}),
|
|
295
|
+
...(meta ? { meta } : {}),
|
|
296
|
+
};
|
|
297
|
+
this.facts.push(fact);
|
|
298
|
+
this._indexFact(fact);
|
|
299
|
+
this._statsCache.clear(); // 新增事实 -> 作用域统计失效
|
|
300
|
+
// 新增先记 upsert 再裁剪 (2026-09-18 修复 WAL 事件序): 原先 _prune 先写 remove、
|
|
301
|
+
// _markMutated 后补 upsert, WAL 重放时 upsert 在 remove 之后 → 被裁剪的事实"复活"。
|
|
302
|
+
// 改为 upsert → remove 顺序, 重放结果与内存一致。
|
|
303
|
+
this._markMutated(fact);
|
|
304
|
+
// 总量裁剪: 超 maxFacts 时删除最弱事实 (防记忆膨胀)
|
|
305
|
+
this._prune();
|
|
306
|
+
return fact;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// L1 总量裁剪: 超 maxFacts 时, 按「衰减后有效分 × 重要性」排序, 删除最弱事实。
|
|
311
|
+
// 补齐「衰减只在查询层生效、不删数据」的缺口 -> 这里做存储层硬清理。
|
|
312
|
+
// 只在 add 新增时触发; 去重命中/加分(hit) 不增条数, 无需裁剪。
|
|
313
|
+
_prune() {
|
|
314
|
+
const max = this.opts.maxFacts;
|
|
315
|
+
if (!max || max <= 0 || this.facts.length <= max) return 0;
|
|
316
|
+
const nowD = this._nowDays();
|
|
317
|
+
const scored = this.facts.map((f) => {
|
|
318
|
+
const days = Math.max(0, nowD - new Date(f.lastAccess).getTime() / 86400000);
|
|
319
|
+
const recency = Math.exp(-this._lambdaOf(f) * days * days); // 0~1 (L4 程序性记忆衰减更慢, 更抗裁剪)
|
|
320
|
+
const imp = Math.min(f.importance || 0, 20) / 20; // 0~1
|
|
321
|
+
return { id: f.id, key: (f.score * (0.4 + 0.6 * recency)) * (0.5 + 0.5 * imp) };
|
|
322
|
+
});
|
|
323
|
+
scored.sort((a, b) => b.key - a.key);
|
|
324
|
+
const keep = new Set(scored.slice(0, max).map((x) => x.id));
|
|
325
|
+
const removedIds = this.facts.filter((f) => !keep.has(f.id)).map((f) => f.id);
|
|
326
|
+
this.facts = this.facts.filter((f) => keep.has(f.id));
|
|
327
|
+
this.rebuildIndex(); // 重建倒排索引 (内部已清 _statsCache)
|
|
328
|
+
if (removedIds.length) this._markRemoved(removedIds);
|
|
329
|
+
return removedIds.length;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 字符级索引 key: 中文拆单字 + 英文按 token (对中文检索才有效)
|
|
333
|
+
// 索引层用单字(宽召回), 精排用 bigram(准匹配)
|
|
334
|
+
_charKeys(s) {
|
|
335
|
+
const chars = (String(s).match(/[\u4e00-\u9fff]/g) || []); // 中文单字
|
|
336
|
+
const en = (String(s).toLowerCase().match(/[a-z0-9]+/g) || []); // 英文/数字 token
|
|
337
|
+
return new Set([...chars, ...en]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 倒排索引: 把一条事实的字符 key 挂到索引 (key -> factId)
|
|
341
|
+
_indexFact(fact) {
|
|
342
|
+
for (const k of this._charKeys(fact.content)) {
|
|
343
|
+
if (!this._index.has(k)) this._index.set(k, new Set());
|
|
344
|
+
this._index.get(k).add(fact.id);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 重建索引 (facts 外部变更后调用)
|
|
349
|
+
rebuildIndex() {
|
|
350
|
+
this._index = new Map();
|
|
351
|
+
this._statsCache.clear();
|
|
352
|
+
for (const fact of this.facts) this._indexFact(fact);
|
|
353
|
+
return this._index.size;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ==== BM25 检索 (升级版) ====
|
|
357
|
+
|
|
358
|
+
// 精排分词: 中文 bigram + 英文 token (解决"止损"匹配"止损规则"的长段问题)
|
|
359
|
+
_bigramSet(s) {
|
|
360
|
+
const out = new Set();
|
|
361
|
+
const low = String(s || "").toLowerCase();
|
|
362
|
+
const cjk = low.match(/[\u4e00-\u9fff]+/g) || [];
|
|
363
|
+
for (const seg of cjk) {
|
|
364
|
+
if (seg.length === 1) { out.add(seg); continue; }
|
|
365
|
+
for (let i = 0; i < seg.length - 1; i++) out.add(seg.slice(i, i + 2));
|
|
366
|
+
}
|
|
367
|
+
const en = low.match(/[a-z0-9]+/g) || [];
|
|
368
|
+
for (const tk of en) out.add("en:" + tk);
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 在作用域内统计 bigram 文档频率 / 平均长度 (BM25 参数)
|
|
373
|
+
// 关键: df 用 bigram token 统计 (而非单字索引), 否则 IDF 恒为常数, 失去"罕见词权重高"的灵魂
|
|
374
|
+
// 同时在 scoped 内计算, 保证 scope (AML 多租户) 统计隔离
|
|
375
|
+
_queryStats(scoped) {
|
|
376
|
+
const N = scoped.length;
|
|
377
|
+
const df = new Map();
|
|
378
|
+
let totalLen = 0;
|
|
379
|
+
for (const f of scoped) {
|
|
380
|
+
const bg = this._bigramSet(f.content);
|
|
381
|
+
totalLen += bg.size;
|
|
382
|
+
for (const t of bg) df.set(t, (df.get(t) || 0) + 1);
|
|
383
|
+
}
|
|
384
|
+
return { N, avgdl: N ? totalLen / N : 1, df };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 单查询词 IDF: ln(1 + (N - df + 0.5)/(df + 0.5))
|
|
388
|
+
// df 来自作用域内 bigram 文档频率 (见 _queryStats), 罕见词高分、常见词低分
|
|
389
|
+
_idf(tok, stats) {
|
|
390
|
+
const df = stats.df.get(tok) || 0;
|
|
391
|
+
return Math.log(1 + (stats.N - df + 0.5) / (df + 0.5));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// BM25 打分 (简化 tf=1, 因 tokenize 去重为 set)
|
|
395
|
+
_bm25Score(fact, qAll, stats) {
|
|
396
|
+
const fAll = this._bigramSet(fact.content);
|
|
397
|
+
if (!fAll.size) return 0;
|
|
398
|
+
const docLen = fAll.size;
|
|
399
|
+
const k1 = 1.5;
|
|
400
|
+
const b = 0.75;
|
|
401
|
+
let s = 0;
|
|
402
|
+
for (const t of qAll) {
|
|
403
|
+
if (!fAll.has(t)) continue;
|
|
404
|
+
const idf = this._idf(t, stats);
|
|
405
|
+
const tf = 1;
|
|
406
|
+
s += idf * ((tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (docLen / stats.avgdl))));
|
|
407
|
+
}
|
|
408
|
+
return s;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// bigram Jaccard 相似度 (0~1): 两段文本的 bigram 集合重合度
|
|
412
|
+
// 用于语义相似去重 (LLM 提炼变体字面不同但语义相同), 阈值通常 0.5+
|
|
413
|
+
// (集合运算收敛到 utils/similarity, 分词仍用本类的 scope 感知 _bigramSet)
|
|
414
|
+
_jaccard(a, b) {
|
|
415
|
+
return setJaccard(this._bigramSet(a), this._bigramSet(b));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// bigram overlap 系数 (0~1): 交集 / 较短集合, 对「词序变化但共享核心词」的松散同义改写更敏感
|
|
419
|
+
// 比 Jaccard 更宽松: LLM 提炼变体词序/措辞大变时 Jaccard 可能 <0.6, 但核心词重合度高, overlap 能捕获
|
|
420
|
+
_overlap(a, b) {
|
|
421
|
+
return setOverlap(this._bigramSet(a), this._bigramSet(b));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 查找与给定内容最相似的现有事实 (相似度 >= threshold 才返回, 默认 null)
|
|
425
|
+
// method: "jaccard" (默认) 或 "overlap" (词序变化容错)
|
|
426
|
+
// 供 add(similarThreshold) / 提炼去重使用; 中文 bigram 变体通常 >0.6
|
|
427
|
+
findSimilar(content, { threshold = 0.6, scope = null, method = "jaccard" } = {}) {
|
|
428
|
+
const c = this._norm(content);
|
|
429
|
+
if (!c) return null;
|
|
430
|
+
const scoped = this._live(scope);
|
|
431
|
+
const fn = method === "overlap" ? (a, b) => this._overlap(a, b) : (a, b) => this._jaccard(a, b);
|
|
432
|
+
let best = null;
|
|
433
|
+
let bestScore = 0;
|
|
434
|
+
for (const f of scoped) {
|
|
435
|
+
const s = fn(c, f.content);
|
|
436
|
+
if (s > bestScore) { bestScore = s; best = f; }
|
|
437
|
+
}
|
|
438
|
+
return bestScore >= threshold ? best : null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 检索: BM25 主导 (IDF 区分常见/罕见词 + 长度归一) + 子串强信号 + 高斯衰减(乘性时效) + 命中权重
|
|
442
|
+
query(q, { limit = 5, minScore = 1, scope = null } = {}) {
|
|
443
|
+
const nowD = this._nowDays();
|
|
444
|
+
const ql = (q || "").toLowerCase();
|
|
445
|
+
const qAll = this._bigramSet(ql);
|
|
446
|
+
// scope 过滤基底 (检索隔离: 只查指定 scope 的事实); 软删条目一律不进检索
|
|
447
|
+
const scoped = this._live(scope);
|
|
448
|
+
// 空查询: 按衰减分返回全部 (保持旧行为, 供 memory-ticker 取 top facts)
|
|
449
|
+
if (qAll.size === 0) {
|
|
450
|
+
return scoped
|
|
451
|
+
.map((f) => ({ ...f, effectiveScore: this._decay(f.score, Math.max(0, nowD - new Date(f.lastAccess).getTime() / 86400000), f.layer) }))
|
|
452
|
+
.sort((a, b) => b.effectiveScore - a.effectiveScore)
|
|
453
|
+
.slice(0, limit);
|
|
454
|
+
}
|
|
455
|
+
// 倒排候选集 (至少命中一个查询字符 key; 阈值保护防常见字退化)
|
|
456
|
+
let candidates = scoped;
|
|
457
|
+
const qKeys = this._charKeys(q);
|
|
458
|
+
if (qKeys.size > 0 && this._index && this._index.size) {
|
|
459
|
+
const candIds = new Set();
|
|
460
|
+
for (const k of qKeys) {
|
|
461
|
+
const ids = this._index.get(k);
|
|
462
|
+
if (ids) for (const id of ids) candIds.add(id);
|
|
463
|
+
}
|
|
464
|
+
if (candIds.size > 0 && candIds.size <= scoped.length * 0.9) {
|
|
465
|
+
candidates = scoped.filter((f) => candIds.has(f.id));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// BM25 参数在作用域内计算, 按 scope 缓存 (facts 不可变, add 时失效)
|
|
469
|
+
const scopeKey = scope == null ? "__all__" : "s:" + scope;
|
|
470
|
+
let stats = this._statsCache.get(scopeKey);
|
|
471
|
+
if (!stats) { stats = this._queryStats(scoped); this._statsCache.set(scopeKey, stats); }
|
|
472
|
+
const scored = [];
|
|
473
|
+
for (const f of candidates) {
|
|
474
|
+
const days = Math.max(0, nowD - new Date(f.lastAccess).getTime() / 86400000);
|
|
475
|
+
const fc = f.content.toLowerCase();
|
|
476
|
+
// BM25 主导
|
|
477
|
+
const bm = this._bm25Score(f, qAll, stats);
|
|
478
|
+
// 无交集(词未命中且无整句子串)则跳过, 避免返回无关噪声
|
|
479
|
+
if (bm === 0 && !(ql && fc.includes(ql))) continue;
|
|
480
|
+
// 时效+命中权重做成"乘性因子": 越新越接近 1, 越旧最多折到 0.4 倍
|
|
481
|
+
// 避免旧记忆(短)靠微弱加法反超新记忆(长), 保证新旧事实冲突时返回新事实
|
|
482
|
+
const recency = Math.exp(-this._lambdaOf(f) * days * days); // 0~1, 越新越大 (L4 衰减更慢)
|
|
483
|
+
let s = bm * 10 * (0.4 + 0.6 * recency);
|
|
484
|
+
// 子串强信号 (整句命中)
|
|
485
|
+
if (ql && fc.includes(ql)) s += 5;
|
|
486
|
+
// 命中权重 (辅助)
|
|
487
|
+
s += (f.hits > 0 ? Math.min(f.hits, 5) : 0);
|
|
488
|
+
// 重要性因子 (补齐 CrewAI 三因子: 语义×时效×重要性); 默认 importance=10 -> +1.5, 不喧宾夺主
|
|
489
|
+
s += Math.min(f.importance || 0, 20) / 20 * 3;
|
|
490
|
+
if (s >= minScore) scored.push({ ...f, effectiveScore: s, bm25: bm });
|
|
491
|
+
}
|
|
492
|
+
scored.sort((a, b) => b.effectiveScore - a.effectiveScore);
|
|
493
|
+
return scored.slice(0, limit);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 多查询变体检索 + RRF 融合 (供 LLM 查询扩展等场景: 每个变体各查一遍再融合)
|
|
497
|
+
queryMulti(queries, { limit = 5, scope = null, minScore = 1 } = {}) {
|
|
498
|
+
const lists = [];
|
|
499
|
+
for (const q of queries) {
|
|
500
|
+
const r = this.query(q, { limit: Math.max(limit * 2, 10), scope, minScore });
|
|
501
|
+
if (r.length) lists.push(r);
|
|
502
|
+
}
|
|
503
|
+
if (!lists.length) return [];
|
|
504
|
+
if (lists.length === 1) return lists[0].slice(0, limit);
|
|
505
|
+
return rrfFuse(lists).slice(0, limit);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---- 可插拔 embedder (dense 语义检索, 零依赖默认关闭) ----
|
|
509
|
+
// 用户注入: ctx.consume("facts").setEmbedder(async (text) => number[])
|
|
510
|
+
setEmbedder(fn) {
|
|
511
|
+
this.embedder = typeof fn === "function" ? fn : null;
|
|
512
|
+
this._embedCache.clear();
|
|
513
|
+
return this;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async _embed(text) {
|
|
517
|
+
if (!this.embedder) throw new Error("未配置 embedder");
|
|
518
|
+
const v = await this.embedder(String(text));
|
|
519
|
+
return Array.isArray(v) && v.length ? v : null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 余弦相似度 (零依赖)
|
|
523
|
+
_cosine(a, b) {
|
|
524
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
525
|
+
let dot = 0, na = 0, nb = 0;
|
|
526
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
527
|
+
if (!na || !nb) return 0;
|
|
528
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 语义检索: embedder 有则 dense cosine 排序 + 与 BM25 RRF 融合; 无则退化为 BM25
|
|
532
|
+
async querySemantic(q, { limit = 5, scope = null } = {}) {
|
|
533
|
+
if (!this.embedder) return this.query(q, { limit, scope });
|
|
534
|
+
const scoped = scope == null ? this.facts : this.facts.filter((f) => f.scope === scope);
|
|
535
|
+
const qv = await this._embed(q).catch(() => null);
|
|
536
|
+
if (!qv) return this.query(q, { limit, scope });
|
|
537
|
+
// 懒加载每条事实的 embedding (内存缓存, 不落盘避免 facts.json 膨胀)
|
|
538
|
+
const dense = [];
|
|
539
|
+
for (const f of scoped) {
|
|
540
|
+
let ev = this._embedCache.get(f.id);
|
|
541
|
+
if (!ev) {
|
|
542
|
+
try { ev = await this._embed(f.content); } catch { ev = null; }
|
|
543
|
+
this._embedCache.set(f.id, ev);
|
|
544
|
+
}
|
|
545
|
+
if (ev) dense.push({ ...f, dense: this._cosine(qv, ev) });
|
|
546
|
+
// LRU 淘汰: Map 迭代序 = 插入序, 超限删最旧
|
|
547
|
+
if (this._embedCache.size > this._embedCacheMax) {
|
|
548
|
+
const oldest = this._embedCache.keys().next();
|
|
549
|
+
if (!oldest.done) this._embedCache.delete(oldest.value);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
dense.sort((a, b) => b.dense - a.dense);
|
|
553
|
+
// dense 与 BM25 双路 RRF 融合
|
|
554
|
+
const bm25 = this.query(q, { limit, scope });
|
|
555
|
+
return rrfFuse([dense, bm25]).slice(0, limit);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
hit(id) {
|
|
559
|
+
return withFileLock(this.file, () => {
|
|
560
|
+
this._reload();
|
|
561
|
+
const f = this.facts.find((x) => x.id === id);
|
|
562
|
+
if (!f) return;
|
|
563
|
+
f.hits += 1;
|
|
564
|
+
f.lastAccess = nowISO();
|
|
565
|
+
f.score += this.opts.hitBonus;
|
|
566
|
+
this._markMutated(f);
|
|
567
|
+
return f;
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
addMemory(message) {
|
|
572
|
+
// 从对话中提取记忆: 简单启发式, 过滤掉问候/无信息量
|
|
573
|
+
// v1.0.7 治理: 子串/开头匹配拦"你好皮皮虾"类 (旧版精确匹配逃逸), 加长度上限防长文污染
|
|
574
|
+
const STOP = ["你好", "您好", "在吗", "谢谢", "感谢", "辛苦了", "收到", "哈喽", "hello", "hi", "再见", "拜拜", "好的", "嗯", "ok", "好的好的"];
|
|
575
|
+
const clean = (message || "").trim();
|
|
576
|
+
// 太短 (<4) 无信息量, 太长 (>200) 不适合当原子事实 (整段对话原文不应入库)
|
|
577
|
+
if (!clean || clean.length < 4 || clean.length > 200) return null;
|
|
578
|
+
const lower = clean.toLowerCase();
|
|
579
|
+
// 整句就是寒暄词 (精确)
|
|
580
|
+
if (STOP.some((s) => lower === s)) return null;
|
|
581
|
+
// 短句以寒暄词开头 (如 "你好皮皮虾", "好的没问题") → 不作为长期事实
|
|
582
|
+
if (clean.length <= 12 && STOP.some((s) => lower.startsWith(s))) return null;
|
|
583
|
+
// 疑问/指令句式 → 临时查询, 不作长期事实
|
|
584
|
+
// v1.0.8 修复 (P1-1): 原实现写作 `clean.length <= 8 && /.../.test(clean)`,
|
|
585
|
+
// 长度前置条件把整条过滤器架空了 —— 只要超过 8 字, 任何疑问句/指令都照样入库
|
|
586
|
+
// (实测污染率 40%: "今天几号了现在"、"帮我看看这个文件里写了什么内容" 全成了长期事实)。
|
|
587
|
+
// 现改为按"句式特征"判定, 与长度完全解耦, 且只认句首/句尾标记, 不动句中的陈述性内容,
|
|
588
|
+
// 保证 "我习惯用 Node 22 跑测试" 这类真事实不受影响。
|
|
589
|
+
// 判据 1: 问号/疑问助词/回忆口吻收尾 ("来着" 是典型的"我在问你"信号)
|
|
590
|
+
if (/[??]\s*$|(吗|呢|来着)[??]?$/.test(clean)) return null;
|
|
591
|
+
// 判据 2: 疑问词起手 (允许"现在/今天"等时间前缀, 覆盖 "现在几点" "今天几号")
|
|
592
|
+
if (/^(现在|今天|明天|昨天|当前|目前)?\s*(几点|几号|星期几|周几|多少|什么|啥|怎么|怎样|如何|为什么|为啥|在哪|哪里|哪儿|哪个|哪位|谁|是否|能不能|可不可以|有没有|是不是|知道|你记得|还记得)/.test(clean)) return null;
|
|
593
|
+
// 判据 3: 句中疑问词 —— 收尾/起手都没命中的漏网句 (如 "文件放在哪里的来着")。
|
|
594
|
+
// 只收强制疑问语义的词, 不放 `多少` 这类可能出现在陈述里的词 (否则 "不管花多少钱都要做" 会被误杀)。
|
|
595
|
+
if (/(为什么|为啥|怎么办|怎么样|是不是|有没有|对不对|要不要|行不行|好不好|在哪|到哪|去哪儿)/.test(clean)) return null;
|
|
596
|
+
// 判据 4: 祈使/指令起手 (对助手下的操作指令, 不是关于用户的事实)
|
|
597
|
+
if (/^(帮我|请|麻烦|替我|你帮我|你给我|给我|查一下|查下|搜一下|搜下|找一下|找下|看一下|看看|列出|列一下|数一下|统计一下|告诉我|说下|说一下|读一下|读读|打开|执行|运行)/.test(clean)) return null;
|
|
598
|
+
return this.add(clean, { source: "conversation" });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
list() {
|
|
602
|
+
// 软删条目不出现在列表 (治理语义: 遗忘即不可见, 但数据仍在, 可 restore 回滚)
|
|
603
|
+
return this._live().sort((a, b) => b.score - a.score);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
count() {
|
|
607
|
+
return this.facts.length;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// 有效条数 (排除软删)
|
|
611
|
+
countLive() {
|
|
612
|
+
return this._live().length;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ==================== 记忆治理 (吸收自 ppx-v2 mem-store) ====================
|
|
616
|
+
// 核心差异: ppx-agent 原版遗忘是不可逆硬删 (/_prune), 这里引入"软删 + 可回滚 + 版本链",
|
|
617
|
+
// 让遗忘变成可审计、可撤销的操作 —— 误删一条重要记忆不再无法挽回。
|
|
618
|
+
|
|
619
|
+
// 软删: 标记 status='deleted' 并记录删除时间/原因, 数据保留可回滚
|
|
620
|
+
forget(idOrContent, { reason = null } = {}) {
|
|
621
|
+
return withFileLock(this.file, () => {
|
|
622
|
+
this._reload();
|
|
623
|
+
const key = String(idOrContent || "");
|
|
624
|
+
// 先按 id 命中; 未命中再按内容匹配 —— 内容匹配不过滤 status, 保证重复 forget 幂等
|
|
625
|
+
const f = this.facts.find((x) => x.id === key)
|
|
626
|
+
|| this.facts.find((x) => this._normKey(x.content) === this._normKey(key) && x.status !== "archived");
|
|
627
|
+
if (!f) return null;
|
|
628
|
+
if (f.status === "deleted") return f; // 幂等: 已软删则原样返回, 不覆盖原删除原因
|
|
629
|
+
f.status = "deleted";
|
|
630
|
+
f.deletedAt = nowISO();
|
|
631
|
+
f.deleteReason = reason ? String(reason).slice(0, 200) : null;
|
|
632
|
+
this.rebuildIndex();
|
|
633
|
+
this._markMutated(f);
|
|
634
|
+
return f;
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 回滚软删
|
|
639
|
+
restore(id) {
|
|
640
|
+
return withFileLock(this.file, () => {
|
|
641
|
+
this._reload();
|
|
642
|
+
const f = this.facts.find((x) => x.id === String(id || ""));
|
|
643
|
+
if (!f) return null;
|
|
644
|
+
if (f.status !== "deleted") return f; // 已是活跃, 幂等
|
|
645
|
+
f.status = "active";
|
|
646
|
+
delete f.deletedAt;
|
|
647
|
+
delete f.deleteReason;
|
|
648
|
+
f.lastAccess = nowISO(); // 恢复视作一次访问, 避免恢复即被衰减清空
|
|
649
|
+
this.rebuildIndex();
|
|
650
|
+
this._markMutated(f);
|
|
651
|
+
return f;
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 列出已软删的记忆 (供人工/审计复核)
|
|
656
|
+
deletedList() {
|
|
657
|
+
return this.facts.filter((f) => f.status === "deleted").sort((a, b) => String(b.deletedAt || "").localeCompare(String(a.deletedAt || "")));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// 更新内容: 保留旧版为 prevId 版本链 (记忆演化可追溯), 新条继承 id/分数
|
|
661
|
+
update(id, content, { importance, layer, source } = {}) {
|
|
662
|
+
return withFileLock(this.file, () => {
|
|
663
|
+
this._reload();
|
|
664
|
+
const f = this.facts.find((x) => x.id === String(id || ""));
|
|
665
|
+
if (!f) return null;
|
|
666
|
+
const norm = this._norm(content);
|
|
667
|
+
if (!norm) return null;
|
|
668
|
+
// 旧版快照入链 (只保留一层历史, 防无限膨胀)
|
|
669
|
+
const archived = {
|
|
670
|
+
...f,
|
|
671
|
+
id: cryptoRandomId(),
|
|
672
|
+
status: "archived",
|
|
673
|
+
archivedAt: nowISO(),
|
|
674
|
+
supersededBy: f.id,
|
|
675
|
+
};
|
|
676
|
+
f.content = norm;
|
|
677
|
+
f.prevId = archived.id;
|
|
678
|
+
f.updatedAt = nowISO();
|
|
679
|
+
if (importance != null) { f.importance = importance; f.score = Math.max(f.score, importance); }
|
|
680
|
+
if (layer != null) f.layer = Number(layer) === LAYER_L4 ? LAYER_L4 : LAYER_L1;
|
|
681
|
+
if (source != null) f.source = source;
|
|
682
|
+
this.facts.push(archived);
|
|
683
|
+
this.rebuildIndex();
|
|
684
|
+
this._markMutated(archived, f);
|
|
685
|
+
return f;
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// TTL 扫描: 超过 ttlDays 未访问的条目软归档 (非硬删)
|
|
690
|
+
// 与 /_prune 的分工: _prune 是容量保护的硬删, sweepExpired 是时效治理的软归档 (可回滚)
|
|
691
|
+
sweepExpired({ ttlDays = 90, layer = null, dryRun = false } = {}) {
|
|
692
|
+
const nowD = this._nowDays();
|
|
693
|
+
const targets = [];
|
|
694
|
+
for (const f of this.facts) {
|
|
695
|
+
if (f.status === "deleted") continue;
|
|
696
|
+
if (layer != null && Number(f.layer || LAYER_L1) !== Number(layer)) continue;
|
|
697
|
+
const ttl = Number(f.ttlDays || ttlDays);
|
|
698
|
+
if (!ttl || ttl <= 0) continue;
|
|
699
|
+
const days = Math.max(0, nowD - new Date(f.lastAccess).getTime() / 86400000);
|
|
700
|
+
if (days >= ttl) targets.push(f.id);
|
|
701
|
+
}
|
|
702
|
+
if (dryRun || !targets.length) return { swept: targets.length, ids: targets, dryRun: !!dryRun };
|
|
703
|
+
return withFileLock(this.file, () => {
|
|
704
|
+
this._reload();
|
|
705
|
+
const touched = [];
|
|
706
|
+
let n = 0;
|
|
707
|
+
for (const id of targets) {
|
|
708
|
+
const f = this.facts.find((x) => x.id === id);
|
|
709
|
+
if (f && f.status !== "deleted") {
|
|
710
|
+
this._softDelete(f, `TTL ${ttlDays} 天未访问自动归档`);
|
|
711
|
+
n++;
|
|
712
|
+
touched.push(f);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
this.rebuildIndex();
|
|
716
|
+
this._markMutated(...touched);
|
|
717
|
+
return { swept: n, ids: targets, dryRun: false };
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 按层清空 (L4 程序性记忆 / L1 事实), 默认软删, hard=true 才真删
|
|
722
|
+
clearLayer(layer = LAYER_L1, { hard = false } = {}) {
|
|
723
|
+
return withFileLock(this.file, () => {
|
|
724
|
+
this._reload();
|
|
725
|
+
const target = Number(layer);
|
|
726
|
+
const hits = this.facts.filter((f) => Number(f.layer || LAYER_L1) === target);
|
|
727
|
+
let removedIds = [];
|
|
728
|
+
const touched = [];
|
|
729
|
+
if (hard) {
|
|
730
|
+
removedIds = hits.map((f) => f.id);
|
|
731
|
+
this.facts = this.facts.filter((f) => Number(f.layer || LAYER_L1) !== target);
|
|
732
|
+
} else {
|
|
733
|
+
for (const f of hits) {
|
|
734
|
+
if (f.status === "deleted") continue;
|
|
735
|
+
f.status = "deleted";
|
|
736
|
+
f.deletedAt = nowISO();
|
|
737
|
+
f.deleteReason = `clearLayer(${target})`;
|
|
738
|
+
touched.push(f);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
this.rebuildIndex();
|
|
742
|
+
if (hard) this._markRemoved(removedIds); else this._markMutated(...touched);
|
|
743
|
+
return { layer: target, affected: hits.length, hard: !!hard };
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// 导出全量记忆 (含软删/归档, 供备份与迁移)
|
|
748
|
+
exportAll({ includeDeleted = true } = {}) {
|
|
749
|
+
const items = includeDeleted ? this.facts : this._live();
|
|
750
|
+
return {
|
|
751
|
+
version: 1,
|
|
752
|
+
exportedAt: nowISO(),
|
|
753
|
+
count: items.length,
|
|
754
|
+
items: items.map((f) => ({ ...f })),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// 导入记忆: merge(默认, 按内容去重跳过重复) 或 replace(整体替换)
|
|
759
|
+
importAll(payload, { mode = "merge" } = {}) {
|
|
760
|
+
const items = Array.isArray(payload) ? payload : (payload && Array.isArray(payload.items) ? payload.items : null);
|
|
761
|
+
if (!items) return { ok: false, reason: "导入数据格式非法 (需数组或 {items:[]})" };
|
|
762
|
+
return withFileLock(this.file, () => {
|
|
763
|
+
this._reload();
|
|
764
|
+
if (mode === "replace") {
|
|
765
|
+
// v2.7.0: 补全对象字段 (与 merge 分支一致, 见 _normalizeFact) —— 旧版只留 id/content,
|
|
766
|
+
// lastAccess/importance 缺失导致衰减/recency 计算 NaN, 检索永远返回空
|
|
767
|
+
this.facts = items.map((it) => this._normalizeFact(it)).filter(Boolean);
|
|
768
|
+
this.rebuildIndex();
|
|
769
|
+
this._markReplace(this.facts);
|
|
770
|
+
return { ok: true, mode, imported: this.facts.length, skipped: 0 };
|
|
771
|
+
}
|
|
772
|
+
let imported = 0, skipped = 0;
|
|
773
|
+
const seen = new Set(this.facts.map((f) => this._normKey(f.content)));
|
|
774
|
+
const added = [];
|
|
775
|
+
for (const it of items) {
|
|
776
|
+
const factObj = this._normalizeFact(it);
|
|
777
|
+
if (!factObj) { skipped++; continue; }
|
|
778
|
+
const k = this._normKey(factObj.content);
|
|
779
|
+
if (seen.has(k)) { skipped++; continue; }
|
|
780
|
+
seen.add(k);
|
|
781
|
+
this.facts.push(factObj);
|
|
782
|
+
imported++;
|
|
783
|
+
added.push(factObj);
|
|
784
|
+
}
|
|
785
|
+
this.rebuildIndex();
|
|
786
|
+
this._markMutated(...added);
|
|
787
|
+
return { ok: true, mode, imported, skipped };
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// 可观测: L1 原子记忆统计 (总量/来源分布/类型分布), 供 agent.stats() 聚合
|
|
792
|
+
stats() {
|
|
793
|
+
const bySource = {};
|
|
794
|
+
const byType = {};
|
|
795
|
+
const byLayer = {};
|
|
796
|
+
let deleted = 0, archived = 0;
|
|
797
|
+
for (const f of this.facts) {
|
|
798
|
+
const s = f.source || "unknown";
|
|
799
|
+
const t = f.type || "general";
|
|
800
|
+
const l = Number(f.layer || LAYER_L1);
|
|
801
|
+
bySource[s] = (bySource[s] || 0) + 1;
|
|
802
|
+
byType[t] = (byType[t] || 0) + 1;
|
|
803
|
+
byLayer[l] = (byLayer[l] || 0) + 1;
|
|
804
|
+
if (f.status === "deleted") deleted++;
|
|
805
|
+
if (f.status === "archived") archived++;
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
total: this.facts.length,
|
|
809
|
+
live: this.facts.length - deleted - archived,
|
|
810
|
+
deleted,
|
|
811
|
+
archived,
|
|
812
|
+
max_facts: this.opts.maxFacts || 0,
|
|
813
|
+
by_source: bySource,
|
|
814
|
+
by_type: byType,
|
|
815
|
+
by_layer: byLayer,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function cryptoRandomId() {
|
|
821
|
+
// v2.6.1: Math.random() 名实不符且非加密安全 (碰撞风险随条目数增长)
|
|
822
|
+
// 改用 Node 内置 crypto.randomUUID() (UUID v4, 加密安全随机源)
|
|
823
|
+
return "f_" + crypto.randomUUID();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// RRF (Reciprocal Rank Fusion): 融合多个排序列表 (每项按 rank 倒数加权求和)。
|
|
827
|
+
// 用于多查询变体/多信号检索的融合, 纯函数零依赖。
|
|
828
|
+
export function rrfFuse(lists, { k = 60 } = {}) {
|
|
829
|
+
const score = new Map(); // id -> fused score
|
|
830
|
+
const items = new Map(); // id -> item (保留首个出现的对象)
|
|
831
|
+
for (const list of lists) {
|
|
832
|
+
list.forEach((item, rank) => {
|
|
833
|
+
const id = item && item.id;
|
|
834
|
+
if (id == null) return;
|
|
835
|
+
if (!items.has(id)) items.set(id, item);
|
|
836
|
+
score.set(id, (score.get(id) || 0) + 1 / (k + rank + 1));
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
return [...score.entries()]
|
|
840
|
+
.sort((a, b) => b[1] - a[1])
|
|
841
|
+
.map(([id]) => items.get(id));
|
|
842
|
+
}
|