@modusensus/dsh-mneme 0.10.0 → 0.11.0

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 (79) hide show
  1. package/README.md +11 -3
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +8 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +298 -12
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1153 -106
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +28 -0
  12. package/lib/graph/anchoring.js +7 -2
  13. package/lib/index.js +50 -4
  14. package/lib/inject.js +15 -3
  15. package/lib/lang.js +54 -0
  16. package/lib/quality-filter.js +48 -12
  17. package/lib/service.js +72 -8
  18. package/lib/settings.js +8 -1
  19. package/lib/store.js +657 -0
  20. package/lib/tools.js +127 -1
  21. package/lib/wiki/inject.js +164 -0
  22. package/lib/wiki/partition.js +200 -0
  23. package/lib/wiki/plan.js +214 -0
  24. package/lib/wiki/queries.js +220 -0
  25. package/lib/wiki/render.js +187 -0
  26. package/lib/wiki/scan.js +371 -0
  27. package/lib/wiki/t0.js +463 -0
  28. package/lib/wiki/t1.js +496 -0
  29. package/lib/write-admission.js +5 -3
  30. package/package.json +4 -2
  31. package/src/api-standalone.js +17 -21
  32. package/src/api.js +298 -12
  33. package/src/bootstrap-http.js +105 -0
  34. package/src/bootstrap.js +5 -1
  35. package/src/choro-command.js +104 -0
  36. package/src/config-keys.js +144 -0
  37. package/src/config.js +28 -0
  38. package/src/graph/anchoring.js +7 -2
  39. package/src/index.js +50 -4
  40. package/src/inject.js +15 -3
  41. package/src/lang.js +54 -0
  42. package/src/quality-filter.js +48 -12
  43. package/src/service.js +72 -8
  44. package/src/settings.js +8 -1
  45. package/src/store.js +657 -0
  46. package/src/tools.js +127 -1
  47. package/src/wiki/inject.js +164 -0
  48. package/src/wiki/partition.js +200 -0
  49. package/src/wiki/plan.js +214 -0
  50. package/src/wiki/queries.js +220 -0
  51. package/src/wiki/render.js +187 -0
  52. package/src/wiki/scan.js +371 -0
  53. package/src/wiki/t0.js +463 -0
  54. package/src/wiki/t1.js +496 -0
  55. package/src/write-admission.js +5 -3
  56. package/test/api-routes-gaps.test.js +131 -1
  57. package/test/api.test.js +7 -1
  58. package/test/bootstrap.test.js +8 -0
  59. package/test/choro-command.test.js +218 -0
  60. package/test/client.test.js +820 -19
  61. package/test/config-keys-routes.test.js +233 -0
  62. package/test/contrast.test.js +14 -7
  63. package/test/graph-boosted-relations.test.js +189 -0
  64. package/test/graph-multipath.test.js +269 -0
  65. package/test/inject.test.js +51 -0
  66. package/test/llm-audit.test.js +29 -1
  67. package/test/quality-backfill.test.js +290 -0
  68. package/test/quality-filter.test.js +113 -4
  69. package/test/service.test.js +1 -1
  70. package/test/wiki-api.test.js +153 -0
  71. package/test/wiki-inject.test.js +265 -0
  72. package/test/wiki-partition.test.js +112 -0
  73. package/test/wiki-plan.test.js +80 -0
  74. package/test/wiki-queries.test.js +187 -0
  75. package/test/wiki-scan.test.js +208 -0
  76. package/test/wiki-store.test.js +190 -0
  77. package/test/wiki-t0.test.js +380 -0
  78. package/test/wiki-t1.test.js +235 -0
  79. package/test/wiki-tools.test.js +121 -0
