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.
Files changed (74) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +131 -14
  3. package/README_CN.md +121 -17
  4. package/config.json.example +8 -1
  5. package/docs/api-reference.md +1 -1
  6. package/integrations/README.md +1 -1
  7. package/integrations/agy/README.md +73 -0
  8. package/integrations/agy/setup.sh +75 -0
  9. package/integrations/setup-all.sh +1 -1
  10. package/package.json +5 -4
  11. package/src/__tests__/access-tracker-novelty.test.ts +22 -2
  12. package/src/__tests__/activity-counter.test.ts +38 -38
  13. package/src/__tests__/age-label.test.ts +33 -0
  14. package/src/__tests__/cluster-consolidation.test.ts +313 -22
  15. package/src/__tests__/consolidation-engine.test.ts +90 -0
  16. package/src/__tests__/contracts.test.ts +2 -2
  17. package/src/__tests__/data-checkup.test.ts +31 -3
  18. package/src/__tests__/dream-pipeline.test.ts +41 -1
  19. package/src/__tests__/dream-scope-isolation.test.ts +386 -0
  20. package/src/__tests__/dream-write-batching.test.ts +211 -0
  21. package/src/__tests__/env-config-invariant.test.ts +80 -0
  22. package/src/__tests__/env-config.test.ts +60 -0
  23. package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
  24. package/src/__tests__/maintenance-locks.test.ts +8 -0
  25. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
  26. package/src/__tests__/memory-output.test.ts +9 -0
  27. package/src/__tests__/memory-utility.test.ts +256 -0
  28. package/src/__tests__/openai-contract.test.ts +513 -0
  29. package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
  30. package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
  31. package/src/__tests__/source-heartbeat.test.ts +6 -2
  32. package/src/__tests__/store-patch-metadata.test.ts +30 -0
  33. package/src/__tests__/synthesis-contract.test.ts +252 -0
  34. package/src/__tests__/synthesis-promotion.test.ts +369 -0
  35. package/src/__tests__/trusted-memory.test.ts +28 -0
  36. package/src/__tests__/utility-weight.test.ts +57 -0
  37. package/src/__tests__/verbatim-self-recall.test.ts +279 -0
  38. package/src/__tests__/version-manager.test.ts +35 -0
  39. package/src/access-tracker.ts +10 -1
  40. package/src/activity-counter.ts +69 -11
  41. package/src/age-label.ts +29 -0
  42. package/src/audit-log.ts +35 -0
  43. package/src/auto-gc.ts +97 -30
  44. package/src/cli.ts +224 -10
  45. package/src/consolidation-engine.ts +317 -123
  46. package/src/data-checkup.ts +17 -3
  47. package/src/dream-pipeline.ts +90 -12
  48. package/src/embedder.ts +20 -5
  49. package/src/env-config.ts +53 -0
  50. package/src/forget-engine.ts +8 -2
  51. package/src/gateway-server.ts +272 -0
  52. package/src/ingest.ts +23 -5
  53. package/src/llm-client.ts +86 -25
  54. package/src/mcp-server.ts +2 -1
  55. package/src/mcp-tools-advanced.ts +7 -2
  56. package/src/mcp-tools-core.ts +11 -1
  57. package/src/mcp-tools-governance.ts +45 -3
  58. package/src/memory-evolution.ts +16 -0
  59. package/src/memory-output.ts +12 -8
  60. package/src/memory-promotion.ts +280 -0
  61. package/src/memory-store-port.ts +32 -3
  62. package/src/memory-utility.ts +314 -0
  63. package/src/recall-ledger.ts +76 -0
  64. package/src/retriever.ts +82 -9
  65. package/src/scope-policy.ts +22 -1
  66. package/src/session-output.ts +4 -3
  67. package/src/skill-promotion.ts +152 -1
  68. package/src/store.ts +231 -71
  69. package/src/synthesis-contract.ts +286 -0
  70. package/src/version-manager.ts +83 -0
  71. package/src/workflow-observation-schema.ts +26 -0
  72. package/integrations/gemini-cli/README.md +0 -57
  73. package/integrations/gemini-cli/setup.sh +0 -64
  74. /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Retrieve-audit revision/provenance coverage.
