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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream 合成契约 —— cluster insight 与 cross-memory pattern 的提示词、解析与写库前校验。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块(2026-08-23):
|
|
5
|
+
* 在此之前,「簇 → 一条派生记忆」这件事没有契约,只有两个各自为政的 LLM 调用:
|
|
6
|
+
* - cluster_insight 走 `generateL0` —— 那是**分块索引摘要器**,提示词写的是
|
|
7
|
+
* 「给对话片段写一句话摘要,用于快速检索」,从不要求结论、因果、决策或行动,
|
|
8
|
+
* 还明文要求「端口号/IP/URL/文件路径 → 原样保留」。名字叫 insight,干的是做目录。
|
|
9
|
+
* - cross_memory_pattern 走 `extractPattern` —— 目标概念写的是「反复出现的偏好、
|
|
10
|
+
* 行为倾向、价值观」,那是**性格画像**框架,于是产出星座运势腔。
|
|
11
|
+
*
|
|
12
|
+
* 2026-08-22 的全量诊断量到了后果(同长度区间 40–160 字,确定性正则,无 LLM 判官):
|
|
13
|
+
* 手写 pivot 40.4% > 原料 transcript 25.1% > insight 14.4% > pattern 12.1%
|
|
14
|
+
* —— **产物的结论密度低于它自己的原料,合成这一步在做减法**;
|
|
15
|
+
* pattern 侧 56.1% 命中性向归因句式,对照原料 1.4%(40 倍富集)。
|
|
16
|
+
* 同一批真实簇做三臂对照(n=40,种子 9001):只把提示词换成结论导向 + 允许弃权,
|
|
17
|
+
* 同模型下结论连接词 17.5% → 32.3%、性向归因 62.5% → 0%、弃权率 0% → 22.5%。
|
|
18
|
+
* 结论一直在输入里,是合成把它变成了目录。
|
|
19
|
+
*
|
|
20
|
+
* 所以这里定的是三件事,缺一条都会退回老样子:
|
|
21
|
+
* 1. **目标概念**:要的是「可复用结论」(规则/因果/决策/踩坑/判断转折),不是摘要,
|
|
22
|
+
* 更不是性格描写。两个提示词都显式禁掉「注重/强调/倾向于」那一族措辞。
|
|
23
|
+
* 2. **弃权路径**:炼不出来必须能说「没有」。老 `generateL0` 的 `if (!insight)` 只在
|
|
24
|
+
* LLM 调用失败时触发,**永远不会因为「这簇不值得留」而不写** —— 2,427 条按构造
|
|
25
|
+
* 全是情节摘要,就是这么来的。
|
|
26
|
+
* 3. **写库前校验**:LLM 说了什么不算数,能过校验才写。老路径只检查
|
|
27
|
+
* `response.length < 5`,后果是库里有 28 条记忆的正文**就是它自己的系统提示词原文**。
|
|
28
|
+
*
|
|
29
|
+
* 本模块是纯函数 + 常量,不碰网络也不碰库,故校验规则可以直接单测。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** 一条合格的合成产物。 */
|
|
33
|
+
export interface SynthesisOutput {
|
|
34
|
+
/** 结论正文。不截断 —— 截断一句结论等于毁掉它。 */
|
|
35
|
+
text: string;
|
|
36
|
+
/** 支撑这条结论的源记录编号(1-based,已去重升序,且都在簇内)。 */
|
|
37
|
+
evidence: number[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 校验不通过的原因。逐项区分是为了让「LLM 不好好干活」有形状,而不是笼统一个 null。 */
|
|
41
|
+
export type SynthesisRejectReason =
|
|
42
|
+
| "unparsable" // 拿不到 JSON
|
|
43
|
+
| "flag-not-boolean" // has 字段不是 boolean
|
|
44
|
+
| "empty-text" // 说有结论却没给正文
|
|
45
|
+
| "too-short"
|
|
46
|
+
| "too-long"
|
|
47
|
+
| "prompt-echo" // 正文是提示词自身的回声(老路径进库 28 条的形态)
|
|
48
|
+
| "evidence-missing" // 没给 evidence 或不是数组
|
|
49
|
+
| "evidence-too-few" // 合法编号不够
|
|
50
|
+
| "evidence-out-of-range"; // 编号不是簇内真实存在的下标
|
|
51
|
+
|
|
52
|
+
export type SynthesisVerdict =
|
|
53
|
+
| { status: "ok"; output: SynthesisOutput }
|
|
54
|
+
| { status: "abstained" }
|
|
55
|
+
| { status: "rejected"; reason: SynthesisRejectReason };
|
|
56
|
+
|
|
57
|
+
/** 正文长度闸。下界防空壳,上界防 LLM 把整段推理倒进来。 */
|
|
58
|
+
export const SYNTHESIS_MIN_LEN = 12;
|
|
59
|
+
export const SYNTHESIS_MAX_LEN = 300;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 提示词回声标记 —— 命中任意一条即判 `prompt-echo`。
|
|
63
|
+
*
|
|
64
|
+
* 挑选判据:必须是**只可能来自提示词本身**的长串。一条真实结论里不会出现
|
|
65
|
+
* 「宁可不产出」或「记忆索引助手」,但完全可能出现「JSON」「evidence」这类词,
|
|
66
|
+
* 所以后者不进表 —— 这张表宁可漏也不能误杀真结论。
|
|
67
|
+
* 头两条是老 `generateL0` 提示词的原文,那 28 条泄漏就是照它进的库。
|
|
68
|
+
*/
|
|
69
|
+
export const PROMPT_ECHO_MARKERS: readonly string[] = [
|
|
70
|
+
"保真规则",
|
|
71
|
+
"端口号/IP/URL/文件路径",
|
|
72
|
+
"记忆索引助手",
|
|
73
|
+
"记忆模式发现助手",
|
|
74
|
+
"宁可不产出",
|
|
75
|
+
"那是性格描写不是结论",
|
|
76
|
+
"不要加任何解释或代码围栏",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** 送进模型的簇文本总预算(字符)。三臂实验用的就是这个量级,够 p90 簇全文进模型。 */
|
|
80
|
+
export const CLUSTER_INPUT_BUDGET = 12_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 把总预算在簇成员之间**等额分配**,短成员用不完的份额让给长成员。
|
|
84
|
+
*
|
|
85
|
+
* 老路径是 `text.slice(0, 2000)` —— 整簇拼成一条长文本后从头切。实测 64.2% 的簇被截,
|
|
86
|
+
* 中位只有 62.7% 的源文本进得了模型,p10 仅 13.8%:**排在后面的成员根本没被看见**,
|
|
87
|
+
* 而簇最大有 168 个成员。等额分配保证每条源都有机会出现在模型眼前,这是「跨条目结论」
|
|
88
|
+
* 能成立的物理前提 —— 看不到的东西无法被跨。
|
|
89
|
+
*
|
|
90
|
+
* 算法:按长度升序,每轮把「剩余预算 / 剩余条数」作为当条份额;短于份额的整条收下并
|
|
91
|
+
* 把余量退回池子,长于份额的截到份额并加省略号。单趟 O(n log n)。
|
|
92
|
+
*/
|
|
93
|
+
export function allocateClusterBudget(texts: string[], totalBudget = CLUSTER_INPUT_BUDGET): string[] {
|
|
94
|
+
if (texts.length === 0) return [];
|
|
95
|
+
const out = new Array<string>(texts.length);
|
|
96
|
+
const order = texts
|
|
97
|
+
.map((t, i) => ({ i, len: t.length }))
|
|
98
|
+
.sort((a, b) => a.len - b.len);
|
|
99
|
+
|
|
100
|
+
let remainingBudget = Math.max(0, totalBudget);
|
|
101
|
+
let remainingCount = order.length;
|
|
102
|
+
|
|
103
|
+
for (const { i } of order) {
|
|
104
|
+
const share = Math.floor(remainingBudget / remainingCount);
|
|
105
|
+
const text = texts[i];
|
|
106
|
+
if (text.length <= share) {
|
|
107
|
+
out[i] = text;
|
|
108
|
+
remainingBudget -= text.length;
|
|
109
|
+
} else {
|
|
110
|
+
// 至少留 1 个字符,避免 share=0 时产出空串(空串会让编号错位、evidence 无从指认)
|
|
111
|
+
out[i] = share > 1 ? `${text.slice(0, share - 1)}…` : text.slice(0, 1);
|
|
112
|
+
remainingBudget -= out[i].length;
|
|
113
|
+
}
|
|
114
|
+
remainingCount--;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 把簇文本编号拼成 user message。编号是 1-based,与 evidence 的语义一致。 */
|
|
120
|
+
export function numberClusterTexts(texts: string[]): string {
|
|
121
|
+
return texts.map((t, i) => `[${i + 1}] ${t}`).join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* cluster insight 的提示词。
|
|
126
|
+
*
|
|
127
|
+
* 与三臂实验里跑出 32.3%(qwen-turbo)/ 45.2%(qwen-max)的那版同源,另加两条:
|
|
128
|
+
* 「三要素齐全」的硬要求,以及 evidence 字段 —— 后者不只是为了可追溯,它同时是一道
|
|
129
|
+
* 隐性质检:说不出这句话是从哪几条来的,通常意味着它是凭空润色出来的。
|
|
130
|
+
*/
|
|
131
|
+
export const CLUSTER_INSIGHT_PROMPT = `你在为一个长期记忆库提炼「可复用结论」。下面是同一次工作中若干条编号记录。
|
|
132
|
+
|
|
133
|
+
一条合格的结论必须**自带理由或后果**。只说"该做什么"而不说"为什么"或"否则会怎样"的,
|
|
134
|
+
不是结论,是待办事项——待办会过期,结论不会,而这个库只留不会过期的东西。
|
|
135
|
+
|
|
136
|
+
形状(两段都在才写):
|
|
137
|
+
① 在什么条件下、对什么对象,该怎么做或会怎样
|
|
138
|
+
② 为什么 / 否则会怎样 / 根因是什么
|
|
139
|
+
|
|
140
|
+
合格示例:
|
|
141
|
+
多 org 协作不能用 fine-grained token,必须换 classic PAT,因为一个 fine-grained token 只能绑定一个 resource owner。
|
|
142
|
+
分派前必须先等 store 完成惰性初始化,否则读到的 hasFtsSupport 还是初始化前的 false,hybrid 检索会静默落回 vector 路径。
|
|
143
|
+
批量删记忆要显式关掉 cascade,否则同 scope 内相似度高的条目会被连带降权,本意要保护的池子反而先受伤。
|
|
144
|
+
|
|
145
|
+
不合格示例(这几种一律返回 has:false,不要硬改成合格的样子):
|
|
146
|
+
配置扫描路径时应统一添加多个目录以兼容不同格式 ← 只有动作,没说不这么做会怎样
|
|
147
|
+
完成了三版对齐与定稿,删除了旧草稿 ← 复述经过
|
|
148
|
+
注重流程闭环与规范性,重视可追溯 ← 性格描写
|
|
149
|
+
把端口改成 8080 后服务恢复正常 ← 绑死在一次性对象上
|
|
150
|
+
|
|
151
|
+
其余硬性要求:
|
|
152
|
+
- 结论要在未来另一个不同场景里仍然成立。绑死在一次性对象上的(某个进程号、某次会议、某天的待办)不算。
|
|
153
|
+
- 不要写某人"注重什么""强调什么""倾向于什么"——那是性格描写不是结论。
|
|
154
|
+
- evidence 填支撑这条结论的记录编号,至少 1 个,且必须是上面真实出现过的编号。
|
|
155
|
+
- 这批记录里**大多数**确实提炼不出这样的结论,那就老实返回 has:false。宁可不产出,也不要把待办事项包装成结论。
|
|
156
|
+
|
|
157
|
+
只输出**一个** JSON 对象,不要加任何解释或代码围栏。即使你想到不止一条结论,也只输出最重要的那一条,
|
|
158
|
+
不要输出多个 JSON 对象——多输出的会被整条丢弃。
|
|
159
|
+
{"has": true, "conclusion": "一句话,不超过120字", "evidence": [编号, ...]}
|
|
160
|
+
或 {"has": false}`;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* cross-memory pattern 的提示词。
|
|
164
|
+
*
|
|
165
|
+
* 目标概念从「反复出现的偏好、行为倾向、价值观或循环主题」换成「跨条目才成立的
|
|
166
|
+
* 规则/因果/决策/踩坑/判断转折」。旧措辞是这一侧 56.1% 性向归因的直接来源:
|
|
167
|
+
* 它要的就是性格画像,模型只是忠实执行。
|
|
168
|
+
*
|
|
169
|
+
* evidence ≥2 在这里是**定义性**的,不是可选的严格度:单条支撑的东西按定义不是
|
|
170
|
+
* 跨条目模式。旧提示词嘴上写了「至少 2 条记忆作为证据支撑」,实现却既不接收也不校验
|
|
171
|
+
* 编号,于是这句话零成本。
|
|
172
|
+
*/
|
|
173
|
+
export const CLUSTER_PATTERN_PROMPT = `你在为一个长期记忆库提炼「跨条目才成立的结论」。下面是同一组相关记录,已编号。
|
|
174
|
+
|
|
175
|
+
和逐条摘要不同,你要找的是单独看任何一条都看不出来、必须放在一起才成立的东西。
|
|
176
|
+
而且它必须**自带理由或后果**:只说"该做什么"而不说"为什么"或"否则会怎样"的,
|
|
177
|
+
不是结论,是待办事项——待办会过期,结论不会,而这个库只留不会过期的东西。
|
|
178
|
+
|
|
179
|
+
形状(两段都在才写):
|
|
180
|
+
① 这类情况反复出现时,固定该怎么做 / 会怎样
|
|
181
|
+
② 为什么 / 否则会怎样 / 根因是什么
|
|
182
|
+
|
|
183
|
+
合格示例:
|
|
184
|
+
这套流程里凡是"改了真相源"的动作都必须连带扫引用点,因为漏扫不会报错,只会让两份文档各说各话,直到几天后被别人撞见。
|
|
185
|
+
同一个 bug 连修三次不成必须停下来质疑架构,而不是第四次重试,因为前三次失败说明诊断本身是错的,重试只是在错误位置上继续投入。
|
|
186
|
+
|
|
187
|
+
不合格示例(这几种一律返回 has:false,不要硬改成合格的样子):
|
|
188
|
+
注重规则有效性验证和成本效益评估,倾向于通过增量改造提升效果 ← 性格画像,这一类是本任务最常见的错误产出
|
|
189
|
+
多次进行配置调整与验证,持续关注同步状态 ← 复述经过
|
|
190
|
+
统一使用绝对路径调用脚本 ← 只有动作,没说不这么做会怎样
|
|
191
|
+
|
|
192
|
+
其余硬性要求:
|
|
193
|
+
- 必须由**至少 2 条**记录共同支撑,把这些编号填进 evidence。只有 1 条支撑的不算跨条目。
|
|
194
|
+
- 绑死在一次性对象上的(某个进程号、某次会议、某天的待办)不算。
|
|
195
|
+
- 这批记录里**大多数**确实凑不出这样的跨条目结论,那就老实返回 has:false。宁可不产出,也不要把"他们都在做类似的事"写成模式。
|
|
196
|
+
|
|
197
|
+
只输出**一个** JSON 对象,不要加任何解释或代码围栏。即使你想到不止一条模式,也只输出最重要的那一条,
|
|
198
|
+
不要输出多个 JSON 对象——多输出的会被整条丢弃。
|
|
199
|
+
{"has": true, "pattern": "一句话,不超过150字", "evidence": [编号, ...]}
|
|
200
|
+
或 {"has": false}`;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 从可能带围栏 / 带前言的原始回复里抠出 JSON 对象。与 `chatJson` 同款兜底。
|
|
204
|
+
*
|
|
205
|
+
* **刻意保持严格**:模型偶尔会一口气输出多个 JSON 对象(一条结论一个),
|
|
206
|
+
* 此时「第一个 { 到最后一个 }」跨越了所有对象、解析失败,整条判 unparsable。
|
|
207
|
+
* 不去挑其中一个来救 —— 挑哪一条是任意的,而任意选择会让「模型没照契约干活」
|
|
208
|
+
* 这件事从计数里消失。根因在提示词侧治(两个提示词都写死「只输出一个 JSON 对象」);
|
|
209
|
+
* 2026-08-23 实测该形态占 3.3%(60 次调用 2 次),且 finish_reason 全为 stop、
|
|
210
|
+
* 与 token 截断无关——所以不要试图靠加大 max_tokens 来修它。
|
|
211
|
+
*/
|
|
212
|
+
export function parseSynthesisJson(raw: string | null): Record<string, unknown> | null {
|
|
213
|
+
if (!raw) return null;
|
|
214
|
+
const fence = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
|
|
215
|
+
const candidates = [fence ? fence[1].trim() : raw.trim()];
|
|
216
|
+
const start = raw.indexOf("{");
|
|
217
|
+
const end = raw.lastIndexOf("}");
|
|
218
|
+
if (start >= 0 && end > start) candidates.push(raw.slice(start, end + 1));
|
|
219
|
+
|
|
220
|
+
for (const c of candidates) {
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(c) as unknown;
|
|
223
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
224
|
+
return parsed as Record<string, unknown>;
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// 试下一个候选
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 写库前的唯一一道闸。**不做任何补救性纠正** —— 不截断、不补 evidence、不猜 has 的意图。
|
|
235
|
+
* 补救会让「模型没照契约干活」这件事消失在日志之外,而那正是要看见的东西。
|
|
236
|
+
*/
|
|
237
|
+
export function validateSynthesis(params: {
|
|
238
|
+
parsed: Record<string, unknown> | null;
|
|
239
|
+
/** 正文字段名:insight 侧是 conclusion,pattern 侧是 pattern。 */
|
|
240
|
+
textField: string;
|
|
241
|
+
/** 簇成员数,用来判 evidence 编号是否真实存在。 */
|
|
242
|
+
memberCount: number;
|
|
243
|
+
/** 合法 evidence 编号的下限。insight = 1,pattern = 2(定义性要求)。 */
|
|
244
|
+
minEvidence: number;
|
|
245
|
+
}): SynthesisVerdict {
|
|
246
|
+
const { parsed, textField, memberCount, minEvidence } = params;
|
|
247
|
+
if (!parsed) return { status: "rejected", reason: "unparsable" };
|
|
248
|
+
|
|
249
|
+
const has = parsed.has;
|
|
250
|
+
if (typeof has !== "boolean") return { status: "rejected", reason: "flag-not-boolean" };
|
|
251
|
+
if (!has) return { status: "abstained" };
|
|
252
|
+
|
|
253
|
+
const rawText = parsed[textField];
|
|
254
|
+
if (typeof rawText !== "string" || rawText.trim().length === 0) {
|
|
255
|
+
return { status: "rejected", reason: "empty-text" };
|
|
256
|
+
}
|
|
257
|
+
const text = rawText.trim();
|
|
258
|
+
if (text.length < SYNTHESIS_MIN_LEN) return { status: "rejected", reason: "too-short" };
|
|
259
|
+
if (text.length > SYNTHESIS_MAX_LEN) return { status: "rejected", reason: "too-long" };
|
|
260
|
+
if (PROMPT_ECHO_MARKERS.some(m => text.includes(m))) {
|
|
261
|
+
return { status: "rejected", reason: "prompt-echo" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const rawEvidence = parsed.evidence;
|
|
265
|
+
if (!Array.isArray(rawEvidence)) return { status: "rejected", reason: "evidence-missing" };
|
|
266
|
+
|
|
267
|
+
const seen = new Set<number>();
|
|
268
|
+
let sawOutOfRange = false;
|
|
269
|
+
for (const item of rawEvidence) {
|
|
270
|
+
const n = typeof item === "number" ? item : Number(item);
|
|
271
|
+
if (!Number.isInteger(n)) { sawOutOfRange = true; continue; }
|
|
272
|
+
if (n < 1 || n > memberCount) { sawOutOfRange = true; continue; }
|
|
273
|
+
seen.add(n);
|
|
274
|
+
}
|
|
275
|
+
const evidence = [...seen].sort((a, b) => a - b);
|
|
276
|
+
|
|
277
|
+
if (evidence.length < minEvidence) {
|
|
278
|
+
// 一个都没对上(且确实给了越界编号)说明模型在编号;对上了但不够数是证据不足。
|
|
279
|
+
return {
|
|
280
|
+
status: "rejected",
|
|
281
|
+
reason: evidence.length === 0 && sawOutOfRange ? "evidence-out-of-range" : "evidence-too-few",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { status: "ok", output: { text, evidence } };
|
|
286
|
+
}
|
package/src/version-manager.ts
CHANGED
|
@@ -29,14 +29,90 @@ export interface VersionGroupMetadata {
|
|
|
29
29
|
version_created: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export interface SingletonVersionGroupRepair {
|
|
33
|
+
id: string;
|
|
34
|
+
groupId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
// ============================================================================
|
|
33
38
|
// Core Functions
|
|
34
39
|
// ============================================================================
|
|
35
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Plan repairs for version groups that have only one surviving member.
|
|
43
|
+
*
|
|
44
|
+
* Planning is pure so callers can take the membership snapshot under their
|
|
45
|
+
* own storage lock, then apply the returned metadata-only repairs atomically.
|
|
46
|
+
*/
|
|
47
|
+
export function planSingletonVersionGroupRepairs(
|
|
48
|
+
entries: Array<Pick<MemoryEntry, "id" | "metadata">>,
|
|
49
|
+
): SingletonVersionGroupRepair[] {
|
|
50
|
+
const groups = new Map<string, { count: number; onlyMemberId: string }>();
|
|
51
|
+
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const meta = parseMetadata(entry.metadata);
|
|
54
|
+
const groupId = typeof meta.version_group === "string" ? meta.version_group : null;
|
|
55
|
+
if (!groupId) continue;
|
|
56
|
+
|
|
57
|
+
const current = groups.get(groupId);
|
|
58
|
+
if (current) current.count++;
|
|
59
|
+
else groups.set(groupId, { count: 1, onlyMemberId: entry.id });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [...groups.entries()]
|
|
63
|
+
.filter(([, group]) => group.count === 1)
|
|
64
|
+
.map(([groupId, group]) => ({ id: group.onlyMemberId, groupId }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Remove only the three fields that make an entry a version-group member. */
|
|
68
|
+
export function clearVersionGroupMetadata(
|
|
69
|
+
meta: Record<string, unknown>,
|
|
70
|
+
expectedGroupId: string,
|
|
71
|
+
): Record<string, unknown> {
|
|
72
|
+
if (meta.version_group !== expectedGroupId) return meta;
|
|
73
|
+
delete meta.version_group;
|
|
74
|
+
delete meta.version_rank;
|
|
75
|
+
delete meta.version_created;
|
|
76
|
+
return meta;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 纯计算版:给一条 entry 的(已解析)metadata 打 version-group 三键,不写库。
|
|
81
|
+
* 就地修改并返回传入的 meta 对象。
|
|
82
|
+
*
|
|
83
|
+
* 供 consolidation-engine 3a 在 patchMetadataBatch 的 patchFn 里使用 —— patchFn 在
|
|
84
|
+
* 锁内拿到库中最新 meta,这里只负责"该设哪些键";groupId 的决策(沿用已有组 or
|
|
85
|
+
* 新生成)由调用方在 cluster 级做一次、全 cluster 共享,修掉了旧路径"每个 member
|
|
86
|
+
* 重新从旧内存串判组 → 各造一个新 groupId"的 Bug-1 半边。
|
|
87
|
+
*
|
|
88
|
+
* @param preserveCreated canonical 语义:已有 version_created 则保留;member 语义传 false(总是刷新)
|
|
89
|
+
*/
|
|
90
|
+
export function computeVersionGroupPatch(
|
|
91
|
+
meta: Record<string, unknown>,
|
|
92
|
+
entry: MemoryEntry,
|
|
93
|
+
groupId: string,
|
|
94
|
+
preserveCreated: boolean,
|
|
95
|
+
nowIso: string,
|
|
96
|
+
): Record<string, unknown> {
|
|
97
|
+
meta.version_group = groupId;
|
|
98
|
+
meta.version_rank = computeVersionRank(entry);
|
|
99
|
+
if (!preserveCreated || !meta.version_created) meta.version_created = nowIso;
|
|
100
|
+
return meta;
|
|
101
|
+
}
|
|
102
|
+
|
|
36
103
|
/**
|
|
37
104
|
* Create a version group from two entries that would otherwise be merged.
|
|
38
105
|
* Instead of archiving the weaker one, both get tagged with the same group.
|
|
39
106
|
*
|
|
107
|
+
* ⚠️ 2026-08-14 起 consolidation-engine 3a 不再走本函数(改走 computeVersionGroupPatch
|
|
108
|
+
* + patchMetadataBatch,每 cluster 一次 commit)。本函数保留原签名原行为(逐条 update,
|
|
109
|
+
* 每次调用 2 个 commit),唯一剩余调用方是 llm-consolidation.ts:executeMergeDecisions ——
|
|
110
|
+
* 该路径的调用链(auto-consolidation.ts)生产零 import 且被 RECALLNEST_LLM_CONSOLIDATION
|
|
111
|
+
* flag 关闭,是不可达死代码;其调用循环里还留着与 Bug-1 同族的 stale-canonical 问题
|
|
112
|
+
* (多 member 时每次从旧内存串判组)。**有意不修不批量化**:给不可达路径补测试无收益。
|
|
113
|
+
* 若 auto-consolidation 被接回生产路径,先修那个循环再上线(见 2026-08-14 dream 根治
|
|
114
|
+
* 方案 plan.md D 节否决项)。
|
|
115
|
+
*
|
|
40
116
|
* @param store Memory store
|
|
41
117
|
* @param canonical The stronger entry (higher canonicalScore)
|
|
42
118
|
* @param member The weaker entry
|
|
@@ -79,6 +155,13 @@ export async function createVersionGroup(
|
|
|
79
155
|
return groupId;
|
|
80
156
|
}
|
|
81
157
|
|
|
158
|
+
/** 3a 批量路径的 groupId 决策:canonical 已有组沿用,否则新生成(cluster 级一次)。 */
|
|
159
|
+
export function resolveGroupId(canonical: MemoryEntry): string {
|
|
160
|
+
const canonMeta = parseMetadata(canonical.metadata);
|
|
161
|
+
const existing = typeof canonMeta.version_group === "string" ? canonMeta.version_group : null;
|
|
162
|
+
return existing ?? generateGroupId();
|
|
163
|
+
}
|
|
164
|
+
|
|
82
165
|
/**
|
|
83
166
|
* Compute version rank for an entry.
|
|
84
167
|
* Formula: (0.5 * importance + 0.5 * confidence) × (1 + log1p(accessCount))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
import { boundedStringSchema, identifierSchema, normalizedStringListSchema, optionalBoundedStringSchema } from "./schema-utils.js";
|
|
4
|
+
import { RECALLED_IDS_CAP } from "./recall-ledger.js";
|
|
4
5
|
|
|
5
6
|
export const WORKFLOW_OBSERVATION_OUTCOMES = [
|
|
6
7
|
"success",
|
|
@@ -11,6 +12,27 @@ export const WORKFLOW_OBSERVATION_OUTCOMES = [
|
|
|
11
12
|
|
|
12
13
|
export const WorkflowObservationOutcomeSchema = z.enum(WORKFLOW_OBSERVATION_OUTCOMES);
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* P0 join key:这次任务用到过哪些记忆。
|
|
17
|
+
*
|
|
18
|
+
* 超上限**截断而不报错**——观测层的第一守则是别把上报挡回去,一条记多了的
|
|
19
|
+
* observation 也远好过一条写不进去的。
|
|
20
|
+
* // simplified: capped, best-effort join signal — 不是账目,别拿它做审计口径。
|
|
21
|
+
* 需要完整读取轨迹时查 audit.jsonl 的 retrieve 记录。
|
|
22
|
+
*/
|
|
23
|
+
const recalledIdsSchema = z.array(z.string().trim().min(1).max(128))
|
|
24
|
+
.transform((items) => {
|
|
25
|
+
const deduped: string[] = [];
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
if (seen.has(item)) continue;
|
|
29
|
+
seen.add(item);
|
|
30
|
+
deduped.push(item);
|
|
31
|
+
}
|
|
32
|
+
return deduped.slice(0, RECALLED_IDS_CAP);
|
|
33
|
+
})
|
|
34
|
+
.optional();
|
|
35
|
+
|
|
14
36
|
export const WorkflowObservationInputSchema = z.object({
|
|
15
37
|
workflowId: identifierSchema("workflowId", 120),
|
|
16
38
|
outcome: WorkflowObservationOutcomeSchema.default("success"),
|
|
@@ -24,6 +46,10 @@ export const WorkflowObservationInputSchema = z.object({
|
|
|
24
46
|
recordedAt: z.string().datetime().default(() => new Date().toISOString()),
|
|
25
47
|
skillId: optionalBoundedStringSchema(128),
|
|
26
48
|
idempotencyKey: optionalBoundedStringSchema(160),
|
|
49
|
+
/** P0 join key(会话级):上报这条 observation 的读者身份,对应 memory metadata 的 readerIds。 */
|
|
50
|
+
readerId: optionalBoundedStringSchema(64),
|
|
51
|
+
/** P0 join key(任务级,更精确):本次任务窗口内被检索返回过的 memory id。 */
|
|
52
|
+
recalledIds: recalledIdsSchema,
|
|
27
53
|
});
|
|
28
54
|
|
|
29
55
|
export const WorkflowObservationRecordSchema = WorkflowObservationInputSchema.extend({
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# Gemini CLI Integration
|
|
2
|
-
|
|
3
|
-
> Gemini CLI 接入指南:一键配 MCP + continuity 规则,让 Gemini CLI 在新窗口里主动恢复稳定上下文。
|
|
4
|
-
|
|
5
|
-
## Quick Start
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
bash integrations/gemini-cli/setup.sh
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## What It Does
|
|
12
|
-
|
|
13
|
-
- Adds RecallNest as an MCP server in `~/.gemini/settings.json` with `trust: true`
|
|
14
|
-
- Installs a managed RecallNest block in `~/.gemini/GEMINI.md`
|
|
15
|
-
|
|
16
|
-
## Continuity Rules
|
|
17
|
-
|
|
18
|
-
The managed block comes from [gemini-md-snippet.md](gemini-md-snippet.md) and tells Gemini CLI to:
|
|
19
|
-
|
|
20
|
-
- call `resume_context` at the start of fresh windows or continuity-sensitive tasks
|
|
21
|
-
- run lightweight `search_memory` on task pivots inside the same project before repo exploration drifts
|
|
22
|
-
- reuse known `scope` / `sessionId` and the resolved scope returned by `resume_context` in follow-up recall calls
|
|
23
|
-
- treat recalled or startup-hook repo state as unverified until this window explicitly checks the repo
|
|
24
|
-
- save `checkpoint_session` before leaving resumable work
|
|
25
|
-
- do not inspect repo state just to enrich a close-window checkpoint unless the user explicitly asked for repo state
|
|
26
|
-
- do not write unverified repo-state claims into `checkpoint_session`
|
|
27
|
-
- capture durable facts with `store_memory` and reusable workflows with `store_workflow_pattern`
|
|
28
|
-
|
|
29
|
-
## Shared Index
|
|
30
|
-
|
|
31
|
-
Gemini CLI shares the same LanceDB index as Claude Code and Codex. Memories ingested from any source are searchable by all three.
|
|
32
|
-
|
|
33
|
-
## Manual Setup
|
|
34
|
-
|
|
35
|
-
Add to `~/.gemini/settings.json`:
|
|
36
|
-
|
|
37
|
-
```json
|
|
38
|
-
{
|
|
39
|
-
"mcpServers": {
|
|
40
|
-
"recallnest": {
|
|
41
|
-
"command": "bun",
|
|
42
|
-
"args": ["run", "RECALLNEST_PATH/src/mcp-server.ts"],
|
|
43
|
-
"trust": true
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
Replace `RECALLNEST_PATH` with your actual path.
|
|
50
|
-
|
|
51
|
-
Then copy [gemini-md-snippet.md](gemini-md-snippet.md) into `~/.gemini/GEMINI.md` if you are not using `setup.sh`.
|
|
52
|
-
|
|
53
|
-
## Verify
|
|
54
|
-
|
|
55
|
-
Start Gemini CLI and ask: "resume my context for RecallNest continuity work"
|
|
56
|
-
|
|
57
|
-
If `resume_context` or `search_memory` is called, you're set.
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# RecallNest — Gemini CLI MCP setup (idempotent)
|
|
3
|
-
# Usage: bash integrations/gemini-cli/setup.sh
|
|
4
|
-
|
|
5
|
-
set -euo pipefail
|
|
6
|
-
|
|
7
|
-
GEMINI_SETTINGS="$HOME/.gemini/settings.json"
|
|
8
|
-
GEMINI_MD="$HOME/.gemini/GEMINI.md"
|
|
9
|
-
GEMINI_DIR="$HOME/.gemini"
|
|
10
|
-
RECALLNEST_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
11
|
-
MCP_ENTRY="$RECALLNEST_DIR/src/mcp-server.ts"
|
|
12
|
-
RULES_SNIPPET="$RECALLNEST_DIR/integrations/gemini-cli/gemini-md-snippet.md"
|
|
13
|
-
RULES_HELPER="$RECALLNEST_DIR/integrations/shared/install-managed-block.sh"
|
|
14
|
-
|
|
15
|
-
if [[ ! -f "$MCP_ENTRY" ]]; then
|
|
16
|
-
echo "ERROR: $MCP_ENTRY not found. Run this from the recallnest repo root."
|
|
17
|
-
exit 1
|
|
18
|
-
fi
|
|
19
|
-
|
|
20
|
-
if [[ ! -f "$RULES_HELPER" ]]; then
|
|
21
|
-
echo "ERROR: $RULES_HELPER not found."
|
|
22
|
-
exit 1
|
|
23
|
-
fi
|
|
24
|
-
|
|
25
|
-
# Create ~/.gemini/ and settings.json if missing
|
|
26
|
-
mkdir -p "$GEMINI_DIR"
|
|
27
|
-
if [[ ! -f "$GEMINI_SETTINGS" ]]; then
|
|
28
|
-
echo '{}' > "$GEMINI_SETTINGS"
|
|
29
|
-
echo "Created $GEMINI_SETTINGS"
|
|
30
|
-
fi
|
|
31
|
-
|
|
32
|
-
# Check if already configured
|
|
33
|
-
if bun -e "
|
|
34
|
-
const c = JSON.parse(require('fs').readFileSync('$GEMINI_SETTINGS','utf8'));
|
|
35
|
-
process.exit(c.mcpServers?.recallnest ? 0 : 1);
|
|
36
|
-
" 2>/dev/null; then
|
|
37
|
-
echo "RecallNest MCP already configured in $GEMINI_SETTINGS — skipping."
|
|
38
|
-
else
|
|
39
|
-
bun -e "
|
|
40
|
-
const fs = require('fs');
|
|
41
|
-
const c = JSON.parse(fs.readFileSync('$GEMINI_SETTINGS','utf8'));
|
|
42
|
-
if (!c.mcpServers) c.mcpServers = {};
|
|
43
|
-
c.mcpServers.recallnest = {
|
|
44
|
-
command: 'bun',
|
|
45
|
-
args: ['run', '$MCP_ENTRY'],
|
|
46
|
-
trust: true
|
|
47
|
-
};
|
|
48
|
-
fs.writeFileSync('$GEMINI_SETTINGS', JSON.stringify(c, null, 2) + '\n');
|
|
49
|
-
"
|
|
50
|
-
echo "Added RecallNest MCP to $GEMINI_SETTINGS"
|
|
51
|
-
fi
|
|
52
|
-
|
|
53
|
-
. "$RULES_HELPER"
|
|
54
|
-
install_managed_markdown_block "$RULES_SNIPPET" "$GEMINI_MD" "recallnest-continuity"
|
|
55
|
-
echo "Installed RecallNest continuity rules in $GEMINI_MD"
|
|
56
|
-
|
|
57
|
-
echo ""
|
|
58
|
-
echo "Setup complete. Restart Gemini CLI to activate."
|
|
59
|
-
echo ""
|
|
60
|
-
echo "Continuity baseline seed:"
|
|
61
|
-
echo " (cd \"$RECALLNEST_DIR\" && bun run seed:continuity)"
|
|
62
|
-
echo ""
|
|
63
|
-
echo "Managed snippet source:"
|
|
64
|
-
echo " $RULES_SNIPPET"
|
|
File without changes
|