@modusensus/dsh-mneme 0.8.8 → 0.8.10
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 +4 -4
- package/lib/api-standalone.js +11 -1
- package/lib/api.js +7 -2
- package/lib/client.js +371 -164
- package/lib/config.js +26 -0
- package/lib/continuity.js +1 -1
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +6 -6
- package/lib/entities/extractor.js +2 -2
- package/lib/index.js +10 -4
- package/lib/inject.js +68 -9
- package/lib/peak-hours.js +117 -117
- package/lib/service.js +28 -3
- package/lib/settings.js +9 -1
- package/lib/summarize.js +2 -2
- package/lib/tools.js +17 -8
- package/lib/write-admission.js +165 -22
- package/package.json +1 -1
- package/scripts/benchmark-recall.js +57 -7
- package/src/api-standalone.js +11 -1
- package/src/api.js +7 -2
- package/src/config.js +26 -0
- package/src/continuity.js +1 -1
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +6 -6
- package/src/entities/extractor.js +2 -2
- package/src/index.js +10 -4
- package/src/inject.js +68 -9
- package/src/peak-hours.js +117 -117
- package/src/service.js +28 -3
- package/src/settings.js +9 -1
- package/src/summarize.js +2 -2
- package/src/tools.js +17 -8
- package/src/write-admission.js +165 -22
- package/test/api.test.js +19 -3
- package/test/client.test.js +17 -6
- package/test/continuity.test.js +1 -1
- package/test/epistemic.test.js +27 -0
- package/test/helpers/write-admission-samples.js +308 -0
- package/test/inject.test.js +137 -0
- package/test/llm-message-source.test.js +32 -2
- package/test/recall-anti-update.test.js +82 -0
- package/test/write-admission.test.js +208 -0
|
@@ -10,9 +10,13 @@ import { createTools } from "../src/tools.js";
|
|
|
10
10
|
import {
|
|
11
11
|
createWriteAdmission,
|
|
12
12
|
extractTopicKeys,
|
|
13
|
+
informationlessReason,
|
|
14
|
+
ADMISSION_DENY_BLANK,
|
|
15
|
+
ADMISSION_DENY_NOISE,
|
|
13
16
|
ADMISSION_OPERATION_TYPE,
|
|
14
17
|
ADMISSION_TRIGGER_SOURCE
|
|
15
18
|
} from "../src/write-admission.js";
|
|
19
|
+
import { POSITIVE_SAMPLES, NEGATIVE_SAMPLES, SAMPLE_VALUES, referenceScan } from "./helpers/write-admission-samples.js";
|
|
16
20
|
|
|
17
21
|
// #254 写入准入(第一阶段:只计量,不拦截)。
|
|
18
22
|
// 本批次没有阈值、没有拦截分支,验收看两件事:默认路径零行为变化(无会话身份的
|
|
@@ -343,3 +347,207 @@ test("旧库(llm_audit_logs 无 session_key)打开自动补列,准入行
|
|
|
343
347
|
rmSync(dir, { recursive: true, force: true });
|
|
344
348
|
}
|
|
345
349
|
});
|
|
350
|
+
|
|
351
|
+
// --- 第 1 级确定性拒绝(#254 第二阶段) ----------------------------------------
|
|
352
|
+
// 判据面按 09-21 / 09-22 两轮收窄到两类:空白 / 纯噪声 + 注入进来的密钥 / PII。
|
|
353
|
+
// 去重键命中**不**在这里(归 write-update 放行),G1/G2 阈值也只是计量。
|
|
354
|
+
//
|
|
355
|
+
// 两个开关都默认关,所以这一组测试的第一件事是证明「关掉时逐字段等于只计量那一
|
|
356
|
+
// 阶段」。拦截面本身要证明三件事:拒了(真的没落库)、可解释(审计行里有 reason)、
|
|
357
|
+
// 不误杀(正常文本一条不动)。
|
|
358
|
+
|
|
359
|
+
function level1Store(opts = {}) {
|
|
360
|
+
const { enabled = false, enforce = false, sensitiveScan = null, now } = opts;
|
|
361
|
+
const store = createStore(":memory:");
|
|
362
|
+
const writeAdmission = createWriteAdmission({
|
|
363
|
+
store,
|
|
364
|
+
now,
|
|
365
|
+
config: { writeAdmission: { enabled, enforce } },
|
|
366
|
+
sensitiveScan
|
|
367
|
+
});
|
|
368
|
+
const service = createService({ store, mirror: null, config: {}, writeAdmission });
|
|
369
|
+
return { store, service, writeAdmission };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 落库行数(含归档 / 遗忘:被拒的写入一条都不该多)。 */
|
|
373
|
+
function rowCount(store) {
|
|
374
|
+
return store.list({ limit: 500, includeArchived: true, includeForgotten: true }).length;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
test("信息量判据:blank 与 noise 分开报,正常文本不命中", () => {
|
|
378
|
+
assert.equal(informationlessReason({ title: "", content: "" }), "blank");
|
|
379
|
+
assert.equal(informationlessReason({ title: " ", content: "\n\t " }), "blank", "纯空白折掉之后什么都没写");
|
|
380
|
+
assert.equal(informationlessReason({ title: "标题", content: "" }), null, "有一边有内容就不是空写入");
|
|
381
|
+
assert.equal(informationlessReason({ title: "!!!", content: "--- ..." }), "noise", "只有标点 = 归一化后什么都不剩");
|
|
382
|
+
assert.equal(informationlessReason({ title: "a", content: "..." }), null, "只剩一个字母也算有信息");
|
|
383
|
+
// 两种 reason 分开报是为了可解释:blank 是提交了空表单,noise 是只填了标点,修法不同。
|
|
384
|
+
assert.notEqual(ADMISSION_DENY_BLANK, ADMISSION_DENY_NOISE);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("默认关:enabled 关时判据根本不跑,连 enforce 开着也没用", () => {
|
|
388
|
+
const { store, service } = level1Store({ enabled: false, enforce: true });
|
|
389
|
+
const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "", content: " " });
|
|
390
|
+
assert.equal(result.action, "created", "判据没开就不该有任何拒绝分支");
|
|
391
|
+
assert.equal(rowCount(store), 1);
|
|
392
|
+
const rows = admissionRows(store, "s");
|
|
393
|
+
assert.equal(rows.length, 1, "只计量那一阶段的行为逐字段保留");
|
|
394
|
+
// 验收第 1 条是「默认路径逐字节一致」,所以这里断言的是**整个 metadata**与只计量
|
|
395
|
+
// 阶段相同:连一个恒为 "allow" 的 decision 键都不该多出来。多一个键就意味着
|
|
396
|
+
// 「默认关零行为变化」从事实变成了需要解释的说法。
|
|
397
|
+
assert.deepEqual(rows[0].metadata, { gate: "g1", topics: [] });
|
|
398
|
+
assert.equal(rows[0].metadata.deny, undefined, "判据没跑就不该有拒绝面");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("仅告警档:enabled 开、enforce 关 → 写入成功,审计行带拒绝面", () => {
|
|
402
|
+
const { store, service } = level1Store({ enabled: true, enforce: false });
|
|
403
|
+
const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "!!", content: "..." });
|
|
404
|
+
assert.equal(result.action, "created", "只开检测不拦写入");
|
|
405
|
+
assert.equal(rowCount(store), 1);
|
|
406
|
+
const rows = admissionRows(store, "s");
|
|
407
|
+
assert.equal(rows.length, 1);
|
|
408
|
+
assert.equal(rows[0].metadata.decision, "allow", "仅告警档的决策仍是放行");
|
|
409
|
+
assert.deepEqual(rows[0].metadata.deny, { reason: "noise", enforced: false });
|
|
410
|
+
// 「deny 非空但 decision=allow」就是仅告警档的指纹,不必再读配置才能解释这一行。
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("硬拒:enabled + enforce → 不落库、返回 reason、审计行标 enforced", () => {
|
|
414
|
+
const { store, service } = level1Store({ enabled: true, enforce: true });
|
|
415
|
+
const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: " ", content: "" });
|
|
416
|
+
assert.equal(result.action, "denied");
|
|
417
|
+
assert.equal(result.reason, "blank");
|
|
418
|
+
assert.equal(result.memory, null, "被拒的写入没有行可返回");
|
|
419
|
+
assert.equal(rowCount(store), 0, "store.save 在拒绝时根本没有被调用");
|
|
420
|
+
|
|
421
|
+
const rows = admissionRows(store, "s");
|
|
422
|
+
assert.equal(rows.length, 1, "拒绝面必须留审计(验收第 2 条:可解释、不静默丢弃)");
|
|
423
|
+
assert.equal(rows[0].related_memory_ids.length, 0, "没有产生任何行,不能挂一个不存在的 id");
|
|
424
|
+
assert.equal(rows[0].metadata.decision, "deny");
|
|
425
|
+
assert.deepEqual(rows[0].metadata.deny, { reason: "blank", enforced: true });
|
|
426
|
+
|
|
427
|
+
// 标题有内容就不是空写入:标题有信息(去重键就是它),归一化后非空不该被拒。
|
|
428
|
+
const titled = service.saveWithDedupe({ _sessionKey: "s2", type: "project", title: "标题", content: "" });
|
|
429
|
+
assert.equal(titled.action, "created", "title 有内容 = 有信息,不能判成 blank");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("被拦下的写入不推进话题基准(它没进库,不能成为下一次 g2 的基准)", () => {
|
|
433
|
+
const { store, service } = level1Store({
|
|
434
|
+
enabled: true, enforce: true, sensitiveScan: referenceScan, now: () => 1_000_000
|
|
435
|
+
});
|
|
436
|
+
// 用密钥类而不是空白类来测这件事:空白/噪声行**不可能**带话题锚(锚是 `#254` /
|
|
437
|
+
// 路径这类含数字或字母的串,normalizeForHash 之后仍非空,所以它在第 1 级根本不
|
|
438
|
+
// 会命中)——这条回归只有在 sensitive 那一档才可复现,而密钥行通常也写着上下文。
|
|
439
|
+
const denied = service.saveWithDedupe({
|
|
440
|
+
_sessionKey: "s",
|
|
441
|
+
type: "project",
|
|
442
|
+
title: "见 #254",
|
|
443
|
+
content: `token 是 ${SAMPLE_VALUES.githubPat}`
|
|
444
|
+
});
|
|
445
|
+
assert.equal(denied.action, "denied");
|
|
446
|
+
assert.equal(denied.deny.kind, "github_token");
|
|
447
|
+
// 同一话题再来一条正常写入:如果被拒那条推进了基准,这里就会凭空多出一个 g2。
|
|
448
|
+
service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "记录", content: "#254 的正文" });
|
|
449
|
+
const g2 = admissionRows(store, "s").filter((r) => r.metadata.g2);
|
|
450
|
+
assert.equal(g2.length, 0, "拒绝行不能造出一条不存在的时间线");
|
|
451
|
+
assert.equal(rowCount(store), 1, "只有第二条真的落库了");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("硬拒在 pinned 豁免之前:写着空内容的 constraint 一样被拒", () => {
|
|
455
|
+
// pinned 豁免的是**预算与冷却**(constraint / preference 是逐字保真池,不该被会话
|
|
456
|
+
// 预算拦),不是「这条能不能落库」。顺序反了就会出现「pinned 行绕过第 1 级」。
|
|
457
|
+
const { store, service } = level1Store({ enabled: true, enforce: true });
|
|
458
|
+
const result = service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "...", content: "" });
|
|
459
|
+
assert.equal(result.action, "denied");
|
|
460
|
+
assert.equal(result.reason, "noise");
|
|
461
|
+
assert.equal(rowCount(store), 0);
|
|
462
|
+
assert.equal(admissionRows(store, "s")[0].metadata.deny.reason, "noise");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("sensitiveScan 注入:正样本一条都不漏放(密钥 + PII)", () => {
|
|
466
|
+
const { store, service } = level1Store({ enabled: true, enforce: true, sensitiveScan: referenceScan });
|
|
467
|
+
for (const sample of POSITIVE_SAMPLES) {
|
|
468
|
+
const result = service.saveWithDedupe({
|
|
469
|
+
_sessionKey: `sess-${sample.id}`,
|
|
470
|
+
type: "project",
|
|
471
|
+
title: sample.title,
|
|
472
|
+
content: sample.content
|
|
473
|
+
});
|
|
474
|
+
assert.equal(result.action, "denied", `${sample.id} 被漏放(${sample.why})`);
|
|
475
|
+
assert.equal(result.reason, "sensitive", `${sample.id} 的 reason 该是 sensitive`);
|
|
476
|
+
const deny = admissionRows(store, `sess-${sample.id}`)[0].metadata.deny;
|
|
477
|
+
assert.equal(deny.kind, sample.kind, `${sample.id} 的 kind 要落进审计,enforce 前的分布才可按类看`);
|
|
478
|
+
assert.equal(deny.enforced, true);
|
|
479
|
+
}
|
|
480
|
+
assert.equal(rowCount(store), 0, "正样本一条都不该落库");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("sensitiveScan 注入:负样本一条都不误杀(含敏感词但没有值)", () => {
|
|
484
|
+
const { store, service } = level1Store({ enabled: true, enforce: true, sensitiveScan: referenceScan });
|
|
485
|
+
for (const sample of NEGATIVE_SAMPLES) {
|
|
486
|
+
const result = service.saveWithDedupe({
|
|
487
|
+
_sessionKey: `sess-${sample.id}`,
|
|
488
|
+
type: "project",
|
|
489
|
+
title: sample.title,
|
|
490
|
+
content: sample.content
|
|
491
|
+
});
|
|
492
|
+
assert.equal(result.action, "created", `${sample.id} 被误杀(${sample.why})`);
|
|
493
|
+
}
|
|
494
|
+
assert.equal(rowCount(store), NEGATIVE_SAMPLES.length, "负样本应当全部落库");
|
|
495
|
+
// 负样本一条 deny 面都不该有——误杀面是这条闸门唯一会伤人的地方。
|
|
496
|
+
for (const sample of NEGATIVE_SAMPLES) {
|
|
497
|
+
for (const row of admissionRows(store, `sess-${sample.id}`)) {
|
|
498
|
+
assert.equal(row.metadata.deny, undefined, `${sample.id} 不该带任何拒绝面`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("sensitiveScan 抛错按「未命中」处理:判据故障不能让写入变成不可用", () => {
|
|
504
|
+
const { store, service } = level1Store({
|
|
505
|
+
enabled: true,
|
|
506
|
+
enforce: true,
|
|
507
|
+
sensitiveScan() { throw new Error("scanner boom"); }
|
|
508
|
+
});
|
|
509
|
+
const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "正常", content: "正常正文" });
|
|
510
|
+
assert.equal(result.action, "created", "扫描器坏了必须放行,不能把所有写入拦死");
|
|
511
|
+
assert.equal(rowCount(store), 1);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("命中且 enforce 时不发候选集查询(决策已定,白花成本)", () => {
|
|
515
|
+
const { store, service } = level1Store({ enabled: true, enforce: true });
|
|
516
|
+
// 先落一条同内容(走正常开关),再做一次同内容写入:若还查候选集,就会带上 dup。
|
|
517
|
+
store.save({ type: "project", title: "已存在", content: "已存在的正文" });
|
|
518
|
+
const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "...", content: "" });
|
|
519
|
+
assert.equal(result.action, "denied");
|
|
520
|
+
const rows = admissionRows(store, "s");
|
|
521
|
+
assert.equal(rows.length, 1, "只该有拒绝那一行");
|
|
522
|
+
assert.equal(rows[0].metadata.dup, undefined, "拒绝路径不查候选集");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("接线:被拒时 memory_save 返回 action=denied 与可行动的 reason", async () => {
|
|
526
|
+
const store = createStore(":memory:");
|
|
527
|
+
const writeAdmission = createWriteAdmission({
|
|
528
|
+
store,
|
|
529
|
+
config: { writeAdmission: { enabled: true, enforce: true } }
|
|
530
|
+
});
|
|
531
|
+
const service = createService({ store, mirror: null, config: {}, writeAdmission });
|
|
532
|
+
const registered = [];
|
|
533
|
+
createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, {}, null);
|
|
534
|
+
const save = registered.find((t) => t.name === "memory_save");
|
|
535
|
+
|
|
536
|
+
const denied = await save.execute(
|
|
537
|
+
{ type: "project", title: "...", content: "" },
|
|
538
|
+
{ agent: { session: { id: "sess-deny" } } }
|
|
539
|
+
);
|
|
540
|
+
assert.equal(denied.action, "denied");
|
|
541
|
+
assert.equal(denied.reason, "noise");
|
|
542
|
+
assert.equal(denied.id, undefined, "没有落库就不该编一个 id 出来");
|
|
543
|
+
assert.equal(store.countLlmAudits({ sessionKey: "sess-deny" }), 1, "工具层拒绝也要留审计");
|
|
544
|
+
|
|
545
|
+
// 正常写入不受影响,返回形状与既有调用方一致
|
|
546
|
+
const ok = await save.execute(
|
|
547
|
+
{ type: "project", title: "正常标题", content: "正常正文内容" },
|
|
548
|
+
{ agent: { session: { id: "sess-deny" } } }
|
|
549
|
+
);
|
|
550
|
+
assert.equal(ok.action, "created");
|
|
551
|
+
assert.ok(ok.id, "正常路径仍然返回 id");
|
|
552
|
+
});
|
|
553
|
+
|