@modusensus/dsh-mneme 0.9.1 → 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 +7 -6
- package/bin/cli.mjs +61 -7
- package/docs/CONFIGURATION.md +23 -0
- package/lib/api.js +57 -2
- package/lib/client.js +72 -9
- package/lib/config.js +17 -0
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +52 -22
- package/lib/scope-state.js +277 -0
- package/lib/service.js +80 -3
- package/lib/settings.js +7 -0
- package/lib/store.js +139 -0
- package/lib/tools.js +71 -0
- package/package.json +1 -1
- package/src/api.js +57 -2
- package/src/config.js +17 -0
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +52 -22
- package/src/scope-state.js +277 -0
- package/src/service.js +80 -3
- package/src/settings.js +7 -0
- package/src/store.js +139 -0
- package/src/tools.js +71 -0
- package/test/api.test.js +113 -1
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +64 -3
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/readme-claims.test.js +68 -0
- package/test/scope-state.test.js +481 -0
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
package/src/tools.js
CHANGED
|
@@ -7,6 +7,7 @@ import { matchesPlatform } from "./runtime/closure.js";
|
|
|
7
7
|
import { verifyPayload } from "./runtime/verify.js";
|
|
8
8
|
import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
|
|
9
9
|
import { injectChildEnabled } from "./config.js";
|
|
10
|
+
import { normalizeScopeStateDimension, normalizeScopeStateValue, normalizeScopeStateState } from "./scope-state.js";
|
|
10
11
|
import { lastUserQuery } from "./inject.js";
|
|
11
12
|
|
|
12
13
|
const TEXT_OUTPUT = (text) => [{ type: "text", text }];
|
|
@@ -500,6 +501,76 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
500
501
|
}
|
|
501
502
|
}),
|
|
502
503
|
|
|
504
|
+
// #471:政策轴——作用域级可逆闸门。与 memory_forget/memory_archive 的分工:
|
|
505
|
+
// 那两个是事实轴的单条操作(改的是「这条记忆属于谁 / 还算不算数」,一条条来);
|
|
506
|
+
// 本工具是政策轴的按作用域操作(改的是「这一路现在通不通」,跨会话存续、
|
|
507
|
+
// 松开即原样回来,一条记忆行都不动)。判断指引:整条工作线要临时静音用本工具;
|
|
508
|
+
// 单条记忆不该再被想起才用 memory_forget/memory_archive。
|
|
509
|
+
defineTool({
|
|
510
|
+
name: "set_scope_state",
|
|
511
|
+
description:
|
|
512
|
+
"Press or release the reversible scope gate for one scope (issue #471): while paused, that scope's memories stay stored and unchanged but are held back from injection and dream consolidation. " +
|
|
513
|
+
"This is not deletion and not forgetting — release with state=active and everything comes back exactly as it was, which is why it is the right tool for temporarily silencing a whole work stream rather than editing memories one by one. " +
|
|
514
|
+
"dimension is agent or workspace; scope_value is the label itself, or * for that dimension's global gate. " +
|
|
515
|
+
"Both switches (scopeState.enabled/enforce) are off by default, so setting a state has no effect on injection until the user turns them on in the panel.",
|
|
516
|
+
parameters: {
|
|
517
|
+
dimension: { type: "string", required: true, description: "Scope dimension to press: \"agent\" or \"workspace\"" },
|
|
518
|
+
scope_value: { type: "string", required: true, description: "The scope label, or \"*\" for that dimension's global gate" },
|
|
519
|
+
state: { type: "string", required: true, description: "\"paused\" presses the gate, \"active\" releases it" },
|
|
520
|
+
note: { type: "string", description: "Optional short reason, stored with the decision so the next reader knows why it was pressed" }
|
|
521
|
+
},
|
|
522
|
+
output: {
|
|
523
|
+
schema: {
|
|
524
|
+
type: "object",
|
|
525
|
+
additionalProperties: false,
|
|
526
|
+
properties: {
|
|
527
|
+
scope: {
|
|
528
|
+
type: "object",
|
|
529
|
+
additionalProperties: false,
|
|
530
|
+
properties: {
|
|
531
|
+
dimension: { type: "string", required: true },
|
|
532
|
+
scope_value: { type: "string", required: true },
|
|
533
|
+
state: { type: "string", required: true },
|
|
534
|
+
// 理由要能读回来:不带它,下次读的人只知道「按下过」却不知道为什么。
|
|
535
|
+
// 这与库层 setScopeState 的 note 取舍是同一条理由——不带理由的重按
|
|
536
|
+
// 不抹掉旧理由,正是为了让这里读得回来。可选:从没写过就没有。
|
|
537
|
+
note: { type: "string" },
|
|
538
|
+
decided_at: { type: "string", required: true }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
// 回执如实回显当前值(不是回显入参):同一个状态重复按下时 decided_at 保持
|
|
544
|
+
// 首次值,回显它才看得出「这次没有真的改变什么」。
|
|
545
|
+
render: (_args, value) =>
|
|
546
|
+
TEXT_OUTPUT(
|
|
547
|
+
`Scope ${value.scope.dimension}:${value.scope.scope_value} is now ${value.scope.state} (decided at ${value.scope.decided_at}).`
|
|
548
|
+
)
|
|
549
|
+
},
|
|
550
|
+
async execute(args) {
|
|
551
|
+
const dimension = normalizeScopeStateDimension(args.dimension);
|
|
552
|
+
const scopeValue = normalizeScopeStateValue(args.scope_value);
|
|
553
|
+
const state = normalizeScopeStateState(args.state);
|
|
554
|
+
if (dimension === null) throw new Error("dimension must be \"agent\" or \"workspace\"");
|
|
555
|
+
if (scopeValue === null) throw new Error("scope_value must be a non-empty label or \"*\"");
|
|
556
|
+
if (state === null) throw new Error("state must be \"active\" or \"paused\"");
|
|
557
|
+
const row = service.setScopeState({ dimension, scope_value: scopeValue, state, note: args.note });
|
|
558
|
+
if (row === undefined) throw new Error("scope state could not be recorded");
|
|
559
|
+
return {
|
|
560
|
+
scope: {
|
|
561
|
+
dimension: row.dimension,
|
|
562
|
+
scope_value: row.scope_value,
|
|
563
|
+
state: row.state,
|
|
564
|
+
// 没写过理由时键整个不出现(而不是 note: undefined):output schema 是
|
|
565
|
+
// 闭合的,DTO 矩阵会拿真实返回值过 validateJsonSchemaValue,留一个
|
|
566
|
+
// 值为 undefined 的键等于留个空壳。
|
|
567
|
+
...(row.note === undefined ? {} : { note: row.note }),
|
|
568
|
+
decided_at: row.decided_at
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}),
|
|
573
|
+
|
|
503
574
|
// #230(#164 设计稿评审线):document 型记忆注册——agent 产长文档的指针
|
|
504
575
|
// 行铸造口。写入权分离:全文归 agent(管线对文件零读零写零改),库里只
|
|
505
576
|
// 存摘要 + doc_path + evidence 三样;C2 比对去重、supersede 记账在
|
package/test/api.test.js
CHANGED
|
@@ -538,6 +538,63 @@ test("GET /api/dsh-mneme/semantic reports configured/coverage/degraded", async (
|
|
|
538
538
|
assert.equal(noIndex.degraded, false);
|
|
539
539
|
});
|
|
540
540
|
|
|
541
|
+
// --- distill-tx-embed-gap 后续:/semantic 区分「有向量」与「可检索」 -----------
|
|
542
|
+
|
|
543
|
+
test("GET /api/dsh-mneme/semantic reports per-dimension/per-model vector breakdown", async () => {
|
|
544
|
+
const { routes, store, service } = setup(undefined);
|
|
545
|
+
// 三种状态混进一个库:3 维 2 条、旧 4 维 1 条、不可解析 1 条;
|
|
546
|
+
// 模型出身 m1#aaa 一行、m2#bbb 一行、无出身两行。
|
|
547
|
+
const a = service.saveWithDedupe({ type: "preference", title: "同维A", content: "x" }).memory;
|
|
548
|
+
const b = service.saveWithDedupe({ type: "preference", title: "同维B", content: "x" }).memory;
|
|
549
|
+
const c = service.saveWithDedupe({ type: "preference", title: "旧维C", content: "x" }).memory;
|
|
550
|
+
const d = service.saveWithDedupe({ type: "preference", title: "坏D", content: "x" }).memory;
|
|
551
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), a.id);
|
|
552
|
+
store.db.prepare("UPDATE memories SET embedding = ?, embedding_model = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), "m1#aaa", b.id);
|
|
553
|
+
store.db.prepare("UPDATE memories SET embedding = ?, embedding_model = ? WHERE id = ?").run(JSON.stringify([1, 2, 3, 4]), "m2#bbb", c.id);
|
|
554
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("not-json", d.id);
|
|
555
|
+
|
|
556
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
557
|
+
const res = new FakeRes();
|
|
558
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
559
|
+
assert.equal(res.statusCode, 200);
|
|
560
|
+
const data = JSON.parse(res.body);
|
|
561
|
+
|
|
562
|
+
// 无 embedder → 无当前维度可比对:unsearchable 报 0、retrievable 报 null。
|
|
563
|
+
assert.equal(data.vectors.unsearchable, 0);
|
|
564
|
+
assert.equal(data.vectors.retrievable, null);
|
|
565
|
+
const dims = Object.fromEntries(data.vectors.byDimension.map((e) => [e.dimension, e.count]));
|
|
566
|
+
assert.equal(dims["3"], 2);
|
|
567
|
+
assert.equal(dims["4"], 1);
|
|
568
|
+
assert.equal(dims[null], 1, "unparseable vector counted under null");
|
|
569
|
+
const models = Object.fromEntries(data.vectors.byModel.map((e) => [e.model, e.count]));
|
|
570
|
+
assert.equal(models["m1#aaa"], 1);
|
|
571
|
+
assert.equal(models["m2#bbb"], 1);
|
|
572
|
+
assert.equal(models[null], 2, "rows without provenance counted under null");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("GET /api/dsh-mneme/semantic counts stale-dimension rows as unsearchable with a matching embedder", async () => {
|
|
576
|
+
// 核心回归锁:库里掺一条错维向量,端点必须报出 unsearchable ≥ 1。
|
|
577
|
+
// 旧实现无论怎么混都只给 coverage,状态卡说不出「有向量但检索不到」。
|
|
578
|
+
const { routes, store, service } = setup({ name: "OpenAI", embed() {}, dimension: 3 });
|
|
579
|
+
const a = service.saveWithDedupe({ type: "preference", title: "同维", content: "x" }).memory;
|
|
580
|
+
const b = service.saveWithDedupe({ type: "preference", title: "错维", content: "x" }).memory;
|
|
581
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), a.id);
|
|
582
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8]), b.id);
|
|
583
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
584
|
+
const res = new FakeRes();
|
|
585
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
586
|
+
assert.equal(res.statusCode, 200);
|
|
587
|
+
const data = JSON.parse(res.body);
|
|
588
|
+
assert.equal(data.vectors.unsearchable, 1, "the 8-dim row is not searchable against a 3-dim embedder");
|
|
589
|
+
assert.equal(data.vectors.retrievable, 1, "the 3-dim row is searchable");
|
|
590
|
+
// coverage 走 semantic.vectorIndex.getStats(本测试未挂 → 0),所以「有向量的行」
|
|
591
|
+
// 用 byDimension 总和对照:两条都在「有向量」里,但只有一条可检索 —— 这正是
|
|
592
|
+
// unsearchable 必须存在的原因。
|
|
593
|
+
const embeddedRows = data.vectors.byDimension.reduce((s, e) => s + e.count, 0);
|
|
594
|
+
assert.equal(embeddedRows, 2, "both rows have vectors");
|
|
595
|
+
assert.equal(data.vectors.unsearchable, 1, "yet only one is retrievable");
|
|
596
|
+
});
|
|
597
|
+
|
|
541
598
|
// --- Bug8: llm-audit API (pagination + stats) --------------------------------
|
|
542
599
|
|
|
543
600
|
test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
|
|
@@ -686,8 +743,11 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
686
743
|
// persona 页新增 personaEnabled(人格注入顶层总闸;顶层扁平键,同上)。
|
|
687
744
|
// 计数锁钉的是「白名单键 ↔ config schema 成对」:schema 加了键而漏了白名单
|
|
688
745
|
// (或反过来)时,effective 会静默少键/多键,面板读不到也报不出来。
|
|
746
|
+
// issue #471 新增 scopeState.enabled/scopeState.enforce(政策轴作用域级可逆闸门;
|
|
747
|
+
// 两个都是嵌套对象子字段,都进 NESTED_FLAG_PATHS)。
|
|
689
748
|
// 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99
|
|
690
|
-
|
|
749
|
+
// #471 再 + 2 = 101。
|
|
750
|
+
assert.equal(Object.keys(data.effective).length, 101);
|
|
691
751
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
692
752
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
693
753
|
assert.equal(data.effective.dreamMergeGuard, true);
|
|
@@ -695,6 +755,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
695
755
|
// issue #380:注入前判定两级开关,默认全关(默认路径零行为变化)
|
|
696
756
|
assert.equal(data.effective["preInjectGate.enabled"], false);
|
|
697
757
|
assert.equal(data.effective["preInjectGate.enforce"], false);
|
|
758
|
+
// issue #471:政策轴作用域级可逆闸门,两级开关默认全关(默认路径零行为变化)
|
|
759
|
+
assert.equal(data.effective["scopeState.enabled"], false);
|
|
760
|
+
assert.equal(data.effective["scopeState.enforce"], false);
|
|
698
761
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
699
762
|
assert.equal(data.effective.autoDreamFailureBackoff, false);
|
|
700
763
|
assert.equal(data.effective.dreamMaxTokens, 131072);
|
|
@@ -755,6 +818,47 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
755
818
|
assert.equal(data.effective.dreamCandidateMode, "window");
|
|
756
819
|
assert.equal(data.effective.dreamCandidateMax, 0);
|
|
757
820
|
assert.equal(data.effective.dreamCandidateMinSim, 0.85);
|
|
821
|
+
// #481:没有 overrides 时不存在「改了还没生效」的键,必须是空数组而不是缺字段
|
|
822
|
+
// ——面板按数组长度渲染提示条,字段缺失会让它渲染出 undefined.length 这类东西。
|
|
823
|
+
assert.ok(Array.isArray(data.pendingRestart), "pendingRestart must always be an array");
|
|
824
|
+
assert.deepEqual(data.pendingRestart, []);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("GET /api/dsh-mneme/features reports only the overrides the frozen config has not picked up", async () => {
|
|
828
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
829
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
830
|
+
// #481 实测现场:面板显示人格总闸「已开启」,人格段却不注入——因为开关在宿主
|
|
831
|
+
// 启动时合并进 cfg 并冻结,运行中改 kv 只是持久化。pendingRestart 就是把
|
|
832
|
+
// 「显式覆盖 ≠ 冻结值」这个落差报出来,否则用户只能靠反复试错发现。
|
|
833
|
+
settings.setFeatureFlags({
|
|
834
|
+
personaEnabled: true, // 冻结值 false → 待重启
|
|
835
|
+
autoDream: false, // 冻结值 true → 待重启
|
|
836
|
+
"memoryQualityFilter.enabled": false, // 冻结值 true → 待重启
|
|
837
|
+
distillMaxChars: 24000 // 与冻结值相同 → 不该报
|
|
838
|
+
});
|
|
839
|
+
const res = new FakeRes();
|
|
840
|
+
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
841
|
+
assert.equal(res.statusCode, 200);
|
|
842
|
+
const data = JSON.parse(res.body);
|
|
843
|
+
// 比的是集合而非顺序(顺序跟白名单分组走,不是契约的一部分)。
|
|
844
|
+
assert.deepEqual([...data.pendingRestart].sort(), ["autoDream", "memoryQualityFilter.enabled", "personaEnabled"]);
|
|
845
|
+
assert.ok(!data.pendingRestart.includes("distillMaxChars"),
|
|
846
|
+
"a key whose override equals the frozen value is not pending");
|
|
847
|
+
// effective 仍然是「运行时取什么值」= overrides 优先,不受本字段影响。
|
|
848
|
+
assert.equal(data.effective.personaEnabled, true);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("GET /api/dsh-mneme/features cannot report pendingRestart without a frozen config", async () => {
|
|
852
|
+
// config 缺席(旧调用方直连、未传 cfg)时无从比较冻结值——返回空数组,不编造。
|
|
853
|
+
const { routes, settings } = setup(undefined);
|
|
854
|
+
settings.setFeatureFlags({ personaEnabled: true });
|
|
855
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
856
|
+
const res = new FakeRes();
|
|
857
|
+
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
858
|
+
assert.equal(res.statusCode, 200);
|
|
859
|
+
const data = JSON.parse(res.body);
|
|
860
|
+
assert.deepEqual(data.pendingRestart, []);
|
|
861
|
+
assert.equal(data.effective.personaEnabled, true, "effective still reports the override");
|
|
758
862
|
});
|
|
759
863
|
|
|
760
864
|
test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
|
|
@@ -792,6 +896,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
792
896
|
// NESTED_FLAG_PATHS——否则 configFlagValue 落到顶层同名键上,effective 静默缺键)。
|
|
793
897
|
"distillRecords.enabled": true,
|
|
794
898
|
"distillRecords.retentionDays": 30,
|
|
899
|
+
// #471:政策轴的两个点号键往返——白名单与 NESTED_FLAG_PATHS 成对缺一即静默缺键。
|
|
900
|
+
"scopeState.enabled": true,
|
|
901
|
+
"scopeState.enforce": true,
|
|
795
902
|
// #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返;
|
|
796
903
|
// 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。
|
|
797
904
|
sensitiveScanEnabled: true,
|
|
@@ -813,6 +920,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
813
920
|
"preInjectGate.enforce": true,
|
|
814
921
|
"distillRecords.enabled": true,
|
|
815
922
|
"distillRecords.retentionDays": 30,
|
|
923
|
+
"scopeState.enabled": true,
|
|
924
|
+
"scopeState.enforce": true,
|
|
816
925
|
sensitiveScanEnabled: true,
|
|
817
926
|
embedProvider: "local",
|
|
818
927
|
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
@@ -829,6 +938,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
829
938
|
assert.equal(data.effective["preInjectGate.enforce"], true);
|
|
830
939
|
assert.equal(data.effective["distillRecords.enabled"], true);
|
|
831
940
|
assert.equal(data.effective["distillRecords.retentionDays"], 30, "整数点号键也要读得回来");
|
|
941
|
+
// #471:政策轴两个点号键同款往返
|
|
942
|
+
assert.equal(data.effective["scopeState.enabled"], true);
|
|
943
|
+
assert.equal(data.effective["scopeState.enforce"], true);
|
|
832
944
|
assert.equal(data.effective.sensitiveScanEnabled, true);
|
|
833
945
|
assert.equal(data.effective.embedProvider, "local");
|
|
834
946
|
assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// `--flag value` 的值本身可以是 `--` 开头的字符串。真撞上的那次是 CI:
|
|
2
|
+
//
|
|
3
|
+
// not ok 846 - CLI:reclaim 默认 dry-run 报数字,--apply --vacuum 才真收
|
|
4
|
+
// ... bin/cli.mjs reclaim --url http://127.0.0.1:55423 --token <以 -- 开头的随机 token>
|
|
5
|
+
// 未配置访问 token,无法调用该命令
|
|
6
|
+
//
|
|
7
|
+
// token 由 `crypto.randomBytes(24).toString("base64url")` 生成(src/api-standalone.js),
|
|
8
|
+
// base64url 的字母表含 `-`,于是约 1/4096 的 token 头两位都是 `-` → 拼出来就是 `--…`
|
|
9
|
+
// → 旧 parseArgv 把它当新旗标、token 变 `true` → 退回环境变量/配置文件 → 全空。
|
|
10
|
+
// 也就是说这是一条**真实用户的随机性**,不是测试写法问题:一次 CI 红只是运气不好。
|
|
11
|
+
//
|
|
12
|
+
// 这个文件把解析语义钉死:值可以是任意 `--` 开头的串,而真旗标仍按旗标解析。
|
|
13
|
+
//
|
|
14
|
+
// 下面刻意不写整串字面量:那个 token 是一次性测试服务现生成的、早已随进程消失,
|
|
15
|
+
// 而一整串高熵字符留在源码里会被密钥扫描(gitleaks 的 generic-api-key 按熵报警)
|
|
16
|
+
// 当成真凭据。用拼接构造,形状一样、不触发扫描。
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { parseArgv, KNOWN_FLAGS, flagValue, flagBool } from "../bin/cli.mjs";
|
|
20
|
+
|
|
21
|
+
// 拼成 `--token` / `--` 开头的值,两个片段各自都是低熵短串。
|
|
22
|
+
const FLAG_TOKEN = "--tok" + "en";
|
|
23
|
+
const DASH_VALUE = "--" + "dash-prefixed-value";
|
|
24
|
+
|
|
25
|
+
test("CLI 解析:--token 的值以 -- 开头时仍取到值(CI 回归现场)", () => {
|
|
26
|
+
const { positionals, flags } = parseArgv([
|
|
27
|
+
"reclaim", "--url", "http://127.0.0.1:55423",
|
|
28
|
+
FLAG_TOKEN, DASH_VALUE
|
|
29
|
+
]);
|
|
30
|
+
assert.deepEqual(positionals, ["reclaim"]);
|
|
31
|
+
assert.equal(flags.token, DASH_VALUE);
|
|
32
|
+
// 这条才是当初真正失败的地方:值为 true 时 flagValue 视为「没给」。
|
|
33
|
+
assert.equal(flagValue(flags, "token"), DASH_VALUE);
|
|
34
|
+
// 旧实现会在这里多出一个键:把值当成旗标名(`dash-prefixed-value`)。
|
|
35
|
+
assert.deepEqual(Object.keys(flags).sort(), ["token", "url"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("CLI 解析:单横线开头的值同样按值处理", () => {
|
|
39
|
+
const { flags } = parseArgv(["add", "--title", "-x", "--content", "-"]);
|
|
40
|
+
assert.equal(flags.title, "-x");
|
|
41
|
+
assert.equal(flags.content, "-");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("CLI 解析:真旗标仍按旗标解析,不会被前一个旗标吞成值", () => {
|
|
45
|
+
// --limit 后面跟 --json:--json 是已注册旗标,所以 --limit 没有值(true),
|
|
46
|
+
// --json 是布尔。这是旧语义,必须保持,否则 `--limit --json` 会静默变成
|
|
47
|
+
// limit="--json"。
|
|
48
|
+
const { flags } = parseArgv(["list", "--limit", "--json"]);
|
|
49
|
+
assert.equal(flags.limit, true);
|
|
50
|
+
assert.equal(flagBool(flags, "json"), true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("CLI 解析:等号形式与 `--` 分隔符语义不变", () => {
|
|
54
|
+
assert.equal(parseArgv(["reclaim", "--token=--abc"]).flags.token, "--abc");
|
|
55
|
+
// `--` 之后全是位置参数,即使以 -- 开头。
|
|
56
|
+
assert.deepEqual(parseArgv(["search", "--", "--weird"]).positionals, ["search", "--weird"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("CLI 解析:未知的 -- 开头词当作上一个旗标的值", () => {
|
|
60
|
+
// 这是本修复的判定规则:只有 KNOWN_FLAGS 里登记过的词才算旗标。
|
|
61
|
+
const { flags } = parseArgv(["add", "--content", "--not-a-real-flag"]);
|
|
62
|
+
assert.equal(flags.content, "--not-a-real-flag");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("CLI KNOWN_FLAGS 覆盖 flagValue/flagBool 的全部实际用法", async () => {
|
|
66
|
+
// 漏登记的代价:该旗标会被当成前一个旗标的值吞掉,且没有任何报错。
|
|
67
|
+
// 所以用文本锁把 KNOWN_FLAGS 与 cli.mjs 里的调用点钉在一起——新增命令
|
|
68
|
+
// 忘登记会在这里炸,而不是等 1/4096 的用户撞上。
|
|
69
|
+
const { readFileSync } = await import("node:fs");
|
|
70
|
+
const { fileURLToPath } = await import("node:url");
|
|
71
|
+
const path = await import("node:path");
|
|
72
|
+
const cliPath = path.join(
|
|
73
|
+
path.dirname(path.dirname(fileURLToPath(import.meta.url))), "bin", "cli.mjs");
|
|
74
|
+
const src = readFileSync(cliPath, "utf8");
|
|
75
|
+
|
|
76
|
+
const used = new Set();
|
|
77
|
+
for (const m of src.matchAll(/flag(?:Value|Bool)\(\s*flags\s*,([^)]*)\)/g)) {
|
|
78
|
+
// 只认字符串字面量;`...names` 这种转发形式由调用方各自登记。
|
|
79
|
+
for (const s of m[1].matchAll(/'([^']+)'/g)) used.add(s[1].toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
assert.ok(used.size >= 15, "至少要解析出 CLI 全部取值/布尔旗标,否则这把锁失效了");
|
|
82
|
+
|
|
83
|
+
const missing = [...used].filter((k) => !KNOWN_FLAGS.has(k));
|
|
84
|
+
assert.deepEqual(missing, [],
|
|
85
|
+
"以下旗标在用但没登记进 KNOWN_FLAGS,会被前一个 --flag 吞成值:" + missing.join(", "));
|
|
86
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -1051,6 +1051,47 @@ test("settings card folds everything beyond the first screen behind All settings
|
|
|
1051
1051
|
"the default/changed badge must be decided from overrides, not a new backend field");
|
|
1052
1052
|
});
|
|
1053
1053
|
|
|
1054
|
+
// #481:面板开关「改了不生效」的体验坑。开关在宿主启动时合并进冻结 cfg,
|
|
1055
|
+
// 此后面板改 kv 只是持久化——用户看到「已改」就以为生效了,实测(人格总闸)
|
|
1056
|
+
// 因此白白排查了一轮。这条锁三件事:①第三态徽标由后端的 pendingRestart 名单
|
|
1057
|
+
// 决定(前端算不出来:overrides 只说明「改了」,不说明「用没用上」);
|
|
1058
|
+
// ②卡片顶部有整卡提示行;③中英文案与语义色齐备,且「待重启」不能靠
|
|
1059
|
+
// opacity 之类的弱化手段与「已改」区分(弱化在浅色主题下会掉到看不清)。
|
|
1060
|
+
test("feature switches expose a third badge state for saved-but-not-live overrides (#481)", () => {
|
|
1061
|
+
// ① 判定来源必须是后端字段,而不是前端自己比 overrides。
|
|
1062
|
+
assert.ok(/const pending = new Set\(\(state && state\.pendingRestart\) \|\| \[\]\)/.test(clientSource),
|
|
1063
|
+
"the pending set must come from the backend pendingRestart list");
|
|
1064
|
+
assert.ok(/pending\.has\(k\)[\s\S]{0,240}?mneme-badge--pending/.test(clientSource),
|
|
1065
|
+
"flagTag must return the pending badge before falling back to default/changed");
|
|
1066
|
+
// 后端字段缺失(旧宿主)时 pending 为空集,面板退回两态——不能因此崩掉。
|
|
1067
|
+
assert.ok(/\(state && state\.pendingRestart\) \|\| \[\]/.test(clientSource),
|
|
1068
|
+
"a missing pendingRestart must degrade to an empty set, not throw");
|
|
1069
|
+
|
|
1070
|
+
// ② 整卡提示行:只在有待重启项时出现,位置在说明之后、错误行之前。
|
|
1071
|
+
const noticeAt = clientSource.indexOf('t("memory.features.pendingRestartNotice")');
|
|
1072
|
+
const descAt = clientSource.indexOf('h("div", { className: "mneme-set-desc" }, t("memory.features.desc"))');
|
|
1073
|
+
assert.ok(noticeAt > descAt && descAt >= 0, "the notice row must sit after the card description");
|
|
1074
|
+
assert.ok(/pending\.size > 0 && h\("div", \{ className: "mneme-pending" \}/.test(clientSource),
|
|
1075
|
+
"the notice row must render only when something is actually pending");
|
|
1076
|
+
|
|
1077
|
+
// ③ 双语齐备(各出现两次:zh 表 + en 表)。
|
|
1078
|
+
for (const key of ["memory.features.tagPending", "memory.features.tagPending.hint",
|
|
1079
|
+
"memory.features.pendingRestartNotice"]) {
|
|
1080
|
+
const occurrences = clientSource.split("\"" + key + "\"").length - 1;
|
|
1081
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ④ 样式走语义色层(AGENTS.md 第 9 条),且不靠透明度弱化区分状态。
|
|
1085
|
+
assert.ok(/\.mneme-badge--pending\{[^}]*var\(--mneme-warn\)[^}]*\}/.test(clientSource),
|
|
1086
|
+
"the pending badge must use the --mneme-warn semantic colour");
|
|
1087
|
+
assert.ok(!/\.mneme-badge--pending\{[^}]*opacity/.test(clientSource),
|
|
1088
|
+
"the pending badge must not rely on opacity to distinguish itself from changed");
|
|
1089
|
+
assert.ok(/\.mneme-pending\{[^}]*var\(--mneme-warn\)[^}]*\}/.test(clientSource),
|
|
1090
|
+
"the notice row must use the --mneme-warn semantic colour");
|
|
1091
|
+
assert.ok(/\.mneme-pending-dot\{[^}]*background:currentColor/.test(clientSource),
|
|
1092
|
+
"the notice dot must inherit currentColor so the colour has one source");
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1054
1095
|
// v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
|
|
1055
1096
|
// 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
|
|
1056
1097
|
test("conflict queue ships a central review surface on the status tab", () => {
|
|
@@ -1665,8 +1706,8 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
1665
1706
|
"distillRecords.enabled must be an item of the core group, not merely referenced elsewhere"
|
|
1666
1707
|
);
|
|
1667
1708
|
assert.ok(
|
|
1668
|
-
/const FEATURE_NUMBERS = \["distillRecords\.retentionDays"\]/.test(clientSource),
|
|
1669
|
-
"retentionDays must be in the numeric key list"
|
|
1709
|
+
/const FEATURE_NUMBERS = \["distillRecords\.retentionDays", "dreamMaxTokens", "sleepMaxTokens"\]/.test(clientSource),
|
|
1710
|
+
"retentionDays must be in the numeric key list (dreamMaxTokens/sleepMaxTokens joined by #478)"
|
|
1670
1711
|
);
|
|
1671
1712
|
assert.ok(
|
|
1672
1713
|
/for \(const key of FEATURE_NUMBERS\) numDraft\[key\] = String\(e\[key\] \?\? ""\);/.test(clientSource)
|
|
@@ -1694,7 +1735,12 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
1694
1735
|
"memory.features.distillRecords.enabled",
|
|
1695
1736
|
"memory.features.distillRecords.enabled.hint",
|
|
1696
1737
|
"memory.features.distillRecords.retentionDays",
|
|
1697
|
-
"memory.features.distillRecords.retentionDays.hint"
|
|
1738
|
+
"memory.features.distillRecords.retentionDays.hint",
|
|
1739
|
+
// #478:面板新暴露的两个 max_tokens 预算键,label 与 hint 缺一即哑行
|
|
1740
|
+
"memory.features.dreamMaxTokens",
|
|
1741
|
+
"memory.features.dreamMaxTokens.hint",
|
|
1742
|
+
"memory.features.sleepMaxTokens",
|
|
1743
|
+
"memory.features.sleepMaxTokens.hint"
|
|
1698
1744
|
]) {
|
|
1699
1745
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1700
1746
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
@@ -2054,6 +2100,21 @@ test("persona card is multi-card with visible field labels and save-failure erro
|
|
|
2054
2100
|
assert.ok(labelBlock, "persona field labels must be visible (featnumlabel class), not aria-only");
|
|
2055
2101
|
// 保存失败提示:personaError 状态有渲染路径(非仅 setState)
|
|
2056
2102
|
assert.ok(clientSource.includes("t(\"memory.settings.personaSaveFailed\")"), "save failure must render an error line");
|
|
2103
|
+
// 卡删除 × 常显回归锁 v2(#457 内联覆写被 #459 回滚的教训):× 不得复用
|
|
2104
|
+
// mneme-set-del(opacity:0 仅在 .mneme-set-row:hover 解除,卡标签不在该容器
|
|
2105
|
+
// → 永远不可见),必须用独立类 mneme-persona-del 且该类常显(opacity:1)。
|
|
2106
|
+
assert.ok(
|
|
2107
|
+
/className: "mneme-persona-del"/.test(clientSource),
|
|
2108
|
+
"persona card delete × must use the dedicated always-visible class"
|
|
2109
|
+
);
|
|
2110
|
+
const delStyle = clientSource.match(/\.mneme-persona-del\{[^}]*\}/);
|
|
2111
|
+
assert.ok(delStyle, "mneme-persona-del style rule must exist");
|
|
2112
|
+
assert.ok(!/opacity:0/.test(delStyle[0]), "mneme-persona-del must not be transparent");
|
|
2113
|
+
// 不许再把 mneme-set-del 挂回卡删除按钮(防 #457 式修法回归);直接锁按钮
|
|
2114
|
+
// 的 className 值(onClick 前最近的 className 属性,注释文字不参与)。
|
|
2115
|
+
const delBtn = clientSource.match(/className: "([^"]+)"[\s\S]{0,300}?onClick: \(\) => removePersonaCard\(id\)/);
|
|
2116
|
+
assert.ok(delBtn, "card delete button wiring must exist");
|
|
2117
|
+
assert.strictEqual(delBtn[1], "mneme-persona-del", "card delete × must use mneme-persona-del, not mneme-set-del");
|
|
2057
2118
|
});
|
|
2058
2119
|
|
|
2059
2120
|
// 回归锁(复验实测踩中):面板「检测更新」的状态名必须与后端 classify
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
|
|
6
|
+
// ============================================================ distill-tx-embed-gap
|
|
7
|
+
// 回归锁:此前 scheduleEmbed 在 txDepth>0 时首行直接 return(注释写 deferred,
|
|
8
|
+
// 实际 transaction() 从不补跑)——与 #372 的实体抽取是同一个 bug 模板,只是受害者
|
|
9
|
+
// 换成了嵌入:自动蒸馏(summarize 全部走 service.transaction)写入的 session:* 行
|
|
10
|
+
// 100% 拿不到向量,语义检索对这批行永远失明。照 #372 的修复模板改为入队、提交后
|
|
11
|
+
// 补跑、回滚截断。五把锁与 test/entities.test.js 的 #372 锁一一对应,锁的是
|
|
12
|
+
// 「commit 才补跑、回滚绝不补跑」这个契约本身——任何一侧改回丢弃或回滚也补跑,
|
|
13
|
+
// 这里当场红。
|
|
14
|
+
|
|
15
|
+
function makeService(config = {}) {
|
|
16
|
+
const store = createStore(":memory:");
|
|
17
|
+
const service = createService({ store, mirror: null, config });
|
|
18
|
+
return { store, service };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 8 维定值向量;schedule 分支同步写回(语义同真实 embedder:调度即含写回),
|
|
22
|
+
// 让整条链路确定性、无异步漂移。embedSingle 仅供 ready-gate/embedSingle 分支
|
|
23
|
+
// 的调用方使用,本文件的锁都走 schedule 分支。
|
|
24
|
+
function makeEmbedder(store, embedded) {
|
|
25
|
+
return {
|
|
26
|
+
schedule(memory) {
|
|
27
|
+
embedded.push(memory.id);
|
|
28
|
+
store.setEmbedding(memory.id, new Array(8).fill(0.5), "fake-model-hash");
|
|
29
|
+
},
|
|
30
|
+
embedSingle: async () => new Array(8).fill(0.5),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("distill-tx: embedding queued inside transaction is written exactly once after commit", () => {
|
|
35
|
+
const { store, service } = makeService();
|
|
36
|
+
const embedded = [];
|
|
37
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
38
|
+
const r = service.transaction(() =>
|
|
39
|
+
service.saveWithDedupe({ type: "preference", title: "事务内创建", content: "x" })
|
|
40
|
+
);
|
|
41
|
+
const id = r.memory.id;
|
|
42
|
+
// 提交后补跑恰一次:向量非空、维度等于 embedder 输出(8 维)
|
|
43
|
+
assert.deepEqual(embedded, [id], "drain fires exactly once, after commit");
|
|
44
|
+
const row = store.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
|
|
45
|
+
const vec = JSON.parse(row.embedding);
|
|
46
|
+
assert.equal(vec.length, 8, "vector dimension equals embedder output");
|
|
47
|
+
store.close();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("distill-tx: nothing schedules while txDepth > 0 (queued, not dropped, not immediate)", () => {
|
|
51
|
+
const { store, service } = makeService();
|
|
52
|
+
const embedded = [];
|
|
53
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
54
|
+
service.transaction(() => {
|
|
55
|
+
service.saveWithDedupe({ type: "preference", title: "事务内观察", content: "x" });
|
|
56
|
+
// 事务内此刻不得触发(排进队列而不是丢弃,也不是立即跑)
|
|
57
|
+
assert.equal(embedded.length, 0, "no embedding while txDepth > 0");
|
|
58
|
+
});
|
|
59
|
+
assert.equal(embedded.length, 1, "queued entry drains after commit");
|
|
60
|
+
store.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("distill-tx: rolled-back transaction never embeds rolled-back rows", () => {
|
|
64
|
+
const { store, service } = makeService();
|
|
65
|
+
const embedded = [];
|
|
66
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
67
|
+
assert.throws(() => {
|
|
68
|
+
service.transaction(() => {
|
|
69
|
+
service.saveWithDedupe({ type: "preference", title: "会被回滚", content: "x" });
|
|
70
|
+
throw new Error("boom");
|
|
71
|
+
});
|
|
72
|
+
}, /boom/);
|
|
73
|
+
assert.equal(embedded.length, 0, "no embedding against rolled-back rows");
|
|
74
|
+
assert.equal(store.count(), 0, "row itself rolled back");
|
|
75
|
+
store.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 与 #372 复审补锁同构:内层回滚截断只丢内层条目,外层提交 drain 外层条目。
|
|
79
|
+
test("distill-tx: inner rollback drops only inner entries; outer commit drains the rest", () => {
|
|
80
|
+
const { store, service } = makeService();
|
|
81
|
+
const embedded = [];
|
|
82
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
83
|
+
const ids = [];
|
|
84
|
+
service.transaction(() => {
|
|
85
|
+
ids.push(service.saveWithDedupe({ type: "preference", title: "外层A", content: "x" }).memory.id);
|
|
86
|
+
try {
|
|
87
|
+
service.transaction(() => {
|
|
88
|
+
service.saveWithDedupe({ type: "preference", title: "内层回滚", content: "x" });
|
|
89
|
+
throw new Error("inner boom");
|
|
90
|
+
});
|
|
91
|
+
} catch { /* 内层回滚,外层继续 */ }
|
|
92
|
+
ids.push(service.saveWithDedupe({ type: "preference", title: "外层B", content: "x" }).memory.id);
|
|
93
|
+
});
|
|
94
|
+
assert.deepEqual(embedded.sort(), ids.sort(), "inner rolled-back row never embedded; outer rows drain once");
|
|
95
|
+
assert.equal(store.count(), 2);
|
|
96
|
+
store.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("distill-tx: no unbounded cap — large transaction drains every entry", () => {
|
|
100
|
+
const { store, service } = makeService();
|
|
101
|
+
const embedded = [];
|
|
102
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
103
|
+
service.transaction(() => {
|
|
104
|
+
for (let i = 0; i < 150; i++) {
|
|
105
|
+
service.saveWithDedupe({ type: "preference", title: `批量${i}`, content: "x" });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
assert.equal(embedded.length, 150, "no silent drop at any queue cap");
|
|
109
|
+
store.close();
|
|
110
|
+
});
|