recallnest 2.6.1 → 3.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 +191 -0
- package/README.md +131 -14
- package/README_CN.md +121 -17
- package/config.json.example +8 -1
- package/docs/api-reference.md +1 -1
- package/integrations/README.md +1 -1
- package/integrations/agy/README.md +73 -0
- package/integrations/agy/setup.sh +75 -0
- package/integrations/setup-all.sh +1 -1
- package/package.json +5 -4
- package/src/__tests__/access-tracker-novelty.test.ts +22 -2
- package/src/__tests__/activity-counter.test.ts +38 -38
- package/src/__tests__/age-label.test.ts +33 -0
- package/src/__tests__/cluster-consolidation.test.ts +313 -22
- package/src/__tests__/consolidation-engine.test.ts +90 -0
- package/src/__tests__/contracts.test.ts +2 -2
- package/src/__tests__/data-checkup.test.ts +31 -3
- package/src/__tests__/dream-pipeline.test.ts +41 -1
- package/src/__tests__/dream-scope-isolation.test.ts +386 -0
- package/src/__tests__/dream-write-batching.test.ts +211 -0
- package/src/__tests__/env-config-invariant.test.ts +80 -0
- package/src/__tests__/env-config.test.ts +60 -0
- package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
- package/src/__tests__/maintenance-locks.test.ts +8 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
- package/src/__tests__/memory-output.test.ts +9 -0
- package/src/__tests__/memory-utility.test.ts +256 -0
- package/src/__tests__/openai-contract.test.ts +513 -0
- package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
- package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
- package/src/__tests__/source-heartbeat.test.ts +6 -2
- package/src/__tests__/store-patch-metadata.test.ts +30 -0
- package/src/__tests__/synthesis-contract.test.ts +252 -0
- package/src/__tests__/synthesis-promotion.test.ts +369 -0
- package/src/__tests__/trusted-memory.test.ts +28 -0
- package/src/__tests__/utility-weight.test.ts +57 -0
- package/src/__tests__/verbatim-self-recall.test.ts +279 -0
- package/src/__tests__/version-manager.test.ts +35 -0
- package/src/access-tracker.ts +10 -1
- package/src/activity-counter.ts +69 -11
- package/src/age-label.ts +29 -0
- package/src/audit-log.ts +35 -0
- package/src/auto-gc.ts +97 -30
- package/src/cli.ts +224 -10
- package/src/consolidation-engine.ts +317 -123
- package/src/data-checkup.ts +17 -3
- package/src/dream-pipeline.ts +90 -12
- package/src/embedder.ts +20 -5
- package/src/env-config.ts +53 -0
- package/src/forget-engine.ts +8 -2
- package/src/gateway-server.ts +272 -0
- package/src/ingest.ts +23 -5
- package/src/llm-client.ts +86 -25
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +7 -2
- package/src/mcp-tools-core.ts +11 -1
- package/src/mcp-tools-governance.ts +45 -3
- package/src/memory-evolution.ts +16 -0
- package/src/memory-output.ts +12 -8
- package/src/memory-promotion.ts +280 -0
- package/src/memory-store-port.ts +32 -3
- package/src/memory-utility.ts +314 -0
- package/src/recall-ledger.ts +76 -0
- package/src/retriever.ts +82 -9
- package/src/scope-policy.ts +22 -1
- package/src/session-output.ts +4 -3
- package/src/skill-promotion.ts +152 -1
- package/src/store.ts +231 -71
- package/src/synthesis-contract.ts +286 -0
- package/src/version-manager.ts +83 -0
- package/src/workflow-observation-schema.ts +26 -0
- package/integrations/gemini-cli/README.md +0 -57
- package/integrations/gemini-cli/setup.sh +0 -64
- /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
|
@@ -20,12 +20,16 @@
|
|
|
20
20
|
import type { MemoryEntry, MemorySearchResult } from "./store.js";
|
|
21
21
|
import { deterministicId } from "./store.js";
|
|
22
22
|
import type { MemoryStorePort } from "./memory-store-port.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
23
|
+
import type { ScopeMatchMode } from "./scope-policy.js";
|
|
24
|
+
import { computeVersionGroupPatch, resolveGroupId } from "./version-manager.js";
|
|
25
|
+
import { isActiveMemory, parseEvolution, buildSupersedeMetadata, patchEvolutionOnMeta } from "./memory-evolution.js";
|
|
25
26
|
import { cosineSimilarity } from "./multi-vector.js";
|
|
26
27
|
import type { LLMClient } from "./llm-client.js";
|
|
27
28
|
import type { Embedder } from "./embedder.js";
|
|
28
29
|
import { logWarn } from "./stderr-log.js";
|
|
30
|
+
import { DURABLE_MEMORY_CATEGORIES, type DurableMemoryCategory } from "./memory-schema.js";
|
|
31
|
+
import { getConflictPolicyForCategory, type MemoryBoundaryMetadata } from "./memory-boundaries.js";
|
|
32
|
+
import type { SynthesisVerdict } from "./synthesis-contract.js";
|
|
29
33
|
|
|
30
34
|
// ---------------------------------------------------------------------------
|
|
31
35
|
// Config & Types
|
|
@@ -46,6 +50,12 @@ export interface ConsolidationConfig {
|
|
|
46
50
|
tripleJaccardThreshold?: number;
|
|
47
51
|
/** KG evidence: both sides need at least this many triples to qualify (default 2) */
|
|
48
52
|
minTriplesForEvidence?: number;
|
|
53
|
+
/**
|
|
54
|
+
* scope 过滤模式(2026-08-16 加)。默认 "family" = store 历史语义(无冒号按前缀)。
|
|
55
|
+
* dream 传 "exact":它拿到的是一个具体 scope 名,前缀语义会把兄弟 scope
|
|
56
|
+
* (`memory` → `memory:pivot`)卷进同一批候选与 vectorSearch 结果。
|
|
57
|
+
*/
|
|
58
|
+
scopeMatch?: ScopeMatchMode;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
export const DEFAULT_CONSOLIDATION_CONFIG: ConsolidationConfig = {
|
|
@@ -122,6 +132,53 @@ function parseMetadata(entry: MemoryEntry): Record<string, unknown> {
|
|
|
122
132
|
try { return JSON.parse(entry.metadata); } catch { return {}; }
|
|
123
133
|
}
|
|
124
134
|
|
|
135
|
+
/**
|
|
136
|
+
* 合成契约版本号。写进每条派生物的 metadata,用来把「新契约的产物」和历史存量分开。
|
|
137
|
+
*
|
|
138
|
+
* 存在的理由是可评估性:两把确定性判据(结论连接词率 / 性向归因率)要在改动前后
|
|
139
|
+
* 做对照,而库里 4,500+ 条老产物和新产物混在一张表里、`cluster_insight` 标记完全
|
|
140
|
+
* 相同。没有这个标记,"改完之后的产物质量"这个问题就只能靠时间戳去猜。
|
|
141
|
+
*
|
|
142
|
+
* **提示词属于契约,改了就要推版本号**——否则前后两批产物混在同一个标记下,
|
|
143
|
+
* 「这个数字是哪版代码跑出来的」又变成了猜。v2(2026-08-23 当日)= v1 加上
|
|
144
|
+
* 「只输出一个 JSON 对象」的约束:v1 实测有 3.3% 的调用会一口气吐出多个 JSON
|
|
145
|
+
* 对象、整条判 unparsable(finish_reason 全为 stop,与 token 截断无关),
|
|
146
|
+
* 补这一句后同口径实测降到 0.0%。
|
|
147
|
+
*/
|
|
148
|
+
export const SYNTHESIS_CONTRACT_VERSION = 2;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 派生物(cluster insight / cross-memory pattern)的边界声明。
|
|
152
|
+
*
|
|
153
|
+
* 为什么必须显式写(2026-08-22 查实):4,749 条历史派生物**全部没有 boundary 字段**。
|
|
154
|
+
* 检索侧 `retriever.ts` 的 `isEvidenceLayer` 在缺字段时回退到 `isTranscriptScope(scope)`,
|
|
155
|
+
* 于是其中 96.4% 因为恰好落在 `cc:` / `codex:` scope 而被判成 evidence —— **靠 scope
|
|
156
|
+
* 命名侥幸判对,不是靠设计**。剩下 173 条落在 `memory` / `project:*` 这类非 transcript
|
|
157
|
+
* scope 上,静默拿到了 durable 权威:一句 LLM 复述,在检索里和手写结论平起平坐。
|
|
158
|
+
*
|
|
159
|
+
* 定成 evidence 而不是 durable:这是模型对既有记忆的再加工,不是从外部世界捕获的
|
|
160
|
+
* 事实,也不是人拍板的结论。它可以作为线索被检索到,但不该压过它自己的源。
|
|
161
|
+
* authority 取 `distillation`(它确实是提炼产物),与 layer 一起才说得全 ——
|
|
162
|
+
* 「是什么来源」和「有多大权威」是两个问题。
|
|
163
|
+
*/
|
|
164
|
+
export function buildDerivedBoundary(category: string): MemoryBoundaryMetadata {
|
|
165
|
+
const durable = toDurableCategory(category);
|
|
166
|
+
return {
|
|
167
|
+
layer: "evidence",
|
|
168
|
+
authority: "distillation",
|
|
169
|
+
conflictPolicy: getConflictPolicyForCategory(durable),
|
|
170
|
+
originalCategory: durable,
|
|
171
|
+
note: "Dream-synthesized derivative: a lead to its sources, never authority over them.",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 簇的多数票类目可能落在 legacy 值上;边界字段只认 durable 六类,兜底到 events。 */
|
|
176
|
+
function toDurableCategory(category: string): DurableMemoryCategory {
|
|
177
|
+
return (DURABLE_MEMORY_CATEGORIES as readonly string[]).includes(category)
|
|
178
|
+
? category as DurableMemoryCategory
|
|
179
|
+
: "events";
|
|
180
|
+
}
|
|
181
|
+
|
|
125
182
|
function isActive(entry: MemoryEntry): boolean {
|
|
126
183
|
// Unified lifecycle check via evolution.status (no legacy meta.state fallback)
|
|
127
184
|
return isActiveMemory(entry.metadata);
|
|
@@ -163,7 +220,7 @@ export function detectHeuristicContradiction(textA: string, textB: string): bool
|
|
|
163
220
|
// Store interface (duck-typed to avoid hard dependency)
|
|
164
221
|
// ---------------------------------------------------------------------------
|
|
165
222
|
|
|
166
|
-
type ConsolidationStore = Pick<MemoryStorePort, "list" | "getById" | "vectorSearch" | "update">;
|
|
223
|
+
type ConsolidationStore = Pick<MemoryStorePort, "list" | "getById" | "vectorSearch" | "update" | "patchMetadataBatch">;
|
|
167
224
|
|
|
168
225
|
// ---------------------------------------------------------------------------
|
|
169
226
|
// Engine
|
|
@@ -184,11 +241,18 @@ export class ConsolidationEngine {
|
|
|
184
241
|
maxEntriesPerRun,
|
|
185
242
|
tripleJaccardThreshold = 0.5,
|
|
186
243
|
minTriplesForEvidence = 2,
|
|
244
|
+
scopeMatch = "family",
|
|
187
245
|
} = this.config;
|
|
188
246
|
|
|
189
247
|
// 1. Fetch entries in scope
|
|
190
|
-
const entries = await this.store.list([scope], undefined, maxEntriesPerRun, 0);
|
|
191
|
-
|
|
248
|
+
const entries = await this.store.list([scope], undefined, maxEntriesPerRun, 0, scopeMatch);
|
|
249
|
+
// 派生 insight 不参与去重(2026-08-16 加,与 dream gather 的既有排除对齐)。
|
|
250
|
+
// 此前只有 gather(dream-pipeline.ts)排除派生物,3a 自己重新取数时不排除 ——
|
|
251
|
+
// 实测后果:`memory:pivot` 里 3 条手写原件被 3a 合并进 LLM 复述、复述当了 canonical
|
|
252
|
+
// (双方 importance 0.7 / accessCount 0,canonicalScore 打平时任取其一),
|
|
253
|
+
// 其中两条带 canonicalKey 与来源 tag,赢家是零来源的短复述。
|
|
254
|
+
// 派生物既不做 seed 也不做 member —— 摘要不该有资格顶掉它自己的原文。
|
|
255
|
+
const active = entries.filter(e => isActive(e) && !isDerivedInsight(e.metadata));
|
|
192
256
|
|
|
193
257
|
if (active.length === 0) {
|
|
194
258
|
return { originalCount: 0, clustersFound: 0, mergedCount: 0, relationsAdded: 0, tripleEvidenceMerges: 0, conflictsDetected: [], scope };
|
|
@@ -222,7 +286,7 @@ export class ConsolidationEngine {
|
|
|
222
286
|
if (!full?.vector?.length) continue;
|
|
223
287
|
|
|
224
288
|
const similar = await this.store.vectorSearch(
|
|
225
|
-
full.vector, 10, clusterThreshold, [scope],
|
|
289
|
+
full.vector, 10, clusterThreshold, [scope], scopeMatch,
|
|
226
290
|
);
|
|
227
291
|
|
|
228
292
|
// Seeds are active-only (above), but vectorSearch filters by scope alone, so
|
|
@@ -236,7 +300,10 @@ export class ConsolidationEngine {
|
|
|
236
300
|
s => s.entry.id !== entry.id
|
|
237
301
|
&& !clustered.has(s.entry.id)
|
|
238
302
|
&& s.entry.category === entry.category
|
|
239
|
-
&& isActive(s.entry)
|
|
303
|
+
&& isActive(s.entry)
|
|
304
|
+
// vectorSearch 只按 scope 过滤,派生 insight 会从这里回到候选集里 ——
|
|
305
|
+
// seed 侧已在上面滤掉,member 侧必须同样滤(2026-08-16)。
|
|
306
|
+
&& !isDerivedInsight(s.entry.metadata),
|
|
240
307
|
);
|
|
241
308
|
|
|
242
309
|
if (members.length === 0) continue;
|
|
@@ -284,11 +351,27 @@ export class ConsolidationEngine {
|
|
|
284
351
|
|
|
285
352
|
// Get similarity scores relative to canonical
|
|
286
353
|
const pairResults = await this.store.vectorSearch(
|
|
287
|
-
canonical.vector, memberEntries.length + 5, clusterThreshold, [scope],
|
|
354
|
+
canonical.vector, memberEntries.length + 5, clusterThreshold, [scope], scopeMatch,
|
|
288
355
|
);
|
|
289
356
|
const scoreMap = new Map<string, number>();
|
|
290
357
|
for (const r of pairResults) scoreMap.set(r.entry.id, r.score);
|
|
291
358
|
|
|
359
|
+
// 2026-08-14 批量化重构(dream 写爆库根治 + Bug-1 修复):
|
|
360
|
+
//
|
|
361
|
+
// 旧路径对每个 member 逐条写库 2-4 次(merge: createVersionGroup 2 + member 1
|
|
362
|
+
// + canonical 1;link: 2),一个 cluster 写 canonical N 遍 —— 且每次 patch 都
|
|
363
|
+
// 基于循环里从不刷新的旧内存串,后写的把先写的键抹掉:member1 经
|
|
364
|
+
// createVersionGroup 落库的 version_group 被随后的 sourceMemories 更新(旧串
|
|
365
|
+
// 起底)覆盖,member2 又因读到无组的旧串新造一个 groupId。最终态是 canonical
|
|
366
|
+
// 与全部 member 通常都不带组、sourceMemories 只剩最后一个 member(2 成员的
|
|
367
|
+
// cluster 同样全丢)。
|
|
368
|
+
//
|
|
369
|
+
// 新路径:先纯计算把 member 分进 merge/link 两组,cluster 末尾一次
|
|
370
|
+
// patchMetadataBatch —— patchFn 在锁内以库中最新 meta 为底座应用,canonical
|
|
371
|
+
// 只写一次(version-group 键 + sourceMemories 累积 + cluster_members 累积),
|
|
372
|
+
// groupId 在 cluster 级决策一次全组共享。每 cluster 1 个 commit(原 2-4×N)。
|
|
373
|
+
const mergeMembers: MemoryEntry[] = [];
|
|
374
|
+
const linkMembers: MemoryEntry[] = [];
|
|
292
375
|
for (const member of sorted.slice(1)) {
|
|
293
376
|
const sim = scoreMap.get(member.id) ?? clusterThreshold;
|
|
294
377
|
|
|
@@ -302,37 +385,78 @@ export class ConsolidationEngine {
|
|
|
302
385
|
);
|
|
303
386
|
|
|
304
387
|
if (sim >= mergeThreshold || jaccard >= tripleJaccardThreshold) {
|
|
305
|
-
|
|
306
|
-
await createVersionGroup(this.store, canonical, member, scope);
|
|
307
|
-
// C-1: Also mark the weaker entry as consolidated via evolution metadata
|
|
308
|
-
const consolidatedMeta = buildConsolidatedMetadata(member.metadata, canonical.id);
|
|
309
|
-
await this.store.update(member.id, { metadata: consolidatedMeta }, [scope]);
|
|
310
|
-
// Mark canonical as having source memories
|
|
311
|
-
const canonEvo = parseEvolution(canonical.metadata, canonical.timestamp);
|
|
312
|
-
if (!canonEvo.sourceMemories.includes(member.id)) {
|
|
313
|
-
const updatedCanon = patchEvolution(canonical.metadata, {
|
|
314
|
-
sourceMemories: [...canonEvo.sourceMemories, member.id],
|
|
315
|
-
});
|
|
316
|
-
await this.store.update(canonical.id, { metadata: updatedCanon }, [scope]);
|
|
317
|
-
}
|
|
388
|
+
mergeMembers.push(member);
|
|
318
389
|
mergedCount++;
|
|
319
390
|
if (sim < mergeThreshold) tripleEvidenceMerges++;
|
|
320
391
|
} else {
|
|
321
|
-
|
|
322
|
-
const memberMeta = parseMetadata(member);
|
|
323
|
-
memberMeta.clustered_with = canonical.id;
|
|
324
|
-
await this.store.update(member.id, { metadata: JSON.stringify(memberMeta) }, [scope]);
|
|
325
|
-
|
|
326
|
-
const canonMeta = parseMetadata(canonical);
|
|
327
|
-
if (!Array.isArray(canonMeta.cluster_members)) canonMeta.cluster_members = [];
|
|
328
|
-
if (!(canonMeta.cluster_members as string[]).includes(member.id)) {
|
|
329
|
-
(canonMeta.cluster_members as string[]).push(member.id);
|
|
330
|
-
}
|
|
331
|
-
await this.store.update(canonical.id, { metadata: JSON.stringify(canonMeta) }, [scope]);
|
|
392
|
+
linkMembers.push(member);
|
|
332
393
|
relationsAdded++;
|
|
333
394
|
}
|
|
334
395
|
}
|
|
335
396
|
|
|
397
|
+
if (mergeMembers.length > 0 || linkMembers.length > 0) {
|
|
398
|
+
// groupId 决策:canonical 已有组沿用,否则 cluster 级新生成一次。
|
|
399
|
+
// 决策基于本 cluster 开头刚 getById 的 canonical(毫秒级新鲜);组字段只有
|
|
400
|
+
// dream 自己写、且本 scope 有 per-scope dream 锁,锁外决策的竞态面趋零。
|
|
401
|
+
const groupId = mergeMembers.length > 0 ? resolveGroupId(canonical) : null;
|
|
402
|
+
const nowIso = new Date().toISOString();
|
|
403
|
+
const mergeIds = mergeMembers.map(m => m.id);
|
|
404
|
+
const linkIds = linkMembers.map(m => m.id);
|
|
405
|
+
|
|
406
|
+
const patches: Array<{
|
|
407
|
+
id: string;
|
|
408
|
+
patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown>;
|
|
409
|
+
}> = [];
|
|
410
|
+
|
|
411
|
+
patches.push({
|
|
412
|
+
id: canonical.id,
|
|
413
|
+
patchFn: (meta, entry) => {
|
|
414
|
+
if (groupId) computeVersionGroupPatch(meta, entry, groupId, true, nowIso);
|
|
415
|
+
if (mergeIds.length > 0) {
|
|
416
|
+
const evo = meta.evolution !== null && typeof meta.evolution === "object" && !Array.isArray(meta.evolution)
|
|
417
|
+
? (meta.evolution as Record<string, unknown>)
|
|
418
|
+
: {};
|
|
419
|
+
const existing = Array.isArray(evo.sourceMemories) ? (evo.sourceMemories as string[]) : [];
|
|
420
|
+
const accumulated = [...existing];
|
|
421
|
+
for (const id of mergeIds) if (!accumulated.includes(id)) accumulated.push(id);
|
|
422
|
+
patchEvolutionOnMeta(meta, { sourceMemories: accumulated });
|
|
423
|
+
}
|
|
424
|
+
if (linkIds.length > 0) {
|
|
425
|
+
const members = Array.isArray(meta.cluster_members) ? (meta.cluster_members as string[]) : [];
|
|
426
|
+
for (const id of linkIds) if (!members.includes(id)) members.push(id);
|
|
427
|
+
meta.cluster_members = members;
|
|
428
|
+
}
|
|
429
|
+
return meta;
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
for (const member of mergeMembers) {
|
|
434
|
+
patches.push({
|
|
435
|
+
id: member.id,
|
|
436
|
+
patchFn: (meta, entry) => {
|
|
437
|
+
// Tier 3.3 version coexistence + C-1 consolidated 标记,一次写齐
|
|
438
|
+
computeVersionGroupPatch(meta, entry, groupId as string, false, nowIso);
|
|
439
|
+
return patchEvolutionOnMeta(meta, {
|
|
440
|
+
status: "consolidated",
|
|
441
|
+
consolidatedInto: canonical.id,
|
|
442
|
+
});
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for (const member of linkMembers) {
|
|
448
|
+
patches.push({
|
|
449
|
+
id: member.id,
|
|
450
|
+
patchFn: (meta) => {
|
|
451
|
+
meta.clustered_with = canonical.id;
|
|
452
|
+
return meta;
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
await this.store.patchMetadataBatch(patches, [scope]);
|
|
458
|
+
}
|
|
459
|
+
|
|
336
460
|
// Conflict detection within cluster
|
|
337
461
|
for (let i = 0; i < memberEntries.length; i++) {
|
|
338
462
|
for (let j = i + 1; j < memberEntries.length; j++) {
|
|
@@ -373,6 +497,21 @@ export interface ClusterConsolidationResult {
|
|
|
373
497
|
* 「LLM 全程不可用」和「今天没料可炼」会长得一模一样。
|
|
374
498
|
*/
|
|
375
499
|
llmFailures: number;
|
|
500
|
+
/**
|
|
501
|
+
* 模型自己判定「这簇没有可提炼结论」的次数(insight + pattern 合计)。
|
|
502
|
+
*
|
|
503
|
+
* 2026-08-23 加。在此之前**这个数按构造只能是 0** —— 老 `generateL0` 没有弃权路径,
|
|
504
|
+
* 每个合格簇必写一条。所以它不是锦上添花的计量,而是新契约是否真在生效的直接读数:
|
|
505
|
+
* 长期为 0 意味着模型又在硬凑了。诊断实测同批簇上一个合格提示词弃权约 22.5%。
|
|
506
|
+
*/
|
|
507
|
+
synthesisAbstained: number;
|
|
508
|
+
/**
|
|
509
|
+
* 写库前校验拦下的次数(提示词回声 / evidence 不合法 / 长度越界 / JSON 解析不出)。
|
|
510
|
+
*
|
|
511
|
+
* 与 `llmFailures` 分开:那个是「没拿到回复」,这个是「拿到了但不合契约」。
|
|
512
|
+
* 老路径两者都不计 —— 于是 28 条系统提示词原文安静地进了库。
|
|
513
|
+
*/
|
|
514
|
+
synthesisRejected: number;
|
|
376
515
|
}
|
|
377
516
|
|
|
378
517
|
/**
|
|
@@ -389,7 +528,7 @@ export async function clusterAndConsolidate(params: {
|
|
|
389
528
|
entries: MemoryEntry[];
|
|
390
529
|
embedder: Pick<Embedder, "embedPassage">;
|
|
391
530
|
llm: LLMClient;
|
|
392
|
-
store: Pick<MemoryStorePort, "store" | "update">;
|
|
531
|
+
store: Pick<MemoryStorePort, "store" | "update" | "patchMetadataBatch">;
|
|
393
532
|
scope: string;
|
|
394
533
|
/** Minimum cluster size to trigger consolidation (default: 3) */
|
|
395
534
|
minClusterSize?: number;
|
|
@@ -418,6 +557,8 @@ export async function clusterAndConsolidate(params: {
|
|
|
418
557
|
patternsExtracted: 0,
|
|
419
558
|
entriesLinked: 0,
|
|
420
559
|
llmFailures: 0,
|
|
560
|
+
synthesisAbstained: 0,
|
|
561
|
+
synthesisRejected: 0,
|
|
421
562
|
};
|
|
422
563
|
|
|
423
564
|
// Filter to active entries with vectors
|
|
@@ -481,34 +622,11 @@ export async function clusterAndConsolidate(params: {
|
|
|
481
622
|
break;
|
|
482
623
|
}
|
|
483
624
|
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
// scope、cluster 最多,撞上一次超时的概率接近 1,且两次挂的位置不同(不是某对
|
|
490
|
-
// 内容有毒,是这一阶段本来就没有防护)。隔壁 llm-consolidation.ts:68 的同类调用
|
|
491
|
-
// 一直有 try/catch,本处缺的是同一层保护。
|
|
492
|
-
//
|
|
493
|
-
// 吸收成 null 而不是新开一条分支:generateL0 返回 null(熔断打开 / 空响应)本来
|
|
494
|
-
// 就是合法路径,下面 `if (!insight)` 已经按「零产出轮次」处理。失败另计 llmFailures,
|
|
495
|
-
// 避免吸收变静默。
|
|
496
|
-
const combinedText = cluster.map(m => m.text).join("\n---\n");
|
|
497
|
-
let insight: string | null;
|
|
498
|
-
try {
|
|
499
|
-
insight = await llm.generateL0(combinedText);
|
|
500
|
-
} catch (err) {
|
|
501
|
-
result.llmFailures++;
|
|
502
|
-
logWarn(`[WARN] cluster insight LLM 调用失败,本簇跳过: ${err instanceof Error ? err.message : String(err)}`);
|
|
503
|
-
insight = null;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (!insight) {
|
|
507
|
-
// CC-9: No insight produced — this is a zero-yield round
|
|
508
|
-
consecutiveLowYield++;
|
|
509
|
-
result.clustersConsolidated++;
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
625
|
+
// 每簇共用的三个量,2026-08-23 从 insight 分支里提上来。
|
|
626
|
+
// 提上来是 pattern 解耦的前置条件:以前 bestCat / maxImportance / sortedSourceIds
|
|
627
|
+
// 都算在 `if (!insight) continue` 之后,pattern 想独立跑就拿不到它们。
|
|
628
|
+
const memberTexts = cluster.map(m => m.text);
|
|
629
|
+
const sortedSourceIds = cluster.map(m => m.id).slice().sort();
|
|
512
630
|
|
|
513
631
|
// Determine category (majority vote from cluster members)
|
|
514
632
|
const catCounts = new Map<string, number>();
|
|
@@ -527,63 +645,122 @@ export async function clusterAndConsolidate(params: {
|
|
|
527
645
|
// Importance = max of cluster members
|
|
528
646
|
const maxImportance = Math.max(...cluster.map(m => m.importance));
|
|
529
647
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
648
|
+
/**
|
|
649
|
+
* 一次合成调用的统一入口:吸收异常、把三种结局(产出 / 弃权 / 校验不过)分别计数。
|
|
650
|
+
*
|
|
651
|
+
* 单个 cluster 的 LLM 失败不该炸掉整个 scope。llm-client 的 chat() 在 catch 里是
|
|
652
|
+
* `throw err`(记完熔断计数后原样抛),所以 AbortController 超时会一路冒到
|
|
653
|
+
* runDream 之外 —— 2026-08-09 memory 轮次连挂两次正是这条路径:memory 是最大
|
|
654
|
+
* scope、cluster 最多,撞上一次超时的概率接近 1,且两次挂的位置不同(不是某对
|
|
655
|
+
* 内容有毒,是这一阶段本来就没有防护)。
|
|
656
|
+
*
|
|
657
|
+
* 吸收成 `abstained` 而不是新开一条分支:调用方对「本簇这一侧没有产出」的处理
|
|
658
|
+
* 本来就一样。失败另计 llmFailures、校验不过另计 synthesisRejected,避免吸收变静默。
|
|
659
|
+
*/
|
|
660
|
+
const runSynthesis = async (
|
|
661
|
+
label: string,
|
|
662
|
+
call: () => Promise<SynthesisVerdict>,
|
|
663
|
+
): Promise<SynthesisVerdict> => {
|
|
664
|
+
let verdict: SynthesisVerdict;
|
|
665
|
+
try {
|
|
666
|
+
verdict = await call();
|
|
667
|
+
} catch (err) {
|
|
668
|
+
result.llmFailures++;
|
|
669
|
+
logWarn(`[WARN] ${label} LLM 调用失败,本簇跳过: ${err instanceof Error ? err.message : String(err)}`);
|
|
670
|
+
return { status: "abstained" };
|
|
671
|
+
}
|
|
672
|
+
if (verdict.status === "abstained") {
|
|
673
|
+
result.synthesisAbstained++;
|
|
674
|
+
} else if (verdict.status === "rejected") {
|
|
675
|
+
result.synthesisRejected++;
|
|
676
|
+
// 打出来的是**原因**不是「失败了」:`prompt-echo` 说明模型在回声提示词,
|
|
677
|
+
// `evidence-out-of-range` 说明它在编造编号,两者要采取的动作完全不同。
|
|
678
|
+
logWarn(`[WARN] ${label} 输出未通过写库前校验(${verdict.reason}),本簇该侧不写库`);
|
|
679
|
+
}
|
|
680
|
+
return verdict;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
/** 派生物统一的边界声明。见下方 buildDerivedBoundary 的注释。 */
|
|
684
|
+
const derivedBoundary = buildDerivedBoundary(bestCat);
|
|
685
|
+
|
|
686
|
+
let effectsThisCluster = 0;
|
|
687
|
+
|
|
688
|
+
// ---- cluster insight ---------------------------------------------------
|
|
689
|
+
const insightVerdict = await runSynthesis("cluster insight", () =>
|
|
690
|
+
llm.synthesizeClusterInsight(memberTexts));
|
|
691
|
+
|
|
692
|
+
if (insightVerdict.status === "ok") {
|
|
693
|
+
const { text: insight, evidence } = insightVerdict.output;
|
|
694
|
+
|
|
695
|
+
// Embed the insight text
|
|
696
|
+
const insightVector = await params.embedder.embedPassage(insight);
|
|
697
|
+
|
|
698
|
+
// Store the insight as a new memory. P0-2/P1-2: derive a deterministic id from the
|
|
699
|
+
// (sorted) source member ids so re-running dream/consolidate on the same cluster
|
|
700
|
+
// upserts the same insight row instead of appending a near-duplicate each run
|
|
701
|
+
// (the highest-frequency dup-id source). Cross-run stable because sourceIds are stable.
|
|
702
|
+
const insightEntry = await store.store({
|
|
703
|
+
id: deterministicId(scope, `cluster-insight:${sortedSourceIds.join(",")}`),
|
|
704
|
+
text: insight,
|
|
705
|
+
vector: insightVector,
|
|
706
|
+
category: bestCat,
|
|
707
|
+
scope,
|
|
708
|
+
importance: maxImportance,
|
|
709
|
+
metadata: JSON.stringify({
|
|
710
|
+
evolution: {
|
|
711
|
+
status: "active",
|
|
712
|
+
version: 1,
|
|
713
|
+
accessCount: 0,
|
|
714
|
+
lastAccessedAt: null,
|
|
715
|
+
supersededBy: null,
|
|
716
|
+
consolidatedInto: null,
|
|
717
|
+
sourceMemories: cluster.map(m => m.id),
|
|
718
|
+
validFrom: Date.now(),
|
|
719
|
+
validUntil: null,
|
|
720
|
+
},
|
|
721
|
+
cluster_insight: true,
|
|
722
|
+
boundary: derivedBoundary,
|
|
723
|
+
synthesis_contract: SYNTHESIS_CONTRACT_VERSION,
|
|
724
|
+
/** 模型自己指认的支撑源(sourceMemories 的子集)。空集进不来——校验挡在前面。 */
|
|
725
|
+
evidenceMemories: evidence.map(i => cluster[i - 1]?.id).filter((id): id is string => Boolean(id)),
|
|
726
|
+
}),
|
|
568
727
|
});
|
|
569
|
-
|
|
570
|
-
result.
|
|
728
|
+
|
|
729
|
+
result.insightsGenerated++;
|
|
730
|
+
effectsThisCluster++;
|
|
731
|
+
|
|
732
|
+
// Mark source memories as consolidated_into (but keep them active).
|
|
733
|
+
// 2026-08-14 批量化:N 次逐条 update → 1 次 patchMetadataBatch。patchFn 在锁内
|
|
734
|
+
// 以最新 meta 为底座,pattern 段随后的 contributedToPattern 批不会再把这里写的
|
|
735
|
+
// consolidatedInto 抹掉(旧路径的 Bug-2:两段都从同一个旧内存串起底 patch)。
|
|
736
|
+
// 本批独立于 pattern 批提交:pattern 路径失败时 insight 的源链接已落库(保持
|
|
737
|
+
// 旧异常语义 —— insight 源链接从来不等 pattern)。
|
|
738
|
+
result.entriesLinked += await store.patchMetadataBatch(
|
|
739
|
+
cluster.map(member => ({
|
|
740
|
+
id: member.id,
|
|
741
|
+
patchFn: (meta: Record<string, unknown>) => patchEvolutionOnMeta(meta, {
|
|
742
|
+
consolidatedInto: insightEntry.id,
|
|
743
|
+
// Keep status active — still individually searchable
|
|
744
|
+
}),
|
|
745
|
+
})),
|
|
746
|
+
[scope],
|
|
747
|
+
);
|
|
571
748
|
}
|
|
572
749
|
|
|
573
750
|
result.clustersConsolidated++;
|
|
574
751
|
|
|
575
|
-
// HP-5: Cross-memory pattern extraction
|
|
752
|
+
// ---- HP-5: Cross-memory pattern extraction ------------------------------
|
|
753
|
+
//
|
|
754
|
+
// 2026-08-23 解耦:这一段不再挂在 insight 成功之后。原路径是
|
|
755
|
+
// `if (!insight) { ...; continue; }`,于是 insight 一旦弃权或失败,pattern 抽取
|
|
756
|
+
// 根本不执行 —— 两个目标概念不同的合成被绑成一条命。改后两者独立失败、独立弃权:
|
|
757
|
+
// 「这簇没有单条结论」和「这簇没有跨条目模式」是两个判断,没有理由互相代表。
|
|
576
758
|
if (extractPatterns && cluster.length >= 3) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
result.llmFailures++;
|
|
583
|
-
logWarn(`[WARN] pattern 抽取 LLM 调用失败,本簇跳过: ${err instanceof Error ? err.message : String(err)}`);
|
|
584
|
-
patternText = null;
|
|
585
|
-
}
|
|
586
|
-
if (patternText) {
|
|
759
|
+
const patternVerdict = await runSynthesis("cross-memory pattern", () =>
|
|
760
|
+
llm.synthesizeClusterPattern(memberTexts));
|
|
761
|
+
|
|
762
|
+
if (patternVerdict.status === "ok") {
|
|
763
|
+
const { text: patternText, evidence } = patternVerdict.output;
|
|
587
764
|
// 与同函数上方的 cluster insight 取同一个值:派生物继承源里最高的 importance,
|
|
588
765
|
// 不额外加成。原本这里是 maxImportance + 0.1,会让 LLM 抽出的 pattern 比它的
|
|
589
766
|
// 任何一条源记忆都重要——而 importance >= 0.95 在本仓库里是「人工 pin」的专用
|
|
@@ -615,23 +792,38 @@ export async function clusterAndConsolidate(params: {
|
|
|
615
792
|
},
|
|
616
793
|
cross_memory_pattern: true,
|
|
617
794
|
source_cluster_size: cluster.length,
|
|
795
|
+
// pattern 恒为 patterns 类目,边界单独构造,不跟 insight 的多数票类目走。
|
|
796
|
+
boundary: buildDerivedBoundary("patterns"),
|
|
797
|
+
synthesis_contract: SYNTHESIS_CONTRACT_VERSION,
|
|
798
|
+
evidenceMemories: evidence.map(i => cluster[i - 1]?.id).filter((id): id is string => Boolean(id)),
|
|
618
799
|
}),
|
|
619
800
|
});
|
|
620
801
|
|
|
621
|
-
// Mark source memories with contributedToPattern
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
802
|
+
// Mark source memories with contributedToPattern(同上:一批提交,锁内最新
|
|
803
|
+
// meta 起底 —— 与 insight 批叠加而非互抹)
|
|
804
|
+
await store.patchMetadataBatch(
|
|
805
|
+
cluster.map(member => ({
|
|
806
|
+
id: member.id,
|
|
807
|
+
patchFn: (meta: Record<string, unknown>) => patchEvolutionOnMeta(meta, {
|
|
808
|
+
contributedToPattern: patternEntry.id,
|
|
809
|
+
}),
|
|
810
|
+
})),
|
|
811
|
+
[scope],
|
|
812
|
+
);
|
|
628
813
|
|
|
629
814
|
result.patternsExtracted++;
|
|
815
|
+
effectsThisCluster++;
|
|
630
816
|
}
|
|
631
817
|
}
|
|
632
818
|
|
|
633
|
-
// CC-9:
|
|
634
|
-
|
|
819
|
+
// CC-9: 本簇两侧都没写出东西才算零产出轮次。
|
|
820
|
+
// 判据从「insight 有没有」换成「这一簇有没有任何副作用」—— 解耦之后 insight 弃权
|
|
821
|
+
// 而 pattern 产出是合法结局,把它算成零产出会让早停提前触发、白扔掉后面的簇。
|
|
822
|
+
if (effectsThisCluster === 0) {
|
|
823
|
+
consecutiveLowYield++;
|
|
824
|
+
} else {
|
|
825
|
+
consecutiveLowYield = 0;
|
|
826
|
+
}
|
|
635
827
|
}
|
|
636
828
|
|
|
637
829
|
return result;
|
|
@@ -666,8 +858,10 @@ export function formatConsolidationResult(result: ConsolidationResult): string {
|
|
|
666
858
|
* captured from the outside world.
|
|
667
859
|
*
|
|
668
860
|
* Single source of truth for the two metadata flags: retrieval uses it to
|
|
669
|
-
* collapse a derivative with its sources,
|
|
670
|
-
* derivatives from being re-consolidated as if they were raw material
|
|
861
|
+
* collapse a derivative with its sources, the dream gather uses it to keep
|
|
862
|
+
* derivatives from being re-consolidated as if they were raw material, and
|
|
863
|
+
* (2026-08-16) 3a uses it on both the seed side and the member side — a summary
|
|
864
|
+
* must never be eligible to become the canonical row over its own source.
|
|
671
865
|
*/
|
|
672
866
|
export function isDerivedInsight(metadata: string | undefined): boolean {
|
|
673
867
|
if (!metadata) return false;
|
package/src/data-checkup.ts
CHANGED
|
@@ -130,7 +130,7 @@ function checkConflictBacklog(openConflictCount: number): CheckResult {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/** Check 5: Version group integrity — members should reference the same group ID. */
|
|
133
|
-
function checkVersionGroups(entries: MemoryEntry
|
|
133
|
+
function checkVersionGroups(entries: Array<Pick<MemoryEntry, "id" | "metadata">>): CheckResult {
|
|
134
134
|
const groups = new Map<string, { ids: string[]; ranks: number[] }>();
|
|
135
135
|
|
|
136
136
|
for (const e of entries) {
|
|
@@ -292,7 +292,7 @@ function checkUsageDistribution(entries: MemoryEntry[]): CheckResult {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
export interface CheckupDeps {
|
|
295
|
-
store: Pick<MemoryStore, "list" | "stats" | "getVectors">;
|
|
295
|
+
store: Pick<MemoryStore, "list" | "listPage" | "stats" | "getVectors">;
|
|
296
296
|
openConflictCount: number;
|
|
297
297
|
/** Optional override path for source-heartbeat.json (used in tests). */
|
|
298
298
|
heartbeatPath?: string;
|
|
@@ -310,13 +310,27 @@ export async function runDataCheckup(deps: CheckupDeps): Promise<CheckupReport>
|
|
|
310
310
|
// 截断披露:库总量 > 已扫,说明只体检了最近 SCAN_LIMIT 条。
|
|
311
311
|
const totalAvailable = (await deps.store.stats()).totalCount;
|
|
312
312
|
|
|
313
|
+
// Version-group membership must be checked against the whole corpus. Looking only
|
|
314
|
+
// at the newest SCAN_LIMIT rows turns a healthy pair into a fake singleton whenever
|
|
315
|
+
// its other member falls outside the sample. Keep this second pass lightweight by
|
|
316
|
+
// retaining only id + metadata and never loading vectors.
|
|
317
|
+
const VERSION_GROUP_PAGE_SIZE = 5000;
|
|
318
|
+
const versionEntries: Array<Pick<MemoryEntry, "id" | "metadata">> = [];
|
|
319
|
+
for (let offset = 0; ; offset += VERSION_GROUP_PAGE_SIZE) {
|
|
320
|
+
const page = await deps.store.listPage({ limit: VERSION_GROUP_PAGE_SIZE, offset });
|
|
321
|
+
for (const entry of page) {
|
|
322
|
+
versionEntries.push({ id: entry.id, metadata: entry.metadata });
|
|
323
|
+
}
|
|
324
|
+
if (page.length < VERSION_GROUP_PAGE_SIZE) break;
|
|
325
|
+
}
|
|
326
|
+
|
|
313
327
|
return {
|
|
314
328
|
checks: [
|
|
315
329
|
checkVectorDimensions(entries),
|
|
316
330
|
checkOrphanMemories(entries),
|
|
317
331
|
checkTierDistribution(entries),
|
|
318
332
|
checkConflictBacklog(deps.openConflictCount),
|
|
319
|
-
checkVersionGroups(
|
|
333
|
+
checkVersionGroups(versionEntries),
|
|
320
334
|
checkSourceHealth(deps.heartbeatPath),
|
|
321
335
|
checkInterferenceDensity(entries),
|
|
322
336
|
checkUsageDistribution(entries),
|