@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.
Files changed (106) hide show
  1. package/README.md +82 -19
  2. package/bin/cli.mjs +69 -9
  3. package/docs/CONFIGURATION.md +48 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +339 -59
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +669 -256
  9. package/lib/config.js +50 -2
  10. package/lib/dream/sleep.js +35 -12
  11. package/lib/dream.js +86 -14
  12. package/lib/embedding.js +153 -22
  13. package/lib/index.js +32 -0
  14. package/lib/inject.js +34 -3
  15. package/lib/lang.js +124 -0
  16. package/lib/pipeline-activities.js +178 -0
  17. package/lib/pipeline-attribution.js +165 -0
  18. package/lib/pipeline-command.js +279 -0
  19. package/lib/pipeline-confirm.js +142 -0
  20. package/lib/pipeline-exec.js +174 -0
  21. package/lib/pipeline-nudge.js +1281 -0
  22. package/lib/pipeline-observations.js +181 -0
  23. package/lib/pipeline-observe.js +133 -0
  24. package/lib/pipeline-runner.js +346 -0
  25. package/lib/pipeline-runs.js +442 -0
  26. package/lib/pipeline.js +128 -7
  27. package/lib/scope-state.js +277 -0
  28. package/lib/semantic.js +66 -10
  29. package/lib/service.js +80 -3
  30. package/lib/settings.js +139 -1
  31. package/lib/store.js +216 -3
  32. package/lib/summarize.js +2 -3
  33. package/lib/tools.js +71 -0
  34. package/package.json +3 -3
  35. package/scripts/changelog-prep.mjs +62 -7
  36. package/scripts/check-sync.js +8 -6
  37. package/scripts/release-docs.mjs +204 -0
  38. package/scripts/release-notes.mjs +162 -0
  39. package/src/api-standalone.js +78 -12
  40. package/src/api.js +339 -59
  41. package/src/candidates.js +454 -454
  42. package/src/config.js +50 -2
  43. package/src/dream/sleep.js +35 -12
  44. package/src/dream.js +86 -14
  45. package/src/embedding.js +153 -22
  46. package/src/index.js +32 -0
  47. package/src/inject.js +34 -3
  48. package/src/lang.js +124 -0
  49. package/src/pipeline-activities.js +178 -0
  50. package/src/pipeline-attribution.js +165 -0
  51. package/src/pipeline-command.js +279 -0
  52. package/src/pipeline-confirm.js +142 -0
  53. package/src/pipeline-exec.js +174 -0
  54. package/src/pipeline-nudge.js +1281 -0
  55. package/src/pipeline-observations.js +181 -0
  56. package/src/pipeline-observe.js +133 -0
  57. package/src/pipeline-runner.js +346 -0
  58. package/src/pipeline-runs.js +442 -0
  59. package/src/pipeline.js +128 -7
  60. package/src/scope-state.js +277 -0
  61. package/src/semantic.js +66 -10
  62. package/src/service.js +80 -3
  63. package/src/settings.js +139 -1
  64. package/src/store.js +216 -3
  65. package/src/summarize.js +2 -3
  66. package/src/tools.js +71 -0
  67. package/test/api-hardening.test.js +173 -0
  68. package/test/api.test.js +210 -1
  69. package/test/changelog-prep.test.js +74 -8
  70. package/test/cli-enums.test.js +37 -0
  71. package/test/cli-flag-value.test.js +86 -0
  72. package/test/client.test.js +216 -6
  73. package/test/distill-tx-embed-gap.test.js +110 -0
  74. package/test/docs-config-keys.test.js +65 -0
  75. package/test/dream-maxtokens-fallback.test.js +132 -0
  76. package/test/embed-text-reasons.test.js +112 -0
  77. package/test/helpers/write-admission-samples.js +6 -2
  78. package/test/lib-smoke.test.js +20 -0
  79. package/test/persona-api.test.js +172 -0
  80. package/test/persona-flag.test.js +36 -0
  81. package/test/persona-inject.test.js +145 -0
  82. package/test/persona-settings.test.js +145 -0
  83. package/test/pipeline-activities.test.js +155 -0
  84. package/test/pipeline-attribution.test.js +175 -0
  85. package/test/pipeline-command.test.js +243 -0
  86. package/test/pipeline-confirm.test.js +148 -0
  87. package/test/pipeline-exec.test.js +172 -0
  88. package/test/pipeline-nudge.test.js +1036 -0
  89. package/test/pipeline-observations.test.js +157 -0
  90. package/test/pipeline-observe.test.js +127 -0
  91. package/test/pipeline-r1r2.test.js +149 -0
  92. package/test/pipeline-runner.test.js +442 -0
  93. package/test/pipeline-runs.test.js +767 -0
  94. package/test/pipeline.test.js +137 -6
  95. package/test/readme-claims.test.js +68 -0
  96. package/test/reindex-backfill.test.js +83 -4
  97. package/test/release-docs.test.js +130 -0
  98. package/test/release-notes.test.js +240 -0
  99. package/test/release-workflow.test.js +16 -0
  100. package/test/route-policy.test.js +113 -0
  101. package/test/scope-state.test.js +481 -0
  102. package/test/security-doc.test.js +139 -0
  103. package/test/sensitive-scan.test.js +44 -0
  104. package/test/tools-dto-schema-matrix.test.js +12 -3
  105. package/test/tools.test.js +6 -6
  106. package/test/workflow-guards.test.js +156 -0
@@ -0,0 +1,112 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { embedText, embedTextDetailed, createEmbedder } from "../src/embedding.js";
4
+
5
+ // distill-tx-embed-gap 的"坏了为什么看不出来"半边:embedText 此前把「配置缺」
6
+ // 「网络挂」「超时」「HTTP 4xx/5xx」「响应不是 JSON」「响应没有向量」全部压成
7
+ // null,回执账本只能统一写 embedding-request-returned-no-vector——分诊不可能。
8
+ // embedTextDetailed 把失败模式拆开;embedText 保留旧 null 契约(调用方不变)。
9
+
10
+ const FULL = {
11
+ enabled: true,
12
+ baseUrl: "https://api.example.com/v1",
13
+ apiKey: "sk-test",
14
+ model: "text-embedding-3-small"
15
+ };
16
+
17
+ async function withFetch(impl, fn) {
18
+ const original = globalThis.fetch;
19
+ globalThis.fetch = impl;
20
+ try {
21
+ return await fn();
22
+ } finally {
23
+ globalThis.fetch = original;
24
+ }
25
+ }
26
+
27
+ test("detailed: 成功时 reason 为 null,vector 与旧契约一致", async () => {
28
+ await withFetch(async () => ({ ok: true, json: async () => ({ data: [{ embedding: [1, 2, 3] }] }) }), async () => {
29
+ const out = await embedTextDetailed(FULL, "猫咪");
30
+ assert.deepEqual(out.vector, [1, 2, 3]);
31
+ assert.equal(out.reason, null);
32
+ // 旧包装的 null 契约不变
33
+ assert.deepEqual(await embedText(FULL, "猫咪"), [1, 2, 3]);
34
+ });
35
+ });
36
+
37
+ test("detailed: 配置缺失时逐字段点名,不伪装成网络故障", async () => {
38
+ assert.match((await embedTextDetailed({ ...FULL, model: "" }, "x")).reason, /^config-incomplete:model$/);
39
+ assert.match((await embedTextDetailed({ ...FULL, apiKey: "" }, "x")).reason, /^config-incomplete:apiKey$/);
40
+ assert.match((await embedTextDetailed({ ...FULL, baseUrl: "" }, "x")).reason, /^config-incomplete:baseUrl$/);
41
+ assert.match(await embedTextDetailed(FULL, "").then((o) => o.reason), /config-incomplete:text/);
42
+ });
43
+
44
+ test("detailed: HTTP 状态码进 reason(401 钥匙 / 404 路径 / 429 限流)", async () => {
45
+ await withFetch(async () => ({ ok: false, status: 404, text: async () => "Not Found" }), async () => {
46
+ const out = await embedTextDetailed(FULL, "x");
47
+ assert.equal(out.vector, null);
48
+ assert.equal(out.reason, "http-404:Not Found");
49
+ });
50
+ await withFetch(async () => ({ ok: false, status: 429, text: async () => "" }), async () => {
51
+ const out = await embedTextDetailed(FULL, "x");
52
+ assert.equal(out.reason, "http-429", "空响应体不拼尾巴");
53
+ });
54
+ // 旧包装在这些场景全部 null,不抛
55
+ await withFetch(async () => ({ ok: false, status: 401, text: async () => "denied" }), async () => {
56
+ assert.equal(await embedText(FULL, "x"), null);
57
+ });
58
+ });
59
+
60
+ test("detailed: 超时与网络错误分型,不混为一谈", async () => {
61
+ await withFetch(async () => {
62
+ const err = new Error("socket hang up");
63
+ err.name = "TypeError";
64
+ throw err;
65
+ }, async () => {
66
+ const out = await embedTextDetailed(FULL, "x");
67
+ assert.match(out.reason, /^request-failed:network:socket hang up/);
68
+ });
69
+ await withFetch(async () => {
70
+ const err = new Error("The operation was aborted");
71
+ err.name = "TimeoutError";
72
+ throw err;
73
+ }, async () => {
74
+ const out = await embedTextDetailed(FULL, "x");
75
+ assert.match(out.reason, /^request-failed:timeout:/, "慢和不通是两种病");
76
+ });
77
+ });
78
+
79
+ test("detailed: 响应不是 JSON 与响应没有向量各自有名", async () => {
80
+ await withFetch(async () => ({ ok: true, json: async () => { throw new Error("bad"); } }), async () => {
81
+ const out = await embedTextDetailed(FULL, "x");
82
+ assert.equal(out.reason, "response-not-json");
83
+ });
84
+ await withFetch(async () => ({ ok: true, json: async () => ({ data: [] }) }), async () => {
85
+ const out = await embedTextDetailed(FULL, "x");
86
+ assert.equal(out.reason, "response-no-vector");
87
+ });
88
+ });
89
+
90
+ test("embedFor 回执账本带上可区分 reason(不再是统一的 no-vector)", async () => {
91
+ const receipts = [];
92
+ const embedder = createEmbedder({
93
+ store: { setEmbedding() {} },
94
+ settings: { getVectorConfig: () => FULL },
95
+ logger: { info() {}, warn() {} }
96
+ });
97
+ // embedReceipts 在账本上;rejection 已在 schedule() 内消化,这里直接等 schedule promise。
98
+ await withFetch(async () => ({ ok: false, status: 404, text: async () => "no route" }), async () => {
99
+ const r = await embedder.schedule({ id: "m1", title: "t", content: "c" });
100
+ assert.equal(r.status, "failed");
101
+ assert.equal(r.reason, "http-404:no route");
102
+ });
103
+ await withFetch(async () => ({ ok: true, json: async () => ({ data: [{ embedding: [1, 2, 3] }] }) }), async () => {
104
+ const r = await embedder.schedule({ id: "m2", title: "t", content: "c" });
105
+ assert.equal(r.status, "ok");
106
+ assert.equal(r.dim, 3);
107
+ });
108
+ const snap = embedder.embedReceipts();
109
+ assert.equal(snap.totals.failed, 1);
110
+ assert.equal(snap.totals.ok, 1);
111
+ assert.equal(snap.lastFailReason, "http-404:no route", "账本记的是分诊后的原因");
112
+ });
@@ -47,7 +47,11 @@ export const SAMPLE_VALUES = {
47
47
  ".eyJzdWIiOiIxMjM0NTY3ODkwIn0",
48
48
  ".dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
49
49
  ),
