@modusensus/dsh-mneme 0.8.15 → 0.8.16
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 +8 -3
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +14 -2
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +171 -3
- package/lib/client.js +1065 -156
- package/lib/config.js +68 -1
- package/lib/dream/decisions.js +20 -0
- package/lib/dream.js +2 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +24 -7
- package/lib/inject.js +27 -63
- package/lib/maintenance.js +25 -5
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/service.js +27 -2
- package/lib/settings.js +13 -2
- package/lib/store.js +196 -7
- package/lib/summarize.js +208 -60
- package/package.json +1 -1
- package/src/api-standalone.js +22 -39
- package/src/api.js +171 -3
- package/src/config.js +68 -1
- package/src/dream/decisions.js +20 -0
- package/src/dream.js +2 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +24 -7
- package/src/inject.js +27 -63
- package/src/maintenance.js +25 -5
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/service.js +27 -2
- package/src/settings.js +13 -2
- package/src/store.js +196 -7
- package/src/summarize.js +208 -60
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +25 -2
- package/test/client.test.js +354 -2
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +39 -0
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +98 -0
- package/test/maintenance.test.js +112 -2
- package/test/pre-inject-gate.test.js +10 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/service.test.js +7 -2
- package/test/settings.test.js +8 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +174 -0
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
|
});
|
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", () => {
|
package/test/summarize.test.js
CHANGED
|
@@ -274,6 +274,43 @@ test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费
|
|
|
274
274
|
assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计");
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
+
// #239 定标埋点:窗口字符数必须落进审计,否则下一级预判规则的阈值无从选起。
|
|
278
|
+
// 第二条才是本改动的目的——预判开关关着(默认档位)时行为逐字节不变,但从这一刻
|
|
279
|
+
// 起成功行开始留数,验收 4「默认配置下调用量可量化下降」才有账可对。
|
|
280
|
+
test("#239 计量:window-too-small 的 skip 行带 distillChars 与阈值", async () => {
|
|
281
|
+
const { events, service } = setup({ summarizeMinWindowChars: 1000 });
|
|
282
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
283
|
+
const session = {
|
|
284
|
+
id: "s239-metric-skip",
|
|
285
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
286
|
+
events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
|
|
287
|
+
};
|
|
288
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
289
|
+
const row = service.listLlmAudits()[0];
|
|
290
|
+
assert.equal(row.error_message, "window-too-small");
|
|
291
|
+
// 计的是**真正发给模型的转录文本**长度(含角色前缀:一条 1 字的「好」实测记 4),
|
|
292
|
+
// 与闸门比较的口径同一个数,定标才对得上。这里不锁字面量,锁语义:短于阈值才该 skip。
|
|
293
|
+
assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0,
|
|
294
|
+
"skip 行必须带窗口字符数");
|
|
295
|
+
assert.equal(row.metadata.minWindowChars, 1000, "阈值随行落档,事后能复核是哪条闸门拦的");
|
|
296
|
+
assert.ok(row.metadata.distillChars < row.metadata.minWindowChars, "skip 的前提:窗口短于阈值");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("#239 计量:预判开关关闭时成功行仍记录 distillChars", async () => {
|
|
300
|
+
const { events, service } = setup();
|
|
301
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
302
|
+
const session = {
|
|
303
|
+
id: "s239-metric-success",
|
|
304
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
305
|
+
events: [userMessage("今天决定把窗口字符数记进审计", 1), { seq: 2, type: "turn/end" }]
|
|
306
|
+
};
|
|
307
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
308
|
+
const row = service.listLlmAudits()[0];
|
|
309
|
+
assert.equal(row.status, "success");
|
|
310
|
+
assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0,
|
|
311
|
+
"默认档位下也要留数,否则验收 4 无从对账");
|
|
312
|
+
});
|
|
313
|
+
|
|
277
314
|
test("#239 窗口达到阈值时预判放行(不误杀)", async () => {
|
|
278
315
|
const { events, calls } = setup({ summarizeMinWindowChars: 5 });
|
|
279
316
|
const handler = events.find((e) => e.name === "session/event").fn;
|
|
@@ -1463,3 +1500,140 @@ test("parseSummaryJson salvage does not turn string-embedded objects into memori
|
|
|
1463
1500
|
assert.equal(parsed[0].title, "good");
|
|
1464
1501
|
assert.match(parsed[0].content, /fake/);
|
|
1465
1502
|
});
|
|
1503
|
+
|
|
1504
|
+
// ── Issue #399:蒸馏窗口原始记录 + 记忆级溯源 ─────────────────────────────
|
|
1505
|
+
//
|
|
1506
|
+
// 这五条锁的是「把蒸馏从替换降级为叠加索引」的四个承重点:重跑幂等、隐私谓词
|
|
1507
|
+
// 在落盘路径同样成立、留存不受喂给模型的裁剪约束、裁剪不毁资产、默认关零写入。
|
|
1508
|
+
// 锁形状而非锁实现:断言直接查表,不去读 summarize.js 的中间变量。
|
|
1509
|
+
|
|
1510
|
+
/** Issue #399 的开启档 + 关掉真实节流(否则同会话第二轮会被间隔门挡下)。 */
|
|
1511
|
+
const RECORDS_ON = { distillRecords: { enabled: true }, distillRateLimitIntervalMs: 0 };
|
|
1512
|
+
|
|
1513
|
+
function countRows(store, sql) {
|
|
1514
|
+
return store.db.prepare(sql).get().c;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function recordsSession(id, events) {
|
|
1518
|
+
return {
|
|
1519
|
+
id,
|
|
1520
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
1521
|
+
events
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function eventHandler(events) {
|
|
1526
|
+
return events.find((e) => e.name === "session/event").fn;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
test("issue#399: re-distilling the same window never duplicates records", async () => {
|
|
1530
|
+
const first = setup(RECORDS_ON);
|
|
1531
|
+
const session = recordsSession("s399-idem", [userMessage("同一窗口的提问", 1), { seq: 2, type: "turn/end" }]);
|
|
1532
|
+
await eventHandler(first.events)(session, { seq: 2, type: "turn/end" });
|
|
1533
|
+
assert.equal(countRows(first.store, "SELECT count(*) AS c FROM distill_records"), 1);
|
|
1534
|
+
|
|
1535
|
+
// 模拟宿主重启后重跑同一 seq 段(代码注释里点名的路径:max-runs 耗尽额度、游标
|
|
1536
|
+
// 写入随事务回滚后丢失):删掉持久化游标 + 换一个全新的 summarizer 实例,于是
|
|
1537
|
+
// lastDistilledSeq 这份内存游标也一并归零,同一窗口会被完整重蒸一遍。
|
|
1538
|
+
first.store.db.prepare("DELETE FROM distill_cursors").run();
|
|
1539
|
+
const second = setup(RECORDS_ON, { store: first.store });
|
|
1540
|
+
await eventHandler(second.events)(session, { seq: 2, type: "turn/end" });
|
|
1541
|
+
|
|
1542
|
+
assert.equal(
|
|
1543
|
+
countRows(first.store, "SELECT count(*) AS c FROM distill_records"),
|
|
1544
|
+
1,
|
|
1545
|
+
"同一 (session_id, seq) 只留一行:重跑必须幂等,保留首次落盘时间才诚实"
|
|
1546
|
+
);
|
|
1547
|
+
assert.equal(
|
|
1548
|
+
countRows(
|
|
1549
|
+
first.store,
|
|
1550
|
+
`SELECT count(*) AS c FROM (
|
|
1551
|
+
SELECT memory_id, session_id, seq_start, seq_end FROM memory_provenance
|
|
1552
|
+
GROUP BY memory_id, session_id, seq_start, seq_end HAVING count(*) > 1)`
|
|
1553
|
+
),
|
|
1554
|
+
0,
|
|
1555
|
+
"同一窗口重跑不重复挂链接(唯一索引 + ON CONFLICT DO NOTHING)"
|
|
1556
|
+
);
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
test("issue#399: records keep only public transcript; reasoning and machine-injected content never land on disk", async () => {
|
|
1560
|
+
const { events, store, calls } = setup(RECORDS_ON);
|
|
1561
|
+
const session = recordsSession("s399-priv", [
|
|
1562
|
+
userMessage("真实提问:这个报错怎么修", 1),
|
|
1563
|
+
// 机器注入的三类:AGENTS.md / skill 正文 / 文件变更通知。source.kind 不是
|
|
1564
|
+
// user / agent-message / subagent-settled,必须整个事件被挡在门外。
|
|
1565
|
+
{ seq: 2, type: "user/message", data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 注入正文不该落盘" }] } },
|
|
1566
|
+
{ seq: 3, type: "user/message", data: { source: { kind: "skill" }, content: [{ type: "text", text: "SKILL_BODY 不该落盘" }] } },
|
|
1567
|
+
{ seq: 4, type: "user/message", data: { source: { kind: "file-change" }, content: [{ type: "text", text: "FILE_CHANGE 通知不该落盘" }] } },
|
|
1568
|
+
{
|
|
1569
|
+
seq: 5,
|
|
1570
|
+
type: "assistant/message",
|
|
1571
|
+
data: { message: { content: [
|
|
1572
|
+
{ type: "reasoning", text: "PRIVATE_REASONING 不该落盘" },
|
|
1573
|
+
{ type: "text", text: "公开回答:检查第 3 行。" }
|
|
1574
|
+
] } }
|
|
1575
|
+
},
|
|
1576
|
+
toolResultEvent("TOOL_OUTPUT 只进渲染不落盘", false, 6),
|
|
1577
|
+
{ seq: 7, type: "turn/end" }
|
|
1578
|
+
]);
|
|
1579
|
+
await eventHandler(events)(session, { seq: 7, type: "turn/end" });
|
|
1580
|
+
|
|
1581
|
+
const texts = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all().map((r) => r.text).join("\n");
|
|
1582
|
+
assert.ok(texts.includes("真实提问"), "用户提示落盘");
|
|
1583
|
+
assert.ok(texts.includes("公开回答"), "助手公开正文落盘");
|
|
1584
|
+
assert.ok(!texts.includes("AGENTS.md"), "插件注入的 AGENTS.md 绝不落盘");
|
|
1585
|
+
assert.ok(!texts.includes("SKILL_BODY"), "skill 正文绝不落盘");
|
|
1586
|
+
assert.ok(!texts.includes("FILE_CHANGE"), "文件变更通知绝不落盘");
|
|
1587
|
+
assert.ok(!texts.includes("PRIVATE_REASONING"), "私有推理链绝不落盘");
|
|
1588
|
+
assert.ok(!texts.includes("TOOL_OUTPUT"), "工具结果不落盘(#399 已知边界 3)");
|
|
1589
|
+
// 渲染路径照旧带工具输出(codingRetrospect 靠它沉淀踩坑根因)——两条路径在
|
|
1590
|
+
// 「工具输出」上的差异是设计的一部分,不是漂移:漂移的方向必须是「落盘的更少」。
|
|
1591
|
+
assert.ok(JSON.stringify(calls[0].messages).includes("TOOL_OUTPUT"), "工具输出仍进 LLM 转录");
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
test("issue#399: records keep the untruncated prompt while the LLM transcript stays capped", async () => {
|
|
1595
|
+
// 头部标记 + 超长正文:trimTranscript 保尾部,所以「渲染里找不到头部标记」正是
|
|
1596
|
+
// 被 distillMaxChars 截断的证据;记录里必须连头部标记都在。
|
|
1597
|
+
const long = `HEAD_MARKER ${"x".repeat(1200)} TAIL_MARKER`;
|
|
1598
|
+
const { events, store, calls } = setup({ ...RECORDS_ON, distillMaxChars: 200 });
|
|
1599
|
+
const session = recordsSession("s399-long", [userMessage(long, 1), { seq: 2, type: "turn/end" }]);
|
|
1600
|
+
await eventHandler(events)(session, { seq: 2, type: "turn/end" });
|
|
1601
|
+
|
|
1602
|
+
const rows = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all();
|
|
1603
|
+
assert.equal(rows.length, 1);
|
|
1604
|
+
assert.equal(rows[0].text, long, "留存的是整段原文,不截断、不加省略号");
|
|
1605
|
+
assert.ok(!JSON.stringify(calls[0].messages).includes("HEAD_MARKER"), "喂 LLM 的转录仍受 distillMaxChars 约束");
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
test("issue#399: expiring records keeps the memories and leaves provenance dangling-but-present", async () => {
|
|
1609
|
+
const { events, store } = setup(RECORDS_ON);
|
|
1610
|
+
const session = recordsSession("s399-expire", [userMessage("会被裁剪的原文", 1), { seq: 2, type: "turn/end" }]);
|
|
1611
|
+
await eventHandler(events)(session, { seq: 2, type: "turn/end" });
|
|
1612
|
+
assert.equal(store.count(), 2);
|
|
1613
|
+
assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2);
|
|
1614
|
+
|
|
1615
|
+
// 到期裁剪:把 cutoff 推到未来,等价于「这条记录已经过期」。
|
|
1616
|
+
assert.equal(store.clearDistillRecords(new Date(Date.now() + 86400000).toISOString()), 1);
|
|
1617
|
+
assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0);
|
|
1618
|
+
assert.equal(store.count(), 2, "裁剪原文绝不级联删记忆");
|
|
1619
|
+
assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2, "链接保留,从此指向空区间");
|
|
1620
|
+
const memoryId = store.all()[0].id;
|
|
1621
|
+
assert.ok(store.provenanceFor(memoryId).length >= 1, "面板/CLI 仍能回答「这条记忆出自哪个窗口」,只是来源已过期");
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
test("issue#399: enabled=false (the default) writes nothing at all", async () => {
|
|
1625
|
+
const { events, store, service } = setup({ distillRateLimitIntervalMs: 0 });
|
|
1626
|
+
let saverCalls = 0;
|
|
1627
|
+
const original = service.saveDistillRecords;
|
|
1628
|
+
service.saveDistillRecords = (...args) => {
|
|
1629
|
+
saverCalls++;
|
|
1630
|
+
return original(...args);
|
|
1631
|
+
};
|
|
1632
|
+
const session = recordsSession("s399-off", [userMessage("默认关不该留原文", 1), { seq: 2, type: "turn/end" }]);
|
|
1633
|
+
await eventHandler(events)(session, { seq: 2, type: "turn/end" });
|
|
1634
|
+
|
|
1635
|
+
assert.equal(saverCalls, 0, "默认关:连收集都不做,一次写入调用都不发");
|
|
1636
|
+
assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0);
|
|
1637
|
+
assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 0);
|
|
1638
|
+
assert.equal(store.count(), 2, "记忆写入照旧");
|
|
1639
|
+
});
|