recallnest 2.6.0 → 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 +7 -5
- 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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall Ledger — 进程内的「这次会话读到过哪些记忆」账本。
|
|
3
|
+
*
|
|
4
|
+
* 存在的理由(P0 join key):价值回溯要的是 `(memory_id, outcome)` 配对,而库里
|
|
5
|
+
* 一边有结果没参与者(workflow observation 不记 memory),一边有参与者没结果
|
|
6
|
+
* (access-tracker 记命中不记任务成败)。这个模块补的就是中间那一段:
|
|
7
|
+
* 检索命中时记一笔,`workflow_observe` 上报时把窗口内命中的 id 一起写进 observation。
|
|
8
|
+
*
|
|
9
|
+
* 为什么是**进程级**而不是挂在 AccessTracker 实例上:
|
|
10
|
+
* `createComponentResolver`(runtime-config.ts:226)按 profileName 缓存 components,
|
|
11
|
+
* 每个 profile 各有一个 AccessTracker → 各自随机一个 readerId。检索走 profile A、
|
|
12
|
+
* 上报走 default 时,两边 readerId 对不上,join 直接失效。而 access-tracker.ts:63
|
|
13
|
+
* 的注释本来就写着「one stdio MCP server process ≈ one CC session」——那句话描述的
|
|
14
|
+
* 语义单位是**进程**,实现却落在实例上。这里把它扶正,顺带修掉多 profile 时
|
|
15
|
+
* readerIds/distinctReaderCount 被同一个会话灌进多个假 reader 的虚高。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 本进程(≈ 本次 CC 会话)的读者身份。所有 AccessTracker 默认共享它。 */
|
|
19
|
+
export const PROCESS_READER_ID = `r-${crypto.randomUUID().slice(0, 8)}`;
|
|
20
|
+
|
|
21
|
+
/** 默认回看窗口:一次任务从检索到上报 outcome 的常见跨度。 */
|
|
22
|
+
export const DEFAULT_RECALL_WINDOW_MS = 30 * 60_000;
|
|
23
|
+
|
|
24
|
+
/** 一条 observation 最多带多少个 memory id。防止长会话把整个 top-k 历史灌进 observation。 */
|
|
25
|
+
export const RECALLED_IDS_CAP = 64;
|
|
26
|
+
|
|
27
|
+
/** 账本自身的常驻上限;超出时按最后命中时间淘汰最老的。 */
|
|
28
|
+
const LEDGER_CAP = 512;
|
|
29
|
+
|
|
30
|
+
/** memoryId → 最后一次被检索返回的时间戳(ms)。 */
|
|
31
|
+
const hits = new Map<string, number>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 记录这批 id 在本进程被检索返回过。
|
|
35
|
+
*
|
|
36
|
+
* 注意调用点在 novelty/cooldown gate **之前**:那两道闸问的是「值不值得强化衰减半衰期」,
|
|
37
|
+
* 这里问的是「agent 有没有真的看到这条」。同一批 id 两个问题的答案可以不同,
|
|
38
|
+
* 用强化闸去过滤 join 参与者会系统性漏掉最常被读到的那些条目。
|
|
39
|
+
*/
|
|
40
|
+
export function recordRecallHits(ids: readonly string[], now = Date.now()): void {
|
|
41
|
+
for (const id of ids) {
|
|
42
|
+
if (typeof id === "string" && id.length > 0) hits.set(id, now);
|
|
43
|
+
}
|
|
44
|
+
if (hits.size > LEDGER_CAP) {
|
|
45
|
+
const sorted = [...hits.entries()].sort((a, b) => b[1] - a[1]);
|
|
46
|
+
hits.clear();
|
|
47
|
+
for (const [id, ts] of sorted.slice(0, LEDGER_CAP)) hits.set(id, ts);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 取窗口内被命中过的 memory id,最近命中的排前面。
|
|
53
|
+
* windowMs <= 0 表示不限时间(仍受 cap 约束)。
|
|
54
|
+
*/
|
|
55
|
+
export function recentRecallHits(
|
|
56
|
+
windowMs = DEFAULT_RECALL_WINDOW_MS,
|
|
57
|
+
cap = RECALLED_IDS_CAP,
|
|
58
|
+
now = Date.now(),
|
|
59
|
+
): string[] {
|
|
60
|
+
const cutoff = windowMs > 0 ? now - windowMs : Number.NEGATIVE_INFINITY;
|
|
61
|
+
return [...hits.entries()]
|
|
62
|
+
.filter(([, ts]) => ts >= cutoff)
|
|
63
|
+
.sort((a, b) => b[1] - a[1])
|
|
64
|
+
.slice(0, Math.max(0, cap))
|
|
65
|
+
.map(([id]) => id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 当前账本条目数(诊断/测试用)。 */
|
|
69
|
+
export function recallLedgerSize(): number {
|
|
70
|
+
return hits.size;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 清空账本。测试隔离用;生产路径不调用。 */
|
|
74
|
+
export function resetRecallLedger(): void {
|
|
75
|
+
hits.clear();
|
|
76
|
+
}
|
package/src/retriever.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { expandQueryWithAliases } from "./aliases.js";
|
|
|
13
13
|
import { expandQuery } from "./query-expander.js";
|
|
14
14
|
import { detectLang, tokenizeFts } from "./language-hook.js";
|
|
15
15
|
import { type AccessTracker, computeHotnessScore, parseAccessMetadata } from "./access-tracker.js";
|
|
16
|
+
import { readUtility } from "./memory-utility.js";
|
|
16
17
|
import { weibullDecay, resolveTier, isDecayExempt, adjustHalfLifeForEmotion, computeArousalBoost } from "./decay-engine.js";
|
|
17
18
|
import { logInfo, logWarn } from "./stderr-log.js";
|
|
18
19
|
import { extractBoundaryMetadata, isDurableMemoryScope, isTranscriptScope } from "./memory-boundaries.js";
|
|
@@ -39,7 +40,7 @@ import { isKGModeEnabled } from "./kg-extractor.js";
|
|
|
39
40
|
import { detectEntities } from "./query-entity-detector.js";
|
|
40
41
|
import { buildGraph, pprTraverse } from "./ppr-traversal.js";
|
|
41
42
|
import { logInfo } from "./stderr-log.js";
|
|
42
|
-
import type
|
|
43
|
+
import { AUDIT_RETRIEVED_CAP, type AuditLogger } from "./audit-log.js";
|
|
43
44
|
import { parseEmotion, isEmotionScoringEnabled } from "./memory-schema.js";
|
|
44
45
|
import { parseNarrative, isNarrativeModeEnabled } from "./narrative-schema.js";
|
|
45
46
|
import type { EmotionMetadata } from "./memory-schema.js";
|
|
@@ -111,6 +112,19 @@ export interface RetrievalConfig {
|
|
|
111
112
|
* 0 = disabled (default), 0.15 = recommended.
|
|
112
113
|
*/
|
|
113
114
|
hotnessWeight: number;
|
|
115
|
+
/**
|
|
116
|
+
* Utility weight — 价值回溯(memory-utility.ts)算出的效用值对最终分的影响强度。
|
|
117
|
+
* Formula: score *= 1 + utilityWeight * utility,utility ∈ [-0.8, 0.8]。
|
|
118
|
+
* 与 hotness/frequency 的区别在信号来源:那两个数「被读过多少次」,
|
|
119
|
+
* 这个数「被用到的那些任务成没成」——前者是热度,后者是因果贡献。
|
|
120
|
+
* 没有 utility 的条目分数原样不动(bonus-only 冷启动,不惩罚没数据的)。
|
|
121
|
+
*
|
|
122
|
+
* 0 = disabled(默认)。**默认关是有意的影子期**:目前生产库里带 join key 的
|
|
123
|
+
* observation 从 0 开始积累,样本不足时开启只会放大噪声。
|
|
124
|
+
* 升级条件写在 open-loops「MemOS 价值回溯」条目里,不在这行注释里——
|
|
125
|
+
* 注释不会有人定期回来读,那个条目每窗口都会被推到眼前。
|
|
126
|
+
*/
|
|
127
|
+
utilityWeight: number;
|
|
114
128
|
/**
|
|
115
129
|
* Per-category score thresholds. When a result's category matches a key,
|
|
116
130
|
* that threshold is used instead of hardMinScore. Categories not listed
|
|
@@ -234,6 +248,15 @@ export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
|
|
|
234
248
|
bm25Weight: 0.3,
|
|
235
249
|
minScore: 0.3,
|
|
236
250
|
rerank: "cross-encoder",
|
|
251
|
+
// ⚠️ 2026-08-23 查实:**这里超过 20 的部分从来没生效过**。两条候选腿最终都落到
|
|
252
|
+
// store.ts 的 `clampInt(limit, 1, 20)`(vectorSearch:588 / bm25Search:643),
|
|
253
|
+
// 路径是 retriever.ts:1109 算出 ceil(candidatePoolSize × multiplier) →
|
|
254
|
+
// runVectorSearch/runBM25Search(:1341/:1373) → store.*Search(limit) → 被压成 20。
|
|
255
|
+
// 那个 clamp 从初始提交 c10607a(2026-03-03)就在,而 07-16 调参(9374a4f)只改了本文件、
|
|
256
|
+
// 没碰 store.ts —— 所以这个 30 从落地当天就是空转。retrieval-profiles.ts 的 24/26/28 同理。
|
|
257
|
+
// **不要顺手去改那个 clamp**:动它等于扩候选池,而扩池方案已于 2026-08-22 跑 shadow 后被
|
|
258
|
+
// 实测否决(RecallNest memory:pivot `rejected-recall-widen-candidate-pool`,主犯认定订正为
|
|
259
|
+
// applyLayerAdmission 79.4% 而非 hard_min 13.5%)。要推翻它得先拿出那条否决记录要求的新证据。
|
|
237
260
|
candidatePoolSize: 30,
|
|
238
261
|
sourceDiversity: 1,
|
|
239
262
|
recencyHalfLifeDays: 14,
|
|
@@ -254,6 +277,7 @@ export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
|
|
|
254
277
|
hardMinScore: 0.35,
|
|
255
278
|
timeDecayHalfLifeDays: 60,
|
|
256
279
|
hotnessWeight: 0,
|
|
280
|
+
utilityWeight: 0,
|
|
257
281
|
};
|
|
258
282
|
|
|
259
283
|
/**
|
|
@@ -894,12 +918,28 @@ export class MemoryRetriever {
|
|
|
894
918
|
|
|
895
919
|
// F-1: Audit log — record retrieve operation (non-blocking, silent on failure)
|
|
896
920
|
try {
|
|
897
|
-
this.auditLogger
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
921
|
+
if (this.auditLogger) {
|
|
922
|
+
const retrieved = results.slice(0, AUDIT_RETRIEVED_CAP).map((r) => {
|
|
923
|
+
const evo = parseEvolution(r.entry.metadata, r.entry.timestamp);
|
|
924
|
+
const boundary = extractBoundaryMetadata(r.entry.metadata);
|
|
925
|
+
return {
|
|
926
|
+
id: r.entry.id.slice(0, 8),
|
|
927
|
+
rev: evo.version,
|
|
928
|
+
st: evo.status,
|
|
929
|
+
...(boundary?.layer ? { ly: boundary.layer } : {}),
|
|
930
|
+
...(boundary?.authority ? { au: boundary.authority } : {}),
|
|
931
|
+
};
|
|
932
|
+
});
|
|
933
|
+
this.auditLogger.log({
|
|
934
|
+
operation: "retrieve",
|
|
935
|
+
scope: context.scopeFilter?.[0],
|
|
936
|
+
actor: context.source || "manual",
|
|
937
|
+
details: `query="${context.query.slice(0, 80)}" hits=${results.length}`,
|
|
938
|
+
...(retrieved.length > 0 ? { retrieved } : {}),
|
|
939
|
+
// Only when the list is short of the truth — a truncated list must look truncated.
|
|
940
|
+
...(results.length > retrieved.length ? { retrievedTotal: results.length } : {}),
|
|
941
|
+
});
|
|
942
|
+
}
|
|
903
943
|
} catch {
|
|
904
944
|
// Audit must never block retrieval
|
|
905
945
|
}
|
|
@@ -1212,8 +1252,14 @@ export class MemoryRetriever {
|
|
|
1212
1252
|
const weighted = this.applyImportanceWeight(boosted);
|
|
1213
1253
|
trace?.endStage(weighted.length, weighted.map(r => r.score));
|
|
1214
1254
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1255
|
+
// 价值回溯:紧跟 importance 之后,因为两者回答的是同一类问题(这条本身有多值得),
|
|
1256
|
+
// 只是 importance 说的是「存的时候认为多重要」,utility 说的是「用起来结果怎么样」。
|
|
1257
|
+
trace?.startStage("utility_weight", weighted.length);
|
|
1258
|
+
const utilityWeighted = this.applyUtilityWeight(weighted);
|
|
1259
|
+
trace?.endStage(utilityWeighted.length, utilityWeighted.map(r => r.score));
|
|
1260
|
+
|
|
1261
|
+
trace?.startStage("confidence_weight", utilityWeighted.length);
|
|
1262
|
+
const confidenceWeighted = this.applyConfidence(utilityWeighted);
|
|
1217
1263
|
trace?.endStage(confidenceWeighted.length, confidenceWeighted.map(r => r.score));
|
|
1218
1264
|
|
|
1219
1265
|
let afterEmotionVector = confidenceWeighted;
|
|
@@ -2163,6 +2209,33 @@ export class MemoryRetriever {
|
|
|
2163
2209
|
*
|
|
2164
2210
|
* No-op when hotnessWeight is 0 or AccessTracker is absent.
|
|
2165
2211
|
*/
|
|
2212
|
+
/**
|
|
2213
|
+
* 价值回溯加权:被用在成功任务里的记忆上浮,被用在失败任务里的下沉。
|
|
2214
|
+
*
|
|
2215
|
+
* 这是 utility 唯一的消费点——它**只喂排序**,不进 tier、不进 decay 豁免、
|
|
2216
|
+
* 更不写回 importance(那一列是阈值语义,≥0.95 会触发永久免衰减,
|
|
2217
|
+
* 详见 memory-utility.ts 抬头的三条理由)。
|
|
2218
|
+
*
|
|
2219
|
+
* No-op when utilityWeight is 0(默认)或条目没有 utility。
|
|
2220
|
+
*/
|
|
2221
|
+
private applyUtilityWeight(results: RetrievalResult[]): RetrievalResult[] {
|
|
2222
|
+
const raw = this.config.utilityWeight;
|
|
2223
|
+
const weight = Math.min(1, Math.max(0, Number.isFinite(raw) ? raw : 0));
|
|
2224
|
+
if (weight <= 0) return results;
|
|
2225
|
+
|
|
2226
|
+
const weighted = results.map((r) => {
|
|
2227
|
+
const utility = readUtility(r.entry.metadata);
|
|
2228
|
+
if (utility === null) return r; // 没数据 ≠ 中性差评,原样放行
|
|
2229
|
+
const factor = 1 + weight * utility;
|
|
2230
|
+
return {
|
|
2231
|
+
...r,
|
|
2232
|
+
score: clamp01(r.score * factor, 0),
|
|
2233
|
+
};
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
return weighted.sort((a, b) => b.score - a.score);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2166
2239
|
private applyHotnessBlend(results: RetrievalResult[]): RetrievalResult[] {
|
|
2167
2240
|
const raw = this.config.hotnessWeight;
|
|
2168
2241
|
const alpha = Math.min(1, Math.max(0, Number.isFinite(raw) ? raw : 0));
|
package/src/scope-policy.ts
CHANGED
|
@@ -38,8 +38,29 @@ export function resolveSessionScope(sessionId?: string): string | undefined {
|
|
|
38
38
|
return normalized ? `session:${normalized}` : undefined;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* scope 过滤的匹配模式(2026-08-16 加,互审 C1/K3 双方独立命中)。
|
|
43
|
+
*
|
|
44
|
+
* - `family`(默认,历史行为):**由 scope 字符串里有没有冒号隐式决定** —— 含冒号走精确,
|
|
45
|
+
* 不含冒号走前缀。`search_memory` 等靠它做 `project` → `project:*` 的家族匹配。
|
|
46
|
+
* - `exact`:调用方明说"我给的就是一个具体 scope 名",一律精确匹配。
|
|
47
|
+
*
|
|
48
|
+
* 为什么要显式模式:隐式规则在调用方**拿真实 scope 名当参数**时会失效——真实 scope 名
|
|
49
|
+
* 里恰好没有冒号(`memory` / `cc` / `global`),于是被当成 family selector。
|
|
50
|
+
* 实测后果:weekly `dream --scope memory` 借前缀把 `memory:pivot`(手写提炼层)
|
|
51
|
+
* 一起卷进同一轮聚类,14 条 insight 的来源横跨两个 scope(2026-08-16 查库坐实)。
|
|
52
|
+
* 修法不是改默认语义(会伤 search_memory),是让 dream 这类"喂具体 scope 名"的
|
|
53
|
+
* 入口显式声明 exact。
|
|
54
|
+
*/
|
|
55
|
+
export type ScopeMatchMode = "exact" | "family";
|
|
56
|
+
|
|
57
|
+
export function matchesScopeFilter(
|
|
58
|
+
rowScope: string,
|
|
59
|
+
scopeFilter?: string[],
|
|
60
|
+
mode: ScopeMatchMode = "family",
|
|
61
|
+
): boolean {
|
|
42
62
|
if (!scopeFilter || scopeFilter.length === 0) return true;
|
|
63
|
+
if (mode === "exact") return scopeFilter.some((scope) => rowScope === scope);
|
|
43
64
|
return scopeFilter.some((scope) => scope.includes(":") ? rowScope === scope : rowScope.startsWith(scope));
|
|
44
65
|
}
|
|
45
66
|
|
package/src/session-output.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResumeContextResponse, SessionCheckpointRecord } from "./session-schema.js";
|
|
2
2
|
import { cleanText } from "./context-composer-text.js";
|
|
3
|
+
import { formatIsoAgeLabel } from "./age-label.js";
|
|
3
4
|
|
|
4
5
|
function listBlock(label: string, items: string[]): string[] {
|
|
5
6
|
if (items.length === 0) return [];
|
|
@@ -32,7 +33,7 @@ export function formatCheckpointSaved(record: SessionCheckpointRecord): string {
|
|
|
32
33
|
`Checkpoint ${record.checkpointId.slice(0, 8)}`,
|
|
33
34
|
`Session: ${record.sessionId}`,
|
|
34
35
|
`Scope: ${record.resolvedScope}`,
|
|
35
|
-
`Updated: ${record.updatedAt}`,
|
|
36
|
+
`Updated: ${record.updatedAt} (${formatIsoAgeLabel(record.updatedAt)})`,
|
|
36
37
|
`Summary: ${record.summary}`,
|
|
37
38
|
...listBlock("Decisions", record.decisions),
|
|
38
39
|
...listBlock("Open loops", record.openLoops),
|
|
@@ -48,7 +49,7 @@ export function formatCheckpointSummary(record: SessionCheckpointRecord | null):
|
|
|
48
49
|
`Latest checkpoint`,
|
|
49
50
|
`Session: ${record.sessionId}`,
|
|
50
51
|
`Scope: ${record.resolvedScope}`,
|
|
51
|
-
`Updated: ${record.updatedAt}`,
|
|
52
|
+
`Updated: ${record.updatedAt} (${formatIsoAgeLabel(record.updatedAt)})`,
|
|
52
53
|
`Summary: ${record.summary}`,
|
|
53
54
|
];
|
|
54
55
|
if (record.nextActions.length > 0) {
|
|
@@ -123,7 +124,7 @@ export function formatResumeContext(response: ResumeContextResponse): string {
|
|
|
123
124
|
if (response.latestCheckpoint.resolvedScope) {
|
|
124
125
|
lines.push(`Scope: ${response.latestCheckpoint.resolvedScope}`);
|
|
125
126
|
}
|
|
126
|
-
lines.push(`Updated: ${response.latestCheckpoint.updatedAt}`);
|
|
127
|
+
lines.push(`Updated: ${response.latestCheckpoint.updatedAt} (${formatIsoAgeLabel(response.latestCheckpoint.updatedAt)})`);
|
|
127
128
|
lines.push(`Summary: ${response.latestCheckpoint.summary}`);
|
|
128
129
|
}
|
|
129
130
|
|
package/src/skill-promotion.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface PromotionCandidate {
|
|
|
30
30
|
verification?: VerifyResult;
|
|
31
31
|
/** Read-signal evidence aggregated from source entries(Artel 读驱动升华:被真实读过的候选提权)。 */
|
|
32
32
|
readEvidence?: { totalAccess: number; distinctReaders: number };
|
|
33
|
+
/** 这个候选覆盖了几个不同来源(episode)。1 = 同一次经历的重复,不是跨任务规律。 */
|
|
34
|
+
distinctSources?: number;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export interface PromotionScanResult {
|
|
@@ -40,6 +42,10 @@ export interface PromotionScanResult {
|
|
|
40
42
|
truncatedCases: number;
|
|
41
43
|
/** 向量回填失败(库中无向量)而被跳过的条目数。 */
|
|
42
44
|
vectorlessSkipped: number;
|
|
45
|
+
/** No-silent-caps 披露:条数够、但来源不够(同一个坑重复)而被拒的簇数。 */
|
|
46
|
+
rejectedSingleSource: number;
|
|
47
|
+
/** No-silent-caps 披露:拿不到来源指针、跨来源判据弃权放行的簇数。 */
|
|
48
|
+
abstainedUnknownSource: number;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface PromotionConfig {
|
|
@@ -58,6 +64,19 @@ export interface PromotionConfig {
|
|
|
58
64
|
/** pattern_to_skill 通道最多探测的 structured patterns 数(每个一次 vectorSearch,
|
|
59
65
|
* default: 1000,按 recency 优先)。 */
|
|
60
66
|
maxPatternProbes: number;
|
|
67
|
+
/**
|
|
68
|
+
* 一个簇要来自几个**不同来源**才算规律(default: 2)。
|
|
69
|
+
*
|
|
70
|
+
* 借鉴 MemOS 的 L1→L2 判据:它数的是「几个不同 episode」,而这里原本数的是
|
|
71
|
+
* 「几条相似 case」——差的是维度不是数字。**同一个坑踩三次**在旧口径下是 3 条
|
|
72
|
+
* 相似 case、会被识别成一条规律;在 MemOS 那儿是 1 个 episode 重复,够不上 L2。
|
|
73
|
+
* 而反复撞同一个坑本该走 failure-burst 生成「Avoid」反模式,跟「这类子任务该怎么做」
|
|
74
|
+
* 是相反的两种东西,现在它们混在同一条通道里。
|
|
75
|
+
*
|
|
76
|
+
* 审计原文的要害:「两个完全不同的任务遇到同类子问题,才诱导出一条可迁移 L2」——
|
|
77
|
+
* 最小值是 2,但要害在「跨任务」不在「2」。设 1 = 关闭该判据(旧行为,对照用)。
|
|
78
|
+
*/
|
|
79
|
+
minDistinctSources: number;
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
const DEFAULT_CONFIG: PromotionConfig = {
|
|
@@ -68,6 +87,7 @@ const DEFAULT_CONFIG: PromotionConfig = {
|
|
|
68
87
|
maxBucketSize: 2_000,
|
|
69
88
|
pageSize: 1_000,
|
|
70
89
|
maxPatternProbes: 1_000,
|
|
90
|
+
minDistinctSources: 2,
|
|
71
91
|
};
|
|
72
92
|
|
|
73
93
|
// ---------------------------------------------------------------------------
|
|
@@ -268,6 +288,98 @@ export function greedyCluster(
|
|
|
268
288
|
return clusters;
|
|
269
289
|
}
|
|
270
290
|
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Source identity (MemOS-style episode counting)
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 一条记录来自「哪一次」——跨来源去重的 key。
|
|
297
|
+
*
|
|
298
|
+
* 三级取值,越靠前越可靠:
|
|
299
|
+
* 1. `metadata.evidenceMemories` —— dream 合成产物指向的簇内真实源(synthesis-contract
|
|
300
|
+
* 2026-08-23 起写入,已验证互不重复且无悬空)。同一批源出来的两条派生物
|
|
301
|
+
* 签名相同 → 算一个来源,不是两个。
|
|
302
|
+
* 2. `tags` 里的 `src:<sessionId 前 8 位>` —— shared-behaviors §5.3 要求 pivot 写入必带。
|
|
303
|
+
* 2026-08-23 实测:`memory:pivot` 池 1699 条覆盖率 99.8%,其中 cases 260 条 100% 覆盖。
|
|
304
|
+
* ⚠️ 但全库只有 2.3%、`category=cases` 全库仅 1.2% —— **这个判据只在 pivot 池成立**,
|
|
305
|
+
* 别拿去当全库口径。
|
|
306
|
+
* 3. 兜底 `day:<scope>:<YYYY-MM-DD>` —— 没有来源指针时退到「同一天同一 scope 算一次」。
|
|
307
|
+
* 粗,但比把同一次经历数成 N 次强。
|
|
308
|
+
*/
|
|
309
|
+
export function extractSourceKey(entry: Pick<MemoryEntry, "metadata" | "scope" | "timestamp">): string {
|
|
310
|
+
let meta: Record<string, unknown> = {};
|
|
311
|
+
try {
|
|
312
|
+
const parsed: unknown = JSON.parse(entry.metadata || "{}");
|
|
313
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
314
|
+
meta = parsed as Record<string, unknown>;
|
|
315
|
+
}
|
|
316
|
+
} catch { /* fall through to day bucket */ }
|
|
317
|
+
|
|
318
|
+
const evidence = meta.evidenceMemories;
|
|
319
|
+
if (Array.isArray(evidence) && evidence.length > 0) {
|
|
320
|
+
const ids = evidence.filter((v): v is string => typeof v === "string").sort();
|
|
321
|
+
if (ids.length > 0) return `ev:${ids.join("|")}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (Array.isArray(meta.tags)) {
|
|
325
|
+
const src = meta.tags.find((t): t is string => typeof t === "string" && t.startsWith("src:"));
|
|
326
|
+
if (src) return src;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const day = Number.isFinite(entry.timestamp) && entry.timestamp > 0
|
|
330
|
+
? new Date(entry.timestamp).toISOString().slice(0, 10)
|
|
331
|
+
: "unknown";
|
|
332
|
+
return `${UNKNOWN_SOURCE_PREFIX}${entry.scope}:${day}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 兜底 key 的前缀。带这个前缀 = 我们其实不知道它来自哪一次。 */
|
|
336
|
+
export const UNKNOWN_SOURCE_PREFIX = "day:";
|
|
337
|
+
|
|
338
|
+
/** 一组记录覆盖了几个不同来源。1 = 全部来自同一次经历。 */
|
|
339
|
+
export function countDistinctSources(
|
|
340
|
+
entries: ReadonlyArray<Pick<MemoryEntry, "metadata" | "scope" | "timestamp">>,
|
|
341
|
+
): number {
|
|
342
|
+
return new Set(entries.map(extractSourceKey)).size;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export type SourceVerdict =
|
|
346
|
+
| { status: "ok"; distinctSources: number }
|
|
347
|
+
| { status: "rejected"; distinctSources: number }
|
|
348
|
+
/** 没有可信的来源指针 —— 不知道就不判,放行。 */
|
|
349
|
+
| { status: "abstained" };
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 判一个簇是否来自足够多的不同经历。**带弃权路径**。
|
|
353
|
+
*
|
|
354
|
+
* 为什么要弃权而不是一律判:兜底 key 是「同 scope 同一天算一次」,而一天内解决三个
|
|
355
|
+
* 不同问题写三条 case 是完全正常的——那是 3 个 episode,兜底会把它们并成 1 个,
|
|
356
|
+
* 于是一条真规律被当成「同一个坑踩三次」拒掉。**误伤的代价比漏判高**:漏判只是
|
|
357
|
+
* 维持现状(旧行为),误伤是把本来能升华的东西堵死,而且堵得无声无息。
|
|
358
|
+
*
|
|
359
|
+
* 所以判据只在**拿得到真来源指针**时生效。实际效果就是它只在 `memory:pivot` 池成立
|
|
360
|
+
* (2026-08-23 实测该池 cases 100% 带 `src:`,而全库 cases 只有 1.2%)——
|
|
361
|
+
* 这恰好是本方案的适用范围,不是巧合:§5.3 要求 pivot 写入必带来源指针,
|
|
362
|
+
* 那条规矩本来就是为了让「回原文核对」成为可能,跨来源计数是它的另一个消费者。
|
|
363
|
+
*/
|
|
364
|
+
export function judgeDistinctSources(
|
|
365
|
+
entries: ReadonlyArray<Pick<MemoryEntry, "metadata" | "scope" | "timestamp">>,
|
|
366
|
+
minDistinctSources: number,
|
|
367
|
+
): SourceVerdict {
|
|
368
|
+
if (minDistinctSources <= 1) {
|
|
369
|
+
return { status: "ok", distinctSources: countDistinctSources(entries) };
|
|
370
|
+
}
|
|
371
|
+
const keys = entries.map(extractSourceKey);
|
|
372
|
+
const known = keys.filter((k) => !k.startsWith(UNKNOWN_SOURCE_PREFIX));
|
|
373
|
+
if (known.length === 0) return { status: "abstained" };
|
|
374
|
+
|
|
375
|
+
// 有真指针的按真指针数;没指针的每条各算一次未知来源——不能让「不知道」
|
|
376
|
+
// 白白顶上一个来源,也不能因为混了几条没指针的就整簇弃权。
|
|
377
|
+
const distinctSources = new Set(keys).size;
|
|
378
|
+
return distinctSources >= minDistinctSources
|
|
379
|
+
? { status: "ok", distinctSources }
|
|
380
|
+
: { status: "rejected", distinctSources };
|
|
381
|
+
}
|
|
382
|
+
|
|
271
383
|
// ---------------------------------------------------------------------------
|
|
272
384
|
// Read evidence (Artel-style read-driven promotion)
|
|
273
385
|
// ---------------------------------------------------------------------------
|
|
@@ -348,6 +460,8 @@ export async function scanForPromotions(
|
|
|
348
460
|
scannedPatterns: patterns.length,
|
|
349
461
|
truncatedCases: 0,
|
|
350
462
|
vectorlessSkipped: 0,
|
|
463
|
+
rejectedSingleSource: 0,
|
|
464
|
+
abstainedUnknownSource: 0,
|
|
351
465
|
};
|
|
352
466
|
|
|
353
467
|
// 2. Cluster cases bucket-by-bucket (topicTag, untagged fallback)。
|
|
@@ -378,6 +492,16 @@ export async function scanForPromotions(
|
|
|
378
492
|
if (cluster.members.length < cfg.minCaseOccurrences) continue;
|
|
379
493
|
if (caseCandidates >= cfg.maxCandidates) break;
|
|
380
494
|
|
|
495
|
+
// 跨来源判据:条数够不代表是规律。同一次经历重复 N 条走的是 failure-burst
|
|
496
|
+
// 反模式那条路,不是「这类子任务该怎么做」这条。拿不到来源指针时弃权放行。
|
|
497
|
+
const sourceVerdict = judgeDistinctSources(cluster.members, cfg.minDistinctSources);
|
|
498
|
+
if (sourceVerdict.status === "rejected") {
|
|
499
|
+
result.rejectedSingleSource++;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (sourceVerdict.status === "abstained") result.abstainedUnknownSource++;
|
|
503
|
+
const distinctSources = sourceVerdict.status === "ok" ? sourceVerdict.distinctSources : undefined;
|
|
504
|
+
|
|
381
505
|
// Compute average intra-cluster similarity for scoring
|
|
382
506
|
const avgSim = computeAverageIntraClusterSimilarity(cluster.members);
|
|
383
507
|
|
|
@@ -397,6 +521,7 @@ export async function scanForPromotions(
|
|
|
397
521
|
caseReadEvidence.distinctReaders,
|
|
398
522
|
),
|
|
399
523
|
readEvidence: caseReadEvidence,
|
|
524
|
+
distinctSources,
|
|
400
525
|
});
|
|
401
526
|
}
|
|
402
527
|
}
|
|
@@ -433,6 +558,16 @@ export async function scanForPromotions(
|
|
|
433
558
|
|
|
434
559
|
if (similarCases.length < 2) continue;
|
|
435
560
|
|
|
561
|
+
// 同一判据也管 pattern→skill:MemOS 的 L2/L3→Skill 门就是「N 个独立 episode」。
|
|
562
|
+
// 支撑证据全来自同一次经历时,这条 skill 学的是那一次,不是那一类。
|
|
563
|
+
const patternVerdict = judgeDistinctSources(similarCases.map(h => h.entry), cfg.minDistinctSources);
|
|
564
|
+
if (patternVerdict.status === "rejected") {
|
|
565
|
+
result.rejectedSingleSource++;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (patternVerdict.status === "abstained") result.abstainedUnknownSource++;
|
|
569
|
+
const patternDistinctSources = patternVerdict.status === "ok" ? patternVerdict.distinctSources : undefined;
|
|
570
|
+
|
|
436
571
|
// A2: zero-LLM verifier — pattern 当 draft,supporting cases 当 evidence。
|
|
437
572
|
// steps 传 [] 不是漏传:pattern.text 已含完整 Steps 块,resonance 用整段文本即可;
|
|
438
573
|
// extractSteps(pattern.text) 仅用于下面的 suggestedImplementation 字段。
|
|
@@ -462,6 +597,7 @@ export async function scanForPromotions(
|
|
|
462
597
|
),
|
|
463
598
|
verification,
|
|
464
599
|
readEvidence: patternReadEvidence,
|
|
600
|
+
distinctSources: patternDistinctSources,
|
|
465
601
|
});
|
|
466
602
|
}
|
|
467
603
|
|
|
@@ -495,6 +631,18 @@ export function formatPromotionResult(result: PromotionScanResult): string {
|
|
|
495
631
|
if (result.vectorlessSkipped > 0) {
|
|
496
632
|
lines.push(`⚠️ ${result.vectorlessSkipped} entries skipped (no vector in store).`);
|
|
497
633
|
}
|
|
634
|
+
if (result.abstainedUnknownSource > 0) {
|
|
635
|
+
lines.push(
|
|
636
|
+
`ℹ️ ${result.abstainedUnknownSource} cluster(s) 没有可信来源指针,跨来源判据已弃权放行`
|
|
637
|
+
+ `(这些 scope 里的条目没带 src: tag,判了就是瞎判)。`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
if (result.rejectedSingleSource > 0) {
|
|
641
|
+
lines.push(
|
|
642
|
+
`⚠️ ${result.rejectedSingleSource} cluster(s) had enough entries but too few distinct sources`
|
|
643
|
+
+ ` — 同一次经历的重复不算规律(走 failure-burst 反模式那条路,不是这条)。`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
498
646
|
|
|
499
647
|
if (result.candidates.length === 0) {
|
|
500
648
|
lines.push("No promotion candidates found.");
|
|
@@ -507,7 +655,10 @@ export function formatPromotionResult(result: PromotionScanResult): string {
|
|
|
507
655
|
lines.push(`### ${i + 1}. [${c.type}] ${c.suggestedName}`);
|
|
508
656
|
lines.push(`Confidence: ${(c.confidence * 100).toFixed(1)}%`);
|
|
509
657
|
lines.push(`Description: ${c.suggestedDescription}`);
|
|
510
|
-
lines.push(
|
|
658
|
+
lines.push(
|
|
659
|
+
`Sources: ${c.sourceEntries.length} entries`
|
|
660
|
+
+ (c.distinctSources !== undefined ? ` from ${c.distinctSources} distinct source(s)` : ""),
|
|
661
|
+
);
|
|
511
662
|
if (c.readEvidence) {
|
|
512
663
|
lines.push(`Read evidence: ${c.readEvidence.totalAccess} total reads, ${c.readEvidence.distinctReaders} distinct reader(s)`);
|
|
513
664
|
}
|