50
- stripeKey: assemble("sk_", "live_", "51H8Qw2KZvN3mPqRsTuVwXyZ0123456789abcdef")
50
+ stripeKey: assemble("sk_", "live_", "51H8Qw2KZvN3mPqRsTuVwXyZ0123456789abcdef"),
51
+ // 这条曾以整串字面量留在下面的 content 里,被 GitHub secret scanning 报成 openai_api_key
52
+ // (alert #1)。它是形状样本不是真凭据(正文就是 a-z、0-9 顺序串),但扫描器只看形状,
53
+ // 所以按上面第 32-34 行的口径统一走拼接,别让它再当例外。
54
+ openaiKey: assemble("sk-proj-", "abcdefghijklmnopqrstuvwxyz0123456789ABCD")
51
55
  };
52
56
 
53
57
  /** 密钥凭据类正样本。kind 与 label 是判据该报回来的东西。 */
@@ -73,7 +77,7 @@ export const SECRET_SAMPLES = [
73
77
  kind: "openai_key",
74
78
  label: "OpenAI API key",
75
79
  title: "调试记录",
76
- content: "临时用 sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD 调了一次",
80
+ content: `临时用 ${SAMPLE_VALUES.openaiKey} 调了一次`,
77
81
  why: "sk-/sk-proj- 前缀 + 长串"
78
82
  },
