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.
- package/LICENSE +201 -0
- package/README.md +265 -0
- package/bin/ppx-channels.js +3 -0
- package/bin/ppx-serve.js +6 -0
- package/bin/ppx.js +3 -0
- package/config/identity.md +6 -0
- package/config/ishiki.md +16 -0
- package/config/ppx.json +151 -0
- package/package.json +69 -0
- package/src/agent/context.js +179 -0
- package/src/agent/index.js +717 -0
- package/src/agent/prompts.js +107 -0
- package/src/aml-server.js +151 -0
- package/src/ans/eviction.js +144 -0
- package/src/ans/guard.js +120 -0
- package/src/ans/lifecycle.js +93 -0
- package/src/ans/proactive.js +129 -0
- package/src/ans/reward.js +112 -0
- package/src/ans/values.js +15 -0
- package/src/audit/audit-chain.js +167 -0
- package/src/audit/verifier.js +120 -0
- package/src/bus/circuit-breaker.js +115 -0
- package/src/bus/runtime-bus.js +94 -0
- package/src/channels/base.js +35 -0
- package/src/channels/feishu.js +127 -0
- package/src/channels/http.js +592 -0
- package/src/channels/index.js +110 -0
- package/src/channels/log.js +29 -0
- package/src/channels/wechat-crypto.js +74 -0
- package/src/channels/wechat.js +197 -0
- package/src/channels-cli.js +124 -0
- package/src/cli.js +120 -0
- package/src/config/channels.js +170 -0
- package/src/config/index.js +224 -0
- package/src/config/providers.js +189 -0
- package/src/config/settings.js +182 -0
- package/src/core/policy.js +272 -0
- package/src/core/trace.js +89 -0
- package/src/evolve/playbook.js +194 -0
- package/src/llm/client.js +446 -0
- package/src/llm/dsml.js +74 -0
- package/src/llm/embedder.js +35 -0
- package/src/llm/fence.js +105 -0
- package/src/llm/index.js +4 -0
- package/src/llm/retry.js +73 -0
- package/src/llm/router.js +98 -0
- package/src/mcp/client.js +375 -0
- package/src/mcp/index.js +116 -0
- package/src/memory/asset-hub.js +131 -0
- package/src/memory/canvas.js +131 -0
- package/src/memory/compaction.js +28 -0
- package/src/memory/experience.js +122 -0
- package/src/memory/fact-store.js +699 -0
- package/src/memory/failure-episode.js +99 -0
- package/src/memory/fork.js +83 -0
- package/src/memory/index.js +7 -0
- package/src/memory/l0.js +52 -0
- package/src/memory/l2.js +131 -0
- package/src/memory/l3.js +112 -0
- package/src/memory/memory-ticker.js +240 -0
- package/src/memory/session.js +398 -0
- package/src/mode/blackboard.js +49 -0
- package/src/mode/graph.js +41 -0
- package/src/mode/index.js +64 -0
- package/src/mode/legion.js +51 -0
- package/src/mode/plan-exec.js +50 -0
- package/src/mode/router.js +40 -0
- package/src/orchestrator/agent-worker.js +70 -0
- package/src/orchestrator/dag.js +83 -0
- package/src/orchestrator/index.js +2 -0
- package/src/orchestrator/legion.js +188 -0
- package/src/orchestrator/supervisor.js +177 -0
- package/src/persona/index.js +29 -0
- package/src/plugin/builtin.js +212 -0
- package/src/plugin/context.js +79 -0
- package/src/plugin/index.js +62 -0
- package/src/seam/registry.js +98 -0
- package/src/seam/shell.js +55 -0
- package/src/selfheal/evolve.js +68 -0
- package/src/selfheal/healer.js +167 -0
- package/src/selfheal/run.js +9 -0
- package/src/server.js +60 -0
- package/src/services/learning-service.js +177 -0
- package/src/services/memory-health.js +99 -0
- package/src/services/memory-service.js +160 -0
- package/src/skills/loader.js +150 -0
- package/src/skills/verify.js +100 -0
- package/src/tools/advanced.js +353 -0
- package/src/tools/builtin.js +298 -0
- package/src/tools/catalog.js +159 -0
- package/src/tools/command-guard.js +112 -0
- package/src/tools/custom.js +47 -0
- package/src/tools/delegate.js +297 -0
- package/src/tools/document.js +253 -0
- package/src/tools/governance.js +260 -0
- package/src/tools/index.js +11 -0
- package/src/tools/methods.js +178 -0
- package/src/tools/ocr.js +59 -0
- package/src/tools/seam.js +125 -0
- package/src/tools/selfmod.js +176 -0
- package/src/utils/logger.js +17 -0
- package/src/utils/pii.js +42 -0
- package/src/utils/store.js +108 -0
- package/src/utils/text.js +16 -0
- package/src/utils/trace.js +153 -0
- package/src/utils/winutf8.js +15 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
// src/memory/fact-store.js - 记忆存储 (高斯衰减遗忘)
|
|
2
|
+
// 架构参考 openhanako: 每条记忆有 importance, 高斯衰减, 命中加分
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ensureDir, readJson, writeJson, nowISO, logicalDay, withFileLock } from "../utils/store.js";
|
|
5
|
+
|
|
6
|
+
// 记忆动词前缀: 去重时剔除, 让"记住:X"与"X"视为同一条 (防 LLM 提炼版与原文冗余)
|
|
7
|
+
const MEMORY_VERB_PREFIXES = [
|
|
8
|
+
/^(请记住|请记得|记得要|记住要|要记住|请牢记|别忘了|记住|记得|用户说|用户提到|提醒你)[::\s,,、]*/,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
// 记忆层级 (吸收自 ppx-v2 的 L4 程序性记忆): L1=事实/用户记忆, L4=程序性记忆(技能/流程/方法论)
|
|
12
|
+
// L4 衰减远慢于 L1 —— 技能与流程应当长期留存, 而非像闲聊事实一样快速遗忘
|
|
13
|
+
export const L4_DECAY_PER_DAY = 0.005;
|
|
14
|
+
export const LAYER_L1 = 1;
|
|
15
|
+
export const LAYER_L4 = 4;
|
|
16
|
+
|
|
17
|
+
export class FactStore {
|
|
18
|
+
constructor(dataDir, opts = {}) {
|
|
19
|
+
this.dir = path.join(dataDir, "memory");
|
|
20
|
+
ensureDir(this.dir);
|
|
21
|
+
this.file = path.join(this.dir, "facts.json");
|
|
22
|
+
// v1.0.9: 兼容 snake 配置键 (config.memory 是 snake_case, 原只认 camel 导致衰减/容量配置全部死键不生效)
|
|
23
|
+
const SNAKE_TO_CAMEL = {
|
|
24
|
+
decay_per_day: "decayPerDay", hit_bonus: "hitBonus", base_importance: "baseImportance",
|
|
25
|
+
forget_speed: "forgetSpeed", max_facts: "maxFacts",
|
|
26
|
+
};
|
|
27
|
+
const normOpts = {};
|
|
28
|
+
for (const [k, v] of Object.entries(opts || {})) normOpts[SNAKE_TO_CAMEL[k] || k] = v;
|
|
29
|
+
this.opts = {
|
|
30
|
+
decayPerDay: 0.02, // lambda
|
|
31
|
+
hitBonus: 5,
|
|
32
|
+
baseImportance: 10,
|
|
33
|
+
forgetSpeed: 1.0,
|
|
34
|
+
maxFacts: 1000, // L1 事实总量上限, 超限按「衰减分×重要性」裁剪最弱 (0/负数=不裁剪)
|
|
35
|
+
...normOpts,
|
|
36
|
+
};
|
|
37
|
+
this.facts = readJson(this.file, []);
|
|
38
|
+
// 倒排索引: token -> Set<factId>, 检索 O(n) -> O(候选)
|
|
39
|
+
this._index = new Map();
|
|
40
|
+
// BM25 作用域统计缓存 (facts 内容不可变, add 时失效即可), 避免每查询 O(N) 重算
|
|
41
|
+
this._statsCache = new Map();
|
|
42
|
+
// 可插拔 embedder (dense 语义检索, 默认 null = 纯 BM25); _embedCache 内存缓存不落盘
|
|
43
|
+
this.embedder = null;
|
|
44
|
+
this._embedCache = new Map();
|
|
45
|
+
for (const fact of this.facts) this._indexFact(fact);
|
|
46
|
+
this.save();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
save() {
|
|
50
|
+
writeJson(this.file, this.facts);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 高斯衰减: score = score * exp(-lambda * t^2), t = days since last access
|
|
54
|
+
// layer 感知 (吸收自 ppx-v2 五层记忆): L4 程序性记忆衰减极慢 (lambda 0.005), 技能/流程应当长期留存
|
|
55
|
+
_decay(score, days, layer = 1) {
|
|
56
|
+
if (days <= 0) return score;
|
|
57
|
+
const base = Number(layer) === 4 ? L4_DECAY_PER_DAY : this.opts.decayPerDay;
|
|
58
|
+
const lambda = base * this.opts.forgetSpeed;
|
|
59
|
+
return score * Math.exp(-lambda * days * days);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 该条记忆的实际衰减率 (按 layer)
|
|
63
|
+
_lambdaOf(f) {
|
|
64
|
+
const base = Number(f?.layer) === 4 ? L4_DECAY_PER_DAY : this.opts.decayPerDay;
|
|
65
|
+
return base * this.opts.forgetSpeed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 有效记忆集: 排除软删 (status='deleted') 与版本链归档 (status='archived')
|
|
69
|
+
// 软删是 ppx-v2 记忆治理的核心语义: 遗忘可回滚, 不是不可逆的物理删除
|
|
70
|
+
_live(scope = null) {
|
|
71
|
+
const base = scope == null ? this.facts : this.facts.filter((f) => f.scope === scope);
|
|
72
|
+
return base.filter((f) => f.status !== "deleted" && f.status !== "archived");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_nowDays() {
|
|
76
|
+
return Date.now() / 86400000;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 内容归一化: 去首尾空白 + 折叠连续空白, 用于去重比对
|
|
80
|
+
_norm(s) {
|
|
81
|
+
return String(s || "").trim().replace(/\s+/g, " ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 查重键: 在 _norm 基础上去掉"记忆动词前缀"和尾部标点, 让
|
|
85
|
+
// 原文「记住:老板的生日是 10 月 1 日」与 LLM 提炼的「老板的生日是 10 月 1 日」判定为同一条, 防冗余
|
|
86
|
+
// 只去记忆类动词, 不碰普通句子, 避免误合并
|
|
87
|
+
_normKey(s) {
|
|
88
|
+
let k = this._norm(s);
|
|
89
|
+
for (const re of MEMORY_VERB_PREFIXES) k = k.replace(re, "");
|
|
90
|
+
return k.replace(/[。!?!?;;,,]+$/, "");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
add(content, { importance = this.opts.baseImportance, type = "general", source = "manual", dedupe = true, scope = null, meta = null, similarThreshold = 0, layer = LAYER_L1, ttlDays = null } = {}) {
|
|
94
|
+
const norm = this._norm(content);
|
|
95
|
+
if (!norm) return null;
|
|
96
|
+
// 跨进程/多 agent 共享 dataDir 时的写保护: 锁内读-改-写, 防并发覆盖丢更新 (与 Experience 对称)
|
|
97
|
+
// add 是唯一写入口, 锁内重读磁盘最新 facts (防基于过期内存操作), 操作后落盘
|
|
98
|
+
return withFileLock(this.file, () => {
|
|
99
|
+
// 锁内重读: 拿最新磁盘状态再操作 (多进程共享 dataDir 时不丢别的进程刚写入的事实)
|
|
100
|
+
this.facts = readJson(this.file, []);
|
|
101
|
+
this.rebuildIndex();
|
|
102
|
+
const now = nowISO();
|
|
103
|
+
if (dedupe) {
|
|
104
|
+
// 内容去重: 归一化后相同 (含"记住:"等前缀差异) 已存在则命中加分, 不新增
|
|
105
|
+
// 排除软删记忆 (已 forget 的条目不应拦截新记忆写入)
|
|
106
|
+
const normKey = this._normKey(content);
|
|
107
|
+
const existing = this.facts.find((f) => f.status !== "deleted" && this._normKey(f.content) === normKey);
|
|
108
|
+
if (existing) {
|
|
109
|
+
existing.hits += 1;
|
|
110
|
+
existing.lastAccess = now;
|
|
111
|
+
existing.score += this.opts.hitBonus;
|
|
112
|
+
this.save();
|
|
113
|
+
return existing;
|
|
114
|
+
}
|
|
115
|
+
// 语义相似去重: similarThreshold>0 时, 与现有事实相似度达标则命中加分
|
|
116
|
+
// 双保险: 先 Jaccard (模板类变体), 未命中再 overlap (词序变化大的松散变体)
|
|
117
|
+
if (similarThreshold > 0) {
|
|
118
|
+
const similar = this.findSimilar(norm, { threshold: similarThreshold, scope })
|
|
119
|
+
|| this.findSimilar(norm, { threshold: similarThreshold, scope, method: "overlap" });
|
|
120
|
+
if (similar) {
|
|
121
|
+
similar.hits += 1;
|
|
122
|
+
similar.lastAccess = now;
|
|
123
|
+
similar.score += this.opts.hitBonus;
|
|
124
|
+
this.save();
|
|
125
|
+
return similar;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const fact = {
|
|
130
|
+
id: cryptoRandomId(),
|
|
131
|
+
content: norm,
|
|
132
|
+
type,
|
|
133
|
+
source,
|
|
134
|
+
importance,
|
|
135
|
+
score: importance,
|
|
136
|
+
created: now,
|
|
137
|
+
lastAccess: now,
|
|
138
|
+
hits: 0,
|
|
139
|
+
scope,
|
|
140
|
+
// 记忆治理字段 (吸收自 ppx-v2 mem-store): 层级 / 状态 / 版本链 / TTL
|
|
141
|
+
layer: Number(layer) === LAYER_L4 ? LAYER_L4 : LAYER_L1,
|
|
142
|
+
status: "active",
|
|
143
|
+
prevId: null,
|
|
144
|
+
...(ttlDays ? { ttlDays: Number(ttlDays) } : {}),
|
|
145
|
+
...(meta ? { meta } : {}),
|
|
146
|
+
};
|
|
147
|
+
this.facts.push(fact);
|
|
148
|
+
this._indexFact(fact);
|
|
149
|
+
this._statsCache.clear(); // 新增事实 -> 作用域统计失效
|
|
150
|
+
// 总量裁剪: 超 maxFacts 时删除最弱事实 (防记忆膨胀); 未裁剪时正常落盘
|
|
151
|
+
const pruned = this._prune();
|
|
152
|
+
if (!pruned) this.save();
|
|
153
|
+
return fact;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// L1 总量裁剪: 超 maxFacts 时, 按「衰减后有效分 × 重要性」排序, 删除最弱事实。
|
|
158
|
+
// 补齐「衰减只在查询层生效、不删数据」的缺口 -> 这里做存储层硬清理。
|
|
159
|
+
// 只在 add 新增时触发; 去重命中/加分(hit) 不增条数, 无需裁剪。
|
|
160
|
+
_prune() {
|
|
161
|
+
const max = this.opts.maxFacts;
|
|
162
|
+
if (!max || max <= 0 || this.facts.length <= max) return 0;
|
|
163
|
+
const nowD = this._nowDays();
|
|
164
|
+
const scored = this.facts.map((f) => {
|
|
165
|
+
const days = Math.max(0, nowD - new Date(f.lastAccess).getTime() / 86400000);
|
|
166
|
+
const recency = Math.exp(-this._lambdaOf(f) * days * days); // 0~1 (L4 程序性记忆衰减更慢, 更抗裁剪)
|
|
167
|
+
const imp = Math.min(f.importance || 0, 20) / 20; // 0~1
|
|
168
|
+
return { id: f.id, key: (f.score * (0.4 + 0.6 * recency)) * (0.5 + 0.5 * imp) };
|
|
169
|
+
});
|
|
170
|
+
scored.sort((a, b) => b.key - a.key);
|
|
171
|
+
const keep = new Set(scored.slice(0, max).map((x) => x.id));
|
|
172
|
+
const before = this.facts.length;
|
|
173
|
+
this.facts = this.facts.filter((f) => keep.has(f.id));
|
|
174
|
+
this.rebuildIndex(); // 重建倒排索引 (内部已清 _statsCache)
|
|
175
|
+
this.save();
|
|
176
|
+
return before - this.facts.length;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 字符级索引 key: 中文拆单字 + 英文按 token (对中文检索才有效)
|
|
180
|
+
// 索引层用单字(宽召回), 精排用 bigram(准匹配)
|
|
181
|
+
_charKeys(s) {
|
|
182
|
+
const chars = (String(s).match(/[\u4e00-\u9fff]/g) || []); // 中文单字
|
|
183
|
+
const en = (String(s).toLowerCase().match(/[a-z0-9]+/g) || []); // 英文/数字 token
|
|
184
|
+
return new Set([...chars, ...en]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 倒排索引: 把一条事实的字符 key 挂到索引 (key -> factId)
|
|
188
|
+
_indexFact(fact) {
|
|
189
|
+
for (const k of this._charKeys(fact.content)) {
|
|
190
|
+
if (!this._index.has(k)) this._index.set(k, new Set());
|
|
191
|
+
this._index.get(k).add(fact.id);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 重建索引 (facts 外部变更后调用)
|
|
196
|
+
rebuildIndex() {
|
|
197
|
+
this._index = new Map();
|
|
198
|
+
this._statsCache.clear();
|
|
199
|
+
for (const fact of this.facts) this._indexFact(fact);
|
|
200
|
+
return this._index.size;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ==== BM25 检索 (升级版) ====
|
|
204
|
+
|
|
205
|
+
// 精排分词: 中文 bigram + 英文 token (解决"止损"匹配"止损规则"的长段问题)
|
|
206
|
+
_bigramSet(s) {
|
|
207
|
+
const out = new Set();
|
|
208
|
+
const low = String(s || "").toLowerCase();
|
|
209
|
+
const cjk = low.match(/[\u4e00-\u9fff]+/g) || [];
|
|
210
|
+
for (const seg of cjk) {
|
|
211
|
+
if (seg.length === 1) { out.add(seg); continue; }
|
|
212
|
+
for (let i = 0; i < seg.length - 1; i++) out.add(seg.slice(i, i + 2));
|
|
213
|
+
}
|
|
214
|
+
const en = low.match(/[a-z0-9]+/g) || [];
|
|
215
|
+
for (const tk of en) out.add("en:" + tk);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 在作用域内统计 bigram 文档频率 / 平均长度 (BM25 参数)
|
|
220
|
+
// 关键: df 用 bigram token 统计 (而非单字索引), 否则 IDF 恒为常数, 失去"罕见词权重高"的灵魂
|
|
221
|
+
// 同时在 scoped 内计算, 保证 scope (AML 多租户) 统计隔离
|
|
222
|
+
_queryStats(scoped) {
|
|
223
|
+
const N = scoped.length;
|
|
224
|
+
const df = new Map();
|
|
225
|
+
let totalLen = 0;
|
|
226
|
+
for (const f of scoped) {
|
|
227
|
+
const bg = this._bigramSet(f.content);
|
|
228
|
+
totalLen += bg.size;
|
|
229
|
+
for (const t of bg) df.set(t, (df.get(t) || 0) + 1);
|
|
230
|
+
}
|
|
231
|
+
return { N, avgdl: N ? totalLen / N : 1, df };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 单查询词 IDF: ln(1 + (N - df + 0.5)/(df + 0.5))
|
|
235
|
+
// df 来自作用域内 bigram 文档频率 (见 _queryStats), 罕见词高分、常见词低分
|
|
236
|
+
_idf(tok, stats) {
|
|
237
|
+
const df = stats.df.get(tok) || 0;
|
|
238
|
+
return Math.log(1 + (stats.N - df + 0.5) / (df + 0.5));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// BM25 打分 (简化 tf=1, 因 tokenize 去重为 set)
|
|
242
|
+
_bm25Score(fact, qAll, stats) {
|
|
243
|
+
const fAll = this._bigramSet(fact.content);
|
|
244
|
+
if (!fAll.size) return 0;
|
|
245
|
+
const docLen = fAll.size;
|
|
246
|
+
const k1 = 1.5;
|
|
247
|
+
const b = 0.75;
|
|
248
|
+
let s = 0;
|
|
249
|
+
for (const t of qAll) {
|
|
250
|
+
if (!fAll.has(t)) continue;
|
|
251
|
+
const idf = this._idf(t, stats);
|
|
252
|
+
const tf = 1;
|
|
253
|
+
s += idf * ((tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (docLen / stats.avgdl))));
|
|
254
|
+
}
|
|
255
|
+
return s;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// bigram Jaccard 相似度 (0~1): 两段文本的 bigram 集合重合度
|
|
259
|
+
// 用于语义相似去重 (LLM 提炼变体字面不同但语义相同), 阈值通常 0.5+
|
|
260
|
+
_jaccard(a, b) {
|
|
261
|
+
const A = this._bigramSet(a);
|
|
262
|
+
const B = this._bigramSet(b);
|
|
263
|
+
if (!A.size || !B.size) return 0;
|
|
264
|
+
let inter = 0;
|
|
265
|
+
for (const t of A) if (B.has(t)) inter++;
|
|
266
|
+
const union = A.size + B.size - inter;
|
|
267
|
+
return union ? inter / union : 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// bigram overlap 系数 (0~1): 交集 / 较短集合, 对「词序变化但共享核心词」的松散同义改写更敏感
|
|
271
|
+
// 比 Jaccard 更宽松: LLM 提炼变体词序/措辞大变时 Jaccard 可能 <0.6, 但核心词重合度高, overlap 能捕获
|
|
272
|
+
_overlap(a, b) {
|
|
273
|
+
const A = this._bigramSet(a);
|
|
274
|
+
const B = this._bigramSet(b);
|
|
275
|
+
if (!A.size || !B.size) return 0;
|
|
276
|
+
let inter = 0;
|
|
277
|
+
for (const t of A) if (B.has(t)) inter++;
|
|
278
|
+
return inter / Math.min(A.size, B.size);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 查找与给定内容最相似的现有事实 (相似度 >= threshold 才返回, 默认 null)
|
|
282
|
+
// method: "jaccard" (默认) 或 "overlap" (词序变化容错)
|
|
283
|
+
// 供 add(similarThreshold) / 提炼去重使用; 中文 bigram 变体通常 >0.6
|
|
284
|
+
findSimilar(content, { threshold = 0.6, scope = null, method = "jaccard" } = {}) {
|
|
285
|
+
const c = this._norm(content);
|
|
286
|
+
if (!c) return null;
|
|
287
|
+
const scoped = this._live(scope);
|
|
288
|
+
const fn = method === "overlap" ? (a, b) => this._overlap(a, b) : (a, b) => this._jaccard(a, b);
|
|
289
|
+
let best = null;
|
|
290
|
+
let bestScore = 0;
|
|
291
|
+
for (const f of scoped) {
|
|
292
|
+
const s = fn(c, f.content);
|
|
293
|
+
if (s > bestScore) { bestScore = s; best = f; }
|
|
294
|
+
}
|
|
295
|
+
return bestScore >= threshold ? best : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 检索: BM25 主导 (IDF 区分常见/罕见词 + 长度归一) + 子串强信号 + 高斯衰减(乘性时效) + 命中权重
|
|
299
|
+
query(q, { limit = 5, minScore = 1, scope = null } = {}) {
|
|
300
|
+
const nowD = this._nowDays();
|
|
301
|
+
const ql = (q || "").toLowerCase();
|
|
302
|
+
const qAll = this._bigramSet(ql);
|
|
303
|
+
// scope 过滤基底 (检索隔离: 只查指定 scope 的事实); 软删条目一律不进检索
|
|
304
|
+
const scoped = this._live(scope);
|
|
305
|
+
// 空查询: 按衰减分返回全部 (保持旧行为, 供 memory-ticker 取 top facts)
|
|
306
|
+
if (qAll.size === 0) {
|
|
307
|
+
return scoped
|
|
308
|
+
.map((f) => ({ ...f, effectiveScore: this._decay(f.score, Math.max(0, nowD - new Date(f.lastAccess).getTime() / 86400000), f.layer) }))
|
|
309
|
+
.sort((a, b) => b.effectiveScore - a.effectiveScore)
|
|
310
|
+
.slice(0, limit);
|
|
311
|
+
}
|
|
312
|
+
// 倒排候选集 (至少命中一个查询字符 key; 阈值保护防常见字退化)
|
|
313
|
+
let candidates = scoped;
|
|
314
|
+
const qKeys = this._charKeys(q);
|
|
315
|
+
if (qKeys.size > 0 && this._index && this._index.size) {
|
|
316
|
+
const candIds = new Set();
|
|
317
|
+
for (const k of qKeys) {
|
|
318
|
+
const ids = this._index.get(k);
|
|
319
|
+
if (ids) for (const id of ids) candIds.add(id);
|
|
320
|
+
}
|
|
321
|
+
if (candIds.size > 0 && candIds.size <= scoped.length * 0.9) {
|
|
322
|
+
candidates = scoped.filter((f) => candIds.has(f.id));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// BM25 参数在作用域内计算, 按 scope 缓存 (facts 不可变, add 时失效)
|
|
326
|
+
const scopeKey = scope == null ? "__all__" : "s:" + scope;
|
|
327
|
+
let stats = this._statsCache.get(scopeKey);
|
|
328
|
+
if (!stats) { stats = this._queryStats(scoped); this._statsCache.set(scopeKey, stats); }
|
|
329
|
+
const scored = [];
|
|
330
|
+
for (const f of candidates) {
|
|
331
|
+
const days = Math.max(0, nowD - new Date(f.lastAccess).getTime() / 86400000);
|
|
332
|
+
const fc = f.content.toLowerCase();
|
|
333
|
+
// BM25 主导
|
|
334
|
+
const bm = this._bm25Score(f, qAll, stats);
|
|
335
|
+
// 无交集(词未命中且无整句子串)则跳过, 避免返回无关噪声
|
|
336
|
+
if (bm === 0 && !(ql && fc.includes(ql))) continue;
|
|
337
|
+
// 时效+命中权重做成"乘性因子": 越新越接近 1, 越旧最多折到 0.4 倍
|
|
338
|
+
// 避免旧记忆(短)靠微弱加法反超新记忆(长), 保证新旧事实冲突时返回新事实
|
|
339
|
+
const recency = Math.exp(-this._lambdaOf(f) * days * days); // 0~1, 越新越大 (L4 衰减更慢)
|
|
340
|
+
let s = bm * 10 * (0.4 + 0.6 * recency);
|
|
341
|
+
// 子串强信号 (整句命中)
|
|
342
|
+
if (ql && fc.includes(ql)) s += 5;
|
|
343
|
+
// 命中权重 (辅助)
|
|
344
|
+
s += (f.hits > 0 ? Math.min(f.hits, 5) : 0);
|
|
345
|
+
// 重要性因子 (补齐 CrewAI 三因子: 语义×时效×重要性); 默认 importance=10 -> +1.5, 不喧宾夺主
|
|
346
|
+
s += Math.min(f.importance || 0, 20) / 20 * 3;
|
|
347
|
+
if (s >= minScore) scored.push({ ...f, effectiveScore: s, bm25: bm });
|
|
348
|
+
}
|
|
349
|
+
scored.sort((a, b) => b.effectiveScore - a.effectiveScore);
|
|
350
|
+
return scored.slice(0, limit);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 多查询变体检索 + RRF 融合 (供 LLM 查询扩展等场景: 每个变体各查一遍再融合)
|
|
354
|
+
queryMulti(queries, { limit = 5, scope = null, minScore = 1 } = {}) {
|
|
355
|
+
const lists = [];
|
|
356
|
+
for (const q of queries) {
|
|
357
|
+
const r = this.query(q, { limit: Math.max(limit * 2, 10), scope, minScore });
|
|
358
|
+
if (r.length) lists.push(r);
|
|
359
|
+
}
|
|
360
|
+
if (!lists.length) return [];
|
|
361
|
+
if (lists.length === 1) return lists[0].slice(0, limit);
|
|
362
|
+
return rrfFuse(lists).slice(0, limit);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---- 可插拔 embedder (dense 语义检索, 零依赖默认关闭) ----
|
|
366
|
+
// 用户注入: ctx.consume("facts").setEmbedder(async (text) => number[])
|
|
367
|
+
setEmbedder(fn) {
|
|
368
|
+
this.embedder = typeof fn === "function" ? fn : null;
|
|
369
|
+
this._embedCache.clear();
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async _embed(text) {
|
|
374
|
+
if (!this.embedder) throw new Error("未配置 embedder");
|
|
375
|
+
const v = await this.embedder(String(text));
|
|
376
|
+
return Array.isArray(v) && v.length ? v : null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 余弦相似度 (零依赖)
|
|
380
|
+
_cosine(a, b) {
|
|
381
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
382
|
+
let dot = 0, na = 0, nb = 0;
|
|
383
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
384
|
+
if (!na || !nb) return 0;
|
|
385
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 语义检索: embedder 有则 dense cosine 排序 + 与 BM25 RRF 融合; 无则退化为 BM25
|
|
389
|
+
async querySemantic(q, { limit = 5, scope = null } = {}) {
|
|
390
|
+
if (!this.embedder) return this.query(q, { limit, scope });
|
|
391
|
+
const scoped = scope == null ? this.facts : this.facts.filter((f) => f.scope === scope);
|
|
392
|
+
const qv = await this._embed(q).catch(() => null);
|
|
393
|
+
if (!qv) return this.query(q, { limit, scope });
|
|
394
|
+
// 懒加载每条事实的 embedding (内存缓存, 不落盘避免 facts.json 膨胀)
|
|
395
|
+
const dense = [];
|
|
396
|
+
for (const f of scoped) {
|
|
397
|
+
let ev = this._embedCache.get(f.id);
|
|
398
|
+
if (!ev) {
|
|
399
|
+
try { ev = await this._embed(f.content); } catch { ev = null; }
|
|
400
|
+
this._embedCache.set(f.id, ev);
|
|
401
|
+
}
|
|
402
|
+
if (ev) dense.push({ ...f, dense: this._cosine(qv, ev) });
|
|
403
|
+
}
|
|
404
|
+
dense.sort((a, b) => b.dense - a.dense);
|
|
405
|
+
// dense 与 BM25 双路 RRF 融合
|
|
406
|
+
const bm25 = this.query(q, { limit, scope });
|
|
407
|
+
return rrfFuse([dense, bm25]).slice(0, limit);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
hit(id) {
|
|
411
|
+
return withFileLock(this.file, () => {
|
|
412
|
+
this.facts = readJson(this.file, []);
|
|
413
|
+
this.rebuildIndex();
|
|
414
|
+
const f = this.facts.find((x) => x.id === id);
|
|
415
|
+
if (!f) return;
|
|
416
|
+
f.hits += 1;
|
|
417
|
+
f.lastAccess = nowISO();
|
|
418
|
+
f.score += this.opts.hitBonus;
|
|
419
|
+
this.save();
|
|
420
|
+
return f;
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
addMemory(message) {
|
|
425
|
+
// 从对话中提取记忆: 简单启发式, 过滤掉问候/无信息量
|
|
426
|
+
// v1.0.7 治理: 子串/开头匹配拦"你好皮皮虾"类 (旧版精确匹配逃逸), 加长度上限防长文污染
|
|
427
|
+
const STOP = ["你好", "您好", "在吗", "谢谢", "感谢", "辛苦了", "收到", "哈喽", "hello", "hi", "再见", "拜拜", "好的", "嗯", "ok", "好的好的"];
|
|
428
|
+
const clean = (message || "").trim();
|
|
429
|
+
// 太短 (<4) 无信息量, 太长 (>200) 不适合当原子事实 (整段对话原文不应入库)
|
|
430
|
+
if (!clean || clean.length < 4 || clean.length > 200) return null;
|
|
431
|
+
const lower = clean.toLowerCase();
|
|
432
|
+
// 整句就是寒暄词 (精确)
|
|
433
|
+
if (STOP.some((s) => lower === s)) return null;
|
|
434
|
+
// 短句以寒暄词开头 (如 "你好皮皮虾", "好的没问题") → 不作为长期事实
|
|
435
|
+
if (clean.length <= 12 && STOP.some((s) => lower.startsWith(s))) return null;
|
|
436
|
+
// 疑问/指令短句 (如 "现在几点", "文件在哪") → 临时查询, 不作长期事实
|
|
437
|
+
if (clean.length <= 8 && /[??]|几点|多少|什么|怎么|为什么|在哪|帮我|请|查一下/.test(clean)) return null;
|
|
438
|
+
return this.add(clean, { source: "conversation" });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
list() {
|
|
442
|
+
// 软删条目不出现在列表 (治理语义: 遗忘即不可见, 但数据仍在, 可 restore 回滚)
|
|
443
|
+
return this._live().sort((a, b) => b.score - a.score);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
count() {
|
|
447
|
+
return this.facts.length;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 有效条数 (排除软删)
|
|
451
|
+
countLive() {
|
|
452
|
+
return this._live().length;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ==================== 记忆治理 (吸收自 ppx-v2 mem-store) ====================
|
|
456
|
+
// 核心差异: ppx-agent 原版遗忘是不可逆硬删 (/_prune), 这里引入"软删 + 可回滚 + 版本链",
|
|
457
|
+
// 让遗忘变成可审计、可撤销的操作 —— 误删一条重要记忆不再无法挽回。
|
|
458
|
+
|
|
459
|
+
// 软删: 标记 status='deleted' 并记录删除时间/原因, 数据保留可回滚
|
|
460
|
+
forget(idOrContent, { reason = null } = {}) {
|
|
461
|
+
return withFileLock(this.file, () => {
|
|
462
|
+
this.facts = readJson(this.file, []);
|
|
463
|
+
this.rebuildIndex();
|
|
464
|
+
const key = String(idOrContent || "");
|
|
465
|
+
// 先按 id 命中; 未命中再按内容匹配 —— 内容匹配不过滤 status, 保证重复 forget 幂等
|
|
466
|
+
const f = this.facts.find((x) => x.id === key)
|
|
467
|
+
|| this.facts.find((x) => this._normKey(x.content) === this._normKey(key) && x.status !== "archived");
|
|
468
|
+
if (!f) return null;
|
|
469
|
+
if (f.status === "deleted") return f; // 幂等: 已软删则原样返回, 不覆盖原删除原因
|
|
470
|
+
f.status = "deleted";
|
|
471
|
+
f.deletedAt = nowISO();
|
|
472
|
+
f.deleteReason = reason ? String(reason).slice(0, 200) : null;
|
|
473
|
+
this.rebuildIndex();
|
|
474
|
+
this.save();
|
|
475
|
+
return f;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// 回滚软删
|
|
480
|
+
restore(id) {
|
|
481
|
+
return withFileLock(this.file, () => {
|
|
482
|
+
this.facts = readJson(this.file, []);
|
|
483
|
+
this.rebuildIndex();
|
|
484
|
+
const f = this.facts.find((x) => x.id === String(id || ""));
|
|
485
|
+
if (!f) return null;
|
|
486
|
+
if (f.status !== "deleted") return f; // 已是活跃, 幂等
|
|
487
|
+
f.status = "active";
|
|
488
|
+
delete f.deletedAt;
|
|
489
|
+
delete f.deleteReason;
|
|
490
|
+
f.lastAccess = nowISO(); // 恢复视作一次访问, 避免恢复即被衰减清空
|
|
491
|
+
this.rebuildIndex();
|
|
492
|
+
this.save();
|
|
493
|
+
return f;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 列出已软删的记忆 (供人工/审计复核)
|
|
498
|
+
deletedList() {
|
|
499
|
+
return this.facts.filter((f) => f.status === "deleted").sort((a, b) => String(b.deletedAt || "").localeCompare(String(a.deletedAt || "")));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// 更新内容: 保留旧版为 prevId 版本链 (记忆演化可追溯), 新条继承 id/分数
|
|
503
|
+
update(id, content, { importance, layer, source } = {}) {
|
|
504
|
+
return withFileLock(this.file, () => {
|
|
505
|
+
this.facts = readJson(this.file, []);
|
|
506
|
+
this.rebuildIndex();
|
|
507
|
+
const f = this.facts.find((x) => x.id === String(id || ""));
|
|
508
|
+
if (!f) return null;
|
|
509
|
+
const norm = this._norm(content);
|
|
510
|
+
if (!norm) return null;
|
|
511
|
+
// 旧版快照入链 (只保留一层历史, 防无限膨胀)
|
|
512
|
+
const archived = {
|
|
513
|
+
...f,
|
|
514
|
+
id: cryptoRandomId(),
|
|
515
|
+
status: "archived",
|
|
516
|
+
archivedAt: nowISO(),
|
|
517
|
+
supersededBy: f.id,
|
|
518
|
+
};
|
|
519
|
+
f.content = norm;
|
|
520
|
+
f.prevId = archived.id;
|
|
521
|
+
f.updatedAt = nowISO();
|
|
522
|
+
if (importance != null) { f.importance = importance; f.score = Math.max(f.score, importance); }
|
|
523
|
+
if (layer != null) f.layer = Number(layer) === LAYER_L4 ? LAYER_L4 : LAYER_L1;
|
|
524
|
+
if (source != null) f.source = source;
|
|
525
|
+
this.facts.push(archived);
|
|
526
|
+
this.rebuildIndex();
|
|
527
|
+
this.save();
|
|
528
|
+
return f;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// TTL 扫描: 超过 ttlDays 未访问的条目软归档 (非硬删)
|
|
533
|
+
// 与 /_prune 的分工: _prune 是容量保护的硬删, sweepExpired 是时效治理的软归档 (可回滚)
|
|
534
|
+
sweepExpired({ ttlDays = 90, layer = null, dryRun = false } = {}) {
|
|
535
|
+
const nowD = this._nowDays();
|
|
536
|
+
const targets = [];
|
|
537
|
+
for (const f of this.facts) {
|
|
538
|
+
if (f.status === "deleted") continue;
|
|
539
|
+
if (layer != null && Number(f.layer || LAYER_L1) !== Number(layer)) continue;
|
|
540
|
+
const ttl = Number(f.ttlDays || ttlDays);
|
|
541
|
+
if (!ttl || ttl <= 0) continue;
|
|
542
|
+
const days = Math.max(0, nowD - new Date(f.lastAccess).getTime() / 86400000);
|
|
543
|
+
if (days >= ttl) targets.push(f.id);
|
|
544
|
+
}
|
|
545
|
+
if (dryRun || !targets.length) return { swept: targets.length, ids: targets, dryRun: !!dryRun };
|
|
546
|
+
return withFileLock(this.file, () => {
|
|
547
|
+
this.facts = readJson(this.file, []);
|
|
548
|
+
this.rebuildIndex();
|
|
549
|
+
let n = 0;
|
|
550
|
+
for (const id of targets) {
|
|
551
|
+
const f = this.facts.find((x) => x.id === id);
|
|
552
|
+
if (f && f.status !== "deleted") {
|
|
553
|
+
f.status = "deleted";
|
|
554
|
+
f.deletedAt = nowISO();
|
|
555
|
+
f.deleteReason = `TTL ${ttlDays} 天未访问自动归档`;
|
|
556
|
+
n++;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
this.rebuildIndex();
|
|
560
|
+
this.save();
|
|
561
|
+
return { swept: n, ids: targets, dryRun: false };
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// 按层清空 (L4 程序性记忆 / L1 事实), 默认软删, hard=true 才真删
|
|
566
|
+
clearLayer(layer = LAYER_L1, { hard = false } = {}) {
|
|
567
|
+
return withFileLock(this.file, () => {
|
|
568
|
+
this.facts = readJson(this.file, []);
|
|
569
|
+
this.rebuildIndex();
|
|
570
|
+
const target = Number(layer);
|
|
571
|
+
const hits = this.facts.filter((f) => Number(f.layer || LAYER_L1) === target);
|
|
572
|
+
if (hard) {
|
|
573
|
+
this.facts = this.facts.filter((f) => Number(f.layer || LAYER_L1) !== target);
|
|
574
|
+
} else {
|
|
575
|
+
for (const f of hits) {
|
|
576
|
+
if (f.status === "deleted") continue;
|
|
577
|
+
f.status = "deleted";
|
|
578
|
+
f.deletedAt = nowISO();
|
|
579
|
+
f.deleteReason = `clearLayer(${target})`;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
this.rebuildIndex();
|
|
583
|
+
this.save();
|
|
584
|
+
return { layer: target, affected: hits.length, hard: !!hard };
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 导出全量记忆 (含软删/归档, 供备份与迁移)
|
|
589
|
+
exportAll({ includeDeleted = true } = {}) {
|
|
590
|
+
const items = includeDeleted ? this.facts : this._live();
|
|
591
|
+
return {
|
|
592
|
+
version: 1,
|
|
593
|
+
exportedAt: nowISO(),
|
|
594
|
+
count: items.length,
|
|
595
|
+
items: items.map((f) => ({ ...f })),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 导入记忆: merge(默认, 按内容去重跳过重复) 或 replace(整体替换)
|
|
600
|
+
importAll(payload, { mode = "merge" } = {}) {
|
|
601
|
+
const items = Array.isArray(payload) ? payload : (payload && Array.isArray(payload.items) ? payload.items : null);
|
|
602
|
+
if (!items) return { ok: false, reason: "导入数据格式非法 (需数组或 {items:[]})" };
|
|
603
|
+
return withFileLock(this.file, () => {
|
|
604
|
+
this.facts = readJson(this.file, []);
|
|
605
|
+
this.rebuildIndex();
|
|
606
|
+
if (mode === "replace") {
|
|
607
|
+
this.facts = items.map((it) => ({
|
|
608
|
+
...it,
|
|
609
|
+
id: it.id || cryptoRandomId(),
|
|
610
|
+
content: this._norm(it.content),
|
|
611
|
+
})).filter((f) => f.content);
|
|
612
|
+
this.rebuildIndex();
|
|
613
|
+
this.save();
|
|
614
|
+
return { ok: true, mode, imported: this.facts.length, skipped: 0 };
|
|
615
|
+
}
|
|
616
|
+
let imported = 0, skipped = 0;
|
|
617
|
+
const seen = new Set(this.facts.map((f) => this._normKey(f.content)));
|
|
618
|
+
for (const it of items) {
|
|
619
|
+
const norm = this._norm(it.content);
|
|
620
|
+
if (!norm) { skipped++; continue; }
|
|
621
|
+
const k = this._normKey(norm);
|
|
622
|
+
if (seen.has(k)) { skipped++; continue; }
|
|
623
|
+
seen.add(k);
|
|
624
|
+
const now = nowISO();
|
|
625
|
+
this.facts.push({
|
|
626
|
+
id: it.id || cryptoRandomId(),
|
|
627
|
+
content: norm,
|
|
628
|
+
type: it.type || "general",
|
|
629
|
+
source: it.source || "import",
|
|
630
|
+
importance: it.importance ?? this.opts.baseImportance,
|
|
631
|
+
score: it.score ?? (it.importance ?? this.opts.baseImportance),
|
|
632
|
+
created: it.created || now,
|
|
633
|
+
lastAccess: it.lastAccess || now,
|
|
634
|
+
hits: it.hits || 0,
|
|
635
|
+
scope: it.scope ?? null,
|
|
636
|
+
layer: Number(it.layer) === LAYER_L4 ? LAYER_L4 : LAYER_L1,
|
|
637
|
+
status: it.status === "deleted" ? "deleted" : "active",
|
|
638
|
+
prevId: it.prevId ?? null,
|
|
639
|
+
...(it.ttlDays ? { ttlDays: Number(it.ttlDays) } : {}),
|
|
640
|
+
...(it.meta ? { meta: it.meta } : {}),
|
|
641
|
+
});
|
|
642
|
+
imported++;
|
|
643
|
+
}
|
|
644
|
+
this.rebuildIndex();
|
|
645
|
+
this.save();
|
|
646
|
+
return { ok: true, mode, imported, skipped };
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 可观测: L1 原子记忆统计 (总量/来源分布/类型分布), 供 agent.stats() 聚合
|
|
651
|
+
stats() {
|
|
652
|
+
const bySource = {};
|
|
653
|
+
const byType = {};
|
|
654
|
+
const byLayer = {};
|
|
655
|
+
let deleted = 0, archived = 0;
|
|
656
|
+
for (const f of this.facts) {
|
|
657
|
+
const s = f.source || "unknown";
|
|
658
|
+
const t = f.type || "general";
|
|
659
|
+
const l = Number(f.layer || LAYER_L1);
|
|
660
|
+
bySource[s] = (bySource[s] || 0) + 1;
|
|
661
|
+
byType[t] = (byType[t] || 0) + 1;
|
|
662
|
+
byLayer[l] = (byLayer[l] || 0) + 1;
|
|
663
|
+
if (f.status === "deleted") deleted++;
|
|
664
|
+
if (f.status === "archived") archived++;
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
total: this.facts.length,
|
|
668
|
+
live: this.facts.length - deleted - archived,
|
|
669
|
+
deleted,
|
|
670
|
+
archived,
|
|
671
|
+
max_facts: this.opts.maxFacts || 0,
|
|
672
|
+
by_source: bySource,
|
|
673
|
+
by_type: byType,
|
|
674
|
+
by_layer: byLayer,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function cryptoRandomId() {
|
|
680
|
+
return "f_" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// RRF (Reciprocal Rank Fusion): 融合多个排序列表 (每项按 rank 倒数加权求和)。
|
|
684
|
+
// 用于多查询变体/多信号检索的融合, 纯函数零依赖。
|
|
685
|
+
export function rrfFuse(lists, { k = 60 } = {}) {
|
|
686
|
+
const score = new Map(); // id -> fused score
|
|
687
|
+
const items = new Map(); // id -> item (保留首个出现的对象)
|
|
688
|
+
for (const list of lists) {
|
|
689
|
+
list.forEach((item, rank) => {
|
|
690
|
+
const id = item && item.id;
|
|
691
|
+
if (id == null) return;
|
|
692
|
+
if (!items.has(id)) items.set(id, item);
|
|
693
|
+
score.set(id, (score.get(id) || 0) + 1 / (k + rank + 1));
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
return [...score.entries()]
|
|
697
|
+
.sort((a, b) => b[1] - a[1])
|
|
698
|
+
.map(([id]) => items.get(id));
|
|
699
|
+
}
|