@modusensus/dsh-mneme 0.9.0 → 0.9.1

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 (88) hide show
  1. package/README.md +78 -16
  2. package/bin/cli.mjs +8 -2
  3. package/docs/CONFIGURATION.md +25 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +282 -57
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +598 -248
  9. package/lib/config.js +33 -2
  10. package/lib/embedding.js +116 -15
  11. package/lib/index.js +32 -0
  12. package/lib/inject.js +34 -3
  13. package/lib/lang.js +124 -0
  14. package/lib/pipeline-activities.js +178 -0
  15. package/lib/pipeline-attribution.js +165 -0
  16. package/lib/pipeline-command.js +279 -0
  17. package/lib/pipeline-confirm.js +142 -0
  18. package/lib/pipeline-exec.js +174 -0
  19. package/lib/pipeline-nudge.js +1281 -0
  20. package/lib/pipeline-observations.js +181 -0
  21. package/lib/pipeline-observe.js +133 -0
  22. package/lib/pipeline-runner.js +346 -0
  23. package/lib/pipeline-runs.js +442 -0
  24. package/lib/pipeline.js +128 -7
  25. package/lib/semantic.js +66 -10
  26. package/lib/settings.js +132 -1
  27. package/lib/store.js +77 -3
  28. package/lib/summarize.js +2 -3
  29. package/package.json +3 -3
  30. package/scripts/changelog-prep.mjs +62 -7
  31. package/scripts/check-sync.js +8 -6
  32. package/scripts/release-docs.mjs +204 -0
  33. package/scripts/release-notes.mjs +162 -0
  34. package/src/api-standalone.js +78 -12
  35. package/src/api.js +282 -57
  36. package/src/candidates.js +454 -454
  37. package/src/config.js +33 -2
  38. package/src/embedding.js +116 -15
  39. package/src/index.js +32 -0
  40. package/src/inject.js +34 -3
  41. package/src/lang.js +124 -0
  42. package/src/pipeline-activities.js +178 -0
  43. package/src/pipeline-attribution.js +165 -0
  44. package/src/pipeline-command.js +279 -0
  45. package/src/pipeline-confirm.js +142 -0
  46. package/src/pipeline-exec.js +174 -0
  47. package/src/pipeline-nudge.js +1281 -0
  48. package/src/pipeline-observations.js +181 -0
  49. package/src/pipeline-observe.js +133 -0
  50. package/src/pipeline-runner.js +346 -0
  51. package/src/pipeline-runs.js +442 -0
  52. package/src/pipeline.js +128 -7
  53. package/src/semantic.js +66 -10
  54. package/src/settings.js +132 -1
  55. package/src/store.js +77 -3
  56. package/src/summarize.js +2 -3
  57. package/test/api-hardening.test.js +173 -0
  58. package/test/api.test.js +98 -1
  59. package/test/changelog-prep.test.js +74 -8
  60. package/test/cli-enums.test.js +37 -0
  61. package/test/client.test.js +152 -3
  62. package/test/docs-config-keys.test.js +65 -0
  63. package/test/helpers/write-admission-samples.js +6 -2
  64. package/test/lib-smoke.test.js +20 -0
  65. package/test/persona-api.test.js +172 -0
  66. package/test/persona-flag.test.js +36 -0
  67. package/test/persona-inject.test.js +145 -0
  68. package/test/persona-settings.test.js +145 -0
  69. package/test/pipeline-activities.test.js +155 -0
  70. package/test/pipeline-attribution.test.js +175 -0
  71. package/test/pipeline-command.test.js +243 -0
  72. package/test/pipeline-confirm.test.js +148 -0
  73. package/test/pipeline-exec.test.js +172 -0
  74. package/test/pipeline-nudge.test.js +1036 -0
  75. package/test/pipeline-observations.test.js +157 -0
  76. package/test/pipeline-observe.test.js +127 -0
  77. package/test/pipeline-r1r2.test.js +149 -0
  78. package/test/pipeline-runner.test.js +442 -0
  79. package/test/pipeline-runs.test.js +767 -0
  80. package/test/pipeline.test.js +137 -6
  81. package/test/reindex-backfill.test.js +83 -4
  82. package/test/release-docs.test.js +130 -0
  83. package/test/release-notes.test.js +240 -0
  84. package/test/release-workflow.test.js +16 -0
  85. package/test/route-policy.test.js +113 -0
  86. package/test/security-doc.test.js +139 -0
  87. package/test/sensitive-scan.test.js +44 -0
  88. package/test/workflow-guards.test.js +156 -0
