@modusensus/dsh-mneme 0.9.2 → 0.9.4

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 (59) hide show
  1. package/README.md +11 -7
  2. package/docs/CONFIGURATION.md +9 -2
  3. package/docs/WORKFLOWS.md +70 -3
  4. package/lib/api.js +178 -3
  5. package/lib/capabilities.js +1 -1
  6. package/lib/client.js +315 -27
  7. package/lib/config.js +70 -3
  8. package/lib/dream.js +1 -1
  9. package/lib/index.js +21 -1
  10. package/lib/inject.js +10 -25
  11. package/lib/lang.js +25 -0
  12. package/lib/organize.js +4 -2
  13. package/lib/pipeline-nudge-auto.js +126 -0
  14. package/lib/pipeline-nudge.js +337 -28
  15. package/lib/pipeline-runs.js +1 -1
  16. package/lib/pipeline.js +4 -4
  17. package/lib/profile-extract.js +161 -0
  18. package/lib/scope.js +30 -1
  19. package/lib/service.js +3 -2
  20. package/lib/settings.js +22 -14
  21. package/lib/summarize.js +64 -3
  22. package/lib/tools.js +240 -1
  23. package/lib/user-settings.js +78 -0
  24. package/package.json +1 -1
  25. package/src/api.js +178 -3
  26. package/src/capabilities.js +1 -1
  27. package/src/config.js +70 -3
  28. package/src/dream.js +1 -1
  29. package/src/index.js +21 -1
  30. package/src/inject.js +10 -25
  31. package/src/lang.js +25 -0
  32. package/src/organize.js +4 -2
  33. package/src/pipeline-nudge-auto.js +126 -0
  34. package/src/pipeline-nudge.js +337 -28
  35. package/src/pipeline-runs.js +1 -1
  36. package/src/pipeline.js +4 -4
  37. package/src/profile-extract.js +161 -0
  38. package/src/scope.js +30 -1
  39. package/src/service.js +3 -2
  40. package/src/settings.js +22 -14
  41. package/src/summarize.js +64 -3
  42. package/src/tools.js +240 -1
  43. package/src/user-settings.js +78 -0
  44. package/test/api-nudge-route.test.js +259 -0
  45. package/test/api.test.js +19 -1
  46. package/test/client.test.js +115 -7
  47. package/test/lang.test.js +11 -0
  48. package/test/organize-tool.test.js +232 -0
  49. package/test/persona-addressing.test.js +83 -0
  50. package/test/persona-settings.test.js +30 -8
  51. package/test/pipeline-nudge-auto.test.js +224 -0
  52. package/test/pipeline-nudge.test.js +161 -6
  53. package/test/profile-extract.test.js +269 -0
  54. package/test/scope-optional-service.test.js +110 -0
  55. package/test/session-usage.test.js +11 -2
  56. package/test/summarize.test.js +225 -0
  57. package/test/tools-dto-schema-matrix.test.js +48 -5
  58. package/test/tools.test.js +6 -1
  59. package/test/user-settings-preview.test.js +127 -0
@@ -1926,13 +1926,51 @@ test("M.O. empty-state, list and scope copy exist in both languages", () => {
1926
1926
  "memory.actions.scope.project",
1927
1927
  "memory.actions.scope.global",
1928
1928
  "memory.actions.scope.none",
1929
- "memory.actions.broken"
1929
+ "memory.actions.broken",
1930
+ // 手动沉淀的全部回执:这八句里缺任何一句,按钮点下去就是一行 key 裸奔,
1931
+ // 而那恰好是「点了没反应」的观感来源。
1932
+ "memory.actions.distill.action",
1933
+ "memory.actions.distill.busy",
1934
+ "memory.actions.distill.hint",
1935
+ "memory.actions.distill.noProject",
1936
+ "memory.actions.distill.saved",
1937
+ "memory.actions.distill.none",
1938
+ "memory.actions.distill.noSessions",
1939
+ "memory.actions.distill.unreadable",
1940
+ "memory.actions.distill.failed"
1930
1941
  ]) {
1931
1942
  const hits = (clientSource.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) || []).length;
1932
1943
  assert.equal(hits, 2, `${key} must exist in both zh and en (got ${hits})`);
1933
1944
  }
1934
1945
  });
1935
1946
 