3
+ *
4
+ * `3fc1875` connected the retrieve audit (setAuditLogger had zero callers, so the
5
+ * `?.` call was a permanent no-op). What it records is `query="…" hits=N` — enough to
6
+ * prove a retrieval happened, not enough to answer the question an audit log is for:
7
+ * when an agent acted on recalled memory, *which revision did it read*?
8
+ *
9
+ * That gap is not cosmetic. `archiveBeliefVersion` rewrites a belief in place: the
10
+ * canonical id stays, `evolution.version` goes up, and the old text survives as a
11
+ * separate `superseded` row. So "memory abc1234 was retrieved" is ambiguous across every
12
+ * belief change, and a stale answer is indistinguishable from a current one after the
13
+ * fact.
14
+ *
15
+ * These tests pin the extra fields on the existing audit row. They deliberately do not
16
+ * introduce a second audit path — the assertions run against the same `AuditLogger`
17
+ * interface the store/update/delete operations already use.
18
+ */
19
+
20
+ import { describe, expect, it } from "bun:test";
21
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ import { AUDIT_RETRIEVED_CAP, createAuditLogger, type AuditEntry } from "../audit-log.js";
26
+ import { createRetriever } from "../retriever.js";
27
+
28
+ function recordingLogger(): { entries: AuditEntry[]; logger: any } {
29
+ const entries: AuditEntry[] = [];
30
+ return {
31
+ entries,
32
+ logger: {
33
+ log(entry: Omit<AuditEntry, "timestamp">) {
34
+ entries.push({ timestamp: new Date().toISOString(), ...entry });
35
+ },
36
+ getRecent: () => entries,
37
+ exportAll: () => entries,
38
+ count: () => entries.length,
39
+ },
40
+ };
41
+ }
42
+
43
+ interface FakeRow {
44
+ id: string;
45
+ text: string;
46
+ metadata?: Record<string, unknown>;
47
+ score?: number;
48
+ }
49
+
50
+ function buildRetriever(rows: FakeRow[]) {
51
+ const results = rows.map((row) => ({
52
+ entry: {
53
+ id: row.id,
54
+ text: row.text,
55
+ vector: [1, 0, 0],
56
+ category: "cases",
57
+ scope: "memory",
58
+ importance: 0.8,
59
+ timestamp: Date.parse("2026-08-01T00:00:00.000Z"),
60
+ ...(row.metadata ? { metadata: JSON.stringify(row.metadata) } : {}),
61
+ },
62
+ score: row.score ?? 0.9,
63
+ }));
64
+
65
+ return createRetriever(
66
+ {
67
+ hasFtsSupport: false,
68
+ async vectorSearch() {
69
+ return results;
70
+ },
71
+ } as any,
72
+ {
73
+ async embedQuery() {
74
+ return [1, 0, 0];
75
+ },
76
+ async embedPassage() {
77
+ return [1, 0, 0];
78
+ },
79
+ } as any,
80
+ {
81
+ mode: "vector",
82
+ rerank: "none",
83
+ filterNoise: false,
84
+ hardMinScore: 0,
85
+ minScore: 0,
86
+ recencyWeight: 0,
87
+ timeDecayHalfLifeDays: 0,
88
+ },
89
+ );
90
+ }
91
+
92
+ describe("retrieve audit records revision and provenance", () => {
93
+ it("records the served revision, lifecycle status, and boundary of each hit", async () => {
94
+ const { entries, logger } = recordingLogger();
95
+ const retriever = buildRetriever([
96
+ {
97
+ id: "aaaaaaaa-1111-2222-3333-444444444444",
98
+ text: "Alice uses Bun, not npm",
99
+ metadata: {
100
+ evolution: { status: "active", version: 3 },
101
+ boundary: {
102
+ layer: "durable",
103
+ authority: "structured-memory",
104
+ conflictPolicy: "latest-wins",
105
+ },
106
+ },
107
+ },
108
+ ]);
109
+ retriever.setAuditLogger(logger);
110
+
111
+ await retriever.retrieve({ query: "which runtime does recallnest use", scopeFilter: ["memory"], limit: 5 });
112
+
113
+ const row = entries.find((e) => e.operation === "retrieve");
114
+ expect(row).toBeDefined();
115
+ expect(row?.retrieved).toEqual([
116
+ { id: "aaaaaaaa", rev: 3, st: "active", ly: "durable", au: "structured-memory" },
117
+ ]);
118
+ // The pre-existing details string is untouched — this extends the row, not replaces it.
119
+ expect(row?.details).toContain('query="which runtime does recallnest use"');
120
+ expect(row?.details).toContain("hits=1");
121
+ });
122
+
123
+ it("distinguishes a superseded revision from the live one", async () => {
124
+ const { entries, logger } = recordingLogger();
125
+ // Both rows are reachable by design: belief history is archive-first, so a query can
126
+ // legitimately surface the old text. Without `rev`/`st` the audit row cannot tell
127
+ // afterwards whether the agent read the current belief or the retired one.
128
+ const retriever = buildRetriever([
129
+ {
130
+ id: "cccccccc-0000-0000-0000-000000000000",
131
+ text: "Deploys go through the mini",
132
+ metadata: {
133
+ evolution: { status: "active", version: 2 },
134
+ boundary: { layer: "durable", authority: "structured-memory", conflictPolicy: "latest-wins" },
135
+ },
136
+ },
137
+ {
138
+ id: "hhhhhhhh-0000-0000-0000-000000000000",
139
+ text: "Deploys go through the MacBook",
140
+ metadata: {
141
+ evolution: { status: "superseded", version: 1, supersededBy: "cccccccc-0000-0000-0000-000000000000" },
142
+ boundary: { layer: "durable", authority: "structured-memory", conflictPolicy: "latest-wins" },
143
+ },
144
+ score: 0.85,
145
+ },
146
+ ]);
147
+ retriever.setAuditLogger(logger);
148
+
149
+ await retriever.retrieve({
150
+ query: "where do deploys run",
151
+ scopeFilter: ["memory"],
152
+ limit: 5,
153
+ // Superseded rows are excluded by default; this is the path that can serve one.
154
+ includeArchived: true,
155
+ });
156
+
157
+ const row = entries.find((e) => e.operation === "retrieve");
158
+ const served = row?.retrieved ?? [];
159
+ const byId = new Map(served.map((r) => [r.id, r]));
160
+
161
+ expect(byId.get("cccccccc")).toMatchObject({ rev: 2, st: "active" });
162
+ expect(byId.get("hhhhhhhh")).toMatchObject({ rev: 1, st: "superseded" });
163
+ });
164
+
165
+ it("defaults a memory with no evolution block to revision 1 / active", async () => {
166
+ const { entries, logger } = recordingLogger();
167
+ // Pre-versioning rows exist in the live database; they must not break the audit row.
168
+ const retriever = buildRetriever([
169
+ { id: "eeeeeeee-0000-0000-0000-000000000000", text: "legacy entry with no metadata" },
170
+ ]);
171
+ retriever.setAuditLogger(logger);
172
+
173
+ await retriever.retrieve({ query: "legacy", scopeFilter: ["memory"], limit: 5 });
174
+
175
+ const row = entries.find((e) => e.operation === "retrieve");
176
+ expect(row?.retrieved).toEqual([{ id: "eeeeeeee", rev: 1, st: "active" }]);
177
+ // No boundary metadata means no layer/authority claim — absent, not guessed.
178
+ expect(row?.retrieved?.[0]).not.toHaveProperty("ly");
179
+ expect(row?.retrieved?.[0]).not.toHaveProperty("au");
180
+ });
181
+
182
+ it("caps the list and says so, rather than silently reporting a short one", async () => {
183
+ const { entries, logger } = recordingLogger();
184
+ const overCap = AUDIT_RETRIEVED_CAP + 4;
185
+ const retriever = buildRetriever(
186
+ Array.from({ length: overCap }, (_, i) => ({
187
+ id: `${String(i).padStart(8, "0")}-0000-0000-0000-000000000000`,
188
+ text: `candidate ${i}`,
189
+ metadata: { evolution: { status: "active", version: 1 } },
190
+ score: 0.9 - i * 0.001,
191
+ })),
192
+ );
193
+ retriever.setAuditLogger(logger);
194
+
195
+ await retriever.retrieve({ query: "many candidate rows for one query", scopeFilter: ["memory"], limit: overCap });
196
+
197
+ const row = entries.find((e) => e.operation === "retrieve");
198
+ expect(row?.retrieved).toHaveLength(AUDIT_RETRIEVED_CAP);
199
+ // audit.jsonl has no rotation, so the list is bounded — but truncation must be legible.
200
+ expect(row?.retrievedTotal).toBe(overCap);
201
+ expect(row?.details).toContain(`hits=${overCap}`);
202
+ });
203
+
204
+ it("round-trips the new fields through the real file logger", async () => {
205
+ // The unit tests above assert against an in-memory logger. This one proves the fields
206
+ // survive the actual JSONL path — serialization, append, and parse-back — so a shape
207
+ // that only works in tests cannot pass. Uses a temp dir, never the production log.
208
+ const dir = mkdtempSync(join(tmpdir(), "recallnest-audit-"));
209
+ const logPath = join(dir, "audit.jsonl");
210
+
211
+ try {
212
+ const retriever = buildRetriever([
213
+ {
214
+ id: "ffffffff-0000-0000-0000-000000000000",
215
+ text: "Release verification runs on the mini",
216
+ metadata: {
217
+ evolution: { status: "active", version: 5 },
218
+ boundary: { layer: "evidence", authority: "distillation", conflictPolicy: "append-only" },
219
+ },
220
+ },
221
+ ]);
222
+ retriever.setAuditLogger(createAuditLogger(logPath));
223
+
224
+ await retriever.retrieve({
225
+ query: "where does release verification run",
226
+ scopeFilter: ["memory"],
227
+ limit: 5,
228
+ });
229
+
230
+ const lines = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean);
231
+ const row = lines.map((l) => JSON.parse(l) as AuditEntry).find((e) => e.operation === "retrieve");
232
+
233
+ expect(row?.retrieved).toEqual([
234
+ { id: "ffffffff", rev: 5, st: "active", ly: "evidence", au: "distillation" },
235
+ ]);
236
+ expect(typeof row?.timestamp).toBe("string");
237
+ } finally {
238
+ rmSync(dir, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ it("omits both fields when nothing was served", async () => {
243
+ const { entries, logger } = recordingLogger();
244
+ const retriever = buildRetriever([]);
245
+ retriever.setAuditLogger(logger);
246
+
247
+ await retriever.retrieve({ query: "a question with no stored answer", scopeFilter: ["memory"], limit: 5 });
248
+
249
+ const row = entries.find((e) => e.operation === "retrieve");
250
+ expect(row).toBeDefined();
251
+ expect(row).not.toHaveProperty("retrieved");
252
+ // retrievedTotal exists to flag truncation; with an empty list there is none to flag.
253
+ expect(row).not.toHaveProperty("retrievedTotal");
254
+ });
255
+ });
@@ -48,10 +48,14 @@ function makeEntry(id: string): MemoryEntry {
48
48
  };
49
49
  }
50
50
 
51
- function createMockStore(entries: MemoryEntry[]): Pick<MemoryStore, "list" | "stats" | "getVectors"> {
51
+ function createMockStore(entries: MemoryEntry[]): Pick<MemoryStore, "list" | "listPage" | "stats" | "getVectors"> {
52
52
  return {
53
53
  // 复刻真实 store:list() 为性能不返回向量,诊断检查经 getVectors 补回。
54
54
  async list() { return entries.map(e => ({ ...e, vector: [] })); },
55
+ async listPage(opts: { limit?: number; offset?: number } = {}) {
56
+ const { limit = 1000, offset = 0 } = opts;
57
+ return entries.slice(offset, offset + limit).map(e => ({ ...e, vector: [] }));
58
+ },
55
59
  async stats() {
56
60
  return { totalCount: entries.length, scopeCounts: {}, categoryCounts: {} };
57
61
  },
@@ -62,7 +66,7 @@ function createMockStore(entries: MemoryEntry[]): Pick<MemoryStore, "list" | "st
62
66
  }
63
67
  return map;
64
68
  },
65
- } as Pick<MemoryStore, "list" | "stats" | "getVectors">;
69
+ } as Pick<MemoryStore, "list" | "listPage" | "stats" | "getVectors">;
66
70
  }
