@modusensus/dsh-mneme 0.9.3 → 0.9.5

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 (102) hide show
  1. package/README.md +56 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +10 -2
  4. package/docs/DAEMON.md +9 -2
  5. package/docs/DISTILL.md +66 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +1 -1
  8. package/docs/STORAGE.md +1 -1
  9. package/lib/api-standalone.js +286 -8
  10. package/lib/api.js +254 -3
  11. package/lib/client.js +1000 -50
  12. package/lib/config.js +50 -1
  13. package/lib/document.js +15 -2
  14. package/lib/dream/decisions.js +88 -3
  15. package/lib/dream/sleep.js +63 -13
  16. package/lib/dream.js +66 -10
  17. package/lib/index.js +6 -1
  18. package/lib/inject.js +10 -25
  19. package/lib/lang.js +48 -6
  20. package/lib/memory-source.js +9 -0
  21. package/lib/organize.js +9 -3
  22. package/lib/pipeline.js +62 -1
  23. package/lib/profile-extract.js +161 -0
  24. package/lib/provenance-http.js +126 -0
  25. package/lib/serve.js +13 -0
  26. package/lib/service.js +70 -6
  27. package/lib/settings.js +17 -13
  28. package/lib/share-http.js +281 -0
  29. package/lib/share-import.js +234 -0
  30. package/lib/share.js +335 -0
  31. package/lib/store.js +272 -11
  32. package/lib/summarize.js +118 -8
  33. package/lib/tools.js +240 -1
  34. package/lib/user-settings.js +78 -0
  35. package/package.json +1 -1
  36. package/src/api-standalone.js +286 -8
  37. package/src/api.js +254 -3
  38. package/src/config.js +50 -1
  39. package/src/document.js +15 -2
  40. package/src/dream/decisions.js +88 -3
  41. package/src/dream/sleep.js +63 -13
  42. package/src/dream.js +66 -10
  43. package/src/index.js +6 -1
  44. package/src/inject.js +10 -25
  45. package/src/lang.js +48 -6
  46. package/src/memory-source.js +9 -0
  47. package/src/organize.js +9 -3
  48. package/src/pipeline.js +62 -1
  49. package/src/profile-extract.js +161 -0
  50. package/src/provenance-http.js +126 -0
  51. package/src/serve.js +13 -0
  52. package/src/service.js +70 -6
  53. package/src/settings.js +17 -13
  54. package/src/share-http.js +281 -0
  55. package/src/share-import.js +234 -0
  56. package/src/share.js +335 -0
  57. package/src/store.js +272 -11
  58. package/src/summarize.js +118 -8
  59. package/src/tools.js +240 -1
  60. package/src/user-settings.js +78 -0
  61. package/test/api.test.js +384 -3
  62. package/test/audit.test.js +2 -2
  63. package/test/cli-receive.test.js +390 -0
  64. package/test/cli-share.test.js +437 -0
  65. package/test/client-share.test.js +225 -0
  66. package/test/client.test.js +215 -6
  67. package/test/dedupe-candidate-window.test.js +180 -0
  68. package/test/dream-candidate-hybrid.test.js +2 -2
  69. package/test/dream-fence.test.js +164 -0
  70. package/test/dream.test.js +87 -5
  71. package/test/helpers/dream-mock.js +6 -2
  72. package/test/lang.test.js +11 -0
  73. package/test/llm-message-source.test.js +1 -1
  74. package/test/mint-source.test.js +1 -1
  75. package/test/organize-tool.test.js +232 -0
  76. package/test/persona-addressing.test.js +83 -0
  77. package/test/persona-settings.test.js +30 -8
  78. package/test/pipeline-r1r2.test.js +129 -0
  79. package/test/policy-epoch.test.js +2 -2
  80. package/test/profile-extract.test.js +269 -0
  81. package/test/provenance-http.test.js +184 -0
  82. package/test/readme-claims.test.js +45 -0
  83. package/test/reasoning-effort.test.js +10 -10
  84. package/test/scope-candidates.test.js +7 -4
  85. package/test/scope-state.test.js +130 -1
  86. package/test/serve.test.js +62 -0
  87. package/test/share-foreign-rows.test.js +144 -0
  88. package/test/share-http.test.js +144 -0
  89. package/test/share-import.test.js +577 -0
  90. package/test/share-imported-guard.test.js +311 -0
  91. package/test/share-imported-identity.test.js +111 -0
  92. package/test/share-pack.test.js +511 -0
  93. package/test/share-receive-http.test.js +756 -0
  94. package/test/share-routes.test.js +337 -0
  95. package/test/share-store.test.js +133 -0
  96. package/test/share-validate.test.js +101 -0
  97. package/test/standalone-api.test.js +524 -5
  98. package/test/summarize-parse-diagnostics.test.js +75 -0
  99. package/test/summarize.test.js +240 -2
  100. package/test/tools-dto-schema-matrix.test.js +48 -5
  101. package/test/tools.test.js +6 -1
  102. package/test/user-settings-preview.test.js +127 -0
package/lib/client.js CHANGED
@@ -240,6 +240,42 @@ window.__ModuleLoader__.load({
240
240
  t("memory.explorer.scopeBadge"));
241
241
  };
242
242
 
243
+ /**
244
+ * 「标题 + 类型」两列的小清单。抽屉的整包退出清单与接收弹层的撞名清单是同一个形状
245
+ * (`{type,title}`),第三份手写 `.map` 就是第三处会各自漂移的地方——所以这里只有一份。
246
+ * 分享弹层的跳过清单**不走它**:那一列的第二格是 reason 标签,不是类型。
247
+ */
248
+ function TypeRowList({ t, rows }) {
249
+ const list = Array.isArray(rows) ? rows : [];
250
+ if (!list.length) return null;
251
+ return h("div", { className: "mneme-sharelist" },
252
+ list.map((row, i) => h("div", { key: `${row.type}-${i}`, className: "mneme-sharow" },
253
+ h("span", { className: "mneme-sharowtitle" }, row.title || ""),
254
+ h("span", { className: "mneme-sharowreason" }, typeLabel(t, row.type)))));
255
+ }
256
+
257
+ /**
258
+ * 外来行的来源徽标。别名不单独存列:`importedTagFor` 打的那条 `from-<别名>` 标签就是唯一读数,
259
+ * 所以这里只认标签,不猜别的来源。先判 `source === "imported"` 再判标签——
260
+ * 一个人手工给自己的一条打了 from-x 不算外来行,徽标会把它误报成别人的。
261
+ * 搜索态不出现这个徽标,且这是对的:那张 DTO 的 source 是召回渠道(src/service.js:939),
262
+ * 判据在那一侧读不出来。不许为了让搜索结果显示徽标而改成「认标签就算」——那会把
263
+ * 手工标签误报成外来行,也是 memory-source.js 那条契约的反面。
264
+ * from-shared 是空别名的中性值,照原样显示,不兜成「未知来源」。
265
+ */
266
+ const ForeignBadge = ({ m, t }) => {
267
+ // 判据先于标签:`isImported` 就是 src/memory-source.js 那条契约的读数,
268
+ // 手工打了 from-x 标签的本机行不算外来行。正向字面量也要留着——test/client-share.test.js
269
+ // 的锁按 `m.source === "imported"` 钉这条契约,写成反向判断会让那条锁无处可钉。
270
+ const isImported = !!m && m.source === "imported";
271
+ if (!isImported) return null;
272
+ const tags = Array.isArray(m.tags) ? m.tags : [];
273
+ const tag = tags.find((x) => typeof x === "string" && x.startsWith("from-"));
274
+ if (!tag) return null;
275
+ return h("span", { className: "mneme-badge mneme-badge--foreign", title: tag },
276
+ t("memory.explorer.foreignBadge").replace("{alias}", tag.slice(5)));
277
+ };
278
+
243
279
  // Unified API fetcher: attaches the optional apiToken (set in the settings
244
280
  // view, persisted in localStorage) as a Bearer header. When no token has
245
281
  // been configured the header is omitted and the API stays open (default).