package/lib/client.js CHANGED
@@ -301,6 +301,17 @@ window.__ModuleLoader__.load({
301
301
  zh: {
302
302
  "memory.panel.empty": "暂无记忆条目",
303
303
  "memory.panel.open": "记忆",
304
+ "wiki.panel.open": "仓库地志",
305
+ "wiki.empty.noRepo": "还没有登记的仓库。先在仓库目录里跑 `dsh-mneme-choro <仓库路径> --apply` 生成骨架页并登记,然后回到这里。",
306
+ "wiki.empty.disabled": "chorography(仓库地志)未启用:在「设置」里打开「仓库地志」后重启 DSH 生效。",
307
+ "wiki.empty.pickRepo": "已登记多个仓库,选一个:",
308
+ "wiki.search.placeholder": "在页面里检索…",
309
+ "wiki.state.loading": "加载中…",
310
+ "wiki.state.pickPage": "从左侧选一个模块页。",
311
+ "wiki.state.pageMissing": "索引还在、磁盘页面缺失:在仓库目录重跑一次 `dsh-mneme-choro <仓库路径> --apply` 即可重建。",
312
+ "wiki.state.unavailable": "chorography 查询装配不可用(宿主环境缺件)。",
313
+ "wiki.state.error": "加载失败",
314
+ "wiki.tree.title": "模块",
304
315
  "memory.tab.all": "全部",
305
316
  "memory.tab.preference": "偏好",
306
317
  "memory.tab.project": "项目",
@@ -322,16 +333,21 @@ window.__ModuleLoader__.load({
322
333
  "memory.settings.profileSaved": "画像已保存",
323
334
  "memory.settings.profileExtract": "从记忆提取草稿",
324
335
  "memory.settings.profileExtracting": "正在提取…",
325
- "memory.settings.profileExtractHint": "会花一次模型调用,只在你点这个按钮时发生;提取出来的是草稿,不采用就不进库,你手写的画像也不会被覆盖。",
336
+ "memory.settings.profileExtractHint": "会花一次模型调用,只在你点这个按钮时发生;提取出来的是草稿副本,只在这里展示——不会写进记忆库,也不会动你自己手写的「用户画像」。想留下它,点「复制草稿」。",
326
337
  "memory.settings.profileExtractDisabled": "没有提取:功能开关里的「画像提取草稿」还没打开。",
327
338
  "memory.settings.profileExtractEmpty": "库里没有可用的记忆条目,这次没有能提炼的内容(空输入下硬写一段画像只会是编的)。",
328
339
  "memory.settings.profileExtractNoRoute": "没有可用的模型:先在宿主里选一个默认模型(或在配置里填 dreamProvider / dreamModel),再点一次。",
329
340
  "memory.settings.profileSaveFailed": "画像没存进去",
330
341
  "memory.settings.profileExtractFailed": "提取没成:模型没按格式返回,或这次调用失败了。你现有的画像一个字都没被改动。",
331
- "memory.settings.profileDraftTitle": "草稿(还没保存)",
342
+ "memory.settings.profileDraftTitle": "草稿副本(不会自动保存)",
332
343
  "memory.settings.profileDraftRows": "依据记忆条数",
344
+ "memory.status.entitiesSampled": "分类计数只统计最近 500 个实体;总数是全库",
345
+ "memory.status.entitiesUnknown": "总数读不到;下面是最近 500 个实体的分类计数",
333
346
  "memory.settings.profileDraftTruncated": "条目较多,这次只读了前面一部分",
334
- "memory.settings.profileDraftApply": "采用为画像",
347
+ "memory.settings.profileDraftCopy": "复制草稿",
348
+ "memory.settings.profileDraftCopied": "已复制",
349
+ "memory.settings.profileDraftCopyHint": "「用户画像」那一栏只由你自己填写;要把它写进去,请粘贴后自己点保存。",
350
+ "memory.settings.profileDraftCopyFailed": "复制没成功:请选中上面的草稿手动复制。",
335
351
  "memory.settings.profileDraftDiscard": "丢弃草稿",
336
352
  "memory.settings.persona": "人格定义(beta)",
337
353
  "memory.settings.personaHint": "给这位 Agent 一张角色卡:名字、性格、回复要求。开关打开且下方「人格定义」总闸开启时,每轮对话都会注入。",
@@ -368,7 +384,14 @@ window.__ModuleLoader__.load({
368
384
  "memory.features.personaStrictAddressing": "严格人格寻址",
369
385
  "memory.features.personaStrictAddressing.hint": "开:会话身份没有对应人格卡、或那张卡被关掉时,就不注入人格——默认卡只兜「解析不到会话身份」的会话。宿主里跑着自带身份的 bot(DS Bot 那类)时关掉这种兜底,免得两句「你是谁」撞在同一轮。默认关 = 未命中就落默认卡(历史行为)。",
370
386
  "memory.features.profileExtractEnabled": "画像提取草稿",
371
- "memory.features.profileExtractEnabled.hint": "打开「设置 → 个性化 → 用户画像」里的「从记忆提取草稿」按钮:读一批活跃记忆、花一次模型调用,产一段「助手眼中的用户」草稿。草稿要你点「采用」才会写进画像,绝不自动覆盖你手写的原文。轻量档预设关(用户显式勾选仍然赢)。",
387
+ "memory.features.profileExtractEnabled.hint": "打开「设置 → 个性化 → Agent 眼中的你」卡上的「从记忆提取草稿」按钮:读一批活跃记忆、花一次模型调用,产一段「助手眼中的用户」草稿。草稿只在那张卡上展示副本,可一键复制,不会写进记忆库、也不会覆盖你自己填的「用户画像」。轻量档预设关(用户显式勾选仍然赢)。",
388
+ "memory.features.chorographyEnabled": "仓库地志(chorography)",
389
+ "memory.features.chorographyEnabled.hint": "读一遍仓库写成一份带页码的志书:确定性骨架零模型、可以随时重建;语义段由 CLI 的 headless 会话逐模块补。打开后有四个入口——模型侧三个只读工具、面板「仓库地志」浏览页、每轮的开局提纲注入、/choro 命令。关着时它们全部不存在(模型工具列表里根本没有,注入块不渲染,/choro 不注册)。默认关。",
390
+ "memory.features.chorographyInject": "开局注入档位",
391
+ "memory.features.chorographyInject.hint": "每轮往上下文里塞地志提纲的份量:light 只列文件数最多的 6 个模块(约 8 行)、full 列全部模块、off 不注入(工具与面板页仍可用)。只注模块名 / 文件数 / 页状态 / 页相对路径,不含正文;正文要模型自己用 choro_read_page 取。轻量档预设压成 off。",
392
+ "memory.features.chorographyInject.off": "off(不注入)",
393
+ "memory.features.chorographyInject.light": "light(前 6 个模块)",
394
+ "memory.features.chorographyInject.full": "full(全部模块)",
372
395
  "memory.settings.rules": "规则",
373
396
  "memory.settings.rulesHint": "给 Agent 立下必须遵守的规矩,随时增删,下一轮即生效。",
374
397
  "memory.settings.ruleAdd": "添加规则",
@@ -390,6 +413,7 @@ window.__ModuleLoader__.load({
390
413
  "memory.explorer.tabSettings": "设置",
391
414
  "memory.explorer.tabStatus": "状态",
392
415
  "memory.entities.count": "{n} 个实体",
416
+ "memory.entities.countSampled": "列表只列最近 {shown} 个;上方总数是全库",
393
417
  "memory.entities.pick": "从左侧选择一个实体,查看它的属性与关系",
394
418
  // 搜索框有字但一条没筛出来时用它;与 entities.none(库里真没实体)分开,
395
419
  // 否则「暂无实体」会把打错一个字误导成「你还没开抽取」。
@@ -452,6 +476,23 @@ window.__ModuleLoader__.load({
452
476
  "memory.graph.viewInGraph": "在实体视图中打开",
453
477
  "memory.graph.loading": "加载中…",
454
478
  "memory.graph.distance": "距中心 {n} 跳",
479
+ // issue #24 块4:被动确认的复核面。边侧卡显示权重/来源,复核区列「抬过的边」。
480
+ "memory.graph.weight": "权重 {w}",
481
+ "memory.graph.source": "来源 {s}",
482
+ "memory.graph.boosted.title": "被用出来的边",
483
+ "memory.graph.boosted.hint": "正常触达会自动抬高边的权重,这里列出被抬过的那些——抬升是唯一会改变检索形状的动作。",
484
+ "memory.graph.boosted.empty": "当前筛选下没有边(只列权重已高于其来源基线的边)",
485
+ "memory.graph.boosted.loadFailed": "这一屏没取到数据(接口不可用),不是「没有边」——稍后重试。",
486
+ "memory.graph.boosted.gateOff": "被动确认当前是关的,今后不会再自动抬权;下面列的是此前已经抬过的边。",
487
+ "memory.graph.boosted.show": "查看被用出来的边",
488
+ "memory.graph.boosted.hide": "收起",
489
+ "memory.graph.boosted.gain": "+{g}",
490
+ // 边的来源(#24 块2 的 RELATION_SOURCE_DEFAULTS):决定权重的起点,
491
+ // 所以复核时必须和权重一起显示,否则「0.5 比 1.0 弱」这种误读无可避免。
492
+ "memory.graph.src.manual": "人工建立",
493
+ "memory.graph.src.confirmed": "人工确认",
494
+ "memory.graph.src.tag": "标签共现",
495
+ "memory.graph.src.llm": "模型抽取",
455
496
  "memory.settings.vectorTitle": "向量搜索",
456
497
  "memory.settings.vectorHint": "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。",
457
498
  "memory.settings.vectorEnabled": "启用向量搜索",
@@ -518,6 +559,15 @@ window.__ModuleLoader__.load({
518
559
  "memory.settings.mcp.snippet": "{ \"mcpServers\": { \"dsh-mneme\": { \"command\": \"dsh-mneme-mcp\", \"env\": { \"DSH_MNEME_TOKEN\": \"<外部访问 API 的 token>\" } } } }",
519
560
  "memory.settings.mcp.copy": "复制",
520
561
  "memory.settings.mcp.copied": "已复制",
562
+ "memory.settings.configKeys.title": "配置键覆盖面",
563
+ "memory.settings.configKeys.desc": "配置文件里的键,面板能改多少、哪些只能手改。只列键名,不显示任何值。",
564
+ "memory.settings.configKeys.summary": "schema 里共 {schema} 个键:面板能改 {panel} 个,{file} 个只能改配置文件,另有 {undefaulted} 个登记了却没有默认值。",
565
+ "memory.settings.configKeys.fileOnlyHead": "只能改配置文件({n} 个)",
566
+ "memory.settings.configKeys.undefaultedHead": "面板登记了、但运行时没有默认值({n} 个)",
567
+ "memory.settings.configKeys.undefaultedHint": "这些键在白名单里,所以面板上有输入框;但 schema 没给默认值,留空时「当前生效值」里就没有它。填一个值才会生效。",
568
+ "memory.settings.configKeys.show": "展开清单",
569
+ "memory.settings.configKeys.hide": "收起清单",
570
+ "memory.settings.configKeys.loadFailed": "没能取到这份清单(接口暂时不可用)——这不等于「没有只能改文件的键」。",
521
571
  "memory.settings.mcp.hint": "数据面走「外部访问 API」(默认 127.0.0.1:8790,Bearer),token 就是上面那张;可用 DSH_MNEME_URL / DSH_MNEME_TOKEN 环境变量或 ~/.dsh-mneme/cli.json 覆盖。command 需在 PATH 上(随 @modusensus/dsh-mneme 安装自带)。",
522
572
  "memory.settings.extapi.savedHint": "已保存,重启 DSH 后生效",
523
573
  "memory.settings.extapi.invalidPort": "端口需为 1-65535 的数字",
@@ -797,6 +847,19 @@ window.__ModuleLoader__.load({
797
847
  "memory.explorer.imported": "已合并 {n} 条编辑",
798
848
  "memory.explorer.importFailed": "导入失败",
799
849
  "memory.explorer.importNoFile": "请先选择文件",
850
+ "memory.explorer.bootstrap": "从目录引导…",
851
+ "memory.explorer.bootstrapTitle": "从目录引导",
852
+ "memory.explorer.bootstrapHint": "读这个目录里的确定性产物(脚本命令、README、贡献规范、CI 工作流、顶层结构、最近提交),一条规则一条记忆。不调用模型,重复执行只会合并更新、不会重复入库。",
853
+ "memory.explorer.bootstrapDir": "目录路径",
854
+ "memory.explorer.bootstrapConfirm": "开始引导",
855
+ "memory.explorer.bootstraping": "引导中…",
856
+ "memory.explorer.bootstrapDone": "新建 {n} 条、并入 {m} 条",
857
+ "memory.explorer.bootstrapNoDir": "请先填写目录路径",
858
+ "memory.explorer.bootstrapFailed": "引导失败",
859
+ "memory.explorer.bootstrapErr.missing-dir": "没给目录路径",
860
+ "memory.explorer.bootstrapErr.dir-not-found": "这个路径不存在",
861
+ "memory.explorer.bootstrapErr.not-a-directory": "这个路径不是目录",
862
+ "memory.explorer.bootstrapErr.bootstrap-unavailable": "当前装配下这条能力没接线",
800
863
  "memory.explorer.sharePack": "分享记忆包…",
801
864
  "memory.explorer.shareTitle": "分享记忆包",
802
865
  "memory.explorer.shareHint": "把本机活跃记忆打成一个可外发的包:密钥/PII 整条不发,本机路径抹成 ~,归档与遗忘的不进。",
@@ -1079,6 +1142,13 @@ window.__ModuleLoader__.load({
1079
1142
  "memory.status.token.heat": "8 周热力 · 每天花了多少",
1080
1143
  "memory.status.token.heatLess": "少",
1081
1144
  "memory.status.token.heatMore": "多",
1145
+ "memory.status.token.sink": "8 周沉淀 · 进来的 vs 稳住的",
1146
+ "memory.status.token.sinkAdded": "新增",
1147
+ "memory.status.token.sinkMerged": "巩固",
1148
+ "memory.status.token.sinkArchived": "归档",
1149
+ "memory.status.token.sinkGap": "巩固/新增",
1150
+ "memory.status.token.sinkEmpty": "最近还没有记录",
1151
+ "memory.status.token.sinkFoot": "巩固按去重记录数计,同一条被多轮合并只算一次",
1082
1152
  "memory.status.token.last7": "近 7 天",
1083
1153
  "memory.status.token.meterOnly": "(只计量)",
1084
1154
  "memory.status.token.detail": "详细状态卡(原「库内一览 / 后台运转」9 张,点击展开)",
@@ -1093,6 +1163,17 @@ window.__ModuleLoader__.load({
1093
1163
  en: {
1094
1164
  "memory.panel.empty": "No memories yet",
1095
1165
  "memory.panel.open": "Memory",
1166
+ "wiki.panel.open": "Repo Atlas",
1167
+ "wiki.empty.noRepo": "No repository registered yet. Run `dsh-mneme-choro <repo-path> --apply` inside the repository to generate skeleton pages and register it, then come back here.",
1168
+ "wiki.empty.disabled": "chorography (Repo Atlas) is disabled: turn it on under Settings and restart DSH to apply.",
1169
+ "wiki.empty.pickRepo": "Multiple repositories registered — pick one:",
1170
+ "wiki.search.placeholder": "Search pages…",
1171
+ "wiki.state.loading": "Loading…",
1172
+ "wiki.state.pickPage": "Pick a module page from the tree.",
1173
+ "wiki.state.pageMissing": "Index exists but the page file is missing: re-run `dsh-mneme-choro <repo-path> --apply` to rebuild.",
1174
+ "wiki.state.unavailable": "chorography queries are unavailable (host wiring missing).",
1175
+ "wiki.state.error": "Failed to load",
1176
+ "wiki.tree.title": "Modules",
1096
1177
  "memory.tab.all": "All",
1097
1178
  "memory.tab.preference": "Preferences",
1098
1179
  "memory.tab.project": "Projects",
@@ -1114,16 +1195,21 @@ window.__ModuleLoader__.load({
1114
1195
  "memory.settings.profileSaved": "Profile saved",
1115
1196
  "memory.settings.profileExtract": "Draft from memory",
1116
1197
  "memory.settings.profileExtracting": "Extracting…",
1117
- "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.",
1198
+ "memory.settings.profileExtractHint": "Costs one model call, and only when you press this button. The result is a draft copy shown here only — it is never written to the memory store and never touches the User Profile you wrote yourself. Press “Copy draft” to keep it.",
1118
1199
  "memory.settings.profileExtractDisabled": "Nothing extracted: the “Profile draft from memory” feature switch is still off.",
1119
1200
  "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).",
1120
1201
  "memory.settings.profileExtractNoRoute": "No model available: pick a default model in the host (or set dreamProvider / dreamModel), then try again.",
1121
1202
  "memory.settings.profileSaveFailed": "Profile was not saved",
1122
1203
  "memory.settings.profileExtractFailed": "Extraction failed: the model did not return the required shape, or the call errored. Your existing profile is untouched.",
1123
- "memory.settings.profileDraftTitle": "Draft (not saved)",
1204
+ "memory.settings.profileDraftTitle": "Draft copy (never saved automatically)",
1124
1205
  "memory.settings.profileDraftRows": "Based on entries",
1206
+ "memory.status.entitiesSampled": "Per-type counts cover the newest 500 entities only; the total is the whole library",
1207
+ "memory.status.entitiesUnknown": "Total unavailable; the counts below cover the newest 500 entities",
1125
1208
  "memory.settings.profileDraftTruncated": "many entries — only the first batch was read",
1126
- "memory.settings.profileDraftApply": "Apply as profile",
1209
+ "memory.settings.profileDraftCopy": "Copy draft",
1210
+ "memory.settings.profileDraftCopied": "Copied",
1211
+ "memory.settings.profileDraftCopyHint": "The User Profile field is yours to write; to keep this text, paste it there and save it yourself.",
1212
+ "memory.settings.profileDraftCopyFailed": "Copy failed: select the draft above and copy it manually.",
1127
1213
  "memory.settings.profileDraftDiscard": "Discard draft",
1128
1214
  "memory.settings.persona": "Persona (beta)",
1129
1215
  "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.",
@@ -1160,7 +1246,14 @@ window.__ModuleLoader__.load({
1160
1246
  "memory.features.personaStrictAddressing": "Strict persona addressing",
1161
1247
  "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.",
1162
1248
  "memory.features.profileExtractEnabled": "Profile draft from memory",
1163
- "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).",
1249
+ "memory.features.profileExtractEnabled.hint": "Adds the \"Draft from memory\" button to the \"You in the agent's eyes\" card under Settings → Personalization: reads a batch of active memories, spends one model call, and returns an \"as the assistant sees them\" draft. The draft is only shown there as a copy you can copy out — it is never written to the memory store and never overwrites the User Profile you wrote yourself. Off in the lightweight preset (an explicit choice still wins).",
1250
+ "memory.features.chorographyEnabled": "Repo atlas (chorography)",
1251
+ "memory.features.chorographyEnabled.hint": "Reads a repository once and writes it up as a paginated atlas: the deterministic skeleton costs no model and can be rebuilt any time; the semantic sections are added per module by a headless session in the CLI. Turning it on enables four entry points — three read-only tools for the model, the atlas panel page, the per-turn outline injection, and the /choro command. With it off none of them exist (the tools are absent from the model's tool list, the injection block is not rendered, /choro is not registered). Off by default.",
1252
+ "memory.features.chorographyInject": "Opening injection tier",
1253
+ "memory.features.chorographyInject.hint": "How much of the atlas outline goes into context every turn: light lists the six modules with the most files (about 8 lines), full lists every module, off injects nothing (tools and the panel page still work). Only module name / file count / page status / page path are injected, never page bodies — the model fetches those with choro_read_page. The lightweight preset forces off.",
1254
+ "memory.features.chorographyInject.off": "off (no injection)",
1255
+ "memory.features.chorographyInject.light": "light (top 6 modules)",
1256
+ "memory.features.chorographyInject.full": "full (all modules)",
1164
1257
  "memory.settings.rules": "Rules",
1165
1258
  "memory.settings.rulesHint": "Rules the agent must follow — add or remove anytime, effective next turn.",
1166
1259
  "memory.settings.ruleAdd": "Add Rule",
@@ -1182,6 +1275,7 @@ window.__ModuleLoader__.load({
1182
1275
  "memory.explorer.tabSettings": "Settings",
1183
1276
  "memory.explorer.tabStatus": "Status",
1184
1277
  "memory.entities.count": "{n} entities",
1278
+ "memory.entities.countSampled": "The list shows the newest {shown} only; the total above is the whole library",
1185
1279
  "memory.entities.pick": "Select an entity to see its attributes and relations",
1186
1280
  "memory.entities.noMatch": "No entities match that search — try another word",
1187
1281
  "memory.entities.none": "No entities yet — turn on entity extraction and people / projects / concepts will accumulate here",
@@ -1242,6 +1336,23 @@ window.__ModuleLoader__.load({
1242
1336
  "memory.graph.viewInGraph": "Open in entity explorer",
1243
1337
  "memory.graph.loading": "Loading…",
1244
1338
  "memory.graph.distance": "{n} hop(s) from root",
1339
+ // issue #24 块4:被动确认的复核面。
1340
+ "memory.graph.weight": "weight {w}",
1341
+ "memory.graph.source": "via {s}",
1342
+ "memory.graph.boosted.title": "Edges put to use",
1343
+ "memory.graph.boosted.hint": "A normal touch raises an edge's weight automatically. These are the ones that rose — raising is the only action that changes what gets recalled.",
1344
+ "memory.graph.boosted.empty": "No edges match this filter (only edges already above their source baseline are listed)",
1345
+ "memory.graph.boosted.loadFailed": "Couldn't load this panel (endpoint unavailable) — that is not the same as \"no edges\". Try again later.",
1346
+ "memory.graph.boosted.gateOff": "Passive confirmation is off, so nothing more will be raised; the edges below were raised earlier.",
1347
+ "memory.graph.boosted.show": "Show edges put to use",
1348
+ "memory.graph.boosted.hide": "Hide",
1349
+ "memory.graph.boosted.gain": "+{g}",
1350
+ // 边的来源(#24 块2 的 RELATION_SOURCE_DEFAULTS):决定权重的起点,
1351
+ // 所以复核时必须和权重一起显示,否则「0.5 比 1.0 弱」这种误读无可避免。
1352
+ "memory.graph.src.manual": "manual",
1353
+ "memory.graph.src.confirmed": "confirmed",
1354
+ "memory.graph.src.tag": "tag overlap",
1355
+ "memory.graph.src.llm": "model-extracted",
1245
1356
  "memory.settings.vectorTitle": "Vector Search",
1246
1357
  "memory.settings.vectorHint": "Match by meaning, not just wording — connect any OpenAI-compatible embeddings API.",
1247
1358
  "memory.settings.vectorEnabled": "Enable vector search",
@@ -1304,6 +1415,15 @@ window.__ModuleLoader__.load({
1304
1415
  "memory.settings.mcp.snippet": "{ \"mcpServers\": { \"dsh-mneme\": { \"command\": \"dsh-mneme-mcp\", \"env\": { \"DSH_MNEME_TOKEN\": \"<token from External API above>\" } } } }",
1305
1416
  "memory.settings.mcp.copy": "Copy",
1306
1417
  "memory.settings.mcp.copied": "Copied",
1418
+ "memory.settings.configKeys.title": "Config key coverage",
1419
+ "memory.settings.configKeys.desc": "How many config-file keys the panel can change, and which ones are file-only. Key names only — no values are shown.",
1420
+ "memory.settings.configKeys.summary": "The schema has {schema} keys: the panel can change {panel}, {file} are file-only, and {undefaulted} are registered without a runtime default.",
1421
+ "memory.settings.configKeys.fileOnlyHead": "File-only ({n})",
1422
+ "memory.settings.configKeys.undefaultedHead": "Registered for the panel but has no runtime default ({n})",
1423
+ "memory.settings.configKeys.undefaultedHint": "These keys are on the whitelist, so the panel shows an input for them, but the schema gives no default — while unset they are absent from the effective snapshot. Set a value for them to take effect.",
1424
+ "memory.settings.configKeys.show": "Show the list",
1425
+ "memory.settings.configKeys.hide": "Hide the list",
1426
+ "memory.settings.configKeys.loadFailed": "Couldn't load this list (endpoint unavailable) — that is not the same as \"no file-only keys\".",
1307
1427
  "memory.settings.mcp.hint": "Data plane is the external API (default 127.0.0.1:8790, Bearer) — the token is the one above; override with DSH_MNEME_URL / DSH_MNEME_TOKEN env vars or ~/.dsh-mneme/cli.json. The command must be on PATH (installed with @modusensus/dsh-mneme).",
1308
1428
  "memory.settings.extapi.savedHint": "Saved. Takes effect after restarting DSH",
1309
1429
  "memory.settings.extapi.invalidPort": "Port must be a number between 1 and 65535",
@@ -1566,6 +1686,19 @@ window.__ModuleLoader__.load({
1566
1686
  "memory.explorer.imported": "Merged {n} edits",
1567
1687
  "memory.explorer.importFailed": "Import failed",
1568
1688
  "memory.explorer.importNoFile": "Choose a file first",
1689
+ "memory.explorer.bootstrap": "Bootstrap from a directory…",
1690
+ "memory.explorer.bootstrapTitle": "Bootstrap from a directory",
1691
+ "memory.explorer.bootstrapHint": "Reads the deterministic artifacts there (script commands, README, contributing rules, CI workflows, top-level layout, recent commits) — one rule, one memory. No model call; running it again merges instead of duplicating.",
1692
+ "memory.explorer.bootstrapDir": "Directory path",
1693
+ "memory.explorer.bootstrapConfirm": "Bootstrap",
1694
+ "memory.explorer.bootstraping": "Bootstrapping…",
1695
+ "memory.explorer.bootstrapDone": "Created {n}, merged {m}",
1696
+ "memory.explorer.bootstrapNoDir": "Enter a directory path first",
1697
+ "memory.explorer.bootstrapFailed": "Bootstrap failed",
1698
+ "memory.explorer.bootstrapErr.missing-dir": "No directory path was given",
1699
+ "memory.explorer.bootstrapErr.dir-not-found": "That path does not exist",
1700
+ "memory.explorer.bootstrapErr.not-a-directory": "That path is not a directory",
1701
+ "memory.explorer.bootstrapErr.bootstrap-unavailable": "This capability is not wired in the current assembly",
1569
1702
  "memory.explorer.sharePack": "Share memories…",
1570
1703
  "memory.explorer.shareTitle": "Memory share pack",
1571
1704
  "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.",
@@ -1848,6 +1981,13 @@ window.__ModuleLoader__.load({
1848
1981
  "memory.status.token.heat": "8-week heat · daily spend",
1849
1982
  "memory.status.token.heatLess": "less",
1850
1983
  "memory.status.token.heatMore": "more",
1984
+ "memory.status.token.sink": "8-week consolidation · in vs kept",
1985
+ "memory.status.token.sinkAdded": "added",
1986
+ "memory.status.token.sinkMerged": "consolidated",
1987
+ "memory.status.token.sinkArchived": "archived",
1988
+ "memory.status.token.sinkGap": "consolidated/added",
1989
+ "memory.status.token.sinkEmpty": "nothing recorded yet",
1990
+ "memory.status.token.sinkFoot": "consolidated counts distinct records; re-merging the same record counts once",
1851
1991
  "memory.status.token.last7": "last 7 days",
1852
1992
  "memory.status.token.meterOnly": "(metered only)",
1853
1993
  "memory.status.token.detail": "Detailed status cards (the original 9 under Library / Background activity, click to expand)",
@@ -1934,10 +2074,12 @@ window.__ModuleLoader__.load({
1934
2074
  // / -warning / -success / -warn / --dsw-alias-border),
1935
2075
  // 于是长年吃的是写在 fallback 里的硬编码色。这里按宿主真实 token 现算一组
1936
2076
  // 面板自有语义色:混色基准全是宿主 token,宿主换肤时跟着走,不引入新字面量。
1937
- // 必须挂四个根——面板不只长在 .mneme-x 下:记忆库 sheet portal 到 body
2077
+ // 必须挂满五个根——面板不只长在 .mneme-x 下:记忆库 sheet portal 到 body
1938
2078
  // (.mneme-overlay)、侧边栏入口在宿主插槽(.mneme-triggerwrap)、顶部入口
1939
- // portal 到宿主侧边栏(.mneme-topentry)。
1940
- ".mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry{" +
2079
+ // portal 到宿主侧边栏(.mneme-topentry)。第五个根是宿主内整页面板
2080
+ // (.mneme-panel)配套的侧栏图标容器:那颗图标长在宿主的 panelGlyph 里,
2081
+ // 不在 .mneme-x 的子树里,而它右上角的未处理冲突 badge 要吃 --mneme-danger。
2082
+ ".mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry,.mneme-paneliglyph{" +
1941
2083
  "--mneme-fg-dim:color-mix(in srgb,var(--dsw-alias-label-primary) 60%,transparent);" +
1942
2084
  "--mneme-graphic-dim:color-mix(in srgb,var(--dsw-alias-label-primary) 50%,transparent);" +
1943
2085
  "--mneme-accent:color-mix(in srgb,var(--dsw-alias-state-business-primary) 85%,var(--dsw-alias-label-primary));" +
@@ -1977,6 +2119,9 @@ window.__ModuleLoader__.load({
1977
2119
  // 低于详情抽屉(6),下拉菜单/导入对话框随容器整体上浮。
1978
2120
  ".mneme-xtools{position:absolute;right:14px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:8px;padding:0;z-index:3}",
1979
2121
  ".mneme-xcount{flex:none;font-size:12px;line-height:16px;color:var(--mneme-fg-dim);white-space:nowrap}",
2122
+ // 目录计数下面那行小字:说清「总数是全库、列表只列最近 N 个」,两个口径
2123
+ // 不一样不能只靠一个数字冒充(实体页此前就吃到这个亏)。
2124
+ ".mneme-xcountnote{font-size:11px;line-height:15px;color:var(--mneme-fg-dim);white-space:nowrap}",
1980
2125
  // --- three-column browse layout: hairline separators, no outer box ---
1981
2126
  ".mneme-xmain{flex:1;min-height:0;display:flex;flex-direction:row;overflow:hidden}",
1982
2127
  ".mneme-xside{flex:none;width:264px;min-width:0;min-height:0;overflow-y:auto;overflow-x:hidden;padding:16px 14px;border-right:1px solid var(--dsw-alias-border-l2);box-sizing:border-box;display:flex;flex-direction:column;gap:8px}",
@@ -2089,6 +2234,13 @@ window.__ModuleLoader__.load({
2089
2234
  ".mneme-relrow:hover{background:var(--dsw-alias-interactive-bg-hover)}",
2090
2235
  ".mneme-reltype{flex:none;font-size:11px;line-height:16px;color:var(--mneme-accent);border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary) 35%,transparent);border-radius:6px;padding:0 6px}",
2091
2236
  ".mneme-relname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
2237
+ // issue #24 块4 复核面:抬过的边。用虚线边色(--mneme-graphic-dim 同层)
2238
+ // 而不是新增颜色——它表达的是「同一条边,只是被用过」,不是新类别。
2239
+ ".mneme-boosted{margin-top:6px;padding-top:6px;border-top:1px dashed var(--dsw-alias-border-l2)}",
2240
+ ".mneme-boostedbody{margin-top:6px}",
2241
+ ".mneme-boostedrow{cursor:default;font-size:12px;line-height:18px;padding:5px 10px}",
2242
+ ".mneme-boostedrow:hover{background:none}",
2243
+ ".mneme-boostedgain{font-size:11px;color:var(--mneme-accent)}",
2092
2244
  // --- settings: quiet stacked sections, one concern per section ---
2093
2245
  ".mneme-set{flex:1;min-height:0;overflow-y:auto;padding:8px 24px 48px;box-sizing:border-box}",
2094
2246
  ".mneme-set-inner{max-width:720px;margin:0 auto}",
@@ -2123,6 +2275,49 @@ window.__ModuleLoader__.load({
2123
2275
  ".mneme-set-cmddesc{font-size:12px;line-height:17px;color:var(--mneme-fg-dim);margin-top:1px;word-break:break-word}",
2124
2276
  // --- settings sub-view ---
2125
2277
  ".mneme-set{flex:1;min-height:0;overflow-y:auto;padding:8px 24px 48px;box-sizing:border-box}",
2278
+ // --- 宿主中列整页面板(sidebar.panellist + main 成对座位)---
2279
+ // 宿主把 main 槽渲染在三列 grid 的中列里,那一层只给了 min-width:0 与
2280
+ // overflow:hidden,没有任何尺寸约束——注册者必须自己铺满并自管滚动,
2281
+ // 否则内容会撑破中列(右栏打开时中列更窄,宽度还会变)。
2282
+ ".mneme-panel{box-sizing:border-box;flex:1;min-width:0;min-height:0;width:100%;height:100%;display:flex;flex-direction:column;overflow:hidden;background:var(--dsw-alias-bg-layer-1)}",
2283
+ // 侧栏图标容器:badge 用绝对定位挂在它上面。宿主 panelRow 没有
2284
+ // overflow:hidden,所以 -3px 的偏移不会被裁;这里的 position:relative
2285
+ // 只提供定位上下文,不接管尺寸(由宿主的 panelGlyph 决定)。
2286
+ ".mneme-paneliglyph{position:relative;display:inline-flex;align-items:center;justify-content:center}",
2287
+ // --- chorography 面板(仓库地志):顶栏检索 + 左树右文。颜色全部引用
2288
+ // 既有语义层(--dsw-alias-* 正文/边框、--mneme-fg-dim 弱化),不引入新
2289
+ // 字面量色——contrast.test.js 会重算每一条 color 声明。内容渲染挂在
2290
+ // .mneme-panel 根内(#562 的中列整页容器),不新增渲染根。
2291
+ ".mneme-wikibar{flex:none;display:flex;align-items:center;gap:10px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1)}",
2292
+ ".mneme-wikirepo{flex:none;max-width:40%;font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
2293
+ ".mneme-wikisearch{flex:1;min-width:0;height:28px;padding:0 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12.5px;outline:none;transition:border-color .12s}",
2294
+ ".mneme-wikisearch:focus{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-business-primary) 15%,transparent)}",
2295
+ ".mneme-wikibody{flex:1;min-height:0;display:flex;overflow:hidden}",
2296
+ ".mneme-wikitree{flex:none;width:232px;overflow-y:auto;border-right:1px solid var(--dsw-alias-border-l1);padding:10px 6px}",
2297
+ ".mneme-wikitreeitem{display:block;width:100%;text-align:left;border:none;background:none;cursor:pointer;font-family:inherit;font-size:12.5px;line-height:18px;color:var(--dsw-alias-label-secondary);padding:5px 8px;border-radius:7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .12s}",
2298
+ ".mneme-wikitreeitem:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
2299
+ ".mneme-wikitreeitem.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}",
2300
+ ".mneme-wikipage{flex:1;min-width:0;overflow-y:auto;padding:18px 22px 44px;color:var(--dsw-alias-label-primary)}",
2301
+ ".mneme-wikipage h2,.mneme-wikipage h3,.mneme-wikipage h4,.mneme-wikipage h5{margin:18px 0 8px;color:var(--dsw-alias-label-primary);line-height:1.35}",
2302
+ ".mneme-wikipage h2{font-size:17px;border-bottom:1px solid var(--dsw-alias-border-l1);padding-bottom:6px}",
2303
+ ".mneme-wikipage h3{font-size:15px}",
2304
+ ".mneme-wikipage h4,.mneme-wikipage h5{font-size:13.5px}",
2305
+ ".mneme-wikipage p{margin:6px 0;font-size:13px;line-height:21px;color:var(--dsw-alias-label-secondary)}",
2306
+ ".mneme-wikipage ul,.mneme-wikipage ol{margin:6px 0;padding-left:22px}",
2307
+ ".mneme-wikipage li{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:3px 0}",
2308
+ ".mneme-wikipre{margin:10px 0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));overflow-x:auto;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);white-space:pre}",
2309
+ ".mneme-wikicode{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-active);border-radius:4px;padding:1px 5px}",
2310
+ ".mneme-wikitablerow{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11.5px;line-height:19px;color:var(--dsw-alias-label-secondary);white-space:pre;overflow-x:auto}",
2311
+ ".mneme-wikiquote{margin:8px 0;padding:6px 12px;border-left:3px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:12.5px;line-height:19px}",
2312
+ ".mneme-wikiempty{padding:26px 22px;font-size:13px;line-height:21px;color:var(--dsw-alias-label-secondary);display:flex;flex-direction:column;gap:8px;overflow-y:auto}",
2313
+ ".mneme-wikiemptytitle{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary)}",
2314
+ ".mneme-wikihits{position:absolute;inset-inline:22px;top:8px;z-index:3;display:flex;flex-direction:column;gap:4px;max-height:46%;overflow-y:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:8px;box-shadow:0 10px 28px color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent)}",
2315
+ ".mneme-wikihit{text-align:left;border:none;background:none;cursor:pointer;font-family:inherit;font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);padding:5px 7px;border-radius:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
2316
+ ".mneme-wikihit:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
2317
+ ".mneme-wikihitsclose{flex:none;align-self:flex-end;border:none;background:none;cursor:pointer;color:var(--mneme-fg-dim);font-size:15px;line-height:16px;padding:0 4px}",
2318
+ // 相对定位由 .mneme-panel 提供?它没有——检索浮层需要定位上下文,给页面列加
2319
+ // position:relative(不能动 .mneme-panel 本身:记忆面板还挂在同一根上)。
2320
+ ".mneme-wikipage{position:relative}",
2126
2321
  // --- 记忆库页面:居中 sheet,非全屏 ---
2127
2322
  // 从对话直接打开一页记忆库:背板压暗 + 居中圆角卡片(上限 1180×880),
2128
2323
  // 对话仍留在背板之后,任何状态下(含新会话 hero、无 tab 环)都可用。
@@ -2422,6 +2617,25 @@ window.__ModuleLoader__.load({
2422
2617
  ".mneme-heatcell.l4{background:var(--mneme-heat-1)}",
2423
2618
  ".mneme-heatlegend{display:flex;align-items:center;gap:3px;margin-top:8px;font-size:10.5px;color:var(--mneme-fg-dim)}",
2424
2619
  ".mneme-heatlegend .mneme-heatcell{width:10px;height:10px}",
2620
+ // 热力右侧的空白:热力只有 56 格 × 11px ≈ 745px 宽,而容器可用宽 968px,
2621
+ // 右边常年空着约 220px。原先三块图各自独占整行,空白是「每块只占半行」造成的,
2622
+ // 不是某个空 div——所以这里把热力与沉淀图并成两列(左侧热力按内容宽 auto,
2623
+ // 右侧吃剩余),容器窄到放不下两列时回落单列竖排。
2624
+ ".mneme-flowrow{display:grid;grid-template-columns:auto minmax(0,1fr);gap:20px;align-items:start}",
2625
+ "@container (max-width:960px){.mneme-flowrow{grid-template-columns:minmax(0,1fr)}}",
2626
+ ".mneme-flowheat{min-width:0}",
2627
+ ".mneme-flowsink{min-width:0;display:flex;flex-direction:column}",
2628
+ // 8 周沉淀只画两条(进来的 / 稳住的):220px 宽里三序列会细到看不清,
2629
+ // 归档数改在下方图例里给总量。
2630
+ ".mneme-sinkchart{display:flex;align-items:flex-end;gap:4px;height:96px;margin-top:12px}",
2631
+ ".mneme-sinkcol{flex:1;min-width:0;display:flex;align-items:flex-end;gap:2px;height:100%}",
2632
+ ".mneme-sinkbar{flex:1;min-width:0;border-radius:2px 2px 0 0;min-height:1px}",
2633
+ ".mneme-sinkbar--added{background:var(--mneme-chart-3)}",
2634
+ ".mneme-sinkbar--merged{background:var(--mneme-chart-2)}",
2635
+ ".mneme-sinklabels{display:flex;gap:4px;margin-top:4px;font-size:9.5px;color:var(--mneme-fg-dim);font-variant-numeric:tabular-nums}",
2636
+ ".mneme-sinklabels span{flex:1;min-width:0;text-align:center;white-space:nowrap}",
2637
+ ".mneme-sinklegend{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;margin-top:8px;font-size:10.5px;color:var(--mneme-fg-dim)}",
2638
+ ".mneme-sinklegend b{font-weight:600;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}",
2425
2639
  // 原 9 卡降级折叠区:默认收起,展开才挂载子卡(见 TokenFlowSection 注释)。
2426
2640
  ".mneme-legacy{max-width:1000px;margin:0 auto 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 16px}",
2427
2641
  ".mneme-legacy summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--dsw-alias-label-secondary)}",
@@ -2465,6 +2679,11 @@ window.__ModuleLoader__.load({
2465
2679
  ".mneme-set-tocchip:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}",
2466
2680
  ".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}",
2467
2681
  ".mneme-set-hint{font-size:12px;color:var(--mneme-fg-dim)}",
2682
+ // 配置键覆盖清单(#386 第 7 条):一行一排 chip,长键名换行不截断——
2683
+ // 这张表的用处就是让「memoryQualityFilter.archiveThreshold」这样的名字能被
2684
+ // 完整抄进配置文件,省略号会直接毁掉它的用途。
2685
+ ".mneme-keylist{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}",
2686
+ ".mneme-keychip{font-family:monospace;font-size:11px;padding:2px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base,transparent);color:var(--mneme-fg-dim);word-break:break-all;user-select:all}",
2468
2687
  // --- 重要性编辑行:与查看态同一份 Lucide 星形(此前是文本星号下拉,角更尖锐且与其他星不统一) ---
2469
2688
  ".mneme-staredit{display:inline-flex;align-items:center;gap:1px;justify-self:start}",
2470
2689
  ".mneme-starbtn{border:none;background:none;cursor:pointer;padding:2px 1px;display:inline-flex;color:var(--dsw-alias-label-secondary);transition:transform .12s}",
@@ -2790,9 +3009,12 @@ window.__ModuleLoader__.load({
2790
3009
  // ④ 关系图谱 the same ego graph the old graph-only tab drew
2791
3010
  function EntityPanel({ t, focusEntity, onJumpMemory }) {
2792
3011
  const [entities, setEntities] = useState(null);
2793
- // #512:这条记忆的归因——窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。
2794
- // null = 还没读到(或读失败),两者要分得开,不能被当成「没有归因」。
2795
- const [provenance, setProvenance] = useState(null); // null = still loading
3012
+ // 目录只取最近 500 个(limit=500),可「一共抽出多少实体」问的是全库。
3013
+ // 后端回 total + totalExact(见 src/api.js 的 /entities),这里必须用全库数,
3014
+ // 否则左侧栏永远显示「500 个实体」——那正是「实体太少」这个观感的另一半来源:
3015
+ // 状态页已经在 #559 修正,实体页这一处当时漏了。null = 全库数读不到(老宿主
3016
+ // 或计数失败),此时退回切片长度并如实标成切片口径,不冒充全库。
3017
+ const [entityTotal, setEntityTotal] = useState(null);
2796
3018
  const [railQuery, setRailQuery] = useState("");
2797
3019
  const [entityName, setEntityName] = useState(focusEntity || "");
2798
3020
  const [depth, setDepth] = useState(1);
@@ -2801,6 +3023,10 @@ window.__ModuleLoader__.load({
2801
3023
  const [selected, setSelected] = useState(null); // { kind: "node"|"edge", node?|edge? }
2802
3024
  const [attrs, setAttrs] = useState([]);
2803
3025
  const [related, setRelated] = useState([]);
3026
+ // issue #24 块4:被动确认的复核面。默认收起——它是一个「想查的时候
3027
+ // 才查」的面,不该占实体页第一屏;展开时才去取,省一次请求。
3028
+ const [boostedOpen, setBoostedOpen] = useState(false);
3029
+ const [boosted, setBoosted] = useState(null); // { count, passiveConfirmEnabled, items }
2804
3030
  const svgRef = useRef(null);
2805
3031
  const posRef = useRef([]); // live simulation positions, not React state
2806
3032
  const dragRef = useRef(null); // { id, moved }
@@ -2811,8 +3037,12 @@ window.__ModuleLoader__.load({
2811
3037
  let cancelled = false;
2812
3038
  apiFetch("/api/dsh-mneme/entities?limit=500")
2813
3039
  .then((r) => (r.ok ? r.json() : { entities: [] }))
2814
- .then((j) => { if (!cancelled) setEntities(Array.isArray(j.entities) ? j.entities : []); })
2815
- .catch(() => { if (!cancelled) setEntities([]); });
3040
+ .then((j) => {
3041
+ if (cancelled) return;
3042
+ setEntities(Array.isArray(j.entities) ? j.entities : []);
3043
+ setEntityTotal(j.totalExact === true && typeof j.total === "number" ? j.total : null);
3044
+ })
3045
+ .catch(() => { if (!cancelled) { setEntities([]); setEntityTotal(null); } });
2816
3046
  return () => { cancelled = true; };
2817
3047
  }, []);
2818
3048
 
@@ -2866,6 +3096,21 @@ window.__ModuleLoader__.load({
2866
3096
  return () => { cancelled = true; };
2867
3097
  }, [entityName]);
2868
3098
 
3099
+ // issue #24 块4 复核面:只在展开时取,收起时不发请求。
3100
+ const loadBoosted = useCallback(async () => {
3101
+ try {
3102
+ const res = await apiFetch("/api/dsh-mneme/semantic/graph/boosted-relations?limit=100");
3103
+ setBoosted(res.ok
3104
+ ? await res.json()
3105
+ : { count: 0, items: [], passiveConfirmEnabled: false, error: true });
3106
+ } catch {
3107
+ setBoosted({ count: 0, items: [], passiveConfirmEnabled: false, error: true });
3108
+ }
3109
+ }, []);
3110
+ useEffect(() => {
3111
+ if (boostedOpen) loadBoosted();
3112
+ }, [boostedOpen, loadBoosted]);
3113
+
2869
3114
  // Simulation: run in rAF against posRef, tick React only every few frames.
2870
3115
  // Re-seeds when data changes; dragging writes straight into posRef.
2871
3116
  useEffect(() => {
@@ -3049,8 +3294,13 @@ window.__ModuleLoader__.load({
3049
3294
  value: railQuery,
3050
3295
  onChange: (e) => setRailQuery(e.target.value)
3051
3296
  }),
3297
+ // 大数字用全库数;全库数读不到(老宿主 / 计数失败)时退回切片长度,
3298
+ // 但那只是「这次取回多少」,所以要多一行脚注说清列表本身只列最近 500 个。
3052
3299
  entities !== null && entities.length > 0 && h("div", { className: "mneme-xcount" },
3053
- t("memory.entities.count").replace("{n}", String(entities.length)))
3300
+ t("memory.entities.count").replace("{n}", String(entityTotal !== null ? entityTotal : entities.length))),
3301
+ entities !== null && entityTotal !== null && entityTotal > entities.length
3302
+ && h("div", { className: "mneme-xcountnote" },
3303
+ t("memory.entities.countSampled").replace("{shown}", String(entities.length)))
3054
3304
  ),
3055
3305
  h("div", { className: "mneme-entrailbody" },
3056
3306
  entities === null && h("div", { className: "mneme-xempty" }, "…"),
@@ -3096,6 +3346,17 @@ window.__ModuleLoader__.load({
3096
3346
  ),
3097
3347
  h("div", { className: "mneme-gs-meta" },
3098
3348
  `${t("memory.graph.relation")} · ${formatRelativeTime(selected.edge.created_at, t)}`),
3349
+ // issue #24 块4:复核要看得见「这条边被抬过」。weight 是当前分,
3350
+ // source 决定它的起点(manual 1.0 / confirmed 1.0 / tag 0.3 / llm 0.4)——
3351
+ // 只看 weight 会把「tag 边被抬到 0.5」误读成「比 manual 边弱」。
3352
+ (selected.edge.weight !== undefined || selected.edge.source !== undefined) && h("div", { className: "mneme-gs-meta" },
3353
+ selected.edge.weight !== undefined
3354
+ ? t("memory.graph.weight").replace("{w}", Number(selected.edge.weight).toFixed(2))
3355
+ : null,
3356
+ selected.edge.weight !== undefined && selected.edge.source ? " · " : null,
3357
+ selected.edge.source
3358
+ ? t("memory.graph.source").replace("{s}", t(`memory.graph.src.${selected.edge.source}`) || selected.edge.source)
3359
+ : null),
3099
3360
  selected.edge.memory_id && h("button", {
3100
3361
  className: "mneme-footbtn",
3101
3362
  onClick: () => onJumpMemory && onJumpMemory({ id: selected.edge.memory_id })
@@ -3200,7 +3461,53 @@ window.__ModuleLoader__.load({
3200
3461
  h("span", { className: "mneme-reltype" }, e.relation_type),
3201
3462
  h("span", { className: "mneme-relname", style: { flex: 1 } }, otherName)
3202
3463
  );
3203
- })
3464
+ }),
3465
+ // issue #24 块4「例外管理」的可见面。被动确认是唯一会悄悄
3466
+ // 改变检索形状的动作(正常触达就抬权),所以它必须有个地方
3467
+ // 能查——否则「自动确认」和「没人看过的自动确认」没有区别。
3468
+ // 默认收起:这是审计视角,不是日常视角。
3469
+ h("div", { className: "mneme-boosted" },
3470
+ h("button", {
3471
+ className: boostedOpen ? "mneme-chip mneme-active" : "mneme-chip",
3472
+ "aria-expanded": boostedOpen ? "true" : "false",
3473
+ onClick: () => setBoostedOpen(!boostedOpen),
3474
+ title: t("memory.graph.boosted.hint")
3475
+ }, boostedOpen ? t("memory.graph.boosted.hide") : t("memory.graph.boosted.show")),
3476
+ boostedOpen && h("div", { className: "mneme-boostedbody" },
3477
+ (() => {
3478
+ if (boosted === null) return h("div", { className: "mneme-hint" }, t("memory.graph.loading"));
3479
+ // 加载失败 ≠ 闸关:接口挂了却说「被动确认是关的」,会把人
3480
+ // 引去翻设置,而真正的问题是这一屏没取到数据。
3481
+ if (boosted.error === true) return h("div", { className: "mneme-hint" }, t("memory.graph.boosted.loadFailed"));
3482
+ const items = boosted.items || [];
3483
+ const off = boosted.passiveConfirmEnabled !== true;
3484
+ // 闸关 ≠ 没有数据:已经抬过的边仍然在表里,关掉闸只表示
3485
+ // 「今后不再自动抬」。所以先出闸关提示,再照常列历史——
3486
+ // 否则人永远没法复核既有的提升。
3487
+ // 空表提示与闸门状态无关:闸关且一条都没有时,光说「下面列的是
3488
+ // 此前已经抬过的边」却不给任何行,人会以为这一屏坏了。
3489
+ return h(react.Fragment, null,
3490
+ off && h("div", { className: "mneme-hint" }, t("memory.graph.boosted.gateOff")),
3491
+ items.length === 0
3492
+ ? h("div", { className: "mneme-hint" }, t("memory.graph.boosted.empty"))
3493
+ : h(react.Fragment, null,
3494
+ h("div", { className: "mneme-hint" }, t("memory.graph.boosted.hint")),
3495
+ items.map((b) => h("div", {
3496
+ key: b.id,
3497
+ className: "mneme-relrow mneme-boostedrow",
3498
+ title: `${t("memory.graph.source").replace("{s}", t(`memory.graph.src.${b.source}`) || b.source)} · ${Number(b.weight).toFixed(2)}`
3499
+ },
3500
+ h("span", { className: "mneme-relname", style: { textAlign: "right", flex: 1 } }, b.from_name ?? b.from),
3501
+ h("span", { className: "mneme-reltype" }, b.relation_type),
3502
+ h("span", { className: "mneme-relname", style: { flex: 1 } }, `${b.to_name ?? b.to} `,
3503
+ h("span", { className: "mneme-boostedgain" },
3504
+ t("memory.graph.boosted.gain").replace("{g}", Number(b.gain).toFixed(2))))
3505
+ ))
3506
+ )
3507
+ );
3508
+ })()
3509
+ )
3510
+ )
3204
3511
  )
3205
3512
  ),
3206
3513
  // ② 属性 — current snapshot (valid_until IS NULL);空层整层隐藏
@@ -3279,7 +3586,12 @@ window.__ModuleLoader__.load({
3279
3586
  // 人格定义页(beta):注入总闸。分组随 enhance(行为开关,非座位闸门);
3280
3587
  // 人格内容在设置页「个性化」区的人格卡。
3281
3588
  // 严格寻址是同一条链上的第二个口径开关(只在总闸打开时起作用)。
3282
- "personaEnabled", "personaStrictAddressing", "profileExtractEnabled"] },
3589
+ "personaEnabled", "personaStrictAddressing", "profileExtractEnabled",
3590
+ // chorography(仓库地志)的暴露闸(M1 就有的键,M3 才上 UI):它是「是否
3591
+ // 启用这个子系统」而不是「每轮付多少」,所以和上面这些行为开关同组;
3592
+ // 每轮付出多少由紧随组尾的 choroSub 档位管。放组末是因为子块按组尾渲染
3593
+ // ——档位下拉要贴着它的总闸。
3594
+ "chorographyEnabled"] },
3283
3595
  { key: "group.dream", items: ["sleepModeEnabled"] },
3284
3596
  // v0.8.0 A4(issue #17):作用域隔离组——标注总开关 + 严格硬过滤。
3285
3597
  { key: "group.scope", items: ["scopeEnabled", "strictScope"] }
@@ -3303,6 +3615,9 @@ window.__ModuleLoader__.load({
3303
3615
  // 蒸馏思考强度(issue #315):比 entity 多一个 off(显式关思考,思考型模型
3304
3616
  // 蒸馏防推理烧预算)。后端枚举含 off,面板必须能选到,否则操作者用不上。
3305
3617
  const SUMMARIZE_REASONING = ["off", "none", "low", "medium", "high"];
3618
+ // 地志注入档位(M3):与后端 settings.js 的 FEATURE_FLAG_ENUMS.chorographyInject
3619
+ // 同一份取值。light 是默认档,off 才是不注入。
3620
+ const CHORO_TIERS = ["off", "light", "full"];
3306
3621
 
3307
3622
  function FeatureRow({ name, hint, on, disabled, onToggle, sub, tag }) {
3308
3623
  return h("div", { className: "mneme-featrow", style: sub ? { paddingLeft: 18, opacity: 0.86 } : undefined },
@@ -3720,6 +4035,24 @@ window.__ModuleLoader__.load({
3720
4035
  h("div", { className: "mneme-featsubhint" }, t("memory.features.distillRecords.retentionDays.hint"))
3721
4036
  );
3722
4037
 
4038
+ // chorography M3:开局注入的档位。挂在 chorographyEnabled 下(总闸关着时
4039
+ // 不展开——没启用子系统就没有「注入多少」可谈)。枚举键没有通用渲染器,
4040
+ // 与上面几个子块同款手写下拉。轻量档(lightMode)在消费侧压成 off,
4041
+ // 面板不因此灰掉:那是运行时预设,不是用户选择。
4042
+ const choroSub = eff.chorographyEnabled && h("div", { className: "mneme-featsub" },
4043
+ h("div", { className: "mneme-featnum" },
4044
+ h("span", { className: "mneme-featnumlabel" }, t("memory.features.chorographyInject")),
4045
+ h("select", {
4046
+ className: "mneme-select",
4047
+ value: eff.chorographyInject || "light",
4048
+ disabled: busy,
4049
+ onChange: (e) => put({ chorographyInject: e.target.value })
4050
+ },
4051
+ CHORO_TIERS.map((r) => h("option", { key: r, value: r }, t(`memory.features.chorographyInject.${r}`))))
4052
+ ),
4053
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.chorographyInject.hint"))
4054
+ );
4055
+
3723
4056
  if (error && !state) return h("section", { className: "mneme-set-card" },
3724
4057
  h("div", { className: "mneme-set-title" }, t("memory.features.title")),
3725
4058
  h("div", { className: "mneme-set-hint" }, error));
@@ -3754,7 +4087,7 @@ window.__ModuleLoader__.load({
3754
4087
  // 子块按组尾追加。distillSub 紧跟在 core 组最后一行
3755
4088
  // (distillRecords.enabled)之后,天数行才贴着它的总闸。
3756
4089
  g.key === "group.core" && h(react.Fragment, null, distillSub, summarizeSub),
3757
- g.key === "group.enhance" && h(react.Fragment, null, embedSub, entitySub),
4090
+ g.key === "group.enhance" && h(react.Fragment, null, embedSub, entitySub, choroSub),
3758
4091
  g.key === "group.dream" && h(react.Fragment, null, dreamSub, sleepSub)
3759
4092
  );
3760
4093
  const firstScreen = FEATURE_GROUPS.filter((g) => FEATURE_FIRST_SCREEN.includes(g.key));
@@ -3849,6 +4182,15 @@ window.__ModuleLoader__.load({
3849
4182
  // 话——把「没启用」「库里没东西」「模型没配合」压成同一句「没有草稿」,用户就只能瞎猜。
3850
4183
  const [profileDraft, setProfileDraft] = react.useState(null);
3851
4184
  const [profileExtracting, setProfileExtracting] = react.useState(false);
4185
+ const [profileDraftCopied, setProfileDraftCopied] = react.useState(false);
4186
+ // 复制失败必须**看得见**:announce() 那个 live region 是 1px 裁剪元素,只有屏幕
4187
+ // 阅读器读得到。视力正常的用户看到按钮文字没变、页面也没变化,就会以为复制成功,
4188
+ // 粘贴时拿到旧的剪贴板内容——比不提示更糟。
4189
+ const [profileDraftCopyFailed, setProfileDraftCopyFailed] = react.useState(false);
4190
+ // 复制的每一趟都要能认出「这条回调还属于当前这份草稿吗」。剪贴板是异步的,复制 A 的
4191
+ // promise 还没落地时又提取出草稿 B,A 的回调就会在 B 上打勾或报错——B 一个字都没被
4192
+ // 复制过。用递增序号做票据:换草稿或重新复制都换票,旧票的回调直接丢弃。
4193
+ const profileCopyTicket = react.useRef(0);
3852
4194
  // 保存失败要可见(persona 那条一直有,画像这条以前是静默 catch)。
3853
4195
  const [profileError, setProfileError] = react.useState("");
3854
4196
  const [cmdError, setCmdError] = react.useState("");
@@ -4072,6 +4414,10 @@ window.__ModuleLoader__.load({
4072
4414
  async function extractProfileDraft() {
4073
4415
  setProfileExtracting(true);
4074
4416
  setProfileDraft(null);
4417
+ // 新草稿一出现,上一趟复制的回调立刻作废,连它留下的两种提示也一并清掉。
4418
+ profileCopyTicket.current += 1;
4419
+ setProfileDraftCopied(false);
4420
+ setProfileDraftCopyFailed(false);
4075
4421
  try {
4076
4422
  const res = await apiFetch("/api/dsh-mneme/profile/extract", {
4077
4423
  method: "POST",
@@ -4103,16 +4449,59 @@ window.__ModuleLoader__.load({
4103
4449
  }
4104
4450
  }
4105
4451
 
4106
- // 采用草稿 = 显式写画像。两件事都要对:把草稿文本直接交给保存(不能 setProfile
4107
- // 之后再调无参保存——那读的是这次渲染里的旧闭包值,会存回旧文本),以及**只有
4108
- // 真的存下了才提交界面状态**(否则一次 403 就留下「框里是草稿、库里是旧文、
4109
- // 草稿已被清掉」的三不像,关掉面板后彻底找不回来)。
4110
- async function applyProfileDraft() {
4452
+ // 草稿只展示副本(桉桉 2026-11-04 定的口径)。这一段以前叫「采用为画像」,
4453
+ // 点一下就直接 PUT /profile —— 把用户手写的那份覆盖掉,且没有撤销。现在改成
4454
+ // 复制:要落进「用户画像」必须由用户自己粘贴再点保存,手写原文一个字都不会被
4455
+ // 后台改掉。剪贴板不可用(无权限 / 非安全上下文)时不许假装成功——退回
4456
+ // execCommand 那条老路,仍然失败就明确让她手动选中复制。
4457
+ function copyProfileDraft() {
4111
4458
  if (profileDraft?.kind !== "draft") return;
4112
- const text = profileDraft.text;
4113
- if (await saveProfile(text)) {
4114
- setProfile(text);
4115
- setProfileDraft(null);
4459
+ const text = profileDraft.text || "";
4460
+ // 每次尝试先清掉上次的失败提示:失败过以后再成功,红字不能留在屏上。
4461
+ setProfileDraftCopyFailed(false);
4462
+ // 这一趟的票。回调落地时票已经不是当前那张,就说明界面早已换了一份草稿。
4463
+ const ticket = ++profileCopyTicket.current;
4464
+ const current = () => ticket === profileCopyTicket.current;
4465
+ const ok = () => {
4466
+ if (!current()) return;
4467
+ setProfileDraftCopied(true);
4468
+ announce(t("memory.settings.profileDraftCopied"));
4469
+ setTimeout(() => { if (current()) setProfileDraftCopied(false); }, 1500);
4470
+ };
4471
+ // 剪贴板失败要说话:这个方法唯一的产出就是"文本进了剪贴板",静默失败
4472
+ // 会让用户以为复制成功、粘贴时却拿到旧内容(草稿是只读 <pre>,选中再
4473
+ // 复制是她能自己走的退路,得明确告诉她走这条)。
4474
+ // 失败还要顺手撤掉「已复制」:成功态能挂 1.5 秒,这期间重试失败会同时出现
4475
+ // 「已复制」和「复制没成功」两句相反的话。
4476
+ const failed = () => {
4477
+ if (!current()) return;
4478
+ setProfileDraftCopied(false);
4479
+ setProfileDraftCopyFailed(true);
4480
+ announce(t("memory.settings.profileDraftCopyFailed"));
4481
+ };
4482
+ if (navigator.clipboard?.writeText) {
4483
+ navigator.clipboard.writeText(text).then(ok, () => copyProfileDraftFallback(text) ? ok() : failed());
4484
+ } else {
4485
+ copyProfileDraftFallback(text) ? ok() : failed();
4486
+ }
4487
+ }
4488
+
4489
+ // 没有 Clipboard API(非安全上下文 / 内嵌 webview 未授权)时的退路:
4490
+ // 临时 textarea + execCommand("copy")。失败返回 false,由调用方说话。
4491
+ function copyProfileDraftFallback(text) {
4492
+ try {
4493
+ const ta = document.createElement("textarea");
4494
+ ta.value = text;
4495
+ ta.setAttribute("readonly", "");
4496
+ ta.style.position = "fixed";
4497
+ ta.style.opacity = "0";
4498
+ document.body.appendChild(ta);
4499
+ ta.select();
4500
+ const done = document.execCommand("copy");
4501
+ document.body.removeChild(ta);
4502
+ return done === true;
4503
+ } catch {
4504
+ return false;
4116
4505
  }
4117
4506
  }
4118
4507
 
@@ -4407,35 +4796,7 @@ window.__ModuleLoader__.load({
4407
4796
  saved && h("span", { className: "mneme-saved" }, t("memory.settings.profileSaved")),
4408
4797
  profileError ? h("span", { className: "mneme-saved", style: { color: "var(--dsw-danger, #d33)" } },
4409
4798
  t("memory.settings.profileSaveFailed") + " (" + profileError + ")") : null
4410
- ),
4411
- // 从记忆提取草稿(#497)。两种结局都要显眼:草稿(要用户点「采用」才落库)
4412
- // 与失败原因(开关没开 / 没内容可提 / 模型没配合)。手写的原文在这之上一直
4413
- // 可见——采用前它不会被任何东西覆盖。
4414
- h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 8 } },
4415
- h("button", {
4416
- type: "button",
4417
- className: "mneme-btn",
4418
- disabled: profileExtracting,
4419
- onClick: extractProfileDraft
4420
- }, t("memory.settings.profileExtract")),
4421
- profileExtracting ? h("span", { className: "mneme-saved" }, t("memory.settings.profileExtracting")) : null
4422
- ),
4423
- h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractHint")),
4424
- profileDraft?.kind === "disabled" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractDisabled")) : null,
4425
- profileDraft?.kind === "empty" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractEmpty")) : null,
4426
- profileDraft?.kind === "noroute" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractNoRoute")) : null,
4427
- profileDraft?.kind === "failed" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractFailed")) : null,
4428
- profileDraft?.kind === "draft" ? h("div", null,
4429
- h("div", { className: "mneme-featnumlabel" }, t("memory.settings.profileDraftTitle")),
4430
- h("div", { className: "mneme-set-desc" },
4431
- t("memory.settings.profileDraftRows") + ": " + profileDraft.rowsUsed
4432
- + (profileDraft.truncated ? " · " + t("memory.settings.profileDraftTruncated") : "")),
4433
- h("pre", { className: "mneme-set-input mneme-set-input--area", style: { whiteSpace: "pre-wrap", minHeight: 0 } }, profileDraft.text),
4434
- h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } },
4435
- h("button", { type: "button", className: "mneme-btn", onClick: applyProfileDraft }, t("memory.settings.profileDraftApply")),
4436
- h("button", { type: "button", className: "mneme-btn", onClick: () => setProfileDraft(null) }, t("memory.settings.profileDraftDiscard"))
4437
- )
4438
- ) : null
4799
+ )
4439
4800
  ),
4440
4801
  // 人格卡(beta,v1.2 多卡)— who the agent is:多张角色卡按会话身份注入。
4441
4802
  // 卡内开关只是该卡 enabled 半边;总闸 personaEnabled 在功能开关组,双闸同真。
@@ -4567,7 +4928,45 @@ window.__ModuleLoader__.load({
4567
4928
  className: "mneme-btn",
4568
4929
  onClick: () => loadPreview(personaEditing)
4569
4930
  }, t("memory.settings.personaViewRefresh"))
4570
- )
4931
+ ),
4932
+ // 从记忆提取草稿(#497)。**放在这张卡上而不是「用户画像」卡**:画像那一栏是
4933
+ // 用户自己手填的地方;这一段是从记忆里反推出来的「Agent 眼中的你」,属于可
4934
+ // 沉淀的那一侧。它只出**副本**——不写 user_settings,也不碰手写画像(桉桉
4935
+ // 2026-11-04 定的口径:只展示副本)。要改成手写画像只能自己复制再粘过去。
4936
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 12 } },
4937
+ h("button", {
4938
+ type: "button",
4939
+ className: "mneme-btn",
4940
+ disabled: profileExtracting,
4941
+ onClick: extractProfileDraft
4942
+ }, t("memory.settings.profileExtract")),
4943
+ profileExtracting ? h("span", { className: "mneme-saved" }, t("memory.settings.profileExtracting")) : null
4944
+ ),
4945
+ h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractHint")),
4946
+ profileDraft?.kind === "disabled" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractDisabled")) : null,
4947
+ profileDraft?.kind === "empty" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractEmpty")) : null,
4948
+ profileDraft?.kind === "noroute" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractNoRoute")) : null,
4949
+ profileDraft?.kind === "failed" ? h("div", { className: "mneme-set-desc" }, t("memory.settings.profileExtractFailed")) : null,
4950
+ profileDraft?.kind === "draft" ? h("div", null,
4951
+ h("div", { className: "mneme-featnumlabel" }, t("memory.settings.profileDraftTitle")),
4952
+ h("div", { className: "mneme-set-desc" },
4953
+ t("memory.settings.profileDraftRows") + ": " + profileDraft.rowsUsed
4954
+ + (profileDraft.truncated ? " · " + t("memory.settings.profileDraftTruncated") : "")),
4955
+ h("pre", { className: "mneme-set-input mneme-set-input--area", style: { whiteSpace: "pre-wrap", minHeight: 0 } }, profileDraft.text),
4956
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } },
4957
+ h("button", { type: "button", className: "mneme-btn", onClick: copyProfileDraft },
4958
+ profileDraftCopied ? t("memory.settings.profileDraftCopied") : t("memory.settings.profileDraftCopy")),
4959
+ h("button", { type: "button", className: "mneme-btn", onClick: () => {
4960
+ profileCopyTicket.current += 1;
4961
+ setProfileDraft(null);
4962
+ setProfileDraftCopied(false);
4963
+ setProfileDraftCopyFailed(false);
4964
+ } }, t("memory.settings.profileDraftDiscard")),
4965
+ h("span", { className: "mneme-set-desc" }, t("memory.settings.profileDraftCopyHint")),
4966
+ profileDraftCopyFailed ? h("span", { className: "mneme-set-desc", role: "alert", style: { color: "var(--mneme-danger)" } },
4967
+ t("memory.settings.profileDraftCopyFailed")) : null
4968
+ )
4969
+ ) : null
4571
4970
  ),
4572
4971
  // 规则 — numbered rows, hover reveals the delete affordance
4573
4972
  h("section", { className: "mneme-set-card" },
@@ -4808,6 +5207,10 @@ window.__ModuleLoader__.load({
4808
5207
  ),
4809
5208
  apiTokenSaved && h("div", { style: { marginTop: 8 } }, h("span", { className: "mneme-saved" }, t("memory.settings.apiTokenSaved")))
4810
5209
  ),
5210
+ // 配置键覆盖面(issue #386 第 7 条):只读核对表,放在 connect 组尾——
5211
+ // 它与上面的 API Token / 独立服务同属「我在这台机器上能调什么」那一类,
5212
+ // 而不是功能开关(那些在 personal/core/enhance 各组里改)。
5213
+ h(ConfigKeysCard, { t }),
4811
5214
  // —— 帮助:支持与鼓励(图标行 + 检测更新 + star,兼当「关于」收尾) ——
4812
5215
  groupHead("help"),
4813
5216
  // 反馈三入口:新建 issue 预填(版本+平台)/ 邮件 / 浏览已知问题。
@@ -4883,6 +5286,16 @@ window.__ModuleLoader__.load({
4883
5286
  // 上下文裁不住它;运行时拒绝 react-dom 时就地渲染(position:fixed 仍然成立)。
4884
5287
  function MemoryOverlay({ t }) {
4885
5288
  const [open, setOpen] = useOverlayOpen();
5289
+ // 库内是否有请求在飞(目前只有「从目录引导」会置位)。Esc / 背板 / ✕ 三条
5290
+ // 关闭路径都要看它:关掉弹层会把 MemoryExplorer 连同引导弹层一起卸载,
5291
+ // 而 POST 还在飞,回来那句「新建几条、并入几条」就再没有地方显示——
5292
+ // 库写进去了,用户却以为没成功(评审 #4453225573)。
5293
+ const [explorerBusy, setExplorerBusy] = useState(false);
5294
+ // 用 ref 读而不是进 effect 依赖:那个 effect 在 [open] 变化时会
5295
+ // closeBtnRef.current.focus(),若把 explorerBusy 也列进依赖,每次请求
5296
+ // 起落都会重跑一次并抢焦点——用户正打字输目录时会被踢出输入框。
5297
+ const explorerBusyRef = useRef(false);
5298
+ useEffect(() => { explorerBusyRef.current = explorerBusy; }, [explorerBusy]);
4886
5299
  // #178:弹层焦点管理——打开时把焦点移入面板(关闭按钮为入口),
4887
5300
  // Tab 循环圈在面板内(Tab 从最后一个元素出去回到关闭按钮),
4888
5301
  // Esc/关闭时把焦点还给触发元素(侧边栏入口)。
@@ -4891,7 +5304,8 @@ window.__ModuleLoader__.load({
4891
5304
  useEffect(() => {
4892
5305
  if (!open) return undefined;
4893
5306
  const onKey = (e) => {
4894
- if (e.key === "Escape") { setOpen(false); return; }
5307
+ // 库内请求在飞时不响应 Esc:见上面 explorerBusy 的注释。
5308
+ if (e.key === "Escape") { if (!explorerBusyRef.current) setOpen(false); return; }
4895
5309
  if (e.key !== "Tab" || !panelRef.current) return;
4896
5310
  const focusables = panelRef.current.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])');
4897
5311
  if (focusables.length === 0) return;
@@ -4919,7 +5333,7 @@ window.__ModuleLoader__.load({
4919
5333
  const tree = h(react.Fragment, null,
4920
5334
  h("div", {
4921
5335
  className: "mneme-backdrop",
4922
- onClick: () => setOpen(false),
5336
+ onClick: () => { if (!explorerBusyRef.current) setOpen(false); },
4923
5337
  "aria-hidden": "true"
4924
5338
  }),
4925
5339
  h("div", { className: "mneme-overlay", role: "region", "aria-label": t("memory.view.label"), ref: panelRef },
@@ -4932,12 +5346,15 @@ window.__ModuleLoader__.load({
4932
5346
  type: "button",
4933
5347
  className: "mneme-footbtn",
4934
5348
  ref: closeBtnRef,
5349
+ // 忙碌时不禁用、只标 aria-disabled:真的 disabled 会把焦点从按钮上
5350
+ // 拿走,而 #178 的 Tab 环靠 closeBtnRef 当入口,焦点落空就出不去。
5351
+ "aria-disabled": String(explorerBusy),
4935
5352
  "aria-label": t("memory.overlay.close"),
4936
5353
  title: t("memory.overlay.close"),
4937
- onClick: () => setOpen(false)
5354
+ onClick: () => { if (!explorerBusyRef.current) setOpen(false); }
4938
5355
  }, "✕")
4939
5356
  ),
4940
- h("div", { className: "mneme-overlaybody" }, h(MemoryExplorer, { t }))
5357
+ h("div", { className: "mneme-overlaybody" }, h(MemoryExplorer, { t, onBusyChange: setExplorerBusy }))
4941
5358
  )
4942
5359
  );
4943
5360
  if (reactDom && typeof document !== "undefined") return reactDom.createPortal(tree, document.body);
@@ -5009,7 +5426,7 @@ window.__ModuleLoader__.load({
5009
5426
 
5010
5427
  // 实体 — directory snapshot grouped by entity type.
5011
5428
  function EntitiesStatusCard({ t }) {
5012
- const [state, setState] = useState({ loading: true, error: false, total: 0, byType: [] });
5429
+ const [state, setState] = useState({ loading: true, error: false, total: 0, totalKnown: true, byType: [] });
5013
5430
  useEffect(() => {
5014
5431
  let cancelled = false;
5015
5432
  const order = ["organization", "person", "project", "technology", "concept"];
@@ -5026,9 +5443,16 @@ window.__ModuleLoader__.load({
5026
5443
  const byType = order.concat("other")
5027
5444
  .filter((k) => counts.has(k))
5028
5445
  .map((k) => [k, counts.get(k)]);
5029
- setState({ loading: false, error: false, total: list.length, byType });
5446
+ // 大数字用后端回的全库 total;分类型小行仍来自本次切片(最近 500 个),
5447
+ // 两个口径不一样,切片写进 foot 里说清楚——以前 total 取 list.length,
5448
+ // 一万六千个实体永远显示成 500,看着像"实体太少"。
5449
+ // totalExact 为假(计数失败 / 老宿主)时 total 只是切片长度,不能当全库数
5450
+ // 显示:那正是原来「永远 500」的错法。这时大数字显示“—”,脚注说明总数读不到。
5451
+ const totalKnown = j.totalExact === true && typeof j.total === "number";
5452
+ const realTotal = totalKnown ? j.total : list.length;
5453
+ setState({ loading: false, error: false, total: realTotal, totalKnown, sampled: list.length < realTotal, byType });
5030
5454
  })
5031
- .catch(() => { if (!cancelled) setState({ loading: false, error: true, total: 0, byType: [] }); });
5455
+ .catch(() => { if (!cancelled) setState({ loading: false, error: true, total: 0, totalKnown: true, byType: [] }); });
5032
5456
  return () => { cancelled = true; };
5033
5457
  }, []);
5034
5458
  const rows = state.byType.map(([ty, n]) => ({ label: entityTypeLabel(t, ty), value: String(n) }));
@@ -5037,8 +5461,11 @@ window.__ModuleLoader__.load({
5037
5461
  title: t("memory.status.entities"),
5038
5462
  loading: state.loading,
5039
5463
  error: state.error,
5040
- num: state.total.toLocaleString(),
5041
- rows
5464
+ num: state.totalKnown === false ? "—" : state.total.toLocaleString(),
5465
+ rows,
5466
+ foot: state.totalKnown === false
5467
+ ? t("memory.status.entitiesUnknown")
5468
+ : state.sampled === true ? t("memory.status.entitiesSampled") : null
5042
5469
  });
5043
5470
  }
5044
5471
 
@@ -5499,6 +5926,76 @@ window.__ModuleLoader__.load({
5499
5926
  });
5500
5927
  }
5501
5928
 
5929
+ // --- 配置键覆盖面(issue #386 第 7 条)---
5930
+ // 面板能改的键(settings.js 白名单)与 schema 里真有的键是两张表,从没被摆在一起
5931
+ // 对过。这条卡把它们交/差出来,回答一个具体问题:**「我在配置文件里看到的这个键,
5932
+ // 为什么在面板上找不到」**——答案要么是「它只能改文件」(fileOnly),要么是「它
5933
+ // 登记了却没默认值」(undefaulted,见 config-keys.js 对 dreamProvider 的说明)。
5934
+ //
5935
+ // 只显示键名,一个值都不显示:apiToken 的名字出现在「仅配置文件」里正是这条 issue
5936
+ // 想让人看见的(否则用户不知道它存在),值则一律不出服务端。
5937
+ //
5938
+ // 默认收起:这是一张核对表,不是日常要看的东西。做只读清单而不是补一批输入框,
5939
+ // 也是维护者在 issue 里给的接手口径(「做成只读清单就能先关掉大部分价值」)。
5940
+ function ConfigKeysCard({ t }) {
5941
+ const [state, setState] = useState({ loading: true, data: null, failed: false });
5942
+ const [open, setOpen] = useState(false);
5943
+ useEffect(() => {
5944
+ let cancelled = false;
5945
+ apiFetch("/api/dsh-mneme/config-keys")
5946
+ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
5947
+ .then((j) => { if (!cancelled) setState({ loading: false, data: j, failed: false }); })
5948
+ .catch(() => { if (!cancelled) setState({ loading: false, data: null, failed: true }); });
5949
+ return () => { cancelled = true; };
5950
+ }, []);
5951
+ const d = state.data;
5952
+ const counts = (d && d.counts) || null;
5953
+ const fileOnly = (d && d.fileOnly) || [];
5954
+ const undefaulted = (d && d.undefaulted) || [];
5955
+ const title = t("memory.settings.configKeys.title");
5956
+ const body = state.loading
5957
+ ? h("div", { className: "mneme-set-hint" }, "…")
5958
+ // 取数失败与「真的没有只读键」必须分开说:把失败画成 0 条,人会以为清单就是空的。
5959
+ : state.failed || !counts
5960
+ ? h("div", { className: "mneme-set-hint", style: { color: "var(--mneme-danger)" } },
5961
+ t("memory.settings.configKeys.loadFailed"))
5962
+ : h(react.Fragment, null,
5963
+ h("div", { className: "mneme-set-hint", style: { marginTop: 4 } },
5964
+ t("memory.settings.configKeys.summary")
5965
+ .replace("{schema}", String(counts.schemaLeaf))
5966
+ .replace("{panel}", String(counts.panelEditable))
5967
+ .replace("{file}", String(counts.fileOnly))
5968
+ // 三类的数字必须都来自服务端算出来的桶:写成 schema − panel 会与展开后
5969
+ // 「只能改文件」那段对不上(评审 #4452245244),因为 schema 之外还多出
5970
+ // 白名单里登记了却没默认值的那几个键。
5971
+ .replace("{undefaulted}", String(counts.undefaulted))),
5972
+ open && h("div", { style: { marginTop: 10 } },
5973
+ // 两段各自的标题也带上条数,收起时只看 summary 就够判断要不要展开。
5974
+ h("div", { className: "mneme-featsubhint", style: { marginTop: 2 } },
5975
+ t("memory.settings.configKeys.fileOnlyHead")
5976
+ .replace("{n}", String(fileOnly.length))),
5977
+ h("div", { className: "mneme-keylist" },
5978
+ fileOnly.map((k) => h("code", { key: k, className: "mneme-keychip" }, k))),
5979
+ undefaulted.length > 0 && h(react.Fragment, null,
5980
+ h("div", { className: "mneme-featsubhint", style: { marginTop: 10 } },
5981
+ t("memory.settings.configKeys.undefaultedHead")
5982
+ .replace("{n}", String(undefaulted.length))),
5983
+ h("div", { className: "mneme-keylist" },
5984
+ undefaulted.map((k) => h("code", { key: k, className: "mneme-keychip" }, k))),
5985
+ h("div", { className: "mneme-featsubhint", style: { marginTop: 4 } },
5986
+ t("memory.settings.configKeys.undefaultedHint"))
5987
+ )
5988
+ ),
5989
+ h("button", { type: "button", className: "mneme-btn", style: { marginTop: 10 }, onClick: () => setOpen(!open) },
5990
+ open ? t("memory.settings.configKeys.hide") : t("memory.settings.configKeys.show"))
5991
+ );
5992
+ return h("section", { className: "mneme-set-card" },
5993
+ h("div", { className: "mneme-set-title" }, title),
5994
+ h("div", { className: "mneme-set-desc" }, t("memory.settings.configKeys.desc")),
5995
+ body
5996
+ );
5997
+ }
5998
+
5502
5999
  // --- Token 去向三屏(2026-10 可观测性改造)---
5503
6000
  // 原 LlmStatusCard 只有一个「LLM 消耗」总数,且 by_source 把两个最大成本项
5504
6001
  // dream_consolidate / dream_summarize 合并成 autoDream(2026-10 审计里巩固
@@ -5540,6 +6037,11 @@ window.__ModuleLoader__.load({
5540
6037
  function TokenFlowSection({ t }) {
5541
6038
  const [stats, setStats] = useState(null);
5542
6039
  const [heatStats, setHeatStats] = useState(null);
6040
+ // 沉淀曲线(/semantic/consolidation-stats)与 token 审计不同源:前者读
6041
+ // memories.created_at / receipt_chain / archived_at,与「花了多少 token」
6042
+ // 无关,所以是独立请求、独立 error 位——一个失败不该把另一个图也拖成错误态。
6043
+ const [sinkStats, setSinkStats] = useState(null);
6044
+ const [sinkError, setSinkError] = useState(false);
5543
6045
  const [statsError, setStatsError] = useState(false);
5544
6046
  // #521 评审:待确认冲突与「上一轮跳过明细」同源(/dream-status),一次请求两用。
5545
6047
  // 跳过明细必须出现在首屏——它原先只挂在折叠区里的 DreamStatusCards 上,而那个
@@ -5566,6 +6068,14 @@ window.__ModuleLoader__.load({
5566
6068
  setHeatStats(j && typeof j === "object" ? (j.stats || j) : {});
5567
6069
  })
5568
6070
  .catch(() => { if (!cancelled) setHeatStats({}); });
6071
+ // 沉淀曲线同样要 56 天窗口,理由与热力一致(7 天的 by_day 撑不满 8 周)。
6072
+ apiFetch("/api/dsh-mneme/semantic/consolidation-stats?days=56")
6073
+ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
6074
+ .then((j) => {
6075
+ if (cancelled) return;
6076
+ setSinkStats(j && typeof j === "object" ? j : {});
6077
+ })
6078
+ .catch(() => { if (!cancelled) setSinkError(true); });
5569
6079
  // KPI 第 4 格的待确认冲突与 DreamStatusCards 同源(/dream-status),
5570
6080
  // 同样监听 mneme:conflicts-changed 重取(#295),两处计数永不漂移。
5571
6081
  const loadConflicts = () => {
@@ -5685,6 +6195,42 @@ window.__ModuleLoader__.load({
5685
6195
  for (const d of [1, 3, 5]) rows[(d - startDow + 7) % 7] = ["一", "三", "五"][[1, 3, 5].indexOf(d)];
5686
6196
  return rows;
5687
6197
  })();
6198
+ // 8 周沉淀:56 天原始序列在热力右侧那条约 220px 的竖列里会细到看不出,
6199
+ // 所以按周聚成 8 桶(右端对齐今天 UTC,与 7 天柱状同一套「补连续区间」
6200
+ // 口径,缺周填 0)。三条线只画两条——新增与巩固是这张图要表达的剪刀差,
6201
+ // 归档数改在图例里给总量(它从 10-12 才开始非零,混进同一坐标会误导)。
6202
+ // sinkStats 为 null(未到)与 {}(已知为空)必须分开:前者不画图,后者
6203
+ // 画空态,否则加载态会先画 8 根零高柱子。
6204
+ const sinkDays = (() => {
6205
+ if (!sinkStats) return null;
6206
+ const rows = Array.isArray(sinkStats.by_day) ? sinkStats.by_day : [];
6207
+ const byDay = new Map(rows.map((d) => [String((d && d.day) ?? ""), d]));
6208
+ const out = [];
6209
+ for (let w = 7; w >= 0; w--) {
6210
+ let added = 0;
6211
+ let merged = 0;
6212
+ let archived = 0;
6213
+ let first = "";
6214
+ for (let i = 6; i >= 0; i--) {
6215
+ const key = new Date(endDay.getTime() - (w * 7 + i) * 86400000).toISOString().slice(0, 10);
6216
+ if (!first) first = key;
6217
+ const row = byDay.get(key);
6218
+ added += Number((row && row.added) ?? 0);
6219
+ merged += Number((row && row.merged) ?? 0);
6220
+ archived += Number((row && row.archived) ?? 0);
6221
+ }
6222
+ out.push({ key: first, added, merged, archived });
6223
+ }
6224
+ return out;
6225
+ })();
6226
+ const sinkMax = Math.max(1, ...((sinkDays ?? []).map((d) => d.added)));
6227
+ const sinkSum = (field) => (sinkDays ?? []).reduce((a, d) => a + d[field], 0);
6228
+ const sinkAddedTotal = sinkSum("added");
6229
+ const sinkMergedTotal = sinkSum("merged");
6230
+ const sinkArchivedTotal = sinkSum("archived");
6231
+ // 巩固/新增的比值取自总量而非逐周:单周可能新增为 0(无分母),总量口径
6232
+ // 才是「这个库到底在膨胀还是在巩固」的答案。
6233
+ const sinkGap = sinkAddedTotal > 0 ? `${((sinkMergedTotal / sinkAddedTotal) * 100).toFixed(0)}%` : "—";
5688
6234
  const kpi = (title, num, cap) =>
5689
6235
  h(StatusCard, { t, title, loading, error: statsError, num, cap });
5690
6236
  const segClass = (s) =>
@@ -5778,29 +6324,76 @@ window.__ModuleLoader__.load({
5778
6324
  h("div", { className: "mneme-barlabels" },
5779
6325
  days.map((d) => h("span", { key: d.day }, d.day.slice(5)))
5780
6326
  ),
5781
- h("h3", { className: "mneme-xcolhead" }, t("memory.status.token.heat")),
5782
- // 月份行与格子列同用列主序网格对齐:每月占的列数 = 该月在这个
5783
- // 56 天窗口里的列数(按列的起始日期算),标签只在该列起点画一次。
5784
- h("div", { className: "mneme-heatmonths" },
5785
- monthLabels.map((m) => h("span", { key: m.key, style: { gridColumn: `${m.from} / span ${m.span}` } }, m.label))
5786
- ),
5787
- h("div", { className: "mneme-heatwrap" },
5788
- h("div", { className: "mneme-heatrows", "aria-hidden": "true" },
5789
- heatRowLabels.map((d, i) => h("span", { key: i }, d))
6327
+ // 热力与沉淀并排两列:热力只有约 745px,右侧原本整块空着。两列
6328
+ // 在窄容器里回落单列(见 .mneme-flowrow 的 @container)。
6329
+ h("div", { className: "mneme-flowrow" },
6330
+ h("div", { className: "mneme-flowheat" },
6331
+ h("h3", { className: "mneme-xcolhead" }, t("memory.status.token.heat")),
6332
+ // 月份行与格子列同用列主序网格对齐:每月占的列数 = 该月在这个
6333
+ // 56 天窗口里的列数(按列的起始日期算),标签只在该列起点画一次。
6334
+ h("div", { className: "mneme-heatmonths" },
6335
+ monthLabels.map((m) => h("span", { key: m.key, style: { gridColumn: `${m.from} / span ${m.span}` } }, m.label))
6336
+ ),
6337
+ h("div", { className: "mneme-heatwrap" },
6338
+ h("div", { className: "mneme-heatrows", "aria-hidden": "true" },
6339
+ heatRowLabels.map((d, i) => h("span", { key: i }, d))
6340
+ ),
6341
+ h("div", { className: "mneme-heatgrid" },
6342
+ heatCells.map((c) => h("div", {
6343
+ key: c.key,
6344
+ className: heatLevel(c.total, heatQ) ? `mneme-heatcell l${heatLevel(c.total, heatQ)}` : "mneme-heatcell",
6345
+ title: `${c.key} · ${c.total.toLocaleString()}`
6346
+ }))
6347
+ )
6348
+ ),
6349
+ h("div", { className: "mneme-heatlegend" },
6350
+ h("span", null, t("memory.status.token.heatLess")),
6351
+ h("span", { className: "mneme-heatcell" }),
6352
+ [1, 2, 3, 4].map((lv) => h("span", { key: lv, className: `mneme-heatcell l${lv}` })),
6353
+ h("span", null, t("memory.status.token.heatMore"))
6354
+ )
5790
6355
  ),
5791
- h("div", { className: "mneme-heatgrid" },
5792
- heatCells.map((c) => h("div", {
5793
- key: c.key,
5794
- className: heatLevel(c.total, heatQ) ? `mneme-heatcell l${heatLevel(c.total, heatQ)}` : "mneme-heatcell",
5795
- title: `${c.key} · ${c.total.toLocaleString()}`
5796
- }))
6356
+ h("div", { className: "mneme-flowsink" },
6357
+ h("h3", { className: "mneme-xcolhead" }, t("memory.status.token.sink")),
6358
+ sinkError
6359
+ ? h("div", { className: "mneme-statuscap", style: { color: "var(--mneme-danger)" } }, t("memory.status.error"))
6360
+ : !sinkDays
6361
+ // 未到:什么都不画。画空态会把「还没加载完」说成「最近没有记录」。
6362
+ ? null
6363
+ : sinkAddedTotal + sinkMergedTotal + sinkArchivedTotal === 0
6364
+ ? h("div", { className: "mneme-statuscap", style: { marginTop: "12px" } }, t("memory.status.token.sinkEmpty"))
6365
+ : h("div", { className: "mneme-sinkchart" },
6366
+ (sinkDays ?? []).map((d) => h("div", { className: "mneme-sinkcol", key: d.key },
6367
+ // 两根柱同一基准(本期最大新增),高度差就是剪刀差本身;
6368
+ // 巩固可能远小于新增,故给 1px 下限免得整根消失。
6369
+ h("div", {
6370
+ className: "mneme-sinkbar mneme-sinkbar--added",
6371
+ style: { height: `${Math.max((d.added / sinkMax) * 100, d.added > 0 ? 1 : 0)}%` },
6372
+ title: `${d.key} · ${t("memory.status.token.sinkAdded")} ${d.added}`
6373
+ }),
6374
+ h("div", {
6375
+ className: "mneme-sinkbar mneme-sinkbar--merged",
6376
+ style: { height: `${Math.max((d.merged / sinkMax) * 100, d.merged > 0 ? 1 : 0)}%` },
6377
+ title: `${d.key} · ${t("memory.status.token.sinkMerged")} ${d.merged}`
6378
+ })
6379
+ ))
6380
+ ),
6381
+ sinkDays && !sinkError
6382
+ ? h("div", { className: "mneme-sinklabels" },
6383
+ sinkDays.map((d) => h("span", { key: d.key }, d.key.slice(5)))
6384
+ )
6385
+ : null,
6386
+ // 图例只在有数据时给:加载中把三个 0 画出来等于谎报「最近没东西进库」。
6387
+ sinkDays && !sinkError
6388
+ ? h("div", { className: "mneme-sinklegend" },
6389
+ h("span", null, `${t("memory.status.token.sinkAdded")} `, h("b", null, sinkAddedTotal.toLocaleString())),
6390
+ h("span", null, `${t("memory.status.token.sinkMerged")} `, h("b", null, sinkMergedTotal.toLocaleString())),
6391
+ h("span", null, `${t("memory.status.token.sinkArchived")} `, h("b", null, sinkArchivedTotal.toLocaleString())),
6392
+ h("span", null, `${t("memory.status.token.sinkGap")} `, h("b", null, sinkGap))
6393
+ )
6394
+ : null,
6395
+ h("div", { className: "mneme-statuscap", style: { marginTop: "6px", fontSize: "11px" } }, t("memory.status.token.sinkFoot"))
5797
6396
  )
5798
- ),
5799
- h("div", { className: "mneme-heatlegend" },
5800
- h("span", null, t("memory.status.token.heatLess")),
5801
- h("span", { className: "mneme-heatcell" }),
5802
- [1, 2, 3, 4].map((lv) => h("span", { key: lv, className: `mneme-heatcell l${lv}` })),
5803
- h("span", null, t("memory.status.token.heatMore"))
5804
6397
  )
5805
6398
  )
5806
6399
  );
@@ -6498,6 +7091,13 @@ window.__ModuleLoader__.load({
6498
7091
  const [saveError, setSaveError] = useState(false);
6499
7092
  const [copied, setCopied] = useState(false);
6500
7093
  const [entities, setEntities] = useState(null);
7094
+ // #512:这条记忆的归因——窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。
7095
+ // null = 还没读到(或读失败),两者要分得开,不能被当成「没有归因」。
7096
+ // 这个 state 必须住在**读它的那个组件**里:加归因区块时它曾被误放进
7097
+ // EntityPanel(同文件的上一个组件),于是 MemoryDrawer 的 effect 与渲染块
7098
+ // 引用到一个不存在的绑定,一开抽屉就 ReferenceError 把整个 renderer 带走
7099
+ // (issue #553)。同名 state 落在隔壁组件里,静态扫描与 review 都不容易看出来。
7100
+ const [provenance, setProvenance] = useState(null); // null = still loading
6501
7101
  // 整包退出的两段:先干跑清点(点按钮那一发),再确认物删。撤是**物理删除**不是归档
6502
7102
  // (归档行仍占 share_external_id 的 UNIQUE 位,撤了会导不回来,理由在
6503
7103
  // src/share-import.js 的 deleteSharePack),所以这里比删除那条路多一站清点。
@@ -6890,6 +7490,98 @@ window.__ModuleLoader__.load({
6890
7490
  );
6891
7491
  }
6892
7492
 
7493
+ // --- 从目录引导弹层(issue #386 第 6 条)---
7494
+ // 后端能力早在 #220 就实现了(src/bootstrap.js:六类确定性产物,零 LLM,吃
7495
+ // saveWithDedupe 的 (type,title,scope) 去重),但只有 Bearer 数据面一条入口,
7496
+ // 面板够不着——用户照文档找过来,发现产品界面里没有这个东西。
7497
+ //
7498
+ // 与 ImportDialog 的两处刻意的不同:
7499
+ // 1. 成功路径**不**重复 setBusy(false)。这里没有「同键再提交一次会追加正文」的
7500
+ // 风险(bootstrap 是 _overwrite 刷新),但 900ms 的提示阅读时间同样成立;
7501
+ // 更要紧的是这一段里组件仍挂着,按钮若重新可点,人会以为「没反应」再点一次。
7502
+ // 定时器句柄存进 ref,卸载时清掉——否则第二次提交的结果会写进已卸载的组件
7503
+ // (#386 第 6 条评审)。
7504
+ // 2. 错误按服务端的码分开说。「目录不存在」「不是目录」「能力没接线」三种情况下
7505
+ // 用户该做的事完全不同,统一成一句「引导失败」等于让他自己猜。
7506
+ function BootstrapDialog({ t, onClose, onImported, onBusyChange }) {
7507
+ const [dir, setDir] = useState("");
7508
+ const [busy, setBusy] = useState(false);
7509
+ const [msg, setMsg] = useState("");
7510
+ const [err, setErr] = useState("");
7511
+ const timerRef = useRef(null);
7512
+ useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
7513
+ // 忙碌状态要让外面也知道:菜单那个「⋯」在请求进行中若把弹层卸掉,等于绕过
7514
+ // 了下面那两个 disabled 的按钮,请求还在飞、界面已经没了(评审 #4452245292)。
7515
+ useEffect(() => { if (onBusyChange) onBusyChange(busy); }, [busy, onBusyChange]);
7516
+ useEffect(() => () => { if (onBusyChange) onBusyChange(false); }, [onBusyChange]);
7517
+ const submit = () => {
7518
+ if (busy) return;
7519
+ const target = String(dir || "").trim();
7520
+ if (!target) { setErr(t("memory.explorer.bootstrapNoDir")); return; }
7521
+ setBusy(true);
7522
+ // 同步告诉父组件,别等下面那个 effect:effect 要等这次渲染提交后才跑,
7523
+ // 中间那一拍父组件的 bootstrapBusy 还是 false,此时点「⋯」仍会把弹层卸掉,
7524
+ // 请求的成功或失败就再没有地方显示(评审 #4452539705)。
7525
+ if (onBusyChange) onBusyChange(true);
7526
+ setErr("");
7527
+ apiFetch("/api/dsh-mneme/bootstrap", {
7528
+ method: "POST",
7529
+ headers: { "Content-Type": "application/json" },
7530
+ // 传原串(不 trim):前后空格在多数文件系统里是合法路径的一部分,
7531
+ // 服务端也只是 trim 判空、回传原值。
7532
+ body: JSON.stringify({ dir })
7533
+ })
7534
+ .then((res) => res.json().then((d) => ({ ok: res.ok, d })))
7535
+ .then(({ ok, d }) => {
7536
+ if (!ok) {
7537
+ const code = d && d.error;
7538
+ const key = "memory.explorer.bootstrapErr." + String(code || "");
7539
+ const localized = t(key);
7540
+ // t() 对未知键原样返回键名——那就退回通用文案,别把键名甩给用户。
7541
+ setErr(localized === key ? t("memory.explorer.bootstrapFailed") : localized);
7542
+ setBusy(false);
7543
+ return;
7544
+ }
7545
+ setMsg(t("memory.explorer.bootstrapDone")
7546
+ .replace("{n}", String((d && d.created) ?? 0))
7547
+ .replace("{m}", String((d && d.merged) ?? 0)));
7548
+ // onImported 同步调,只把 onClose 留在定时器里:切视图会卸载本组件并清掉
7549
+ // timerRef,若刷新也排在那 900ms 之后,这一步就静默丢了——库已经写进去了,
7550
+ // 列表却还是旧的(评审 #4452539711)。
7551
+ onImported();
7552
+ timerRef.current = setTimeout(() => onClose(), 900);
7553
+ })
7554
+ .catch(() => { setBusy(false); setErr(t("memory.explorer.bootstrapFailed")); });
7555
+ };
7556
+ return h("div", { className: "mneme-impdialog", role: "region", "aria-label": t("memory.explorer.bootstrapTitle") },
7557
+ h("div", { className: "mneme-set-title" }, t("memory.explorer.bootstrapTitle")),
7558
+ h("div", { className: "mneme-featsubhint", style: { marginTop: 4 } }, t("memory.explorer.bootstrapHint")),
7559
+ h("div", { className: "mneme-improw" },
7560
+ h("span", { className: "mneme-featnumlabel" }, t("memory.explorer.bootstrapDir")),
7561
+ // 用文本框而不是 <input webkitdirectory>:后者给的是「选了哪个目录里的
7562
+ // 一批文件」,拿不到目录本身,而 bootstrap 要的正是那个目录。
7563
+ h("input", {
7564
+ className: "mneme-set-input",
7565
+ style: { marginBottom: 0, flex: 1, minWidth: 0 },
7566
+ type: "text",
7567
+ value: dir,
7568
+ placeholder: "/path/to/project",
7569
+ // 请求进行中锁住输入框:否则用户改成 B,回来的提示就贴在 B 旁边,
7570
+ // 而它说的是 A 的结果(评审 #4452245279)。
7571
+ disabled: busy,
7572
+ onChange: (e) => { setDir(e.target.value); setErr(""); }
7573
+ })
7574
+ ),
7575
+ err && h("div", { className: "mneme-featsubhint", style: { color: "var(--mneme-danger)", marginTop: 8 } }, err),
7576
+ msg && h("div", { className: "mneme-saved", style: { marginTop: 8 } }, msg),
7577
+ h("div", { className: "mneme-improw", style: { justifyContent: "flex-end" } },
7578
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: onClose }, t("memory.explorer.importCancel")),
7579
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: submit },
7580
+ busy ? t("memory.explorer.bootstraping") : t("memory.explorer.bootstrapConfirm"))
7581
+ )
7582
+ );
7583
+ }
7584
+
6893
7585
  // --- 分享包弹层 ---
6894
7586
  // 这一面的支点只有一句:**看见 report 之前不给下载**。服务端一次 POST 就把包和报告
6895
7587
  // 一起交回,所以这一格拦不住靠协议、只能靠界面自己:CLI 那侧没人拦得住,面板至少要让
@@ -7333,7 +8025,7 @@ window.__ModuleLoader__.load({
7333
8025
  );
7334
8026
  }
7335
8027
 
7336
- function MemoryExplorer({ t }) {
8028
+ function MemoryExplorer({ t, onBusyChange }) {
7337
8029
  const [view, setView] = useState("memory"); // memory | entity | status | settings
7338
8030
  const [items, setItems] = useState([]); // loaded browse pages, chrono desc
7339
8031
  const [total, setTotal] = useState(0); // server count for the current type filter
@@ -7369,6 +8061,21 @@ window.__ModuleLoader__.load({
7369
8061
  const [pendingIds, setPendingIds] = useState(() => new Set());
7370
8062
  const [menuOpen, setMenuOpen] = useState(false);
7371
8063
  const [importOpen, setImportOpen] = useState(false);
8064
+ const [bootstrapOpen, setBootstrapOpen] = useState(false);
8065
+ // 引导请求是否在飞:「⋯」按钮在忙碌时必须不关弹层(评审 #4452245292)。
8066
+ const [bootstrapBusy, setBootstrapBusy] = useState(false);
8067
+ // 一路直接抬到外层 MemoryOverlay:它要在 Esc / 背板 / ✕ 三条关闭路径上一起挡
8068
+ // (评审 #4453225573)。刻意**不**用 useEffect 转报——那要等渲染提交后才跑,
8069
+ // 中间那一拍外层守卫读到的是 false,正是 #4452539705 那条的老毛病。
8070
+ // 走 BootstrapDialog 的回调,它的 submit 里是同步调的。
8071
+ // better-sidebar 那个 tab 注册时不传 onBusyChange,所以这里必须容忍 undefined。
8072
+ // 身份必须稳定:BootstrapDialog 的卸载 effect 依赖 [onBusyChange],父组件每
8073
+ // 重渲染一次就会先跑 cleanup 调 onBusyChange(false)——而父组件恰好是在
8074
+ // setBootstrapBusy(true) 之后重渲染的,那一抹等于把刚置上的忙碌标记当场抹掉。
8075
+ const reportBootstrapBusy = useCallback((v) => {
8076
+ setBootstrapBusy(v);
8077
+ if (onBusyChange) onBusyChange(v);
8078
+ }, [onBusyChange]);
7372
8079
  const [createOpen, setCreateOpen] = useState(false);
7373
8080
  const [shareOpen, setShareOpen] = useState(false);
7374
8081
  const [receiveOpen, setReceiveOpen] = useState(false);
@@ -7472,7 +8179,10 @@ window.__ModuleLoader__.load({
7472
8179
  .catch(() => { if (!cancelled) setRemoteItems([]); });
7473
8180
  }, 250);
7474
8181
  return () => { cancelled = true; clearTimeout(timer); };
7475
- }, [query, semantic, searchTopK]);
8182
+ // reloadKey 必须进依赖:导入/引导/接收成功时只递增它,而搜索态下 items 走
8183
+ // remoteItems 那条路——少了这一格,搜索词不变就永远看不到刚进来的新行
8184
+ // (评审 #4452245305)。
8185
+ }, [query, semantic, searchTopK, reloadKey]);
7476
8186
 
7477
8187
  // Live view: while the memory tab is open, quietly re-fetch page 1
7478
8188
  // every 30s (and on every activation) so fresh memories surface
@@ -7664,7 +8374,18 @@ window.__ModuleLoader__.load({
7664
8374
  setSelectedId(target.id);
7665
8375
  };
7666
8376
 
8377
+ // 引导请求在飞时不许离开记忆视图(评审 #4453639221):`.mneme-xtools` 整块挂在
8378
+ // `view === "memory"` 之下,BootstrapDialog 就在里面。任何离开这个视图的动作都会
8379
+ // 连它一起卸载——请求还在飞、界面已经没了,那句「新建几条、并入几条」就再没有
8380
+ // 地方显示。这里只拦「离开」的路径:jumpToMemory / browseWithFilter 是回到
8381
+ // memory,不拦。
8382
+ const leaveMemory = (key) => {
8383
+ if (bootstrapBusy) return;
8384
+ setView(key);
8385
+ };
8386
+
7667
8387
  const openGraphFor = (name) => {
8388
+ if (bootstrapBusy) return;
7668
8389
  setGraphFocus(name || "");
7669
8390
  setView("entity");
7670
8391
  };
@@ -7693,9 +8414,14 @@ window.__ModuleLoader__.load({
7693
8414
  h("button", {
7694
8415
  key: s.key,
7695
8416
  type: "button",
8417
+ // 引导请求在飞时四个视图标签一律禁用(评审 #4453639221):离开 memory
8418
+ // 会把整个 `.mneme-xtools` 连同里面的 BootstrapDialog 卸掉。onClick 仍
8419
+ // 走 leaveMemory 而不是 setView——disabled 只是第一道,第二道在函数里,
8420
+ // 免得将来有人给标签换一种可点的实现就绕过去了。
8421
+ disabled: bootstrapBusy,
7696
8422
  className: view === s.key ? "mneme-vtab mneme-active" : "mneme-vtab",
7697
8423
  "aria-pressed": String(view === s.key),
7698
- onClick: () => setView(s.key)
8424
+ onClick: () => leaveMemory(s.key)
7699
8425
  },
7700
8426
  h(Icon, { name: s.key === "memory" ? "database" : s.key === "entity" ? "waypoints" : s.key === "status" ? "activity" : "settings", size: 14, className: "mneme-vtabico" }),
7701
8427
  s.label
@@ -7722,20 +8448,41 @@ window.__ModuleLoader__.load({
7722
8448
  "aria-label": t("memory.explorer.more"),
7723
8449
  "aria-expanded": String(menuOpen),
7724
8450
  title: t("memory.explorer.more"),
7725
- onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); setCreateOpen(false); setShareOpen(false); setReceiveOpen(false); }
8451
+ // 引导请求在飞时整个「⋯」都不给开:菜单与引导弹层共用同一个
8452
+ // 绝对定位锚点、同一层 z-index,弹层排在菜单之后渲染,会把
8453
+ // 菜单顶上的两项导出盖住——它们没有 disabled,却点不到
8454
+ // (评审 #4453225585)。不开菜单,就不存在「可点但被遮住」。
8455
+ // 这也顺带把 #4452245292 那条锁得更死:菜单开不了,就没有
8456
+ // 任何路径能在请求中途把引导弹层卸掉。
8457
+ disabled: bootstrapBusy,
8458
+ onClick: () => {
8459
+ setMenuOpen(!menuOpen);
8460
+ setImportOpen(false);
8461
+ // 引导弹层只在不忙的时候才关:它内部已经用 busy 禁用了取消与提交,
8462
+ // 这里若照旧卸载,等于给了一条绕过那两道锁的路(评审 #4452245292)。
8463
+ if (!bootstrapBusy) setBootstrapOpen(false);
8464
+ setCreateOpen(false);
8465
+ setShareOpen(false);
8466
+ setReceiveOpen(false);
8467
+ }
7726
8468
  }, "⋯"),
7727
8469
  menuOpen && h("div", { className: "mneme-menu", role: "menu" },
7728
- h("button", { type: "button", role: "menuitem", onClick: () => { setCreateOpen(true); setMenuOpen(false); } },
8470
+ // 引导请求在飞时,会挂弹层的那几项一律 disabled:这些弹层共用同一个
8471
+ // 绝对定位锚点,后渲染的会盖住引导弹层,它那句「新建几条、并入几条」
8472
+ // 就没地方看了(评审 #4452539738)。导出两项不挂弹层,照旧可点。
8473
+ h("button", { type: "button", role: "menuitem", disabled: bootstrapBusy, onClick: () => { setCreateOpen(true); setMenuOpen(false); } },
7729
8474
  t("memory.explorer.newMemory")),
7730
8475
  h("button", { type: "button", role: "menuitem", onClick: () => { setMenuOpen(false); exportData("json"); } },
7731
8476
  t("memory.explorer.exportJson")),
7732
8477
  h("button", { type: "button", role: "menuitem", onClick: () => { setMenuOpen(false); exportData("markdown"); } },
7733
8478
  t("memory.explorer.exportMarkdown")),
7734
- h("button", { type: "button", role: "menuitem", onClick: () => { setImportOpen(true); setMenuOpen(false); } },
8479
+ h("button", { type: "button", role: "menuitem", disabled: bootstrapBusy, onClick: () => { setImportOpen(true); setMenuOpen(false); } },
7735
8480
  t("memory.explorer.importMd")),
7736
- h("button", { type: "button", role: "menuitem", onClick: () => { setShareOpen(true); setMenuOpen(false); } },
8481
+ h("button", { type: "button", role: "menuitem", disabled: bootstrapBusy, onClick: () => { setBootstrapOpen(true); setMenuOpen(false); } },
8482
+ t("memory.explorer.bootstrap")),
8483
+ h("button", { type: "button", role: "menuitem", disabled: bootstrapBusy, onClick: () => { setShareOpen(true); setMenuOpen(false); } },
7737
8484
  t("memory.explorer.sharePack")),
7738
- h("button", { type: "button", role: "menuitem", onClick: () => { setReceiveOpen(true); setMenuOpen(false); } },
8485
+ h("button", { type: "button", role: "menuitem", disabled: bootstrapBusy, onClick: () => { setReceiveOpen(true); setMenuOpen(false); } },
7739
8486
  t("memory.explorer.receivePack")),
7740
8487
  ),
7741
8488
  importOpen && h(ImportDialog, {
@@ -7743,6 +8490,12 @@ window.__ModuleLoader__.load({
7743
8490
  onClose: () => setImportOpen(false),
7744
8491
  onImported: () => setReloadKey((k) => k + 1)
7745
8492
  }),
8493
+ bootstrapOpen && h(BootstrapDialog, {
8494
+ t,
8495
+ onClose: () => setBootstrapOpen(false),
8496
+ onBusyChange: reportBootstrapBusy,
8497
+ onImported: () => setReloadKey((k) => k + 1)
8498
+ }),
7746
8499
  shareOpen && h(ShareDialog, { t, onClose: () => setShareOpen(false) }),
7747
8500
  receiveOpen && h(ReceiveDialog, {
7748
8501
  t,
@@ -8020,8 +8773,10 @@ window.__ModuleLoader__.load({
8020
8773
  // 入口形状跟着宿主 v0.2 的侧边栏走,分两态:
8021
8774
  // · 展开态:宿主把「插件 / 自动化任务」这类全局面板渲染成 nav.panelList
8022
8775
  // 里的 panelRow(36px 行高、透明底、图标 + 标题)。我们把按钮 portal 进
8023
- // 同一组并原样套用宿主 panelRow 的实时类名,于是它是该组的第三行——低调,
8024
- // 而不是 v0.8.10 那样长得像第二个高亮「新会话」按钮。
8776
+ // 同一组并原样套用宿主 panelRow 的实时类名,于是它是该组的一行——低调,
8777
+ // 而不是 v0.8.10 那样长得像第二个高亮「新会话」按钮。落点是「更多」行
8778
+ // 之前而不是组尾:宿主 0.2.2 起把「更多」追加进同一个 nav,组尾已不再是
8779
+ // 面板行,落在它下面会被展开的菜单盖住(#573)。
8025
8780
  // · 收起态(Windows 标题栏):panelList / regionArea / footArea 被宿主
8026
8781
  // display:none,只有开关与「新会话」被改成固定在标题栏上。我们把按钮搬回
8027
8782
  // 标题栏、并排在「新会话」右侧(84px / 120px 的算法见上面 CSS 注释),
@@ -8096,6 +8851,33 @@ window.__ModuleLoader__.load({
8096
8851
  return h("span", { className: "mneme-entrybadge", "aria-hidden": "true" },
8097
8852
  pending > 99 ? "99+" : String(pending));
8098
8853
  }
8854
+ // --- 宿主内整页面板(新版宿主):侧栏图标 + 中列整页 ---
8855
+ // 首选承载方式:记忆库直接长在宿主中列里,与会话平级,不再叠一层背板
8856
+ // 浮层。老宿主没声明 sidebar.panellist 时退回 footer.action + 浮层(见 apply)。
8857
+ function SidebarPanelGlyph({ size }) {
8858
+ // 红点复用模块级单例:图标与回落按钮共享同一个计数、同一条 60s 兜底轮询。
8859
+ const pending = useConflictBadgeCount();
8860
+ // 宿主 PanelRow 已经自带 button / aria-label / Tooltip / active 高亮,
8861
+ // 这里只交一颗图形——再造一层按钮会套出两颗可聚焦元素,Tab 要停两次,
8862
+ // 而且 host 的 selectPanel 也轮不到我们调。
8863
+ return h("span", { className: "mneme-paneliglyph" },
8864
+ renderMnemeMark({ size: size || 18 }),
8865
+ h(ConflictBadge, { pending })
8866
+ );
8867
+ }
8868
+
8869
+ function MnemeMainPanel({ t }) {
8870
+ // 进面板 = 用户准备处理冻结冲突:重取一次红点计数。理由与浮层时代
8871
+ // openLibrary 里那次重取相同——裁决走别的路径或后台刚写过库时,计数
8872
+ // 不该等 60s 兜底轮询才对齐。
8873
+ useEffect(() => { conflictBadgeCount.refresh(); }, []);
8874
+ // 不自带「回到会话」按钮:宿主的 openSession / 新建会话都会
8875
+ // selectPanel(null),用户点侧栏任何会话行就回得去(baleen 同样不做)。
8876
+ return h("div", { className: "mneme-panel" },
8877
+ h(MemoryExplorer, { t })
8878
+ );
8879
+ }
8880
+
8099
8881
  function SidebarFallbackTrigger({ wide, t }) {
8100
8882
  const pending = useConflictBadgeCount();
8101
8883
  // #295 评审:.mneme-trigger 自身 overflow:hidden 且无定位上下文,badge
@@ -8139,6 +8921,12 @@ window.__ModuleLoader__.load({
8139
8921
  const list = findPanelList(region);
8140
8922
  return list ? list.querySelector('[class*="panelRow"]:not(.mneme-topentry-native)') : null;
8141
8923
  };
8924
+ // 面板列表里的「更多」行(宿主 PanelMoreMenu 的锚点)。宿主 0.2.2 起先渲染
8925
+ // 面板行、再把「更多」追加进同一个 nav,于是列表最后一项不再是面板行——
8926
+ // 我们的入口若仍 appendChild 到末尾就会落在它下面,菜单一展开就盖住入口
8927
+ // (#573)。按 [class*=] 前缀找,和其余几处一样不写死哈希类名;老宿主
8928
+ // 没有这行时返回 null,退回追加到末尾的老行为。
8929
+ const findMoreAnchor = (list) => list.querySelector('[class*="moreAnchor"]');
8142
8930
  // 占位锚点:新会话行(现役外壳里 newSession 按钮嵌在 logoRow 内,
8143
8931
  // 旧外壳是根的直接子按钮)。判定与生态插件的 sidebar-entry-core
8144
8932
  // 一致;锚点不可靠时回退到 regionArea 之前的旧位置。
@@ -8164,6 +8952,14 @@ window.__ModuleLoader__.load({
8164
8952
  // 列表时退回「新会话」行之后,由 .mneme-topentry-native 兜住低调行样式。
8165
8953
  const list = wide ? findPanelList(region) : null;
8166
8954
  if (list) {
8955
+ // 落在面板组末尾、但在「更多」行之前(#573);没有「更多」行时组尾
8956
+ // 就是终点,退回老行为。
8957
+ const more = findMoreAnchor(list);
8958
+ if (more) {
8959
+ if (created.parentElement === list && created.nextElementSibling === more) return;
8960
+ list.insertBefore(created, more);
8961
+ return;
8962
+ }
8167
8963
  if (created.parentElement === list && created === list.lastElementChild) return;
8168
8964
  list.appendChild(created);
8169
8965
  return;
@@ -9356,13 +10152,214 @@ window.__ModuleLoader__.load({
9356
10152
  const t = ctx.locale.bind(NS);
9357
10153
  ctx.effect(() => ctx.locale.register(NS, dictionaries), "dsh-mneme: dictionaries");
9358
10154
 
9359
- // 记忆库唯一入口:sidebar.footer.action 注册作为锚点与回退;真实按钮
9360
- // 由 SidebarTopEntry portal 到侧边栏工作区上方。overlay 从这个常驻
9361
- // 插槽挂载(portal 到 body),会话切换不影响它的开关状态。
9362
- // v0.7.15 起不再注册 conversation.view tab——对话内嵌的面板被悬浮
9363
- // 输入框遮挡、挤压会话布局,sheet 页是更舒服的承载方式。
9364
- ctx.slots.inject("sidebar.footer.action", () => {
9365
- return ctx.slots.register({
10155
+ // --- 入口座位 ---
10156
+ // --- chorography(仓库地志)面板(M1,spec §7)---------------------
10157
+ // 浏览页:左模块树 / 右正文 / 顶部检索。数据从 /choro/* 四条读路由来,
10158
+ // 真源是仓库磁盘的 MD。座位无条件注册(P11):inject 同名二次注册的
10159
+ // cordis 语义没验证过,不赌;chorographyEnabled 关着时页面渲染「未启用」
10160
+ // 空态(路由 409 disabled),开关生效走既有「重启生效」纪律——座位常挂
10161
+ // 的收敛形态就是 D2 拆双 Cordis 条目时的样子。
10162
+ function WikiGlyph({ size }) {
10163
+ // 图形走 currentColor,粗细对比交给宿主的 active 态;不引入新色值。
10164
+ return h("span", { className: "mneme-paneliglyph", "aria-hidden": "true" },
10165
+ h("svg", {
10166
+ width: size || 18, height: size || 18, viewBox: "0 0 24 24",
10167
+ fill: "none", stroke: "currentColor", strokeWidth: 1.8,
10168
+ strokeLinecap: "round", strokeLinejoin: "round"
10169
+ },
10170
+ h("path", { d: "M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2z" }),
10171
+ h("path", { d: "M9 4v14" }),
10172
+ h("path", { d: "M15 6v14" })
10173
+ )
10174
+ );
10175
+ }
10176
+
10177
+ // 只读 MD 渲染(P13):React 文本节点天然转义,不经过 innerHTML,无原始
10178
+ // HTML 通道。frontmatter 折叠不显示;表格按等宽行渲染(知识库页的表都是
10179
+ // 两三列的清单,保真优先于交互);`code` 拆成等宽 span。
10180
+ function renderWikiMarkdown(content) {
10181
+ const lines = String(content ?? "").split("\n");
10182
+ let i = 0;
10183
+ // frontmatter:首行是 --- 时吃到下一行 ---
10184
+ if (lines[0] && lines[0].trim() === "---") {
10185
+ i = 1;
10186
+ while (i < lines.length && lines[i].trim() !== "---") i++;
10187
+ i++;
10188
+ }
10189
+ const out = [];
10190
+ const inline = (text) => String(text).split(/`([^`]+)`/).map((part, idx) =>
10191
+ idx % 2 === 1 ? h("code", { key: idx, className: "mneme-wikicode" }, part) : part
10192
+ );
10193
+ let list = null; // "ul" | "ol" | null
10194
+ const flushList = () => {
10195
+ if (list) { out.push(h(list, { key: out.length }, listItems)); listItems = []; list = null; }
10196
+ };
10197
+ let listItems = [];
10198
+ let inCode = false;
10199
+ let codeLines = [];
10200
+ for (; i < lines.length; i++) {
10201
+ const line = lines[i];
10202
+ if (line.trim().startsWith("```")) {
10203
+ if (inCode) {
10204
+ out.push(h("pre", { key: out.length, className: "mneme-wikipre" }, codeLines.join("\n")));
10205
+ codeLines = []; inCode = false;
10206
+ } else { flushList(); inCode = true; }
10207
+ continue;
10208
+ }
10209
+ if (inCode) { codeLines.push(line); continue; }
10210
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
10211
+ if (heading) {
10212
+ flushList();
10213
+ const level = Math.min(heading[1].length + 1, 5);
10214
+ out.push(h(`h${level}`, { key: out.length }, inline(heading[2])));
10215
+ continue;
10216
+ }
10217
+ const ul = line.match(/^\s*[-*]\s+(.*)$/);
10218
+ const ol = line.match(/^\s*\d+[.)]\s+(.*)$/);
10219
+ if (ul || ol) {
10220
+ const want = ul ? "ul" : "ol";
10221
+ if (list !== want) flushList();
10222
+ list = want;
10223
+ listItems.push(h("li", { key: listItems.length }, inline(ul ? ul[1] : ol[1])));
10224
+ continue;
10225
+ }
10226
+ flushList();
10227
+ if (/^\s*\|/.test(line)) { out.push(h("div", { key: out.length, className: "mneme-wikitablerow" }, inline(line.trim()))); continue; }
10228
+ if (/^>\s?/.test(line)) { out.push(h("blockquote", { key: out.length, className: "mneme-wikiquote" }, inline(line.replace(/^>\s?/, "")))); continue; }
10229
+ if (line.trim() === "") continue;
10230
+ out.push(h("p", { key: out.length }, inline(line)));
10231
+ }
10232
+ flushList();
10233
+ if (inCode && codeLines.length > 0) out.push(h("pre", { key: out.length, className: "mneme-wikipre" }, codeLines.join("\n")));
10234
+ return out;
10235
+ }
10236
+
10237
+ function WikiMainPanel({ t }) {
10238
+ const [state, setState] = useState({ phase: "loading", repos: [], repo: null, modules: [], edges: [], page: null, hits: [], q: "", selected: null, disabled: false, error: null });
10239
+ // apiFetch 是裸 fetch:非 2xx 不抛错,返回 Response。这里统一解 JSON 并把
10240
+ // 状态码带回来——409 disabled(用户没开)/ 503 unavailable(装配缺件)
10241
+ // 各有各的空态文案(#497 的语义分叉在这里接住)。
10242
+ const readJson = async (res) => {
10243
+ let data = {};
10244
+ try { data = await res.json(); } catch { /* 空响应体 */ }
10245
+ return { status: res.status, data };
10246
+ };
10247
+ const load = useCallback(async (repoRef) => {
10248
+ try {
10249
+ const { status, data } = await readJson(await apiFetch(`/api/dsh-mneme/choro/repos`));
10250
+ if (status === 409) { setState((s) => ({ ...s, phase: "disabled" })); return; }
10251
+ if (status === 503) { setState((s) => ({ ...s, phase: "unavailable" })); return; }
10252
+ const repos = data.repos ?? [];
10253
+ if (repos.length === 0) { setState((s) => ({ ...s, phase: "no-repo", repos: [] })); return; }
10254
+ const ref = repoRef ?? (repos.length === 1 ? repos[0].id : null);
10255
+ if (ref === null) { setState((s) => ({ ...s, phase: "pick-repo", repos })); return; }
10256
+ const mod = await readJson(await apiFetch(`/api/dsh-mneme/choro/modules?repo=${encodeURIComponent(ref)}`));
10257
+ if (mod.status !== 200) { setState((s) => ({ ...s, phase: "error", error: `modules ${mod.status}` })); return; }
10258
+ setState((s) => ({ ...s, phase: "ready", repos, repo: mod.data.repo, modules: mod.data.modules ?? [], edges: mod.data.edges ?? [], page: null, selected: null }));
10259
+ } catch (err) {
10260
+ setState((s) => ({ ...s, phase: "error", error: String(err?.message ?? err) }));
10261
+ }
10262
+ }, []);
10263
+ useEffect(() => { load(); }, [load]);
10264
+ const openPage = useCallback(async (slug) => {
10265
+ const repo = state.repo;
10266
+ if (!repo) return;
10267
+ setState((s) => ({ ...s, selected: slug, page: null }));
10268
+ try {
10269
+ const { status, data } = await readJson(await apiFetch(`/api/dsh-mneme/choro/page?repo=${encodeURIComponent(repo.id)}&slug=${encodeURIComponent(slug)}`));
10270
+ if (status !== 200 || data.error) { setState((s) => ({ ...s, page: { error: data.error ?? `page ${status}` } })); return; }
10271
+ setState((s) => ({ ...s, page: data.page }));
10272
+ } catch (err) {
10273
+ setState((s) => ({ ...s, page: { error: String(err?.message ?? err) } }));
10274
+ }
10275
+ }, [state.repo]);
10276
+ const runSearch = useCallback(async (q) => {
10277
+ setState((s) => ({ ...s, q }));
10278
+ const repo = state.repo;
10279
+ if (!repo || !q.trim()) { setState((s) => ({ ...s, hits: [] })); return; }
10280
+ try {
10281
+ const { data } = await readJson(await apiFetch(`/api/dsh-mneme/choro/search?repo=${encodeURIComponent(repo.id)}&q=${encodeURIComponent(q)}`));
10282
+ setState((s) => ({ ...s, hits: data.hits ?? [] }));
10283
+ } catch { setState((s) => ({ ...s, hits: [] })); }
10284
+ }, [state.repo]);
10285
+
10286
+ if (state.phase === "loading") return h("div", { className: "mneme-panel mneme-wikiempty" }, t("wiki.state.loading"));
10287
+ if (state.phase === "disabled") return h("div", { className: "mneme-panel mneme-wikiempty" }, t("wiki.empty.disabled"));
10288
+ if (state.phase === "unavailable") return h("div", { className: "mneme-panel mneme-wikiempty" }, t("wiki.state.unavailable"));
10289
+ if (state.phase === "error") return h("div", { className: "mneme-panel mneme-wikiempty" }, `${t("wiki.state.error")}: ${state.error}`);
10290
+ if (state.phase === "no-repo") {
10291
+ return h("div", { className: "mneme-panel mneme-wikiempty" },
10292
+ h("div", { className: "mneme-wikiemptytitle" }, t("wiki.panel.open")),
10293
+ h("p", null, t("wiki.empty.noRepo")));
10294
+ }
10295
+ if (state.phase === "pick-repo") {
10296
+ return h("div", { className: "mneme-panel mneme-wikiempty" },
10297
+ h("div", { className: "mneme-wikiemptytitle" }, t("wiki.empty.pickRepo")),
10298
+ state.repos.map((r) => h("button", {
10299
+ key: r.id, type: "button", className: "mneme-wikitreeitem",
10300
+ onClick: () => load(r.id)
10301
+ }, `${r.name}`)));
10302
+ }
10303
+ const tree = [];
10304
+ for (const mod of state.modules) {
10305
+ const depth = (mod.slug.match(/-/g) || []).length;
10306
+ tree.push(h("button", {
10307
+ key: mod.slug,
10308
+ type: "button",
10309
+ className: `mneme-wikitreeitem${state.selected === mod.slug ? " mneme-active" : ""}`,
10310
+ style: { paddingLeft: `${8 + Math.min(depth, 4) * 14}px` },
10311
+ title: mod.title,
10312
+ onClick: () => openPage(mod.slug)
10313
+ }, mod.slug));
10314
+ }
10315
+ const pageBody = state.page
10316
+ ? (state.page.error
10317
+ ? h("div", { className: "mneme-wikiempty" }, state.page.error === "page-missing" ? t("wiki.state.pageMissing") : state.page.error)
10318
+ : renderWikiMarkdown(state.page.content))
10319
+ : h("div", { className: "mneme-wikiempty" }, t("wiki.state.pickPage"));
10320
+ return h("div", { className: "mneme-panel" },
10321
+ h("div", { className: "mneme-wikibar" },
10322
+ h("span", { className: "mneme-wikirepo" }, state.repo ? `${state.repo.name}` : ""),
10323
+ h("input", {
10324
+ className: "mneme-wikisearch",
10325
+ placeholder: t("wiki.search.placeholder"),
10326
+ value: state.q,
10327
+ onChange: (e) => runSearch(e.target.value),
10328
+ "aria-label": t("wiki.search.placeholder")
10329
+ })),
10330
+ h("div", { className: "mneme-wikibody" },
10331
+ h("nav", { className: "mneme-wikitree", "aria-label": t("wiki.tree.title") }, tree),
10332
+ h("div", { className: "mneme-wikipage" },
10333
+ state.hits.length > 0 && h("div", { className: "mneme-wikihits" },
10334
+ state.hits.map((hit, idx) => h("button", {
10335
+ key: `${hit.slug}-${hit.line}-${idx}`,
10336
+ type: "button",
10337
+ className: "mneme-wikihit",
10338
+ onClick: () => { setState((s) => ({ ...s, hits: [] })); openPage(hit.slug); }
10339
+ }, `${hit.slug} · ${hit.section} · L${hit.line}: ${hit.excerpt}`)),
10340
+ h("button", { type: "button", className: "mneme-wikihitsclose", onClick: () => setState((s) => ({ ...s, hits: [] })) }, "×")),
10341
+ pageBody)));
10342
+ }
10343
+
10344
+ // 两套宿主形态,二选一,靠宿主是否声明了 sidebar.panellist 判定:
10345
+ // · 宿主内整页面板(首选):sidebar.panellist 出图标、main 出中列整页。
10346
+ // 图标长在宿主自己的面板列表里(PanelRow 自己处理点击与 aria-current),
10347
+ // 插件只管渲染一颗 glyph,不自己造按钮、不自己调 selectPanel。
10348
+ // · 老宿主回落:没有这两个座位时,footer.action 出按钮 + 居中 sheet,
10349
+ // 并保留 SidebarTopEntry 的 DOM portal(尽量把入口放到面板列表位置)。
10350
+ // 两套都挂同一个模块级 overlay 状态,但不会同时装:panellist 一旦出现就
10351
+ // 先把回落拆掉,否则侧栏会冒出两个记忆入口。
10352
+ // 判定 + 装载都收在两个闭包里:谁都可以问「宿主有没有这套座位」,
10353
+ // 也都可以在条件变化后重装回落——但同一时刻至多一份注册在册。
10354
+ const hostHasPanelSeat = () => typeof ctx.slots.spec === "function"
10355
+ && ctx.slots.spec("sidebar.panellist") !== undefined;
10356
+ let footerSeat = null;
10357
+ const dropFooterSeat = () => {
10358
+ if (footerSeat) { footerSeat(); footerSeat = null; }
10359
+ };
10360
+ const mountFooterSeat = () => {
10361
+ if (footerSeat || hostHasPanelSeat()) return;
10362
+ footerSeat = ctx.slots.register({
9366
10363
  name: "sidebar.footer.action",
9367
10364
  id: "dsh-mneme",
9368
10365
  order: 0,
@@ -9375,6 +10372,56 @@ window.__ModuleLoader__.load({
9375
10372
  }),
9376
10373
  h(MemoryOverlay, { t })
9377
10374
  ));
10375
+ };
10376
+
10377
+ ctx.slots.inject("sidebar.panellist", () => {
10378
+ dropFooterSeat();
10379
+ const disposeGlyph = ctx.slots.register({
10380
+ name: "sidebar.panellist",
10381
+ id: "dsh-mneme",
10382
+ // 排在宿主自己的面板之后(baleen 占 45):记忆是高频入口,但排在
10383
+ // 已有面板之后不抢位。order 只影响列表排序,不影响可发现性。
10384
+ order: 60,
10385
+ label: () => t("memory.panel.open")
10386
+ }, (props) => h(SidebarPanelGlyph, { size: props && props.size }));
10387
+ // main 的 key 必须与上面侧栏的 id 逐字相同:宿主点面板行时调
10388
+ // layout.selectPanel(id),它按 options.key 在 main 槽里找 entry,
10389
+ // 对不上直接抛 "main panel ... is not registered"。
10390
+ const disposeMain = ctx.slots.register({
10391
+ name: "main",
10392
+ key: "dsh-mneme"
10393
+ }, () => h(MnemeMainPanel, { t }));
10394
+ // chorography 第二个座位(M1):成对规则同上——id 与 key 逐字同为
10395
+ // "dsh-mneme-wiki"。座位无条件注册(P11),内容层自己渲染空态;
10396
+ // D2 拆双 Cordis 条目后这里自然变成可独立禁用的形态。
10397
+ const disposeWikiGlyph = ctx.slots.register({
10398
+ name: "sidebar.panellist",
10399
+ id: "dsh-mneme-wiki",
10400
+ order: 61,
10401
+ label: () => t("wiki.panel.open")
10402
+ }, (props) => h(WikiGlyph, { size: props && props.size }));
10403
+ const disposeWikiMain = ctx.slots.register({
10404
+ name: "main",
10405
+ key: "dsh-mneme-wiki"
10406
+ }, () => h(WikiMainPanel, { t }));
10407
+ return () => {
10408
+ disposeGlyph();
10409
+ disposeMain();
10410
+ disposeWikiGlyph();
10411
+ disposeWikiMain();
10412
+ // 座位被撤(宿主折叠该槽 / 换宿主形态)时把回落装回去:两条路径都
10413
+ // 只靠各自的 inject 触发,谁不动谁就永远停在「没有入口」的状态。
10414
+ mountFooterSeat();
10415
+ };
10416
+ });
10417
+
10418
+ // 老宿主回落:footer.action 这一格既能当锚点(SidebarTopEntry 靠它挂
10419
+ // portal)也能当兜底按钮,新宿主上则整格不注册——否则侧栏两颗记忆入口。
10420
+ ctx.slots.inject("sidebar.footer.action", () => {
10421
+ mountFooterSeat();
10422
+ return () => {
10423
+ dropFooterSeat();
10424
+ };
9378
10425
  });
9379
10426
 
9380
10427
  // --- better-sidebar 生态 tab(可选软依赖)---