67
71
 
68
72
  // ---------------------------------------------------------------------------
@@ -203,3 +203,33 @@ describe("MemoryStore.listPage", () => {
203
203
  expect(withVec.vector).toEqual([1, 0, 0]);
204
204
  });
205
205
  });
206
+
207
+ describe("MemoryStore.repairSingletonVersionGroups", () => {
208
+ it("dissolves only singleton groups and preserves all unrelated metadata", async () => {
209
+ const store = createStore();
210
+ const singleton = await seed(store, {
211
+ text: "singleton",
212
+ metadata: JSON.stringify({
213
+ version_group: "vg-single",
214
+ version_rank: 1.4,
215
+ version_created: "2026-07-07T00:00:00.000Z",
216
+ marker: "keep",
217
+ }),
218
+ });
219
+ const pairA = await seed(store, {
220
+ text: "pair-a",
221
+ metadata: JSON.stringify({ version_group: "vg-pair", version_rank: 2, version_created: "2026-07-07T00:00:00.000Z" }),
222
+ });
223
+ const pairB = await seed(store, {
224
+ text: "pair-b",
225
+ metadata: JSON.stringify({ version_group: "vg-pair", version_rank: 1, version_created: "2026-07-07T00:00:00.000Z" }),
226
+ });
227
+
228
+ expect(await store.repairSingletonVersionGroups()).toBe(1);
229
+ const singletonMeta = JSON.parse((await store.getById(singleton.id))!.metadata!);
230
+ expect(singletonMeta).toEqual({ marker: "keep" });
231
+ expect(JSON.parse((await store.getById(pairA.id))!.metadata!).version_group).toBe("vg-pair");
232
+ expect(JSON.parse((await store.getById(pairB.id))!.metadata!).version_group).toBe("vg-pair");
233
+ expect(await store.repairSingletonVersionGroups()).toBe(0);
234
+ });
235
+ });
@@ -0,0 +1,252 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ CLUSTER_INSIGHT_PROMPT,
5
+ CLUSTER_PATTERN_PROMPT,
6
+ PROMPT_ECHO_MARKERS,
7
+ SYNTHESIS_MAX_LEN,
8
+ allocateClusterBudget,
9
+ numberClusterTexts,
10
+ parseSynthesisJson,
11
+ validateSynthesis,
12
+ } from "../synthesis-contract.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // 输入预算分配(R3:老路径 slice(0,2000) 让 64.2% 的簇被截,p10 只有 13.8% 的源进得了模型)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe("allocateClusterBudget", () => {
19
+ it("总量不超预算", () => {
20
+ const texts = Array.from({ length: 20 }, () => "x".repeat(5000));
21
+ const out = allocateClusterBudget(texts, 1000);
22
+ expect(out.reduce((s, t) => s + t.length, 0)).toBeLessThanOrEqual(1000);
23
+ });
24
+
25
+ it("短成员整条保留,长成员才被截", () => {
26
+ const out = allocateClusterBudget(["短", "y".repeat(999)], 100);
27
+ expect(out[0]).toBe("短");
28
+ expect(out[1].length).toBeLessThan(999);
29
+ });
30
+
31
+ it("短成员用不完的份额让给长成员,而不是浪费掉", () => {
32
+ // 均分是 50/50;第一条只用 1 个字符,第二条应拿到远超 50 的份额。
33
+ const out = allocateClusterBudget(["短", "y".repeat(999)], 100);
34
+ expect(out[1].length).toBeGreaterThan(50);
35
+ });
36
+
37
+ it("靠后的成员不会被整条丢掉——这正是老 slice(0,2000) 的失效形态", () => {
38
+ // 老路径把 168 条拼成一条长文本再从头切,排在后面的成员一个字都进不了模型。
39
+ const texts = Array.from({ length: 50 }, (_, i) => `第${i}条`.repeat(200));
40
+ const out = allocateClusterBudget(texts, 2000);
41
+ expect(out.length).toBe(50);
42
+ expect(out.every(t => t.length > 0)).toBe(true);
43
+ // 最后一条必须真的携带自己的内容,而不是空壳
44
+ expect(out[49]).toContain("第49条");
45
+ });
46
+
47
+ it("空簇返回空数组", () => {
48
+ expect(allocateClusterBudget([], 100)).toEqual([]);
49
+ });
50
+ });
51
+
52
+ describe("numberClusterTexts", () => {
53
+ it("编号从 1 开始,与 evidence 的语义一致", () => {
54
+ expect(numberClusterTexts(["a", "b"])).toBe("[1] a\n[2] b");
55
+ });
56
+ });
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // JSON 解析
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe("parseSynthesisJson", () => {
63
+ it("裸 JSON", () => {
64
+ expect(parseSynthesisJson('{"has":false}')).toEqual({ has: false });
65
+ });
66
+
67
+ it("markdown 围栏", () => {
68
+ expect(parseSynthesisJson('```json\n{"has":false}\n```')).toEqual({ has: false });
69
+ });
70
+
71
+ it("带前言的回复也能抠出对象", () => {
72
+ expect(parseSynthesisJson('好的,结果如下:\n{"has":false}\n以上。')).toEqual({ has: false });
73
+ });
74
+
75
+ it("拿不到 JSON 时返回 null 而不是抛异常", () => {
76
+ expect(parseSynthesisJson("完全不是 JSON")).toBeNull();
77
+ expect(parseSynthesisJson(null)).toBeNull();
78
+ });
79
+
80
+ it("顶层是数组不算合法(契约要的是对象)", () => {
81
+ expect(parseSynthesisJson("[1,2,3]")).toBeNull();
82
+ });
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // 写库前校验 —— 每条 reject 分支都要有一条测试撑着,
87
+ // 因为这一层的全部意义就是「LLM 说了什么不算数」。
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const base = { textField: "conclusion", memberCount: 5, minEvidence: 1 };
91
+
92
+ describe("validateSynthesis", () => {
93
+ it("合格输出:去重 + 升序", () => {
94
+ const v = validateSynthesis({
95
+ ...base,
96
+ parsed: { has: true, conclusion: "必须先初始化再分派,否则读到的是初始化前的值", evidence: [3, 1, 3] },
97
+ });
98
+ expect(v.status).toBe("ok");
99
+ if (v.status === "ok") {
100
+ expect(v.output.evidence).toEqual([1, 3]);
101
+ expect(v.output.text).toContain("必须先初始化");
102
+ }
103
+ });
104
+
105
+ it("has:false 是弃权,不是失败——这条路径老 generateL0 根本没有", () => {
106
+ expect(validateSynthesis({ ...base, parsed: { has: false } }).status).toBe("abstained");
107
+ });
108
+
109
+ it("解析不出 JSON → unparsable", () => {
110
+ const v = validateSynthesis({ ...base, parsed: null });
111
+ expect(v).toEqual({ status: "rejected", reason: "unparsable" });
112
+ });
113
+
114
+ it("has 不是 boolean → 拒绝(不猜它的意图)", () => {
115
+ for (const has of ["true", 1, null, undefined]) {
116
+ const v = validateSynthesis({ ...base, parsed: { has, conclusion: "随便什么", evidence: [1] } });
117
+ expect(v).toEqual({ status: "rejected", reason: "flag-not-boolean" });
118
+ }
119
+ });
120
+
121
+ it("说有结论却没给正文 → empty-text", () => {
122
+ expect(validateSynthesis({ ...base, parsed: { has: true, evidence: [1] } }))
123
+ .toEqual({ status: "rejected", reason: "empty-text" });
124
+ expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: " ", evidence: [1] } }))
125
+ .toEqual({ status: "rejected", reason: "empty-text" });
126
+ });
127
+
128
+ it("长度越界两侧都拒", () => {
129
+ expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "太短", evidence: [1] } }))
130
+ .toEqual({ status: "rejected", reason: "too-short" });
131
+ expect(validateSynthesis({
132
+ ...base,
133
+ parsed: { has: true, conclusion: "长".repeat(SYNTHESIS_MAX_LEN + 1), evidence: [1] },
134
+ })).toEqual({ status: "rejected", reason: "too-long" });
135
+ });
136
+
137
+ it("evidence 缺失或不是数组 → evidence-missing", () => {
138
+ expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本" } }))
139
+ .toEqual({ status: "rejected", reason: "evidence-missing" });
140
+ expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: "1,2" } }))
141
+ .toEqual({ status: "rejected", reason: "evidence-missing" });
142
+ });
143
+
144
+ it("编号全部越界 → evidence-out-of-range(模型在编号,不是证据不够)", () => {
145
+ const v = validateSynthesis({
146
+ ...base,
147
+ parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: [99, 0, -1] },
148
+ });
149
+ expect(v).toEqual({ status: "rejected", reason: "evidence-out-of-range" });
150
+ });
151
+
152
+ it("pattern 侧只给 1 个合法编号 → evidence-too-few(跨条目按定义要 2 条)", () => {
153
+ const v = validateSynthesis({
154
+ parsed: { has: true, pattern: "一条长度足够越过下限的跨条目结论文本", evidence: [2] },
155
+ textField: "pattern",
156
+ memberCount: 5,
157
+ minEvidence: 2,
158
+ });
159
+ expect(v).toEqual({ status: "rejected", reason: "evidence-too-few" });
160
+ });
161
+
162
+ it("重复编号不算两条证据([2,2] 去重后只剩 1 个)", () => {
163
+ const v = validateSynthesis({
164
+ parsed: { has: true, pattern: "一条长度足够越过下限的跨条目结论文本", evidence: [2, 2] },
165
+ textField: "pattern",
166
+ memberCount: 5,
167
+ minEvidence: 2,
168
+ });
169
+ expect(v).toEqual({ status: "rejected", reason: "evidence-too-few" });
170
+ });
171
+
172
+ it("数字字符串编号可接受(模型常把编号写成字符串)", () => {
173
+ const v = validateSynthesis({
174
+ ...base,
175
+ parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: ["2", 3] },
176
+ });
177
+ expect(v.status).toBe("ok");
178
+ if (v.status === "ok") expect(v.output.evidence).toEqual([2, 3]);
179
+ });
180
+
181
+ it("非整数编号不算合法", () => {
182
+ const v = validateSynthesis({
183
+ ...base,
184
+ parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: [1.5, "abc"] },
185
+ });
186
+ expect(v).toEqual({ status: "rejected", reason: "evidence-out-of-range" });
187
+ });
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // 提示词回声 —— R4 的直接回归:库里曾有 28 条记忆的正文就是系统提示词原文
192
+ // ---------------------------------------------------------------------------
193
+
194
+ describe("提示词回声拦截(R4 回归)", () => {
195
+ it("拦下那 28 条真实泄漏的原文形态", () => {
196
+ const leaked = "保真规则:端口号/IP/URL/文件路径/API名称 → 原样保留;函数名/事件名/配置项 → 逐项保留不概括。";
197
+ const v = validateSynthesis({ ...base, parsed: { has: true, conclusion: leaked, evidence: [1, 2] } });
198
+ expect(v).toEqual({ status: "rejected", reason: "prompt-echo" });
199
+ });
200
+
201
+ it("新提示词自己的句子被回声也拦得住", () => {
202
+ const v = validateSynthesis({
203
+ ...base,
204
+ parsed: { has: true, conclusion: "提炼不出来就说没有。宁可不产出,也不要硬凑。", evidence: [1] },
205
+ });
206
+ expect(v).toEqual({ status: "rejected", reason: "prompt-echo" });
207
+ });
208
+
209
+ it("正常结论不被误杀(标记表宁可漏也不误杀真结论)", () => {
210
+ const normal = "批量删记忆要显式关掉 cascade,否则同 scope 内相似度高的条目会被连带降权。";
211
+ expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: normal, evidence: [1] } }).status).toBe("ok");
212
+ });
213
+
214
+ it("标记全部真的出现在提示词里——否则这道闸是摆设", () => {
215
+ // 反向断言:防有人改提示词时把某条标记的原文改掉,闸门静默失效而测试照绿。
216
+ const corpus = CLUSTER_INSIGHT_PROMPT + "\n" + CLUSTER_PATTERN_PROMPT;
217
+ const legacyOnly = new Set(["保真规则", "端口号/IP/URL/文件路径", "记忆索引助手", "记忆模式发现助手"]);
218
+ for (const marker of PROMPT_ECHO_MARKERS) {
219
+ if (legacyOnly.has(marker)) continue; // 老提示词的标记,本仓已无原文,保留是为了拦历史形态
220
+ expect(corpus).toContain(marker);
221
+ }
222
+ });
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // 提示词本身的契约:三件必须写死的事
227
+ // ---------------------------------------------------------------------------
228
+
229
+ describe("提示词契约", () => {
230
+ it("两个提示词都必须给出弃权出口", () => {
231
+ for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
232
+ expect(p).toContain('has": false');
233
+ }
234
+ });
235
+
236
+ it("两个提示词都必须显式禁掉性向归因措辞(56.1% 的直接来源)", () => {
237
+ for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
238
+ expect(p).toContain("倾向于");
239
+ expect(p).toContain("性格");
240
+ }
241
+ });
242
+
243
+ it("两个提示词都必须要求 evidence 编号", () => {
244
+ for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
245
+ expect(p).toContain("evidence");
246
+ }
247
+ });
248
+
249
+ it("pattern 侧必须写死 ≥2 条支撑(旧提示词写了但实现不校验,等于没写)", () => {
250
+ expect(CLUSTER_PATTERN_PROMPT).toContain("至少 2 条");
251
+ });
252
+ });