1947
+ // 手动沉淀按钮:面板上「立即跑一次」的唯一入口。锁三件事,每件都对应一种会静默坏掉的情形:
1948
+ // ① 它必须打 POST 且带上项目根——GET 是预览、不写盘,打成 GET 就成了「点了永远 0 条」;
1949
+ // ② materialize 不许出现:按钮只产提议,落草稿是另一个决定;
1950
+ // ③ 没有 project 时必须禁用——服务端只会回 400,点了必然失败的按钮比没有按钮更糟。
1951
+ test("M.O. pane offers a manual distill that posts the project root", () => {
1952
+ const bodyOf = (name) => {
1953
+ const start = clientSource.indexOf(`function ${name}(`);
1954
+ assert.ok(start > -1, `${name} must exist`);
1955
+ const next = clientSource.slice(start + 1).indexOf("\n function ");
1956
+ return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
1957
+ };
1958
+ const bar = bodyOf("PipelineDistillButton");
1959
+ assert.ok(bar.includes('"/api/dsh-mneme/nudge?project="'), "the button must hit the nudge route");
1960
+ assert.ok(bar.includes("encodeURIComponent(project)"), "the project root must travel with the request");
1961
+ assert.ok(bar.includes('method: "POST"'), "a preview GET writes nothing — the button must POST");
1962
+ assert.ok(!bar.includes("materialize"), "the button must never materialize a draft");
1963
+ assert.ok(bar.includes("!project"), "the button must be disabled when the project is unknown");
1964
+ assert.ok(bar.includes("503"), "an unreadable session log must be told apart from an empty result");
1965
+ // 座位:按钮挂在清单头上,且清单要拿到 project——漏传的话按钮永远禁用,
1966
+ // 界面上看起来只是「这个功能没做好」。
1967
+ assert.ok(bodyOf("PipelineWorkflowList").includes("PipelineDistillButton"), "the control belongs in the list header");
1968
+ assert.ok(
1969
+ bodyOf("PipelineBrowser").includes("activeId, onOpen: setPicked, project }"),
1970
+ "the browser must hand the project root down to the list"
1971
+ );
1972
+ });
1973
+
1936
1974
  // 画布能放大缩小。锁的重点不是「有没有缩放」,而是滚轮那一条必须**自己挂非被动监听**:
1937
1975
  // React 的 onWheel 挂在 root 上且是 passive 的,preventDefault 会被浏览器忽略,
1938
1976
  // 于是 Ctrl+滚轮既缩放画布又缩放整页,谁也没拦住——这个错本地测不出来,只在真浏览器里响。
@@ -2039,11 +2077,13 @@ test("panel copy and icons must not silently vanish (missing dict lines, unregis
2039
2077
  // 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
2040
2078
  // + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
2041
2079
  test("persona page ships settings card, master switch and i18n (beta v1)", () => {
2042
- // 开关进 FEATURE_GROUPS 的 enhance 组(行为开关非座位闸门)
2043
- assert.ok(
2044
- /group\.enhance", items: \[(?:[^\]]|\n)*"personaEnabled"\]/.test(clientSource),
2045
- "FEATURE_GROUPS group.enhance must carry personaEnabled"
2046
- );
2080
+ // 开关进 FEATURE_GROUPS 的 enhance 组(行为开关非座位闸门)。按组解析成员再逐个点名,
2081
+ // 而不是把某一行写成字面量正则——那样往组里加第二个键就会把这条护栏弄红(实测踩过)。
2082
+ const enhance = clientSource.match(/group\.enhance", items: \[((?:[^\]]|\n)*)\]/);
2083
+ assert.ok(enhance, "FEATURE_GROUPS group.enhance must be parseable");
2084
+ for (const flag of ["personaEnabled", "personaStrictAddressing"]) {
2085
+ assert.ok(enhance[1].includes(`"${flag}"`), `FEATURE_GROUPS group.enhance must carry ${flag}`);
2086
+ }
2047
2087
  // i18n 键中英双语齐备(occurrences ≥ 2 模式;label + .hint 两路由字典锁统一覆盖)
2048
2088
  for (const key of [
2049
2089
  "memory.features.personaEnabled",
@@ -2058,7 +2098,17 @@ test("persona page ships settings card, master switch and i18n (beta v1)", () =>
2058
2098
  "memory.settings.personaEnabledToggle",
2059
2099
  "memory.settings.personaView",
2060
2100
  "memory.settings.personaViewHint",
2061
- "memory.settings.personaViewEmpty"
2101
+ "memory.settings.personaViewEmpty",
2102
+ // 预览改由后端渲染之后新增的读数文案(缺一种语言就是半边界面裸奔键名)
2103
+ "memory.features.personaStrictAddressing",
2104
+ "memory.features.personaStrictAddressing.hint",
2105
+ "memory.settings.personaViewUnknown",
2106
+ "memory.settings.personaViewSourceDefault",
2107
+ "memory.settings.personaViewSourceCard",
2108
+ "memory.settings.personaViewSourceNone",
2109
+ "memory.settings.personaViewSourceFallback",
2110
+ "memory.settings.personaViewGateOff",
2111
+ "memory.settings.personaViewRefresh"
2062
2112
  ]) {
2063
2113
  const occurrences = clientSource.split(`"${key}"`).length - 1;
2064
2114
  assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
@@ -2072,6 +2122,17 @@ test("persona page ships settings card, master switch and i18n (beta v1)", () =>
2072
2122
  clientSource.includes("memory.features.personaEnabled.hint"),
2073
2123
  "the master-switch hint must document the two-gate semantics"
2074
2124
  );
2125
+ // 「注入生效文本」必须由后端给:前端再拼一份就是第二张皮(原来正是这段前端拼接
2126
+ // 没有人格行、没有表头与前缀,却自称生效文本)。
2127
+ assert.ok(clientSource.includes("injectedPreview.text"), "the view must render the backend-rendered block");
2128
+ assert.ok(!clientSource.includes('"- " + profile.trim()'), "frontend hand-splicing of the injected block must be gone");
2129
+ assert.ok(clientSource.includes("setInjectedPreview(null)"), "a failed preview must fail closed to 未知, not to a stale or guessed block");
2130
+ // 预览只能有一个所有者,且要丢掉过期响应:先切卡 A 再切卡 B 时,A 的 GET 可能后回来。
2131
+ // 这张卡的标题是「注入生效文本」,显示另一张卡的文本比不显示更糟。
2132
+ assert.ok(clientSource.includes("if (seq !== previewSeq.current) return;"), "preview must drop stale responses");
2133
+ assert.equal(clientSource.split("setInjectedPreview(").length - 1, 2, "loadPreview must be the only preview writer (success + failure)");
2134
+ // source=default 有两种来路:带了 ?agent=<身份> 却回落 ≠ 真的解析不到身份。
2135
+ assert.ok(clientSource.includes("injectedPreview.agent"), "the fallback label must separate a resolved-but-fallen-back identity from an unresolvable one");
2075
2136
  });
2076
2137
 
2077
2138
  // v1.2 多卡回归:卡列表 CRUD 可见、字段标签渲染为可见元素(v1.1 反馈:
@@ -2149,3 +2210,50 @@ test("update-check status names match the backend classify contract verbatim", (
2149
2210
  assert.ok(apiSource.includes('path: "/api/dsh-mneme/version-check"'),
2150
2211
  "the backend must register the version-check route");
2151
2212
  });
2213
+
2214
+ // 画像提取草稿(#497)面板面:路由对齐、四种结局各有自己的话、以及「采用」必须把草稿
2215
+ // 文本直接交给保存。最后一条防的是一个具体陷阱——保存函数带默认参数时,把函数本身当
2216
+ // onClick 处理器会把 click 事件当成要存的文本存进去。
2217
+ test("profile extraction ships its button, distinct outcomes and correct save wiring", () => {
2218
+ const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
2219
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/profile/extract"'),
2220
+ "the panel must call the extract route");
2221
+ assert.ok(apiSource.includes('path: "/api/dsh-mneme/profile/extract"'),
2222
+ "the backend must register the extract route");
2223
+
2224
+ // 四种结局(成功草稿 / 未启用 / 没有可用记忆 / 模型没配合)必须各有一句人话:
2225
+ // 压成一句「没有草稿」就等于让用户自己猜是坏了还是本来就没东西。
2226
+ for (const key of [
2227
+ "memory.settings.profileExtract",
2228
+ "memory.settings.profileExtracting",
2229
+ "memory.settings.profileExtractHint",
2230
+ "memory.settings.profileExtractDisabled",
2231
+ "memory.settings.profileExtractEmpty",
2232
+ "memory.settings.profileExtractNoRoute",
2233
+ "memory.settings.profileExtractFailed",
2234
+ "memory.settings.profileSaveFailed",
2235
+ "memory.settings.profileDraftTitle",
2236
+ "memory.settings.profileDraftRows",
2237
+ "memory.settings.profileDraftTruncated",
2238
+ "memory.settings.profileDraftApply",
2239
+ "memory.settings.profileDraftDiscard",
2240
+ "memory.features.profileExtractEnabled",
2241
+ "memory.features.profileExtractEnabled.hint"
2242
+ ]) {
2243
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2244
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2245
+ }
2246
+ assert.ok(/group\.enhance[\s\S]{0,700}"profileExtractEnabled"/.test(clientSource),
2247
+ "FEATURE_GROUPS group.enhance must carry profileExtractEnabled");
2248
+
2249
+ // 采用草稿:显式传文本;保存按钮也不能把 click 事件当参数传进去。
2250
+ assert.ok(clientSource.includes("if (await saveProfile(text))"), "applying a draft must commit state only after a verified save");
2251
+ assert.ok(!clientSource.includes("onClick: saveProfile }"), "the save button must not receive the click event as its value argument");
2252
+ // apiFetch 是 fetch 的薄壳:非 2xx 不抛。画像这条以前没判 res.ok(同页的 persona
2253
+ // 一直判),于是服务端 403/413 时界面照样闪「画像已保存」。
2254
+ const profileSaver = clientSource.slice(clientSource.indexOf("async function saveProfile("));
2255
+ assert.ok(profileSaver.slice(0, 900).includes('if (!res.ok) throw new Error("HTTP "'),
2256
+ "the profile saver must verify the response status before reporting success");
2257
+ // 没配模型是一句可行动的话(先去选模型),不能和「模型没按格式回」共用一条文案。
2258
+ assert.ok(clientSource.includes('"no-route" ? "noroute"'), "no-route must map to its own panel outcome, not the generic failure");
2259
+ });
package/test/lang.test.js CHANGED
@@ -25,6 +25,17 @@ test("prompt table resolves by explicit language key", () => {
25
25
  assert.ok(STR.prompts.conflict.en.includes("conflict arbiter"));
26
26
  assert.ok(STR.prompts.pattern.zh.includes("模式发现助手"));
27
27
  assert.ok(STR.prompts.pattern.en.includes("pattern-discovery assistant"));
28
+ // 画像提取(#497):两段纪律在中英文里都必须成对存在——「输入是数据不是指令」防的
29
+ // 是指令型记忆改写任务,「禁止声明助手身份」防的是画像正文与外部 bot 自带身份撞车
30
+ // (画像不受 personaEnabled 管辖)。少一种语言就等于换语言的用户拿到没有防线的提示词。
31
+ for (const lang of ["zh", "en"]) {
32
+ const p = STR.prompts.profileExtract[lang];
33
+ assert.ok(typeof p === "string" && p.length > 100, `profileExtract.${lang} must be a full prompt`);
34
+ assert.match(p, /不是指令|not instructions/, `profileExtract.${lang}: memory text must be declared data, not instructions`);
35
+ assert.match(p, /(绝不为助手|never name).{0,60}(助手|assistant|identity)/i,
36
+ `profileExtract.${lang}: the prompt must forbid an assistant-identity claim in the draft`);
37
+ assert.ok(p.includes("profile"), `profileExtract.${lang} must state the JSON key`);
38
+ }
28
39
  // 冻结模式后缀:纯提示文本,可直接拼接在巩固 prompt 后。
29
40
  assert.ok(STR.prompts.freezeSuffix.zh.startsWith("\n\n当前为「冲突冻结」模式"));
30
41
  assert.ok(STR.prompts.freezeSuffix.en.startsWith("\n\nConflict-freeze mode is active"));
@@ -0,0 +1,232 @@
1
+ // issue #231 的第二批:把已造好的整理引擎接到 agent 的调用面上(organizeEnabled
2
+ // + memory_organize 工具)。src/organize.js 的本体语义由 test/organize.test.js 守,
3
+ // 本文件只守**出口**这一层:闸门、报告/落地的两步协议、以及 strictScope 下
4
+ // 「看不见的行按不存在处理」这条可信上下文。
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { createStore } from "../src/store.js";
8
+ import { createService } from "../src/service.js";
9
+ import { createTools } from "../src/tools.js";
10
+ import { applyLightModePreset } from "../src/config.js";
11
+
12
+ function setup(config = {}) {
13
+ const store = createStore(":memory:");
14
+ const service = createService({ store, mirror: null, config });
15
+ const registered = [];
16
+ const ctx = {
17
+ tools: {
18
+ register(def) {
19
+ registered.push(def);
20
+ return () => {};
21
+ }
22
+ }
23
+ };
24
+ createTools(ctx, service, config, undefined);
25
+ const byName = Object.fromEntries(registered.map((def) => [def.name, def]));
26
+ return { store, service, byName, registered };
27
+ }
28
+
29
+ const CANDIDATES = [
30
+ { type: "project", title: "发布只在包目录内", content: "根目录发过坏包,发布必须在 dsh-mneme/ 内执行。" },
31
+ { type: "project", title: "另一条", content: "第二条候选的正文。" }
32
+ ];
33
+
34
+ test("memory_organize registers when organizeEnabled is on", async () => {
35
+ const { byName } = setup({ organizeEnabled: true });
36
+ const tool = byName.memory_organize;
37
+ assert.ok(tool, "organizeEnabled=true 应当注册 memory_organize");
38
+ const report = await tool.execute({ mode: "dryRun", candidates: CANDIDATES });
39
+ assert.equal(report.status, "ok");
40
+ assert.equal(report.counts.total, 2, "两条候选都应进报告");
41
+ assert.equal(report.run_id ? typeof report.run_id : "none", "string", "dryRun 必须回一个 run_id 供 apply 引用");
42
+ });
43
+
44
+ // 默认关=零行为变化是仓库红线(AGENTS.md 自查清单第 1 条)。这条现在就会绿——
45
+ // 它防的是「以后有人把默认翻成 true」,而不是今天的缺失实现。
46
+ test("memory_organize stays unregistered by default", () => {
47
+ const { registered } = setup();
48
+ assert.equal(registered.some((t) => t.name === "memory_organize"), false, "organizeEnabled 缺省时不得注册");
49
+ });
50
+
51
+ test("lightMode preset turns organize off, but an explicit user switch wins", () => {
52
+ // 轻量档那一格是**预设**,不是强制(合并顺序「用户开关 > 轻量预设 > bundle」在
53
+ // index.js),所以这里断言的是预设值本身——同 continuity.test.js 的
54
+ // continuityRescueEnabled 那条。工具侧的注册与否由上一条默认关用例守着。
55
+ assert.equal(applyLightModePreset({ lightMode: true }).organizeEnabled, false);
56
+ assert.equal(applyLightModePreset({ lightMode: true, organizeEnabled: true }).organizeEnabled, false, "预设写死 false,赢的是合并时的用户开关,不是这里");
57
+ });
58
+
59
+ test("dryRun writes no memory rows but leaves one organize audit row", () => {
60
+ const { store, byName } = setup({ organizeEnabled: true });
61
+ return byName.memory_organize.execute({ mode: "dryRun", candidates: CANDIDATES }).then((report) => {
62
+ assert.deepEqual(store.list({ limit: null }), [], "比对是报告,不是动作:记忆表必须一行不写");
63
+ const runs = store.listDreamRuns({ limit: 10 });
64
+ assert.equal(runs.length, 1, "dryRun 只留 dream_runs 一行回执");
65
+ assert.equal(runs[0].run_type, "organize");
66
+ assert.equal(runs[0].id, report.run_id);
67
+ });
68
+ });
69
+
70
+ test("apply lands the decisions it references and reports counters", async () => {
71
+ const { store, byName } = setup({ organizeEnabled: true });
72
+ const report = await byName.memory_organize.execute({ mode: "dryRun", candidates: CANDIDATES });
73
+ const applied = await byName.memory_organize.execute({
74
+ mode: "apply",
75
+ run_id: report.run_id,
76
+ decisions: [
77
+ { action: "save", index: 0 },
78
+ { action: "discard", index: 1 }
79
+ ]
80
+ });
81
+ assert.equal(applied.saved, 1, "只数新建的行");
82
+ assert.equal(applied.discarded, 1);
83
+ assert.equal(applied.status, "ok");
84
+ assert.equal(store.list({ limit: null }).length, 1, "一条 save、一条 discard → 库里只有一行");
85
+ const types = store.listDreamRuns({ limit: 10 }).map((r) => r.run_type);
86
+ assert.deepEqual(types, ["organize", "organize"], "报告与落地各留一行审计");
87
+ });
88
+
89
+ test("apply refuses a run_id that was never reported", async () => {
90
+ const { byName } = setup({ organizeEnabled: true });
91
+ await assert.rejects(
92
+ () => byName.memory_organize.execute({ mode: "apply", run_id: "no-such-run", decisions: [{ action: "discard", index: 0 }] }),
93
+ /unknown organize run/,
94
+ "没有比对过的候选一律不落库(organize 硬规则 2)"
95
+ );
96
+ });
97
+
98
+ test("apply refuses a missing run_id", async () => {
99
+ const { byName } = setup({ organizeEnabled: true });
100
+ await assert.rejects(
101
+ () => byName.memory_organize.execute({ mode: "apply", decisions: [{ action: "discard", index: 0 }] }),
102
+ /run_id is required/,
103
+ "apply 必须引用一次真实 dryRun"
104
+ );
105
+ });
106
+
107
+ // 整理接口不能成为第二条「写进库却不带 scope」的通道:memory_save 在 scopeEnabled
108
+ // 下按会话身份盖 auto 章,organize 落地的候选必须同源。显式参数改盖 explicit,
109
+ // 与 memory_save / memory_register_document 同一规则。
110
+ test("save lands candidates stamped with the session scope (auto), like memory_save", async () => {
111
+ const { store, byName } = setup({ organizeEnabled: true, scopeEnabled: true });
112
+ const exec = { agent: { session: { id: "s1", header: { agentPreset: "me", cwd: "D:\\p" } } } };
113
+ const report = await byName.memory_organize.execute({ mode: "dryRun", candidates: CANDIDATES }, exec);
114
+ await byName.memory_organize.execute({ mode: "apply", run_id: report.run_id, decisions: [{ action: "save", index: 0 }] }, exec);
115
+ const row = store.list({ limit: null })[0];
116
+ assert.equal(row.agent_scope, "me", "缺省吃会话身份");
117
+ assert.equal(row.agent_scope_source, "auto");
118
+ assert.equal(row.workspace_scope, "D:\\p");
119
+ });
120
+
121
+ test("explicit scope arguments stamp as explicit and win over the session label", async () => {
122
+ const { store, byName } = setup({ organizeEnabled: true, scopeEnabled: true });
123
+ const exec = { agent: { session: { id: "s1", header: { agentPreset: "me", cwd: "D:\\p" } } } };
124
+ const report = await byName.memory_organize.execute(
125
+ { mode: "dryRun", candidates: CANDIDATES, agent_scope: "other-agent" },
126
+ exec
127
+ );
128
+ await byName.memory_organize.execute({ mode: "apply", run_id: report.run_id, decisions: [{ action: "save", index: 0 }] }, exec);
129
+ const row = store.list({ limit: null })[0];
130
+ assert.equal(row.agent_scope, "other-agent", "显式参数盖在会话身份之上");
131
+ assert.equal(row.agent_scope_source, "explicit");
132
+ });
133
+
134
+ test("unknown mode is rejected", async () => {
135
+ const { byName } = setup({ organizeEnabled: true });
136
+ await assert.rejects(() => byName.memory_organize.execute({ mode: "force" }), /unknown mode/);
137
+ });
138
+
139
+ // #170 复核项 4 的口径延伸到整理接口的**两侧**:报告侧不得泄漏存在性,落地侧不得
140
+ // 绕过 scope 治理改库。用例形状是刻意挑的:比对键只看三列**值**(sensitivity/agent/
141
+ // workspace),可见性还多认一个 `explicit` 来源,所以要造出「按值该命中、但会话看不见」
142
+ // 那一格——匿名会话(解析不到 agentPreset)对 explicit 行 fail-closed。
143
+ // 报告侧少了过滤就是探测口:`agent_scope` 是模型自己给的参数,A 会话可以拿 B 的标题问
144
+ // 「那一档有没有这行」,拿回 id/title 就等于确认存在——memory_get 在同一条件下回「不存在」。
145
+ test("dryRun reports an invisible match as new, with no id or title", async () => {
146
+ const config = { organizeEnabled: true, scopeEnabled: true, strictScope: true };
147
+ const { store, byName } = setup(config);
148
+ store.save({
149
+ type: "project",
150
+ title: "发布只在包目录内",
151
+ content: "team-a 名下的旧行。",
152
+ agent_scope: "team-a",
153
+ agent_scope_source: "explicit",
154
+ workspace_scope: "D:\\p",
155
+ workspace_scope_source: "auto"
156
+ });
157
+ const exec = { agent: { session: { id: "s-anon", header: { cwd: "D:\\p" } } } };
158
+ const report = await byName.memory_organize.execute(
159
+ {
160
+ mode: "dryRun",
161
+ candidates: [{ type: "project", title: "发布只在包目录内", content: "换一种说法的同一件事。" }],
162
+ agent_scope: "team-a",
163
+ workspace_scope: "D:\\p"
164
+ },
165
+ exec
166
+ );
167
+ assert.equal(report.items[0].verdict, "new", "看不见的行按不存在处理 → 报告只能说「新条目」");
168
+ assert.equal(report.items[0].match, undefined, "不得带回落点 id/title/sim");
169
+ assert.equal(report.counts.new, 1);
170
+ });
171
+
172
+ // 反面对照:strictScope 是**可选**硬过滤,关掉它时那行本来就可读(A2 只降权)。少了这条,
173
+ // 过滤就变成「闸门关着也少召回」的第二重惩罚——修安全问题不许顺手改默认行为。
174
+ test("the same row is still reported as exact when strictScope is off", async () => {
175
+ const config = { organizeEnabled: true, scopeEnabled: true };
176
+ const { store, byName } = setup(config);
177
+ const row = store.save({
178
+ type: "project",
179
+ title: "发布只在包目录内",
180
+ content: "team-a 名下的旧行。",
181
+ agent_scope: "team-a",
182
+ agent_scope_source: "explicit",
183
+ workspace_scope: "D:\\p",
184
+ workspace_scope_source: "auto"
185
+ });
186
+ const exec = { agent: { session: { id: "s-anon", header: { cwd: "D:\\p" } } } };
187
+ const report = await byName.memory_organize.execute(
188
+ {
189
+ mode: "dryRun",
190
+ candidates: [{ type: "project", title: "发布只在包目录内", content: "换一种说法的同一件事。" }],
191
+ agent_scope: "team-a",
192
+ workspace_scope: "D:\\p"
193
+ },
194
+ exec
195
+ );
196
+ assert.equal(report.items[0].verdict, "exact");
197
+ assert.equal(report.items[0].match.id, row.id, "闸门关着时照常给出落点,否则比对报告失去意义");
198
+ });
199
+
200
+ // 落地侧:报告已按「不存在」说了,archive 也不许真的动那一行(`opts.hiddenIds` 由可信
201
+ // 调用层算好传入,绝不取 payload 字段——那是调用方可自选的内容,不是授权)。
202
+ test("archive of a row the caller cannot see is skipped as out of scope", async () => {
203
+ const config = { organizeEnabled: true, scopeEnabled: true, strictScope: true };
204
+ const { store, byName } = setup(config);
205
+ const teamRow = store.save({
206
+ type: "project",
207
+ title: "发布只在包目录内",
208
+ content: "team-a 名下的旧行。",
209
+ agent_scope: "team-a",
210
+ agent_scope_source: "explicit",
211
+ workspace_scope: "D:\\p",
212
+ workspace_scope_source: "auto"
213
+ });
214
+ const exec = { agent: { session: { id: "s-anon", header: { cwd: "D:\\p" } } } };
215
+ const report = await byName.memory_organize.execute(
216
+ {
217
+ mode: "dryRun",
218
+ candidates: [{ type: "project", title: "发布只在包目录内", content: "换一种说法的同一件事。" }],
219
+ agent_scope: "team-a",
220
+ workspace_scope: "D:\\p"
221
+ },
222
+ exec
223
+ );
224
+ const applied = await byName.memory_organize.execute(
225
+ { mode: "apply", run_id: report.run_id, decisions: [{ action: "archive", id: teamRow.id }] },
226
+ exec
227
+ );
228
+ assert.equal(applied.archived, 0, "看不见的行按不存在处理,不得归档");
229
+ assert.equal(applied.skipped.length, 1);
230
+ assert.match(applied.skipped[0].error, /out of scope/, "文案维持「unknown, archived or out of scope」:不泄漏存在性");
231
+ assert.equal(store.getById(teamRow.id).archived, false, "他人的行原样未动");
232
+ });
@@ -0,0 +1,83 @@
1
+ // 修复一:人格寻址的兜底口径。default 卡的存在理由是「这一帧解析不到会话身份」,
2
+ // 而不是「所有没建卡的 agent 都该拿到它」——后者会把某个人格无条件塞进外部 bot 的
3
+ // 会话(DS Bot 那类:它自己带身份与设定,两处身份声明撞在同一帧里)。
4
+ // 现状(personaStrictAddressing 缺省 = false)已由 test/persona-inject.test.js:113
5
+ // 与 :124 钉住,本文件第一条照样锁它——这是 opt-in,不是把别人的行为改了。
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
+ createInjector(ctx, service, settings, { maxInjectedItems: 3, importanceThreshold: 3, ...over });
27
+ return { store, settings, settingsCtx: contexts.find((c) => c.name === "user-settings") };
28
+ }
29
+
30
+ const withPreset = (agentPreset) => ({ agent: { session: { id: "s1", header: { agentPreset } } } });
31
+
32
+ // 种子:default 卡(Amarre)开着 + 一张 zephyr 卡。
33
+ function seed(settings, zephyrEnabled = true) {
34
+ settings.setPersona({ name: "Amarre", personality: "轻松幽默", enabled: true });
35
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷危", enabled: zephyrEnabled });
36
+ }
37
+
38
+ test("缺省(开关关):没建卡的身份仍落 default——本 PR 不改默认行为", () => {
39
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
40
+ seed(settings);
41
+ const text = settingsCtx.text(withPreset("unknown-bot"));
42
+ assert.ok(text.includes("Amarre"), "personaStrictAddressing 缺省时行为逐字节不变");
43
+ store.close();
44
+ });
45
+
46
+ test("strict on:没建卡的身份不注入任何人格行", () => {
47
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true, personaStrictAddressing: true });
48
+ seed(settings);
49
+ settings.setProfile("用户是规划背景");
50
+ const text = settingsCtx.text(withPreset("unknown-bot"));
51
+ assert.ok(!text.includes("Amarre"), "外部身份不该收到别人的人格");
52
+ assert.ok(!text.includes("你的人格名字"), "无人格时整段人格行都不出现,不是留个空标签");
53
+ store.close();
54
+ });
55
+
56
+ test("strict on:显式关掉的卡不再回落 default——这让「关掉这张卡」第一次有意义", () => {
57
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true, personaStrictAddressing: true });
58
+ seed(settings, false);
59
+ const text = settingsCtx.text(withPreset("zephyr"));
60
+ assert.ok(!text.includes("泽菲尔"), "关掉的卡本身不注入");
61
+ assert.ok(!text.includes("Amarre"), "关掉之后落回 default 等于没关");
62
+ store.close();
63
+ });
64
+
65
+ test("strict on:解析不到身份(无 session.header)照旧用 default 卡", () => {
66
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true, personaStrictAddressing: true });
67
+ seed(settings);
68
+ const text = settingsCtx.text({});
69
+ assert.ok(text.includes("你的人格名字:Amarre"), "无名会话正是 default 卡的受众,strict 不许把它一起关掉");
70
+ store.close();
71
+ });
72
+
73
+ test("strict on:命中的卡照常注入,画像与规则不受影响", () => {
74
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true, personaStrictAddressing: true });
75
+ seed(settings);
76
+ settings.setProfile("用户是规划背景");
77
+ settings.setRules(["不提及用户名"]);
78
+ const hit = settingsCtx.text(withPreset("zephyr"));
79
+ assert.ok(hit.includes("你的人格名字:泽菲尔"), "身份命中时行为与现状一致");
80
+ assert.ok(hit.includes("- 用户画像:用户是规划背景"), "画像恒注入:本开关只管人格行");
81
+ assert.ok(hit.includes("- 规则:不提及用户名"), "规则恒注入:同上");
82
+ store.close();
83
+ });
@@ -7,6 +7,7 @@ import test from "node:test";
7
7
  import assert from "node:assert/strict";
