@modusensus/dsh-mneme 0.9.4 → 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 (80) hide show
  1. package/README.md +53 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +4 -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 +195 -1
  11. package/lib/client.js +783 -30
  12. package/lib/document.js +15 -2
  13. package/lib/dream/decisions.js +88 -3
  14. package/lib/dream/sleep.js +63 -13
  15. package/lib/dream.js +65 -9
  16. package/lib/lang.js +23 -6
  17. package/lib/memory-source.js +9 -0
  18. package/lib/organize.js +5 -1
  19. package/lib/pipeline.js +62 -1
  20. package/lib/provenance-http.js +126 -0
  21. package/lib/serve.js +13 -0
  22. package/lib/service.js +67 -4
  23. package/lib/share-http.js +281 -0
  24. package/lib/share-import.js +234 -0
  25. package/lib/share.js +335 -0
  26. package/lib/store.js +272 -11
  27. package/lib/summarize.js +60 -11
  28. package/package.json +1 -1
  29. package/src/api-standalone.js +286 -8
  30. package/src/api.js +195 -1
  31. package/src/document.js +15 -2
  32. package/src/dream/decisions.js +88 -3
  33. package/src/dream/sleep.js +63 -13
  34. package/src/dream.js +65 -9
  35. package/src/lang.js +23 -6
  36. package/src/memory-source.js +9 -0
  37. package/src/organize.js +5 -1
  38. package/src/pipeline.js +62 -1
  39. package/src/provenance-http.js +126 -0
  40. package/src/serve.js +13 -0
  41. package/src/service.js +67 -4
  42. package/src/share-http.js +281 -0
  43. package/src/share-import.js +234 -0
  44. package/src/share.js +335 -0
  45. package/src/store.js +272 -11
  46. package/src/summarize.js +60 -11
  47. package/test/api.test.js +370 -2
  48. package/test/audit.test.js +2 -2
  49. package/test/cli-receive.test.js +390 -0
  50. package/test/cli-share.test.js +437 -0
  51. package/test/client-share.test.js +225 -0
  52. package/test/client.test.js +139 -0
  53. package/test/dedupe-candidate-window.test.js +180 -0
  54. package/test/dream-candidate-hybrid.test.js +2 -2
  55. package/test/dream-fence.test.js +164 -0
  56. package/test/dream.test.js +87 -5
  57. package/test/helpers/dream-mock.js +6 -2
  58. package/test/llm-message-source.test.js +1 -1
  59. package/test/mint-source.test.js +1 -1
  60. package/test/pipeline-r1r2.test.js +129 -0
  61. package/test/policy-epoch.test.js +2 -2
  62. package/test/provenance-http.test.js +184 -0
  63. package/test/readme-claims.test.js +45 -0
  64. package/test/reasoning-effort.test.js +10 -10
  65. package/test/scope-candidates.test.js +7 -4
  66. package/test/scope-state.test.js +130 -1
  67. package/test/serve.test.js +62 -0
  68. package/test/share-foreign-rows.test.js +144 -0
  69. package/test/share-http.test.js +144 -0
  70. package/test/share-import.test.js +577 -0
  71. package/test/share-imported-guard.test.js +311 -0
  72. package/test/share-imported-identity.test.js +111 -0
  73. package/test/share-pack.test.js +511 -0
  74. package/test/share-receive-http.test.js +756 -0
  75. package/test/share-routes.test.js +337 -0
  76. package/test/share-store.test.js +133 -0
  77. package/test/share-validate.test.js +101 -0
  78. package/test/standalone-api.test.js +524 -5
  79. package/test/summarize-parse-diagnostics.test.js +75 -0
  80. package/test/summarize.test.js +15 -2
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,6 +313,9 @@ 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": "保存画像",
@@ -311,7 +350,7 @@ window.__ModuleLoader__.load({
311
350
  "memory.settings.personaDefaultCard": "默认卡(兜底)",
312
351
  "memory.settings.personaAdd": "添加卡",
313
352
  "memory.settings.personaAddPlaceholder": "输入 agent 身份名(如 nyx),按它寻址注入",
314
- "memory.settings.personaEditingDefault": "正在编辑:默认卡——没有命中任何身份卡的会话用它",
353
+ "memory.settings.personaEditingDefault": "正在编辑:默认卡——解析不到身份的会话一定用它;身份解析到了却没建卡(或卡被关掉)的会话,在默认口径下也落到这里,开着「严格人格寻址」则不注入人格",
315
354
  "memory.settings.personaEditingFor": "正在编辑身份卡:",
316
355
  "memory.settings.personaDeleteCard": "删除这张卡",
317
356
  "memory.settings.personaView": "Agent 眼中的你(注入生效文本)",
@@ -454,6 +493,11 @@ window.__ModuleLoader__.load({
454
493
  "memory.settings.mode.standard": "标准",
455
494
  "memory.settings.mode.savedHint": "已保存,重启 DSH 后生效",
456
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——那条走指令路径,不受这个压制。",
457
501
  "memory.settings.extapi.title": "外部访问 API",
458
502
  "memory.settings.extapi.desc": "开放一个本机接口,让其他工具(插件、命令行、桌面应用)也能读写这份记忆库,默认只允许本机连接。改动保存后重启 DSH 生效。",
459
503
  "memory.settings.extapi.enabled": "启用",
@@ -753,6 +797,61 @@ window.__ModuleLoader__.load({
753
797
  "memory.explorer.imported": "已合并 {n} 条编辑",
754
798
  "memory.explorer.importFailed": "导入失败",
755
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": "这个包在本库里已经没有行了",
756
855
  "memory.explorer.newMemory": "新增记忆…",
757
856
  "memory.explorer.newTitle": "新增记忆",
758
857
  "memory.explorer.newHint": "手动写一条记忆。同类型同标题会并入既有条目,而不是长出第二行。",
@@ -802,6 +901,10 @@ window.__ModuleLoader__.load({
802
901
  "memory.status.dreamNever": "尚未运行",
803
902
  "memory.status.conflicts": "待确认冲突",
804
903
  "memory.status.conflictsHint": "冻结的矛盾记忆,等待人工确认",
904
+ "memory.status.skippedTitle": "本轮跳过 {n} 条决定",
905
+ "memory.status.skippedHint": "这些决定被校验器拦下,未执行。同类原因已合并计数——出现一整类动作全部被拦,通常意味着提示词与护栏没对齐,值得看一眼。",
906
+ "memory.status.skippedMore": "另有 {n} 类",
907
+ "memory.status.skippedNone": "本轮没有跳过任何决定",
805
908
  "memory.status.injectSuppressed": "极简模式下注入按宿主设计关闭",
806
909
  "memory.status.injectSuppressedHint": "当前会话使用 minimal 预设:记忆注入 / 用户画像 / 热记忆不会送达模型(宿主设计,非插件缺陷)。切到标准模式,或在 ~/.dsh/settings.yaml 设 agent-presets.default: standard;过渡方案:把画像与规则写进 AGENTS.md",
807
910
  "memory.status.injectPreview": "注入预览",
@@ -995,6 +1098,9 @@ window.__ModuleLoader__.load({
995
1098
  "memory.settings.group.search": "Search",
996
1099
  "memory.settings.group.connect": "Connections & safety",
997
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}",
998
1104
  "memory.settings.profile": "User Profile",
999
1105
  "memory.settings.profileHint": "Describe yourself once — the agent reads it every turn.",
1000
1106
  "memory.settings.profileSave": "Save Profile",
@@ -1029,7 +1135,7 @@ window.__ModuleLoader__.load({
1029
1135
  "memory.settings.personaDefaultCard": "Default (fallback)",
1030
1136
  "memory.settings.personaAdd": "Add card",
1031
1137
  "memory.settings.personaAddPlaceholder": "Agent identity (e.g. nyx) used to address injection",
1032
- "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",
1033
1139
  "memory.settings.personaEditingFor": "Editing identity card:",
1034
1140
  "memory.settings.personaDeleteCard": "Delete this card",
1035
1141
  "memory.settings.personaView": "You, as the agent sees you (injected text)",
@@ -1168,6 +1274,9 @@ window.__ModuleLoader__.load({
1168
1274
  "memory.settings.mode.standard": "Standard",
1169
1275
  "memory.settings.mode.savedHint": "Saved. Takes effect after restarting DSH",
1170
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.",
1171
1280
  "memory.settings.extapi.title": "External API",
1172
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.",
1173
1282
  "memory.settings.extapi.enabled": "Enable",
@@ -1450,6 +1559,61 @@ window.__ModuleLoader__.load({
1450
1559
  "memory.explorer.imported": "Merged {n} edits",
1451
1560
  "memory.explorer.importFailed": "Import failed",
1452
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",
1453
1617
  "memory.explorer.newMemory": "New memory…",
1454
1618
  "memory.explorer.newTitle": "New memory",
1455
1619
  "memory.explorer.newHint": "Write one memory by hand. Same type + same title merges into the existing entry instead of minting a second row.",
@@ -1499,6 +1663,10 @@ window.__ModuleLoader__.load({
1499
1663
  "memory.status.dreamNever": "Not yet run",
1500
1664
  "memory.status.conflicts": "Pending conflicts",
1501
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",
1502
1670
  "memory.status.injectSuppressed": "Injection disabled by host minimal preset",
1503
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",
1504
1672
  "memory.status.injectPreview": "Injection preview",
@@ -2104,6 +2272,22 @@ window.__ModuleLoader__.load({
2104
2272
  ".mneme-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
2105
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}",
2106
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)}",
2107
2291
  // 瞬时操作提示(删除/归档):挂在浏览层底部居中,不被抽屉卸载吞掉
2108
2292
  ".mneme-x{position:relative}",
2109
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}",
@@ -2238,9 +2422,20 @@ window.__ModuleLoader__.load({
2238
2422
  // self-contained unit inside the stacked settings view ---
2239
2423
  ".mneme-set-card{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;margin-bottom:10px}",
2240
2424
  // 设置页分组标题(个性化 / 记忆引擎 / 搜索 / 连接与安全 / 帮助):
2241
- // 先给「这一段在管什么」,卡片才是具体选项
2242
- ".mneme-set-grouphead{font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--mneme-fg-dim);margin:24px 0 10px}",
2243
- ".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}",
2244
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}",
2245
2440
  ".mneme-set-hint{font-size:12px;color:var(--mneme-fg-dim)}",
2246
2441
  // --- 重要性编辑行:与查看态同一份 Lucide 星形(此前是文本星号下拉,角更尖锐且与其他星不统一) ---
@@ -3546,6 +3741,31 @@ window.__ModuleLoader__.load({
3546
3741
  return "Unknown";
3547
3742
  };
3548
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
+
3549
3769
  function SettingsContent({ t }) {
3550
3770
  const [profile, setProfile] = react.useState("");
3551
3771
  const [rules, setRules] = react.useState([]);
@@ -3568,6 +3788,10 @@ window.__ModuleLoader__.load({
3568
3788
  const [personaNewName, setPersonaNewName] = react.useState("");
3569
3789
  const [personaSaved, setPersonaSaved] = react.useState(false);
3570
3790
  const [personaError, setPersonaError] = react.useState("");
3791
+ // #514:人是在这一页开关人格的,所以「为什么关了没反应」的答案必须在
3792
+ // 这一页。与状态页告示卡共用 useInjectStatus,两边口径不会各自漂。
3793
+ const injectStatus = useInjectStatus();
3794
+ const minimalWarn = !injectStatus.loading && injectStatus.suppressed;
3571
3795
  // 注入预览:文本由后端渲染(GET /persona 的 preview 字段),前端只显示、不自己拼。
3572
3796
  // 原先这里是一段前端手拼的 profile + rules 列表,注释却写着「注入生效文本」——
3573
3797
  // 它既没有表头、没有「- 用户画像:」前缀,也完全没有人格三行,两份渲染代码必然漂。
@@ -4059,9 +4283,43 @@ window.__ModuleLoader__.load({
4059
4283
  );
4060
4284
  }
4061
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
+
4062
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
+ ),
4063
4321
  // —— 个性化:画像 / 人格 / 规则 / 自定义指令(Agent 怎么对你) ——
4064
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.personal")),
4322
+ groupHead("personal"),
4065
4323
  // 用户画像 — who the agent is talking to
4066
4324
  h("section", { className: "mneme-set-card" },
4067
4325
  h("div", { className: "mneme-set-title" }, t("memory.settings.profile")),
@@ -4112,6 +4370,14 @@ window.__ModuleLoader__.load({
4112
4370
  h("section", { className: "mneme-set-card" },
4113
4371
  h("div", { className: "mneme-set-title" }, t("memory.settings.persona")),
4114
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
+ ),
4115
4381
  // 卡列表:default + 各 byAgent 卡,点选切换编辑位;default 标注兜底位
4116
4382
  h("div", { style: { display: "flex", flexWrap: "wrap", gap: 6 } },
4117
4383
  h("button", {
@@ -4292,7 +4558,7 @@ window.__ModuleLoader__.load({
4292
4558
  )
4293
4559
  ),
4294
4560
  // —— 记忆引擎:运行模式(粗粒度)在前,功能开关(细粒度)紧随 ——
4295
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.engine")),
4561
+ groupHead("engine"),
4296
4562
  // 运行模式 — light vs standard chip radios; each click PUTs and the
4297
4563
  // change only lands after a DSH restart (green saved hint says so).
4298
4564
  h("section", { className: "mneme-set-card" },
@@ -4302,6 +4568,10 @@ window.__ModuleLoader__.load({
4302
4568
  mode === null && !modeError
4303
4569
  ? h("div", { className: "mneme-set-hint" }, "…")
4304
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")),
4305
4575
  h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
4306
4576
  h("button", {
4307
4577
  className: mode === "light" ? "mneme-chip mneme-active" : "mneme-chip",
@@ -4317,12 +4587,20 @@ window.__ModuleLoader__.load({
4317
4587
  ),
4318
4588
  mode === "light" && h("div", { className: "mneme-set-hint", style: { marginTop: 8 } },
4319
4589
  t("memory.settings.mode.offList"))
4320
- )
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
+ )
4321
4599
  ),
4322
4600
  // 功能开关 — 后端能力的细粒度闸门;改动即时保存,重启 DSH 后生效。
4323
4601
  h(FeaturesCard, { t }),
4324
4602
  // —— 搜索:语义召回 ——
4325
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.search")),
4603
+ groupHead("search"),
4326
4604
  // 向量搜索 — semantic recall over an embeddings API
4327
4605
  h("section", { className: "mneme-set-card" },
4328
4606
  h("div", { className: "mneme-set-title" }, t("memory.settings.vectorTitle")),
@@ -4342,7 +4620,7 @@ window.__ModuleLoader__.load({
4342
4620
  )
4343
4621
  ),
4344
4622
  // —— 连接与安全:外部访问 API / 面板令牌 ——
4345
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.connect")),
4623
+ groupHead("connect"),
4346
4624
  // 外部访问 API — standalone HTTP service for plugins/CLI/desktop tools;
4347
4625
  // the token is generated and kept by the backend, so it is read-only
4348
4626
  // here with a copy affordance. Changes need a DSH restart.
@@ -4459,7 +4737,7 @@ window.__ModuleLoader__.load({
4459
4737
  apiTokenSaved && h("div", { style: { marginTop: 8 } }, h("span", { className: "mneme-saved" }, t("memory.settings.apiTokenSaved")))
4460
4738
  ),
4461
4739
  // —— 帮助:支持与鼓励(图标行 + 检测更新 + star,兼当「关于」收尾) ——
4462
- h("div", { className: "mneme-set-grouphead" }, t("memory.settings.group.help")),
4740
+ groupHead("help"),
4463
4741
  // 反馈三入口:新建 issue 预填(版本+平台)/ 邮件 / 浏览已知问题。
4464
4742
  // 纯前端链接零后端成本;插件版本来自 /info,平台取 userAgent。GitHub
4465
4743
  // 仓库当前没有 issue 模板,故用 issues/new?title=&body= 直接预填。
@@ -5191,7 +5469,10 @@ window.__ModuleLoader__.load({
5191
5469
  const [stats, setStats] = useState(null);
5192
5470
  const [heatStats, setHeatStats] = useState(null);
5193
5471
  const [statsError, setStatsError] = useState(false);
5194
- 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: [] });
5195
5476
  const [focusOp, setFocusOp] = useState(null);
5196
5477
  useEffect(() => {
5197
5478
  let cancelled = false;
@@ -5220,9 +5501,14 @@ window.__ModuleLoader__.load({
5220
5501
  .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
5221
5502
  .then((d) => {
5222
5503
  if (cancelled) return;
5223
- 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
+ });
5224
5510
  })
5225
- .catch(() => { if (!cancelled) setConf({ loading: false, error: true, pending: 0 }); });
5511
+ .catch(() => { if (!cancelled) setConf({ loading: false, error: true, pending: 0, skipped: [] }); });
5226
5512
  };
5227
5513
  loadConflicts();
5228
5514
  window.addEventListener("mneme:conflicts-changed", loadConflicts);
@@ -5355,6 +5641,7 @@ window.__ModuleLoader__.load({
5355
5641
  cap: t("memory.status.token.conflictsFoot")
5356
5642
  })
5357
5643
  ),
5644
+ h(SkippedReasonsNotice, { t, buckets: conf.skipped, loading: conf.loading, error: conf.error }),
5358
5645
  h("div", { className: "mneme-tokenflow" },
5359
5646
  h("h3", { className: "mneme-xcolhead" }, t("memory.status.token.donut")),
5360
5647
  statsError
@@ -5442,6 +5729,28 @@ window.__ModuleLoader__.load({
5442
5729
  );
5443
5730
  }
5444
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
+ }
5445
5754
  // 巩固状态卡(×2):最近一次 autoDream 运行 + 待确认冲突计数。数据来自
5446
5755
  // /dream-status,一次取回两张卡共用;失败只影响这两张卡自身。
5447
5756
  // showConflicts=false 用于 2026-10 改造后的降级折叠区:待确认冲突已升入
@@ -5479,6 +5788,8 @@ window.__ModuleLoader__.load({
5479
5788
  // 激活直达队列——StatusPanel 把 onGotoQueue 传进来(一步 prop,无线程)。
5480
5789
  const pending = state.pending > 0;
5481
5790
  const gotoQueue = () => { if (pending && onGotoQueue) onGotoQueue(); };
5791
+ // #521 的跳过明细不在这里渲染:它已提到 TokenFlowSection 的 KPI 带下方,
5792
+ // 首屏可见。这张卡在折叠区里,展开后再重复一遍只会让人以为有两批跳过。
5482
5793
  return h(react.Fragment, null,
5483
5794
  h(StatusCard, { t, title: t("memory.status.dream"), loading: state.loading, error: state.error, num, cap }),
5484
5795
  showConflicts
@@ -5508,15 +5819,7 @@ window.__ModuleLoader__.load({
5508
5819
  // 主动提示,把「为什么没注入」从逐帧解压日志降为看一眼状态页。只在
5509
5820
  // suppressed=true 时渲染——standard 会话与 preset 未知的宿主零打扰。
5510
5821
  function InjectStatusCard({ t }) {
5511
- const [state, setState] = useState({ loading: true, suppressed: false });
5512
- useEffect(() => {
5513
- let cancelled = false;
5514
- apiFetch("/api/dsh-mneme/inject-status")
5515
- .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
5516
- .then((j) => { if (!cancelled) setState({ loading: false, suppressed: !!(j && j.suppressed) }); })
5517
- .catch(() => { if (!cancelled) setState({ loading: false, suppressed: false }); });
5518
- return () => { cancelled = true; };
5519
- }, []);
5822
+ const state = useInjectStatus();
5520
5823
  if (state.loading || !state.suppressed) return null;
5521
5824
  return h("div", { className: "mneme-statuscard", style: { borderColor: "var(--mneme-warn)" } },
5522
5825
  h("div", { className: "mneme-statusnum", style: { fontSize: "16px", lineHeight: "24px" } }, t("memory.status.injectSuppressed")),
@@ -6085,7 +6388,7 @@ window.__ModuleLoader__.load({
6085
6388
  decidedAt ?? null
6086
6389
  ].filter(Boolean).join(" · ");
6087
6390
 
6088
- function MemoryDrawer({ t, memory, conflict, deleting, deleteError, onClose, onDelete, onSaved }) {
6391
+ function MemoryDrawer({ t, memory, conflict, deleting, deleteError, onClose, onDelete, onSaved, onUndone }) {
6089
6392
  const [editing, setEditing] = useState(false);
6090
6393
  const [title, setTitle] = useState(memory.title || "");
6091
6394
  const [content, setContent] = useState(memory.content || "");
@@ -6102,6 +6405,14 @@ window.__ModuleLoader__.load({
6102
6405
  const [saveError, setSaveError] = useState(false);
6103
6406
  const [copied, setCopied] = useState(false);
6104
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);
6105
6416
 
6106
6417
  useEffect(() => {
6107
6418
  let cancelled = false;
@@ -6132,6 +6443,34 @@ window.__ModuleLoader__.load({
6132
6443
  .finally(() => setBusy(false));
6133
6444
  };
6134
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
+
6135
6474
  const save = () => {
6136
6475
  const t2 = title.trim();
6137
6476
  if (!t2) return;
@@ -6246,6 +6585,11 @@ window.__ModuleLoader__.load({
6246
6585
  h("span", { className: "mneme-dmetakey" }, t("memory.explorer.updated")),
6247
6586
  h("span", { className: "mneme-dmetaval", title: formatDate(memory.updated_at) },
6248
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)),
6249
6593
  Array.isArray(memory.tags) && memory.tags.length > 0 && h(react.Fragment, null,
6250
6594
  h("span", { className: "mneme-dmetakey" }, t("memory.explorer.tags")),
6251
6595
  h("span", { className: "mneme-dmetaval" }, memory.tags.join(" · ")))
@@ -6321,7 +6665,41 @@ window.__ModuleLoader__.load({
6321
6665
  : h("button", { type: "button", className: "mneme-btn mneme-btndanger", onClick: () => setConfirmDelete(true) },
6322
6666
  t("memory.explorer.delete")),
6323
6667
  deleteError && h("span", { style: { fontSize: 12, color: "var(--mneme-danger)" } },
6324
- 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"))
6325
6703
  )
6326
6704
  )
6327
6705
  );
@@ -6385,6 +6763,338 @@ window.__ModuleLoader__.load({
6385
6763
  );
6386
6764
  }
6387
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
+
6388
7098
  // #386 第 5 条:面板手动新增一条记忆。字段与 memory_save 工具对齐
6389
7099
  //(type/title/content/importance/tags),但**不放 scope/sensitivity**:归属的
6390
7100
  // 显式声明走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖
@@ -6527,11 +7237,14 @@ window.__ModuleLoader__.load({
6527
7237
  // 方案 A:沉淀/归档筛选 chip——状态页「查看全部」也会带着它们跳转过来。
6528
7238
  const [depositedOnly, setDepositedOnly] = useState(false); // 只看 autoDream 巩固
6529
7239
  const [archivedOnly, setArchivedOnly] = useState(false); // 只看已归档
7240
+ const [foreignOnly, setForeignOnly] = useState(false); // 只看外来行(source=imported)
6530
7241
  const [heatSort, setHeatSort] = useState(false); // 卡片页内热度降序(阶段二补口)
6531
7242
  const [pendingIds, setPendingIds] = useState(() => new Set());
6532
7243
  const [menuOpen, setMenuOpen] = useState(false);
6533
7244
  const [importOpen, setImportOpen] = useState(false);
6534
7245
  const [createOpen, setCreateOpen] = useState(false);
7246
+ const [shareOpen, setShareOpen] = useState(false);
7247
+ const [receiveOpen, setReceiveOpen] = useState(false);
6535
7248
  const itemRefs = useRef(new Map());
6536
7249
  const moreRef = useRef(null);
6537
7250
 
@@ -6569,7 +7282,8 @@ window.__ModuleLoader__.load({
6569
7282
  + (minImp ? `&minImportance=${minImp}` : "")
6570
7283
  + (dateFromIso ? `&updatedFrom=${encodeURIComponent(dateFromIso)}` : "")
6571
7284
  + (depositedOnly ? "&deposited=only" : "")
6572
- + (archivedOnly ? "&archived=only" : "");
7285
+ + (archivedOnly ? "&archived=only" : "")
7286
+ + (foreignOnly ? "&source=imported" : "");
6573
7287
  const listUrl = (offset) => `/api/dsh-mneme/list?limit=${PAGE_SIZE}&offset=${offset}${filterQS}&order=chrono`;
6574
7288
 
6575
7289
  // Browse = paged, pure-chronological pages (order=chrono). The default
@@ -6881,7 +7595,7 @@ window.__ModuleLoader__.load({
6881
7595
  "aria-label": t("memory.explorer.more"),
6882
7596
  "aria-expanded": String(menuOpen),
6883
7597
  title: t("memory.explorer.more"),
6884
- onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); setCreateOpen(false); }
7598
+ onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); setCreateOpen(false); setShareOpen(false); setReceiveOpen(false); }
6885
7599
  }, "⋯"),
6886
7600
  menuOpen && h("div", { className: "mneme-menu", role: "menu" },
6887
7601
  h("button", { type: "button", role: "menuitem", onClick: () => { setCreateOpen(true); setMenuOpen(false); } },
@@ -6891,13 +7605,23 @@ window.__ModuleLoader__.load({
6891
7605
  h("button", { type: "button", role: "menuitem", onClick: () => { setMenuOpen(false); exportData("markdown"); } },
6892
7606
  t("memory.explorer.exportMarkdown")),
6893
7607
  h("button", { type: "button", role: "menuitem", onClick: () => { setImportOpen(true); setMenuOpen(false); } },
6894
- 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")),
6895
7613
  ),
6896
7614
  importOpen && h(ImportDialog, {
6897
7615
  t,
6898
7616
  onClose: () => setImportOpen(false),
6899
7617
  onImported: () => setReloadKey((k) => k + 1)
6900
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
+ }),
6901
7625
  createOpen && h(CreateDialog, {
6902
7626
  t,
6903
7627
  onClose: () => setCreateOpen(false),
@@ -6973,6 +7697,25 @@ window.__ModuleLoader__.load({
6973
7697
  title: t("memory.explorer.filterArchivedHint"),
6974
7698
  onClick: () => setArchivedOnly(!archivedOnly)
6975
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")),
6976
7719
  heatAvailable && h("button", {
6977
7720
  className: heatSort ? "mneme-chip mneme-active" : "mneme-chip",
6978
7721
  title: t("memory.explorer.heatSortHint"),
@@ -7030,6 +7773,7 @@ window.__ModuleLoader__.load({
7030
7773
  h(ImportanceStars, { value: m.importance || 0, size: 12 }),
7031
7774
  h(HeatBadge, { value: m.heat }),
7032
7775
  h(ScopeBadge, { m, t }),
7776
+ h(ForeignBadge, { m, t }),
7033
7777
  m.source && h("span", { className: "mneme-cardsrc", title: m.source }, m.source)
7034
7778
  )
7035
7779
  )),
@@ -7092,7 +7836,8 @@ window.__ModuleLoader__.load({
7092
7836
  h("span", { className: "mneme-xdot", style: { color: memoryTypeColor(m.type) }, title: typeLabel(t, m.type), "aria-hidden": "true" }),
7093
7837
  h("span", { className: "mneme-xtime" }, time),
7094
7838
  h("span", { className: "mneme-xname" }, m.title || m.content?.slice(0, 40)),
7095
- h(ScopeBadge, { m, t })
7839
+ h(ScopeBadge, { m, t }),
7840
+ h(ForeignBadge, { m, t })
7096
7841
  );
7097
7842
  })
7098
7843
  ))
@@ -7122,7 +7867,15 @@ window.__ModuleLoader__.load({
7122
7867
  deleteError,
7123
7868
  onClose: () => setSelectedId(null),
7124
7869
  onDelete: deleteById,
7125
- 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
+ }
7126
7879
  })
7127
7880
  ),
7128
7881
  view === "entity" && h(EntityPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory }),