@modusensus/dsh-mneme 0.9.3 → 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.
package/src/tools.js CHANGED
@@ -677,6 +677,241 @@ export function createTools(ctx, service, config, embedder) {
677
677
  }
678
678
  }),
679
679
 
680
+ // #231 第二批:agent 主动整理接口的调用入口。引擎在 src/organize.js(PR #267),
681
+ // 当时刻意「不进工具列表、不加独立 opt-in 开关——#249 到位时只差注册工具一步」,
682
+ // 本条就是那一步。工具描述是 agent 侧唯一的协议说明,所以两步规则(apply 必须带
683
+ // 真实 run_id、筛除=discard 不删任何东西、archive 可恢复)都写在这里:本体已经
684
+ // 强制了这些,描述的作用是让第一次调用就成功,而不是靠被拒后试错。
685
+ defineTool({
686
+ name: "memory_organize",
687
+ description:
688
+ "Close out a stretch of work: compare candidate memories against the store first, then land only what you decide to keep. " +
689
+ "Two steps through one tool. mode=dryRun (default) takes `candidates` and answers one verdict per candidate — exact / near / new — with the " +
690
+ "matched row when there is one; it writes NO memories, only an audit receipt. mode=apply takes that report's `run_id` plus one decision per " +
691
+ "candidate (save | discard) and may archive a row the report flagged as superseded (action=archive with that matched id). " +
692
+ "apply is rejected without a real run_id, and a report can be applied only once: candidates that were never compared cannot be written, and " +
693
+ "a row the report never matched cannot be archived. Discard keeps a candidate out and deletes nothing; archive is recoverable and never " +
694
+ "physically removes a row. This tool exists only when organizeEnabled is on (off by default, and off in lightweight mode) — if it is missing " +
695
+ "from your tool list the user has not enabled it. For a single write use memory_save instead.",
696
+ parameters: {
697
+ mode: { type: "string", description: '"dryRun" (default: compare and report, writes nothing) or "apply" (land the decisions of a reported dryRun)' },
698
+ candidates: {
699
+ type: "array",
700
+ // 宿主强制的 JSON Schema 子集要求 object 显式声明 additionalProperties,
701
+ // 所以候选字段在这里逐键写死:scope **来源**两列(*_scope_source)故意不给
702
+ // ——那是可信调用层盖的章,不是模型可自选的内容。
703
+ items: {
704
+ type: "object",
705
+ additionalProperties: false,
706
+ properties: {
707
+ type: { type: "string", required: true, description: "Memory type (project / preference / constraint / ... ; 'document' is rejected here — it is minted by memory_register_document)" },
708
+ title: { type: "string", required: true, description: "Short stable title — part of the dedupe key (type + title + scope)" },
709
+ content: { type: "string", required: true, description: "The body that would be stored" },
710
+ importance: { type: "integer", description: "1-5 (default 3)" },
711
+ tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
712
+ sensitivity: { type: "string", description: "Optional per-candidate sensitivity, overriding the payload-level default" },
713
+ agent_scope: { type: "string", description: "Optional per-candidate agent scope, overriding the payload-level default" },
714
+ workspace_scope: { type: "string", description: "Optional per-candidate workspace scope, overriding the payload-level default" }
715
+ }
716
+ },
717
+ description: "dryRun only: candidates to compare (hard cap 50 per call — this is for closing out a work stretch, not bulk import). Candidates missing type/title/content, or of type document, come back in `skipped`."
718
+ },
719
+ run_id: { type: "string", description: "apply only: the run_id returned by the dryRun report you are acting on" },
720
+ decisions: {
721
+ type: "array",
722
+ items: {
723
+ type: "object",
724
+ additionalProperties: false,
725
+ properties: {
726
+ action: { type: "string", required: true, enum: ["save", "discard", "archive"], description: 'save writes the candidate at `index` through the normal dedupe path (an existing row with the same key is merged, not duplicated); discard keeps it out and deletes nothing; archive hides the row at `id` (recoverable, and only legal for a row this report matched)' },
727
+ index: { type: "integer", description: "save | discard: the candidate index from the dryRun report" },
728
+ id: { type: "string", description: "archive: id of the matched row to archive" }
729
+ }
730
+ },
731
+ description: "apply only: one entry per candidate you judged. Entries whose index the dryRun never accepted are reported in `skipped`."
732
+ },
733
+ sensitivity: { type: "string", description: "dryRun only: payload-level default sensitivity label for candidates that do not set their own (also the scope dimension the comparison runs within)" },
734
+ agent_scope: { type: "string", description: "dryRun only: payload-level default agent scope for candidates that do not set their own. 'global' or '*' widens visibility to every agent; any other value narrows it. Defaults to this session's auto-stamped agent label when scope labeling is on (same rule as memory_save)." },
735
+ workspace_scope: { type: "string", description: "dryRun only: payload-level default workspace scope for candidates that do not set their own. 'global' or '*' widens visibility to every workspace; any other value narrows it. Defaults to this session's auto-stamped workspace label when scope labeling is on." }
736
+ },
737
+ output: {
738
+ schema: {
739
+ type: "object",
740
+ additionalProperties: false,
741
+ properties: {
742
+ // mode 是判别键:两种分支的返回键集不同,渲染与调用方都要靠它分流。
743
+ mode: { type: "string", required: true, enum: ["dryRun", "apply"] },
744
+ run_id: { type: "string", required: true },
745
+ status: { type: "string", required: true },
746
+ skipped: {
747
+ type: "array",
748
+ required: true,
749
+ items: {
750
+ type: "object",
751
+ additionalProperties: false,
752
+ properties: {
753
+ index: { type: "integer", required: true },
754
+ action: { type: "string" },
755
+ error: { type: "string", required: true },
756
+ ids: { type: "array", items: { type: "string" } }
757
+ }
758
+ }
759
+ },
760
+ // --- dryRun 分支 ---
761
+ created_at: { type: "string" },
762
+ counts: {
763
+ type: "object",
764
+ additionalProperties: false,
765
+ properties: {
766
+ total: { type: "integer", required: true },
767
+ exact: { type: "integer", required: true },
768
+ near: { type: "integer", required: true },
769
+ new: { type: "integer", required: true },
770
+ skipped: { type: "integer", required: true },
771
+ truncated: { type: "integer", required: true }
772
+ }
773
+ },
774
+ items: {
775
+ type: "array",
776
+ items: {
777
+ type: "object",
778
+ additionalProperties: false,
779
+ properties: {
780
+ index: { type: "integer", required: true },
781
+ type: { type: "string", required: true },
782
+ title: { type: "string", required: true },
783
+ verdict: { type: "string", required: true, enum: ["exact", "near", "new"] },
784
+ // match.sim 只在向量档命中时出现(exact 档无相似度可报);
785
+ // 归档目标必须是报告里出现过的 id,所以这条链接就是 archive 的凭据。
786
+ match: {
787
+ type: "object",
788
+ additionalProperties: false,
789
+ properties: {
790
+ id: { type: "string", required: true },
791
+ title: { type: "string", required: true },
792
+ sim: { type: "number" }
793
+ }
794
+ }
795
+ }
796
+ }
797
+ },
798
+ // --- apply 分支 ---
799
+ dry_run_id: { type: "string" },
800
+ saved: { type: "integer" },
801
+ merged: { type: "integer" },
802
+ discarded: { type: "integer" },
803
+ archived: { type: "integer" },
804
+ memory_ids: { type: "array", items: { type: "string" } },
805
+ degraded: { type: "boolean" }
806
+ }
807
+ },
808
+ render: (_args, value) => {
809
+ if (value.mode === "apply") {
810
+ return TEXT_OUTPUT(
811
+ `organize applied (${value.run_id}, report ${value.dry_run_id}): ${value.saved} saved, ${value.merged} merged, ` +
812
+ `${value.archived} archived, ${value.discarded} discarded${value.skipped.length ? ` | ${value.skipped.length} skipped` : ""}.`
813
+ );
814
+ }
815
+ const c = value.counts;
816
+ return TEXT_OUTPUT(
817
+ `organize dryRun ${value.run_id}: ${c.total} compared — ${c.new} new, ${c.exact} exact, ${c.near} near` +
818
+ `${c.skipped ? `, ${c.skipped} rejected` : ""}${c.truncated ? `, ${c.truncated} over the cap not compared` : ""}. ` +
819
+ `Nothing was written; pass this run_id with your decisions to memory_organize mode=apply.`
820
+ );
821
+ }
822
+ },
823
+ async execute(args, exec) {
824
+ const mode = String(args?.mode ?? "dryRun").trim();
825
+ const scope = resolveSessionScope(exec);
826
+ const autoStamping = config?.scopeEnabled === true;
827
+ // 载荷级缺省与 memory_save 同源:显式参数盖 explicit 章,否则吃会话自动标注
828
+ // 盖 auto 章。少了这一步,整理接口就成了第二条「写进库却不带 scope」的通道
829
+ // ——candidates 会全部落在未标注(全局可见)那一档。
830
+ const agentLabel = args.agent_scope !== undefined
831
+ ? { value: normalizeExplicitScope(args.agent_scope), source: "explicit" }
832
+ : autoStamping && scope ? { value: scope.agent_scope, source: "auto" } : null;
833
+ const workspaceLabel = args.workspace_scope !== undefined
834
+ ? { value: normalizeExplicitScope(args.workspace_scope), source: "explicit" }
835
+ : autoStamping && scope ? { value: scope.workspace_scope, source: "auto" } : null;
836
+ const hiddenIds = [];
837
+ if (mode === "apply" && config?.strictScope === true && scope && Array.isArray(args.decisions)) {
838
+ // #170 复核项 4 的口径延伸(register_document 的 hiddenEvidenceIds 同形):
839
+ // 他 scope 的 explicit 行按「不存在」处理,由**可信调用层**算好传入,绝不
840
+ // 取 payload 里的字段——那是调用方可自选的内容,不是授权。少了这一段,
841
+ // archive 就成了第二条绕过 scope 治理的改库通道。
842
+ for (const decision of args.decisions) {
843
+ if (String(decision?.action ?? "").trim() !== "archive") continue;
844
+ const id = String(decision?.id ?? "").trim();
845
+ if (!id) continue;
846
+ const row = service.getById(id);
847
+ if (row && !service.isVisibleInScope(row, scope)) hiddenIds.push(id);
848
+ }
849
+ }
850
+ const result = await service.organize(
851
+ {
852
+ mode,
853
+ ...(args.candidates !== undefined ? { candidates: args.candidates } : {}),
854
+ ...(args.run_id !== undefined ? { run_id: args.run_id } : {}),
855
+ ...(args.decisions !== undefined ? { decisions: args.decisions } : {}),
856
+ ...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
857
+ ...(agentLabel ? { agent_scope: agentLabel.value, agent_scope_source: agentLabel.source } : {}),
858
+ ...(workspaceLabel ? { workspace_scope: workspaceLabel.value, workspace_scope_source: workspaceLabel.source } : {})
859
+ },
860
+ { hiddenIds }
861
+ );
862
+ if (mode === "apply") {
863
+ return {
864
+ mode: "apply",
865
+ run_id: result.run_id,
866
+ dry_run_id: result.dry_run_id,
867
+ status: result.status,
868
+ saved: result.saved,
869
+ merged: result.merged,
870
+ discarded: result.discarded,
871
+ archived: result.archived,
872
+ memory_ids: result.memory_ids,
873
+ skipped: result.skipped,
874
+ degraded: result.degraded
875
+ };
876
+ }
877
+ // dryRun 的落点必须过一遍调用方的可见性。`agent_scope` / `workspace_scope`
878
+ // 是模型自己给的参数,而 `organize.dryRun` 的比对只看三列**值**(不看
879
+ // `*_scope_source`、不调 `isVisibleInScope`)——不过这一关,dryRun 就成了
880
+ // 「那一档有没有这个标题」的探测口:#170 复核项 4 给 memory_get / memory_delete
881
+ // 的口径是「不存在」,这里也按不存在说,落点一个键都不带。
882
+ // counts 跟着重算:留着引擎那份计数,`exact: 1` 配一个不带 match 的条目本身就是
883
+ // 存在性oracle,等于把泄露从 id 挪到计数器。审计行仍存原始落点(那是给操作者看的
884
+ // 证据面),apply 侧另有 hiddenIds 挡归档。
885
+ const visibleItems = result.items.map((item) => {
886
+ const match = item.match && visibleUnderStrictScope(service.getById(item.match.id), exec) ? item.match : null;
887
+ return {
888
+ index: item.index,
889
+ type: item.type,
890
+ title: item.title,
891
+ verdict: match ? item.verdict : "new",
892
+ ...(match ? { match: { id: match.id, title: match.title, ...(match.sim != null ? { sim: match.sim } : {}) } } : {})
893
+ };
894
+ });
895
+ const verdictCount = (verdict) => visibleItems.filter((i) => i.verdict === verdict).length;
896
+ return {
897
+ mode: "dryRun",
898
+ run_id: result.run_id,
899
+ created_at: result.created_at,
900
+ status: result.status,
901
+ counts: {
902
+ total: visibleItems.length,
903
+ exact: verdictCount("exact"),
904
+ near: verdictCount("near"),
905
+ new: verdictCount("new"),
906
+ skipped: result.counts.skipped,
907
+ truncated: result.counts.truncated
908
+ },
909
+ items: visibleItems,
910
+ skipped: result.skipped
911
+ };
912
+ }
913
+ }),
914
+
680
915
  // 运行时口子(issue #131 / PR-C)。
681
916
  //
682
917
  // 为什么要有这个工具:mneme 的取向是「本体保持完善但轻量;重的功能存在、默认不开、不被依赖」。
@@ -805,10 +1040,14 @@ export function createTools(ctx, service, config, embedder) {
805
1040
  const hiddenTools = new Set();
806
1041
  if (config?.disableMemorySearch === true) hiddenTools.add("memory_search");
807
1042
  if (config?.disableMemoryArchive === true) hiddenTools.add("memory_archive");
1043
+ // #231 第二批是**反向**语义:memory_organize 默认不在场,organizeEnabled 打开才
1044
+ // 注册。仍走同一个闸门而不是在数组里条件展开——模型看不到就不会调,这比在描述里
1045
+ // 劝它别用可靠(上面那段 v0.8.5 的定性对两个方向都成立)。
1046
+ if (config?.organizeEnabled !== true) hiddenTools.add("memory_organize");
808
1047
 
809
1048
  for (const tool of tools) {
810
1049
  if (hiddenTools.has(tool.name)) {
811
- ctx.logger?.info?.(`[dsh-mneme] tool "${tool.name}" hidden by config (disableMemorySearch/disableMemoryArchive)`);
1050
+ ctx.logger?.info?.(`[dsh-mneme] tool "${tool.name}" hidden by config (disableMemorySearch/disableMemoryArchive/organizeEnabled)`);
812
1051
  continue;
813
1052
  }
814
1053
  if (registeredTools.has(tool.name)) {
@@ -0,0 +1,78 @@
1
+ // dsh-mneme/src/user-settings.js
2
+ // 「用户设置」注入块(人格 / 画像 / 规则)的唯一渲染处。
3
+ //
4
+ // 为什么要单独成模块:面板「Agent 眼中的画像」那张卡原本自己在前端拼了一份 profile +
5
+ // rules 的列表,注释写着「注入生效文本」——但它没有表头、没有「- 用户画像:」前缀,
6
+ // 也完全没有人格三行。两处渲染代码各写各的,就一定会漂;而漂了没人报错,因为两边都
7
+ // 「看着像」。现在注入器与预览路由调同一个函数,前端只显示后端给的文本。
8
+ //
9
+ // 顺序是设计口径(人格设计稿⑥):特化指令(人格)先出,画像与规则后出、后写覆盖占
10
+ // 上风,rules 恒最硬。改这个顺序等于改提示词权重,别顺手调。
11
+
12
+ import { STR, langOf } from "./lang.js";
13
+
14
+ /**
15
+ * 提示词变量转义:宿主模板插值对 `{{`/`}}` 会按变量名解析并在缺失时抛错,整轮崩掉。
16
+ * 所以把 2 个以上连续花括号逐字符插 `\`,让任何 `{{`/`}}` 子串都不残留;转换幂等,
17
+ * 单花括号原样通过。`config.escapePromptVariables === false` 时退回原文。
18
+ *
19
+ * 住在这里是刻意的:预览必须与注入共用同一个转义,否则「同源」又是假的。
20
+ * `src/inject.js` 的同名闭包函数只是薄薄一层委托,其余调用点不动。
21
+ */
22
+ export function escapePromptVars(config, text) {
23
+ if (config?.escapePromptVariables === false) return String(text);
24
+ return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
25
+ }
26
+
27
+ /**
28
+ * 按会话身份取人格卡。返回 `{ card, source }`,`source` 是给面板解释「用的哪张卡」的
29
+ * 读数,不参与判定。
30
+ *
31
+ * `strict`(config.personaStrictAddressing,默认关)决定「有身份、没这张身份的卡」
32
+ * 与「这张卡被显式关掉」这两种情况:
33
+ * - 关(现状):落 default 卡。default 于是变成「所有没建卡的 agent 都能拿到它」,
34
+ * 包括宿主里跑的外部 bot——它自带身份与设定,两处「你是谁」撞在同一帧里。
35
+ * - 开:不注入人格(`source: "none"`)。default 退回它的本意:只兜「这一帧解析不到
36
+ * 身份」的会话(无名会话本来就是 default 卡的受众)。
37
+ * 默认关是有意的:这是一次口径变更,不能让老用户的注入文本在升级后静默变化。
38
+ */
39
+ export function resolvePersonaCard(settings, agentId, { strict = false } = {}) {
40
+ const store = settings.getPersona();
41
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
42
+ const card = id ? store.byAgent[id] : null;
43
+ if (card && card.enabled === true) return { card, source: "byAgent" };
44
+ if (id && strict) return { card: {}, source: "none" };
45
+ return { card: store.default, source: "default" };
46
+ }
47
+
48
+ /**
49
+ * 渲染整块「用户设置」。`agentId` 是会话身份(解析不到传 null → 走 default 兜底)。
50
+ * 返回 `{ text, source, agent, configGate }`:`text` 为最终注入文本(全空时 ""),
51
+ * `configGate` 是 personaEnabled 的现值——面板要靠它解释「卡开着为什么没生效」。
52
+ */
53
+ export function renderUserSettingsBlock({ settings, config, agentId = null }) {
54
+ const language = langOf(config);
55
+ const profile = settings.getProfile().trim();
56
+ const rules = settings.getRules();
57
+ const enabled = config?.personaEnabled === true;
58
+ const resolved = enabled
59
+ ? resolvePersonaCard(settings, agentId, { strict: config?.personaStrictAddressing === true })
60
+ : { card: {}, source: "default" };
61
+ const persona = resolved.card;
62
+ const personaLines = persona?.enabled === true
63
+ ? [
64
+ ...(typeof persona.name === "string" && persona.name.trim() ? [STR.personaNameLine[language](persona.name.trim())] : []),
65
+ ...(typeof persona.personality === "string" && persona.personality.trim() ? [STR.personaPersonalityLine[language](persona.personality.trim())] : []),
66
+ ...(typeof persona.replyStyle === "string" && persona.replyStyle.trim() ? [STR.personaReplyStyleLine[language](persona.replyStyle.trim())] : [])
67
+ ]
68
+ : [];
69
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
70
+ if (!profile && !rules.length && !personaLines.length) {
71
+ return { text: "", source: resolved.source, agent: id, configGate: enabled };
72
+ }
73
+ const lines = [STR.userSettingsHeader[language]];
74
+ lines.push(...personaLines);
75
+ if (profile) lines.push(STR.profileLine[language](profile));
76
+ for (const rule of rules) lines.push(STR.ruleLine[language](rule));
77
+ return { text: escapePromptVars(config, lines.join("\n")), source: resolved.source, agent: id, configGate: enabled };
78
+ }
package/test/api.test.js CHANGED
@@ -748,7 +748,13 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
748
748
  // 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99
749
749
  // #471 再 + 2 = 101。
750
750
  // v0.9.3 新增 actionNudgeEnabled(M.O. 自动沉淀;顶层扁平键)= 102。
751
- assert.equal(Object.keys(data.effective).length, 102);
751
+ // issue #493 新增 summarizeParseFailureMaxRetries(连续解析失败后放弃窗口的
752
+ // 护栏,int 键走 INT_RANGES 表)= 103。
753
+ // issue #231 第二批新增 organizeEnabled(memory_organize 的暴露闸;顶层扁平键,
754
+ // 不需要 NESTED_FLAG_PATHS 条目)= 104。
755
+ // 人格寻址口径新增 personaStrictAddressing(顶层扁平键,同上)= 105。
756
+ // issue #497 新增 profileExtractEnabled(画像提取草稿;顶层扁平键,同上)= 106。
757
+ assert.equal(Object.keys(data.effective).length, 106);
752
758
  assert.equal(data.effective.dreamSkipInvalid, true);
753
759
  assert.equal(data.effective.allowCrossTypeMerge, false);
754
760
  assert.equal(data.effective.dreamMergeGuard, true);
@@ -793,10 +799,17 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
793
799
  assert.equal(data.effective.actionRunEnabled, false);
794
800
  // 人格页(beta):注入总闸默认关,effective 必须能读到(同 #254 坑形防线)。
795
801
  assert.equal(data.effective.personaEnabled, false);
802
+ // 人格寻址口径:默认关 = 历史兜底行为不变(升级后不许有人格静默消失或新增)。
803
+ assert.equal(data.effective.personaStrictAddressing, false);
804
+ // 画像提取默认关:面板按钮在用户没开启前不该偷偷花一次 LLM 调用。
805
+ assert.equal(data.effective.profileExtractEnabled, false);
796
806
  // v0.9.3:M.O. 自动沉淀的第三个键。与 actionPipelineEnabled(看图)、
797
807
  // actionRunEnabled(记账)都不是一件事——它挂 session/event 并解压会话日志,
798
808
  // 是三者里最重的一条,所以独立开关、默认关、且进 LIGHT_MODE_OFF。
799
809
  assert.equal(data.effective.actionNudgeEnabled, false);
810
+ // issue #231 第二批:整理接口的暴露闸默认关(关 = memory_organize 根本不注册)。
811
+ // 面板要能读到这一格,否则用户开了也不见效、还以为是 bug。
812
+ assert.equal(data.effective.organizeEnabled, false);
800
813
  // 新增字符串 / URL / 枚举键
801
814
  assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
802
815
  assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
@@ -2077,11 +2077,13 @@ test("panel copy and icons must not silently vanish (missing dict lines, unregis
2077
2077
  // 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
2078
2078
  // + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
2079
2079
  test("persona page ships settings card, master switch and i18n (beta v1)", () => {
2080
- // 开关进 FEATURE_GROUPS 的 enhance 组(行为开关非座位闸门)
2081
- assert.ok(
2082
- /group\.enhance", items: \[(?:[^\]]|\n)*"personaEnabled"\]/.test(clientSource),
2083
- "FEATURE_GROUPS group.enhance must carry personaEnabled"
2084
- );
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
+ }
2085
2087
  // i18n 键中英双语齐备(occurrences ≥ 2 模式;label + .hint 两路由字典锁统一覆盖)
2086
2088
  for (const key of [
2087
2089
  "memory.features.personaEnabled",
@@ -2096,7 +2098,17 @@ test("persona page ships settings card, master switch and i18n (beta v1)", () =>
2096
2098
  "memory.settings.personaEnabledToggle",
2097
2099
  "memory.settings.personaView",
2098
2100
  "memory.settings.personaViewHint",
2099
- "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"
2100
2112
  ]) {
2101
2113
  const occurrences = clientSource.split(`"${key}"`).length - 1;
2102
2114
  assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
@@ -2110,6 +2122,17 @@ test("persona page ships settings card, master switch and i18n (beta v1)", () =>
2110
2122
  clientSource.includes("memory.features.personaEnabled.hint"),
2111
2123
  "the master-switch hint must document the two-gate semantics"
2112
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");
2113
2136
  });
2114
2137
 
2115
2138
  // v1.2 多卡回归:卡列表 CRUD 可见、字段标签渲染为可见元素(v1.1 反馈:
@@ -2187,3 +2210,50 @@ test("update-check status names match the backend classify contract verbatim", (
2187
2210
  assert.ok(apiSource.includes('path: "/api/dsh-mneme/version-check"'),
2188
2211
  "the backend must register the version-check route");
2189
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"));