@@ -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
+ });
@@ -0,0 +1,155 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { businessCommandOf, activityKey, tallyActivities } from "../src/pipeline-activities.js";
4
+
5
+ // 这组测试守两件事:
6
+ // ① 分组结果落在「人能扫」的量级(业务命令才进清单,shell 语法不进)
7
+ // ② 🔴 隐私硬规则:位置参数一律丢弃——回执要显示在界面上,而完整命令行里
8
+ // 几乎必然含绝对路径(路径里含本机用户名)。这一条比功能本身重要。
9
+
10
+ test("业务命令:抽出 {动词, 子命令, 旗标}", () => {
11
+ assert.deepEqual(businessCommandOf("git status"), { verb: "git", sub: "status", flags: [] });
12
+ assert.deepEqual(businessCommandOf("git push origin main"), { verb: "git", sub: "push", flags: [] });
13
+ assert.deepEqual(businessCommandOf("gh pr merge --rebase"), { verb: "gh", sub: "pr", flags: ["--rebase"] });
14
+ assert.deepEqual(businessCommandOf("npm test"), { verb: "npm", sub: "test", flags: [] });
15
+ // 子命令可空
16
+ assert.deepEqual(businessCommandOf("npm"), { verb: "npm", sub: "", flags: [] });
17
+ });
18
+
19
+ test("🔴 隐私硬规则:位置参数一律丢弃,只留动词/子命令/旗标", () => {
20
+ // 实测里真实出现过这种形状:路径含本机用户名。回执绝不能把它带出去。
21
+ const r = businessCommandOf('set-location "c:\\users\\somebody\\desktop\\proj"; git commit -m "x"');
22
+ // 整段里第一个可识别的是 set-location(不在名单)→ 由分段器处理;这里直接测 git commit
23
+ assert.equal(businessCommandOf("git commit -m \"修好了\"").verb, "git");
24
+ assert.deepEqual(businessCommandOf("git commit -m \"修好了\"").flags, ["-m"]);
25
+ // 位置参数(文件名、分支名、路径)一个都不该出现在结果里
26
+ const withPath = businessCommandOf("git add src/foo.js C:/Users/name/x.md");
27
+ assert.equal(JSON.stringify(withPath).includes("Users"), false, "路径绝不能出现在结果里");
28
+ assert.equal(JSON.stringify(withPath).includes("foo.js"), false, "文件名是位置参数,该丢");
29
+ });
30
+
31
+ test("🔴 旗标里的路径参数也要挡住(--file=C:/x 这种)", () => {
32
+ const r = businessCommandOf("git commit --file=C:/Users/name/msg.txt -m x");
33
+ assert.equal(JSON.stringify(r).includes("Users"), false, "带路径的旗标不许原样带出");
34
+ // 纯旗标照常留
35
+ assert.deepEqual(businessCommandOf("gh pr list --state all").flags.includes("--state"), true);
36
+ });
37
+
38
+ test("非业务命令一律不产清单项(shell 语法不是活动)", () => {
39
+ // 实测里密度最高的就是这些——它们不是"做了什么",是句子成分
40
+ for (const noise of [
41
+ 'write-output "=== 检查 ==="',
42
+ "select-object -first 10",
43
+ "foreach-object { $_ }",
44
+ "echo hello",
45
+ "}",
46
+ "if (test-path x) { }",
47
+ "$erroractionpreference='continue'",
48
+ "cd C:/some/dir"
49
+ ]) {
50
+ assert.equal(businessCommandOf(noise), null, `${noise} 不该进清单`);
51
+ }
52
+ });
53
+
54
+ test("分段:一条命令里的多个业务动作分别计入", () => {
55
+ const r = tallyActivities([{ command: 'cd C:/x; git status; echo "---"; npm test' }]);
56
+ const keys = r.activities.map((a) => a.key).sort();
57
+ assert.deepEqual(keys, ["git status", "npm test"], "cd 与 echo 应被排除");
58
+ assert.equal(r.shellNoise, 2, "shell 噪音要单独计数,不能与业务动作混为一谈");
59
+ assert.equal(r.totalSegments, 4);
60
+ });
61
+
62
+ test("计数与排序:按出现次数降序,同次数按名字", () => {
63
+ const r = tallyActivities([
64
+ { command: "git status" },
65
+ { command: "npm test; git status" },
66
+ { command: "git status && gh pr list" }
67
+ ]);
68
+ assert.equal(r.activities[0].key, "git status");
69
+ assert.equal(r.activities[0].count, 3);
70
+ assert.equal(r.businessSegments, 5);
71
+ });
72
+
73
+ test("旗标并集:同一动作的不同旗标合并展示,且截断", () => {
74
+ const r = tallyActivities([
75
+ { command: "gh pr merge --rebase" },
76
+ { command: "gh pr merge --squash" }
77
+ ]);
78
+ assert.equal(r.activities.length, 1);
79
+ assert.equal(r.activities[0].count, 2);
80
+ assert.deepEqual(r.activities[0].flags.sort(), ["--rebase", "--squash"]);
81
+ });
82
+
83
+ test("🔴 回归:脚本路径不得被当成子命令(端到端实测抓到的真 bug)", () => {
84
+ // 实测里出现过 `node d:\dsh\.dsh\_tmp-mo-probe.mjs` —— 原实现把整条路径当 sub,
85
+ // 于是回执里带出本机路径(可能含用户名)。这正是隐私红线要挡的东西。
86
+ const r = businessCommandOf('node d:\\dsh\\.dsh\\_tmp-probe.mjs');
87
+ assert.equal(r.verb, "node");
88
+ assert.equal(JSON.stringify(r).includes("dsh"), false, "路径绝不能出现在结果里");
89
+ assert.equal(r.sub, "", "路径不是子命令,宁可留空");
90
+ // 相对路径同理
91
+ const r2 = businessCommandOf("node test/pipeline-nudge.test.js --test");
92
+ assert.equal(r2.sub, "", "文件名不是子命令");
93
+ assert.deepEqual(r2.flags, ["--test"]);
94
+ });
95
+
96
+ test("🔴 回归:`git -C <path> log` 要扫到 log,而不是把 `-C` 的值当子命令", () => {
97
+ // 实测出现过 `git (join-path -C --oneline --porcelain` 这种读不懂的清单行。
98
+ const r = businessCommandOf('git -C C:/some/dir log --oneline -3');
99
+ assert.equal(r.verb, "git");
100
+ assert.equal(r.sub, "log", "子命令在 -C 的值之后,必须扫描而不是取第一个");
101
+ assert.equal(JSON.stringify(r).includes("some"), false, "路径不许带出");
102
+ });
103
+
104
+ test("子命令形状窄:含 $、括号、引号、长串的一律不算", () => {
105
+ for (const bad of ["$p", "$(Get-Item", "C:/x/y", "d:\\a", "averyveryverylongsubcommandname", "'\"", "{", "@{"]) {
106
+ const r = businessCommandOf(`git ${bad} status`);
107
+ // 要么扫不到(sub 空),要么扫到 status;无论如何不能是那个坏值
108
+ assert.equal(r.sub === bad.toLowerCase(), false, `${bad} 不该被当成子命令`);
109
+ }
110
+ });
111
+
112
+ test("旗标里带 = 或路径的一律丢(--file=C:/x 这种)", () => {
113
+ const r = businessCommandOf("git commit --file=C:/Users/name/msg.txt -m x");
114
+ assert.equal(JSON.stringify(r).includes("Users"), false);
115
+ assert.deepEqual(r.flags, ["-m"], "带值/路径的旗标整个丢掉");
116
+ });
117
+
118
+ test("重定向与引号不影响识别", () => {
119
+ assert.deepEqual(businessCommandOf('npm test 2>&1 | Select-String "x"'), { verb: "npm", sub: "test", flags: [] });
120
+ assert.equal(businessCommandOf("'git' 'status'").verb, "git");
121
+ });
122
+
123
+ test("坏输入不抛:空、非串、缺字段一律安全返回", () => {
124
+ for (const bad of [null, undefined, "", " ", 0, {}]) {
125
+ assert.equal(businessCommandOf(bad), null);
126
+ }
127
+ for (const bad of [null, undefined, [], [{}], [{ command: null }], [0, "x"]]) {
128
+ const r = tallyActivities(bad);
129
+ assert.deepEqual(r.activities, []);
130
+ assert.equal(typeof r.shellNoise, "number");
131
+ }
132
+ assert.equal(activityKey(null), "");
133
+ });
134
+
135
+ test("产出可读:key 是 `动词 子命令`,trim 过", () => {
136
+ assert.equal(activityKey({ verb: "git", sub: "status" }), "git status");
137
+ assert.equal(activityKey({ verb: "npm", sub: "" }), "npm");
138
+ });
139
+
140
+ test("没有子命令时用第一个旗标当键:node --test 与 node --check 不能并成一行", () => {
141
+ // 端到端实测里 16 次 node 调用被并成一条 —— 但它们语义不同(跑测试 vs 查语法)。
142
+ const r = tallyActivities([
143
+ { command: "node --test x.test.js" },
144
+ { command: "node --test y.test.js" },
145
+ { command: "node --check src/foo.js" }
146
+ ]);
147
+ const keys = r.activities.map((a) => a.key).sort();
148
+ assert.deepEqual(keys, ["node --check", "node --test"], "旗标区分语义,该分开");
149
+ assert.equal(r.activities.find((a) => a.key === "node --test").count, 2);
150
+ });
151
+
152
+ test("旗标只当补充:有子命令时键仍以子命令为准", () => {
153
+ const k = activityKey({ verb: "git", sub: "status", flags: ["-C", "--porcelain"] });
154
+ assert.equal(k, "git status", "-C 后面本来跟着被丢弃的路径,不该挤进键里");
155
+ });