recallnest 3.0.1 → 4.0.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/CHANGELOG.md +203 -0
- package/README.md +42 -17
- package/README_CN.md +31 -15
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +10 -2
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +434 -68
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/env-config.ts +118 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +69 -104
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +276 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +3 -4
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +6 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-output.ts +11 -0
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +57 -9
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- package/src/write-verifier.ts +16 -11
package/src/kg-store.ts
CHANGED
|
@@ -522,7 +522,7 @@ export class KGStore {
|
|
|
522
522
|
// Row mapping
|
|
523
523
|
// ============================================================================
|
|
524
524
|
|
|
525
|
-
function rowToTriple(row: Record<string, unknown>): KGTriple {
|
|
525
|
+
export function rowToTriple(row: Record<string, unknown>): KGTriple {
|
|
526
526
|
const sourceMemoryId = row.source_memory_id as string;
|
|
527
527
|
return {
|
|
528
528
|
id: row.id as string,
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known-item eval —— 用 trigger 原话搜自己的宿主,量「宿主排第几」。
|
|
3
|
+
*
|
|
4
|
+
* 每条 trigger(memory_triggers 侧表的一行)天然是一条「问法 → 目标 id」的已知条目查询,零人工标注。
|
|
5
|
+
* 它只回答一件事:一条记忆被它自己写下的问法问到时,能不能排在最前面。量不了答案质量——
|
|
6
|
+
* 单一目标、查询就是 trigger 原文(余弦 1.0 的上限情形),不能拿来代替答案质量的判官。
|
|
7
|
+
*
|
|
8
|
+
* 来由:2026-09-24 诊断(open-loops「RecallNest 检索评分链」症状 C):trigger 并入后 99% 的宿主
|
|
9
|
+
* 已排第 1,打分链走完只剩 38%,名次被流行度信号拿走。本模块是改评分链时 shadow 判据的一部分。
|
|
10
|
+
*
|
|
11
|
+
* 只读约定:
|
|
12
|
+
* - 读侧表与主表只用 query(),侧表不存在就返回空,绝不建表(TriggerStore.ensureInitialized 会建表,这里不用它);
|
|
13
|
+
* - 检索一律 source:"auto-recall"——retriever 里三处强化写入(频次台账、evolution 访问计数、AccessTracker)
|
|
14
|
+
* 都以 source !== "auto-recall" 为条件,跑多少遍都不改库;同时 trivialQueryGate:false,
|
|
15
|
+
* 和 search_memory 一样不过琐碎查询闸(那道闸只管自动召回);
|
|
16
|
+
* - 报告只含 id 与数字,不含 trigger 原文(原文是她的口语问法,不进仓库、不进报告)。
|
|
17
|
+
* 审计日志不归这里管:调用方(eval/known-item.ts)负责把 retriever 的 audit logger 换成空实现。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { loadLanceDB } from "./store.js";
|
|
21
|
+
import { TRIGGER_TABLE_NAME } from "./trigger-store.js";
|
|
22
|
+
import { isActiveMemory } from "./memory-evolution.js";
|
|
23
|
+
import { shouldSkipRetrievalFor } from "./adaptive-retrieval.js";
|
|
24
|
+
import { matchesScopeFilter } from "./scope-policy.js";
|
|
25
|
+
import type { RetrievalContext, RetrievalResult } from "./retriever.js";
|
|
26
|
+
|
|
27
|
+
const MEMORY_TABLE_NAME = "memories";
|
|
28
|
+
const DAY_MS = 86_400_000;
|
|
29
|
+
/** 分数被 clamp01 截平在 1.0 的判定线(打分链每一环都 clamp,1.0 并列时按稳定排序定先后)。 */
|
|
30
|
+
const SATURATED_SCORE = 0.9999;
|
|
31
|
+
|
|
32
|
+
export interface KnownItemTriggerRow {
|
|
33
|
+
id: string;
|
|
34
|
+
memoryId: string;
|
|
35
|
+
scope: string;
|
|
36
|
+
text: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface KnownItemHostRow {
|
|
40
|
+
id: string;
|
|
41
|
+
scope: string;
|
|
42
|
+
category: string;
|
|
43
|
+
importance: number;
|
|
44
|
+
timestamp: number;
|
|
45
|
+
metadata?: string;
|
|
46
|
+
text: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface KnownItemHost {
|
|
50
|
+
id: string;
|
|
51
|
+
scope: string;
|
|
52
|
+
category: string;
|
|
53
|
+
importance: number;
|
|
54
|
+
timestamp: number;
|
|
55
|
+
textLength: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface KnownItemCase {
|
|
59
|
+
/** trigger 行 id(`<宿主 id>#<序号>`),比较两次运行时按它对齐 */
|
|
60
|
+
triggerId: string;
|
|
61
|
+
/** 报告里显示的短名:`<宿主 id 前 8 位>#<序号>` */
|
|
62
|
+
label: string;
|
|
63
|
+
query: string;
|
|
64
|
+
hostId: string;
|
|
65
|
+
/** 按宿主所在 scope 检索(search_memory 带 scope 时就是这么查的) */
|
|
66
|
+
scope: string;
|
|
67
|
+
/** trigger 行记的 scope 与宿主 scope 不一致(按宿主 scope 查时,这条 trigger 自己过不了 scope 过滤) */
|
|
68
|
+
scopeMismatch: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type KnownItemSkipReason = "host-missing" | "host-inactive";
|
|
72
|
+
|
|
73
|
+
export interface KnownItemLoad {
|
|
74
|
+
cases: KnownItemCase[];
|
|
75
|
+
hosts: Map<string, KnownItemHost>;
|
|
76
|
+
triggerRows: number;
|
|
77
|
+
skipped: Array<{ triggerId: string; reason: KnownItemSkipReason }>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface KnownItemResult {
|
|
81
|
+
triggerId: string;
|
|
82
|
+
label: string;
|
|
83
|
+
hostId: string;
|
|
84
|
+
scope: string;
|
|
85
|
+
/** 宿主在返回结果里的名次(1 起);不在返回窗口里为 null */
|
|
86
|
+
rank: number | null;
|
|
87
|
+
score: number | null;
|
|
88
|
+
top1: string | null;
|
|
89
|
+
top1Score: number | null;
|
|
90
|
+
returned: number;
|
|
91
|
+
/** 排在宿主前面、分数被截平在 1.0 的条数(宿主不在窗口里时数整个窗口) */
|
|
92
|
+
saturatedAbove: number;
|
|
93
|
+
/** 检索入口整条跳过(这不是排序问题,单独计数)。琐碎查询闸只管自动召回,这里关着,只有空查询会计入 */
|
|
94
|
+
gateSkipped: boolean;
|
|
95
|
+
scopeMismatch: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface KnownItemCounts {
|
|
99
|
+
n: number;
|
|
100
|
+
at1: number;
|
|
101
|
+
at3: number;
|
|
102
|
+
at10: number;
|
|
103
|
+
atLimit: number;
|
|
104
|
+
notInWindow: number;
|
|
105
|
+
gateSkipped: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface KnownItemSummary {
|
|
109
|
+
limit: number;
|
|
110
|
+
overall: KnownItemCounts;
|
|
111
|
+
byAge: Record<string, KnownItemCounts>;
|
|
112
|
+
byPopularity?: Record<string, KnownItemCounts>;
|
|
113
|
+
/** 第 1 名终分恰好被截平在 1.0 的查询数 */
|
|
114
|
+
top1Saturated: number;
|
|
115
|
+
scopeMismatch: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// 构建用例(纯函数)
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function parseMeta(raw: string | undefined): Record<string, unknown> {
|
|
123
|
+
try {
|
|
124
|
+
const parsed: unknown = JSON.parse(raw || "{}");
|
|
125
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
126
|
+
} catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function triggerIndex(triggerId: string): string {
|
|
132
|
+
const i = triggerId.lastIndexOf("#");
|
|
133
|
+
return i >= 0 ? triggerId.slice(i + 1) : "0";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 把 trigger 行与宿主行配成用例。宿主不存在、非 active(superseded / consolidated 等)或 archived 的剔除——
|
|
138
|
+
* 它们本来就不该被检索到,算进来只会把「资格被正确拦下」误记成召回失败。
|
|
139
|
+
*/
|
|
140
|
+
export function buildKnownItemCases(
|
|
141
|
+
triggerRows: readonly KnownItemTriggerRow[],
|
|
142
|
+
hostRows: readonly KnownItemHostRow[],
|
|
143
|
+
): KnownItemLoad {
|
|
144
|
+
const hostById = new Map(hostRows.map((h) => [h.id, h]));
|
|
145
|
+
const hosts = new Map<string, KnownItemHost>();
|
|
146
|
+
const cases: KnownItemCase[] = [];
|
|
147
|
+
const skipped: KnownItemLoad["skipped"] = [];
|
|
148
|
+
const rows = triggerRows
|
|
149
|
+
.filter((t) => t.memoryId && t.memoryId !== "__schema__")
|
|
150
|
+
.slice()
|
|
151
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
152
|
+
|
|
153
|
+
for (const t of rows) {
|
|
154
|
+
const host = hostById.get(t.memoryId);
|
|
155
|
+
if (!host) {
|
|
156
|
+
skipped.push({ triggerId: t.id, reason: "host-missing" });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const meta = parseMeta(host.metadata);
|
|
160
|
+
if (!isActiveMemory(host.metadata) || meta.archived === true) {
|
|
161
|
+
skipped.push({ triggerId: t.id, reason: "host-inactive" });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
hosts.set(host.id, {
|
|
165
|
+
id: host.id,
|
|
166
|
+
scope: host.scope,
|
|
167
|
+
category: host.category,
|
|
168
|
+
importance: host.importance,
|
|
169
|
+
timestamp: host.timestamp,
|
|
170
|
+
textLength: host.text.length,
|
|
171
|
+
});
|
|
172
|
+
cases.push({
|
|
173
|
+
triggerId: t.id,
|
|
174
|
+
label: `${t.memoryId.slice(0, 8)}#${triggerIndex(t.id)}`,
|
|
175
|
+
query: t.text,
|
|
176
|
+
hostId: t.memoryId,
|
|
177
|
+
scope: host.scope,
|
|
178
|
+
scopeMismatch: t.scope !== host.scope,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return { cases, hosts, triggerRows: rows.length, skipped };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// 只读加载
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 从库里读出 trigger 行与宿主行。只用 query();侧表或主表不存在时返回空用例、不建表。
|
|
190
|
+
* `scopes` 给了就只取这些 scope 的 trigger(与 search_memory 同一套 family 匹配)。
|
|
191
|
+
*/
|
|
192
|
+
export async function loadKnownItemCases(
|
|
193
|
+
dbPath: string,
|
|
194
|
+
opts: { scopes?: string[] } = {},
|
|
195
|
+
): Promise<KnownItemLoad> {
|
|
196
|
+
const lancedb = await loadLanceDB();
|
|
197
|
+
const db = await lancedb.connect(dbPath);
|
|
198
|
+
const names = await db.tableNames();
|
|
199
|
+
if (!names.includes(TRIGGER_TABLE_NAME) || !names.includes(MEMORY_TABLE_NAME)) {
|
|
200
|
+
return { cases: [], hosts: new Map(), triggerRows: 0, skipped: [] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const triggerTable = await db.openTable(TRIGGER_TABLE_NAME);
|
|
204
|
+
const rawTriggers = await triggerTable.query().select(["id", "memory_id", "scope", "text"]).toArray();
|
|
205
|
+
const triggerRows: KnownItemTriggerRow[] = rawTriggers
|
|
206
|
+
.map((r: Record<string, unknown>) => ({
|
|
207
|
+
id: String(r.id ?? ""),
|
|
208
|
+
memoryId: String(r.memory_id ?? ""),
|
|
209
|
+
scope: String(r.scope ?? ""),
|
|
210
|
+
text: String(r.text ?? ""),
|
|
211
|
+
}))
|
|
212
|
+
.filter((r) => r.id && r.memoryId && r.memoryId !== "__schema__")
|
|
213
|
+
.filter((r) => !opts.scopes || opts.scopes.length === 0 || matchesScopeFilter(r.scope, opts.scopes));
|
|
214
|
+
|
|
215
|
+
const memoryTable = await db.openTable(MEMORY_TABLE_NAME);
|
|
216
|
+
const hostIds = [...new Set(triggerRows.map((t) => t.memoryId))];
|
|
217
|
+
const hostRows: KnownItemHostRow[] = [];
|
|
218
|
+
const CHUNK = 200;
|
|
219
|
+
for (let i = 0; i < hostIds.length; i += CHUNK) {
|
|
220
|
+
const list = hostIds
|
|
221
|
+
.slice(i, i + CHUNK)
|
|
222
|
+
.map((id) => `'${id.replace(/'/g, "''")}'`)
|
|
223
|
+
.join(",");
|
|
224
|
+
const rows = await memoryTable
|
|
225
|
+
.query()
|
|
226
|
+
.where(`id IN (${list})`)
|
|
227
|
+
.select(["id", "scope", "category", "importance", "timestamp", "metadata", "text"])
|
|
228
|
+
.toArray();
|
|
229
|
+
for (const r of rows as Array<Record<string, unknown>>) {
|
|
230
|
+
hostRows.push({
|
|
231
|
+
id: String(r.id),
|
|
232
|
+
scope: String(r.scope ?? ""),
|
|
233
|
+
category: String(r.category ?? ""),
|
|
234
|
+
importance: Number(r.importance),
|
|
235
|
+
timestamp: Number(r.timestamp),
|
|
236
|
+
metadata: typeof r.metadata === "string" ? r.metadata : "{}",
|
|
237
|
+
text: String(r.text ?? ""),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return buildKnownItemCases(triggerRows, hostRows);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// 运行
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/** 只要 retrieve 这一个方法——测试可以传假的,CLI 传真的 MemoryRetriever。 */
|
|
249
|
+
export interface KnownItemRetriever {
|
|
250
|
+
retrieve(context: RetrievalContext): Promise<RetrievalResult[]>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function runKnownItemEval(
|
|
254
|
+
retriever: KnownItemRetriever,
|
|
255
|
+
cases: readonly KnownItemCase[],
|
|
256
|
+
opts: { limit?: number; onProgress?: (done: number, total: number) => void } = {},
|
|
257
|
+
): Promise<KnownItemResult[]> {
|
|
258
|
+
const limit = Math.max(1, Math.min(20, Math.floor(opts.limit ?? 20)));
|
|
259
|
+
const out: KnownItemResult[] = [];
|
|
260
|
+
for (const c of cases) {
|
|
261
|
+
// 量的是人主动搜的样子(search_memory 不过琐碎查询闸),source 标 auto-recall 只为不写库
|
|
262
|
+
const context: RetrievalContext = {
|
|
263
|
+
query: c.query,
|
|
264
|
+
limit,
|
|
265
|
+
scopeFilter: [c.scope],
|
|
266
|
+
source: "auto-recall",
|
|
267
|
+
trivialQueryGate: false,
|
|
268
|
+
};
|
|
269
|
+
const results = await retriever.retrieve(context);
|
|
270
|
+
const ids = results.map((r) => r.entry.id);
|
|
271
|
+
const idx = ids.indexOf(c.hostId);
|
|
272
|
+
const above = idx >= 0 ? results.slice(0, idx) : results;
|
|
273
|
+
out.push({
|
|
274
|
+
triggerId: c.triggerId,
|
|
275
|
+
label: c.label,
|
|
276
|
+
hostId: c.hostId,
|
|
277
|
+
scope: c.scope,
|
|
278
|
+
rank: idx >= 0 ? idx + 1 : null,
|
|
279
|
+
score: idx >= 0 ? results[idx].score : null,
|
|
280
|
+
top1: ids.length > 0 ? ids[0].slice(0, 8) : null,
|
|
281
|
+
top1Score: results.length > 0 ? results[0].score : null,
|
|
282
|
+
returned: results.length,
|
|
283
|
+
saturatedAbove: above.filter((r) => r.score >= SATURATED_SCORE).length,
|
|
284
|
+
gateSkipped: shouldSkipRetrievalFor(context),
|
|
285
|
+
scopeMismatch: c.scopeMismatch,
|
|
286
|
+
});
|
|
287
|
+
opts.onProgress?.(out.length, cases.length);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// 汇总与比较(纯函数)
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
function countOf(list: readonly KnownItemResult[], limit: number): KnownItemCounts {
|
|
297
|
+
const within = (k: number) => list.filter((r) => r.rank !== null && r.rank <= k).length;
|
|
298
|
+
return {
|
|
299
|
+
n: list.length,
|
|
300
|
+
at1: within(1),
|
|
301
|
+
at3: within(3),
|
|
302
|
+
at10: within(10),
|
|
303
|
+
atLimit: within(limit),
|
|
304
|
+
notInWindow: list.filter((r) => r.rank === null).length,
|
|
305
|
+
gateSkipped: list.filter((r) => r.gateSkipped).length,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function ageBucket(ageDays: number): string {
|
|
310
|
+
if (ageDays <= 1) return "a) ≤1 天";
|
|
311
|
+
if (ageDays <= 3) return "b) 1–3 天";
|
|
312
|
+
if (ageDays <= 7) return "c) 3–7 天";
|
|
313
|
+
if (ageDays <= 30) return "d) 7–30 天";
|
|
314
|
+
return "e) >30 天";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function popularityBucket(multiplier: number): string {
|
|
318
|
+
if (multiplier <= 1.0001) return "a) ×1.0";
|
|
319
|
+
if (multiplier <= 1.3) return "b) ×1.0–1.3";
|
|
320
|
+
return "c) >×1.3";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function groupCounts(
|
|
324
|
+
results: readonly KnownItemResult[],
|
|
325
|
+
keyOf: (r: KnownItemResult) => string | null,
|
|
326
|
+
limit: number,
|
|
327
|
+
): Record<string, KnownItemCounts> {
|
|
328
|
+
const groups = new Map<string, KnownItemResult[]>();
|
|
329
|
+
for (const r of results) {
|
|
330
|
+
const k = keyOf(r);
|
|
331
|
+
if (k === null) continue;
|
|
332
|
+
const g = groups.get(k);
|
|
333
|
+
if (g) g.push(r);
|
|
334
|
+
else groups.set(k, [r]);
|
|
335
|
+
}
|
|
336
|
+
return Object.fromEntries([...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, g]) => [k, countOf(g, limit)]));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @param now 算宿主年龄用的「现在」(快照诊断时传快照时刻,保证重跑一致)
|
|
341
|
+
* @param freqMultiplier 宿主自身的频次加成倍数(FrequencyTracker.getBoostMultiplier);不给就不分热度组
|
|
342
|
+
*/
|
|
343
|
+
export function summarizeKnownItem(
|
|
344
|
+
results: readonly KnownItemResult[],
|
|
345
|
+
hosts: ReadonlyMap<string, KnownItemHost>,
|
|
346
|
+
opts: { now: number; limit: number; freqMultiplier?: (id: string) => number },
|
|
347
|
+
): KnownItemSummary {
|
|
348
|
+
const summary: KnownItemSummary = {
|
|
349
|
+
limit: opts.limit,
|
|
350
|
+
overall: countOf(results, opts.limit),
|
|
351
|
+
byAge: groupCounts(results, (r) => {
|
|
352
|
+
const h = hosts.get(r.hostId);
|
|
353
|
+
return h ? ageBucket((opts.now - h.timestamp) / DAY_MS) : null;
|
|
354
|
+
}, opts.limit),
|
|
355
|
+
top1Saturated: results.filter((r) => r.top1Score !== null && r.top1Score >= SATURATED_SCORE).length,
|
|
356
|
+
scopeMismatch: results.filter((r) => r.scopeMismatch).length,
|
|
357
|
+
};
|
|
358
|
+
if (opts.freqMultiplier) {
|
|
359
|
+
const fm = opts.freqMultiplier;
|
|
360
|
+
summary.byPopularity = groupCounts(results, (r) => popularityBucket(fm(r.hostId)), opts.limit);
|
|
361
|
+
}
|
|
362
|
+
return summary;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface KnownItemRankChange {
|
|
366
|
+
triggerId: string;
|
|
367
|
+
label: string;
|
|
368
|
+
before: number | null;
|
|
369
|
+
after: number | null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface KnownItemComparison {
|
|
373
|
+
shared: number;
|
|
374
|
+
onlyBefore: string[];
|
|
375
|
+
onlyAfter: string[];
|
|
376
|
+
/** 之前不在第 1、之后第 1 */
|
|
377
|
+
rescued: KnownItemRankChange[];
|
|
378
|
+
/** 之前第 1、之后不在第 1——shadow 判据要求逐条说明性质的就是这一栏 */
|
|
379
|
+
lost: KnownItemRankChange[];
|
|
380
|
+
enteredTop10: KnownItemRankChange[];
|
|
381
|
+
leftTop10: KnownItemRankChange[];
|
|
382
|
+
at1: { before: number; after: number };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
type RankRow = Pick<KnownItemResult, "triggerId" | "label" | "rank">;
|
|
386
|
+
|
|
387
|
+
export function compareKnownItemRuns(before: readonly RankRow[], after: readonly RankRow[]): KnownItemComparison {
|
|
388
|
+
const a = new Map(before.map((r) => [r.triggerId, r]));
|
|
389
|
+
const b = new Map(after.map((r) => [r.triggerId, r]));
|
|
390
|
+
const sharedIds = [...a.keys()].filter((id) => b.has(id)).sort();
|
|
391
|
+
const change = (id: string): KnownItemRankChange => ({
|
|
392
|
+
triggerId: id,
|
|
393
|
+
label: a.get(id)!.label,
|
|
394
|
+
before: a.get(id)!.rank,
|
|
395
|
+
after: b.get(id)!.rank,
|
|
396
|
+
});
|
|
397
|
+
const top = (rank: number | null, k: number) => rank !== null && rank <= k;
|
|
398
|
+
const changes = sharedIds.map(change);
|
|
399
|
+
return {
|
|
400
|
+
shared: sharedIds.length,
|
|
401
|
+
onlyBefore: [...a.keys()].filter((id) => !b.has(id)).sort(),
|
|
402
|
+
onlyAfter: [...b.keys()].filter((id) => !a.has(id)).sort(),
|
|
403
|
+
rescued: changes.filter((c) => !top(c.before, 1) && top(c.after, 1)),
|
|
404
|
+
lost: changes.filter((c) => top(c.before, 1) && !top(c.after, 1)),
|
|
405
|
+
enteredTop10: changes.filter((c) => !top(c.before, 10) && top(c.after, 10)),
|
|
406
|
+
leftTop10: changes.filter((c) => top(c.before, 10) && !top(c.after, 10)),
|
|
407
|
+
at1: {
|
|
408
|
+
before: changes.filter((c) => top(c.before, 1)).length,
|
|
409
|
+
after: changes.filter((c) => top(c.after, 1)).length,
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// 文本输出
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
function pct(a: number, n: number): string {
|
|
419
|
+
return n > 0 ? `${a}/${n}(${Math.round((a / n) * 100)}%)` : "0/0";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function countsLine(c: KnownItemCounts, limit: number): string {
|
|
423
|
+
return `第 1 ${pct(c.at1, c.n)}|前 3 ${pct(c.at3, c.n)}|前 10 ${pct(c.at10, c.n)}|前 ${limit} ${pct(c.atLimit, c.n)}|不在窗口 ${c.notInWindow}|入口跳过 ${c.gateSkipped}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function formatKnownItemSummary(s: KnownItemSummary): string {
|
|
427
|
+
const lines = [`总体:${countsLine(s.overall, s.limit)}`, `第 1 名终分截平在 1.0 的查询:${pct(s.top1Saturated, s.overall.n)}`];
|
|
428
|
+
if (s.scopeMismatch > 0) lines.push(`trigger scope 与宿主 scope 不一致:${s.scopeMismatch} 条`);
|
|
429
|
+
lines.push("按宿主存入天数:");
|
|
430
|
+
for (const [k, c] of Object.entries(s.byAge)) lines.push(` ${k}:${countsLine(c, s.limit)}`);
|
|
431
|
+
if (s.byPopularity) {
|
|
432
|
+
lines.push("按宿主自身频次倍数:");
|
|
433
|
+
for (const [k, c] of Object.entries(s.byPopularity)) lines.push(` ${k}:${countsLine(c, s.limit)}`);
|
|
434
|
+
}
|
|
435
|
+
return lines.join("\n");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function formatKnownItemComparison(c: KnownItemComparison): string {
|
|
439
|
+
const fmt = (x: KnownItemRankChange) => `${x.label}:${x.before ?? "—"} → ${x.after ?? "—"}`;
|
|
440
|
+
const lines = [
|
|
441
|
+
`共同用例 ${c.shared};第 1:${c.at1.before} → ${c.at1.after}`,
|
|
442
|
+
`救回第 1(${c.rescued.length})`,
|
|
443
|
+
...c.rescued.map((x) => ` ${fmt(x)}`),
|
|
444
|
+
`掉出第 1(${c.lost.length},每条都要说明性质)`,
|
|
445
|
+
...c.lost.map((x) => ` ${fmt(x)}`),
|
|
446
|
+
`进入前 10(${c.enteredTop10.length})`,
|
|
447
|
+
...c.enteredTop10.map((x) => ` ${fmt(x)}`),
|
|
448
|
+
`掉出前 10(${c.leftTop10.length})`,
|
|
449
|
+
...c.leftTop10.map((x) => ` ${fmt(x)}`),
|
|
450
|
+
];
|
|
451
|
+
if (c.onlyBefore.length || c.onlyAfter.length) {
|
|
452
|
+
lines.push(`用例集不一致:只在前一次 ${c.onlyBefore.length} 条,只在后一次 ${c.onlyAfter.length} 条(两次跑的不是同一个库或同一批 trigger,比较只看共同用例)`);
|
|
453
|
+
}
|
|
454
|
+
return lines.join("\n");
|
|
455
|
+
}
|
package/src/llm-client.ts
CHANGED
|
@@ -175,10 +175,11 @@ export interface DedupDecision {
|
|
|
175
175
|
reason: string;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
/**
|
|
179
|
+
* 多候选去重的结论。与单候选同形:只回答新切片「存 / 并 / 跳」。
|
|
180
|
+
* 2026-10-05 之前多一个 `actions`(让模型标记删除已有记忆),已拿掉,原因见 ingest.ts 的 DedupCheckResult。
|
|
181
|
+
*/
|
|
182
|
+
export type DedupDecisionMulti = DedupDecision;
|
|
182
183
|
|
|
183
184
|
/** Six memory categories (OpenViking-inspired) */
|
|
184
185
|
export type SmartCategory =
|
|
@@ -526,7 +527,8 @@ export class LLMClient {
|
|
|
526
527
|
|
|
527
528
|
const parsed = parseJSON<DedupDecision>(response);
|
|
528
529
|
if (parsed && (parsed.action === "CREATE" || parsed.action === "MERGE" || parsed.action === "SKIP")) {
|
|
529
|
-
|
|
530
|
+
// 只带出结论与原因。多候选分支只有一个候选时也走这里,模型多返回的字段同样不外传。
|
|
531
|
+
return { action: parsed.action, reason: parsed.reason ?? "" };
|
|
530
532
|
}
|
|
531
533
|
|
|
532
534
|
return { action: "CREATE", reason: "JSON 解析失败" };
|
|
@@ -537,7 +539,8 @@ export class LLMClient {
|
|
|
537
539
|
|
|
538
540
|
/**
|
|
539
541
|
* Multi-candidate dedup decision: compare a new chunk against multiple existing
|
|
540
|
-
* memories
|
|
542
|
+
* memories. Decides only what happens to the new chunk; existing memories are
|
|
543
|
+
* never modified or deleted from here.
|
|
541
544
|
*/
|
|
542
545
|
async dedupDecisionMulti(
|
|
543
546
|
newText: string,
|
|
@@ -563,12 +566,9 @@ export class LLMClient {
|
|
|
563
566
|
"- MERGE:新记忆有补充信息,应该合并到已有记忆\n" +
|
|
564
567
|
"- CREATE:新记忆是不同的事,应该独立存储\n" +
|
|
565
568
|
"- 如果两条都是偏好陈述,同品牌/同主题下的不同条目,必须返回 CREATE\n\n" +
|
|
566
|
-
"额外能力:如果发现某些已有记忆已经过时(被新记忆或其他已有记忆完全取代),可以在 actions 里标记删除。\n\n" +
|
|
567
569
|
"只输出一行 JSON:\n" +
|
|
568
|
-
'{"action":"CREATE|MERGE|SKIP","match_index":1,"reason":"简短原因"
|
|
569
|
-
"- match_index 是 1-based
|
|
570
|
-
"- actions 是可选的,只在确实有过时记忆需要清理时才填\n" +
|
|
571
|
-
"- actions 里只支持 delete",
|
|
570
|
+
'{"action":"CREATE|MERGE|SKIP","match_index":1,"reason":"简短原因"}' + "\n\n" +
|
|
571
|
+
"- match_index 是 1-based 索引,指向与新记忆最相关的那条已有记忆",
|
|
572
572
|
`${existingBlock}\n\n[新记忆]\n${newText.slice(0, 1000)}`,
|
|
573
573
|
);
|
|
574
574
|
|
|
@@ -576,21 +576,8 @@ export class LLMClient {
|
|
|
576
576
|
|
|
577
577
|
const parsed = parseJSON<DedupDecisionMulti>(response);
|
|
578
578
|
if (parsed && (parsed.action === "CREATE" || parsed.action === "MERGE" || parsed.action === "SKIP")) {
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
? parsed.actions.filter(
|
|
582
|
-
(a) =>
|
|
583
|
-
typeof a === "object" && a !== null &&
|
|
584
|
-
typeof a.match_index === "number" &&
|
|
585
|
-
a.match_index >= 1 && a.match_index <= existingEntries.length &&
|
|
586
|
-
a.action === "delete",
|
|
587
|
-
)
|
|
588
|
-
: [];
|
|
589
|
-
return {
|
|
590
|
-
action: parsed.action,
|
|
591
|
-
reason: parsed.reason ?? "",
|
|
592
|
-
actions: validActions.length > 0 ? validActions : undefined,
|
|
593
|
-
};
|
|
579
|
+
// 只带出结论与原因;模型多返回的字段(旧提示词教过它写 actions)一律丢掉
|
|
580
|
+
return { action: parsed.action, reason: parsed.reason ?? "" };
|
|
594
581
|
}
|
|
595
582
|
|
|
596
583
|
return { action: "CREATE", reason: "JSON 解析失败" };
|
package/src/llm-consolidation.ts
CHANGED
|
@@ -141,8 +141,8 @@ export async function executeMergeDecisions(
|
|
|
141
141
|
|
|
142
142
|
const canonical = groupEntries[0];
|
|
143
143
|
for (const member of groupEntries.slice(1)) {
|
|
144
|
-
|
|
145
|
-
mergeCount++;
|
|
144
|
+
// null: one of the two rows was deleted meanwhile — nothing was merged
|
|
145
|
+
if (await createVersionGroup(store, canonical, member, scope)) mergeCount++;
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
package/src/mcp-tool-deps.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { WorkflowObservationStore } from "./workflow-observation-store.js";
|
|
|
19
19
|
import type { KGExtractor } from "./kg-extractor.js";
|
|
20
20
|
import type { KGStore } from "./kg-store.js";
|
|
21
21
|
import type { AuditLogger } from "./audit-log.js";
|
|
22
|
+
import type { ForgetPinArchive } from "./forget-engine.js";
|
|
22
23
|
|
|
23
24
|
export type ToolTier = "core" | "advanced" | "governance";
|
|
24
25
|
export type ToolSchema = Parameters<McpServer["tool"]>[2];
|
|
@@ -45,4 +46,9 @@ export interface ToolRegistryDeps {
|
|
|
45
46
|
getKGExtractor: () => KGExtractor | null;
|
|
46
47
|
/** Live getter for the lazily-initialized KG store (null unless RECALLNEST_KG_MODE=true). */
|
|
47
48
|
getKGStore: () => KGStore | null;
|
|
49
|
+
/**
|
|
50
|
+
* Pin files that forget_memory archives. Left unset at the runtime entry, which gives the
|
|
51
|
+
* data/pins directory; tests set it so that no case reads or moves a real pin.
|
|
52
|
+
*/
|
|
53
|
+
forgetPins?: ForgetPinArchive;
|
|
48
54
|
}
|