8
8
  import { createStore } from "../src/store.js";
9
9
  import { createSettings } from "../src/settings.js";
10
+ import { resolvePersonaCard } from "../src/user-settings.js";
10
11
 
11
12
  function setup() {
12
13
  const store = createStore(":memory:");
@@ -81,10 +82,14 @@ test("v1 legacy single-object persona reads as default (zero migration)", () =>
81
82
  store.close();
82
83
  });
83
84
 
84
- test("byAgent card round-trips via setPersonaFor/getPersonaFor", () => {
85
+ // 寻址口径住在 src/user-settings.js(注入与面板预览共用一份),不在 settings 里——
86
+ // settings 只存卡。原来那两个 getPersonaFor 用例因此改指向 resolvePersonaCard:
87
+ // 断言的是同一件事,但落在唯一那份实现上。
88
+ test("byAgent card round-trips via setPersonaFor/resolvePersonaCard", () => {
85
89
  const { store, settings } = setup();
86
90
  settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷危占有", enabled: true });
87
- assert.deepEqual(settings.getPersonaFor("zephyr"), { name: "泽菲尔", personality: "冷危占有", enabled: true });
91
+ assert.deepEqual(resolvePersonaCard(settings, "zephyr").card, { name: "泽菲尔", personality: "冷危占有", enabled: true });
92
+ assert.equal(resolvePersonaCard(settings, "zephyr").source, "byAgent");
88
93
  // default 仍在且未被影响
89
94
  assert.deepEqual(settings.getPersona().default, {});
90
95
  // 卡列表含 zephyr
@@ -92,17 +97,34 @@ test("byAgent card round-trips via setPersonaFor/getPersonaFor", () => {
92
97
  store.close();
93
98
  });
94
99
 
95
- test("getPersonaFor falls back to default when id card missing or disabled", () => {
100
+ test("缺省寻址:身份没建卡、或卡被关掉,都落 default", () => {
96
101
  const { store, settings } = setup();
97
102
  settings.setPersona({ name: "Amarre", enabled: true }); // 单对象形写 default
98
103
  settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: false }); // 卡存在但关
99
- // 未注册身份 → default
100
- assert.equal(settings.getPersonaFor("unknown-bot").name, "Amarre");
104
+ assert.equal(resolvePersonaCard(settings, "unknown-bot").card.name, "Amarre");
101
105
  // 身份卡存在但 enabled=false → 落 default(兜底优先,不注入半张卡)
102
- assert.equal(settings.getPersonaFor("zephyr").name, "Amarre");
106
+ assert.equal(resolvePersonaCard(settings, "zephyr").card.name, "Amarre");
103
107
  // 身份缺失(null/空串)→ default
104
- assert.equal(settings.getPersonaFor(null).name, "Amarre");
105
- assert.equal(settings.getPersonaFor("").name, "Amarre");
108
+ assert.equal(resolvePersonaCard(settings, null).card.name, "Amarre");
109
+ assert.equal(resolvePersonaCard(settings, "").card.name, "Amarre");
110
+ store.close();
111
+ });
112
+
113
+ test("strict 寻址:身份没建卡与卡被关掉都不注入,无名会话仍用 default", () => {
114
+ const { store, settings } = setup();
115
+ settings.setPersona({ name: "Amarre", enabled: true });
116
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: false });
117
+ const strict = { strict: true };
118
+ for (const id of ["unknown-bot", "zephyr"]) {
119
+ const { card, source } = resolvePersonaCard(settings, id, strict);
120
+ assert.equal(source, "none", `${id} 在严格寻址下不该拿到任何卡`);
121
+ assert.deepEqual(card, {});
122
+ }
123
+ // 开着的身份卡照样命中(strict 不改变命中语义)
124
+ settings.setPersonaFor("mentor", { name: "导师", enabled: true });
125
+ assert.equal(resolvePersonaCard(settings, "mentor", strict).source, "byAgent");
126
+ // 解析不到身份 = default 卡的受众,strict 不动这一格
127
+ assert.equal(resolvePersonaCard(settings, null, strict).card.name, "Amarre");
106
128
  store.close();
107
129
  });
108
130