@modusensus/dsh-mneme 0.8.15 → 0.9.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/README.md +21 -3
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +16 -3
- package/docs/CONTINUITY.md +18 -3
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +294 -10
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2767 -337
- package/lib/config.js +99 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +37 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +7 -1
- package/lib/entities/extractor.js +4 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +72 -18
- package/lib/inject.js +33 -64
- package/lib/maintenance.js +25 -5
- package/lib/pipeline.js +534 -0
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/scope.js +16 -4
- package/lib/serve.js +1 -1
- package/lib/service.js +83 -17
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +19 -2
- package/lib/store.js +251 -10
- package/lib/summarize.js +239 -60
- package/lib/tools.js +11 -2
- package/package.json +1 -1
- package/scripts/publish-gate.mjs +197 -0
- package/src/api-standalone.js +22 -39
- package/src/api.js +294 -10
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +99 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +37 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +7 -1
- package/src/entities/extractor.js +4 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +72 -18
- package/src/inject.js +33 -64
- package/src/maintenance.js +25 -5
- package/src/pipeline.js +534 -0
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/scope.js +16 -4
- package/src/serve.js +1 -1
- package/src/service.js +83 -17
- package/src/session-usage.js +350 -0
- package/src/settings.js +19 -2
- package/src/store.js +251 -10
- package/src/summarize.js +239 -60
- package/src/tools.js +11 -2
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +171 -8
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/client.test.js +715 -4
- package/test/continuity.test.js +84 -0
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +68 -1
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +190 -1
- package/test/maintenance.test.js +112 -2
- package/test/mint-source.test.js +202 -0
- package/test/pipeline.test.js +505 -0
- package/test/placement.test.js +1 -1
- package/test/pre-inject-gate.test.js +10 -0
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/release-workflow.test.js +77 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/serve.test.js +1 -1
- package/test/service.test.js +7 -2
- package/test/session-usage.test.js +308 -0
- package/test/settings.test.js +8 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +350 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话日志使用证据(src/session-usage.js)的回归测试。
|
|
3
|
+
*
|
|
4
|
+
* 本文件锁的重点是**两类「静默少一大截」的坑**(都真踩过):
|
|
5
|
+
* ① **zstd 多帧**:`zlib.zstdDecompressSync(buf)` 只解**第一帧**且不报错——实测一个
|
|
6
|
+
* 1.5 MB 的会话文件只解出 291 字节,于是扫描得出「零命中」的假结论。所以这里用
|
|
7
|
+
* **两帧拼接的夹具**钉死「两帧都要解出来」。
|
|
8
|
+
* ② **参数在 `arguments` 且是 JSON 字符串**:取 `parameters` 会得到 null,看着像
|
|
9
|
+
* 「调用了但没记参数」。夹具按真实形态写。
|
|
10
|
+
*
|
|
11
|
+
* 另外钉住状态推导的语义边界:`candidate` 是「现存日志里没记录」,不是「从未使用」。
|
|
12
|
+
*/
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import zlib from "node:zlib";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
forEachFrame,
|
|
22
|
+
toolCallsInLine,
|
|
23
|
+
identifyCapability,
|
|
24
|
+
deriveUsageState,
|
|
25
|
+
collectUsage,
|
|
26
|
+
summarizeUsage,
|
|
27
|
+
resolveSessionDir,
|
|
28
|
+
findSessionFiles,
|
|
29
|
+
STALE_AFTER_DAYS
|
|
30
|
+
} from "../src/session-usage.js";
|
|
31
|
+
|
|
32
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
33
|
+
|
|
34
|
+
/** 把若干段文本各压成一帧再拼起来,模拟真实的多帧文件。 */
|
|
35
|
+
function multiFrameBuffer(chunks) {
|
|
36
|
+
return Buffer.concat(chunks.map((c) => zlib.zstdCompressSync(Buffer.from(c, "utf8"))));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 造一行真实的 tool-call 记录(参数是 JSON 字符串,不是对象)。 */
|
|
40
|
+
function toolCallLine({ name, args, turn = 1, time = 1700000000000 }) {
|
|
41
|
+
const item = { type: "tool-call", id: `call_${Math.random().toString(36).slice(2)}`, name };
|
|
42
|
+
if (args !== undefined) item.arguments = JSON.stringify(args);
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
type: "assistant/message",
|
|
45
|
+
time,
|
|
46
|
+
data: { turn, step: 1, message: { role: "assistant", content: [item] } }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 造一个会话目录,内容按给定数组写进一个文件。 */
|
|
51
|
+
function makeSessionDir(lines) {
|
|
52
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-"));
|
|
53
|
+
const sessionId = "session-test-0001";
|
|
54
|
+
const dir = join(base, "sessions", `--work-${sessionId.slice(0, 8)}--`, sessionId);
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
writeFileSync(join(dir, "session.v4.jsonl.zstd"), multiFrameBuffer(lines), "utf8");
|
|
57
|
+
return { base, dir: join(base, "sessions"), cleanup: () => rmSync(base, { recursive: true, force: true }) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("多帧 zstd:**每一帧**都要解出来(只解第一帧是踩过的坑)", () => {
|
|
61
|
+
const buf = multiFrameBuffer(["第一帧的内容", "第二帧的内容", "第三帧的内容"]);
|
|
62
|
+
const seen = [];
|
|
63
|
+
const stat = forEachFrame(buf, (text) => seen.push(text));
|
|
64
|
+
assert.deepEqual(seen, ["第一帧的内容", "第二帧的内容", "第三帧的内容"], "三帧都要被回调到");
|
|
65
|
+
assert.equal(stat.frames, 3);
|
|
66
|
+
assert.equal(stat.failed, 0);
|
|
67
|
+
// 反证:证明这个夹具真能暴露「只解第一帧」——直接 zstdDecompressSync 只能拿到第一段
|
|
68
|
+
assert.equal(zlib.zstdDecompressSync(buf).toString("utf8"), "第一帧的内容");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("多帧 zstd:每帧都解;截断产生的**半行**由 jsonl 层丢掉(宁可少一条,不要错一条)", () => {
|
|
72
|
+
// 真实的「坏帧」不是帧头损坏,而是**文件正在写**:末帧被截断。
|
|
73
|
+
// 实测结论(写这条时验过):zstd 是流式格式,截断帧会**解出部分文本而不抛错**,
|
|
74
|
+
// 且 Node 的绑定不暴露「还有多少输入未消费」,所以帧层的完整性判不了。
|
|
75
|
+
// 兜底放在**行层**:半行 JSON 解析失败会被丢弃——代价是少一条记录,收益是绝不误用。
|
|
76
|
+
const good = zlib.zstdCompressSync(Buffer.from("完整帧的内容", "utf8"));
|
|
77
|
+
|
|
78
|
+
// 帧层:两帧都要回调到(这是最初踩的坑——只解第一帧)
|
|
79
|
+
const seen = [];
|
|
80
|
+
const stat = forEachFrame(Buffer.concat([good, good]), (t) => seen.push(t));
|
|
81
|
+
assert.equal(stat.frames, 2);
|
|
82
|
+
assert.equal(seen.length, 2);
|
|
83
|
+
|
|
84
|
+
// 行层兜底:一个被截断的 tool-call 行不能被当完整记录用
|
|
85
|
+
const fullLine = toolCallLine({ name: "skill", args: { name: "agent-reach" } });
|
|
86
|
+
assert.equal(toolCallsInLine(fullLine).length, 1, "完整行要解析出来");
|
|
87
|
+
const halfLine = fullLine.slice(0, Math.floor(fullLine.length / 2));
|
|
88
|
+
assert.deepEqual(toolCallsInLine(halfLine), [], "半行必须被丢掉,不能猜出一个不完整的调用");
|
|
89
|
+
|
|
90
|
+
// 真实截断文件:整帧 + 半帧 → 完整的那些记录照常统计,半行被丢
|
|
91
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-trunc-"));
|
|
92
|
+
try {
|
|
93
|
+
const sessionId = "session-trunc-0001";
|
|
94
|
+
const dir = join(base, "sessions", "work", sessionId);
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
const truncated = zlib.zstdCompressSync(Buffer.from(fullLine, "utf8")).subarray(0, 12);
|
|
97
|
+
writeFileSync(join(dir, "session.v4.jsonl.zstd"), Buffer.concat([good, truncated]));
|
|
98
|
+
const c = collectUsage({ sessionDir: join(base, "sessions") });
|
|
99
|
+
assert.equal(c.scannedFiles, 1);
|
|
100
|
+
// 好帧里没有 tool-call,截断帧里的调用不完整 → 一条都不该被记
|
|
101
|
+
assert.equal(c.skillCalls, 0, "截断的半行不能被算成一次调用");
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(base, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("tool-call 解析:参数在 arguments 且是 JSON 字符串", () => {
|
|
108
|
+
// 真实形态:{"type":"tool-call","name":"skill","arguments":"{\"name\":\"agent-reach\"}"}
|
|
109
|
+
const line = toolCallLine({ name: "skill", args: { name: "agent-reach" }, turn: 7, time: 1792000000000 });
|
|
110
|
+
const calls = toolCallsInLine(line);
|
|
111
|
+
assert.equal(calls.length, 1);
|
|
112
|
+
assert.equal(calls[0].name, "skill");
|
|
113
|
+
assert.equal(calls[0].args.name, "agent-reach", "arguments 要二次解析成对象");
|
|
114
|
+
assert.equal(calls[0].turn, 7);
|
|
115
|
+
assert.equal(calls[0].time, 1792000000000);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("tool-call 解析:坏 JSON、非 tool-call 行、参数缺失都要能分清", () => {
|
|
119
|
+
assert.deepEqual(toolCallsInLine("{ 这不是 JSON"), []);
|
|
120
|
+
assert.deepEqual(toolCallsInLine(JSON.stringify({ type: "user/message" })), []);
|
|
121
|
+
// 参数缺失:**调用本身要保留**(它确实发生过),只是 args 为 null
|
|
122
|
+
const noArgs = toolCallLine({ name: "skill" });
|
|
123
|
+
const calls = toolCallsInLine(noArgs);
|
|
124
|
+
assert.equal(calls.length, 1);
|
|
125
|
+
assert.equal(calls[0].args, null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("能力识别:skill 取 arguments.name;MCP 取 mcp__<server>__<tool> 的 server 段", () => {
|
|
129
|
+
assert.deepEqual(identifyCapability({ name: "skill", args: { name: "agent-reach" } }), {
|
|
130
|
+
kind: "skill",
|
|
131
|
+
name: "agent-reach"
|
|
132
|
+
});
|
|
133
|
+
const mcp = identifyCapability({ name: "mcp__github__get_file_contents", args: {} });
|
|
134
|
+
assert.equal(mcp.kind, "mcp");
|
|
135
|
+
assert.equal(mcp.name, "github", "MCP 按 server 聚合(清单里的条目是 server 粒度)");
|
|
136
|
+
assert.equal(mcp.tool, "get_file_contents");
|
|
137
|
+
// 内置工具不是能力清单里的东西 → null(丢掉,不计 unmatched)
|
|
138
|
+
assert.equal(identifyCapability({ name: "read", args: {} }), null);
|
|
139
|
+
assert.equal(identifyCapability({ name: "pwsh", args: {} }), null);
|
|
140
|
+
// skill 没记 name → 返回 name:null,让上层计入 unmatched(如实报,不硬凑)
|
|
141
|
+
assert.deepEqual(identifyCapability({ name: "skill", args: {} }), { kind: "skill", name: null });
|
|
142
|
+
// mcp__ 后面 server 段为空同理
|
|
143
|
+
assert.equal(identifyCapability({ name: "mcp____x", args: {} }).name, null);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("状态推导:没记录=candidate、用过=trusted、久未用=stale", () => {
|
|
147
|
+
const now = Date.parse("2026-10-16T00:00:00Z");
|
|
148
|
+
assert.equal(deriveUsageState(null, { now }), "candidate");
|
|
149
|
+
assert.equal(deriveUsageState({ uses: 0, lastUsedAt: null }, { now }), "candidate", "0 次也算没记录");
|
|
150
|
+
// 30 天前用过 → trusted
|
|
151
|
+
assert.equal(deriveUsageState({ uses: 3, lastUsedAt: now - 30 * 86400000 }, { now }), "trusted");
|
|
152
|
+
// 超过阈值 → stale
|
|
153
|
+
assert.equal(deriveUsageState({ uses: 3, lastUsedAt: now - (STALE_AFTER_DAYS + 1) * 86400000 }, { now }), "stale");
|
|
154
|
+
// 恰好在阈值上不算 stale(边界)
|
|
155
|
+
assert.equal(deriveUsageState({ uses: 3, lastUsedAt: now - STALE_AFTER_DAYS * 86400000 }, { now }), "trusted");
|
|
156
|
+
// 没有时间戳但有次数 → 不能判 stale,保守给 trusted
|
|
157
|
+
assert.equal(deriveUsageState({ uses: 1, lastUsedAt: null }, { now }), "trusted");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("整条链路:从会话文件聚合出技能与 MCP 计数", () => {
|
|
161
|
+
const ctx = makeSessionDir([
|
|
162
|
+
toolCallLine({ name: "skill", args: { name: "agent-reach" }, turn: 1, time: 1792000000000 }),
|
|
163
|
+
toolCallLine({ name: "mcp__github__get_file_contents", args: {}, turn: 1 }),
|
|
164
|
+
toolCallLine({ name: "skill", args: { name: "agent-reach" }, turn: 2, time: 1792050000000 }),
|
|
165
|
+
toolCallLine({ name: "skill", args: { name: "euthyna" }, turn: 3 }),
|
|
166
|
+
toolCallLine({ name: "read", args: { path: "x" }, turn: 3 }), // 内置,忽略
|
|
167
|
+
toolCallLine({ name: "skill", args: {}, turn: 4 }) // 缺 name → unmatched
|
|
168
|
+
]);
|
|
169
|
+
try {
|
|
170
|
+
const c = collectUsage({ sessionDir: ctx.dir });
|
|
171
|
+
assert.equal(c.present, true);
|
|
172
|
+
assert.equal(c.scannedFiles, 1);
|
|
173
|
+
assert.equal(c.skillCalls, 3, "两次 agent-reach + 一次 euthyna");
|
|
174
|
+
assert.equal(c.mcpCalls, 1);
|
|
175
|
+
assert.equal(c.unmatched, 1, "缺 name 的那次要对不上,不能静默丢");
|
|
176
|
+
assert.equal(c.skills.get("agent-reach").uses, 2);
|
|
177
|
+
// 最近一次使用要取最大的那个时间戳
|
|
178
|
+
assert.equal(c.skills.get("agent-reach").lastUsedAt, 1792050000000);
|
|
179
|
+
assert.equal(c.mcp.get("github").uses, 1);
|
|
180
|
+
} finally {
|
|
181
|
+
ctx.cleanup();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("降级:会话目录不存在时如实报 present=false,不抛错不编造", () => {
|
|
186
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-none-"));
|
|
187
|
+
try {
|
|
188
|
+
const c = collectUsage({ sessionDir: join(base, "no-such-dir") });
|
|
189
|
+
assert.equal(c.present, false);
|
|
190
|
+
assert.equal(c.scannedFiles, 0);
|
|
191
|
+
assert.equal(c.skillCalls, 0);
|
|
192
|
+
assert.deepEqual([...c.skills.keys()], []);
|
|
193
|
+
const s = summarizeUsage(c);
|
|
194
|
+
assert.deepEqual(s.skills, []);
|
|
195
|
+
assert.deepEqual(s.mcp, []);
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(base, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("会话目录默认值由 dshHome 推导,不写死本机路径", () => {
|
|
202
|
+
const dir = resolveSessionDir({ dshHome: "/custom/dsh" });
|
|
203
|
+
assert.ok(dir.includes("sessions"), "默认落在 sessions 子目录");
|
|
204
|
+
assert.ok(!/[A-Za-z]:\\Users/i.test(dir), "不得出现本机盘符路径");
|
|
205
|
+
// 环境变量优先
|
|
206
|
+
assert.ok(resolveSessionDir({ dshHome: join(tmpdir(), "x") }).includes("x"));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("文件发现:**所有版本**的会话文件都要收(只认 v4 会漏掉一半历史)", () => {
|
|
210
|
+
// 实测回归:本机同一会话树下并存三种格式——v4(219)、v3(218)、无版本号(72)。
|
|
211
|
+
// 早期版本只认 `session.v4`,静默漏掉 50 次技能调用(euthyna 44 次),
|
|
212
|
+
// 导致三个明明用过的技能被报成 candidate。所以只要求「以 session 开头」。
|
|
213
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-find-"));
|
|
214
|
+
try {
|
|
215
|
+
const sessions = join(base, "sessions");
|
|
216
|
+
mkdirSync(join(sessions, "a", "id1"), { recursive: true });
|
|
217
|
+
mkdirSync(join(sessions, "b", "id2"), { recursive: true });
|
|
218
|
+
writeFileSync(join(sessions, "a", "id1", "session.v4.jsonl.zstd"), multiFrameBuffer(["x"]));
|
|
219
|
+
writeFileSync(join(sessions, "b", "id2", "session.v3.jsonl.zstd"), multiFrameBuffer(["y"]));
|
|
220
|
+
writeFileSync(join(sessions, "b", "id2", "session.jsonl.zstd"), multiFrameBuffer(["z"]));
|
|
221
|
+
writeFileSync(join(sessions, "a", "id1", "other.jsonl"), "不该被收");
|
|
222
|
+
const found = findSessionFiles(sessions);
|
|
223
|
+
assert.equal(found.files.length, 3, "v4 / v3 / 无版本号三种都要找到,other.jsonl 不要");
|
|
224
|
+
assert.equal(found.unreadable.length, 0);
|
|
225
|
+
assert.equal(found.present, true);
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(base, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("文件上限可注入,且截断要留痕(CodeRabbit 抓到的静默截断)", () => {
|
|
232
|
+
// 实测回归:早期版本把上限写死在遍历里(MAX_SESSION_FILES=2000),于是
|
|
233
|
+
// · 调用方传 maxFiles: 5000 也只拿到 2000 个——参数无效,无从察觉;
|
|
234
|
+
// · 截断发生在内层,外层算不出 truncated(`found.files.length > maxFiles` 永假)。
|
|
235
|
+
// 结果就是「只扫了一部分」却看起来像扫全了——本仓库反复防的那类静默错。
|
|
236
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-cap-"));
|
|
237
|
+
try {
|
|
238
|
+
const sessions = join(base, "sessions");
|
|
239
|
+
// 造 12 个会话文件,限 5 个:应拿到 5 个 + truncated=true
|
|
240
|
+
for (let i = 0; i < 12; i++) {
|
|
241
|
+
const d = join(sessions, `s${String(i).padStart(2, "0")}`);
|
|
242
|
+
mkdirSync(d, { recursive: true });
|
|
243
|
+
writeFileSync(join(d, "session.v4.jsonl.zstd"), multiFrameBuffer([`line${i}`]));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ① limit 真的生效(不是被内层写死的上限压住)
|
|
247
|
+
const capped = findSessionFiles(sessions, { limit: 5 });
|
|
248
|
+
assert.equal(capped.files.length, 5, "limit=5 就该只拿 5 个");
|
|
249
|
+
assert.equal(capped.truncated, true, "被截断必须留痕");
|
|
250
|
+
|
|
251
|
+
// ② 不设限时全拿到,且不报截断
|
|
252
|
+
const all = findSessionFiles(sessions);
|
|
253
|
+
assert.equal(all.files.length, 12);
|
|
254
|
+
assert.equal(all.truncated, false, "没截断就不该报截断");
|
|
255
|
+
|
|
256
|
+
// ③ collectUsage 透传:maxFiles 生效,且 truncated 透出来
|
|
257
|
+
const c = collectUsage({ sessionDir: sessions, maxFiles: 5 });
|
|
258
|
+
assert.equal(c.scannedFiles, 5);
|
|
259
|
+
assert.equal(c.truncated, true, "collectUsage 要把截断标记透出来");
|
|
260
|
+
assert.equal(c.totalFiles, 5, "totalFiles 是被限制后的数量(不是磁盘总数)");
|
|
261
|
+
|
|
262
|
+
// ④ 给足够大的 maxFiles 时能扫全(旧实现这里会失败:内层卡 2000)
|
|
263
|
+
const c2 = collectUsage({ sessionDir: sessions, maxFiles: 9999 });
|
|
264
|
+
assert.equal(c2.scannedFiles, 12);
|
|
265
|
+
assert.equal(c2.truncated, false);
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(base, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("目录不可读要与「空目录」**可分辨**(不许同形)", () => {
|
|
272
|
+
// 实测回归:早期版本用 existsSync 定 present、readdirSync 失败直接 return,
|
|
273
|
+
// 于是 ACL 拒绝的目录产出与「可读空目录」逐字段相同的读数
|
|
274
|
+
// (present=true, scannedFiles=0, unreadable=[]),清单侧 175 条全 candidate。
|
|
275
|
+
// 这正是本仓库反复防的那类错:读不到被显示成「读到但没有」。
|
|
276
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-unreadable-"));
|
|
277
|
+
try {
|
|
278
|
+
// ① 不存在的目录:present=false
|
|
279
|
+
const missing = findSessionFiles(join(base, "nope"));
|
|
280
|
+
assert.equal(missing.present, false);
|
|
281
|
+
assert.equal(missing.files.length, 0);
|
|
282
|
+
|
|
283
|
+
// ② 存在的空目录:present=true、无 unreadable
|
|
284
|
+
const empty = join(base, "empty");
|
|
285
|
+
mkdirSync(empty, { recursive: true });
|
|
286
|
+
const emptyRes = findSessionFiles(empty);
|
|
287
|
+
assert.equal(emptyRes.present, true);
|
|
288
|
+
assert.equal(emptyRes.files.length, 0);
|
|
289
|
+
assert.equal(emptyRes.unreadable.length, 0, "空目录不该有 unreadable");
|
|
290
|
+
|
|
291
|
+
// ③ 悬空符号链接:present=true,但**必须有 unreadable**,与空目录不同形
|
|
292
|
+
try {
|
|
293
|
+
symlinkSync(join(base, "no-such-target"), join(empty, "session.dangling.jsonl.zstd"), "junction");
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error.code === "EPERM" || error.code === "EACCES") return; // 无权限则跳过本断言
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
const dangling = findSessionFiles(empty);
|
|
299
|
+
assert.ok(dangling.unreadable.length > 0, "读不到的项必须留痕,否则与空目录同形");
|
|
300
|
+
// 而且上层能区分:collectUsage 的 readable 会变 false
|
|
301
|
+
const c = collectUsage({ sessionDir: empty });
|
|
302
|
+
assert.equal(c.present, true);
|
|
303
|
+
assert.equal(c.readable, false, "有读不动的项时 readable 必须为 false");
|
|
304
|
+
assert.ok(c.unreadable.length > 0, "unreadable 要透到 collectUsage");
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(base, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
package/test/settings.test.js
CHANGED
|
@@ -182,6 +182,8 @@ test("feature flags accept the new boolean, string, url and enum keys", () => {
|
|
|
182
182
|
settings.setFeatureFlags({
|
|
183
183
|
"memoryQualityFilter.enabled": false,
|
|
184
184
|
"llmAudit.enabled": false,
|
|
185
|
+
"distillRecords.enabled": false,
|
|
186
|
+
"distillRecords.retentionDays": 30,
|
|
185
187
|
bm25SearchEnabled: false,
|
|
186
188
|
conflictFreezeEnabled: true,
|
|
187
189
|
trustEpistemicWeighting: true,
|
|
@@ -196,6 +198,9 @@ test("feature flags accept the new boolean, string, url and enum keys", () => {
|
|
|
196
198
|
const flags = settings.getFeatureFlags();
|
|
197
199
|
assert.equal(flags["memoryQualityFilter.enabled"], false, "nested keys stored flat");
|
|
198
200
|
assert.equal(flags["llmAudit.enabled"], false);
|
|
201
|
+
// #399:布尔与整数范围两类键都要能过白名单(retentionDays 是 natural 1..3650)
|
|
202
|
+
assert.equal(flags["distillRecords.enabled"], false);
|
|
203
|
+
assert.equal(flags["distillRecords.retentionDays"], 30);
|
|
199
204
|
assert.equal(flags.bm25SearchEnabled, false);
|
|
200
205
|
assert.equal(flags.conflictFreezeEnabled, true);
|
|
201
206
|
assert.equal(flags.trustEpistemicWeighting, true);
|
|
@@ -215,6 +220,9 @@ test("nested feature flag keys are validated as booleans and rejected otherwise"
|
|
|
215
220
|
const { store, settings } = setup();
|
|
216
221
|
assert.throws(() => settings.setFeatureFlags({ "memoryQualityFilter.enabled": "yes" }), /memoryQualityFilter\.enabled/);
|
|
217
222
|
assert.throws(() => settings.setFeatureFlags({ "llmAudit.enabled": 1 }), /llmAudit\.enabled/);
|
|
223
|
+
assert.throws(() => settings.setFeatureFlags({ "distillRecords.enabled": 1 }), /distillRecords\.enabled/);
|
|
224
|
+
// 范围外的整数也要被挡(retentionDays 上限 3650)
|
|
225
|
+
assert.throws(() => settings.setFeatureFlags({ "distillRecords.retentionDays": 0 }), /distillRecords\.retentionDays/);
|
|
218
226
|
assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
|
|
219
227
|
store.close();
|
|
220
228
|
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createStore } from "../src/store.js";
|
|
7
|
+
import { createService } from "../src/service.js";
|
|
8
|
+
import { createMirror } from "../src/mirror.js";
|
|
9
|
+
|
|
10
|
+
// 回归(issue #430):写路径上的全表物化。此前每一次业务写都要走两处
|
|
11
|
+
// all() + 逐行 toRow(27 字段 + 三次 JSON.parse):
|
|
12
|
+
// 1. 质量过滤的近重复判定只要最近 20 条 content,却先物化整库再 slice(0,20)
|
|
13
|
+
// (5k 行实测 100ms 级 → 本批换成单列有界查询后 0.06ms 级);
|
|
14
|
+
// 2. syncMirror 先物化整库再逐行 filter 掉 archived/forgotten/document。
|
|
15
|
+
// 本批修复:store 新增单列有界读 recentContents();list() 两条分支的 SELECT *
|
|
16
|
+
// 换成显式列清单(memoryColumns,排除 embedding);syncMirror 改走 list 的无界
|
|
17
|
+
// 档 + SQL 侧过滤。下面四条锁不测墙钟(CI 矩阵 ubuntu+windows × node 22/24 并行,
|
|
18
|
+
// 计时断言不稳),锁的是「谁被物化」这个形状本身。
|
|
19
|
+
|
|
20
|
+
function make(over = {}) {
|
|
21
|
+
const store = createStore(":memory:");
|
|
22
|
+
return { store, config: { memoryQualityFilter: { enabled: true }, ...over } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("#430: recentContents mirrors the all() prefix it replaced", () => {
|
|
26
|
+
const { store } = make();
|
|
27
|
+
for (let i = 0; i < 25; i += 1) {
|
|
28
|
+
store.save({ type: "project", title: `t${i}`, content: `c${i}`, importance: 3 });
|
|
29
|
+
}
|
|
30
|
+
// 旧写法:all().slice(0, 20).map((m) => m.content ?? "")。集合锁与逐位锁都要:
|
|
31
|
+
// 一旦把 ORDER BY 丢掉(或反向),只要 LIMIT 不低于表行数,取到的行一个不少、
|
|
32
|
+
// 只是顺序全反过来,集合锁抓不到这类回归(变异检验证实,见 PR 讨论)。
|
|
33
|
+
// 逐位比较在此安全:store.js 的 per-instance 单调时间戳守卫保证同一实例内
|
|
34
|
+
// updated_at 严格递增,故这几行的先后关系是确定的。
|
|
35
|
+
const expected = store.all().slice(0, 20).map((m) => m.content ?? "");
|
|
36
|
+
const actual = store.recentContents(20);
|
|
37
|
+
assert.equal(actual.length, 20);
|
|
38
|
+
assert.deepEqual(actual, expected, "same 20 contents in the same order as the old prefix");
|
|
39
|
+
assert.deepEqual([...actual].sort(), [...expected].sort(), "same 20 contents as the old prefix (order-agnostic backstop)");
|
|
40
|
+
assert.equal(store.recentContents().length, 20, "default limit is 20");
|
|
41
|
+
assert.equal(store.recentContents(3).length, 3);
|
|
42
|
+
// sanitizePage 口径:0 / 负数 / 非整数落回默认值,而不是 SQLite 里 LIMIT 无界
|
|
43
|
+
assert.equal(store.recentContents(0).length, 20, "limit 0 falls back to the default, never unbounded");
|
|
44
|
+
assert.equal(store.recentContents(-1).length, 20);
|
|
45
|
+
assert.equal(store.recentContents(undefined).length, 20);
|
|
46
|
+
// 口径与 all() 一致:不过滤 archived(近重复判定要连归档内容一起看)
|
|
47
|
+
const { id } = store.save({ type: "project", title: "旧", content: "归档正文", importance: 3 });
|
|
48
|
+
store.setArchived(id, true);
|
|
49
|
+
assert.ok(store.recentContents(20).includes("归档正文"), "archived rows still take part in near-duplicate detection");
|
|
50
|
+
// 空库不炸
|
|
51
|
+
const fresh = createStore(":memory:");
|
|
52
|
+
assert.deepEqual(fresh.recentContents(20), []);
|
|
53
|
+
store.close();
|
|
54
|
+
fresh.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("#430: list() projects columns and never leaks embedding", () => {
|
|
58
|
+
const { store } = make();
|
|
59
|
+
const { id } = store.save({ type: "project", title: "带向量", content: "正文", importance: 3 });
|
|
60
|
+
store.setEmbedding(id, [1, 0, 0]);
|
|
61
|
+
for (const opts of [{ limit: 50 }, { limit: null }, { order: "chrono", limit: 10 }, { type: "project", limit: null }]) {
|
|
62
|
+
const rows = store.list(opts);
|
|
63
|
+
assert.ok(rows.length > 0, `list(${JSON.stringify(opts)}) returned rows`);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
assert.equal("embedding" in row, false, `embedding never leaks into list rows (${JSON.stringify(opts)})`);
|
|
66
|
+
assert.equal(typeof row.content, "string", "content still projected");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
store.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("#430: the write path reads explicit, bounded columns (no SELECT *, no embedding)", () => {
|
|
73
|
+
const { store } = make();
|
|
74
|
+
store.save({ type: "project", title: "带向量", content: "正文", importance: 3 });
|
|
75
|
+
// 语句级探针(不测墙钟):SELECT * 会把全表最重的 embedding 列拖进内存再被
|
|
76
|
+
// toRow 丢掉,且无界档实测 5k 行 263ms → 117ms。这里直接看发出去的 SQL——
|
|
77
|
+
// 将来谁把列投影改回 SELECT *(或手工把 embedding 加回清单),本锁立刻转红,
|
|
78
|
+
// 而「行里没有 embedding」那条锁抓不到这种回归(toRow 本就不输出向量)。
|
|
79
|
+
const seen = [];
|
|
80
|
+
const orig = store.db.prepare.bind(store.db);
|
|
81
|
+
store.db.prepare = (sql) => { seen.push(String(sql)); return orig(sql); };
|
|
82
|
+
const listSql = seen.length;
|
|
83
|
+
store.list({ limit: 50 });
|
|
84
|
+
store.list({ limit: null });
|
|
85
|
+
store.list({ type: "project", limit: 10, order: "chrono" });
|
|
86
|
+
const probed = seen.length - listSql;
|
|
87
|
+
assert.ok(probed >= 3, `expected three distinct list queries, saw ${probed}`);
|
|
88
|
+
for (const sql of seen) {
|
|
89
|
+
assert.ok(!/SELECT\s+\*/i.test(sql), `write path must project explicit columns: ${sql}`);
|
|
90
|
+
assert.ok(!/\bembedding\b/.test(sql), `write path must not read the embedding column: ${sql}`);
|
|
91
|
+
}
|
|
92
|
+
// 质量过滤那条单列读同样要探:它一旦退回 SELECT *,多读的正是全表最重的列。
|
|
93
|
+
seen.length = 0;
|
|
94
|
+
store.recentContents(20);
|
|
95
|
+
assert.equal(seen.length, 1, "recentContents issues exactly one query");
|
|
96
|
+
assert.ok(!/SELECT\s+\*/i.test(seen[0]), `recentContents must read one column: ${seen[0]}`);
|
|
97
|
+
assert.ok(!/\bembedding\b/.test(seen[0]), "recentContents must not read the embedding column");
|
|
98
|
+
assert.match(seen[0], /\bLIMIT\b/i, `recentContents must stay bounded: ${seen[0]}`);
|
|
99
|
+
store.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("#430: quality filter reads recent contents without materializing the table", () => {
|
|
103
|
+
const { store, config } = make();
|
|
104
|
+
// 抛错探针(先例 audit.test.js:182、mirror-dirty.test.js:383):写路径一旦再退回
|
|
105
|
+
// all(),本用例立刻失败,而不是等到某个 profile 上再被观测到。
|
|
106
|
+
store.all = () => { throw new Error("write path must not materialize the whole table"); };
|
|
107
|
+
const service = createService({ store, mirror: null, config });
|
|
108
|
+
const base = "今天下午去操场跑步三圈然后回来洗澡";
|
|
109
|
+
const first = service.saveWithDedupe({ type: "history", title: "记录A", content: base });
|
|
110
|
+
assert.equal(first.action, "created");
|
|
111
|
+
const second = service.saveWithDedupe({ type: "history", title: "记录B", content: `${base}${base}${base}` });
|
|
112
|
+
const row = store.getById(second.memory.id);
|
|
113
|
+
assert.equal(row.archived, true, "near-duplicate still archived");
|
|
114
|
+
assert.ok(row.tags.includes("low_quality"), "low_quality tag still added");
|
|
115
|
+
assert.ok(row.quality_score < 30, `score still below the archive threshold: ${row.quality_score}`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("#430: syncMirror covers the active set without materializing it", () => {
|
|
119
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-430-"));
|
|
120
|
+
try {
|
|
121
|
+
const { store, config } = make();
|
|
122
|
+
const mirror = createMirror(dir);
|
|
123
|
+
// 先按正常路径写一次,把镜像文件建起来
|
|
124
|
+
const seed = createService({ store, mirror, config });
|
|
125
|
+
const a = store.save({ type: "project", title: "活跃项目", content: "项目正文", importance: 3 });
|
|
126
|
+
store.save({ type: "preference", title: "偏好", content: "偏好正文", importance: 3 });
|
|
127
|
+
const hidden = store.save({ type: "project", title: "归档项目", content: "归档正文", importance: 3 });
|
|
128
|
+
store.setArchived(hidden.id, true);
|
|
129
|
+
const held = store.save({ type: "project", title: "遗忘项目", content: "遗忘正文", importance: 3 });
|
|
130
|
+
store.setForget(held.id, true);
|
|
131
|
+
seed.saveWithDedupe({ type: "project", title: "触发同步", content: "第一次触发同步的正文,长度足够不触发短内容规则", importance: 3 });
|
|
132
|
+
|
|
133
|
+
// 写路径不得再出现全表物化
|
|
134
|
+
store.all = () => { throw new Error("syncMirror must not materialize the whole table"); };
|
|
135
|
+
const svc = createService({ store, mirror, config });
|
|
136
|
+
const res = svc.saveWithDedupe({ type: "project", title: "第二次触发", content: "第二次触发同步的正文,内容与上一句不同以免被判近重复", importance: 3 });
|
|
137
|
+
assert.equal(res.action, "created");
|
|
138
|
+
assert.equal(res.memory.archived, false, "quality filter left it active (mirror only renders active rows)");
|
|
139
|
+
|
|
140
|
+
const text = readFileSync(join(dir, "projects.md"), "utf8");
|
|
141
|
+
assert.ok(text.includes(a.id), "active row still rendered");
|
|
142
|
+
assert.ok(text.includes(res.memory.id), "new row still rendered");
|
|
143
|
+
assert.ok(!text.includes("归档项目"), "archived row stays out");
|
|
144
|
+
assert.ok(!text.includes("遗忘项目"), "forgotten row stays out");
|
|
145
|
+
assert.ok(readFileSync(join(dir, "preferences.md"), "utf8").includes("偏好正文"), "sibling type rendered");
|
|
146
|
+
store.close();
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("#430: the document gate is decided in SQL, before anything is rendered", () => {
|
|
153
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-430-doc-"));
|
|
154
|
+
try {
|
|
155
|
+
const { store, config } = make({ memoryQualityFilter: { enabled: true }, documentMemoryEnabled: false });
|
|
156
|
+
const mirror = createMirror(dir);
|
|
157
|
+
const svc = createService({ store, mirror, config });
|
|
158
|
+
// 真 document 行:doc_path 只能经 store.saveDocument 铸造(通用 save 整类拒绝)。
|
|
159
|
+
// 这条用例必须让库里真的有 document 行——上一版用例库里一行都没有,
|
|
160
|
+
// 「闸关时不渲染 document」就无法与「根本没见过 document」区分(变异检验漏网)。
|
|
161
|
+
const doc = store.saveDocument({
|
|
162
|
+
type: "document",
|
|
163
|
+
title: "指针文档",
|
|
164
|
+
content: "文档摘要正文,长度足够",
|
|
165
|
+
doc_path: "docs/pointer.md",
|
|
166
|
+
importance: 3
|
|
167
|
+
});
|
|
168
|
+
svc.saveWithDedupe({ type: "project", title: "项目", content: "项目正文,长度足够绕开短内容归档规则", importance: 3 });
|
|
169
|
+
const docPath = mirror.filePath("document");
|
|
170
|
+
// 闸关(#296 第二批默认):有真指针行也不许渲染出 documents.md
|
|
171
|
+
assert.equal(existsSync(docPath), false, "documents.md stays absent while the gate is off");
|
|
172
|
+
// 陈旧视图必须被删掉:不留一个「看着还在、其实已关」的视图
|
|
173
|
+
writeFileSync(docPath, "陈旧视图", "utf8");
|
|
174
|
+
svc.saveWithDedupe({ type: "project", title: "再写一条", content: "再写一条正文,与上一条正文互不相似", importance: 3 });
|
|
175
|
+
assert.equal(existsSync(docPath), false, "stale documents.md pruned while the gate is off");
|
|
176
|
+
assert.equal(existsSync(join(dir, "index.md")), false, "index.md pruned while the gate is off");
|
|
177
|
+
assert.ok(readFileSync(join(dir, "projects.md"), "utf8").includes("项目正文"), "sibling type still rendered");
|
|
178
|
+
// 反向:闸开时同一份指针行确实会渲染(证明上面那条断言有判别力,而不是
|
|
179
|
+
// 因为 document 行从没进入过渲染集),关回去再必须删掉。
|
|
180
|
+
config.documentMemoryEnabled = true;
|
|
181
|
+
svc.saveWithDedupe({ type: "project", title: "闸开", content: "闸开后的正文,与前面几条互不相似", importance: 3 });
|
|
182
|
+
assert.ok(existsSync(docPath), "documents.md rendered while the gate is on");
|
|
183
|
+
assert.ok(readFileSync(docPath, "utf8").includes(doc.id), "pointer row is what gets rendered");
|
|
184
|
+
config.documentMemoryEnabled = false;
|
|
185
|
+
svc.saveWithDedupe({ type: "project", title: "闸关回去", content: "闸关回去后的正文,同样互不相似", importance: 3 });
|
|
186
|
+
assert.equal(existsSync(docPath), false, "documents.md pruned again once the gate flips back off");
|
|
187
|
+
store.close();
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
package/test/store.test.js
CHANGED
|
@@ -213,6 +213,114 @@ test("search excludes archived by default", () => {
|
|
|
213
213
|
store.close();
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
+
// ============================ #275 第二批:archived_at 精确归档时刻
|
|
217
|
+
|
|
218
|
+
test("schema migration adds archived_at column to legacy database", () => {
|
|
219
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-at-"));
|
|
220
|
+
const dbPath = join(dir, "legacy.db");
|
|
221
|
+
try {
|
|
222
|
+
// 存量库:有 archived 标志位,但没有 archived_at(#275 第一批时代的库)
|
|
223
|
+
const legacy = new DatabaseSync(dbPath);
|
|
224
|
+
legacy.exec(`CREATE TABLE memories (
|
|
225
|
+
id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
|
|
226
|
+
tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3,
|
|
227
|
+
forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
|
|
228
|
+
source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
229
|
+
);`);
|
|
230
|
+
legacy.exec(`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, created_at, updated_at)
|
|
231
|
+
VALUES ('old1', 'history', 't', 'c', '[]', 3, 0, 1, NULL, '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z');`);
|
|
232
|
+
legacy.close();
|
|
233
|
+
|
|
234
|
+
const store = createStore(dbPath);
|
|
235
|
+
const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
236
|
+
assert.ok(cols.includes("archived_at"), "archived_at column added");
|
|
237
|
+
// 存量归档行零重写:不回填 updated_at(那等于把被推翻的代理口径固化进列里)
|
|
238
|
+
const raw = store.db.prepare("SELECT archived_at FROM memories WHERE id = 'old1'").get();
|
|
239
|
+
assert.equal(raw.archived_at, null, "legacy archived row keeps NULL, not backfilled from updated_at");
|
|
240
|
+
assert.equal(store.getById("old1").archived_at, undefined, "NULL 读作 undefined,调用方须显式处理");
|
|
241
|
+
store.close();
|
|
242
|
+
} finally {
|
|
243
|
+
rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("archived_at migration is idempotent: reopening the upgraded database is a no-op", () => {
|
|
248
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-at2-"));
|
|
249
|
+
const dbPath = join(dir, "upgraded.db");
|
|
250
|
+
try {
|
|
251
|
+
const first = createStore(dbPath);
|
|
252
|
+
const m = first.save({ type: "history", title: "t", content: "c" });
|
|
253
|
+
first.setArchived(m.id, true);
|
|
254
|
+
const stamp = first.getById(m.id).archived_at;
|
|
255
|
+
first.close();
|
|
256
|
+
|
|
257
|
+
// 再开一次:addColumn 先 PRAGMA 后 ALTER,不该报错、不该重建、不该动已有值
|
|
258
|
+
const second = createStore(dbPath);
|
|
259
|
+
const cols = second.db.prepare("PRAGMA table_info(memories)").all().filter((c) => c.name === "archived_at");
|
|
260
|
+
assert.equal(cols.length, 1, "列只存在一份,没被重复 ALTER");
|
|
261
|
+
assert.equal(second.getById(m.id).archived_at, stamp, "重开不动已落盘的归档时刻");
|
|
262
|
+
second.close();
|
|
263
|
+
} finally {
|
|
264
|
+
rmSync(dir, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("setArchived stamps archived_at on archive, clears it on restore", () => {
|
|
269
|
+
const store = openMemory();
|
|
270
|
+
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
271
|
+
assert.equal(saved.archived_at, undefined, "新建行未归档 → 无时刻");
|
|
272
|
+
|
|
273
|
+
const archived = store.setArchived(saved.id, true);
|
|
274
|
+
assert.equal(archived.archived, true);
|
|
275
|
+
assert.ok(archived.archived_at, "活跃 → 归档落时刻");
|
|
276
|
+
assert.ok(
|
|
277
|
+
Math.abs(Date.parse(archived.archived_at) - Date.now()) < 5000,
|
|
278
|
+
"落的是当下时刻,不是别的行的 updated_at"
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const restored = store.setArchived(saved.id, false);
|
|
282
|
+
assert.equal(restored.archived, false);
|
|
283
|
+
assert.equal(restored.archived_at, undefined, "取消归档清空");
|
|
284
|
+
store.close();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("setArchived keeps updated_at semantics (existing readers depend on it)", () => {
|
|
288
|
+
// archived_at 是新增列,不是 updated_at 的替代品:面板排序、快照哈希、CAS 都读
|
|
289
|
+
// updated_at,本函数自 v0.2 起就无条件刷它——改口径不得顺手改掉这条既有语义。
|
|
290
|
+
const store = openMemory();
|
|
291
|
+
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
292
|
+
const before = store.getById(saved.id).updated_at;
|
|
293
|
+
const archived = store.setArchived(saved.id, true);
|
|
294
|
+
assert.ok(Date.parse(archived.updated_at) >= Date.parse(before), "updated_at 照旧被刷");
|
|
295
|
+
const again = store.setArchived(saved.id, false);
|
|
296
|
+
assert.ok(Date.parse(again.updated_at) >= Date.parse(archived.updated_at), "取消归档也刷");
|
|
297
|
+
store.close();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("save with archived:true stamps archived_at at insert time", () => {
|
|
301
|
+
// 铸造即归档的行:archived=1 而 archived_at=NULL 会落进「存量行」语义,读侧只能
|
|
302
|
+
// 当它时刻不可追溯——明明是当下发生的归档,白丢一条记录。
|
|
303
|
+
const store = openMemory();
|
|
304
|
+
const born = store.save({ type: "history", title: "生来就归档", content: "c", archived: true });
|
|
305
|
+
assert.equal(born.archived, true);
|
|
306
|
+
assert.ok(born.archived_at, "入库即归档 → 归档时刻 = 入库时刻");
|
|
307
|
+
assert.equal(born.archived_at, born.created_at, "两列同源(同一个 nowIso)");
|
|
308
|
+
|
|
309
|
+
const live = store.save({ type: "history", title: "活跃", content: "c" });
|
|
310
|
+
assert.equal(live.archived_at, undefined);
|
|
311
|
+
store.close();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("update() cannot set archived_at (archived is not an updatable field)", () => {
|
|
315
|
+
// 归档时刻只能由 setArchived 落。若 update 的 patch 能写它,就等于给精确口径开了
|
|
316
|
+
// 一个绕过 setArchived 语义(幂等 / 清空)的后门,代理口径会从缝里长回来。
|
|
317
|
+
const store = openMemory();
|
|
318
|
+
const saved = store.save({ type: "history", title: "t", content: "c" });
|
|
319
|
+
store.update(saved.id, { archived_at: "2020-01-01T00:00:00.000Z", content: "改过" });
|
|
320
|
+
assert.equal(store.getById(saved.id).archived_at, undefined, "patch 里的 archived_at 被忽略");
|
|
321
|
+
store.close();
|
|
322
|
+
});
|
|
323
|
+
|
|
216
324
|
// --- vector search ---
|
|
217
325
|
|
|
218
326
|
test("save/update persist embedding vector and searchVector ranks by cosine", () => {
|