79
83
  {
@@ -40,6 +40,26 @@ test("src/ and lib/ are byte-identical (no publish drift)", () => {
40
40
  assert.deepEqual(drift, [], "src↔lib must stay in sync (npm run sync)");
41
41
  });
42
42
 
43
+ test("没有文件带双 CR(行尾必须是 LF 或 CRLF,不是 CRCRLF)", () => {
44
+ // 这条锁的来历:`src/candidates.js` 从诞生起每个换行都是 `<CR><CR><LF>`,
45
+ // 一路进了 main、又随 v0.9.0 发到 npm,而**当时没有任何检查会红**——
46
+ // 因为 `\r` 在 JS 里是合法空白,解析与测试全绿。代价落在别处:
47
+ // · 任何改动这个文件的人,diff 都是整文件 908/908(噪声掩盖真改动);
48
+ // · 同一份文件 `Get-Content` 数出 908 行、编辑器/工具数出 454 行,两边对不上;
49
+ // · 发布产物里带着畸形行尾。
50
+ // 判据是**扫描字节**而不是依赖某个工具的换行解释——正因工具口径不一致,
51
+ // 才必须查 `<CR><CR><LF>` 这个具体序列本身。
52
+ const bad = [];
53
+ for (const dir of [srcDir, libDir]) {
54
+ for (const file of walk(dir)) {
55
+ if (!file.endsWith(".js")) continue;
56
+ const doubleCr = readFileSync(file, "utf8").match(/\r\r\n/g);
57
+ if (doubleCr) bad.push(`${relative(root, file)}: ${doubleCr.length} 处`);
58
+ }
59
+ }
60
+ assert.deepEqual(bad, [], "行尾出现 <CR><CR><LF>——多出来的 CR 会让 diff 与行数口径一起坏掉");
61
+ });
62
+
43
63
  // ---------------------------------------------------------------------------
44
64
  // lib 运行时冒烟:直接加载 npm 实际分发的产物
45
65
  // ---------------------------------------------------------------------------
@@ -0,0 +1,172 @@
1
+ // Persona API routes: internal (/api/dsh-mneme/persona, requireAuth on write)
2
+ // and standalone (/persona behind the global Bearer gate).
3
+ // Locks: GET reads current value; unauthorized writes are rejected; a corrupt
4
+ // JSON body never lands in the store (design: reports/persona-page-design.md ②).
5
+ // Call shapes mirror test/api.test.js (route registry + FakeRes) and
6
+ // test/standalone-api.test.js (real server on port 0 + fetch).
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { EventEmitter } from "node:events";
10
+ import { createStore } from "../src/store.js";
11
+ import { createService } from "../src/service.js";
12
+ import { createApi } from "../src/api.js";
13
+ import { createSettings } from "../src/settings.js";
14
+ import { createStandaloneApi } from "../src/api-standalone.js";
15
+
16
+ class FakeRes extends EventEmitter {
17
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
18
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
19
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
20
+ }
21
+
22
+ function req(path, method = "GET", body = null, headers = {}) {
23
+ const r = new EventEmitter();
24
+ r.url = path;
25
+ r.method = method;
26
+ r.headers = headers;
27
+ if (body !== null) {
28
+ process.nextTick(() => {
29
+ r.emit("data", Buffer.from(typeof body === "string" ? body : JSON.stringify(body)));
30
+ r.emit("end");
31
+ });
32
+ }
33
+ return r;
34
+ }
35
+
36
+ function setupInternal(apiToken = "") {
37
+ const store = createStore(":memory:");
38
+ const service = createService({ store, mirror: null, config: {} });
39
+ const settings = createSettings(store.db);
40
+ const commands = {
41
+ add: (def) => settings.addCommand(def),
42
+ remove: (id) => settings.removeCommand(id),
43
+ list: () => settings.listCommands()
44
+ };
45
+ const routes = [];
46
+ const ctx = {
47
+ webServer: {
48
+ register(route) {
49
+ routes.push(route);
50
+ return () => {};
51
+ }
52
+ }
53
+ };
54
+ createApi(ctx, service, settings, commands, undefined, undefined, apiToken, {});
55
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/persona");
56
+ return { store, settings, route, apiToken };
57
+ }
58
+
59
+ // ---- internal API(宿主内 /api/dsh-mneme/*) --------------------------------
60
+
61
+ test("internal GET /persona returns current persona without auth", async () => {
62
+ const { store, settings, route } = setupInternal();
63
+ settings.setPersona({ name: "Amarre", enabled: false });
64
+ const res = new FakeRes();
65
+ await route.handler(req("/api/dsh-mneme/persona"), res);
66
+ assert.equal(res.statusCode, 200);
67
+ assert.equal(JSON.parse(res.body).persona.default.name, "Amarre");
68
+ store.close();
69
+ });
70
+
71
+ test("internal PUT /persona without token is rejected and store untouched", async () => {
72
+ // setupInternal("t"):必须带 token 启动,无 token 的写请求才应被拒
73
+ // (apiToken 为空时 isAuthorized 恒放行——与 /profile 的既有语义一致)。
74
+ const { store, settings, route } = setupInternal("t");
75
+ const res = new FakeRes();
76
+ await route.handler(req("/api/dsh-mneme/persona", "PUT", { persona: { name: "evil" } }), res);
77
+ assert.equal(res.statusCode, 401);
78
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
79
+ store.close();
80
+ });
81
+
82
+ test("internal PUT /persona with token round-trips; corrupt JSON body does not land", async () => {
83
+ const { store, settings, route } = setupInternal("t");
84
+ const headers = { authorization: "Bearer t" };
85
+ const ok = new FakeRes();
86
+ await route.handler(req("/api/dsh-mneme/persona", "PUT", { persona: { name: "Amarre", enabled: true } }, headers), ok);
87
+ assert.equal(ok.statusCode, 200);
88
+ assert.equal(settings.getPersona().default.name, "Amarre");
89
+ const bad = new FakeRes();
90
+ await route.handler(req("/api/dsh-mneme/persona", "PUT", "not json", headers), bad);
91
+ // 路由层对坏 JSON 容错(parseBody 口径:坏体不清空库内值),原样回显现值
92
+ assert.equal(bad.statusCode, 200);
93
+ assert.equal(settings.getPersona().default.name, "Amarre");
94
+ store.close();
95
+ });
96
+
97
+ test("internal PUT /persona multi-card form replaces fully; legacy form merges default", async () => {
98
+ const { store, settings, route } = setupInternal("t");
99
+ const headers = { authorization: "Bearer t" };
100
+ // 先铺一张 byAgent 卡
101
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: true });
102
+ // 旧形 PUT(单对象):只动 default、byAgent 保留
103
+ const legacy = new FakeRes();
104
+ await route.handler(req("/api/dsh-mneme/persona", "PUT", { persona: { name: "Amarre-改", enabled: true } }, headers), legacy);
105
+ assert.equal(legacy.statusCode, 200);
106
+ assert.equal(settings.getPersona().default.name, "Amarre-改");
107
+ assert.equal(settings.getPersona().byAgent.zephyr.name, "泽菲尔", "legacy PUT keeps byAgent");
108
+ // 新形 PUT(多卡):全量替换
109
+ const multi = new FakeRes();
110
+ await route.handler(req("/api/dsh-mneme/persona", "PUT", { persona: { default: { name: "默认卡", enabled: true }, byAgent: { zephyr: { name: "泽菲尔-新", enabled: true } } } }, headers), multi);
111
+ assert.equal(multi.statusCode, 200);
112
+ const got = settings.getPersona();
113
+ assert.equal(got.default.name, "默认卡");
114
+ assert.equal(got.byAgent.zephyr.name, "泽菲尔-新");
115
+ store.close();
116
+ });
117
+
118
+ // ---- standalone API(Bearer 数据面,真端口 + fetch) ------------------------
119
+
120
+ async function setupStandalone() {
121
+ const store = createStore(":memory:");
122
+ const service = createService({ store, mirror: null, config: {} });
123
+ const settings = createSettings(store.db);
124
+ const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
125
+ await api.ready;
126
+ const base = `http://127.0.0.1:${api.port}`;
127
+ const auth = { authorization: `Bearer ${api.token}` };
128
+ return {
129
+ store, settings, base, auth,
130
+ close: () => {
131
+ api.server.closeIdleConnections?.();
132
+ api.server.close();
133
+ store.close();
134
+ }
135
+ };
136
+ }
137
+
138
+ test("standalone GET/PUT /persona behind Bearer gate round-trips", async () => {
139
+ const t = await setupStandalone();
140
+ try {
141
+ const get1 = await fetch(`${t.base}/persona`, { headers: t.auth });
142
+ assert.equal(get1.status, 200);
143
+ assert.deepEqual((await get1.json()).persona, { default: {}, byAgent: {} });
144
+ const put = await fetch(`${t.base}/persona`, {
145
+ method: "PUT",
146
+ headers: { ...t.auth, "content-type": "application/json" },
147
+ body: JSON.stringify({ persona: { name: "Zelphi", enabled: false } })
148
+ });
149
+ assert.equal(put.status, 200);
150
+ assert.equal((await put.json()).persona.default.name, "Zelphi");
151
+ const bad = await fetch(`${t.base}/persona`, {
152
+ method: "PUT",
153
+ headers: { ...t.auth, "content-type": "application/json" },
154
+ body: "not json"
155
+ });
156
+ assert.equal(bad.status, 400);
157
+ assert.equal(t.settings.getPersona().default.name, "Zelphi");
158
+ } finally {
159
+ t.close();
160
+ }
161
+ });
162
+
163
+ test("standalone /persona without Bearer is 401", async () => {
164
+ const t = await setupStandalone();
165
+ try {
166
+ const res = await fetch(`${t.base}/persona`, { method: "PUT", body: "{}" });
167
+ assert.equal(res.status, 401);
168
+ assert.deepEqual(t.settings.getPersona(), { default: {}, byAgent: {} });
169
+ } finally {
170
+ t.close();
171
+ }
172
+ });
@@ -0,0 +1,36 @@
1
+ // personaEnabled flag wiring (#254-style): schema key + whitelist pair must
2
+ // surface in /features effective with default false — a missing key would make
3
+ // the panel toggle silently ineffective (client reads effective to register).
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { Config } from "../src/config.js";
7
+ import { createSettings, FEATURE_FLAG_SPEC } from "../src/settings.js";
8
+ import { createStore } from "../src/store.js";
9
+
10
+ test("config schema has personaEnabled defaulting to false", () => {
11
+ const cfg = Config({});
12
+ assert.equal(cfg.personaEnabled, false);
13
+ // 顶层扁平布尔:显式 true 穿透
14
+ const cfgOn = Config({ personaEnabled: true });
15
+ assert.equal(cfgOn.personaEnabled, true);
16
+ });
17
+
18
+ test("personaEnabled is in the boolean whitelist and spec export", () => {
19
+ assert.ok(FEATURE_FLAG_SPEC.booleans.includes("personaEnabled"));
20
+ // 默认关:不在 lightMode 排除名单(轻量纯文本注入物,用户显式勾选赢——
21
+ // 定性同 actionPipelineEnabled;若未来要进名单,此处会失败并提示改设计稿④)
22
+ const cfg = Config({});
23
+ const light = cfg.lightMode === true ? cfg : cfg;
24
+ void light; // lightMode 定性由 api.test.js 计数锁与 config.js 注释共同钉住
25
+ });
26
+
27
+ test("effective snapshot includes personaEnabled (false default) via featureSnapshot path", () => {
28
+ const store = createStore(":memory:");
29
+ const settings = createSettings(store.db);
30
+ // settings.setFeatureFlags 逐键校验合并写:白名单键可写(w1 已锁 TypeError 面)
31
+ settings.setFeatureFlags({ personaEnabled: true });
32
+ assert.equal(settings.getFeatureFlags().personaEnabled, true);
33
+ // 清掉覆盖后 effective 应回落到 config 默认 false——由 api.test.js 的
34
+ // 计数锁 + personaEnabled 默认值断言在 /features 路径上完整验证(w1 已同步 +1)。
35
+ store.close();
36
+ });
@@ -0,0 +1,145 @@
1
+ // Persona injection via renderUserSettings (user-settings context, order 85).
2
+ // Locks: with either gate off, output is byte-identical to the legacy rendering
3
+ // (old users see zero change); with both gates on, persona lines lead the block
4
+ // (design ⑥: persona → profile → rules, rules stay hardest) and go through
5
+ // escapePromptVars.
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { createStore } from "../src/store.js";
9
+ import { createService } from "../src/service.js";
10
+ import { createInjector } from "../src/inject.js";
11
+ import { createSettings } from "../src/settings.js";
12
+
13
+ function setup(over = {}) {
14
+ const store = createStore(":memory:");
15
+ const service = createService({ store, mirror: null, config: {} });
16
+ const settings = createSettings(store.db);
17
+ const contexts = [];
18
+ const ctx = {
19
+ systemPrompt: {
20
+ context(def) {
21
+ contexts.push(def);
22
+ return () => {};
23
+ }
24
+ }
25
+ };
26
+ const config = { maxInjectedItems: 3, importanceThreshold: 3, ...over };
27
+ createInjector(ctx, service, settings, config);
28
+ const settingsCtx = contexts.find((c) => c.name === "user-settings");
29
+ return { store, settings, settingsCtx };
30
+ }
31
+
32
+ test("legacy output unchanged when config gate off (persona stored but ignored)", () => {
33
+ const { store, settings, settingsCtx } = setup({ personaEnabled: false });
34
+ settings.setProfile("用户是规划背景");
35
+ settings.setRules(["不提及用户名"]);
36
+ settings.setPersona({ name: "Amarre", personality: "轻松幽默", enabled: true });
37
+ const text = settingsCtx.text({});
38
+ // 逐字节等于历史渲染:无人格行、无人格名
39
+ assert.equal(text, escapeless([
40
+ "[用户设置] 来自 dsh-mneme 的用户画像与规则:",
41
+ "- 用户画像:用户是规划背景",
42
+ "- 规则:不提及用户名"
43
+ ].join("\n")));
44
+ store.close();
45
+ });
46
+
47
+ test("legacy output unchanged when persona.enabled off (config gate on)", () => {
48
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
49
+ settings.setProfile("用户是规划背景");
50
+ settings.setPersona({ name: "Amarre", personality: "轻松幽默", enabled: false });
51
+ const text = settingsCtx.text({});
52
+ assert.ok(text.includes("用户画像"));
53
+ assert.ok(!text.includes("人格"));
54
+ store.close();
55
+ });
56
+
57
+ test("empty block stays empty when only persona exists but a gate is off", () => {
58
+ const { store, settings, settingsCtx } = setup({ personaEnabled: false });
59
+ settings.setPersona({ name: "Amarre", enabled: true });
60
+ assert.equal(settingsCtx.text({}), "");
61
+ store.close();
62
+ });
63
+
64
+ test("both gates on: persona lines lead, then profile, then rules", () => {
65
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
66
+ settings.setProfile("用户是规划背景");
67
+ settings.setRules(["不提及用户名"]);
68
+ settings.setPersona({ name: "Amarre", personality: "轻松幽默", replyStyle: "先给结论再解释", enabled: true });
69
+ const text = settingsCtx.text({});
70
+ const idxPersona = text.indexOf("你的人格名字:Amarre");
71
+ const idxProfile = text.indexOf("用户画像:用户是规划背景");
72
+ const idxRule = text.indexOf("规则:不提及用户名");
73
+ assert.ok(idxPersona > -1, "persona line present");
74
+ assert.ok(idxProfile > idxPersona, "profile after persona (design ⑥)");
75
+ assert.ok(idxRule > idxProfile, "rules after profile (rules hardest)");
76
+ assert.ok(text.includes("人格性格设定:轻松幽默"));
77
+ assert.ok(text.includes("回复要求:先给结论再解释"));
78
+ store.close();
79
+ });
80
+
81
+ test("persona with blank text fields renders no persona lines (no empty bullets)", () => {
82
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
83
+ settings.setPersona({ name: " ", personality: "", enabled: true });
84
+ assert.equal(settingsCtx.text({}), "");
85
+ settings.setProfile("有画像");
86
+ const text = settingsCtx.text({});
87
+ assert.ok(!text.includes("人格"));
88
+ store.close();
89
+ });
90
+
91
+ test("persona text goes through escapePromptVars (no raw braces)", () => {
92
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
93
+ settings.setPersona({ name: "{{evil}}", enabled: true });
94
+ const text = settingsCtx.text({});
95
+ assert.ok(!text.includes("{{"), "no raw {{ in user-settings block");
96
+ assert.ok(!text.includes("}}"), "no raw }} in user-settings block");
97
+ store.close();
98
+ });
99
+
100
+ // ---- v1.2 多卡寻址:按会话身份取卡,取不到落 default ------------------------
101
+
102
+ test("session identity hit: byAgent card wins over default", () => {
103
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
104
+ settings.setPersona({ name: "Amarre", enabled: true }); // default
105
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷危占有", enabled: true });
106
+ const ctxWithId = { agent: { session: { header: { agentPreset: "zephyr" } } } };
107
+ const text = settingsCtx.text(ctxWithId);
108
+ assert.ok(text.includes("你的人格名字:泽菲尔"), "byAgent card injected");
109
+ assert.ok(!text.includes("Amarre"), "default card not injected when identity hit");
110
+ store.close();
111
+ });
112
+
113
+ test("session identity miss: falls back to default card", () => {
114
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
115
+ settings.setPersona({ name: "Amarre", enabled: true });
116
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: true });
117
+ const ctxUnknown = { agent: { session: { header: { agentPreset: "unknown-bot" } } } };
118
+ const text = settingsCtx.text(ctxUnknown);
119
+ assert.ok(text.includes("Amarre"), "default on identity miss");
120
+ assert.ok(!text.includes("泽菲尔"), "other card not leaked");
121
+ store.close();
122
+ });
123
+
124
+ test("identity card disabled (enabled=false) falls back to default, not partial card", () => {
125
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
126
+ settings.setPersona({ name: "Amarre", enabled: true });
127
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷", enabled: false });
128
+ const ctxWithId = { agent: { session: { header: { agentPreset: "zephyr" } } } };
129
+ const text = settingsCtx.text(ctxWithId);
130
+ assert.ok(text.includes("Amarre"), "disabled id card → default");
131
+ assert.ok(!text.includes("泽菲尔"), "disabled card never partially injected");
132
+ store.close();
133
+ });
134
+
135
+ test("session without header (mock/old host) behaves exactly like v1 (default)", () => {
136
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
137
+ settings.setPersona({ name: "Amarre", personality: "轻松幽默", enabled: true });
138
+ const text = settingsCtx.text({}); // 无 session.header → default,与 v1 一致
139
+ assert.ok(text.includes("你的人格名字:Amarre"));
140
+ assert.ok(text.includes("人格性格设定:轻松幽默"));
141
+ store.close();
142
+ });
143
+
144
+ // 期望值工具:直接写明文(本组用例不含需转义字符,escapePromptVars 为恒等)。
145
+ function escapeless(s) { return s; }
@@ -0,0 +1,145 @@
1
+ // Persona settings (user_settings "persona" key) — v1.2 多卡模型。
2
+ // Locks: legacy v1 single-object reads as default; byAgent cards round-trip;
3
+ // corruption returns empty store without throwing; unknown fields dropped.
4
+ // Whitelist lock guards the #254-style pit: a flag missing from
5
+ // FEATURE_FLAG_BOOLEANS would make the panel toggle silently ineffective.
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { createStore } from "../src/store.js";
9
+ import { createSettings } from "../src/settings.js";
10
+
11
+ function setup() {
12
+ const store = createStore(":memory:");
13
+ const settings = createSettings(store.db);
14
+ return { store, settings };
15
+ }
16
+
17
+ test("persona defaults to empty store; default card round-trips via single-object form", () => {
18
+ const { store, settings } = setup();
19
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
20
+ const input = {
21
+ name: "Amarre",
22
+ personality: "轻松幽默,讲解用类比",
23
+ replyStyle: "先给结论再解释",
24
+ enabled: false,
25
+ provenance: { sourceMemoryIds: [], generatedAt: "", reviewedByUser: false }
26
+ };
27
+ settings.setPersona(input);
28
+ const got = settings.getPersona();
29
+ assert.deepEqual(got.default, input);
30
+ assert.deepEqual(got.byAgent, {});
31
+ store.close();
32
+ });
33
+
34
+ test("default card write drops unknown fields", () => {
35
+ const { store, settings } = setup();
36
+ settings.setPersona({ name: "Amarre", sneaky: "field" });
37
+ const got = settings.getPersona();
38
+ assert.equal(got.default.name, "Amarre");
39
+ assert.equal("sneaky" in got.default, false);
40
+ store.close();
41
+ });
42
+
43
+ test("persona read of corrupt JSON returns empty store without throwing", () => {
44
+ const { store, settings } = setup();
45
+ settings.setPersona({ name: "seed" });
46
+ // 直改底层键模拟损坏(面板口径:坏值显示为空、可重写,不抛错)
47
+ store.db.prepare("UPDATE user_settings SET value = ? WHERE key = 'persona'").run("not json at all");
48
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
49
+ store.close();
50
+ });
51
+
52
+ test("persona read of non-object JSON (array/number) returns empty store", () => {
53
+ const { store, settings } = setup();
54
+ store.db.prepare("INSERT INTO user_settings (key, value) VALUES ('persona', '[1,2]')").run();
55
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
56
+ store.db.prepare("UPDATE user_settings SET value = '42' WHERE key = 'persona'").run();
57
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
58
+ store.close();
59
+ });
60
+
61
+ test("setPersona tolerates non-object input (writes empty persona)", () => {
62
+ const { store, settings } = setup();
63
+ settings.setPersona(null);
64
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
65
+ settings.setPersona("nope");
66
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
67
+ store.close();
68
+ });
69
+
70
+ // ---- v1.2 多卡模型:{ default, byAgent },旧单对象读作 default 兼容 ----------
71
+
72
+ test("v1 legacy single-object persona reads as default (zero migration)", () => {
73
+ const { store, settings } = setup();
74
+ // 直写 v1 形(单对象)模拟存量库
75
+ store.db.prepare("INSERT INTO user_settings (key, value) VALUES ('persona', ?)")
76
+ .run(JSON.stringify({ name: "Amarre", personality: "轻松幽默", enabled: true }));
77
+ const got = settings.getPersona();
78
+ assert.equal(got.default.name, "Amarre");
79
+ assert.equal(got.default.enabled, true);
80
+ assert.deepEqual(got.byAgent, {});
81
+ store.close();
82
+ });
83
+
84
+ test("byAgent card round-trips via setPersonaFor/getPersonaFor", () => {
85
+ const { store, settings } = setup();
86
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷危占有", enabled: true });
87
+ assert.deepEqual(settings.getPersonaFor("zephyr"), { name: "泽菲尔", personality: "冷危占有", enabled: true });
88
+ // default 仍在且未被影响
89
+ assert.deepEqual(settings.getPersona().default, {});
90
+ // 卡列表含 zephyr
91
+ assert.deepEqual(settings.listPersonas(), ["zephyr"]);
92
+ store.close();
93
+ });
94
+
95
+ test("getPersonaFor falls back to default when id card missing or disabled", () => {
96
+ const { store, settings } = setup();
97
+ settings.setPersona({ name: "Amarre", enabled: true }); // 单对象形写 default
98
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: false }); // 卡存在但关
99
+ // 未注册身份 → default
100
+ assert.equal(settings.getPersonaFor("unknown-bot").name, "Amarre");
101
+ // 身份卡存在但 enabled=false → 落 default(兜底优先,不注入半张卡)
102
+ assert.equal(settings.getPersonaFor("zephyr").name, "Amarre");
103
+ // 身份缺失(null/空串)→ default
104
+ assert.equal(settings.getPersonaFor(null).name, "Amarre");
105
+ assert.equal(settings.getPersonaFor("").name, "Amarre");
106
+ store.close();
107
+ });
108
+
109
+ test("setPersona single-object form merges default and keeps byAgent", () => {
110
+ const { store, settings } = setup();
111
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: true });
112
+ settings.setPersona({ name: "Amarre-新", enabled: true }); // 单对象形
113
+ const got = settings.getPersona();
114
+ assert.equal(got.default.name, "Amarre-新");
115
+ assert.equal(got.byAgent.zephyr.name, "泽菲尔", "byAgent must survive a legacy-form write");
116
+ store.close();
117
+ });
118
+
119
+ test("removePersona deletes byAgent card but default is protected", () => {
120
+ const { store, settings } = setup();
121
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: true });
122
+ assert.equal(settings.removePersona("zephyr"), true);
123
+ assert.deepEqual(settings.listPersonas(), []);
124
+ assert.equal(settings.removePersona("default"), false, "default card cannot be removed");
125
+ assert.equal(settings.removePersona("ghost"), false);
126
+ store.close();
127
+ });
128
+
129
+ test("corrupt multi-card JSON reads as empty store without throwing", () => {
130
+ const { store, settings } = setup();
131
+ settings.setPersona({ name: "seed" });
132
+ store.db.prepare("UPDATE user_settings SET value = ? WHERE key = 'persona'").run("broken {");
133
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
134
+ store.close();
135
+ });
136
+
137
+ test("personaEnabled is in the feature-flag boolean whitelist", () => {
138
+ const { store, settings } = setup();
139
+ // 写侧逐键校验的合并写:未知键抛 TypeError,白名单键可用——
140
+ // 这条锁防「白名单漏登记 → 面板开关静默失效」(#254 同型坑)。
141
+ settings.setFeatureFlags({ personaEnabled: true });
142
+ assert.equal(settings.getFeatureFlags().personaEnabled, true);
143
+ assert.throws(() => settings.setFeatureFlags({ notARealFlag: true }), TypeError);
144
+ store.close();
145
+ });