@modusensus/dsh-mneme 0.9.0 → 0.9.2
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 +82 -19
- package/bin/cli.mjs +69 -9
- package/docs/CONFIGURATION.md +48 -0
- package/docs/SEMANTIC.md +3 -3
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +339 -59
- package/lib/candidates.js +454 -454
- package/lib/client.js +669 -256
- package/lib/config.js +50 -2
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +153 -22
- package/lib/index.js +32 -0
- package/lib/inject.js +34 -3
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +128 -7
- package/lib/scope-state.js +277 -0
- package/lib/semantic.js +66 -10
- package/lib/service.js +80 -3
- package/lib/settings.js +139 -1
- package/lib/store.js +216 -3
- package/lib/summarize.js +2 -3
- package/lib/tools.js +71 -0
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +339 -59
- package/src/candidates.js +454 -454
- package/src/config.js +50 -2
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +153 -22
- package/src/index.js +32 -0
- package/src/inject.js +34 -3
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +128 -7
- package/src/scope-state.js +277 -0
- package/src/semantic.js +66 -10
- package/src/service.js +80 -3
- package/src/settings.js +139 -1
- package/src/store.js +216 -3
- package/src/summarize.js +2 -3
- package/src/tools.js +71 -0
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +210 -1
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +216 -6
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/lib-smoke.test.js +20 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +137 -6
- package/test/readme-claims.test.js +68 -0
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +16 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-state.test.js +481 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
- package/test/workflow-guards.test.js +156 -0
package/test/pipeline.test.js
CHANGED
|
@@ -421,7 +421,7 @@ test("绕行不染色:被绕开不等于这一步坏了", () => {
|
|
|
421
421
|
assert.equal(doc.nodes[0].credit.detoured, 1);
|
|
422
422
|
});
|
|
423
423
|
|
|
424
|
-
test(
|
|
424
|
+
test(`必经步骤跳过 ≥${RETIRE_AFTER_SKIPS} 次 → drop-step 与 rework-path 并存(R2 新语义)`, () => {
|
|
425
425
|
const pipeline = validatePipeline(minimal({
|
|
426
426
|
steps: [
|
|
427
427
|
{ id: "A1", phase: "p", title: "这一步其实是多余的", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] },
|
|
@@ -432,10 +432,24 @@ test(`跳过 ≥${RETIRE_AFTER_SKIPS} 次 → 提议删掉这一步`, () => {
|
|
|
432
432
|
{ id: "r2", steps: [{ id: "A1", skipped: true }, { id: "A2" }] },
|
|
433
433
|
{ id: "r3", steps: [{ id: "A1", skipped: true }, { id: "A2" }] }];
|
|
434
434
|
const s = suggestRetirements(pipeline, { runs });
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
assert.equal(s
|
|
438
|
-
assert.
|
|
435
|
+
// R2 新语义:必经的一步总被跳,删步(它可能多余)与修图(流程可能写错)两种读法
|
|
436
|
+
// **同时给人**——07 号文的教训:if/else 吞并会让一种提议永远发不出来。
|
|
437
|
+
assert.equal(s.some((x) => x.kind === "drop-step" && x.stepId === "A1"), true);
|
|
438
|
+
assert.equal(s.some((x) => x.kind === "rework-path" && x.stepId === "A1" && /必经/.test(x.because)), true);
|
|
439
|
+
assert.match(s.find((x) => x.kind === "drop-step").because, /跳过 3 次/);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test(`optional 步骤跳过再多次也绝不提议删(R2 新语义)`, () => {
|
|
443
|
+
const pipeline = validatePipeline(minimal({
|
|
444
|
+
steps: [
|
|
445
|
+
{ id: "OPT", phase: "p", title: "可跑可不跑", when: "w", do: "d", who: "main", tool: "npm run lint", done: "x", risk: "R0", needs: [], why: "y", optional: true },
|
|
446
|
+
{ id: "A2", phase: "p", title: "b", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: ["OPT"], why: "y" }
|
|
447
|
+
]
|
|
448
|
+
}));
|
|
449
|
+
const runs = [1, 2, 3, 4].map((i) => ({ id: `r${i}`, steps: [{ id: "OPT", skipped: true }, { id: "A2" }] }));
|
|
450
|
+
const s = suggestRetirements(pipeline, { runs });
|
|
451
|
+
assert.equal(s.some((x) => x.kind === "drop-step"), false, "可选步骤绝不提议删");
|
|
452
|
+
assert.equal(s.some((x) => x.kind === "optional-skip-ok"), true, "给信息项供人留意");
|
|
439
453
|
});
|
|
440
454
|
|
|
441
455
|
test(`绕开 ≥${RETIRE_AFTER_DETOURS} 次 → 提议重看这条路径(不是删节点)`, () => {
|
|
@@ -467,7 +481,7 @@ test("没到阈值就不提议;两种提议可以同时出现但各自独立",
|
|
|
467
481
|
{ id: "r2", steps: [{ id: "A1", skipped: true, detoured: true }] },
|
|
468
482
|
{ id: "r3", steps: [{ id: "A1", skipped: true, detoured: true }] }];
|
|
469
483
|
const s = suggestRetirements(pipeline, { runs });
|
|
470
|
-
assert.deepEqual(s.map((x) => x.kind).sort(), ["drop-step", "rework-path"], "
|
|
484
|
+
assert.deepEqual(s.map((x) => x.kind).sort(), ["drop-step", "rework-path", "rework-path"], "同一节点上两种提议互不吞并;必经被跳时 drop-step 与 rework-path 并存");
|
|
471
485
|
});
|
|
472
486
|
|
|
473
487
|
test("readPipelineDoc 带上 suggestions(没到阈值时是空数组,不是缺字段)", () => {
|
|
@@ -475,6 +489,123 @@ test("readPipelineDoc 带上 suggestions(没到阈值时是空数组,不是
|
|
|
475
489
|
assert.ok(Array.isArray(doc.suggestions), "字段必须在——缺字段会让面板分不清「没有」和「读不到」");
|
|
476
490
|
});
|
|
477
491
|
|
|
492
|
+
// ── 字段白名单锁:给「静默掉字段」这个洞装一道闸 ────────────────────────────
|
|
493
|
+
//
|
|
494
|
+
// 为什么需要这道锁:`validatePipeline` 的步骤归一化是**显式字段映射**——白名单外的键
|
|
495
|
+
// 会被无声丢弃,不告警、不报错。所以「往工作流里写一个新字段」的默认结局是「它消失了,
|
|
496
|
+
// 而面板上看不出少了东西」。这正是本仓历史上反复抓到的那一类「声称改了但产物里查不到」。
|
|
497
|
+
//
|
|
498
|
+
// 探针实测过(三面逐个数):往步骤里塞 `assert` / `onFailure` / `retries`,三个全部消失。
|
|
499
|
+
// 将来真要加字段,就得同时改三处——归一化返回、节点投影、首屏清单——漏掉任何一处,
|
|
500
|
+
// 症状都是「加了但看不见」,且**不会红**。这道锁把那个症状提前到测试里:
|
|
501
|
+
// 谁动了字段集合,这里当场变红,逼人把三处一起改完、并把新集合写进断言。
|
|
502
|
+
//
|
|
503
|
+
// 反面提醒:断言里写的是**全集**而不是「包含」。包含式断言挡不住「多加了一个字段」,
|
|
504
|
+
// 而多出来的字段同样会进投影、同样会被前端与 agent 读到——那也是一种契约变更。
|
|
505
|
+
//
|
|
506
|
+
// 2026-10-16:`assert` 正式进产品(这一步改了四处:校验、归一化返回、节点投影、
|
|
507
|
+
// 首屏计数)。三处集合相应更新——**这道锁逼着人一次改完,正是它存在的理由**。
|
|
508
|
+
|
|
509
|
+
const STEP_KEYS = [
|
|
510
|
+
"assert", "do", "done", "evidence", "gate", "id", "needs", "optional", "phase", "risk", "title", "tool", "when", "who", "why"
|
|
511
|
+
];
|
|
512
|
+
const NODE_KEYS = [
|
|
513
|
+
"action", "assert", "counterfactual", "credit", "done", "id", "kind", "phase", "risk", "situation",
|
|
514
|
+
"source", "state", "title", "tool", "who", "why"
|
|
515
|
+
];
|
|
516
|
+
const AVAILABLE_KEYS = [
|
|
517
|
+
"assertable", "contrast", "done", "file", "gates", "id", "project", "scope", "scopeLabel", "states",
|
|
518
|
+
"status", "steps", "title", "trigger", "version"
|
|
519
|
+
];
|
|
520
|
+
const NODE_STATE_KEYS = ["candidate", "refuted", "stale", "trusted"];
|
|
521
|
+
|
|
522
|
+
test("字段白名单锁:步骤归一化只留这 14 个键,白名单外的键会被静默丢弃", () => {
|
|
523
|
+
const norm = validatePipeline(minimal({
|
|
524
|
+
steps: [{
|
|
525
|
+
id: "A1", phase: "准备", title: "第一步", when: "有人要跑", do: "做一件事",
|
|
526
|
+
who: "main", done: "做完了", risk: "R0", needs: [], why: "因为",
|
|
527
|
+
assert: ["npm test 全绿"],
|
|
528
|
+
// 这两个仍在白名单外:它们属执行侧,等形状定了再加,免得又造两个存不下的字段。
|
|
529
|
+
onFailure: "halt",
|
|
530
|
+
retries: 0
|
|
531
|
+
}]
|
|
532
|
+
}), "lock.json");
|
|
533
|
+
|
|
534
|
+
assert.deepEqual(Object.keys(norm.steps[0]).sort(), STEP_KEYS,
|
|
535
|
+
"步骤键集合变了。若这是有意的:请同时改 pipeline.js 的归一化返回、toPipelineV1 的节点投影、summarizePipeline 的首屏计数,再把这里的集合更新掉。");
|
|
536
|
+
|
|
537
|
+
// assert 现在留得住(这是 2026-10-16 那一刀要证明的事)
|
|
538
|
+
assert.deepEqual(norm.steps[0].assert, ["npm test 全绿"]);
|
|
539
|
+
|
|
540
|
+
// 仍被丢弃的两个:明写断言,将来若接住了这条会红,提醒更新契约与文档。
|
|
541
|
+
for (const key of ["onFailure", "retries"]) {
|
|
542
|
+
assert.equal(Object.prototype.hasOwnProperty.call(norm.steps[0], key), false,
|
|
543
|
+
`${key} 今天不该出现在归一化结果里;它出现了说明白名单被改过,请一并更新本锁与相关文档`);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("字段白名单锁:节点投影 16 个键、首屏清单 15 个键,节点状态恒为四态", () => {
|
|
548
|
+
const norm = validatePipeline(minimal(), "lock.json");
|
|
549
|
+
const withOrigin = { ...norm, origin: { scope: "project", label: "demo", dir: "", file: "lock.json" } };
|
|
550
|
+
|
|
551
|
+
const v1 = toPipelineV1(withOrigin, {});
|
|
552
|
+
assert.deepEqual(Object.keys(v1.nodes[0]).sort(), NODE_KEYS,
|
|
553
|
+
"节点键集合变了——前端与 agent 读的是这一份,改它要同步面板");
|
|
554
|
+
assert.equal(v1.nodes[0].credit, null,
|
|
555
|
+
"没有运行记录时 credit 必须是 null——一个「补了默认值的空 credit 对象」会让前端以为「跑过但全是 0」");
|
|
556
|
+
const doc = readPipelineDoc({ projectRoot: REPO_ROOT });
|
|
557
|
+
assert.ok(doc.available.length >= 1);
|
|
558
|
+
assert.deepEqual(Object.keys(doc.available[0]).sort(), AVAILABLE_KEYS,
|
|
559
|
+
"首屏清单行的键集合变了——面板第一屏读的就是这一份");
|
|
560
|
+
assert.deepEqual(Object.keys(doc.available[0].states).sort(), NODE_STATE_KEYS,
|
|
561
|
+
"节点状态只能是这四态;多一态就意味着前端有分支没接");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("可断言节点:没写断言时投影是 null 而不是空数组(「没写」不等于「写了没通过」)", () => {
|
|
565
|
+
const bare = validatePipeline(minimal(), "lock.json"); // minimal 的步骤没有 assert
|
|
566
|
+
const withOrigin = { ...bare, origin: { scope: "project", label: "demo", dir: "", file: "lock.json" } };
|
|
567
|
+
const v1 = toPipelineV1(withOrigin, {});
|
|
568
|
+
|
|
569
|
+
assert.equal(v1.nodes[0].assert, null,
|
|
570
|
+
"空数组会让「作者没写断言」与「写了但一条都没过」长得一样——验收时这是两件事");
|
|
571
|
+
// 首屏计数同样要说得清:0/1 是实话,不是缺陷。
|
|
572
|
+
const doc = readPipelineDoc({ projectRoot: REPO_ROOT });
|
|
573
|
+
assert.equal(typeof doc.available[0].assertable, "number");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("可断言节点:写了断言就进投影与首屏计数,且逐条可读", () => {
|
|
577
|
+
const p = validatePipeline(minimal({
|
|
578
|
+
steps: [{
|
|
579
|
+
id: "A1", phase: "准备", title: "第一步", when: "有人要跑", do: "做一件事",
|
|
580
|
+
who: "main", done: "做完了", risk: "R0", needs: [], why: "因为",
|
|
581
|
+
assert: ["git tag 已推送", "release.yml 绿"]
|
|
582
|
+
}]
|
|
583
|
+
}), "lock.json");
|
|
584
|
+
const withOrigin = { ...p, origin: { scope: "project", label: "demo", dir: "", file: "lock.json" } };
|
|
585
|
+
const v1 = toPipelineV1(withOrigin, {});
|
|
586
|
+
|
|
587
|
+
assert.deepEqual(v1.nodes[0].assert, { checks: ["git tag 已推送", "release.yml 绿"], count: 2 });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("可断言节点的形参校验:非数组、空串、非字符串都要拒(坏形状不许静默通过)", () => {
|
|
591
|
+
const base = {
|
|
592
|
+
id: "demo", name: "演示", project: "a/b", version: 0, status: "active", done: "做完",
|
|
593
|
+
trigger: [{ kind: "phrase", value: "跑一下" }],
|
|
594
|
+
steps: [{
|
|
595
|
+
id: "A1", phase: "准备", title: "第一步", when: "有人要跑", do: "做一件事",
|
|
596
|
+
who: "main", done: "做完了", risk: "R0", needs: [], why: "因为"
|
|
597
|
+
}]
|
|
598
|
+
};
|
|
599
|
+
const withAssert = (value) => validatePipeline(
|
|
600
|
+
{ ...base, steps: [{ ...base.steps[0], assert: value }] }, "x.json");
|
|
601
|
+
|
|
602
|
+
assert.throws(() => withAssert("npm test 全绿"), /assert 必须是字符串数组/);
|
|
603
|
+
assert.throws(() => withAssert([""]), /assert\[0\] 必须是非空字符串/);
|
|
604
|
+
assert.throws(() => withAssert([42]), /assert\[0\] 必须是非空字符串/);
|
|
605
|
+
// 空数组是合法的:它就是「判断节点」,只是作者显式写了个空。
|
|
606
|
+
assert.deepEqual(withAssert([]).steps[0].assert, []);
|
|
607
|
+
});
|
|
608
|
+
|
|
478
609
|
// ── 候选路由:与工作流路由是两处不同的东西 ──────────────────────────────────
|
|
479
610
|
|
|
480
611
|
test("路由 /api/dsh-mneme/candidates:读候选层,且把三条边界写进契约", async () => {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 两份 README 曾经都写着「默认全离线」「offline by default」「默认零网络依赖」——
|
|
2
|
+
// 这与代码默认值相反:autoDream 与 autoSummarize 默认开(src/config.js),
|
|
3
|
+
// 两者都把记忆正文交给 ctx.llm.stream(...)(src/dream.js / src/summarize.js);
|
|
4
|
+
// embedProvider 默认还是 "openai",只有显式改成 "local"(ONNX)才真正离线。
|
|
5
|
+
// 一句假话比不写更坏:读者会据此以为装完就能断网用,而它默认会把正文发给宿主模型。
|
|
6
|
+
// 所以这里锁两件事:一、绝对离线声明不许回来;二、隐私节必须写出「巩固走宿主模型」这个出站事实——
|
|
7
|
+
// 删掉假话后如果什么都不说,误导是一样的。
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
const repoRoot = path.dirname(pkgRoot);
|
|
16
|
+
const rootReadmePath = path.join(repoRoot, "README.md");
|
|
17
|
+
|
|
18
|
+
// npm 的 files 清单把整个 test/ 打进包里,所以在 packed / installed 包里跑 npm test 时,
|
|
19
|
+
// 这个文件照样会被执行——但那里没有仓库根 README(只有包内 dsh-mneme/README.md)。
|
|
20
|
+
// 直接 readFileSync 会让整个测试文件在加载阶段 ENOENT 崩掉,把「不是这个仓」报成「断言失败」。
|
|
21
|
+
// 所以仓库根不在时显式 skip:这条守卫的适用面本来就只是仓库,不该在包内假红。
|
|
22
|
+
const hasRepoRoot = existsSync(rootReadmePath);
|
|
23
|
+
const rootReadme = hasRepoRoot ? readFileSync(rootReadmePath, "utf8") : "";
|
|
24
|
+
const pkgReadme = readFileSync(path.join(pkgRoot, "README.md"), "utf8");
|
|
25
|
+
const outsideRepo = hasRepoRoot ? {} : { skip: "not a repo checkout: 仓库根 README 不存在(包内安装态)" };
|
|
26
|
+
|
|
27
|
+
// 只锁「默认 / 无条件」的说法。带条件的离线表述(把 embedProvider 改成 local、离线巩固论文、
|
|
28
|
+
// 运行时离线取件)都是真话,不在这个列表里,否则守卫会把正确写法也判红。
|
|
29
|
+
const ABSOLUTE_CLAIMS = [
|
|
30
|
+
"默认全离线",
|
|
31
|
+
"默认零网络依赖",
|
|
32
|
+
"offline by default",
|
|
33
|
+
"Zero network dependency by default",
|
|
34
|
+
"完全离线的语义记忆引擎",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function section(text, heading) {
|
|
38
|
+
const start = text.indexOf(heading);
|
|
39
|
+
assert.ok(start >= 0, `README 里应有「${heading}」一节`);
|
|
40
|
+
const rest = text.slice(start + heading.length);
|
|
41
|
+
const next = rest.search(/\r?\n## /);
|
|
42
|
+
return next === -1 ? rest : rest.slice(0, next);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("两份 README 都不许再把「离线」写成默认行为", outsideRepo, () => {
|
|
46
|
+
for (const [name, text] of [
|
|
47
|
+
["README.md", rootReadme],
|
|
48
|
+
["dsh-mneme/README.md", pkgReadme],
|
|
49
|
+
]) {
|
|
50
|
+
for (const claim of ABSOLUTE_CLAIMS) {
|
|
51
|
+
assert.ok(!text.includes(claim), `${name} 仍含绝对离线声明:${claim}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("隐私节必须写出「巩固与摘要走宿主模型」这个出站事实", outsideRepo, () => {
|
|
57
|
+
const zh = section(rootReadme, "## 隐私承诺");
|
|
58
|
+
assert.ok(zh.includes("宿主模型"), "中文隐私节应说明巩固与摘要走宿主模型");
|
|
59
|
+
assert.ok(
|
|
60
|
+
zh.includes("autoDream") || zh.includes("autoSummarize"),
|
|
61
|
+
"中文隐私节应点名 autoDream / autoSummarize,否则读者不知道是谁在出站",
|
|
62
|
+
);
|
|
63
|
+
const en = section(rootReadme, "## Privacy");
|
|
64
|
+
assert.ok(
|
|
65
|
+
en.includes("host model"),
|
|
66
|
+
"English Privacy section should disclose that consolidation uses the host model",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
@@ -28,13 +28,13 @@ test("Issue #128: fingerprint match no longer blocks backfill; archived rows are
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const logs = [];
|
|
31
|
-
const
|
|
31
|
+
const summary = await backfillMissingEmbeddings({
|
|
32
32
|
store, embedder, vectorIndex,
|
|
33
33
|
logger: { info: (m) => logs.push(m) },
|
|
34
34
|
rateLimitMs: 0
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
assert.equal(indexed, 1, "the active row is backfilled despite the matching fingerprint");
|
|
37
|
+
assert.equal(summary.indexed, 1, "the active row is backfilled despite the matching fingerprint");
|
|
38
38
|
assert.equal(store.getEmbeddings([active.id]).get(active.id)?.length, 3, "active row now carries a vector");
|
|
39
39
|
assert.equal(store.getEmbeddings([dead.id]).get(dead.id), undefined, "archived row is skipped");
|
|
40
40
|
assert.equal(vectorIndex.modelHash(), "mock-model#abc", "fingerprint unchanged (markModel idempotent)");
|
|
@@ -51,12 +51,91 @@ test("Issue #128: backfill is bounded by maxTotal; the rest stays queued", async
|
|
|
51
51
|
const embedder = { embedSingle: async () => [1, 0, 0], modelHash: "h#1", dimension: 3 };
|
|
52
52
|
const vectorIndex = createVectorIndex({ store });
|
|
53
53
|
|
|
54
|
-
const
|
|
54
|
+
const summary = await backfillMissingEmbeddings({
|
|
55
55
|
store, embedder, vectorIndex,
|
|
56
56
|
logger: { info() {} },
|
|
57
57
|
maxTotal: 4, batchSize: 2, rateLimitMs: 0
|
|
58
58
|
});
|
|
59
|
-
assert.equal(indexed, 4, "stops at the maxTotal bound");
|
|
59
|
+
assert.equal(summary.indexed, 4, "stops at the maxTotal bound");
|
|
60
|
+
// A capped run must SAY it was capped. The old bare-count return made a
|
|
61
|
+
// capped run indistinguishable from a run that drained the backlog.
|
|
62
|
+
assert.equal(summary.finishReason, "capped");
|
|
63
|
+
assert.equal(summary.stillStale, true, "reports that work remains");
|
|
60
64
|
assert.equal(store.needsEmbedding(10).length, 1, "remaining rows stay queued for the next boot");
|
|
61
65
|
store.close();
|
|
62
66
|
});
|
|
67
|
+
|
|
68
|
+
test("maxTotal: 0 drains the whole backlog in one run", async () => {
|
|
69
|
+
const store = createStore(":memory:");
|
|
70
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
71
|
+
for (let i = 0; i < 5; i++) {
|
|
72
|
+
service.saveWithDedupe({ type: "preference", title: `记忆${i}`, content: `内容${i}` });
|
|
73
|
+
}
|
|
74
|
+
const embedder = { embedSingle: async () => [1, 0, 0], modelHash: "h#1", dimension: 3 };
|
|
75
|
+
const vectorIndex = createVectorIndex({ store });
|
|
76
|
+
|
|
77
|
+
const summary = await backfillMissingEmbeddings({
|
|
78
|
+
store, embedder, vectorIndex,
|
|
79
|
+
logger: { info() {} },
|
|
80
|
+
maxTotal: 0, batchSize: 2, rateLimitMs: 0
|
|
81
|
+
});
|
|
82
|
+
assert.equal(summary.indexed, 5, "no cap: every row is embedded");
|
|
83
|
+
assert.equal(summary.finishReason, "drained");
|
|
84
|
+
assert.equal(summary.stillStale, false, "nothing left behind");
|
|
85
|
+
store.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a full-model switch re-embeds old vectors, not just the empty ones", async () => {
|
|
89
|
+
// The old selector only matched NULL/'' — vectors from the previous model
|
|
90
|
+
// stayed "present" forever and were never re-embedded, so they kept
|
|
91
|
+
// inflating coverage while being incomparable to the new query embedding.
|
|
92
|
+
const store = createStore(":memory:");
|
|
93
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
94
|
+
const a = service.saveWithDedupe({ type: "preference", title: "旧向量", content: "甲" }).memory;
|
|
95
|
+
const b = service.saveWithDedupe({ type: "preference", title: "无向量", content: "乙" }).memory;
|
|
96
|
+
store.setEmbedding(a.id, [1, 0, 0], "old-model#aaa");
|
|
97
|
+
// b stays empty.
|
|
98
|
+
|
|
99
|
+
const embedder = { embedSingle: async () => [0, 1, 0], modelHash: "new-model#bbb", dimension: 3 };
|
|
100
|
+
const vectorIndex = createVectorIndex({ store });
|
|
101
|
+
const summary = await backfillMissingEmbeddings({
|
|
102
|
+
store, embedder, vectorIndex, logger: { info() {} }, rateLimitMs: 0
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.equal(summary.indexed, 2, "both the stale-model row and the empty row are re-embedded");
|
|
106
|
+
assert.equal(summary.finishReason, "drained");
|
|
107
|
+
assert.equal(store.needsEmbeddingFor("new-model#bbb", 10).length, 0, "nothing stale remains");
|
|
108
|
+
const breakdown = store.embeddingModelBreakdown();
|
|
109
|
+
assert.equal(breakdown.length, 1, "every vector now carries the new model");
|
|
110
|
+
assert.equal(breakdown[0].model, "new-model#bbb");
|
|
111
|
+
assert.equal(breakdown[0].c, 2);
|
|
112
|
+
store.close();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("rows with unknown provenance (NULL embedding_model) count as stale", async () => {
|
|
116
|
+
const store = createStore(":memory:");
|
|
117
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
118
|
+
const r = service.saveWithDedupe({ type: "preference", title: "存量", content: "丙" }).memory;
|
|
119
|
+
// Simulate a pre-migration row: vector present, provenance unknown.
|
|
120
|
+
store.setEmbedding(r.id, [1, 0, 0]);
|
|
121
|
+
|
|
122
|
+
assert.equal(store.needsEmbedding(10).length, 0, "old selector sees nothing to do");
|
|
123
|
+
assert.equal(store.needsEmbeddingFor("new-model#bbb", 10).length, 1, "new selector flags unknown origin");
|
|
124
|
+
assert.equal(store.hasStaleEmbedding("new-model#bbb"), true);
|
|
125
|
+
store.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("setEmbedding records provenance and clearing the vector clears it", async () => {
|
|
129
|
+
const store = createStore(":memory:");
|
|
130
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
131
|
+
const r = service.saveWithDedupe({ type: "preference", title: "出身", content: "丁" }).memory;
|
|
132
|
+
|
|
133
|
+
store.setEmbedding(r.id, [1, 0, 0], "m#1");
|
|
134
|
+
assert.equal(store.embeddingModelBreakdown()[0].model, "m#1");
|
|
135
|
+
|
|
136
|
+
store.setEmbedding(r.id, null);
|
|
137
|
+
const row = store.db.prepare("SELECT embedding, embedding_model FROM memories WHERE id = ?").get(r.id);
|
|
138
|
+
assert.equal(row.embedding, null);
|
|
139
|
+
assert.equal(row.embedding_model, null, "a cleared vector never keeps a stale model name");
|
|
140
|
+
store.close();
|
|
141
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// 锁发布准备里「改文档表格」这一段的三件事,它们各自都静默失效过或随时会:
|
|
2
|
+
//
|
|
3
|
+
// 1. 根 README 的路线图版本列写的是两段式 **v0.9**,旧实现只认三段式 **v0.9.0**,于是
|
|
4
|
+
// 对根 README 一个字节没改,打印的却是「无版本表格行,跳过」——把「没做」说成「不需要做」。
|
|
5
|
+
// 2. 往 CRLF 文件里插一行而不显式保行尾,会把文件混成一半 CRLF 一半 LF(changelog-prep 踩过)。
|
|
6
|
+
// 3. SECURITY.md 的支持版本表跨 minor 时必须由人挪格。脚本只核对;核对一旦退化成永真,
|
|
7
|
+
// 0.10.0 就能带着「0.9.x 活跃开发中」的表格发出去——那是安全策略文档里的谎话。
|
|
8
|
+
//
|
|
9
|
+
// 前两类直接读真实 README 断言(正则被改窄时,用户会静默看到「跳过」,而测试必须变红)。
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { checkSupportTable, scanReleaseRows, syncReadmeTables } from "../scripts/release-docs.mjs";
|
|
16
|
+
|
|
17
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
18
|
+
const repoRoot = path.dirname(pkgRoot);
|
|
19
|
+
const rootReadme = readFileSync(path.join(repoRoot, "README.md"), "utf8");
|
|
20
|
+
const pkgReadme = readFileSync(path.join(pkgRoot, "README.md"), "utf8");
|
|
21
|
+
const securityDoc = readFileSync(path.join(repoRoot, "SECURITY.md"), "utf8");
|
|
22
|
+
const { version } = JSON.parse(readFileSync(path.join(pkgRoot, "package.json"), "utf8"));
|
|
23
|
+
|
|
24
|
+
const CRLF = "\r\n";
|
|
25
|
+
const crlf = (...lines) => lines.join(CRLF) + CRLF;
|
|
26
|
+
|
|
27
|
+
test("两份真实 README 的版本表都还认得出(根按 minor、子按 patch)", () => {
|
|
28
|
+
const root = scanReleaseRows(rootReadme);
|
|
29
|
+
assert.ok(root.two.length >= 3, `根 README 的路线图应解析出至少 3 行两段式版本行,实际 ${root.two.length}`);
|
|
30
|
+
assert.equal(root.three.length, 0,
|
|
31
|
+
"根 README 的路线图按 minor 排,不该出现三段式版本行——出现了说明表格形状变了,插入位置需要重算");
|
|
32
|
+
const sub = scanReleaseRows(pkgReadme);
|
|
33
|
+
assert.ok(sub.three.length >= 1, `子 README 的版本历史应解析出至少 1 行三段式版本行,实际 ${sub.three.length}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("根 README 的两段式路线图能插进新 minor 占位行(旧实现零命中)", () => {
|
|
37
|
+
const sample = crlf(
|
|
38
|
+
"| 版本 | 主题 | 状态 |",
|
|
39
|
+
"|------|------|------|",
|
|
40
|
+
"| **v0.8** | 生态化 | ✅ 已发布(至 v0.8.16) |",
|
|
41
|
+
"| **v0.9** | 手法 beta | ✅ 已发布(v0.9.0) |",
|
|
42
|
+
);
|
|
43
|
+
const { text, actions } = syncReadmeTables(sample, "0.10.0", "2026-11-01");
|
|
44
|
+
assert.equal(actions.filter((a) => a.kind === "insert").length, 1, "应插入且只插入一行");
|
|
45
|
+
assert.match(text, /\| \*\*v0\.10\*\* \| (待填) \| ✅ 已发布(v0\.10\.0) \|/,
|
|
46
|
+
"占位行按 minor 写 v0.10,状态写这次要发出去的完整版本");
|
|
47
|
+
// 插在 v0.9 之后,而不是表尾——表尾将来会有更高的计划行
|
|
48
|
+
assert.ok(text.indexOf("**v0.10**") > text.indexOf("**v0.9**"), "占位行应排在 v0.9 之后");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("真实根 README 的中英两张路线图各插一行(同一版本号,不能只插一张)", () => {
|
|
52
|
+
// 两张表版本行完全一样,旧实现把全文件版本行合成一个数组判断「已存在」,
|
|
53
|
+
// 于是先插的中文表会让英文表被跳过——英文读者永远等不到那一行。
|
|
54
|
+
const { actions } = syncReadmeTables(rootReadme, "0.10.0", "2026-11-01");
|
|
55
|
+
const inserts = actions.filter((a) => a.kind === "insert");
|
|
56
|
+
assert.equal(inserts.length, 2, "中英路线图各一行,实际:" + JSON.stringify(actions));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("子 README 的版本历史插占位行时写完整 patch 号并带日期", () => {
|
|
60
|
+
const sample = crlf(
|
|
61
|
+
"| 版本 | 主题 |",
|
|
62
|
+
"|------|------|",
|
|
63
|
+
"| **v0.9.0**(2026-10-17) | 手法 beta |",
|
|
64
|
+
"| **v0.8.x** | 作用域隔离 |",
|
|
65
|
+
);
|
|
66
|
+
const { text } = syncReadmeTables(sample, "0.9.1", "2026-10-18");
|
|
67
|
+
assert.match(text, /\| \*\*v0\.9\.1\*\*(2026-10-18) \| (待填) \|/);
|
|
68
|
+
assert.ok(text.indexOf("**v0.9.1**") < text.indexOf("**v0.8.x**"),
|
|
69
|
+
"占位行要排在低于它的最新版本之后、概括行之前");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("路线图里上一版留下的「🚧 准备中」在发版准备时回填成已发布", () => {
|
|
73
|
+
const sample = crlf(
|
|
74
|
+
"| 版本 | 主题 | 状态 |",
|
|
75
|
+
"|------|------|------|",
|
|
76
|
+
"| **v0.9** | 手法 beta | 🚧 准备中 |",
|
|
77
|
+
);
|
|
78
|
+
const { text } = syncReadmeTables(sample, "0.9.0", "2026-10-17");
|
|
79
|
+
assert.match(text, /\| \*\*v0\.9\*\* \| 手法 beta \| ✅ 已发布(v0\.9\.0) \|/);
|
|
80
|
+
// 已经写着已发布的行不动:脚本跑在打 tag 之前,改写补丁号等于提前宣布一个还没发出去的版本。
|
|
81
|
+
const already = crlf(
|
|
82
|
+
"| 版本 | 主题 | 状态 |",
|
|
83
|
+
"|------|------|------|",
|
|
84
|
+
"| **v0.9** | 手法 beta | ✅ 已发布(v0.9.0) |",
|
|
85
|
+
);
|
|
86
|
+
assert.equal(syncReadmeTables(already, "0.9.1", "2026-10-18").text, already,
|
|
87
|
+
"已发布的补丁号由人在真正发出去之后更新,脚本不擅自提前改");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("插入占位行不改变行尾风格(CRLF 文件不会混进裸 LF)", () => {
|
|
91
|
+
const sample = crlf(
|
|
92
|
+
"| 版本 | 主题 |",
|
|
93
|
+
"|------|------|",
|
|
94
|
+
"| **v0.9.0**(2026-10-17) | 手法 beta |",
|
|
95
|
+
);
|
|
96
|
+
const { text } = syncReadmeTables(sample, "0.9.1", "2026-10-18");
|
|
97
|
+
const bareLf = (text.match(/(?<!\r)\n/g) || []).length;
|
|
98
|
+
assert.equal(bareLf, 0, `输出里出现了 ${bareLf} 个裸 LF——CRLF 文件被混写了一半 LF`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("重复跑是幂等的:已有目标的占位行就不再插第二行", () => {
|
|
102
|
+
const sample = crlf(
|
|
103
|
+
"| 版本 | 主题 |",
|
|
104
|
+
"|------|------|",
|
|
105
|
+
"| **v0.9.0**(2026-10-17) | 手法 beta |",
|
|
106
|
+
);
|
|
107
|
+
const once = syncReadmeTables(sample, "0.9.1", "2026-10-18").text;
|
|
108
|
+
const twice = syncReadmeTables(once, "0.9.1", "2026-10-18");
|
|
109
|
+
assert.equal(twice.text, once);
|
|
110
|
+
assert.deepEqual(twice.actions, [], "第二次应报告零变更,而不是再插一行");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("SECURITY.md 支持版本表:同 minor 通过,跨 minor 如实报出中英两处", () => {
|
|
114
|
+
const sameMinor = checkSupportTable(securityDoc, version);
|
|
115
|
+
assert.equal(sameMinor.ok, true, `真实 SECURITY.md 应与 package.json ${version} 一致:${sameMinor.problems.join(";")}`);
|
|
116
|
+
|
|
117
|
+
const nextMinor = `${version.split(".")[0]}.${Number(version.split(".")[1]) + 1}.0`;
|
|
118
|
+
const crossing = checkSupportTable(securityDoc, nextMinor);
|
|
119
|
+
assert.equal(crossing.ok, false, "跨 minor 时必须报错——否则会带着过时的支持范围发版");
|
|
120
|
+
assert.equal(crossing.problems.length, 2, "中英两张表各报一条:" + crossing.problems.join(";"));
|
|
121
|
+
assert.ok(crossing.problems.every((p) => p.includes(crossing.expected)),
|
|
122
|
+
"每条问题都要写出期望值,否则人不知道改成什么:" + crossing.problems.join(";"));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("SECURITY.md 支持版本表:节缺失或表格形状变了也报错(不能静默通过)", () => {
|
|
126
|
+
assert.equal(checkSupportTable("# Security\n\n## 别的节\n", "0.9.0").ok, false);
|
|
127
|
+
const noRows = "## Supported Versions\r\n\r\n没有表格\r\n\r\n## 支持版本\r\n\r\n没有表格\r\n";
|
|
128
|
+
assert.equal(checkSupportTable(noRows, "0.9.0").ok, false,
|
|
129
|
+
"解析不出任何版本行时必须失败:正则失配不能变成永真");
|
|
130
|
+
});
|