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,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2026-08-14 dream 写爆库根治的写入层回归:
|
|
3
|
+
* - store.patchMetadataBatch:真 LanceDB 实例上的批量读改写语义(锁内最新行起底、
|
|
4
|
+
* 按序应用、scope 过滤、单条失败跳过)
|
|
5
|
+
* - activity-counter.pruneWriteCounts:transcript 计数清扫(含未达标条目)
|
|
6
|
+
* - auto-gc 批量归档:maxArchivePerRun 命中时不弃单(flush 后再停)
|
|
7
|
+
*/
|
|
8
|
+
import { afterAll, describe, expect, it } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { MemoryStore, type MemoryEntry } from "../store.js";
|
|
14
|
+
import { incrementWriteCount, getWriteCount, pruneWriteCounts, listScopesAboveThreshold } from "../activity-counter.js";
|
|
15
|
+
import { isTranscriptScope } from "../memory-boundaries.js";
|
|
16
|
+
import { maybeRunGc, resetGcTimestamp } from "../auto-gc.js";
|
|
17
|
+
|
|
18
|
+
const tmpDirs: string[] = [];
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function makeIsolatedStore(): { store: MemoryStore; statsPath: string } {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "rn-batch-"));
|
|
25
|
+
tmpDirs.push(dir);
|
|
26
|
+
return { store: new MemoryStore({ dbPath: join(dir, "lancedb"), vectorDim: 3 }), statsPath: join(dir, "activity-stats.json") };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("store.patchMetadataBatch(真 LanceDB)", () => {
|
|
30
|
+
it("一次调用批量写 N 行,最终态各自生效", async () => {
|
|
31
|
+
const { store } = makeIsolatedStore();
|
|
32
|
+
const scope = "project:batch";
|
|
33
|
+
const a = await store.store({ text: "row a", vector: [1, 0, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
|
|
34
|
+
const b = await store.store({ text: "row b", vector: [0, 1, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
|
|
35
|
+
|
|
36
|
+
const written = await store.patchMetadataBatch([
|
|
37
|
+
{ id: a.id, patchFn: meta => ({ ...meta, marker: "A" }) },
|
|
38
|
+
{ id: b.id, patchFn: meta => ({ ...meta, marker: "B" }) },
|
|
39
|
+
], [scope]);
|
|
40
|
+
|
|
41
|
+
expect(written).toBe(2);
|
|
42
|
+
expect(JSON.parse((await store.getById(a.id))!.metadata!).marker).toBe("A");
|
|
43
|
+
expect(JSON.parse((await store.getById(b.id))!.metadata!).marker).toBe("B");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("patchFn 以库中最新行起底:批量写不吞前一次独立 update 写入的键", async () => {
|
|
47
|
+
const { store } = makeIsolatedStore();
|
|
48
|
+
const scope = "project:fresh";
|
|
49
|
+
const a = await store.store({ text: "row", vector: [1, 0, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
|
|
50
|
+
// 先经独立通道写入一个键(模拟 3a 已提交的 version_group)
|
|
51
|
+
await store.update(a.id, { metadata: JSON.stringify({ version_group: "vg-keep" }) }, [scope]);
|
|
52
|
+
// 批量 patch 只声明自己的键 —— 旧"预物化整串"接口在这里会把 version_group 抹掉
|
|
53
|
+
await store.patchMetadataBatch([
|
|
54
|
+
{ id: a.id, patchFn: meta => ({ ...meta, consolidatedInto: "x" }) },
|
|
55
|
+
], [scope]);
|
|
56
|
+
const final = JSON.parse((await store.getById(a.id))!.metadata!);
|
|
57
|
+
expect(final.version_group).toBe("vg-keep");
|
|
58
|
+
expect(final.consolidatedInto).toBe("x");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("同批 patchFn 按数组序执行(闭包可传递决策)", async () => {
|
|
62
|
+
const { store } = makeIsolatedStore();
|
|
63
|
+
const scope = "project:seq";
|
|
64
|
+
const a = await store.store({ text: "first", vector: [1, 0, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
|
|
65
|
+
const b = await store.store({ text: "second", vector: [0, 1, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
|
|
66
|
+
let decided: string | null = null;
|
|
67
|
+
await store.patchMetadataBatch([
|
|
68
|
+
{ id: a.id, patchFn: meta => { decided = "from-first"; return { ...meta, tag: "lead" }; } },
|
|
69
|
+
{ id: b.id, patchFn: meta => ({ ...meta, tag: decided }) },
|
|
70
|
+
], [scope]);
|
|
71
|
+
expect(JSON.parse((await store.getById(b.id))!.metadata!).tag).toBe("from-first");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("scope 过滤拒绝越权行、不存在的 id 跳过、空数组返回 0", async () => {
|
|
75
|
+
const { store } = makeIsolatedStore();
|
|
76
|
+
const a = await store.store({ text: "in scope", vector: [1, 0, 0], category: "events", scope: "project:x", importance: 0.5, metadata: "{}" });
|
|
77
|
+
const written = await store.patchMetadataBatch([
|
|
78
|
+
{ id: a.id, patchFn: meta => ({ ...meta, touched: true }) },
|
|
79
|
+
{ id: "00000000-0000-4000-8000-000000000000", patchFn: meta => meta },
|
|
80
|
+
], ["project:other"]); // a 不在此 scope
|
|
81
|
+
expect(written).toBe(0);
|
|
82
|
+
expect(JSON.parse((await store.getById(a.id))!.metadata!).touched).toBeUndefined();
|
|
83
|
+
expect(await store.patchMetadataBatch([], ["project:x"])).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("单条 patchFn 抛错跳过该条,其余照写", async () => {
|
|
87
|
+
const { store } = makeIsolatedStore();
|
|
88
|
+
const scope = "project:err";
|
|
89
|
+
const a = await store.store({ text: "bad", vector: [1, 0, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
|
|
90
|
+
const b = await store.store({ text: "good", vector: [0, 1, 0], category: "events", scope, importance: 0.5, metadata: "{}" });
|
|
91
|
+
const written = await store.patchMetadataBatch([
|
|
92
|
+
{ id: a.id, patchFn: () => { throw new Error("boom"); } },
|
|
93
|
+
{ id: b.id, patchFn: meta => ({ ...meta, ok: true }) },
|
|
94
|
+
], [scope]);
|
|
95
|
+
expect(written).toBe(1);
|
|
96
|
+
expect(JSON.parse((await store.getById(b.id))!.metadata!).ok).toBe(true);
|
|
97
|
+
expect(JSON.parse((await store.getById(a.id))!.metadata!).ok).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("pruneWriteCounts(transcript 计数清扫)", () => {
|
|
102
|
+
it("清掉全部 transcript 条目(达标与未达标),durable 一律保留", async () => {
|
|
103
|
+
const dir = mkdtempSync(join(tmpdir(), "rn-prune-"));
|
|
104
|
+
tmpDirs.push(dir);
|
|
105
|
+
const cfg = { statsPath: join(dir, "activity-stats.json") };
|
|
106
|
+
await incrementWriteCount("codex:aaaa1111", 15, cfg); // transcript 达标
|
|
107
|
+
await incrementWriteCount("kimi:bbbb2222", 3, cfg); // transcript 未达标
|
|
108
|
+
await incrementWriteCount("project:harness", 12, cfg); // durable 达标
|
|
109
|
+
await incrementWriteCount("memory", 2, cfg); // durable 未达标
|
|
110
|
+
|
|
111
|
+
const removed = await pruneWriteCounts(isTranscriptScope, cfg);
|
|
112
|
+
|
|
113
|
+
expect(removed.sort()).toEqual(["codex:aaaa1111", "kimi:bbbb2222"]);
|
|
114
|
+
expect(getWriteCount("codex:aaaa1111", cfg)).toBe(0);
|
|
115
|
+
expect(getWriteCount("kimi:bbbb2222", cfg)).toBe(0);
|
|
116
|
+
expect(getWriteCount("project:harness", cfg)).toBe(12);
|
|
117
|
+
expect(getWriteCount("memory", cfg)).toBe(2);
|
|
118
|
+
expect(listScopesAboveThreshold(10, cfg)).toEqual(["project:harness"]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("auto-gc 批量归档的上限语义", () => {
|
|
123
|
+
it("maxArchivePerRun 命中时已选中的候选照常提交(flush 不弃单)", async () => {
|
|
124
|
+
resetGcTimestamp();
|
|
125
|
+
const oldTs = Date.now() - 120 * 86_400_000;
|
|
126
|
+
const activeMeta = (): string => JSON.stringify({
|
|
127
|
+
evolution: { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, validFrom: oldTs, validUntil: null },
|
|
128
|
+
});
|
|
129
|
+
const rows: Array<{ id: string; text: string; importance: number; timestamp: number; metadata: string; category: string; scope: string }> = [
|
|
130
|
+
{ id: "g1", text: "old1", importance: 0.1, timestamp: oldTs, metadata: activeMeta(), category: "events", scope: "project:gc" },
|
|
131
|
+
{ id: "g2", text: "old2", importance: 0.1, timestamp: oldTs, metadata: activeMeta(), category: "events", scope: "project:gc" },
|
|
132
|
+
{ id: "g3", text: "old3", importance: 0.1, timestamp: oldTs, metadata: activeMeta(), category: "events", scope: "project:gc" },
|
|
133
|
+
];
|
|
134
|
+
const batchCalls: number[] = [];
|
|
135
|
+
const store = {
|
|
136
|
+
async stats() { return { totalCount: 100, scopeCounts: {}, categoryCounts: {} }; },
|
|
137
|
+
async repairSingletonVersionGroups() { return 0; },
|
|
138
|
+
async list() { return rows; },
|
|
139
|
+
async listPage(opts: { limit?: number; offset?: number } = {}) {
|
|
140
|
+
const { limit = 1000, offset = 0 } = opts;
|
|
141
|
+
return rows.slice(offset, offset + limit);
|
|
142
|
+
},
|
|
143
|
+
async update() { return null; },
|
|
144
|
+
async patchMetadataBatch(
|
|
145
|
+
patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: unknown) => Record<string, unknown> }>,
|
|
146
|
+
) {
|
|
147
|
+
batchCalls.push(patches.length);
|
|
148
|
+
for (const { id, patchFn } of patches) {
|
|
149
|
+
const row = rows.find(r => r.id === id);
|
|
150
|
+
if (!row) continue;
|
|
151
|
+
row.metadata = JSON.stringify(patchFn(JSON.parse(row.metadata) as Record<string, unknown>, row));
|
|
152
|
+
}
|
|
153
|
+
return patches.length;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const result = await maybeRunGc(
|
|
158
|
+
store as unknown as Parameters<typeof maybeRunGc>[0],
|
|
159
|
+
{ minMemoryCount: 1, minHoursSinceLastGc: 0, decayScoreThreshold: 0.99, maxArchivePerRun: 2, minAgeDays: 30 },
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(result.archivedCount).toBe(2); // 上限生效
|
|
163
|
+
const archived = rows.filter(r => (JSON.parse(r.metadata) as { evolution: { status: string } }).evolution.status === "archived");
|
|
164
|
+
expect(archived.length).toBe(2); // 且真的写进去了(break 前 flush,不弃单)
|
|
165
|
+
expect(batchCalls.reduce((a, b) => a + b, 0)).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("即使低于归档数量门槛也会自愈 singleton version-group", async () => {
|
|
169
|
+
resetGcTimestamp();
|
|
170
|
+
let repairCalls = 0;
|
|
171
|
+
const store = {
|
|
172
|
+
async stats() { return { totalCount: 100, scopeCounts: {}, categoryCounts: {} }; },
|
|
173
|
+
async repairSingletonVersionGroups() { repairCalls++; return 1; },
|
|
174
|
+
async listPage() { return []; },
|
|
175
|
+
async patchMetadataBatch() { return 0; },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const result = await maybeRunGc(
|
|
179
|
+
store as unknown as Parameters<typeof maybeRunGc>[0],
|
|
180
|
+
{ minMemoryCount: 1000, minHoursSinceLastGc: 0, decayScoreThreshold: 0.15, maxArchivePerRun: 100, minAgeDays: 30 },
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(repairCalls).toBe(1);
|
|
184
|
+
expect(result.triggered).toBe(false);
|
|
185
|
+
expect(result.reason).toBe("below_memory_threshold");
|
|
186
|
+
expect(result.dissolvedVersionGroups).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("单成员组自愈有独立节流,不会被每个 dream scope 重复全库扫描", async () => {
|
|
190
|
+
resetGcTimestamp();
|
|
191
|
+
let repairCalls = 0;
|
|
192
|
+
const store = {
|
|
193
|
+
async stats() { return { totalCount: 100, scopeCounts: {}, categoryCounts: {} }; },
|
|
194
|
+
async repairSingletonVersionGroups() { repairCalls++; return 0; },
|
|
195
|
+
async listPage() { return []; },
|
|
196
|
+
async patchMetadataBatch() { return 0; },
|
|
197
|
+
};
|
|
198
|
+
const config = {
|
|
199
|
+
minMemoryCount: 1000,
|
|
200
|
+
minHoursSinceLastGc: 24,
|
|
201
|
+
decayScoreThreshold: 0.15,
|
|
202
|
+
maxArchivePerRun: 100,
|
|
203
|
+
minAgeDays: 30,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
await maybeRunGc(store as unknown as Parameters<typeof maybeRunGc>[0], config);
|
|
207
|
+
await maybeRunGc(store as unknown as Parameters<typeof maybeRunGc>[0], config);
|
|
208
|
+
|
|
209
|
+
expect(repairCalls).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Architecture guard for the invariant env-config.ts declares in its own header:
|
|
7
|
+
* it is the single source of truth for every fixed `RECALLNEST_*` env read, and
|
|
8
|
+
* consumers must call its accessors instead of touching `process.env` directly.
|
|
9
|
+
*
|
|
10
|
+
* That invariant was documented but unenforced, and it had already sprung a leak:
|
|
11
|
+
* `cli.ts` read RECALLNEST_DREAM_BUDGET_MS inline and — precisely because it
|
|
12
|
+
* bypassed the accessor layer — was the one read in the repo with no
|
|
13
|
+
* `Number.isFinite` guard, so `""` silently became a 0ms dream budget and any
|
|
14
|
+
* non-numeric value became a NaN deadline that disabled the guard entirely.
|
|
15
|
+
* Bypassing the chokepoint IS how the guard gets lost; hence a test, not a note.
|
|
16
|
+
*
|
|
17
|
+
* Baseline: 0 violations as of 2026-08-18. If this goes red, the fix is normally
|
|
18
|
+
* to add an accessor to env-config.ts — not to widen EXEMPT.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately NOT matched (these are the exemptions env-config.ts's header
|
|
21
|
+
* already names, and each reads differently so the pattern never sees them):
|
|
22
|
+
* - scope-policy.ts → `env.RECALLNEST_*` on an injected env object
|
|
23
|
+
* - store.ts → RECALLNEST_NS is a local constant, not an env var
|
|
24
|
+
* - runtime-config / embedder / llm-client → dynamic `process.env[name]`
|
|
25
|
+
* config-template expansion, which cannot be statically consolidated
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Add a file here ONLY with a reason, and only when an accessor genuinely can't
|
|
29
|
+
// work (e.g. the read must happen before env-config's module-init boundary).
|
|
30
|
+
const EXEMPT: ReadonlyArray<{ file: string; why: string }> = [];
|
|
31
|
+
|
|
32
|
+
const DIRECT_READ = /process\.env\.RECALLNEST_[A-Z_0-9]+/g;
|
|
33
|
+
|
|
34
|
+
function collectSourceFiles(dir: string, out: string[] = []): string[] {
|
|
35
|
+
for (const entry of readdirSync(dir)) {
|
|
36
|
+
const full = join(dir, entry);
|
|
37
|
+
if (statSync(full).isDirectory()) {
|
|
38
|
+
if (entry === "__tests__" || entry === "node_modules") continue;
|
|
39
|
+
collectSourceFiles(full, out);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!entry.endsWith(".ts") || entry.endsWith(".test.ts") || entry === "env-config.ts") continue;
|
|
43
|
+
out.push(full);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("env-config is the chokepoint for RECALLNEST_* reads", () => {
|
|
49
|
+
it("no src/ module reads process.env.RECALLNEST_* directly", () => {
|
|
50
|
+
const exemptFiles = new Set(EXEMPT.map((e) => e.file));
|
|
51
|
+
const violations: string[] = [];
|
|
52
|
+
|
|
53
|
+
for (const file of collectSourceFiles("src")) {
|
|
54
|
+
if (exemptFiles.has(file)) continue;
|
|
55
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
56
|
+
lines.forEach((line, i) => {
|
|
57
|
+
for (const hit of line.match(DIRECT_READ) ?? []) {
|
|
58
|
+
violations.push(`${file}:${i + 1} ${hit}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
violations,
|
|
65
|
+
violations.length === 0
|
|
66
|
+
? ""
|
|
67
|
+
: `Direct RECALLNEST_* env reads bypass env-config.ts's guards:\n ${violations.join("\n ")}\n` +
|
|
68
|
+
`Fix: add an accessor to src/env-config.ts (with its parsing + fallback) and call it here.`,
|
|
69
|
+
).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("guard actually detects a violation (so a green run means something)", () => {
|
|
73
|
+
const synthetic = 'const x = Number(process.env.RECALLNEST_SOMETHING_NEW ?? 1);';
|
|
74
|
+
expect(synthetic.match(DIRECT_READ)).toHaveLength(1);
|
|
75
|
+
// And it must not fire on the exempted read styles:
|
|
76
|
+
expect('env.RECALLNEST_SESSION_ID'.match(DIRECT_READ)).toBeNull();
|
|
77
|
+
expect('process.env[envVar]'.match(DIRECT_READ)).toBeNull();
|
|
78
|
+
expect('const RECALLNEST_NS = "recallnest:v1";'.match(DIRECT_READ)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -21,6 +21,8 @@ const KEYS = [
|
|
|
21
21
|
"RECALLNEST_RECALL_MODE",
|
|
22
22
|
"RECALLNEST_UI_PORT",
|
|
23
23
|
"RECALLNEST_API_PORT",
|
|
24
|
+
"RECALLNEST_DREAM_BUDGET_MS",
|
|
25
|
+
"RECALLNEST_SYNTHESIS_MODEL",
|
|
24
26
|
];
|
|
25
27
|
|
|
26
28
|
const saved: Record<string, string | undefined> = {};
|
|
@@ -149,3 +151,61 @@ describe("env-config accessors are lazy (read at call time, never frozen)", () =
|
|
|
149
151
|
expect(envConfig.emotionScoring()).toBe(false);
|
|
150
152
|
});
|
|
151
153
|
});
|
|
154
|
+
|
|
155
|
+
describe("env-config dreamBudgetMs — the two silent failure modes it exists to stop", () => {
|
|
156
|
+
const FALLBACK = 2 * 60 * 60 * 1000;
|
|
157
|
+
|
|
158
|
+
it("falls back when unset", () => {
|
|
159
|
+
delete process.env.RECALLNEST_DREAM_BUDGET_MS;
|
|
160
|
+
expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(FALLBACK);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('empty string does NOT become 0 (old `?? default` let "" through → Number("") === 0 → every scope skipped)', () => {
|
|
164
|
+
process.env.RECALLNEST_DREAM_BUDGET_MS = "";
|
|
165
|
+
expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(FALLBACK);
|
|
166
|
+
process.env.RECALLNEST_DREAM_BUDGET_MS = " ";
|
|
167
|
+
expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(FALLBACK);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('non-numeric does NOT become NaN (NaN deadline makes `Date.now() > deadline` permanently false → budget guard gone)', () => {
|
|
171
|
+
for (const bad of ["2h", "abc", "30min", "1_000"]) {
|
|
172
|
+
process.env.RECALLNEST_DREAM_BUDGET_MS = bad;
|
|
173
|
+
const ms = envConfig.dreamBudgetMs(FALLBACK);
|
|
174
|
+
expect(Number.isFinite(ms)).toBe(true);
|
|
175
|
+
expect(ms).toBe(FALLBACK);
|
|
176
|
+
// The regression this guards: a NaN budget silently disables the deadline.
|
|
177
|
+
expect(Date.now() > Date.now() + ms).toBe(false);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("rejects zero and negatives (they would expire the deadline instantly)", () => {
|
|
182
|
+
for (const bad of ["0", "-1", "-60000"]) {
|
|
183
|
+
process.env.RECALLNEST_DREAM_BUDGET_MS = bad;
|
|
184
|
+
expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(FALLBACK);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("accepts a valid override", () => {
|
|
189
|
+
process.env.RECALLNEST_DREAM_BUDGET_MS = "900000";
|
|
190
|
+
expect(envConfig.dreamBudgetMs(FALLBACK)).toBe(900_000);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("env-config synthesisModel — dream 合成的模型旋钮", () => {
|
|
195
|
+
it("未设 → undefined(调用方回落到 config.llm.model,保持老行为)", () => {
|
|
196
|
+
delete process.env.RECALLNEST_SYNTHESIS_MODEL;
|
|
197
|
+
expect(envConfig.synthesisModel()).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("空串 / 纯空白也算未设,不会把模型名设成空字符串", () => {
|
|
201
|
+
for (const blank of ["", " ", "\t"]) {
|
|
202
|
+
process.env.RECALLNEST_SYNTHESIS_MODEL = blank;
|
|
203
|
+
expect(envConfig.synthesisModel()).toBeUndefined();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("正常值去空白后返回", () => {
|
|
208
|
+
process.env.RECALLNEST_SYNTHESIS_MODEL = " qwen-plus ";
|
|
209
|
+
expect(envConfig.synthesisModel()).toBe("qwen-plus");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 每个会话来源的 scope 前缀必须在 memory-boundaries 的清单里登记。
|
|
3
|
+
*
|
|
4
|
+
* 这条约束是承重的,不是整洁问题:TRANSCRIPT_SCOPE_PREFIXES 决定了会话碎片
|
|
5
|
+
* 会不会被降权到 evidence 层。漏登记一个前缀,那个端的 shell 输出就会被当成
|
|
6
|
+
* 「用户说过的话」提炼成 durable 结论——kimi 与 antigravity 就这么漏过
|
|
7
|
+
* (2026-07 进栈,直到 2026-08-01 才发现库里一直被当结论对待)。
|
|
8
|
+
*
|
|
9
|
+
* 所以加新 ingest 源时,这个测试会在你只改了 config.json / cli.ts 却忘了
|
|
10
|
+
* 登记前缀的时候变红。
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "bun:test";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { isTranscriptIngestSource, isTranscriptScope } from "../memory-boundaries.js";
|
|
16
|
+
|
|
17
|
+
const config = JSON.parse(
|
|
18
|
+
readFileSync(join(import.meta.dir, "..", "..", "config.json"), "utf8"),
|
|
19
|
+
) as { sources: Record<string, { path: string; glob: string; description?: string }> };
|
|
20
|
+
|
|
21
|
+
// config 源名 → 它写进库的 scope 前缀。
|
|
22
|
+
// desktop 沿用 cc 前缀是历史行为(Claude Desktop 导出的就是 CC 格式对话)。
|
|
23
|
+
const SOURCE_SCOPE_PREFIX: Record<string, string | null> = {
|
|
24
|
+
cc: "cc",
|
|
25
|
+
codex: "codex",
|
|
26
|
+
gemini: "gemini",
|
|
27
|
+
kimi: "kimi",
|
|
28
|
+
desktop: "cc",
|
|
29
|
+
minis: "minis",
|
|
30
|
+
memory: null, // markdown 记忆文件,不是会话记录,不走 transcript 降权
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("ingest 源的 scope 前缀登记", () => {
|
|
34
|
+
it("config.json 里每个源都在本测试的映射表里有交代", () => {
|
|
35
|
+
for (const name of Object.keys(config.sources)) {
|
|
36
|
+
expect(
|
|
37
|
+
Object.prototype.hasOwnProperty.call(SOURCE_SCOPE_PREFIX, name),
|
|
38
|
+
).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("每个会话类来源的 scope 都被判定为 transcript 证据(会被降权)", () => {
|
|
43
|
+
for (const [name, prefix] of Object.entries(SOURCE_SCOPE_PREFIX)) {
|
|
44
|
+
if (prefix === null) continue;
|
|
45
|
+
// 断言里带上源名,红的时候直接看得出是哪个源漏登记
|
|
46
|
+
expect(`${name}:${isTranscriptScope(`${prefix}:abcd1234`)}`).toBe(`${name}:true`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("minis 源已接线:config 有条目且前缀被降权识别", () => {
|
|
51
|
+
expect(config.sources.minis).toBeDefined();
|
|
52
|
+
expect(config.sources.minis.glob).toBe("*.jsonl");
|
|
53
|
+
expect(isTranscriptScope("minis:9f3e7a21")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("同一个前缀也必须在 source 清单里登记(两道闸,漏一道就静默失真)", () => {
|
|
57
|
+
// isTranscriptScope 看 scope、isTranscriptSource 看条目的 source 字段。
|
|
58
|
+
// 只登记前者时降权仍然生效,错误不会报出来,只会让「按来源过滤」查不准。
|
|
59
|
+
for (const [name, prefix] of Object.entries(SOURCE_SCOPE_PREFIX)) {
|
|
60
|
+
if (prefix === null) continue;
|
|
61
|
+
expect(`${name}:${isTranscriptIngestSource(prefix)}`).toBe(`${name}:true`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("一条反向断言:没登记过的前缀不会被误判成 transcript", () => {
|
|
66
|
+
expect(isTranscriptScope("project:recallnest")).toBe(false);
|
|
67
|
+
expect(isTranscriptScope("memory:pivot")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -48,9 +48,11 @@ describe("runDream cross-process lock", () => {
|
|
|
48
48
|
bodyRan = true;
|
|
49
49
|
return { totalCount: 100, scopeCounts: {}, categoryCounts: {} };
|
|
50
50
|
},
|
|
51
|
+
repairSingletonVersionGroups: async () => 0,
|
|
51
52
|
list: async () => [],
|
|
52
53
|
listPage: async () => [],
|
|
53
54
|
update: async () => null,
|
|
55
|
+
patchMetadataBatch: async (patches: Array<{ id: string; patchFn: unknown }>) => patches.length,
|
|
54
56
|
patchMetadata: async () => null,
|
|
55
57
|
};
|
|
56
58
|
|
|
@@ -83,9 +85,11 @@ describe("runDream cross-process lock", () => {
|
|
|
83
85
|
}));
|
|
84
86
|
const store = {
|
|
85
87
|
stats: async () => ({ totalCount: 100, scopeCounts: {}, categoryCounts: {} }),
|
|
88
|
+
repairSingletonVersionGroups: async () => 0,
|
|
86
89
|
list: async () => entries,
|
|
87
90
|
listPage: async () => [],
|
|
88
91
|
update: async () => null,
|
|
92
|
+
patchMetadataBatch: async (patches: Array<{ id: string; patchFn: unknown }>) => patches.length,
|
|
89
93
|
patchMetadata: async () => null,
|
|
90
94
|
getById: async () => null,
|
|
91
95
|
vectorSearch: async () => [],
|
|
@@ -123,6 +127,7 @@ describe("runDream cross-process lock", () => {
|
|
|
123
127
|
|
|
124
128
|
const store = {
|
|
125
129
|
stats: async () => ({ totalCount: 5, scopeCounts: {}, categoryCounts: {} }),
|
|
130
|
+
repairSingletonVersionGroups: async () => 0,
|
|
126
131
|
// ⚠️ 2026-08-12 标注:这里 list 返回空数组,走的是 **completed_early 早退路径**,
|
|
127
132
|
// 而早退路径 (dream-pipeline.ts:226) 的 resetWriteCount(scope, statsCfg) 一直是对的。
|
|
128
133
|
// 本条测试因此从未覆盖「完整跑完」那条路径 —— 而 bug 恰恰在那条路径上(:301 漏传 scope)。
|
|
@@ -132,6 +137,7 @@ describe("runDream cross-process lock", () => {
|
|
|
132
137
|
list: async () => [], // no active entries → completed_early path (still resets)
|
|
133
138
|
listPage: async () => [],
|
|
134
139
|
update: async () => null,
|
|
140
|
+
patchMetadataBatch: async (patches: Array<{ id: string; patchFn: unknown }>) => patches.length,
|
|
135
141
|
patchMetadata: async () => null,
|
|
136
142
|
getById: async () => null,
|
|
137
143
|
vectorSearch: async () => [],
|
|
@@ -156,8 +162,10 @@ describe("maybeRunGc cross-process lock + throttle", () => {
|
|
|
156
162
|
function makeStore(totalCount: number) {
|
|
157
163
|
return {
|
|
158
164
|
stats: async () => ({ totalCount, scopeCounts: {}, categoryCounts: {} }),
|
|
165
|
+
repairSingletonVersionGroups: async () => 0,
|
|
159
166
|
listPage: async () => [] as unknown[],
|
|
160
167
|
update: async () => null,
|
|
168
|
+
patchMetadataBatch: async (patches: Array<{ id: string; patchFn: unknown }>) => patches.length,
|
|
161
169
|
list: async () => [],
|
|
162
170
|
} as any;
|
|
163
171
|
}
|
|
@@ -59,6 +59,7 @@ describe("B-2: Archive Strategy", () => {
|
|
|
59
59
|
return {
|
|
60
60
|
store: {
|
|
61
61
|
stats: async () => ({ totalCount: data.size, scopeCounts: {}, categoryCounts: {} }),
|
|
62
|
+
repairSingletonVersionGroups: async () => 0,
|
|
62
63
|
list: async () => Array.from(data.values()),
|
|
63
64
|
listPage: async (opts: { limit?: number; offset?: number } = {}) => {
|
|
64
65
|
const { limit = 1000, offset = 0 } = opts;
|
|
@@ -71,6 +72,22 @@ describe("B-2: Archive Strategy", () => {
|
|
|
71
72
|
entry.metadata = patch.metadata;
|
|
72
73
|
return entry;
|
|
73
74
|
},
|
|
75
|
+
// 2026-08-14 auto-gc 批量归档通道:以 data 最新行起底应用 patchFn(生产语义)
|
|
76
|
+
patchMetadataBatch: async (
|
|
77
|
+
patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: unknown) => Record<string, unknown> }>,
|
|
78
|
+
) => {
|
|
79
|
+
let written = 0;
|
|
80
|
+
for (const { id, patchFn } of patches) {
|
|
81
|
+
const entry = data.get(id);
|
|
82
|
+
if (!entry) continue;
|
|
83
|
+
let meta: Record<string, unknown>;
|
|
84
|
+
try { meta = JSON.parse(entry.metadata || "{}") as Record<string, unknown>; } catch { meta = {}; }
|
|
85
|
+
entry.metadata = JSON.stringify(patchFn(meta, entry));
|
|
86
|
+
updates.push({ id, metadata: entry.metadata });
|
|
87
|
+
written++;
|
|
88
|
+
}
|
|
89
|
+
return written;
|
|
90
|
+
},
|
|
74
91
|
},
|
|
75
92
|
updates,
|
|
76
93
|
};
|
|
@@ -25,6 +25,15 @@ function buildResult(id: string, metadata: Record<string, unknown>): RetrievalRe
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
describe("memory output", () => {
|
|
28
|
+
it("renders a whole-day age next to every date so nobody has to subtract", () => {
|
|
29
|
+
const result = buildResult("abcd1234-0000-0000-0000-000000000009", { source: "agent" });
|
|
30
|
+
const search = formatSearchResults([result], { query: "concise", profile: "default" } as any);
|
|
31
|
+
expect(search).toContain("Date Age Retrieval Path");
|
|
32
|
+
expect(search).toMatch(/2026-03-16 \d+d\s+vector/);
|
|
33
|
+
const explain = formatExplainResults([result], { query: "concise", profile: "default" } as any);
|
|
34
|
+
expect(explain).toMatch(/2026-03-16 \(\d+d\)/);
|
|
35
|
+
});
|
|
36
|
+
|
|
28
37
|
it("includes provenance in search results", () => {
|
|
29
38
|
const output = formatSearchResults([
|
|
30
39
|
buildResult("abcd1234-0000-0000-0000-000000000001", {
|