@@ -277,10 +313,26 @@ window.__ModuleLoader__.load({
277
313
  "memory.settings.group.search": "搜索",
278
314
  "memory.settings.group.connect": "连接与安全",
279
315
  "memory.settings.group.help": "帮助",
316
+ "memory.settings.toc.label": "目录",
317
+ "memory.settings.toc.aria": "设置分组目录",
318
+ "memory.settings.toc.jump": "跳到「{name}」",
280
319
  "memory.settings.profile": "用户画像",
281
320
  "memory.settings.profileHint": "写一段自我介绍(角色、背景、偏好),Agent 每轮对话都会自动带上。",
282
321
  "memory.settings.profileSave": "保存画像",
283
322
  "memory.settings.profileSaved": "画像已保存",
323
+ "memory.settings.profileExtract": "从记忆提取草稿",
324
+ "memory.settings.profileExtracting": "正在提取…",
325
+ "memory.settings.profileExtractHint": "会花一次模型调用,只在你点这个按钮时发生;提取出来的是草稿,不采用就不进库,你手写的画像也不会被覆盖。",
326
+ "memory.settings.profileExtractDisabled": "没有提取:功能开关里的「画像提取草稿」还没打开。",
327
+ "memory.settings.profileExtractEmpty": "库里没有可用的记忆条目,这次没有能提炼的内容(空输入下硬写一段画像只会是编的)。",
328
+ "memory.settings.profileExtractNoRoute": "没有可用的模型:先在宿主里选一个默认模型(或在配置里填 dreamProvider / dreamModel),再点一次。",
329
+ "memory.settings.profileSaveFailed": "画像没存进去",
330
+ "memory.settings.profileExtractFailed": "提取没成:模型没按格式返回,或这次调用失败了。你现有的画像一个字都没被改动。",
331
+ "memory.settings.profileDraftTitle": "草稿(还没保存)",
332
+ "memory.settings.profileDraftRows": "依据记忆条数",
333
+ "memory.settings.profileDraftTruncated": "条目较多,这次只读了前面一部分",
334
+ "memory.settings.profileDraftApply": "采用为画像",
335
+ "memory.settings.profileDraftDiscard": "丢弃草稿",
284
336
  "memory.settings.persona": "人格定义(beta)",
285
337
  "memory.settings.personaHint": "给这位 Agent 一张角色卡:名字、性格、回复要求。开关打开且下方「人格定义」总闸开启时,每轮对话都会注入。",
286
338
  "memory.settings.personaName": "名字",
@@ -294,18 +346,29 @@ window.__ModuleLoader__.load({
294
346
  "memory.settings.personaSaveFailed": "保存失败",
295
347
  "memory.settings.personaEnabledToggle": "启用这张卡",
296
348
  "memory.settings.personaEnabledOn": "已启用——身份命中的会话会注入这张卡",
297
- "memory.settings.personaEnabledOff": "未启用——这个身份的会话将使用默认卡",
349
+ "memory.settings.personaEnabledOff": "未启用——默认口径下这个身份的会话会改用默认卡;开着「严格人格寻址」时则完全不注入人格",
298
350
  "memory.settings.personaDefaultCard": "默认卡(兜底)",
299
351
  "memory.settings.personaAdd": "添加卡",
300
352
  "memory.settings.personaAddPlaceholder": "输入 agent 身份名(如 nyx),按它寻址注入",
301
- "memory.settings.personaEditingDefault": "正在编辑:默认卡——没有命中任何身份卡的会话用它",
353
+ "memory.settings.personaEditingDefault": "正在编辑:默认卡——解析不到身份的会话一定用它;身份解析到了却没建卡(或卡被关掉)的会话,在默认口径下也落到这里,开着「严格人格寻址」则不注入人格",
302
354
  "memory.settings.personaEditingFor": "正在编辑身份卡:",
303
355
  "memory.settings.personaDeleteCard": "删除这张卡",
304
356
  "memory.settings.personaView": "Agent 眼中的你(注入生效文本)",
305
- "memory.settings.personaViewHint": "以下是每一轮实际注入的用户画像与规则,只读。",
306
- "memory.settings.personaViewEmpty": "画像与规则均为空——Agent 每轮只会看到记忆库。",
357
+ "memory.settings.personaViewHint": "下面是这一轮真正会送进模型的整段文本:人格 → 画像 → 规则(后写的覆盖前写的,规则最硬)。文本由插件渲染,前端只显示。",
358
+ "memory.settings.personaViewEmpty": "三项都是空的——Agent 每轮只会看到记忆库。",
359
+ "memory.settings.personaViewUnknown": "读不到注入文本(预览请求失败或后端版本过旧)。这里不显示猜测的内容。",
360
+ "memory.settings.personaViewSourceDefault": "当前按「解析不到会话身份」的会话预览,取默认卡。",
361
+ "memory.settings.personaViewSourceFallback": "这个身份解析到了,但它没有可用的卡(没建或已关掉),所以落回默认卡——默认口径下这正是「关掉这张卡」不生效的原因;打开「严格人格寻址」后这种情况改为不注入人格。",
362
+ "memory.settings.personaViewSourceCard": "当前按你正在编辑的这张身份卡预览。",
363
+ "memory.settings.personaViewSourceNone": "按当前寻址口径,这个身份不会注入任何人格——默认卡只兜「解析不到会话身份」的会话。",
364
+ "memory.settings.personaViewGateOff": "总闸「人格定义(beta)」现在是关的:下面这段里不会出现人格,只有画像与规则。",
365
+ "memory.settings.personaViewRefresh": "刷新预览",
307
366
  "memory.features.personaEnabled": "人格定义(beta)",
308
367
  "memory.features.personaEnabled.hint": "把「设置 → 个性化」里的人格卡注入到每轮对话。默认关(beta);总闸与人格卡内的开关同时打开才生效。",
368
+ "memory.features.personaStrictAddressing": "严格人格寻址",
369
+ "memory.features.personaStrictAddressing.hint": "开:会话身份没有对应人格卡、或那张卡被关掉时,就不注入人格——默认卡只兜「解析不到会话身份」的会话。宿主里跑着自带身份的 bot(DS Bot 那类)时关掉这种兜底,免得两句「你是谁」撞在同一轮。默认关 = 未命中就落默认卡(历史行为)。",
370
+ "memory.features.profileExtractEnabled": "画像提取草稿",
371
+ "memory.features.profileExtractEnabled.hint": "打开「设置 → 个性化 → 用户画像」里的「从记忆提取草稿」按钮:读一批活跃记忆、花一次模型调用,产一段「助手眼中的用户」草稿。草稿要你点「采用」才会写进画像,绝不自动覆盖你手写的原文。轻量档预设关(用户显式勾选仍然赢)。",
309
372
  "memory.settings.rules": "规则",
310
373
  "memory.settings.rulesHint": "给 Agent 立下必须遵守的规矩,随时增删,下一轮即生效。",
311
374
  "memory.settings.ruleAdd": "添加规则",
@@ -430,6 +493,11 @@ window.__ModuleLoader__.load({
430
493
  "memory.settings.mode.standard": "标准",
431
494
  "memory.settings.mode.savedHint": "已保存,重启 DSH 后生效",
432
495
  "memory.settings.mode.offList": "已关闭:巩固(autoDream)· 实体抽取 · 语义搜索",
496
+ // #514:用户把宿主「极简(minimal)」与插件「轻量」混为一谈,以为关掉人格是
497
+ // 轻量模式干的。这个词条把两件事分开说清楚,不再让用户自己猜。
498
+ "memory.settings.mode.hostPresetHint": "注意区分:这里的「轻量 / 标准」是插件的记忆引擎档位,与 DSH 宿主的「极简模式(minimal 预设)」是两回事——轻量档不会关闭人格定义。",
499
+ "memory.status.minimalPresetTitle": "当前会话用的是宿主的「极简模式」",
500
+ "memory.status.minimalPresetBody": "极简预设(minimal)把 system prompt 整体接管了,记忆、用户画像、人格定义都不会送达模型。这是宿主的设计边界,插件改不了。想要它们生效:把 DSH 切到标准模式(或在 ~/.dsh/settings.yaml 设 agent-presets.default: standard);过渡方案是把画像与规则写进 AGENTS.md——那条走指令路径,不受这个压制。",
433
501
  "memory.settings.extapi.title": "外部访问 API",
434
502
  "memory.settings.extapi.desc": "开放一个本机接口,让其他工具(插件、命令行、桌面应用)也能读写这份记忆库,默认只允许本机连接。改动保存后重启 DSH 生效。",
435
503
  "memory.settings.extapi.enabled": "启用",
@@ -729,6 +797,61 @@ window.__ModuleLoader__.load({
729
797
  "memory.explorer.imported": "已合并 {n} 条编辑",
730
798
  "memory.explorer.importFailed": "导入失败",
731
799
  "memory.explorer.importNoFile": "请先选择文件",
800
+ "memory.explorer.sharePack": "分享记忆包…",
801
+ "memory.explorer.shareTitle": "分享记忆包",
802
+ "memory.explorer.shareHint": "把本机活跃记忆打成一个可外发的包:密钥/PII 整条不发,本机路径抹成 ~,归档与遗忘的不进。",
803
+ "memory.explorer.shareAlias": "分享别名",
804
+ "memory.explorer.shareAliasBlank": "留空 = from-shared",
805
+ "memory.explorer.shareType": "只发这一类",
806
+ "memory.explorer.shareBuild": "生成",
807
+ "memory.explorer.shareBuilding": "生成中…",
808
+ "memory.explorer.shareFailed": "生成失败",
809
+ "memory.explorer.shareIncluded": "进包 {n} 条",
810
+ "memory.explorer.shareDeduped": "另有 {n} 条同键折叠,够格进包 {m} 条",
811
+ "memory.explorer.shareSkipped": "跳过 {n} 条",
812
+ "memory.explorer.share.skip.archived": "归档",
813
+ "memory.explorer.share.skip.forgotten": "已遗忘",
814
+ "memory.explorer.share.skip.readonly_type": "只读指针行",
815
+ "memory.explorer.share.skip.excluded_type": "不外发的类型",
816
+ "memory.explorer.share.skip.blank_or_noise": "空白或噪声",
817
+ "memory.explorer.share.skip.sensitive": "密钥或 PII",
818
+ "memory.explorer.shareSkipList": "跳过清单({n} 条)",
819
+ "memory.explorer.shareSkipListCapped": "清单只列前 {n} 条,上面计数是全量",
820
+ "memory.explorer.shareNoTitle": "(标题不外发)",
821
+ "memory.explorer.shareTooLarge": "包体 {bytes} 字节,超过 {limit} 字节水位",
822
+ "memory.explorer.shareEmpty": "没有够格外发的条目,包是空的",
823
+ "memory.explorer.shareDownload": "下载包文件",
824
+ "memory.explorer.receivePack": "导入分享包…",
825
+ "memory.explorer.receiveTitle": "导入分享包",
826
+ "memory.explorer.receiveHint": "选她发来的包文件。先干跑预览,点确认才写库;导入的行进外来区,不参与本机的巩固、降级或整理。",
827
+ "memory.explorer.receivePick": "选择包文件…",
828
+ "memory.explorer.receiveNotJson": "这个文件不是 JSON",
829
+ "memory.explorer.receiveNotPack": "这个文件不是分享包(顶层应该是一个 JSON 对象)",
830
+ "memory.explorer.receivePreview": "先干跑预览",
831
+ "memory.explorer.receiveConfirm": "确认导入",
832
+ "memory.explorer.receiveFailed": "导入失败",
833
+ "memory.explorer.receiveFrom": "来自 {alias}",
834
+ "memory.explorer.receiveAnonymous": "未署名",
835
+ "memory.explorer.receiveWouldCreate": "将新建 {n} 条",
836
+ "memory.explorer.receiveDidCreate": "已新建 {n} 条",
837
+ "memory.explorer.receiveExisting": "{n} 条已在库中",
838
+ "memory.explorer.receiveUnchanged": "{n} 条正文相同",
839
+ "memory.explorer.receiveConflicts": "撞名 {n} 条(不合并,请自己看)",
840
+ "memory.explorer.receivePackCount": "包内 {n} 条",
841
+ "memory.explorer.receiveStillDry": "没有写库:这一枪仍是干跑",
842
+ "memory.explorer.filterForeign": "外来",
843
+ "memory.explorer.filterForeignHint": "只看别人分享进来的记忆(source=imported)。这些行不参与本机的巩固、降级或整理。",
844
+ "memory.explorer.filterForeignSearchHint": "搜索时这一档不按:搜索结果里没有可用的来源读数。要看外来区请先清掉搜索词。",
845
+ "memory.explorer.foreignBadge": "来自 {alias}",
846
+ "memory.explorer.foreignPack": "所属分享包",
847
+ "memory.explorer.undoPack": "收起这个包",
848
+ "memory.explorer.undoPackPreview": "将删除这个包的 {n} 条外来记忆(物理删除)",
849
+ "memory.explorer.undoPackConfirm": "确认删除 {n} 条",
850
+ "memory.explorer.undoPackDone": "已收起 {n} 条",
851
+ "memory.explorer.undoPackFailed": "收起失败",
852
+ "memory.explorer.undoPackBusy": "收起中…",
853
+ "memory.explorer.undoPackStillDry": "没有删除:这一枪仍是干跑",
854
+ "memory.explorer.undoPackGone": "这个包在本库里已经没有行了",
732
855
  "memory.explorer.newMemory": "新增记忆…",
733
856
  "memory.explorer.newTitle": "新增记忆",
734
857
  "memory.explorer.newHint": "手动写一条记忆。同类型同标题会并入既有条目,而不是长出第二行。",
@@ -778,6 +901,10 @@ window.__ModuleLoader__.load({
778
901
  "memory.status.dreamNever": "尚未运行",
779
902
  "memory.status.conflicts": "待确认冲突",
780
903
  "memory.status.conflictsHint": "冻结的矛盾记忆,等待人工确认",
904
+ "memory.status.skippedTitle": "本轮跳过 {n} 条决定",
905
+ "memory.status.skippedHint": "这些决定被校验器拦下,未执行。同类原因已合并计数——出现一整类动作全部被拦,通常意味着提示词与护栏没对齐,值得看一眼。",
906
+ "memory.status.skippedMore": "另有 {n} 类",
907
+ "memory.status.skippedNone": "本轮没有跳过任何决定",
781
908
  "memory.status.injectSuppressed": "极简模式下注入按宿主设计关闭",
782
909
  "memory.status.injectSuppressedHint": "当前会话使用 minimal 预设:记忆注入 / 用户画像 / 热记忆不会送达模型(宿主设计,非插件缺陷)。切到标准模式,或在 ~/.dsh/settings.yaml 设 agent-presets.default: standard;过渡方案:把画像与规则写进 AGENTS.md",
783
910
  "memory.status.injectPreview": "注入预览",
@@ -971,10 +1098,26 @@ window.__ModuleLoader__.load({
971
1098
  "memory.settings.group.search": "Search",
972
1099
  "memory.settings.group.connect": "Connections & safety",
973
1100
  "memory.settings.group.help": "Help",
1101
+ "memory.settings.toc.label": "Contents",
1102
+ "memory.settings.toc.aria": "Settings section index",
1103
+ "memory.settings.toc.jump": "Jump to {name}",
974
1104
  "memory.settings.profile": "User Profile",
975
1105
  "memory.settings.profileHint": "Describe yourself once — the agent reads it every turn.",
976
1106
  "memory.settings.profileSave": "Save Profile",
977
1107
  "memory.settings.profileSaved": "Profile saved",
1108
+ "memory.settings.profileExtract": "Draft from memory",
1109
+ "memory.settings.profileExtracting": "Extracting…",
1110
+ "memory.settings.profileExtractHint": "Costs one model call, and only when you press this button. The result is a draft: nothing is stored until you apply it, and your hand-written profile is never overwritten.",
1111
+ "memory.settings.profileExtractDisabled": "Nothing extracted: the “Profile draft from memory” feature switch is still off.",
1112
+ "memory.settings.profileExtractEmpty": "No usable memory entries in the store, so there was nothing to draft from (writing a profile out of an empty input would only be invention).",
1113
+ "memory.settings.profileExtractNoRoute": "No model available: pick a default model in the host (or set dreamProvider / dreamModel), then try again.",
1114
+ "memory.settings.profileSaveFailed": "Profile was not saved",
1115
+ "memory.settings.profileExtractFailed": "Extraction failed: the model did not return the required shape, or the call errored. Your existing profile is untouched.",
1116
+ "memory.settings.profileDraftTitle": "Draft (not saved)",
1117
+ "memory.settings.profileDraftRows": "Based on entries",
1118
+ "memory.settings.profileDraftTruncated": "many entries — only the first batch was read",
1119
+ "memory.settings.profileDraftApply": "Apply as profile",
1120
+ "memory.settings.profileDraftDiscard": "Discard draft",
978
1121
  "memory.settings.persona": "Persona (beta)",
979
1122
  "memory.settings.personaHint": "Give this agent a character card: name, personality, reply style. Injected every turn when the toggle below and the \"Persona\" master switch are both on.",
980
1123
  "memory.settings.personaName": "Name",
@@ -988,18 +1131,29 @@ window.__ModuleLoader__.load({
988
1131
  "memory.settings.personaSaveFailed": "Save failed",
989
1132
  "memory.settings.personaEnabledToggle": "Enable this card",
990
1133
  "memory.settings.personaEnabledOn": "On — sessions matching this identity get this card",
991
- "memory.settings.personaEnabledOff": "Off — sessions with this identity fall back to the default card",
1134
+ "memory.settings.personaEnabledOff": "Off — by default such sessions fall back to the default card; with strict persona addressing they get no persona at all",
992
1135
  "memory.settings.personaDefaultCard": "Default (fallback)",
993
1136
  "memory.settings.personaAdd": "Add card",
994
1137
  "memory.settings.personaAddPlaceholder": "Agent identity (e.g. nyx) used to address injection",
995
- "memory.settings.personaEditingDefault": "Editing: default card — used by sessions matching no identity card",
1138
+ "memory.settings.personaEditingDefault": "Editing: default card — always used by sessions with no resolvable identity; a session whose identity has no card (or whose card is off) also lands here under the default mode, while strict persona addressing injects nothing instead",
996
1139
  "memory.settings.personaEditingFor": "Editing identity card:",
997
1140
  "memory.settings.personaDeleteCard": "Delete this card",
998
1141
  "memory.settings.personaView": "You, as the agent sees you (injected text)",
999
- "memory.settings.personaViewHint": "The profile and rules actually injected every turn. Read-only.",
1000
- "memory.settings.personaViewEmpty": "Profile and rules are both empty — the agent only sees the memory store.",
1142
+ "memory.settings.personaViewHint": "This is the exact block handed to the model this turn: persona → profile → rules (later lines override earlier ones; rules are hardest). The plugin renders it; the panel only displays it.",
1143
+ "memory.settings.personaViewEmpty": "All three are empty — the agent only sees the memory store.",
1144
+ "memory.settings.personaViewUnknown": "Could not read the injected text (preview request failed or backend too old). Nothing is guessed here.",
1145
+ "memory.settings.personaViewSourceDefault": "Preview for sessions whose identity cannot be resolved — the default card applies.",
1146
+ "memory.settings.personaViewSourceFallback": "This identity resolved, but it has no usable card (never created, or switched off), so the default card applies — which is exactly why turning this card off changes nothing under the default mode. With strict persona addressing on, such a session gets no persona at all.",
1147
+ "memory.settings.personaViewSourceCard": "Preview for the identity card you are editing.",
1148
+ "memory.settings.personaViewSourceNone": "Under the current addressing mode this identity injects no persona at all — the default card only covers sessions with no resolvable identity.",
1149
+ "memory.settings.personaViewGateOff": "The master switch “Persona (beta)” is off: the block below contains profile and rules only.",
1150
+ "memory.settings.personaViewRefresh": "Refresh preview",
1001
1151
  "memory.features.personaEnabled": "Persona (beta)",
1002
1152
  "memory.features.personaEnabled.hint": "Inject the persona card (Settings → Personalization) into every turn. Off by default (beta); both this switch and the card's own toggle must be on.",
1153
+ "memory.features.personaStrictAddressing": "Strict persona addressing",
1154
+ "memory.features.personaStrictAddressing.hint": "On: when a session's identity has no persona card — or that card is switched off — no persona is injected, so the default card only covers sessions with no resolvable identity. Turn this on when the host runs a bot that brings its own identity (DS Bot and friends), otherwise two \"you are ...\" claims land in the same turn. Off (default): unresolved identities fall back to the default card, as before.",
1155
+ "memory.features.profileExtractEnabled": "Profile draft from memory",
1156
+ "memory.features.profileExtractEnabled.hint": "Adds the \"Draft from memory\" button to Settings → Personalization → profile: reads a batch of active memories, spends one model call, and returns an \"as the assistant sees them\" draft. Nothing is written until you press Apply, so your hand-written profile is never overwritten automatically. Off in the lightweight preset (an explicit choice still wins).",
1003
1157
  "memory.settings.rules": "Rules",
1004
1158
  "memory.settings.rulesHint": "Rules the agent must follow — add or remove anytime, effective next turn.",
1005
1159
  "memory.settings.ruleAdd": "Add Rule",
@@ -1120,6 +1274,9 @@ window.__ModuleLoader__.load({
1120
1274
  "memory.settings.mode.standard": "Standard",
1121
1275
  "memory.settings.mode.savedHint": "Saved. Takes effect after restarting DSH",
1122
1276
  "memory.settings.mode.offList": "Off: consolidation (autoDream) · entity extraction · semantic search",
1277
+ "memory.settings.mode.hostPresetHint": "Two different things: Light/Standard here is this plugin's memory engine tier; the host's minimal preset is separate — Light never disables persona.",
1278
+ "memory.status.minimalPresetTitle": "This session runs the host's minimal preset",
1279
+ "memory.status.minimalPresetBody": "The minimal preset takes over the whole system prompt, so memory, profile and persona never reach the model. That is a host design boundary the plugin cannot change. To get them back: switch DSH to standard mode (or set agent-presets.default: standard in ~/.dsh/settings.yaml); as an interim workaround put profile and rules in AGENTS.md — that path is not suppressed.",
1123
1280
  "memory.settings.extapi.title": "External API",
1124
1281
  "memory.settings.extapi.desc": "Opens a local API so other tools (plugins, CLIs, desktop apps) can also read and write this memory store; binds to localhost only by default. Changes take effect after restarting DSH.",
1125
1282
  "memory.settings.extapi.enabled": "Enable",
@@ -1402,6 +1559,61 @@ window.__ModuleLoader__.load({
1402
1559
  "memory.explorer.imported": "Merged {n} edits",
1403
1560
  "memory.explorer.importFailed": "Import failed",
1404
1561
  "memory.explorer.importNoFile": "Choose a file first",
1562
+ "memory.explorer.sharePack": "Share memories…",
1563
+ "memory.explorer.shareTitle": "Memory share pack",
1564
+ "memory.explorer.shareHint": "Pack your active memories for someone else: rows holding secrets are dropped whole, home paths become ~, archived and forgotten rows stay out.",
1565
+ "memory.explorer.shareAlias": "Share alias",
1566
+ "memory.explorer.shareAliasBlank": "blank = from-shared",
1567
+ "memory.explorer.shareType": "Only this type",
1568
+ "memory.explorer.shareBuild": "Build",
1569
+ "memory.explorer.shareBuilding": "Building…",
1570
+ "memory.explorer.shareFailed": "Could not build the pack",
1571
+ "memory.explorer.shareIncluded": "{n} rows in the pack",
1572
+ "memory.explorer.shareDeduped": "{n} folded as duplicates; {m} rows qualified",
1573
+ "memory.explorer.shareSkipped": "{n} rows skipped",
1574
+ "memory.explorer.share.skip.archived": "Archived",
1575
+ "memory.explorer.share.skip.forgotten": "Forgotten",
1576
+ "memory.explorer.share.skip.readonly_type": "Read-only pointers",
1577
+ "memory.explorer.share.skip.excluded_type": "Excluded types",
1578
+ "memory.explorer.share.skip.blank_or_noise": "Blank or noise",
1579
+ "memory.explorer.share.skip.sensitive": "Secrets or PII",
1580
+ "memory.explorer.shareSkipList": "Skip list ({n})",
1581
+ "memory.explorer.shareSkipListCapped": "List shows the first {n}; the counts above are complete",
1582
+ "memory.explorer.shareNoTitle": "(title withheld)",
1583
+ "memory.explorer.shareTooLarge": "Pack is {bytes} bytes, over the {limit}-byte water mark",
1584
+ "memory.explorer.shareEmpty": "Nothing qualified - the pack is empty",
1585
+ "memory.explorer.shareDownload": "Download pack file",
1586
+ "memory.explorer.receivePack": "Import a share pack…",
1587
+ "memory.explorer.receiveTitle": "Import a share pack",
1588
+ "memory.explorer.receiveHint": "Pick the file she sent. The first pass is a dry run; nothing is written until Confirm. Imported rows join the foreign area and never take part in this machine's consolidation, demotion or tidying.",
1589
+ "memory.explorer.receivePick": "Choose pack file…",
1590
+ "memory.explorer.receiveNotJson": "That file is not JSON",
1591
+ "memory.explorer.receiveNotPack": "That file is not a share pack (the top level should be one JSON object)",
1592
+ "memory.explorer.receivePreview": "Preview (dry run)",
1593
+ "memory.explorer.receiveConfirm": "Confirm import",
1594
+ "memory.explorer.receiveFailed": "Import failed",
1595
+ "memory.explorer.receiveFrom": "From {alias}",
1596
+ "memory.explorer.receiveAnonymous": "unnamed",
1597
+ "memory.explorer.receiveWouldCreate": "Would create {n}",
1598
+ "memory.explorer.receiveDidCreate": "Created {n}",
1599
+ "memory.explorer.receiveExisting": "{n} already in the store",
1600
+ "memory.explorer.receiveUnchanged": "{n} identical already",
1601
+ "memory.explorer.receiveConflicts": "{n} title clashes (not merged - check them yourself)",
1602
+ "memory.explorer.receivePackCount": "{n} rows in the pack",
1603
+ "memory.explorer.receiveStillDry": "Nothing was written: that call stayed dry",
1604
+ "memory.explorer.filterForeign": "Foreign",
1605
+ "memory.explorer.filterForeignHint": "Only rows shared in by someone else (source=imported). They never join this machine's consolidation, demotion or tidying.",
1606
+ "memory.explorer.filterForeignSearchHint": "Not applied while searching: search results carry no usable provenance readout. Clear the query to browse the foreign area.",
1607
+ "memory.explorer.foreignBadge": "From {alias}",
1608
+ "memory.explorer.foreignPack": "Share pack",
1609
+ "memory.explorer.undoPack": "Withdraw this pack",
1610
+ "memory.explorer.undoPackPreview": "This would delete {n} foreign rows from that pack (hard delete)",
1611
+ "memory.explorer.undoPackConfirm": "Delete {n} rows",
1612
+ "memory.explorer.undoPackDone": "Withdrew {n} rows",
1613
+ "memory.explorer.undoPackFailed": "Withdrawal failed",
1614
+ "memory.explorer.undoPackBusy": "Withdrawing…",
1615
+ "memory.explorer.undoPackStillDry": "Nothing was deleted: that call stayed dry",
1616
+ "memory.explorer.undoPackGone": "That pack has no rows left in this store",
1405
1617
  "memory.explorer.newMemory": "New memory…",
1406
1618
  "memory.explorer.newTitle": "New memory",
1407
1619
  "memory.explorer.newHint": "Write one memory by hand. Same type + same title merges into the existing entry instead of minting a second row.",
@@ -1451,6 +1663,10 @@ window.__ModuleLoader__.load({
1451
1663
  "memory.status.dreamNever": "Not yet run",
1452
1664
  "memory.status.conflicts": "Pending conflicts",
1453
1665
  "memory.status.conflictsHint": "Frozen contradictory memories awaiting confirmation",
1666
+ "memory.status.skippedTitle": "{n} decisions skipped this run",
1667
+ "memory.status.skippedHint": "These decisions were blocked by the validator and not applied. Identical reasons are counted together — a whole action type being blocked usually means the prompt and the guard disagree, which is worth a look.",
1668
+ "memory.status.skippedMore": "{n} more kinds",
1669
+ "memory.status.skippedNone": "No decisions were skipped this run",
1454
1670
  "memory.status.injectSuppressed": "Injection disabled by host minimal preset",
1455
1671
  "memory.status.injectSuppressedHint": "This session uses the minimal preset: memory injection / profile / hot memory never reach the model (by host design, not a plugin defect). Switch to standard mode, or set agent-presets.default: standard in ~/.dsh/settings.yaml. Interim: put profile and rules in AGENTS.md",
1456
1672
  "memory.status.injectPreview": "Injection preview",
@@ -2056,6 +2272,22 @@ window.__ModuleLoader__.load({
2056
2272
  ".mneme-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
2057
2273
  ".mneme-impdialog{position:absolute;right:0;top:calc(100% + 6px);width:300px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);box-shadow:0 12px 32px color-mix(in srgb,var(--dsw-alias-label-primary) 16%,transparent);padding:14px;z-index:20;animation:mneme-fadein .1s ease-out}",
2058
2274
  ".mneme-improw{display:flex;align-items:center;gap:8px;margin-top:10px}",
2275
+ // 分享/接收弹层:宽度与滚动共用 impdialog 的锚,正文一律 --mneme-* 语义层
2276
+ // (纪律 10:宿主 label-tertiary/label-dimmed 是分隔线层,不是正文色)。
2277
+ ".mneme-sharedialog{width:340px;max-height:min(72vh,560px);overflow-y:auto}",
2278
+ ".mneme-sharebody{margin-top:10px;display:flex;flex-direction:column;gap:2px}",
2279
+ ".mneme-sharebreak{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}",
2280
+ ".mneme-sharebreakitem{font-size:11px;line-height:15px;color:var(--mneme-fg-dim);font-variant-numeric:tabular-nums}",
2281
+ ".mneme-sharelist{margin-top:4px;max-height:132px;overflow-y:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px}",
2282
+ ".mneme-sharow{display:flex;justify-content:space-between;gap:8px;font-size:11.5px;line-height:16px}",
2283
+ ".mneme-sharowtitle{color:var(--dsw-alias-label-primary);min-width:0;word-break:break-word}",
2284
+ ".mneme-sharowreason{flex:none;color:var(--mneme-fg-dim)}",
2285
+ // 外来徽标:只有文字色与描边,**不给底色**。`.mneme-badge--scope` / `--frozen` 那两类
2286
+ // 有自己的 10–12% 色晕,而 `test/contrast.test.js` 的正文对比度是按**该条规则自己的
2287
+ // 背景**合成的:`--mneme-ok` 在画布上是 4.62,铺上 12% 绿晕就掉到 4.17(<4.5)。
2288
+ // 与其为它造一个新语义令牌或改用别的色,不如让这一颗走无晕的形状——来源读数本来就在
2289
+ // 文字里(`from-<别名>`),不靠底色说话。
2290
+ ".mneme-badge--foreign{color:var(--mneme-ok);border:1px solid color-mix(in srgb,var(--mneme-ok) 34%,transparent)}",
2059
2291
  // 瞬时操作提示(删除/归档):挂在浏览层底部居中,不被抽屉卸载吞掉
2060
2292
  ".mneme-x{position:relative}",
2061
2293
  ".mneme-toast{position:absolute;left:50%;bottom:20px;transform:translateX(-50%);background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);font-size:12.5px;line-height:18px;padding:7px 16px;border-radius:10px;z-index:30;box-shadow:0 8px 24px color-mix(in srgb,var(--dsw-alias-label-primary) 25%,transparent);animation:mneme-fadein .15s ease-out}",
@@ -2190,9 +2422,20 @@ window.__ModuleLoader__.load({
2190
2422
  // self-contained unit inside the stacked settings view ---
2191
2423
  ".mneme-set-card{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;margin-bottom:10px}",
2192
2424
  // 设置页分组标题(个性化 / 记忆引擎 / 搜索 / 连接与安全 / 帮助):
2193
- // 先给「这一段在管什么」,卡片才是具体选项
2194
- ".mneme-set-grouphead{font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--mneme-fg-dim);margin:24px 0 10px}",
2195
- ".mneme-set-grouphead:first-child{margin-top:8px}",
2425
+ // 先给「这一段在管什么」,卡片才是具体选项;scroll-margin-top 是给 #518 的粘顶目录(实测约 74px 高)留的跳转余量
2426
+ // ——没有它,点 chip 滚过去的标题就停在目录背面
2427
+ ".mneme-set-grouphead{font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--mneme-fg-dim);margin:24px 0 10px;scroll-margin-top:84px}",
2428
+ ".mneme-set-toc+.mneme-set-grouphead{margin-top:8px}", // nav 才是第一个孩子::first-child 自 #518 起永不命中
2429
+ // 设置页目录(#518):分组标题 + 一栏跳转 chip。粘顶是因为设置页是整块
2430
+ // 滚动的(.mneme-set 自己 overflow-y),长页里目录滚走了就等于没有——用户
2431
+ // 提的诉求正是「方便直接跳转」,跳不回去的目录不算数。
2432
+ // 窄屏收成横向滚动:换行会让粘顶条吃掉半屏高度,而设置页本身已经很挤。
2433
+ ".mneme-set-toc{position:sticky;top:0;z-index:2;background:var(--dsw-alias-bg-base,var(--dsw-alias-bg-layer-1));padding:8px 0 10px;margin-bottom:4px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
2434
+ ".mneme-set-toclabel{font-size:11px;font-weight:600;letter-spacing:.02em;color:var(--mneme-fg-dim);margin-bottom:6px}",
2435
+ ".mneme-set-toclist{display:flex;gap:6px;overflow-x:auto;padding:3px}", // 不 wrap:chip flex:none,窄屏横向滚;3px 给 focus ring 留位(滚动容器会裁掉描边)
2436
+ ".mneme-set-tocchip{flex:none;height:26px;padding:0 10px;border-radius:13px;border:1px solid var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:12px;line-height:24px;transition:border-color .12s,color .12s}",
2437
+ ".mneme-set-tocchip:hover{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary)}",
2438
+ ".mneme-set-tocchip:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}",
2196
2439
  ".mneme-set-token{font-family:monospace;font-size:12px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);word-break:break-all;user-select:all}",
2197
2440
  ".mneme-set-hint{font-size:12px;color:var(--mneme-fg-dim)}",
2198
2441
  // --- 重要性编辑行:与查看态同一份 Lucide 星形(此前是文本星号下拉,角更尖锐且与其他星不统一) ---
@@ -2963,7 +3206,8 @@ window.__ModuleLoader__.load({
2963
3206
  { key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled",
2964
3207
  // 人格定义页(beta):注入总闸。分组随 enhance(行为开关,非座位闸门);
2965
3208
  // 人格内容在设置页「个性化」区的人格卡。
2966
- "personaEnabled"] },
3209
+ // 严格寻址是同一条链上的第二个口径开关(只在总闸打开时起作用)。
3210
+ "personaEnabled", "personaStrictAddressing", "profileExtractEnabled"] },
2967
3211
  { key: "group.dream", items: ["sleepModeEnabled"] },
2968
3212
  // v0.8.0 A4(issue #17):作用域隔离组——标注总开关 + 严格硬过滤。
2969
3213
  { key: "group.scope", items: ["scopeEnabled", "strictScope"] }
@@ -3497,6 +3741,31 @@ window.__ModuleLoader__.load({
3497
3741
  return "Unknown";
3498
3742
  };
3499
3743
 
3744
+ // #182 / #514 注入状态探测:一处取数,两处消费(状态页告示卡、设置页提示)。
3745
+ // 分成两份读同一接口会让两处的「已知/未知」判断各自漂移——#514 里用户就是在
3746
+ // 设置页开关人格,却只在状态页才看得到原因。普通函数声明(会提升),这里紧
3747
+ // 挨着设置页放,是为了让源码顺序和「先谁用」对得上。
3748
+ function useInjectStatus() {
3749
+ const [state, setState] = useState({ loading: true, suppressed: false, agentPreset: null });
3750
+ useEffect(() => {
3751
+ let cancelled = false;
3752
+ apiFetch("/api/dsh-mneme/inject-status")
3753
+ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
3754
+ .then((j) => {
3755
+ if (!cancelled) {
3756
+ setState({
3757
+ loading: false,
3758
+ suppressed: !!(j && j.suppressed),
3759
+ agentPreset: (j && j.agentPreset) || null
3760
+ });
3761
+ }
3762
+ })
3763
+ .catch(() => { if (!cancelled) setState({ loading: false, suppressed: false, agentPreset: null }); });
3764
+ return () => { cancelled = true; };
3765
+ }, []);
3766
+ return state;
3767
+ }
3768
+
3500
3769
  function SettingsContent({ t }) {
3501
3770
  const [profile, setProfile] = react.useState("");
3502
3771
  const [rules, setRules] = react.useState([]);
@@ -3504,6 +3773,12 @@ window.__ModuleLoader__.load({
3504
3773
  const [newRule, setNewRule] = react.useState("");
3505
3774
  const [newCmd, setNewCmd] = react.useState({ name: "", description: "", instruction: "" });
3506
3775
  const [saved, setSaved] = react.useState(false);
3776
+ // 画像提取草稿(#497):null = 没有结果;否则带 kind 的一次性结果,四种结局各一句
3777
+ // 话——把「没启用」「库里没东西」「模型没配合」压成同一句「没有草稿」,用户就只能瞎猜。
3778
+ const [profileDraft, setProfileDraft] = react.useState(null);
3779
+ const [profileExtracting, setProfileExtracting] = react.useState(false);
3780
+ // 保存失败要可见(persona 那条一直有,画像这条以前是静默 catch)。
3781
+ const [profileError, setProfileError] = react.useState("");
3507
3782
  const [cmdError, setCmdError] = react.useState("");
3508
3783
  // 人格卡(beta,v1.2 多卡):default 卡草稿 + byAgent 卡列表 + 当前编辑位;
3509
3784
  // provenance 是 v1.5 协作式人格的预留位,v1 只透传不编辑。
@@ -3513,6 +3788,15 @@ window.__ModuleLoader__.load({
3513
3788
  const [personaNewName, setPersonaNewName] = react.useState("");
3514
3789
  const [personaSaved, setPersonaSaved] = react.useState(false);
3515
3790
  const [personaError, setPersonaError] = react.useState("");
3791
+ // #514:人是在这一页开关人格的,所以「为什么关了没反应」的答案必须在
3792
+ // 这一页。与状态页告示卡共用 useInjectStatus,两边口径不会各自漂。
3793
+ const injectStatus = useInjectStatus();
3794
+ const minimalWarn = !injectStatus.loading && injectStatus.suppressed;
3795
+ // 注入预览:文本由后端渲染(GET /persona 的 preview 字段),前端只显示、不自己拼。
3796
+ // 原先这里是一段前端手拼的 profile + rules 列表,注释却写着「注入生效文本」——
3797
+ // 它既没有表头、没有「- 用户画像:」前缀,也完全没有人格三行,两份渲染代码必然漂。
3798
+ // null = 还没读到或读失败(fail-closed:显示「未知」而不是「空」,也不显示旧值)。
3799
+ const [injectedPreview, setInjectedPreview] = react.useState(null);
3516
3800
  const [vector, setVector] = react.useState({ enabled: false, baseUrl: "", apiKey: "", model: "" });
3517
3801
  const [vectorSaved, setVectorSaved] = react.useState(false);
3518
3802
  const [reindexing, setReindexing] = react.useState(false);
@@ -3603,11 +3887,34 @@ window.__ModuleLoader__.load({
3603
3887
  setPersonaDefault(norm(dRaw));
3604
3888
  setPersonaByAgent(Object.fromEntries(Object.entries(bRaw).map(([k, c]) => [k, norm(c)])));
3605
3889
  setPersonaEditing("default");
3890
+ // 预览不在这里写:它只有一个所有者(loadPreview)。两处写同一个 state 就会
3891
+ // 互相覆盖,而这张卡的标题是「注入生效文本」——显示别张卡的文本比不显示更糟。
3606
3892
  } catch { /* ignore */ }
3607
3893
  }, []);
3608
3894
 
3609
3895
  react.useEffect(() => { load(); }, [load]);
3610
3896
 
3897
+ // 预览请求:切卡与点「刷新预览」时各一次。刻意不挂在卡的数据上——卡内改动是
3898
+ // 逐字自动保存的,那样等于每敲一个字发一次 GET。
3899
+ // 序号守卫:先切 A 再切 B 时,A 的 GET 可能后回来;没有这道判断,界面上就是
3900
+ // 「编辑位显示 B、预览显示 A」。
3901
+ const previewSeq = react.useRef(0);
3902
+ const loadPreview = react.useCallback(async (agentId) => {
3903
+ const seq = ++previewSeq.current;
3904
+ const q = agentId && agentId !== "default" ? `?agent=${encodeURIComponent(agentId)}` : "";
3905
+ try {
3906
+ const res = await apiFetch(`/api/dsh-mneme/persona${q}`);
3907
+ const body = await res.json();
3908
+ if (seq !== previewSeq.current) return;
3909
+ setInjectedPreview(body && body.preview && typeof body.preview.text === "string" ? body.preview : null);
3910
+ } catch {
3911
+ if (seq !== previewSeq.current) return;
3912
+ setInjectedPreview(null);
3913
+ }
3914
+ }, []);
3915
+
3916
+ react.useEffect(() => { loadPreview(personaEditing); }, [personaEditing, loadPreview]);
3917
+
3611
3918
  // 帮助与反馈卡片的版本号:独立小请求,失败不影响其它卡片。
3612
3919
  react.useEffect(() => {
3613
3920
  let cancelled = false;
@@ -3665,17 +3972,76 @@ window.__ModuleLoader__.load({
3665
3972
  return () => { cancelled = true; };
3666
3973
  }, []);
3667
3974
 
3668
- async function saveProfile() {
3975
+ // 返回「是否真的存下了」。apiFetch 只是 fetch 的薄壳,非 2xx 不会抛——同页的
3976
+ // savePersonaCards 一直判 `res.ok`,画像这条一直没判,于是服务端 403/413 时界面
3977
+ // 照样闪「画像已保存」(评审实测的同类洞,这次一起收)。
3978
+ async function saveProfile(next = profile) {
3669
3979
  try {
3670
- await apiFetch("/api/dsh-mneme/profile", {
3980
+ const res = await apiFetch("/api/dsh-mneme/profile", {
3671
3981
  method: "PUT",
3672
3982
  headers: { "Content-Type": "application/json" },
3673
- body: JSON.stringify({ profile })
3983
+ body: JSON.stringify({ profile: next })
3674
3984
  });
3985
+ if (!res.ok) throw new Error("HTTP " + res.status);
3675
3986
  setSaved(true);
3987
+ setProfileError("");
3676
3988
  announce(t("memory.settings.profileSaved"));
3677
3989
  setTimeout(() => setSaved(false), 1500);
3678
- } catch { /* ignore */ }
3990
+ return true;
3991
+ } catch (err) {
3992
+ setProfileError((err && err.message) || "failed");
3993
+ setTimeout(() => setProfileError(""), 4000);
3994
+ return false;
3995
+ }
3996
+ }
3997
+
3998
+ // 画像提取:一次宿主 LLM 调用,只在用户点按钮时发生。开关没开时后端直接回
3999
+ // 409,这里显示「未启用」而不是转圈后什么也没有——那会被读成按钮坏了。
4000
+ async function extractProfileDraft() {
4001
+ setProfileExtracting(true);
4002
+ setProfileDraft(null);
4003
+ try {
4004
+ const res = await apiFetch("/api/dsh-mneme/profile/extract", {
4005
+ method: "POST",
4006
+ headers: { "Content-Type": "application/json" },
4007
+ body: "{}"
4008
+ });
4009
+ const body = await res.json().catch(() => null);
4010
+ if (res.ok && typeof body?.draft === "string" && body.draft.trim()) {
4011
+ setProfileDraft({
4012
+ kind: "draft",
4013
+ text: body.draft,
4014
+ rowsUsed: Number.isInteger(body.rowsUsed) ? body.rowsUsed : 0,
4015
+ truncated: body.truncated === true
4016
+ });
4017
+ return;
4018
+ }
4019
+ const error = body && typeof body.error === "string" ? body.error : "";
4020
+ // 五种结局各一句话:没启用 / 库里没内容 / 没配模型 / 模型不配合或调用失败 /
4021
+ // 成功草稿。压在一起就是把「环境缺东西」报成「这次没提炼出东西」。
4022
+ const kind = error === "disabled" ? "disabled"
4023
+ : error === "no-input" ? "empty"
4024
+ : error === "no-route" ? "noroute"
4025
+ : "failed";
4026
+ setProfileDraft({ kind });
4027
+ } catch {
4028
+ setProfileDraft({ kind: "failed" });
4029
+ } finally {
4030
+ setProfileExtracting(false);
4031
+ }
4032
+ }
4033
+
4034
+ // 采用草稿 = 显式写画像。两件事都要对:把草稿文本直接交给保存(不能 setProfile
4035
+ // 之后再调无参保存——那读的是这次渲染里的旧闭包值,会存回旧文本),以及**只有
4036
+ // 真的存下了才提交界面状态**(否则一次 403 就留下「框里是草稿、库里是旧文、
4037
+ // 草稿已被清掉」的三不像,关掉面板后彻底找不回来)。
4038
+ async function applyProfileDraft() {
4039
+ if (profileDraft?.kind !== "draft") return;
4040
+ const text = profileDraft.text;
4041
+ if (await saveProfile(text)) {
4042
+ setProfile(text);
4043
+ setProfileDraft(null);
4044
+ }
3679
4045
  }
3680
4046
 
3681
4047
  // 人格卡保存(v1.2 多卡):全量多卡形 PUT({default, byAgent},后端全量
@@ -3917,9 +4283,43 @@ window.__ModuleLoader__.load({
3917
4283
  );
3918
4284
  }
3919
4285
 
4286
+ // 目录(#518):五项分组标题是设置页唯一的骨架,所以索引直接由这张表
4287
+ // 生成,而不是再手写一遍标题——两份清单迟早会漂,而漂了的目录会把人
4288
+ // 领到空处。tocRefs 是滚动目标,与渲染顺序一一对应(同一个 map 里产出)。
4289
+ const tocGroups = [
4290
+ { key: "personal", label: t("memory.settings.group.personal") },
4291
+ { key: "engine", label: t("memory.settings.group.engine") },
4292
+ { key: "search", label: t("memory.settings.group.search") },
4293
+ { key: "connect", label: t("memory.settings.group.connect") },
4294
+ { key: "help", label: t("memory.settings.group.help") }
4295
+ ];
4296
+ const tocRefs = useRef({});
4297
+ function jumpToGroup(key) {
4298
+ const node = tocRefs.current?.[key];
4299
+ // 偏移不在这里算:标题自己带 scroll-margin-top(见 .mneme-set-grouphead)。
4300
+ // 两处各量一次粘顶条高度必然对不上,跳转会把目标停在目录背后。
4301
+ if (node && node.scrollIntoView) node.scrollIntoView({ block: "start", behavior: "smooth" });
4302
+ }
4303
+ const groupHead = (key) => h("div", {
4304
+ className: "mneme-set-grouphead",
4305
+ ref: (node) => { if (node) tocRefs.current[key] = node; }
4306
+ }, t("memory.settings.group." + key));
4307
+
3920
4308
  return h("div", null,
4309
+ h("nav", { className: "mneme-set-toc", "aria-label": t("memory.settings.toc.aria") },
4310
+ h("div", { className: "mneme-set-toclabel" }, t("memory.settings.toc.label")),
4311
+ h("div", { className: "mneme-set-toclist" },
4312
+ ...tocGroups.map((group) => h("button", {
4313
+ key: group.key,
4314
+ type: "button",
4315
+ className: "mneme-set-tocchip",
4316
+ title: t("memory.settings.toc.jump").replace("{name}", group.label),
4317
+ onClick: () => jumpToGroup(group.key)
4318
+ }, group.label))
4319
+ )
4320
+ ),
3921
4321
  // —— 个性化:画像 / 人格 / 规则 / 自定义指令(Agent 怎么对你) ——
3922
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.personal")),
4322
+ groupHead("personal"),
3923
4323
  // 用户画像 — who the agent is talking to
3924
4324
  h("section", { className: "mneme-set-card" },
3925
4325
  h("div", { className: "mneme-set-title" }, t("memory.settings.profile")),
@@ -3931,15 +4331,53 @@ window.__ModuleLoader__.load({
3931
4331
  onChange: (e) => setProfile(e.target.value)
3932
4332
  }),
3933
4333
  h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
3934
- h("button", { className: "mneme-btn", onClick: saveProfile }, t("memory.settings.profileSave")),
3935
- saved && h("span", { className: "mneme-saved" }, t("memory.settings.profileSaved"))
3936
- )
4334
+ h("button", { className: "mneme-btn", onClick: () => saveProfile() }, t("memory.settings.profileSave")),
4335
+ saved && h("span", { className: "mneme-saved" }, t("memory.settings.profileSaved")),
4336
+ profileError ? h("span", { className: "mneme-saved", style: { color: "var(--dsw-danger, #d33)" } },
4337
+ t("memory.settings.profileSaveFailed") + " (" + profileError + ")") : null
4338
+ ),
4339
+ // 从记忆提取草稿(#497)。两种结局都要显眼:草稿(要用户点「采用」才落库)
4340
+ // 与失败原因(开关没开 / 没内容可提 / 模型没配合)。手写的原文在这之上一直
4341
+ // 可见——采用前它不会被任何东西覆盖。
4342
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 8 } },
4343
+ h("button", {
4344
+ type: "button",
4345
+ className: "mneme-btn",
4346
+ disabled: profileExtracting,
4347
+ onClick: extractProfileDraft
4348
+ }, t("memory.settings.profileExtract")),
4349
+ profileExtracting ? h("span", { className: "mneme-saved" }, t("memory.settings.profileExtracting")) : null
4350
+ ),
4351
+ h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractHint")),
4352
+ profileDraft?.kind === "disabled" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractDisabled")) : null,
4353
+ profileDraft?.kind === "empty" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractEmpty")) : null,
4354
+ profileDraft?.kind === "noroute" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractNoRoute")) : null,
4355
+ profileDraft?.kind === "failed" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractFailed")) : null,
4356
+ profileDraft?.kind === "draft" ? h("div", null,
4357
+ h("div", { className: "mneme-featnumlabel" }, t("memory.settings.profileDraftTitle")),
4358
+ h("div", { className: "mneme-set-desc" },
4359
+ t("memory.settings.profileDraftRows") + ": " + profileDraft.rowsUsed
4360
+ + (profileDraft.truncated ? " · " + t("memory.settings.profileDraftTruncated") : "")),
4361
+ h("pre", { className: "mneme-set-input mneme-set-input--area", style: { whiteSpace: "pre-wrap", minHeight: 0 } }, profileDraft.text),
4362
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } },
4363
+ h("button", { type: "button", className: "mneme-btn", onClick: applyProfileDraft }, t("memory.settings.profileDraftApply")),
4364
+ h("button", { type: "button", className: "mneme-btn", onClick: () => setProfileDraft(null) }, t("memory.settings.profileDraftDiscard"))
4365
+ )
4366
+ ) : null
3937
4367
  ),
3938
4368
  // 人格卡(beta,v1.2 多卡)— who the agent is:多张角色卡按会话身份注入。
3939
4369
  // 卡内开关只是该卡 enabled 半边;总闸 personaEnabled 在功能开关组,双闸同真。
3940
4370
  h("section", { className: "mneme-set-card" },
3941
4371
  h("div", { className: "mneme-set-title" }, t("memory.settings.persona")),
3942
4372
  h("div", { className: "mneme-set-desc" }, t("memory.settings.personaHint")),
4373
+ // #514:人格卡是最容易撞上宿主极简压制的地方——这里的提示比运行模式卡
4374
+ // 那句更要紧,因为用户就是在这里点开关的。
4375
+ minimalWarn && h("div", {
4376
+ style: { marginBottom: 10, padding: "8px 10px", borderLeft: "3px solid var(--mneme-warn)", fontSize: 12, lineHeight: "18px" }
4377
+ },
4378
+ h("div", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary)" } }, t("memory.status.minimalPresetTitle")),
4379
+ h("div", { style: { marginTop: 4, color: "var(--mneme-fg-dim)" } }, t("memory.status.minimalPresetBody"))
4380
+ ),
3943
4381
  // 卡列表:default + 各 byAgent 卡,点选切换编辑位;default 标注兜底位
3944
4382
  h("div", { style: { display: "flex", flexWrap: "wrap", gap: 6 } },
3945
4383
  h("button", {
@@ -4024,15 +4462,40 @@ window.__ModuleLoader__.load({
4024
4462
  t("memory.settings.personaSaveFailed") + " (" + personaError + ")")
4025
4463
  )
4026
4464
  ),
4027
- // Agent 眼中的画像(只读)— 注入生效文本:profile + rules 原样展示。
4028
- // 纯前端拼接(GET 已在 load 里拿到 profile/rules),零后端新请求。
4465
+ // Agent 眼中的你(只读)— 注入生效文本由后端渲染(GET /persona 的 preview 字段),
4466
+ // 前端只显示。以前这一段是前端自己拼的 profile + rules,注释却写着「注入生效文本」:
4467
+ // 没有人格三行、没有表头、没有「- 用户画像:」前缀,两份实现必然漂。
4029
4468
  h("section", { className: "mneme-set-card" },
4030
4469
  h("div", { className: "mneme-set-title" }, t("memory.settings.personaView")),
4031
4470
  h("div", { className: "mneme-set-desc" }, t("memory.settings.personaViewHint")),
4032
- (profile.trim() || rules.length)
4033
- ? h("pre", { className: "mneme-set-input mneme-set-input--area", style: { whiteSpace: "pre-wrap", minHeight: 0 } },
4034
- [profile.trim() ? "- " + profile.trim() : "", ...rules.map((r) => "- " + r)].filter(Boolean).join("\n"))
4035
- : h("div", { className: "mneme-xempty" }, t("memory.settings.personaViewEmpty"))
4471
+ injectedPreview === null
4472
+ ? h("div", { className: "mneme-xempty" }, t("memory.settings.personaViewUnknown"))
4473
+ : h("div", null,
4474
+ h("div", { className: "mneme-set-desc" },
4475
+ injectedPreview.source === "byAgent"
4476
+ ? t("memory.settings.personaViewSourceCard")
4477
+ : injectedPreview.source === "none"
4478
+ ? t("memory.settings.personaViewSourceNone")
4479
+ // source=default 有两种来路,必须分开说:请求带了 ?agent=<身份>
4480
+ // 却仍落默认卡(身份解析到了,只是没建卡或卡被关掉——关卡的人最想
4481
+ // 确认的就是这一格),与真的解析不到会话身份。
4482
+ : injectedPreview.agent
4483
+ ? t("memory.settings.personaViewSourceFallback")
4484
+ : t("memory.settings.personaViewSourceDefault")),
4485
+ injectedPreview.configGate === true
4486
+ ? null
4487
+ : h("div", { className: "mneme-set-desc" }, t("memory.settings.personaViewGateOff")),
4488
+ injectedPreview.text
4489
+ ? h("pre", { className: "mneme-set-input mneme-set-input--area", style: { whiteSpace: "pre-wrap", minHeight: 0 } }, injectedPreview.text)
4490
+ : h("div", { className: "mneme-xempty" }, t("memory.settings.personaViewEmpty"))
4491
+ ),
4492
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } },
4493
+ h("button", {
4494
+ type: "button",
4495
+ className: "mneme-btn",
4496
+ onClick: () => loadPreview(personaEditing)
4497
+ }, t("memory.settings.personaViewRefresh"))
4498
+ )
4036
4499
  ),
4037
4500
  // 规则 — numbered rows, hover reveals the delete affordance
4038
4501
  h("section", { className: "mneme-set-card" },
@@ -4095,7 +4558,7 @@ window.__ModuleLoader__.load({
4095
4558
  )
4096
4559
  ),
4097
4560
  // —— 记忆引擎:运行模式(粗粒度)在前,功能开关(细粒度)紧随 ——
4098
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.engine")),
4561
+ groupHead("engine"),
4099
4562
  // 运行模式 — light vs standard chip radios; each click PUTs and the
4100
4563
  // change only lands after a DSH restart (green saved hint says so).
4101
4564
  h("section", { className: "mneme-set-card" },
@@ -4105,6 +4568,10 @@ window.__ModuleLoader__.load({
4105
4568
  mode === null && !modeError
4106
4569
  ? h("div", { className: "mneme-set-hint" }, "…")
4107
4570
  : h(react.Fragment, null,
4571
+ // #514:用户以为「轻量」关掉了人格。两个词都会在界面上出现,不先
4572
+ // 分开说,用户永远会把宿主的 minimal 记成插件的 light。
4573
+ h("div", { className: "mneme-set-hint", style: { marginBottom: 8 } },
4574
+ t("memory.settings.mode.hostPresetHint")),
4108
4575
  h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
4109
4576
  h("button", {
4110
4577
  className: mode === "light" ? "mneme-chip mneme-active" : "mneme-chip",
@@ -4120,12 +4587,20 @@ window.__ModuleLoader__.load({
4120
4587
  ),
4121
4588
  mode === "light" && h("div", { className: "mneme-set-hint", style: { marginTop: 8 } },
4122
4589
  t("memory.settings.mode.offList"))
4123
- )
4590
+ ),
4591
+ // #514:suppressed 只在「autoInject 开着 + 宿主确实报了 minimal」时为真,
4592
+ // 所以这里不是吓唬人——关掉注入的用户、preset 未知的宿主都不会看到它。
4593
+ minimalWarn && h("div", {
4594
+ style: { marginTop: 10, padding: "8px 10px", borderLeft: "3px solid var(--mneme-warn)", fontSize: 12, lineHeight: "18px" }
4595
+ },
4596
+ h("div", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary)" } }, t("memory.status.minimalPresetTitle")),
4597
+ h("div", { style: { marginTop: 4, color: "var(--mneme-fg-dim)" } }, t("memory.status.minimalPresetBody"))
4598
+ )
4124
4599
  ),
4125
4600
  // 功能开关 — 后端能力的细粒度闸门;改动即时保存,重启 DSH 后生效。
4126
4601
  h(FeaturesCard, { t }),
4127
4602
  // —— 搜索:语义召回 ——
4128
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.search")),
4603
+ groupHead("search"),
4129
4604
  // 向量搜索 — semantic recall over an embeddings API
4130
4605
  h("section", { className: "mneme-set-card" },
4131
4606
  h("div", { className: "mneme-set-title" }, t("memory.settings.vectorTitle")),
@@ -4145,7 +4620,7 @@ window.__ModuleLoader__.load({
4145
4620
  )
4146
4621
  ),
4147
4622
  // —— 连接与安全:外部访问 API / 面板令牌 ——
4148
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.connect")),
4623
+ groupHead("connect"),
4149
4624
  // 外部访问 API — standalone HTTP service for plugins/CLI/desktop tools;
4150
4625
  // the token is generated and kept by the backend, so it is read-only
4151
4626
  // here with a copy affordance. Changes need a DSH restart.
@@ -4262,7 +4737,7 @@ window.__ModuleLoader__.load({
4262
4737
  apiTokenSaved && h("div", { style: { marginTop: 8 } }, h("span", { className: "mneme-saved" }, t("memory.settings.apiTokenSaved")))
4263
4738
  ),
4264
4739
  // —— 帮助:支持与鼓励(图标行 + 检测更新 + star,兼当「关于」收尾) ——
4265
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.help")),
4740
+ groupHead("help"),
4266
4741
  // 反馈三入口:新建 issue 预填(版本+平台)/ 邮件 / 浏览已知问题。
4267
4742
  // 纯前端链接零后端成本;插件版本来自 /info,平台取 userAgent。GitHub
4268
4743
  // 仓库当前没有 issue 模板,故用 issues/new?title=&body= 直接预填。
@@ -4994,7 +5469,10 @@ window.__ModuleLoader__.load({
4994
5469
  const [stats, setStats] = useState(null);
4995
5470
  const [heatStats, setHeatStats] = useState(null);
4996
5471
  const [statsError, setStatsError] = useState(false);
4997
- const [conf, setConf] = useState({ loading: true, error: false, pending: 0 });
5472
+ // #521 评审:待确认冲突与「上一轮跳过明细」同源(/dream-status),一次请求两用。
5473
+ // 跳过明细必须出现在首屏——它原先只挂在折叠区里的 DreamStatusCards 上,而那个
5474
+ // 折叠区默认收起、收起时连子卡都不挂载,等于「一整类动作 100% 被拦」仍旧看不见。
5475
+ const [conf, setConf] = useState({ loading: true, error: false, pending: 0, skipped: [] });
4998
5476
  const [focusOp, setFocusOp] = useState(null);
4999
5477
  useEffect(() => {
5000
5478
  let cancelled = false;
@@ -5023,9 +5501,14 @@ window.__ModuleLoader__.load({
5023
5501
  .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
5024
5502
  .then((d) => {
5025
5503
  if (cancelled) return;
5026
- setConf({ loading: false, error: false, pending: Number((d && d.pendingConflicts) ?? 0) });
5504
+ setConf({
5505
+ loading: false,
5506
+ error: false,
5507
+ pending: Number((d && d.pendingConflicts) ?? 0),
5508
+ skipped: Array.isArray(d && d.lastRun && d.lastRun.skippedReasons) ? d.lastRun.skippedReasons : []
5509
+ });
5027
5510
  })
5028
- .catch(() => { if (!cancelled) setConf({ loading: false, error: true, pending: 0 }); });
5511
+ .catch(() => { if (!cancelled) setConf({ loading: false, error: true, pending: 0, skipped: [] }); });
5029
5512
  };
5030
5513
  loadConflicts();
5031
5514
  window.addEventListener("mneme:conflicts-changed", loadConflicts);
@@ -5158,6 +5641,7 @@ window.__ModuleLoader__.load({
5158
5641
  cap: t("memory.status.token.conflictsFoot")
5159
5642
  })
5160
5643
  ),
5644
+ h(SkippedReasonsNotice, { t, buckets: conf.skipped, loading: conf.loading, error: conf.error }),
5161
5645
  h("div", { className: "mneme-tokenflow" },
5162
5646
  h("h3", { className: "mneme-xcolhead" }, t("memory.status.token.donut")),
5163
5647
  statsError
@@ -5245,6 +5729,28 @@ window.__ModuleLoader__.load({
5245
5729
  );
5246
5730
  }
5247
5731
 
5732
+ // #521 评审:跳过明细只留一份实现。它原先只挂在折叠区里的 DreamStatusCards 上,
5733
+ // 而那个折叠区默认收起、收起时连子卡都不挂载——首屏照样看不出「一整类动作 100%
5734
+ // 被拦」,等于把 #521 的病换个地方犯。现在由 TokenFlowSection 的 KPI 带下方渲染,
5735
+ // 折叠区不再重复一份。
5736
+ function SkippedReasonsNotice({ t, buckets, loading, error }) {
5737
+ const list = Array.isArray(buckets) ? buckets : [];
5738
+ if (loading || error || list.length === 0) return null;
5739
+ const total = list.reduce((sum, b) => sum + (Number(b.count) || 0), 0);
5740
+ return h("div", { className: "mneme-statuscard", style: { borderColor: "var(--mneme-warn)" } },
5741
+ h("div", { className: "mneme-statusnum", style: { fontSize: "16px", lineHeight: "24px" } },
5742
+ t("memory.status.skippedTitle").replace("{n}", String(total))),
5743
+ h("div", { className: "mneme-statuscap", style: { marginBottom: "6px" } }, t("memory.status.skippedHint")),
5744
+ h("ul", { style: { margin: 0, paddingLeft: "18px", fontSize: "12px", lineHeight: "18px" } },
5745
+ list.slice(0, 4).map((bucket, i) =>
5746
+ h("li", { key: i, style: { color: "var(--mneme-fg-dim)" } },
5747
+ (bucket.action ? bucket.action + " · " : "") + bucket.reason + " ×" + String(bucket.count)))),
5748
+ list.length > 4
5749
+ ? h("div", { className: "mneme-statuscap", style: { marginTop: "6px" } },
5750
+ t("memory.status.skippedMore").replace("{n}", String(list.length - 4)))
5751
+ : null
5752
+ );
5753
+ }
5248
5754
  // 巩固状态卡(×2):最近一次 autoDream 运行 + 待确认冲突计数。数据来自
5249
5755
  // /dream-status,一次取回两张卡共用;失败只影响这两张卡自身。
5250
5756
  // showConflicts=false 用于 2026-10 改造后的降级折叠区:待确认冲突已升入
@@ -5282,6 +5788,8 @@ window.__ModuleLoader__.load({
5282
5788
  // 激活直达队列——StatusPanel 把 onGotoQueue 传进来(一步 prop,无线程)。
5283
5789
  const pending = state.pending > 0;
5284
5790
  const gotoQueue = () => { if (pending && onGotoQueue) onGotoQueue(); };
5791
+ // #521 的跳过明细不在这里渲染:它已提到 TokenFlowSection 的 KPI 带下方,
5792
+ // 首屏可见。这张卡在折叠区里,展开后再重复一遍只会让人以为有两批跳过。
5285
5793
  return h(react.Fragment, null,
5286
5794
  h(StatusCard, { t, title: t("memory.status.dream"), loading: state.loading, error: state.error, num, cap }),
5287
5795
  showConflicts
@@ -5311,15 +5819,7 @@ window.__ModuleLoader__.load({
5311
5819
  // 主动提示,把「为什么没注入」从逐帧解压日志降为看一眼状态页。只在
5312
5820
  // suppressed=true 时渲染——standard 会话与 preset 未知的宿主零打扰。
5313
5821
  function InjectStatusCard({ t }) {
5314
- const [state, setState] = useState({ loading: true, suppressed: false });
5315
- useEffect(() => {
5316
- let cancelled = false;
5317
- apiFetch("/api/dsh-mneme/inject-status")
5318
- .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
5319
- .then((j) => { if (!cancelled) setState({ loading: false, suppressed: !!(j && j.suppressed) }); })
5320
- .catch(() => { if (!cancelled) setState({ loading: false, suppressed: false }); });
5321
- return () => { cancelled = true; };
5322
- }, []);
5822
+ const state = useInjectStatus();
5323
5823
  if (state.loading || !state.suppressed) return null;
5324
5824
  return h("div", { className: "mneme-statuscard", style: { borderColor: "var(--mneme-warn)" } },
5325
5825
  h("div", { className: "mneme-statusnum", style: { fontSize: "16px", lineHeight: "24px" } }, t("memory.status.injectSuppressed")),
@@ -5888,7 +6388,7 @@ window.__ModuleLoader__.load({
5888
6388
  decidedAt ?? null
5889
6389
  ].filter(Boolean).join(" · ");
5890
6390
 
5891
- function MemoryDrawer({ t, memory, conflict, deleting, deleteError, onClose, onDelete, onSaved }) {
6391
+ function MemoryDrawer({ t, memory, conflict, deleting, deleteError, onClose, onDelete, onSaved, onUndone }) {
5892
6392
  const [editing, setEditing] = useState(false);
5893
6393
  const [title, setTitle] = useState(memory.title || "");
5894
6394
  const [content, setContent] = useState(memory.content || "");
@@ -5905,6 +6405,14 @@ window.__ModuleLoader__.load({
5905
6405
  const [saveError, setSaveError] = useState(false);
5906
6406
  const [copied, setCopied] = useState(false);
5907
6407
  const [entities, setEntities] = useState(null);
6408
+ // 整包退出的两段:先干跑清点(点按钮那一发),再确认物删。撤是**物理删除**不是归档
6409
+ // (归档行仍占 share_external_id 的 UNIQUE 位,撤了会导不回来,理由在
6410
+ // src/share-import.js 的 deleteSharePack),所以这里比删除那条路多一站清点。
6411
+ const [undoPreview, setUndoPreview] = useState(null);
6412
+ const [undoBusy, setUndoBusy] = useState(false);
6413
+ const [undoError, setUndoError] = useState(false);
6414
+ // 确认那一发却回干跑时唯一出声的一格(见 undoPack 的注释)。
6415
+ const [undoStillDry, setUndoStillDry] = useState(false);
5908
6416
 
5909
6417
  useEffect(() => {
5910
6418
  let cancelled = false;
@@ -5935,6 +6443,34 @@ window.__ModuleLoader__.load({
5935
6443
  .finally(() => setBusy(false));
5936
6444
  };
5937
6445
 
6446
+ const undoPack = (confirm) => {
6447
+ setUndoBusy(true);
6448
+ setUndoError(false);
6449
+ setUndoStillDry(false);
6450
+ apiFetch("/api/dsh-mneme/receive/undo", {
6451
+ method: "POST",
6452
+ headers: { "Content-Type": "application/json" },
6453
+ // 不带 confirm 就是干跑:第一发只清点,第二发才删。packId 只从行上取
6454
+ // (面板行带这个字段是因为「收起这个包」按 pack_id 整批删),面板不自己拼也不猜。
6455
+ body: JSON.stringify(confirm
6456
+ ? { packId: memory.share_pack_id, confirm: true }
6457
+ : { packId: memory.share_pack_id })
6458
+ })
6459
+ .then(async (res) => { if (!res.ok) throw new Error("http"); return res.json(); })
6460
+ .then((d) => {
6461
+ // 只认答复的 dry_run,**不按「我点的是哪一颗」推断**:确认那一发如果回 dry_run:true
6462
+ // (服务端与面板版本不一致的形态),悄悄把预览重画一遍就等于「按了删除却没删也不说」
6463
+ // ——接收弹层那一侧修过同一个形状,这里不许退回去。
6464
+ if (d.dry_run === false) onUndone(d.deleted ?? 0);
6465
+ else {
6466
+ setUndoPreview(d);
6467
+ if (confirm) setUndoStillDry(true);
6468
+ }
6469
+ })
6470
+ .catch(() => setUndoError(true))
6471
+ .finally(() => setUndoBusy(false));
6472
+ };
6473
+
5938
6474
  const save = () => {
5939
6475
  const t2 = title.trim();
5940
6476
  if (!t2) return;
@@ -6049,6 +6585,11 @@ window.__ModuleLoader__.load({
6049
6585
  h("span", { className: "mneme-dmetakey" }, t("memory.explorer.updated")),
6050
6586
  h("span", { className: "mneme-dmetaval", title: formatDate(memory.updated_at) },
6051
6587
  `${formatRelativeTime(memory.updated_at, t)}(${formatDateShort(memory.updated_at)})`),
6588
+ // 包号给人核对:与 `dsh-mneme undo-share <包文件>` 用的是同一个 pack_id,
6589
+ // 两条路撤的是同一批行。
6590
+ memory.share_pack_id && h(react.Fragment, null,
6591
+ h("span", { className: "mneme-dmetakey" }, t("memory.explorer.foreignPack")),
6592
+ h("span", { className: "mneme-dmetaval", title: memory.share_pack_id }, memory.share_pack_id)),
6052
6593
  Array.isArray(memory.tags) && memory.tags.length > 0 && h(react.Fragment, null,
6053
6594
  h("span", { className: "mneme-dmetakey" }, t("memory.explorer.tags")),
6054
6595
  h("span", { className: "mneme-dmetaval" }, memory.tags.join(" · ")))
@@ -6124,7 +6665,41 @@ window.__ModuleLoader__.load({
6124
6665
  : h("button", { type: "button", className: "mneme-btn mneme-btndanger", onClick: () => setConfirmDelete(true) },
6125
6666
  t("memory.explorer.delete")),
6126
6667
  deleteError && h("span", { style: { fontSize: 12, color: "var(--mneme-danger)" } },
6127
- t("memory.explorer.deleteFailed"))
6668
+ t("memory.explorer.deleteFailed")),
6669
+ memory.share_pack_id && h(react.Fragment, null,
6670
+ undoPreview
6671
+ ? (undoPreview.deleted ?? 0) === 0
6672
+ // 「这个包已经不在本库里了」是她真正需要的那一句,而不是
6673
+ // 「将删除 0 条 + 一颗确认删除 0 条的键」——后者真实但没用。
6674
+ ? h("span", { style: { fontSize: 12, color: "var(--mneme-warn)" } },
6675
+ t("memory.explorer.undoPackGone"))
6676
+ : h(react.Fragment, null,
6677
+ h("span", { style: { fontSize: 12, color: "var(--mneme-warn)" } },
6678
+ t("memory.explorer.undoPackPreview").replace("{n}", String(undoPreview.deleted ?? 0))),
6679
+ // sample 是**清点时**的前 20 条({type,title},序为 updated_at DESC),
6680
+ // deleted 是这一笔真带走几行。两个数并发下可以差一条,谁也不是对方的
6681
+ // 依据(src/share-import.js 的 deleteSharePack 那段),所以这里只把
6682
+ // sample 当「我要收起的到底是哪一批」的核对清单,不参与条数。
6683
+ // 清单形状走 TypeRowList:接收弹层的撞名清单与它是同一个形状,
6684
+ // 第三份手写 .map 就是第三处会各自漂移的地方。
6685
+ TypeRowList({ t, rows: undoPreview.sample }),
6686
+ h("button", { type: "button", className: "mneme-btn mneme-btndangerconfirm", disabled: undoBusy, onClick: () => undoPack(true) },
6687
+ t("memory.explorer.undoPackConfirm").replace("{n}", String(undoPreview.deleted ?? 0))),
6688
+ // 取消回到「一颗干净的收起键」:清点结果与那句「仍是干跑」是同一轮的
6689
+ // 两样东西,只清一样会让旧判词挂在空按钮旁边。
6690
+ h("button", { type: "button", className: "mneme-btn", disabled: undoBusy, onClick: () => { setUndoPreview(null); setUndoStillDry(false); } },
6691
+ t("memory.explorer.cancel"))
6692
+ )
6693
+ : h("button", { type: "button", className: "mneme-btn mneme-btndanger", disabled: undoBusy, onClick: () => undoPack(false) },
6694
+ t("memory.explorer.undoPack"))),
6695
+ // 撤销同样是往返一秒量级的枪(清点那一发与接收侧的干跑走的是同一档耗时):
6696
+ // 只把键按灰而不说在忙,看起来就像没反应。这一句说的是**收起**,不是导入——
6697
+ // 物删在飞的时候写成「导入中」是把两件相反的事挂在同一行字上。
6698
+ undoBusy && h("span", { style: { fontSize: 12 } }, t("memory.explorer.undoPackBusy")),
6699
+ undoStillDry && h("span", { style: { fontSize: 12, color: "var(--mneme-danger)" } },
6700
+ t("memory.explorer.undoPackStillDry")),
6701
+ undoError && h("span", { style: { fontSize: 12, color: "var(--mneme-danger)" } },
6702
+ t("memory.explorer.undoPackFailed"))
6128
6703
  )
6129
6704
  )
6130
6705
  );
@@ -6188,6 +6763,338 @@ window.__ModuleLoader__.load({
6188
6763
  );
6189
6764
  }
6190
6765
 
6766
+ // --- 分享包弹层 ---
6767
+ // 这一面的支点只有一句:**看见 report 之前不给下载**。服务端一次 POST 就把包和报告
6768
+ // 一起交回,所以这一格拦不住靠协议、只能靠界面自己:CLI 那侧没人拦得住,面板至少要让
6769
+ // 「随手把整库外发」停一步。八个类型下拉、六个跳过原因都只许是 src/share.js 的投影,
6770
+ // 一条判据都不在这里复述(#411 口径)。
6771
+ const SHARE_TYPE_OPTIONS = ["preference", "project", "decision", "history", "pitfall", "constraint", "rejected_solution", "pattern"];
6772
+ // 顺序即 share.js 的判序(classifyRowForShare 从上往下命中);报告按这个顺序排,
6773
+ // 跨文件锁在 test/client-share.test.js 里同时锁内容与顺序。
6774
+ const SHARE_SKIP_REASONS = ["archived", "forgotten", "readonly_type", "excluded_type", "blank_or_noise", "sensitive"];
6775
+
6776
+ /**
6777
+ * 服务端 Content-Disposition 里的文件名。拼法只住在 src/share-http.js 的
6778
+ * shareFilename(日期 + pack_id 前 8 位),面板复制一份就是第二个口径:旧服务端没带
6779
+ * 这一格时宁可短(pack_id 兜底),也不许在这里再拼一次日期。
6780
+ */
6781
+ const dispositionName = (value) => {
6782
+ const m = /filename="?([^";]+)"?/.exec(value || "");
6783
+ return m ? m[1] : "";
6784
+ };
6785
+
6786
+ // 出包报告。两笔账必须同屏:进包条数与「够格」条数是两个数(包内同键折叠),
6787
+ // 跳过计数是全量而跳过清单截到 50——只报清单会让人以为跳过的就清单里那些。
6788
+ function ShareReport({ t, report, listOpen, setListOpen }) {
6789
+ const skipped = report.skipped || {};
6790
+ const list = Array.isArray(report.skip_list) ? report.skip_list : [];
6791
+ const totalSkipped = SHARE_SKIP_REASONS.reduce((n, k) => n + (skipped[k] || 0), 0);
6792
+ return h("div", { className: "mneme-sharebody" },
6793
+ h("div", { className: "mneme-saved" }, t("memory.explorer.shareIncluded").replace("{n}", String(report.included ?? 0))),
6794
+ (report.deduped || 0) > 0 && h("div", { className: "mneme-featsubhint" },
6795
+ t("memory.explorer.shareDeduped")
6796
+ .replace("{n}", String(report.deduped))
6797
+ .replace("{m}", String(report.included + report.deduped))),
6798
+ totalSkipped > 0 && h("div", { className: "mneme-featsubhint" },
6799
+ t("memory.explorer.shareSkipped").replace("{n}", String(totalSkipped))),
6800
+ totalSkipped > 0 && h("div", { className: "mneme-sharebreak" },
6801
+ SHARE_SKIP_REASONS.filter((k) => (skipped[k] || 0) > 0).map((k) =>
6802
+ h("span", { key: k, className: "mneme-sharebreakitem" },
6803
+ `${t(`memory.explorer.share.skip.${k}`)} ${skipped[k]}`))),
6804
+ list.length > 0 && h("div", null,
6805
+ h("button", {
6806
+ type: "button",
6807
+ className: "mneme-footbtn",
6808
+ "aria-expanded": String(listOpen),
6809
+ onClick: () => setListOpen(!listOpen)
6810
+ }, t("memory.explorer.shareSkipList").replace("{n}", String(list.length))),
6811
+ listOpen && h("div", { className: "mneme-sharelist" },
6812
+ list.map((row, i) => h("div", { key: `${i}-${row.reason}`, className: "mneme-sharow" },
6813
+ // 命中密钥/PII 的那几条服务端就不给标题(空串),这里不许拿正文或摘要兜底:
6814
+ // 摘要是递给对面的离线爆破 oracle(src/share.js 的 skipTitle 那段)。
6815
+ h("span", { className: "mneme-sharowtitle" }, row.title || t("memory.explorer.shareNoTitle")),
6816
+ h("span", { className: "mneme-sharowreason" }, t(`memory.explorer.share.skip.${row.reason}`)))),
6817
+ // 截断的判据用「清单比账短」而不是硬写 50:服务端的水位改了,这里跟着走。
6818
+ totalSkipped > list.length && h("div", { className: "mneme-sharow" },
6819
+ h("span", { className: "mneme-sharowreason" },
6820
+ t("memory.explorer.shareSkipListCapped").replace("{n}", String(list.length))))))
6821
+ );
6822
+ }
6823
+
6824
+ function ShareDialog({ t, onClose }) {
6825
+ const [alias, setAlias] = useState("");
6826
+ const [type, setType] = useState(""); // "" = 整库口径:这个键压根不发
6827
+ const [busy, setBusy] = useState(false);
6828
+ const [pack, setPack] = useState(null);
6829
+ const [fileName, setFileName] = useState("");
6830
+ const [tooLarge, setTooLarge] = useState(null);
6831
+ const [err, setErr] = useState("");
6832
+ const [listOpen, setListOpen] = useState(false);
6833
+
6834
+ const build = () => {
6835
+ setBusy(true);
6836
+ setErr("");
6837
+ setPack(null);
6838
+ setTooLarge(null);
6839
+ setFileName("");
6840
+ setListOpen(false);
6841
+ const body = {};
6842
+ if (alias.trim()) body.alias = alias.trim();
6843
+ if (type) body.type = type;
6844
+ apiFetch("/api/dsh-mneme/share", {
6845
+ method: "POST",
6846
+ headers: { "Content-Type": "application/json" },
6847
+ body: JSON.stringify(body)
6848
+ })
6849
+ .then(async (res) => {
6850
+ const d = await res.json().catch(() => null);
6851
+ // 413 的答复不是包:{error, bytes, limitBytes, hint, report}。报告要给,
6852
+ // 包体一个字节都不许在内存里留着——服务端正是因为它太大才拒的。
6853
+ if (res.status === 413) { setTooLarge(d || {}); setBusy(false); return; }
6854
+ if (!res.ok) {
6855
+ // 与接收弹层同一份形状:服务端只回一个码(empty-pack / invalid-alias / …),
6856
+ // detail 有就带上——那是「哪一格不合格」的唯一读数,面板不自己编第二套文案。
6857
+ // 不这么做的话,日后 shareReply 新增的码在这里全是「生成失败」六个字。
6858
+ setErr(`${t("memory.explorer.shareFailed")}(${(d && d.error) || res.status})`
6859
+ + (d && d.detail ? `:${d.detail}` : ""));
6860
+ setBusy(false);
6861
+ return;
6862
+ }
6863
+ // 200 但读不出报告**不是成功**:这一支不给码(没有码可给),只说失败。
6864
+ if (!d || !d.report) { setErr(t("memory.explorer.shareFailed")); setBusy(false); return; }
6865
+ setPack(d);
6866
+ setFileName(dispositionName(res.headers.get("content-disposition")));
6867
+ setBusy(false);
6868
+ })
6869
+ .catch(() => { setErr(t("memory.explorer.shareFailed")); setBusy(false); });
6870
+ };
6871
+
6872
+ const rep = pack && pack.report;
6873
+ // included 为 0 时不给下载:那种包对面吃的是 empty-pack(400),给一个必败的文件
6874
+ // 不如在这里说清「没有够格外发的条目」。
6875
+ const canDownload = !!rep && rep.included > 0;
6876
+
6877
+ const download = () => {
6878
+ // 面板写的字节必须与 `src/share.js` 量水位的、`bin/cli.mjs` 落盘的**同一份形状**
6879
+ // (pretty-print 只属于 `/export` 那个备份面:人读的档,不是要重投的包)。
6880
+ const blob = new Blob([JSON.stringify(pack)], { type: "application/json" });
6881
+ const url = URL.createObjectURL(blob);
6882
+ const a = document.createElement("a");
6883
+ a.href = url;
6884
+ a.download = fileName || `dsh-mneme-share-${pack.pack_id}.json`;
6885
+ document.body.appendChild(a);
6886
+ a.click();
6887
+ a.remove();
6888
+ URL.revokeObjectURL(url);
6889
+ };
6890
+
6891
+ return h("div", { className: "mneme-impdialog mneme-sharedialog", role: "region", "aria-label": t("memory.explorer.shareTitle") },
6892
+ h("div", { className: "mneme-set-title" }, t("memory.explorer.shareTitle")),
6893
+ h("div", { className: "mneme-featsubhint", style: { marginTop: 4 } }, t("memory.explorer.shareHint")),
6894
+ h("div", { className: "mneme-improw" },
6895
+ h("span", { className: "mneme-featnumlabel" }, t("memory.explorer.shareAlias")),
6896
+ // 尺子与服务端同一把:importedTagFor 把整串标签(含 from-)截到 32,留给别名的
6897
+ // 就是 27。不拦的话她打的第 28 个字起在**对面**被静默吃掉,而这条标签是外来行
6898
+ // 唯一的来源读数。数字由 test/client-share.test.js 跨文件钉住。
6899
+ h("input", {
6900
+ type: "text",
6901
+ className: "mneme-select",
6902
+ value: alias,
6903
+ maxLength: 27,
6904
+ placeholder: t("memory.explorer.shareAliasBlank"),
6905
+ onChange: (e) => setAlias(e.target.value)
6906
+ })
6907
+ ),
6908
+ h("div", { className: "mneme-improw" },
6909
+ h("span", { className: "mneme-featnumlabel" }, t("memory.explorer.shareType")),
6910
+ h("select", { className: "mneme-select", value: type, onChange: (e) => setType(e.target.value) },
6911
+ h("option", { value: "" }, t("memory.tab.all")),
6912
+ SHARE_TYPE_OPTIONS.map((k) => h("option", { key: k, value: k }, typeLabel(t, k))))
6913
+ ),
6914
+ h("div", { className: "mneme-improw", style: { justifyContent: "flex-end" } },
6915
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: onClose },
6916
+ t("memory.explorer.importCancel")),
6917
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: build },
6918
+ busy ? t("memory.explorer.shareBuilding") : t("memory.explorer.shareBuild"))
6919
+ ),
6920
+ err && h("div", { className: "mneme-featsubhint", style: { color: "var(--mneme-danger)", marginTop: 8 } }, err),
6921
+ tooLarge && h("div", { className: "mneme-featsubhint", style: { color: "var(--mneme-warn)", marginTop: 8 } },
6922
+ // 提示语服务端给(SHARE_ERROR_HINT 只许有一份),面板只补自己那句字节数。
6923
+ // 它写着 --type/--tag 的 CLI 口吻,这一格已知:docs/SHARE.md 的『面板』那一节记着。
6924
+ `${t("memory.explorer.shareTooLarge")
6925
+ .replace("{bytes}", String(tooLarge.bytes ?? "?"))
6926
+ .replace("{limit}", String(tooLarge.limitBytes ?? "?"))} ${tooLarge.hint || ""}`),
6927
+ tooLarge && tooLarge.report && h(ShareReport, { t, report: tooLarge.report, listOpen, setListOpen }),
6928
+ rep && h(ShareReport, { t, report: rep, listOpen, setListOpen }),
6929
+ canDownload && h("div", { className: "mneme-improw", style: { justifyContent: "flex-end" } },
6930
+ h("button", { type: "button", className: "mneme-btn", onClick: download },
6931
+ t("memory.explorer.shareDownload"))),
6932
+ // 没有可下载的东西就一定给一句原因:`included` 为 0 与「这答复没有条数」是两种失败,
6933
+ // 但对她的界面是同一种——数字后面没有键。canDownload 是唯一的门,这里就只认它。
6934
+ rep && !canDownload && h("div", { className: "mneme-featsubhint", style: { marginTop: 8 } },
6935
+ t("memory.explorer.shareEmpty")),
6936
+ );
6937
+ }
6938
+
6939
+ // 干跑与真导入**共用这一份读数**:两者的键完全相同,区别只有 dry_run 那一格。
6940
+ // 拆成两个组件就会各写一套文案,而「将新建」与「已新建」的唯一真凭是 dry_run。
6941
+ function ReceiveSummary({ t, d }) {
6942
+ const conflicts = Array.isArray(d.title_conflicts) ? d.title_conflicts : [];
6943
+ const alias = d.alias || t("memory.explorer.receiveAnonymous");
6944
+ return h("div", { className: "mneme-sharebody" },
6945
+ h("div", { className: "mneme-featsubhint" },
6946
+ `${t("memory.explorer.receiveFrom").replace("{alias}", alias)} · ${d.pack_id || ""}`),
6947
+ // dry_run 缺格(旧服务端)时按「没写」处理:宁可少报一次写入,不可虚报。
6948
+ h("div", { className: "mneme-saved" },
6949
+ t(d.dry_run === false ? "memory.explorer.receiveDidCreate" : "memory.explorer.receiveWouldCreate")
6950
+ .replace("{n}", String(d.created ?? 0))),
6951
+ (d.skipped_existing || 0) > 0 && h("div", { className: "mneme-featsubhint" },
6952
+ t("memory.explorer.receiveExisting").replace("{n}", String(d.skipped_existing))),
6953
+ (d.unchanged || 0) > 0 && h("div", { className: "mneme-featsubhint" },
6954
+ t("memory.explorer.receiveUnchanged").replace("{n}", String(d.unchanged))),
6955
+ // 撞名是 advisory 读数:只对「本轮真正会新建的那些」判过,重吃同一个包时恒空
6956
+ //(src/share-import.js:181-186)。所以这里**只有非空才出声**,绝不写「没有冲突」;
6957
+ // 屏上没有这一行不等于没有撞名,等于「本轮没有新的要报」。
6958
+ conflicts.length > 0 && h("div", { className: "mneme-featsubhint", style: { color: "var(--mneme-warn)" } },
6959
+ t("memory.explorer.receiveConflicts").replace("{n}", String(conflicts.length))),
6960
+ TypeRowList({ t, rows: conflicts }),
6961
+ // `conflicts_checked` 是服务端给的**包内条数**,不是「比过了几条」(同一处注释写明:
6962
+ // 第二次吃同一个包时 title_conflicts 恒空,而这一格仍是全量)。所以文案只说
6963
+ // 「包内 N 条」:它是上一行为什么可能沉默的尺,不许读成「比对了 N 条都没撞」。
6964
+ h("div", { className: "mneme-featsubhint" },
6965
+ t("memory.explorer.receivePackCount").replace("{n}", String(d.conflicts_checked ?? 0)))
6966
+ );
6967
+ }
6968
+
6969
+ function ReceiveDialog({ t, onClose, onImported }) {
6970
+ const [file, setFile] = useState(null);
6971
+ const [pack, setPack] = useState(null);
6972
+ const [preview, setPreview] = useState(null);
6973
+ const [result, setResult] = useState(null);
6974
+ const [busy, setBusy] = useState(false);
6975
+ const [err, setErr] = useState("");
6976
+ const fileRef = useRef(null);
6977
+
6978
+ // 一发 POST 两种用法(干跑 / 确认)都从这走,失败文案只有一份。返回 null 表示
6979
+ // 「已经有一句话摆上屏了」——所以每一条不回 null 的路径都必须自己先 setErr,
6980
+ // 否则调用方读到 null 就是什么都不做,现场变成「按了没反应」。
6981
+ const post = (body) => apiFetch("/api/dsh-mneme/receive", {
6982
+ method: "POST",
6983
+ headers: { "Content-Type": "application/json" },
6984
+ body: JSON.stringify(body)
6985
+ })
6986
+ .then(async (res) => {
6987
+ const d = await res.json().catch(() => null);
6988
+ setBusy(false);
6989
+ if (!res.ok) {
6990
+ // 闸门只回一个码(invalid-kind / external-id-mismatch / …),detail 有就带上:
6991
+ // 那是「哪一格不合格」的唯一读数,面板不自己编第二套文案。
6992
+ setErr(`${t("memory.explorer.receiveFailed")}(${(d && d.error) || res.status})`
6993
+ + (d && d.detail ? `:${d.detail}` : ""));
6994
+ return null;
6995
+ }
6996
+ // 200 但正文读不出(前置的 HTML 拦截页、空 body 的代理、被截断的响应)
6997
+ // **不是成功**:这里曾经直接把 null 交回调用方,于是确认那一发悄悄没走完,
6998
+ // 而屏上上一块的「将新建 N 条」还留着——看起来就像她没点确认。
6999
+ if (!d || typeof d !== "object") {
7000
+ setErr(t("memory.explorer.receiveFailed"));
7001
+ return null;
7002
+ }
7003
+ return d;
7004
+ })
7005
+ .catch(() => { setErr(t("memory.explorer.receiveFailed")); setBusy(false); return null; });
7006
+
7007
+ const pick = (f) => {
7008
+ setFile(f || null);
7009
+ setErr("");
7010
+ setPreview(null);
7011
+ setResult(null);
7012
+ setPack(null);
7013
+ if (!f) return;
7014
+ f.text().then((text) => {
7015
+ // 坏 JSON 在本地就拦。「这个文件根本不是 JSON」与「这个包不合格」是两件事,
7016
+ // 后者由服务端的码说(invalid-kind / invalid-entry / …)。
7017
+ // 顶层不是对象的也一样在这里说清:`null`、`0`、`[]` 会让预览键永远灰着且不解释,
7018
+ // 那种「按不动也没话说」的界面比一句错话更坏。
7019
+ let parsed;
7020
+ try {
7021
+ parsed = JSON.parse(text);
7022
+ } catch {
7023
+ setErr(t("memory.explorer.receiveNotJson"));
7024
+ return;
7025
+ }
7026
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
7027
+ setErr(t("memory.explorer.receiveNotPack"));
7028
+ return;
7029
+ }
7030
+ setPack(parsed);
7031
+ }).catch(() => setErr(t("memory.explorer.receiveFailed")));
7032
+ };
7033
+
7034
+ const runPreview = () => {
7035
+ setBusy(true);
7036
+ setErr("");
7037
+ setResult(null);
7038
+ // 不带 confirm = 干跑(服务端缺省即预览,spec §5 沿用 reclaim 的脾气)。
7039
+ post({ pack }).then((d) => { if (d) setPreview(d); });
7040
+ };
7041
+
7042
+ const runApply = () => {
7043
+ setBusy(true);
7044
+ setErr("");
7045
+ post({ pack, confirm: true }).then((d) => {
7046
+ if (!d) return;
7047
+ setResult(d);
7048
+ // 写完才刷新列表;答复说没写就不刷(dry_run 那一格是唯一凭据)。
7049
+ // 同一支里顺手撤掉预览:写成功之后「将新建 40 条」与「已新建 40 条」是同屏的两种时态,
7050
+ // 上面那一句此时不是信息。回干跑时**保留**预览——那一发确实没写,屏上还该留着清点。
7051
+ if (d.dry_run === false) { setPreview(null); onImported(); }
7052
+ });
7053
+ };
7054
+
7055
+ return h("div", { className: "mneme-impdialog mneme-sharedialog", role: "region", "aria-label": t("memory.explorer.receiveTitle") },
7056
+ h("div", { className: "mneme-set-title" }, t("memory.explorer.receiveTitle")),
7057
+ h("div", { className: "mneme-featsubhint", style: { marginTop: 4 } }, t("memory.explorer.receiveHint")),
7058
+ h("div", { className: "mneme-improw" },
7059
+ // 忙的时候不许再选一个文件:预览那一发还在飞,选第二个会清掉 pack/preview,
7060
+ // 而飞回来的那一块数字说的是**前一个**包——确认键亮着、写的却是后一个文件。
7061
+ // 这条窗口按实测是 0.5–3 秒量级,不是理论风险。
7062
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: () => fileRef.current?.click() },
7063
+ file ? file.name : t("memory.explorer.receivePick")),
7064
+ h("input", {
7065
+ ref: fileRef,
7066
+ type: "file",
7067
+ accept: ".json,application/json",
7068
+ style: { display: "none" },
7069
+ // 选完就清空控件的值:否则她改完同一个文件再选一次,浏览器不发 change,
7070
+ // 面板会拿着上一版的包继续预览——同一颗键,两个事实。
7071
+ onChange: (e) => { pick((e.target.files && e.target.files[0]) || null); e.target.value = ""; }
7072
+ })
7073
+ ),
7074
+ err && h("div", { className: "mneme-featsubhint", style: { color: "var(--mneme-danger)", marginTop: 8 } }, err),
7075
+ // 入库是逐条先查 + 逐条 INSERT + 排队嵌入:实测 494 µs/条,单批一千多条要跑一秒开外,
7076
+ // 整库量级近 3 秒。busy 必须挂在预览与确认两发上,不然按钮看起来像没反应。
7077
+ busy && h("div", { className: "mneme-featsubhint", style: { marginTop: 8 } }, t("memory.explorer.importing")),
7078
+ preview && h(ReceiveSummary, { t, d: preview }),
7079
+ result && h(ReceiveSummary, { t, d: result }),
7080
+ // 按了确认却回个「仍是干跑」(服务端只认 confirm 的真值 true,缺省、字符串
7081
+ // "true"、别的旋钮一律不写)——这一句是唯一出声的地方:沉默会让人以为已经写进去了。
7082
+ // 它挂在 result 那一支而不是 ReceiveSummary 里:预览本来就该是干跑,那里再说一遍
7083
+ // 只是噪声。
7084
+ result && result.dry_run !== false && h("div", {
7085
+ className: "mneme-featsubhint", style: { color: "var(--mneme-warn)" }
7086
+ }, t("memory.explorer.receiveStillDry")),
7087
+ h("div", { className: "mneme-improw", style: { justifyContent: "flex-end" } },
7088
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: onClose },
7089
+ t("memory.explorer.importCancel")),
7090
+ h("button", { type: "button", className: "mneme-btn", disabled: busy || !pack, onClick: runPreview },
7091
+ t("memory.explorer.receivePreview")),
7092
+ h("button", { type: "button", className: "mneme-btn", disabled: busy || !preview, onClick: runApply },
7093
+ t("memory.explorer.receiveConfirm"))
7094
+ )
7095
+ );
7096
+ }
7097
+
6191
7098
  // #386 第 5 条:面板手动新增一条记忆。字段与 memory_save 工具对齐
6192
7099
  //(type/title/content/importance/tags),但**不放 scope/sensitivity**:归属的
6193
7100
  // 显式声明走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖
@@ -6330,11 +7237,14 @@ window.__ModuleLoader__.load({
6330
7237
  // 方案 A:沉淀/归档筛选 chip——状态页「查看全部」也会带着它们跳转过来。
6331
7238
  const [depositedOnly, setDepositedOnly] = useState(false); // 只看 autoDream 巩固
6332
7239
  const [archivedOnly, setArchivedOnly] = useState(false); // 只看已归档
7240
+ const [foreignOnly, setForeignOnly] = useState(false); // 只看外来行(source=imported)
6333
7241
  const [heatSort, setHeatSort] = useState(false); // 卡片页内热度降序(阶段二补口)
6334
7242
  const [pendingIds, setPendingIds] = useState(() => new Set());
6335
7243
  const [menuOpen, setMenuOpen] = useState(false);
6336
7244
  const [importOpen, setImportOpen] = useState(false);
6337
7245
  const [createOpen, setCreateOpen] = useState(false);
7246
+ const [shareOpen, setShareOpen] = useState(false);
7247
+ const [receiveOpen, setReceiveOpen] = useState(false);
6338
7248
  const itemRefs = useRef(new Map());
6339
7249
  const moreRef = useRef(null);
6340
7250
 
@@ -6372,7 +7282,8 @@ window.__ModuleLoader__.load({
6372
7282
  + (minImp ? `&minImportance=${minImp}` : "")
6373
7283
  + (dateFromIso ? `&updatedFrom=${encodeURIComponent(dateFromIso)}` : "")
6374
7284
  + (depositedOnly ? "&deposited=only" : "")
6375
- + (archivedOnly ? "&archived=only" : "");
7285
+ + (archivedOnly ? "&archived=only" : "")
7286
+ + (foreignOnly ? "&source=imported" : "");
6376
7287
  const listUrl = (offset) => `/api/dsh-mneme/list?limit=${PAGE_SIZE}&offset=${offset}${filterQS}&order=chrono`;
6377
7288
 
6378
7289
  // Browse = paged, pure-chronological pages (order=chrono). The default
@@ -6684,7 +7595,7 @@ window.__ModuleLoader__.load({
6684
7595
  "aria-label": t("memory.explorer.more"),
6685
7596
  "aria-expanded": String(menuOpen),
6686
7597
  title: t("memory.explorer.more"),
6687
- onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); setCreateOpen(false); }
7598
+ onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); setCreateOpen(false); setShareOpen(false); setReceiveOpen(false); }
6688
7599
  }, "⋯"),
6689
7600
  menuOpen && h("div", { className: "mneme-menu", role: "menu" },
6690
7601
  h("button", { type: "button", role: "menuitem", onClick: () => { setCreateOpen(true); setMenuOpen(false); } },
@@ -6694,13 +7605,23 @@ window.__ModuleLoader__.load({
6694
7605
  h("button", { type: "button", role: "menuitem", onClick: () => { setMenuOpen(false); exportData("markdown"); } },
6695
7606
  t("memory.explorer.exportMarkdown")),
6696
7607
  h("button", { type: "button", role: "menuitem", onClick: () => { setImportOpen(true); setMenuOpen(false); } },
6697
- t("memory.explorer.importMd"))
7608
+ t("memory.explorer.importMd")),
7609
+ h("button", { type: "button", role: "menuitem", onClick: () => { setShareOpen(true); setMenuOpen(false); } },
7610
+ t("memory.explorer.sharePack")),
7611
+ h("button", { type: "button", role: "menuitem", onClick: () => { setReceiveOpen(true); setMenuOpen(false); } },
7612
+ t("memory.explorer.receivePack")),
6698
7613
  ),
6699
7614
  importOpen && h(ImportDialog, {
6700
7615
  t,
6701
7616
  onClose: () => setImportOpen(false),
6702
7617
  onImported: () => setReloadKey((k) => k + 1)
6703
7618
  }),
7619
+ shareOpen && h(ShareDialog, { t, onClose: () => setShareOpen(false) }),
7620
+ receiveOpen && h(ReceiveDialog, {
7621
+ t,
7622
+ onClose: () => setReceiveOpen(false),
7623
+ onImported: () => setReloadKey((k) => k + 1)
7624
+ }),
6704
7625
  createOpen && h(CreateDialog, {
6705
7626
  t,
6706
7627
  onClose: () => setCreateOpen(false),
@@ -6776,6 +7697,25 @@ window.__ModuleLoader__.load({
6776
7697
  title: t("memory.explorer.filterArchivedHint"),
6777
7698
  onClick: () => setArchivedOnly(!archivedOnly)
6778
7699
  }, t("memory.explorer.filterArchived")),
7700
+ h("button", {
7701
+ className: foreignOnly ? "mneme-chip mneme-active" : "mneme-chip",
7702
+ // 搜索态不许**开启**这一档:/search 的 DTO 读不出行来源(`source` 被
7703
+ // searchMemories 盖成召回渠道,见 src/service.js:939),按一个假信号筛比筛不动
7704
+ // 更坏。但已经开着的时候永远许关掉——把它整个 disabled 会把她锁在一个解不开
7705
+ // 的状态里,只能先去清搜索词。
7706
+ disabled: !!query.trim() && !foreignOnly,
7707
+ title: query.trim()
7708
+ ? t("memory.explorer.filterForeignSearchHint")
7709
+ : t("memory.explorer.filterForeignHint"),
7710
+ "aria-pressed": String(foreignOnly),
7711
+ onClick: () => setForeignOnly(!foreignOnly)
7712
+ }, t("memory.explorer.filterForeign")),
7713
+ // 「为什么这一档在搜索时不按」不能只挂在 title 上:disabled 的按钮不发 focus,
7714
+ // 触屏与键盘用户拿不到提示,而她看到的是一个**亮着**的筛选压在一堆没筛过的
7715
+ // 结果上。所以开着外来档又在搜索时,屏上多一行看得见的说明。
7716
+ foreignOnly && !!query.trim() && h("div", {
7717
+ className: "mneme-featsubhint", style: { marginTop: 4 }
7718
+ }, t("memory.explorer.filterForeignSearchHint")),
6779
7719
  heatAvailable && h("button", {
6780
7720
  className: heatSort ? "mneme-chip mneme-active" : "mneme-chip",
6781
7721
  title: t("memory.explorer.heatSortHint"),
@@ -6833,6 +7773,7 @@ window.__ModuleLoader__.load({
6833
7773
  h(ImportanceStars, { value: m.importance || 0, size: 12 }),
6834
7774
  h(HeatBadge, { value: m.heat }),
6835
7775
  h(ScopeBadge, { m, t }),
7776
+ h(ForeignBadge, { m, t }),
6836
7777
  m.source && h("span", { className: "mneme-cardsrc", title: m.source }, m.source)
6837
7778
  )
6838
7779
  )),
@@ -6895,7 +7836,8 @@ window.__ModuleLoader__.load({
6895
7836
  h("span", { className: "mneme-xdot", style: { color: memoryTypeColor(m.type) }, title: typeLabel(t, m.type), "aria-hidden": "true" }),
6896
7837
  h("span", { className: "mneme-xtime" }, time),
6897
7838
  h("span", { className: "mneme-xname" }, m.title || m.content?.slice(0, 40)),
6898
- h(ScopeBadge, { m, t })
7839
+ h(ScopeBadge, { m, t }),
7840
+ h(ForeignBadge, { m, t })
6899
7841
  );
6900
7842
  })
6901
7843
  ))
@@ -6925,7 +7867,15 @@ window.__ModuleLoader__.load({
6925
7867
  deleteError,
6926
7868
  onClose: () => setSelectedId(null),
6927
7869
  onDelete: deleteById,
6928
- onSaved: applyMemoryUpdate
7870
+ onSaved: applyMemoryUpdate,
7871
+ onUndone: (n) => {
7872
+ // 撤掉的是整包:选中那一行已经不存在,关抽屉 + 刷新列表 + 一句 toast
7873
+ // (toast 形状照详情抽屉里 restored 那一条的先例,不带行号免得漂)。
7874
+ setSelectedId(null);
7875
+ setReloadKey((k) => k + 1);
7876
+ setToastMsg(t("memory.explorer.undoPackDone").replace("{n}", String(n)));
7877
+ setTimeout(() => setToastMsg(""), 2500);
7878
+ }
6929
7879
  })
6930
7880
  ),
6931
7881
  view === "entity" && h(EntityPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory }),