@modusensus/dsh-mneme 0.14.0-beta.1 → 0.14.0-beta.2

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 (543) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +830 -830
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -262
  6. package/bin/dsh-mneme-mcp.mjs +510 -510
  7. package/bin/dsh-mneme-serve.mjs +151 -151
  8. package/cordis.patch.yml +21 -21
  9. package/docs/CONFIGURATION.md +407 -404
  10. package/docs/CONTINUITY.md +139 -139
  11. package/docs/DAEMON.md +57 -57
  12. package/docs/DIRECTORIES.md +114 -114
  13. package/docs/DISTILL.md +397 -371
  14. package/docs/DSH-GO.md +97 -97
  15. package/docs/ENTITIES.md +247 -247
  16. package/docs/FORGET.md +77 -77
  17. package/docs/KB.md +110 -110
  18. package/docs/LOCAL_MODEL.md +222 -222
  19. package/docs/SEMANTIC.md +256 -256
  20. package/docs/SHARE.md +552 -552
  21. package/docs/SLEEP.md +202 -202
  22. package/docs/STORAGE.md +122 -122
  23. package/docs/WORKFLOWS.md +366 -366
  24. package/lib/api-standalone.js +1007 -1007
  25. package/lib/api.js +3600 -3600
  26. package/lib/attachment-text.js +122 -122
  27. package/lib/bootstrap-http.js +105 -105
  28. package/lib/bootstrap.js +215 -215
  29. package/lib/candidates.js +454 -454
  30. package/lib/capabilities.js +819 -819
  31. package/lib/choro-command.js +111 -111
  32. package/lib/client.js +14420 -14322
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1344
  36. package/lib/content-hash.js +54 -54
  37. package/lib/continuity.js +258 -258
  38. package/lib/date-hint.js +32 -0
  39. package/lib/diary/auto.js +190 -190
  40. package/lib/diary/compose.js +440 -440
  41. package/lib/diary/dir.js +59 -59
  42. package/lib/diary/export.js +97 -97
  43. package/lib/diary/index.js +26 -26
  44. package/lib/diary/library.js +189 -189
  45. package/lib/diary/qq-inbox.js +142 -142
  46. package/lib/diary/rollover.js +61 -61
  47. package/lib/diary/session-provider.js +122 -122
  48. package/lib/document-index.js +94 -94
  49. package/lib/document.js +322 -322
  50. package/lib/dream/clustering.js +123 -123
  51. package/lib/dream/decisions.js +945 -945
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -2027
  54. package/lib/dream/triage.js +139 -139
  55. package/lib/dream.js +1879 -1879
  56. package/lib/dsh-go/host.js +162 -162
  57. package/lib/dsh-go/panel.js +2091 -2091
  58. package/lib/dsh-go/text.js +342 -342
  59. package/lib/dsh-go/views/common.js +68 -68
  60. package/lib/dsh-go/views/entity.js +48 -48
  61. package/lib/dsh-go/views/memory.js +198 -198
  62. package/lib/dsh-go/views/root.js +30 -30
  63. package/lib/dsh-go/views/settings.js +398 -398
  64. package/lib/dsh-go/views/status.js +51 -51
  65. package/lib/embedding.js +342 -342
  66. package/lib/entities/extractor.js +285 -285
  67. package/lib/fence.js +24 -24
  68. package/lib/graph/anchoring.js +184 -184
  69. package/lib/guide.js +74 -74
  70. package/lib/heat.js +144 -144
  71. package/lib/hot-memory.js +119 -119
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -852
  74. package/lib/inject.js +656 -651
  75. package/lib/kb/index.js +309 -309
  76. package/lib/kb/routes.js +219 -219
  77. package/lib/kb/tools.js +148 -148
  78. package/lib/lang.js +607 -595
  79. package/lib/local-embedder.js +358 -358
  80. package/lib/maintenance.js +162 -162
  81. package/lib/memory-source.js +9 -9
  82. package/lib/mirror.js +330 -330
  83. package/lib/organize.js +456 -456
  84. package/lib/peak-hours.js +172 -172
  85. package/lib/pipeline-activities.js +178 -178
  86. package/lib/pipeline-attribution.js +165 -165
  87. package/lib/pipeline-command.js +279 -279
  88. package/lib/pipeline-confirm.js +142 -142
  89. package/lib/pipeline-exec.js +174 -174
  90. package/lib/pipeline-nudge-auto.js +129 -129
  91. package/lib/pipeline-nudge.js +1623 -1623
  92. package/lib/pipeline-observations.js +181 -181
  93. package/lib/pipeline-observe.js +133 -133
  94. package/lib/pipeline-runner.js +361 -361
  95. package/lib/pipeline-runs.js +442 -442
  96. package/lib/pipeline.js +716 -716
  97. package/lib/placement.js +13 -13
  98. package/lib/pre-inject-gate.js +354 -354
  99. package/lib/profile-extract.js +164 -164
  100. package/lib/profile-facts.js +356 -356
  101. package/lib/provenance-http.js +251 -251
  102. package/lib/quality-filter.js +162 -162
  103. package/lib/recall-stats.js +190 -190
  104. package/lib/reclaim-http.js +150 -150
  105. package/lib/reranker.js +269 -269
  106. package/lib/runtime/adopt.js +176 -176
  107. package/lib/runtime/closure.js +172 -172
  108. package/lib/runtime/download.js +247 -247
  109. package/lib/runtime/layout.js +265 -265
  110. package/lib/runtime/loader.js +252 -252
  111. package/lib/runtime/model-fetch.js +361 -361
  112. package/lib/runtime/provision.js +237 -237
  113. package/lib/runtime/tarball.js +130 -130
  114. package/lib/runtime/verify.js +223 -223
  115. package/lib/safe-slice.js +18 -18
  116. package/lib/scope-state.js +277 -277
  117. package/lib/scope.js +187 -187
  118. package/lib/search/adaptive.js +60 -60
  119. package/lib/search/bm25.js +96 -96
  120. package/lib/semantic.js +572 -572
  121. package/lib/sensitive-scan.js +209 -209
  122. package/lib/serve.js +228 -228
  123. package/lib/service.js +3268 -3268
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -817
  127. package/lib/share-http.js +281 -281
  128. package/lib/share-import.js +234 -234
  129. package/lib/share.js +335 -335
  130. package/lib/store.js +5895 -5895
  131. package/lib/summarize.js +1388 -1343
  132. package/lib/tools.js +1370 -1356
  133. package/lib/user-settings.js +230 -230
  134. package/lib/vector-index.js +180 -180
  135. package/lib/version-check.js +122 -122
  136. package/lib/wiki/claims.js +267 -267
  137. package/lib/wiki/cli-hint.js +90 -90
  138. package/lib/wiki/inject.js +164 -164
  139. package/lib/wiki/partition.js +200 -200
  140. package/lib/wiki/plan.js +214 -214
  141. package/lib/wiki/queries.js +640 -640
  142. package/lib/wiki/render.js +261 -261
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -500
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -609
  147. package/lib/write-admission.js +404 -404
  148. package/locale/en.json +6 -6
  149. package/locale/zh.json +6 -6
  150. package/package.json +1 -1
  151. package/runtime-manifest.json +526 -526
  152. package/scripts/benchmark-embed.js +201 -201
  153. package/scripts/benchmark-recall.js +249 -249
  154. package/scripts/benchmark-rerank.js +166 -166
  155. package/scripts/build-runtime-manifest.mjs +224 -224
  156. package/scripts/changelog-prep.mjs +91 -91
  157. package/scripts/check-sync.js +44 -44
  158. package/scripts/e2e-dsh.js +220 -220
  159. package/scripts/mneme-runtime.mjs +277 -277
  160. package/scripts/publish-gate.mjs +197 -197
  161. package/scripts/release-docs.mjs +210 -210
  162. package/scripts/release-notes.mjs +164 -164
  163. package/scripts/stress-dsh.js +255 -255
  164. package/scripts/sync-lib.js +47 -47
  165. package/scripts/sync-test-badge.mjs +69 -69
  166. package/scripts/test-count-sync.mjs +48 -48
  167. package/src/api-standalone.js +1007 -1007
  168. package/src/api.js +3600 -3600
  169. package/src/attachment-text.js +122 -122
  170. package/src/bootstrap-http.js +105 -105
  171. package/src/bootstrap.js +215 -215
  172. package/src/candidates.js +454 -454
  173. package/src/capabilities.js +819 -819
  174. package/src/choro-command.js +111 -111
  175. package/src/commands.js +95 -95
  176. package/src/config-keys.js +144 -144
  177. package/src/config.js +1361 -1344
  178. package/src/content-hash.js +54 -54
  179. package/src/continuity.js +258 -258
  180. package/src/date-hint.js +32 -0
  181. package/src/diary/auto.js +190 -190
  182. package/src/diary/compose.js +440 -440
  183. package/src/diary/dir.js +59 -59
  184. package/src/diary/export.js +97 -97
  185. package/src/diary/index.js +26 -26
  186. package/src/diary/library.js +189 -189
  187. package/src/diary/qq-inbox.js +142 -142
  188. package/src/diary/rollover.js +61 -61
  189. package/src/diary/session-provider.js +122 -122
  190. package/src/document-index.js +94 -94
  191. package/src/document.js +322 -322
  192. package/src/dream/clustering.js +123 -123
  193. package/src/dream/decisions.js +945 -945
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -2027
  196. package/src/dream/triage.js +139 -139
  197. package/src/dream.js +1879 -1879
  198. package/src/dsh-go/host.js +162 -162
  199. package/src/dsh-go/panel.js +2091 -2091
  200. package/src/dsh-go/text.js +342 -342
  201. package/src/dsh-go/views/common.js +68 -68
  202. package/src/dsh-go/views/entity.js +48 -48
  203. package/src/dsh-go/views/memory.js +198 -198
  204. package/src/dsh-go/views/root.js +30 -30
  205. package/src/dsh-go/views/settings.js +398 -398
  206. package/src/dsh-go/views/status.js +51 -51
  207. package/src/embedding.js +342 -342
  208. package/src/entities/extractor.js +285 -285
  209. package/src/fence.js +24 -24
  210. package/src/graph/anchoring.js +184 -184
  211. package/src/guide.js +74 -74
  212. package/src/heat.js +144 -144
  213. package/src/hot-memory.js +119 -119
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -852
  216. package/src/inject.js +656 -651
  217. package/src/kb/index.js +309 -309
  218. package/src/kb/routes.js +219 -219
  219. package/src/kb/tools.js +148 -148
  220. package/src/lang.js +607 -595
  221. package/src/local-embedder.js +358 -358
  222. package/src/maintenance.js +162 -162
  223. package/src/memory-source.js +9 -9
  224. package/src/mirror.js +330 -330
  225. package/src/organize.js +456 -456
  226. package/src/peak-hours.js +172 -172
  227. package/src/pipeline-activities.js +178 -178
  228. package/src/pipeline-attribution.js +165 -165
  229. package/src/pipeline-command.js +279 -279
  230. package/src/pipeline-confirm.js +142 -142
  231. package/src/pipeline-exec.js +174 -174
  232. package/src/pipeline-nudge-auto.js +129 -129
  233. package/src/pipeline-nudge.js +1623 -1623
  234. package/src/pipeline-observations.js +181 -181
  235. package/src/pipeline-observe.js +133 -133
  236. package/src/pipeline-runner.js +361 -361
  237. package/src/pipeline-runs.js +442 -442
  238. package/src/pipeline.js +716 -716
  239. package/src/placement.js +13 -13
  240. package/src/pre-inject-gate.js +354 -354
  241. package/src/profile-extract.js +164 -164
  242. package/src/profile-facts.js +356 -356
  243. package/src/provenance-http.js +251 -251
  244. package/src/quality-filter.js +162 -162
  245. package/src/recall-stats.js +190 -190
  246. package/src/reclaim-http.js +150 -150
  247. package/src/reranker.js +269 -269
  248. package/src/runtime/adopt.js +176 -176
  249. package/src/runtime/closure.js +172 -172
  250. package/src/runtime/download.js +247 -247
  251. package/src/runtime/layout.js +265 -265
  252. package/src/runtime/loader.js +252 -252
  253. package/src/runtime/model-fetch.js +361 -361
  254. package/src/runtime/provision.js +237 -237
  255. package/src/runtime/tarball.js +130 -130
  256. package/src/runtime/verify.js +223 -223
  257. package/src/safe-slice.js +18 -18
  258. package/src/scope-state.js +277 -277
  259. package/src/scope.js +187 -187
  260. package/src/search/adaptive.js +60 -60
  261. package/src/search/bm25.js +96 -96
  262. package/src/semantic.js +572 -572
  263. package/src/sensitive-scan.js +209 -209
  264. package/src/serve.js +228 -228
  265. package/src/service.js +3268 -3268
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -817
  269. package/src/share-http.js +281 -281
  270. package/src/share-import.js +234 -234
  271. package/src/share.js +335 -335
  272. package/src/store.js +5895 -5895
  273. package/src/summarize.js +1388 -1343
  274. package/src/tools.js +1370 -1356
  275. package/src/user-settings.js +230 -230
  276. package/src/vector-index.js +180 -180
  277. package/src/version-check.js +122 -122
  278. package/src/wiki/claims.js +267 -267
  279. package/src/wiki/cli-hint.js +90 -90
  280. package/src/wiki/inject.js +164 -164
  281. package/src/wiki/partition.js +200 -200
  282. package/src/wiki/plan.js +214 -214
  283. package/src/wiki/queries.js +640 -640
  284. package/src/wiki/render.js +261 -261
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -500
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -609
  289. package/src/write-admission.js +404 -404
  290. package/test/adaptive-inject-budget.test.js +85 -85
  291. package/test/api-hardening.test.js +173 -173
  292. package/test/api-nudge-route.test.js +259 -259
  293. package/test/api-routes-gaps.test.js +1092 -1092
  294. package/test/api.test.js +3007 -2998
  295. package/test/attachment-text.test.js +139 -139
  296. package/test/audit.test.js +448 -448
  297. package/test/baseline-persist.test.js +248 -248
  298. package/test/benchmark.test.js +94 -94
  299. package/test/bm25-corpus-cache.test.js +145 -145
  300. package/test/bootstrap.test.js +228 -228
  301. package/test/candidates.test.js +345 -345
  302. package/test/capabilities.test.js +577 -577
  303. package/test/capacity-warning.test.js +262 -262
  304. package/test/changelog-prep.test.js +125 -125
  305. package/test/choro-command.test.js +218 -218
  306. package/test/cli-enums.test.js +37 -37
  307. package/test/cli-flag-value.test.js +86 -86
  308. package/test/cli-receive.test.js +390 -390
  309. package/test/cli-share.test.js +437 -437
  310. package/test/client-share.test.js +225 -225
  311. package/test/client.test.js +4595 -4425
  312. package/test/clustering.test.js +100 -100
  313. package/test/commands.test.js +106 -106
  314. package/test/config-keys-routes.test.js +233 -233
  315. package/test/config.test.js +153 -153
  316. package/test/conflict-freeze.test.js +344 -344
  317. package/test/conflict-ledger-enforce.test.js +452 -452
  318. package/test/conflict-ledger.test.js +242 -242
  319. package/test/conflict-reviewed.test.js +79 -79
  320. package/test/content-hash.test.js +204 -204
  321. package/test/continuity.test.js +452 -452
  322. package/test/contrast.test.js +432 -432
  323. package/test/date-hint.test.js +156 -0
  324. package/test/dedupe-candidate-window.test.js +180 -180
  325. package/test/dependency-overrides.test.js +78 -78
  326. package/test/diary-library.test.js +187 -187
  327. package/test/diary.test.js +576 -576
  328. package/test/differentiate-feedback.test.js +248 -248
  329. package/test/distill-tx-embed-gap.test.js +110 -110
  330. package/test/docs-config-keys.test.js +65 -65
  331. package/test/document-dir.test.js +233 -233
  332. package/test/document.test.js +703 -703
  333. package/test/dream-archive-guardrail.test.js +116 -116
  334. package/test/dream-baseline-clamp.test.js +174 -174
  335. package/test/dream-candidate-hybrid.test.js +281 -281
  336. package/test/dream-coverage-degrade.test.js +86 -86
  337. package/test/dream-decision-evidence.test.js +314 -314
  338. package/test/dream-failure-backoff.test.js +173 -173
  339. package/test/dream-fence.test.js +207 -207
  340. package/test/dream-maxtokens-fallback.test.js +132 -132
  341. package/test/dream-merge-guardrail.test.js +219 -219
  342. package/test/dream-peak-hours.test.js +194 -194
  343. package/test/dream.test.js +1528 -1528
  344. package/test/dsh-go-host.test.js +134 -134
  345. package/test/dsh-go-panel-actions.test.js +854 -854
  346. package/test/dsh-go-panel.test.js +251 -251
  347. package/test/embed-ready-poll.test.js +94 -94
  348. package/test/embed-text-reasons.test.js +112 -112
  349. package/test/embedding-cache-f32.test.js +276 -276
  350. package/test/embedding-ready.test.js +226 -226
  351. package/test/entities.test.js +711 -711
  352. package/test/entity-recall.test.js +179 -179
  353. package/test/epistemic.test.js +325 -325
  354. package/test/failure-query.test.js +147 -147
  355. package/test/failure-retention.test.js +105 -105
  356. package/test/fnew-0112.test.js +311 -311
  357. package/test/fnew-03.test.js +422 -422
  358. package/test/forget-corrections.test.js +70 -70
  359. package/test/graph-anchoring.test.js +184 -184
  360. package/test/graph-api.test.js +333 -333
  361. package/test/graph-boosted-relations.test.js +189 -189
  362. package/test/graph-inject-budget.test.js +103 -103
  363. package/test/graph-multipath.test.js +269 -269
  364. package/test/graph-passive-confirm.test.js +91 -91
  365. package/test/graph-weight-evolution.test.js +104 -104
  366. package/test/heat.test.js +175 -175
  367. package/test/helpers/dream-mock.js +86 -86
  368. package/test/helpers/peer-worker.mjs +25 -25
  369. package/test/helpers/runtime-source.js +60 -60
  370. package/test/helpers/tar-builder.js +80 -80
  371. package/test/helpers/write-admission-samples.js +312 -312
  372. package/test/hot-memory.test.js +259 -259
  373. package/test/image-description.test.js +271 -0
  374. package/test/image-memory.test.js +170 -170
  375. package/test/inject-content-cap.test.js +91 -91
  376. package/test/inject-first-round-bm25.test.js +89 -89
  377. package/test/inject-heat.test.js +77 -77
  378. package/test/inject-parent-gate.test.js +134 -109
  379. package/test/inject-pin-explicit.test.js +300 -300
  380. package/test/inject-pin-vector-crowd.test.js +225 -225
  381. package/test/inject-pools.test.js +195 -195
  382. package/test/inject-recall.test.js +172 -172
  383. package/test/inject-rotation.test.js +161 -161
  384. package/test/inject.test.js +553 -553
  385. package/test/issue-617-step1-query-and-fusion.test.js +361 -361
  386. package/test/kb-api-wiring.test.js +128 -128
  387. package/test/kb-collections.test.js +272 -272
  388. package/test/kb-routes.test.js +379 -379
  389. package/test/lang.test.js +98 -98
  390. package/test/lastrunat-persist.test.js +101 -101
  391. package/test/lib-smoke.test.js +116 -116
  392. package/test/llm-audit.test.js +815 -815
  393. package/test/llm-message-source.test.js +207 -207
  394. package/test/local-embedder.test.js +292 -292
  395. package/test/maintenance.test.js +501 -501
  396. package/test/mcp-stdio.test.js +385 -385
  397. package/test/mint-source.test.js +311 -311
  398. package/test/mirror-dirty.test.js +424 -424
  399. package/test/mirror-edit-digest.test.js +187 -187
  400. package/test/mirror-generation.test.js +499 -499
  401. package/test/mirror-types.test.js +208 -208
  402. package/test/mirror.test.js +249 -249
  403. package/test/narrative-bars.test.js +162 -162
  404. package/test/organize-tool.test.js +232 -232
  405. package/test/organize.test.js +418 -418
  406. package/test/panel-chart-scale.test.js +90 -90
  407. package/test/panel-tokens.test.js +245 -245
  408. package/test/peak-hours-observability.test.js +70 -70
  409. package/test/peer-blockers.test.js +193 -193
  410. package/test/persona-addressing.test.js +83 -83
  411. package/test/persona-api.test.js +172 -172
  412. package/test/persona-by-session.test.js +568 -568
  413. package/test/persona-flag.test.js +36 -36
  414. package/test/persona-inject.test.js +145 -145
  415. package/test/persona-settings.test.js +198 -198
  416. package/test/pipeline-activities.test.js +155 -155
  417. package/test/pipeline-attribution.test.js +175 -175
  418. package/test/pipeline-command.test.js +243 -243
  419. package/test/pipeline-confirm.test.js +148 -148
  420. package/test/pipeline-exec.test.js +172 -172
  421. package/test/pipeline-nudge-auto.test.js +248 -248
  422. package/test/pipeline-nudge.test.js +1278 -1278
  423. package/test/pipeline-observations.test.js +157 -157
  424. package/test/pipeline-observe.test.js +127 -127
  425. package/test/pipeline-r1r2.test.js +278 -278
  426. package/test/pipeline-runner.test.js +468 -468
  427. package/test/pipeline-runs.test.js +767 -767
  428. package/test/pipeline.test.js +636 -636
  429. package/test/placement.test.js +36 -36
  430. package/test/policy-epoch.test.js +259 -259
  431. package/test/pre-inject-gate.test.js +444 -444
  432. package/test/profile-extract.test.js +269 -269
  433. package/test/profile-facts.test.js +492 -492
  434. package/test/prompt-stance.test.js +128 -128
  435. package/test/provenance-http.test.js +367 -367
  436. package/test/publish-gate.test.js +272 -272
  437. package/test/quality-backfill.test.js +290 -290
  438. package/test/quality-filter.test.js +295 -295
  439. package/test/readme-claims.test.js +113 -113
  440. package/test/reasoning-effort.test.js +752 -752
  441. package/test/recall-anti-update.test.js +82 -82
  442. package/test/recall-evals.test.js +253 -253
  443. package/test/recall-layer.test.js +326 -326
  444. package/test/recall-retention.test.js +128 -128
  445. package/test/recall-stats.test.js +274 -274
  446. package/test/receipt-chain.test.js +451 -451
  447. package/test/reclaim-http.test.js +209 -209
  448. package/test/reflection-update-gate.test.js +129 -129
  449. package/test/reflection.test.js +226 -226
  450. package/test/reindex-backfill.test.js +475 -475
  451. package/test/reindex-scheduler.test.js +369 -369
  452. package/test/release-docs.test.js +156 -156
  453. package/test/release-notes.test.js +265 -265
  454. package/test/release-workflow.test.js +93 -93
  455. package/test/reranker-single-logit.test.js +73 -73
  456. package/test/reranker.test.js +197 -197
  457. package/test/resident-status.test.js +83 -83
  458. package/test/route-policy.test.js +113 -113
  459. package/test/runtime-adopt.test.js +206 -206
  460. package/test/runtime-cli.test.js +89 -89
  461. package/test/runtime-closure.test.js +193 -193
  462. package/test/runtime-download.test.js +302 -302
  463. package/test/runtime-layout.test.js +163 -163
  464. package/test/runtime-loader.test.js +255 -255
  465. package/test/runtime-manifest.test.js +159 -159
  466. package/test/runtime-model-fetch.test.js +348 -348
  467. package/test/runtime-provision.test.js +101 -101
  468. package/test/runtime-tarball.test.js +81 -81
  469. package/test/runtime-verify.test.js +319 -319
  470. package/test/scope-candidates.test.js +491 -491
  471. package/test/scope-distill-stamp.test.js +134 -134
  472. package/test/scope-explicit.test.js +452 -452
  473. package/test/scope-inject-soft.test.js +106 -106
  474. package/test/scope-optional-service.test.js +110 -110
  475. package/test/scope-retrieval.test.js +197 -197
  476. package/test/scope-state.test.js +610 -610
  477. package/test/scope-storage.test.js +255 -255
  478. package/test/scope-strict.test.js +367 -367
  479. package/test/search-fusion.test.js +90 -90
  480. package/test/security-doc.test.js +188 -188
  481. package/test/semantic.test.js +124 -124
  482. package/test/sensitive-scan.test.js +251 -251
  483. package/test/serve-bin.test.js +121 -121
  484. package/test/serve.test.js +258 -258
  485. package/test/service-search.test.js +174 -174
  486. package/test/service.test.js +410 -410
  487. package/test/session-gate.test.js +264 -264
  488. package/test/session-usage.test.js +317 -317
  489. package/test/settings.test.js +273 -273
  490. package/test/share-foreign-rows.test.js +144 -144
  491. package/test/share-http.test.js +144 -144
  492. package/test/share-import.test.js +577 -577
  493. package/test/share-imported-guard.test.js +311 -311
  494. package/test/share-imported-identity.test.js +111 -111
  495. package/test/share-pack.test.js +511 -511
  496. package/test/share-receive-http.test.js +756 -756
  497. package/test/share-routes.test.js +337 -337
  498. package/test/share-store.test.js +133 -133
  499. package/test/share-validate.test.js +101 -101
  500. package/test/skip-invalid-restore.test.js +262 -262
  501. package/test/sleep-action-set.test.js +441 -441
  502. package/test/sleep-catchup.test.js +315 -315
  503. package/test/sleep-failure-backoff.test.js +156 -156
  504. package/test/sleep-heat.test.js +174 -174
  505. package/test/sleep-scheduler-rearm.test.js +89 -89
  506. package/test/sleep.test.js +1455 -1455
  507. package/test/standalone-api.test.js +1344 -1344
  508. package/test/store-vector-cache.test.js +98 -98
  509. package/test/store-write-path-columns.test.js +191 -191
  510. package/test/store.test.js +744 -744
  511. package/test/stress.test.js +209 -209
  512. package/test/summarize-occurred-at.test.js +127 -127
  513. package/test/summarize-parse-diagnostics.test.js +75 -75
  514. package/test/summarize-reasoning-effort.test.js +189 -189
  515. package/test/summarize.test.js +2251 -2251
  516. package/test/supersede-state-tracking.test.js +394 -394
  517. package/test/test-count-sync.test.js +71 -71
  518. package/test/tool-state-ignore.test.js +65 -65
  519. package/test/tools-dto-schema-matrix.test.js +398 -398
  520. package/test/tools-memory-get-schema.test.js +65 -65
  521. package/test/tools.test.js +585 -585
  522. package/test/updated-at-semantics.test.js +113 -113
  523. package/test/user-settings-preview.test.js +127 -127
  524. package/test/user-settings-segments.test.js +527 -527
  525. package/test/vector-index.test.js +334 -334
  526. package/test/version-check.test.js +152 -152
  527. package/test/webServer-optional.test.js +82 -82
  528. package/test/wiki-api.test.js +734 -734
  529. package/test/wiki-claims.test.js +194 -194
  530. package/test/wiki-cli-hint.test.js +118 -118
  531. package/test/wiki-inject.test.js +265 -265
  532. package/test/wiki-partition.test.js +112 -112
  533. package/test/wiki-plan.test.js +80 -80
  534. package/test/wiki-provenance.test.js +202 -202
  535. package/test/wiki-queries.test.js +398 -398
  536. package/test/wiki-scan.test.js +208 -208
  537. package/test/wiki-store.test.js +190 -190
  538. package/test/wiki-t0.test.js +456 -456
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -460
  541. package/test/wiki-tools.test.js +176 -176
  542. package/test/workflow-guards.test.js +155 -155
  543. package/test/write-admission.test.js +668 -668
@@ -1,2091 +1,2091 @@
1
- // local-mneme-panel — a dsh-go native panel over the installed dsh-mneme store.
2
- //
3
- // The panel never talks HTTP: src/dsh-go/host.js dispatches mneme's own registered
4
- // route handlers inside this engine process. Reads are mneme's own GET routes;
5
- // writes go through mneme's own routes (create/update/delete/features/profile/
6
- // rules/mode/conflicts/commands/vector/external/reclaim), so its apiToken policy
7
- // still applies if one is ever set.
8
- import { createHostCall } from "./host.js";
9
- import { buildView } from "./views/root.js";
10
- import {
11
- FLAG_LABELS,
12
- RANGE_DAYS,
13
- TYPE_LABELS,
14
- clip,
15
- errorText,
16
- flagAction,
17
- fmtDay,
18
- fmtMonth,
19
- fmtTime,
20
- joinTags,
21
- scopeText,
22
- splitTags,
23
- entityIcon,
24
- entityTypeLabel,
25
- typeIcon,
26
- typeLabel,
27
- typeTone,
28
- } from "./text.js";
29
-
30
- const PAGE_SIZE = 20;
31
- const cache = new Map();
32
- // Live panel feeds, one emit per open panel/locale. dsh-go feeds are push-based:
33
- // data() runs once and later state changes must be pushed through these emits
34
- // (a `refresh` effect only re-renders the view; it never re-runs data()).
35
- const emitters = new Set();
36
- // 每个面板 scope 打开过哪条记忆。删除确认(confirm 节点)不携带任何参数,
37
- // 只能靠状态决定删谁;状态又是所有面板共用的,所以按 scope 记一份,删除前
38
- // 与本面板打开的那条核对,不匹配就拒绝(评审 #5)。
39
- const openedBy = new Map();
40
- // 冲突页的裁决同样改数据,而 state.conflictId 是模块级共享的:两个面板
41
- // 各看一条冲突时,后开的会盖掉前一个的 id。按面板 scope 记一份再核对
42
- // (评审 #3 的 conflict-win 部分)。
43
- const conflictBy = new Map();
44
- // 「Agent 眼中的你」的编辑态 state.factEdit 同样是共享的;按面板 scope 记
45
- // 一份打开的事实 id,保存与归档前核对(评审 #3 的 facts 部分)。
46
- const factBy = new Map();
47
- // 上一次推给 App 的完整数据:未重新读取的块从这里并回去,保证每份 data 都
48
- // 带着全部绑定键(见 load 的注释)。
49
- let lastPayload = null;
50
- const scopeKey = (scope) => [scope?.sessionId ?? "", scope?.turnId ?? "", scope?.callId ?? ""].join("|");
51
-
52
- const defaultState = () => ({
53
- page: "list", // list|detail|editor|entity|conflict|settings|profile
54
- flagGroup: "basic",
55
- extraRows: [], // 上游新增、面板还没中文名的布尔旗标
56
- factsCards: [],
57
- factsAgent: "",
58
- factsRows: [],
59
- factsItems: [],
60
- factEdit: null,
61
- factsRunText: "",
62
- choroRepos: [],
63
- choroRepo: "",
64
- choroRows: [],
65
- choroNote: "",
66
- choroPageText: "",
67
- settingsView: "settings", // settings|status
68
- sPanel: "menu", // menu|workbench|commands|vector|external|help|facts|choro
69
- profileTab: "profile",
70
- q: "",
71
- type: "all",
72
- range: "all",
73
- minImportance: 0,
74
- scope: "all",
75
- semantic: false,
76
- viewMode: "flat",
77
- heatSort: false,
78
- filtersOpen: false,
79
- offset: 0,
80
- size: PAGE_SIZE,
81
- current: null,
82
- form: {},
83
- // 编辑页打开时的那份快照:只有和它不同的字段才会写回 /update,
84
- // 并且用它挡住「别处已经改过这条记忆」(评审 #4)。
85
- editBase: null,
86
- entityTotal: -1,
87
- entityTypeTotal: 0,
88
- entityQuery: "",
89
- entityName: "",
90
- entityDepth: 1,
91
- entityRelationCount: 0,
92
- entityMemoryCount: 0,
93
- flags: {},
94
- flagOverrides: {},
95
- pendingRestart: [],
96
- pendingConflicts: 0,
97
- pendingIds: new Set(),
98
- rowsCount: 0,
99
- total: 0, // 当前筛选下的总条数,翻页时用它算页数
100
- monthGroups: [],
101
- detailEntities: [],
102
- detailEntityCount: 0,
103
- detailConflict: false,
104
- provenanceText: "",
105
- conflictCount: 0,
106
- conflictId: "",
107
- conflictPick: "",
108
- conflictA: null,
109
- conflictB: null,
110
- conflictReady: false,
111
- wbKv: [],
112
- wbDeposited: [],
113
- wbArchived: [],
114
- commands: [],
115
- cmdCount: 0,
116
- sCmdPick: "",
117
- sCmdName: "",
118
- sCmdDesc: "",
119
- sCmdIns: "",
120
- vector: { enabled: false, baseUrl: "", model: "" },
121
- external: { enabled: false, port: 8790, host: "127.0.0.1" },
122
- // 读成功才允许整体写回:读失败时上面的 vector/external 会落成默认值,
123
- // 此时 PUT 会把用户在 Web 面板里配好的地址/端口覆盖掉(评审 #2)。
124
- vectorLoaded: false,
125
- externalLoaded: false,
126
- // 实体搜索要重读的是「实体清单」而不是整页;load() 按这个标记补读
127
- // (评审 #4)。
128
- entityDirty: false,
129
- // 顶层页签由 App 本地切换,state.page 可能还停在 "list";设置项的动作
130
- // 改完数据后也要重读设置块,否则推到面板上的仍是旧值(评审 #6)。
131
- settingsDirty: false,
132
- versionNote: "",
133
- extractText: "",
134
- injectPreviewText: "",
135
- tokenCategories: [],
136
- tokenSeries: [],
137
- tokenKv: [],
138
- heatSeries: [],
139
- heatNote: "",
140
- reclaimText: "尚未检查。",
141
- reclaimConfirm: false,
142
- notice: "",
143
- noticeTone: "ok",
144
- err: "",
145
- });
146
-
147
- const state = defaultState();
148
-
149
- function resetNotice() {
150
- state.notice = "";
151
- state.err = "";
152
- }
153
-
154
- function notice(text, tone = "ok") {
155
- state.notice = clip(text, 160);
156
- state.noticeTone = tone;
157
- }
158
-
159
- /** Read the resolved action arguments/values the app sends with an invoke. */
160
- function valueOf(event, keys, fallback) {
161
- const value = event?.value;
162
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
163
- for (const key of keys) {
164
- if (value[key] !== undefined && value[key] !== null) return value[key];
165
- }
166
- }
167
- if (typeof value === "string" && value !== "") return value;
168
- if (typeof value === "boolean") return value;
169
- return fallback;
170
- }
171
-
172
- function num(value, fallback) {
173
- const n = Number(value);
174
- return Number.isFinite(n) ? n : fallback;
175
- }
176
-
177
- function rowOf(row) {
178
- const heat = typeof row.heat === "number" ? row.heat : Number.NaN;
179
- const pending = state.pendingIds.has(row.id);
180
- const marks = [];
181
- if (row.archived === true) marks.push("已归档");
182
- if (pending) marks.push("待确认");
183
- return {
184
- title: clip(row.title, 60),
185
- subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at ?? row.created_at)}${marks.length === 0 ? "" : ` · ${marks.join(" · ")}`}`,
186
- trailing: Number.isFinite(heat) ? `★${row.importance ?? 1} · 热度 ${heat.toFixed(2)}` : `★${row.importance ?? 1}`,
187
- ...(typeIcon(row.type) === undefined ? {} : { icon: typeIcon(row.type) }),
188
- tone: typeTone(row.type),
189
- action: { id: "open", args: { id: row.id } },
190
- };
191
- }
192
-
193
- /** Flat dotted flag keys -> nested object so `data.flags.a.b` bindings resolve. */
194
- function nestFlags(effective) {
195
- const out = {};
196
- for (const [key, value] of Object.entries(effective ?? {})) {
197
- const parts = key.split(".");
198
- let node = out;
199
- for (const part of parts.slice(0, -1)) {
200
- if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
201
- node = node[part];
202
- }
203
- // 原样透传:开关是布尔,枚举型旗标(chorographyInject 的 light/full/off)
204
- // 是字符串。以前这里一律写成 `value === true`,下拉永远只看到 true/false。
205
- node[parts[parts.length - 1]] = value;
206
- }
207
- return out;
208
- }
209
-
210
- const enc = (value) => encodeURIComponent(String(value ?? ""));
211
-
212
- /**
213
- * Mount the panel on a context that already provides the dsh-go `dshGo` service.
214
- * Called by src/index.js from `ctx.inject(['dshGo'], ...)`: without the dsh-go
215
- * bridge (desktop / headless hosts) this module never runs.
216
- */
217
- export function mountDshGoPanel(ctx, options = {}) {
218
- // options.apiToken 由 src/index.js 传入:路由注册器给 token / token-write
219
- // 路由包了鉴权,进程内调用不带这个头就会被自家 API 拦成 401(评审 #2)。
220
- const call = createHostCall(ctx, options);
221
-
222
- // ---------------------------------------------------------------- loaders
223
- function groupByMonth(rows, raw) {
224
- const buckets = new Map();
225
- rows.forEach((row, index) => {
226
- const label = fmtMonth(raw[index]?.updated_at ?? raw[index]?.created_at);
227
- if (!buckets.has(label)) buckets.set(label, []);
228
- buckets.get(label).push(row);
229
- });
230
- return [...buckets.entries()].map(([label, list]) => ({ label, rows: list }));
231
- }
232
-
233
- async function loadMemory() {
234
- const statsPromise = call("/recall-stats?window=30").catch(() => ({}));
235
- const conflictsPromise = call("/conflicts").catch(() => ({}));
236
- const dreamPromise = call("/dream-status").catch(() => ({}));
237
- const payload = {
238
- query: state.q,
239
- filter: state.type,
240
- fRange: state.range,
241
- fMin: String(state.minImportance),
242
- fScope: state.scope,
243
- fSemantic: state.semantic,
244
- fView: state.viewMode,
245
- fHeat: state.heatSort,
246
- };
247
- let items = [];
248
- if (state.q !== "") {
249
- const mode = state.semantic ? "hybrid" : "auto";
250
- const res = await call(`/search?q=${enc(state.q)}&topK=20&mode=${mode}`);
251
- // /search 没有 type 参数(它是全库语义检索),类型筛选只能在拿到结果后
252
- // 再收一遍,否则搜索会把用户选好的类型丢掉(评审 #8)。
253
- const hits = res?.items ?? [];
254
- items = state.type === "all" || state.type === "" ? hits : hits.filter((item) => item?.type === state.type);
255
- payload.total = String(items.length);
256
- // 空结果不再靠一块 emptyState 表达:那个结构在首屏就定了,数据晚一步
257
- // 到达时就会和真实列表同时出现。文案跟着数据走,结构不跟着动。
258
- payload.pageText =
259
- items.length === 0
260
- ? "搜索无结果"
261
- : `搜索 · ${items.length} 条`;
262
- } else {
263
- const query = new URLSearchParams({
264
- limit: String(state.size),
265
- offset: String(state.offset),
266
- order: "chrono",
267
- });
268
- if (state.type !== "all" && state.type !== "") query.set("type", state.type);
269
- if (state.minImportance > 0) query.set("minImportance", String(state.minImportance));
270
- const days = RANGE_DAYS[state.range];
271
- if (days !== undefined) query.set("updatedFrom", new Date(Date.now() - days * 86400000).toISOString());
272
- if (state.scope === "deposited") query.set("deposited", "only");
273
- if (state.scope === "archived") query.set("archived", "only");
274
- if (state.scope === "foreign") query.set("source", "imported");
275
- const res = await call(`/list?${query.toString()}`);
276
- items = res?.items ?? [];
277
- const total = num(res?.total, items.length);
278
- const pages = Math.max(1, Math.ceil(total / state.size));
279
- const pageNo = Math.min(pages, Math.floor(state.offset / state.size) + 1);
280
- payload.total = String(total);
281
- // 这行文案同时是左胶囊的标签,列宽只有约 142dp,越长越容易折行。
282
- payload.pageText = total === 0 ? "没有匹配的记忆" : `${total} 条 · ${pageNo}/${pages} 页`;
283
- }
284
- for (const row of items) cache.set(row.id, row);
285
- const dream = await dreamPromise;
286
- state.pendingIds = new Set(Array.isArray(dream?.pendingMemoryIds) ? dream.pendingMemoryIds : []);
287
- const ordered = state.heatSort ? [...items].sort((a, b2) => num(b2.heat, -1) - num(a.heat, -1)) : items;
288
- payload.rows = ordered.map(rowOf);
289
- state.monthGroups = groupByMonth(ordered, ordered);
290
- const [stats, conflicts] = await Promise.all([statsPromise, conflictsPromise]);
291
- payload.injected = String(stats?.injection?.injectedCount ?? 0);
292
- payload.slotRate = String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10);
293
- payload.conflicts = String(conflicts?.total ?? 0);
294
- // metric values render in one style, so units ride along in the value
295
- payload.totalText = `${payload.total ?? "0"} 条`;
296
- payload.injectedText = `${payload.injected} 次`;
297
- payload.slotRateText = `${payload.slotRate}%`;
298
- payload.conflictsText = `${payload.conflicts} 条`;
299
- state.pendingConflicts = num(conflicts?.total, 0);
300
- payload.pendingConflicts = state.pendingConflicts;
301
- // 翻页用的是 chips(原生最小 48dp,两个一定同行),点完立刻把绑定清空,
302
- // 免得选中的那枚一直带着勾。
303
- payload.pagePick = "";
304
- state.rowsCount = (payload.rows ?? []).length;
305
- state.total = num(payload.total, state.rowsCount);
306
- return payload;
307
- }
308
-
309
- async function loadDetail() {
310
- const memo = state.current ?? {};
311
- const entities = await call(`/memories/entities?memoryId=${enc(memo.id ?? "")}`).then((res) => res?.entities ?? []);
312
- state.detailEntityCount = entities.length;
313
- state.detailConflict = state.pendingIds.has(memo.id);
314
- return {
315
- detailTitle: clip(memo.title ?? "(无标题)", 120),
316
- detailMeta: [
317
- { label: "类型", value: typeLabel(memo.type) },
318
- { label: "重要度", value: `★${memo.importance ?? 1}` },
319
- { label: "归属", value: scopeText(memo) },
320
- { label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) },
321
- { label: "标签", value: clip(joinTags(memo.tags) || "—", 40) },
322
- { label: "来源", value: clip(memo.source ?? "—", 40) },
323
- { label: "状态", value: memo.archived ? "已归档" : "正常", tone: memo.archived ? "muted" : "good" },
324
- ],
325
- detailContent: clip(memo.content ?? "", 2000),
326
- detailType: typeLabel(memo.type),
327
- detailTypeTone: typeTone(memo.type) ?? "info",
328
- detailStars: `重要度 ★${memo.importance ?? 1}`,
329
- detailEntities: entities.slice(0, 8).map((item) => ({
330
- title: clip(item.name ?? item.title ?? item.id ?? "—", 40),
331
- subtitle: clip(item.type ?? "实体", 20),
332
- icon: "user",
333
- })),
334
- detailEntityCount: entities.length,
335
- detailConflict: state.detailConflict,
336
- provenanceText: state.provenanceText,
337
- };
338
- }
339
-
340
- function loadEditor() {
341
- return {
342
- form: {
343
- id: state.form.id ?? "",
344
- title: state.form.title ?? "",
345
- content: state.form.content ?? "",
346
- type: state.form.type ?? "project",
347
- importance: num(state.form.importance, 3),
348
- tags: state.form.tags ?? "",
349
- agentScope: state.form.agentScope ?? "",
350
- workspaceScope: state.form.workspaceScope ?? "",
351
- },
352
- };
353
- }
354
-
355
- async function loadEntity() {
356
- const needle = state.entityQuery.trim();
357
- // 名字过滤交给宿主(/entities?q=):本地过滤只能看见 limit 那一条切片,
358
- // 排在后面的实体搜不到(评审 #9)。类型数只在不搜索时重算——它代表整库
359
- // 有几种类型,不该随搜索词一起抖动。
360
- const [directory, matched] = await Promise.all([
361
- needle === "" ? null : call("/entities?limit=200").catch(() => null),
362
- call(`/entities?limit=${needle === "" ? 200 : 30}${needle === "" ? "" : `&q=${enc(needle)}`}`),
363
- ]);
364
- const all = directory?.entities ?? matched?.entities ?? [];
365
- state.entityTotal = num(matched?.total, all.length);
366
- if (needle === "") {
367
- const types = new Set();
368
- for (const item of all) {
369
- if (typeof item?.type === "string" && item.type !== "") types.add(item.type);
370
- }
371
- state.entityTypeTotal = types.size;
372
- }
373
- const shown = (needle === "" ? all : (matched?.entities ?? [])).slice(0, 30);
374
- return {
375
- entityTotal: String(state.entityTotal),
376
- entityTypeTotal: String(state.entityTypeTotal),
377
- entityTotalText: `${String(state.entityTotal)} 个`,
378
- entityTypeTotalText: `${String(state.entityTypeTotal)} 类`,
379
- entityQuery: state.entityQuery,
380
- // 结构固定,空库的提示走这一行文案(见 views/entity.js 的注释)。
381
- entityStatus:
382
- shown.length === 0
383
- ? needle === ""
384
- ? "还没有实体 · 在「设置 › 记忆引擎」的功能开关里打开「抽取人物与项目」"
385
- : `没有名字含「${clip(needle, 20)}」的实体`
386
- : needle === ""
387
- ? `共 ${String(state.entityTotal)} 个实体`
388
- : `搜索「${clip(needle, 20)}」· ${String(shown.length)} 个`,
389
- entities: shown.map((item) => ({
390
- title: clip(item.name ?? item.title ?? item.id ?? "—", 40),
391
- subtitle: clip(entityTypeLabel(item.type ?? "—"), 20),
392
- trailing: item.mention_count === undefined ? undefined : `${item.mention_count} 次`,
393
- ...(entityIcon(item.type) === undefined ? {} : { icon: entityIcon(item.type) }),
394
- action: { id: "open-entity", args: { name: String(item.name ?? "") } },
395
- })),
396
- };
397
- }
398
-
399
- async function loadEntityDetail() {
400
- const name = state.entityName;
401
- const ego = await call(`/semantic/graph/ego?entity=${enc(name)}&depth=${state.entityDepth}&limit=40`);
402
- const attrs = await call(`/semantic/graph/entity-attrs?entity=${enc(name)}`).catch(() => null);
403
- const nodes = Array.isArray(ego?.nodes) ? ego.nodes : [];
404
- const byId = new Map(nodes.map((node) => [node.id, node]));
405
- const root = ego?.root ?? {};
406
- const relations = (Array.isArray(ego?.edges) ? ego.edges : []).slice(0, 30).map((edge) => {
407
- const from = byId.get(edge.from_entity);
408
- const to = byId.get(edge.to_entity);
409
- const other = edge.from_entity === root.id ? edge.to_entity : edge.from_entity;
410
- const otherNode = byId.get(other);
411
- return {
412
- title: `${clip(from?.name ?? "—", 16)} → ${clip(to?.name ?? "—", 16)}`,
413
- subtitle: clip(edge.relation_type ?? "关系", 24),
414
- tone: "info",
415
- ...(otherNode === undefined
416
- ? {}
417
- : { action: { id: "open-entity", args: { name: String(otherNode.name ?? "") } } }),
418
- };
419
- });
420
- const memories = new Map();
421
- for (const edge of Array.isArray(ego?.edges) ? ego.edges : []) {
422
- if (typeof edge.memory_id === "string" && edge.memory_id !== "") {
423
- const memo = cache.get(edge.memory_id);
424
- memories.set(edge.memory_id, {
425
- title: clip(memo?.title ?? `记忆 ${edge.memory_id.slice(0, 8)}`, 40),
426
- subtitle: memo === undefined ? "未在本地缓存" : typeLabel(memo.type),
427
- action: { id: "open", args: { id: edge.memory_id } },
428
- });
429
- }
430
- }
431
- state.entityRelationCount = relations.length;
432
- state.entityMemoryCount = memories.size;
433
- return {
434
- entityName: clip(root.name ?? name, 40),
435
- entityType: clip(entityTypeLabel(root.type ?? "实体"), 20),
436
- entityMentionText: `被提到 ${String(root.mention_count ?? 0)} 次 · 关系 ${relations.length} 条`,
437
- entityDepth: String(state.entityDepth),
438
- entityAttrs: (Array.isArray(attrs?.attrs) ? attrs.attrs : []).slice(0, 24).map((attr) => ({
439
- label: clip(attr.key ?? "—", 30),
440
- value: clip(String(attr.value ?? "—"), 60),
441
- })),
442
- entityRelations: relations,
443
- entityMemories: [...memories.values()].slice(0, 20),
444
- entityRelationCount: relations.length,
445
- entityMemoryCount: memories.size,
446
- entityHint: "点关系可以跳到相邻实体;点记忆可以跳到详情。",
447
- };
448
- }
449
-
450
- async function loadSettings() {
451
- const [features, mode] = await Promise.all([call("/features"), call("/mode")]);
452
- const effective = features?.effective ?? {};
453
- state.flags = effective;
454
- state.flagOverrides = features?.overrides ?? {};
455
- state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
456
- const changed = Object.keys(state.flagOverrides).length;
457
- const pending = state.pendingRestart.length;
458
- // 上游不断加旗标:没给中文名的布尔项仍要出现在面板上,否则新功能在手机上
459
- // 静默消失。数值/字符串键(模型名、预算)不放这里——它们需要输入框,不是开关。
460
- state.extraRows = Object.keys(effective)
461
- .filter((key) => !FLAG_LABELS.has(key) && typeof effective[key] === "boolean")
462
- .sort()
463
- .map((key) => ({
464
- title: key,
465
- subtitle: effective[key] === true ? "已开启 · 点一下关闭" : "已关闭 · 点一下开启",
466
- icon: effective[key] === true ? "toggle-on" : "toggle-off",
467
- action: { id: "flag-set", args: { key } },
468
- }));
469
- const payload = {
470
- extraRows: state.extraRows,
471
- settingsView: state.settingsView,
472
- sPanel: state.sPanel,
473
- flagGroup: state.flagGroup,
474
- flags: nestFlags(effective),
475
- mode: String(mode?.mode ?? "standard"),
476
- flagsMeta: `已改 ${changed} 项 · 待重启生效 ${pending} 项(改动即时写回 mneme,重启引擎后彻底生效)`,
477
- };
478
- if (state.sPanel === "menu") return payload;
479
- if (state.sPanel === "workbench") {
480
- const [deposited, archived] = await Promise.all([
481
- call("/list?deposited=only&limit=8&order=chrono").catch(() => ({})),
482
- call("/list?archived=only&limit=5&order=chrono").catch(() => ({})),
483
- ]);
484
- const depItems = deposited?.items ?? [];
485
- const arcItems = archived?.items ?? [];
486
- for (const row of [...depItems, ...arcItems]) cache.set(row.id, row);
487
- state.wbDeposited = depItems.map((row) => ({
488
- title: clip(row.title, 50),
489
- subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at)}`,
490
- action: { id: "open", args: { id: row.id } },
491
- }));
492
- state.wbArchived = arcItems.map((row) => ({
493
- title: clip(row.title, 50),
494
- subtitle: fmtTime(row.updated_at),
495
- trailing: "点按恢复",
496
- action: { id: "wb-restore", args: { id: row.id } },
497
- }));
498
- Object.assign(payload, {
499
- wbKv: [
500
- { label: "已沉淀", value: `${String(deposited?.total ?? depItems.length)} 条` },
501
- { label: "已归档", value: `${String(archived?.total ?? arcItems.length)} 条` },
502
- ],
503
- wbDeposited: state.wbDeposited,
504
- wbArchived: state.wbArchived,
505
- });
506
- } else if (state.sPanel === "commands") {
507
- const res = await call("/commands").catch(() => ({ commands: [] }));
508
- state.commands = Array.isArray(res?.commands) ? res.commands : [];
509
- state.cmdCount = state.commands.length;
510
- Object.assign(payload, {
511
- cmdRows: state.commands.map((cmd) => ({
512
- title: clip(cmd.name ?? cmd.id ?? "—", 30),
513
- subtitle: clip(cmd.description ?? "", 60),
514
- })),
515
- cmdCount: state.cmdCount,
516
- sCmdPick: state.sCmdPick,
517
- sCmdName: state.sCmdName,
518
- sCmdDesc: state.sCmdDesc,
519
- sCmdIns: state.sCmdIns,
520
- });
521
- } else if (state.sPanel === "vector") {
522
- const res = await call("/vector-config").catch(() => null);
523
- // 读到才算 loaded:读失败时下面会落成默认值,不能再拿它整体覆盖
524
- // 服务端配置(评审 #2)。
525
- state.vectorLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object";
526
- const config = res?.config ?? {};
527
- state.vector = {
528
- enabled: config.enabled === true,
529
- baseUrl: String(config.baseUrl ?? ""),
530
- model: String(config.model ?? ""),
531
- };
532
- const keySet = typeof config.apiKey === "string" && config.apiKey !== "";
533
- Object.assign(payload, {
534
- sVecEnabled: state.vector.enabled,
535
- sVecUrl: state.vector.baseUrl,
536
- sVecModel: state.vector.model,
537
- vectorKv: [
538
- {
539
- label: "状态",
540
- value: state.vector.enabled ? "已启用" : "未启用",
541
- tone: state.vector.enabled ? "good" : "muted",
542
- },
543
- { label: "接口", value: state.vector.baseUrl === "" ? "未配置" : clip(state.vector.baseUrl, 60) },
544
- { label: "模型", value: state.vector.model === "" ? "未配置" : state.vector.model },
545
- { label: "密钥", value: keySet ? "已配置(面板不显示)" : "未配置" },
546
- ],
547
- vectorNote: "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。",
548
- });
549
- } else if (state.sPanel === "external") {
550
- // 读一次真实配置,否则面板永远拿默认值渲染,保存时又把默认值写回去,
551
- // 把用户已经在 Web 面板里配好的端口/地址覆盖掉(评审 #6)。
552
- // 必须带 token=omit:不带的话这条 GET 会顺手生成并落库一个 Bearer。
553
- const res = await call("/external-api?token=omit").catch(() => null);
554
- state.externalLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object";
555
- const config = res?.config ?? {};
556
- state.external = {
557
- enabled: config.enabled === true,
558
- port: num(config.port, 8790),
559
- host: typeof config.host === "string" && config.host !== "" ? config.host : "127.0.0.1",
560
- };
561
- Object.assign(payload, {
562
- sExtEnabled: state.external.enabled,
563
- sExtPort: String(state.external.port),
564
- sExtHost: state.external.host,
565
- });
566
- } else if (state.sPanel === "facts") {
567
- // 「Agent 眼中的你」:一次请求喂饱这张卡(facts / journal / runs)。flag 关着
568
- // 也照读——历史上开过又关掉的库里事实还在,得让人看得见。
569
- const query = state.factsAgent === "" ? "" : `?agent=${encodeURIComponent(state.factsAgent)}`;
570
- const facts = await call(`/profile-facts${query}`).catch(() => ({}));
571
- state.factsCards = Array.isArray(facts?.cards) ? facts.cards.map(String) : [];
572
- const rows = Array.isArray(facts?.facts) ? facts.facts : [];
573
- state.factsItems = rows;
574
- const active = rows.filter((fact) => fact.status !== "archived");
575
- state.factsRows = rows.slice(0, 20).map((fact) => ({
576
- title: clip(String(fact.content ?? ""), 60),
577
- subtitle:
578
- fact.status === "archived"
579
- ? `已归档 · ${fmtTime(fact.updated_at)}`
580
- : `更新于 ${fmtTime(fact.updated_at)}`,
581
- trailing: fact.status === "archived" ? "已归档" : "点一下修改",
582
- action: { id: "facts-edit", args: { id: String(fact.id ?? "") } },
583
- }));
584
- const runs = Array.isArray(facts?.runs) ? facts.runs : [];
585
- const last = runs[0];
586
- state.factsRunText =
587
- last === undefined
588
- ? "还没跑过维护轮。点「立即更新」会让模型读最近的记忆补事实。"
589
- : `最近一轮:${String(last.status ?? "?")} · 改 ${String(last.ops_applied ?? 0)} 条 · 读 ${String(last.input_count ?? 0)} 条记忆 · ${fmtTime(last.started_at)}`;
590
- Object.assign(payload, {
591
- factsAgent: state.factsAgent,
592
- factsCardOptions: [
593
- { label: "默认", value: "" },
594
- // 宿主给的 cards 里常含空串(默认卡),别再和「默认」重复一次。
595
- ...state.factsCards.filter((key) => key !== "").map((key) => ({ label: key, value: key })),
596
- ],
597
- factsRows: state.factsRows,
598
- factsRunText: state.factsRunText,
599
- sFactNew: state.form.factNew ?? "",
600
- sFactEdit: state.factEdit?.content ?? "",
601
- factsEditHint:
602
- state.factEdit === null
603
- ? ""
604
- : `正在修改:${clip(String(state.factEdit.content ?? ""), 40)}`,
605
- });
606
- if (active.length === 0) state.factsRows.push({ title: "还没有事实", subtitle: "点下面的「立即更新」让模型生成一批。" });
607
- } else if (state.sPanel === "choro") {
608
- // 地志是「按仓库」的:先列已登记仓库,再列该仓库的模块骨架页。
609
- const repos = await call("/choro/repos").catch(() => ({}));
610
- state.choroRepos = (Array.isArray(repos?.repos) ? repos.repos : []).map((repo) => ({
611
- id: String(repo.id ?? ""),
612
- name: clip(String(repo.name ?? repo.root_path ?? repo.id ?? ""), 40),
613
- }));
614
- if (state.choroRepo === "" && state.choroRepos.length > 0) state.choroRepo = state.choroRepos[0].id;
615
- const known = state.choroRepos.some((repo) => repo.id === state.choroRepo);
616
- if (!known) state.choroRepo = state.choroRepos[0]?.id ?? "";
617
- let note = "先在地志里登记仓库(命令行 dsh-mneme-choro <repo> --apply)。";
618
- let rows = [];
619
- if (state.choroRepo !== "") {
620
- const map = await call(`/choro/modules?repo=${encodeURIComponent(state.choroRepo)}`).catch(() => ({}));
621
- const modules = Array.isArray(map?.modules) ? map.modules : [];
622
- rows = modules.slice(0, 30).map((item) => ({
623
- title: clip(String(item.title ?? item.slug ?? ""), 40),
624
- subtitle: `${String(item.file_count ?? 0)} 个文件 · ${String(item.page_status ?? "无页")}`,
625
- trailing: "看页",
626
- action: { id: "choro-page", args: { slug: String(item.slug ?? "") } },
627
- }));
628
- const repo = state.choroRepos.find((item) => item.id === state.choroRepo);
629
- const baseline = map?.repo?.gitBaseline;
630
- note =
631
- modules.length === 0
632
- ? "这个仓库还没有骨架页:点下面的「重建骨架」生成一次。"
633
- : `${modules.length} 个模块${baseline ? ` · 基线 ${String(baseline).slice(0, 7)}` : ""} · ${repo?.name ?? ""}`;
634
- }
635
- state.choroRows = rows;
636
- state.choroNote = note;
637
- Object.assign(payload, {
638
- choroRepo: state.choroRepo,
639
- choroRepoOptions:
640
- state.choroRepos.length === 0
641
- ? [{ label: "还没有登记任何仓库", value: "" }]
642
- : state.choroRepos.map((repo) => ({ label: repo.name, value: repo.id })),
643
- choroRows: state.choroRows,
644
- choroNote: state.choroNote,
645
- choroPageText: state.choroPageText,
646
- });
647
- } else if (state.sPanel === "help") {
648
- const info = await call("/info").catch(() => ({}));
649
- Object.assign(payload, {
650
- helpKv: [
651
- { label: "插件", value: `@modusensus/dsh-mneme ${String(info?.version ?? "—")}` },
652
- { label: "记忆库", value: "本机 SQLite(与本机 Web 面板同一份,不跨设备同步)" },
653
- { label: "面板", value: "dsh-go 原生面板" },
654
- ],
655
- versionNote: state.versionNote,
656
- });
657
- }
658
- return payload;
659
- }
660
-
661
- async function loadProfile() {
662
- if (state.profileTab === "persona") {
663
- const res = await call("/persona");
664
- const persona = res?.persona ?? {};
665
- const preview = res?.preview ?? {};
666
- return {
667
- personaText: clip(
668
- JSON.stringify({ default: persona.default ?? {}, byAgent: persona.byAgent ?? {} }, null, 2),
669
- 700,
670
- ),
671
- previewItems: [
672
- { label: "生效来源", value: String(preview.source ?? "—") },
673
- { label: "注入内容", value: clip(preview.text === "" ? "(空)" : preview.text, 300) },
674
- ],
675
- };
676
- }
677
- if (state.profileTab === "rules") {
678
- const res = await call("/rules");
679
- return { editText: clip(JSON.stringify(res?.rules ?? [], null, 2), 2000) };
680
- }
681
- const res = await call("/profile");
682
- return { editText: clip(res?.profile ?? "", 2000), extractText: state.extractText };
683
- }
684
-
685
- async function loadConflict() {
686
- const res = await call("/conflicts");
687
- const items = Array.isArray(res?.items) ? res.items : [];
688
- state.conflictCount = num(res?.total, items.length);
689
- const rows = items.slice(0, 20).map((item) => ({
690
- title: clip(item.a?.title ?? item.b?.title ?? item.id ?? "—", 40),
691
- subtitle: `A:${clip(item.a?.title ?? "—", 20)} · B:${clip(item.b?.title ?? "—", 20)}`,
692
- tone: "warn",
693
- action: { id: "conflict-pick-row", args: { id: String(item.id ?? "") } },
694
- }));
695
- const selected = items.find((item) => item.id === state.conflictId) ?? null;
696
- state.conflictA = selected?.a ?? null;
697
- state.conflictB = selected?.b ?? null;
698
- state.conflictReady = selected !== null;
699
- const side = (memo) =>
700
- memo === null
701
- ? []
702
- : [
703
- { label: "类型", value: typeLabel(memo.type) },
704
- { label: "重要度", value: `★${memo.importance ?? 1}` },
705
- { label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) },
706
- ];
707
- return {
708
- conflictRows: rows,
709
- conflictCount: state.conflictCount,
710
- conflictReady: state.conflictReady,
711
- conflictPick: state.conflictPick,
712
- conflictATitle: clip(state.conflictA?.title ?? "", 120),
713
- conflictAMeta: side(state.conflictA),
714
- conflictAContent: clip(state.conflictA?.content ?? "", 800),
715
- conflictBTitle: clip(state.conflictB?.title ?? "", 120),
716
- conflictBMeta: side(state.conflictB),
717
- conflictBContent: clip(state.conflictB?.content ?? "", 800),
718
- pendingConflicts: state.conflictCount,
719
- };
720
- }
721
-
722
- async function loadStatus() {
723
- const [info, health, dream, inject, stats, conflicts, list, entities, mode, preview, audit, features, recent] =
724
- await Promise.all([
725
- call("/info").catch(() => ({})),
726
- call("/health").catch(() => ({})),
727
- call("/dream-status").catch(() => ({})),
728
- call("/inject-status").catch(() => ({})),
729
- call("/recall-stats?window=30").catch(() => ({})),
730
- call("/conflicts").catch(() => ({})),
731
- call("/list?limit=1").catch(() => ({})),
732
- call("/entities?limit=1").catch(() => ({})),
733
- call("/mode").catch(() => ({})),
734
- call("/inject-preview").catch(() => ({})),
735
- call("/semantic/llm-audit/stats?days=28").catch(() => ({})),
736
- call("/features").catch(() => ({})),
737
- call("/list?limit=200&order=chrono").catch(() => ({})),
738
- ]);
739
- const run = dream?.lastRun ?? {};
740
- const mirror = health?.mirror ?? {};
741
- const snapshot = preview?.snapshot ?? null;
742
- state.injectPreviewText =
743
- snapshot === null
744
- ? "还没有渲染过注入预览:下一轮对话开始时 mneme 会写一份快照。"
745
- : clip(typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot), 500);
746
- const byDay = Array.isArray(audit?.by_day) ? audit.by_day : [];
747
- state.tokenCategories = byDay.map((day) => fmtDay(day.day));
748
- state.tokenSeries = byDay.length === 0 ? [] : [{ name: "调用", values: byDay.map((day) => num(day.count, 0)) }];
749
- state.tokenKv = [
750
- { label: "调用次数", value: `${String(audit?.total_calls ?? 0)} 次` },
751
- { label: "Token", value: String(audit?.total_tokens ?? 0) },
752
- { label: "估算成本", value: `$${Number(audit?.total_cost_usd ?? 0).toFixed(4)}` },
753
- ];
754
- const heatEnabled = (features?.effective ?? {}).heatEnabled === true;
755
- const heated = (recent?.items ?? []).filter((row) => typeof row.heat === "number");
756
- if (heatEnabled && heated.length > 0) {
757
- const hot = heated.filter((row) => row.heat >= 0.7).length;
758
- const warm = heated.filter((row) => row.heat >= 0.3 && row.heat < 0.7).length;
759
- state.heatSeries = [{ name: "条数", values: [hot, warm, heated.length - hot - warm] }];
760
- state.heatNote = `统计最近 ${heated.length} 条记忆的热度。`;
761
- } else {
762
- state.heatSeries = [];
763
- state.heatNote = "mneme 没有开启“热度衰减”,所以看不到热度分布。";
764
- }
765
- state.pendingConflicts = num(conflicts?.total, 0);
766
- return {
767
- // metric cards live on 状态, mirroring mneme's own Status page
768
- totalText: `${String(list?.total ?? 0)} 条`,
769
- injectedText: `${String(stats?.injection?.injectedCount ?? 0)} 次`,
770
- slotRateText: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%`,
771
- conflictsText: `${String(conflicts?.total ?? 0)} 条`,
772
- statusKv: [
773
- { label: "mneme 版本", value: String(info?.version ?? "—") },
774
- { label: "记忆", value: `${String(list?.total ?? 0)} 条` },
775
- { label: "实体", value: `${String(entities?.total ?? 0)} 个` },
776
- { label: "运行模式", value: String(mode?.mode ?? "—") },
777
- { label: "镜像", value: String(mirror.status ?? "—"), tone: mirror.status === "ok" ? "good" : "warn" },
778
- { label: "上次成功", value: fmtTime(mirror.success_at) },
779
- ],
780
- injectKv: [
781
- {
782
- label: "自动注入",
783
- value: inject?.autoInject ? "已开启" : "已关闭",
784
- tone: inject?.autoInject ? "good" : "muted",
785
- },
786
- { label: "注入抑制", value: inject?.suppressed ? "是" : "否" },
787
- { label: "会话预设", value: String(inject?.agentPreset ?? "—") },
788
- { label: "近 30 天注入", value: `${String(stats?.injection?.injectedCount ?? 0)} 次` },
789
- { label: "命中率", value: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%` },
790
- ],
791
- topRows: (stats?.topRecalled ?? []).slice(0, 5).map((row) => ({
792
- title: clip(row.title, 40),
793
- subtitle: typeLabel(row.type),
794
- trailing: `${row.count} 次`,
795
- })),
796
- dreamKv: [
797
- { label: "上次巩固", value: fmtTime(dream?.lastConsolidation) },
798
- { label: "上次运行", value: `${fmtTime(run.created_at)} ${run.status ?? "—"}` },
799
- {
800
- label: "待确认",
801
- value: `${String(conflicts?.total ?? 0)} 条`,
802
- tone: num(conflicts?.total, 0) > 0 ? "warn" : "good",
803
- },
804
- ],
805
- runRows: (dream?.runs ?? []).slice(0, 5).map((row) => ({
806
- title: `${fmtTime(row.created_at)} · ${String(row.status ?? "—")}`,
807
- subtitle: clip(row.summary ?? row.mode ?? "", 40),
808
- })),
809
- injectPreviewText: state.injectPreviewText,
810
- tokenCategories: state.tokenCategories,
811
- tokenSeries: state.tokenSeries,
812
- tokenKv: state.tokenKv,
813
- heatSeries: state.heatSeries,
814
- heatNote: state.heatNote,
815
- reclaimText: state.reclaimText,
816
- reclaimConfirm: state.reclaimConfirm,
817
- pendingConflicts: state.pendingConflicts,
818
- };
819
- }
820
-
821
- // The app's `tabs` bar switches locally, so the payload always carries every
822
- // section (a binding whose key is missing renders as 「—」). That does not mean
823
- // every section must be re-read after every tap: 实体 / 设置 / 状态 only change
824
- // while their own page is open, so a tap on the list used to cost four reads
825
- // (评审 #14). Load the active page's section, keep the rest from the previous
826
- // payload; a full read happens on mount.
827
- async function load(options = {}) {
828
- const full = options.full === true;
829
- const payload = {
830
- page: state.page,
831
- profileTab: state.profileTab,
832
- notice: state.notice,
833
- err: state.err,
834
- };
835
- const jobs = [];
836
- const merge = (promise) => jobs.push(promise.then((part) => Object.assign(payload, part)));
837
- const page = state.page;
838
- const memoryPage = !["entity", "settings", "profile"].includes(page);
839
- if (full || memoryPage) {
840
- const memory = page === "detail" ? loadDetail() : page === "editor" ? loadEditor() : loadMemory();
841
- merge(Promise.resolve(memory));
842
- }
843
- const entityPage = page === "entity";
844
- // 实体搜索要重读实体清单;但搜索后 state.page 仍是 "list"(视图里实体
845
- // 区就是列表),旧条件只看页面,于是搜索完推的仍是搜索前那份(评审 #4)。
846
- if (full || entityPage || state.entityDirty) merge(entityPage ? loadEntityDetail() : loadEntity());
847
- state.entityDirty = false;
848
- if (full || page === "settings" || page === "profile" || state.settingsDirty) {
849
- merge(loadSettings());
850
- merge(loadStatus());
851
- }
852
- state.settingsDirty = false;
853
- if (full || (memoryPage && page === "conflict")) merge(loadConflict());
854
- if (full || page === "profile") merge(loadProfile());
855
- const settled = await Promise.allSettled(jobs);
856
- const failed = settled.find((item) => item.status === "rejected");
857
- if (failed !== undefined) payload.err = errorText(failed.reason);
858
- // 未重新读取的块沿用上一次的完整结果;刷新过的那块覆盖上去。
859
- const merged = lastPayload === null ? payload : { ...lastPayload, ...payload };
860
- lastPayload = merged;
861
- return merged;
862
- }
863
-
864
- /** Re-run the loaders and push the result into every live panel feed. */
865
- async function broadcast() {
866
- if (emitters.size === 0) return;
867
- const payload = await load();
868
- for (const emit of [...emitters]) {
869
- try {
870
- emit(payload);
871
- } catch {
872
- // a closing panel detaches its emitter; nothing to do
873
- }
874
- }
875
- }
876
-
877
- // ---------------------------------------------------------------- actions
878
- function goMemory(page) {
879
- state.page = page;
880
- state.err = "";
881
- }
882
-
883
- function nextValue(list, current, value) {
884
- const picked = typeof value === "string" && list.includes(value) ? value : null;
885
- if (picked !== null) return picked;
886
- const index = list.indexOf(current);
887
- return list[(index + 1) % list.length];
888
- }
889
-
890
- async function applyArchived(archived, event, scope) {
891
- const memo = state.current;
892
- if (memo === null) return { toast: "先选一条记忆" };
893
- // 归档/恢复改的是「正在看」的那一条,参数只能从状态里拿;状态是模块级
894
- // 共享的,两个面板各开一条时后开的会盖掉前一个的 current。与删除同一条
895
- // 规矩:只动本面板自己打开的那条,对不上就拒绝(评审 #10)。
896
- const mine = openedBy.get(scopeKey(scope));
897
- if (mine === undefined || mine !== memo.id) {
898
- return { toast: "这条不是当前打开的记忆,已取消" };
899
- }
900
- try {
901
- const res = await call("/update", { method: "POST", body: { id: mine, archived } });
902
- if (res?.memory !== null && typeof res.memory === "object") {
903
- state.current = res.memory;
904
- openedBy.set(scopeKey(scope), String(res.memory.id ?? mine));
905
- }
906
- notice(archived ? "已归档。" : "已恢复。");
907
- } catch (error) {
908
- state.err = errorText(error);
909
- }
910
- return {};
911
- }
912
-
913
- // 编辑页的比对基准:点「编辑」时看到的那一份快照。冲突后必须用最新的
914
- // 记忆重建它,否则下一次保存会因为 base 为 null 而整份提交、直接跳过版本
915
- // 检查(评审 #11)。
916
- function editBaseOf(memo) {
917
- return {
918
- title: String(memo.title ?? ""),
919
- content: String(memo.content ?? ""),
920
- importance: num(memo.importance, 3),
921
- tags: joinTags(memo.tags),
922
- agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "",
923
- workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "",
924
- updatedAt: typeof memo.updated_at === "string" ? memo.updated_at : "",
925
- };
926
- }
927
-
928
- // 每次打开都回宿主重读,而不是先看 cache:cache 是列表加载时写下的,
929
- // 可能已经几分钟没更新;编辑页会拿它预填表单,随后整份提交回 /update,
930
- // 用陈旧内容覆盖掉工具或 Web 面板刚写进去的新值(评审 #4)。
931
- // /list 支持按 id 取单条(归档行也取得到),不再用 limit=500 扫列表再拿缓存
932
- // 兜底:500 条之外或已归档的条目根本扫不到,兜底等于拿旧值去比对 updated_at
933
- // (评审 #12)。网络失败就让它抛,调用处会报错,绝不悄悄退回缓存。
934
- async function openMemoryById(id) {
935
- const res = await call(`/list?id=${encodeURIComponent(String(id))}`);
936
- const row = (res?.items ?? [])[0];
937
- if (row === undefined) {
938
- cache.delete(id);
939
- return undefined;
940
- }
941
- cache.set(row.id, row);
942
- return row;
943
- }
944
-
945
- const actions = {
946
- "switch-group"(scope, event) {
947
- resetNotice();
948
- const keys = ["basic", "record", "write", "workflow", "retrieval", "scope", "extra"];
949
- state.flagGroup = nextValue(keys, state.flagGroup, valueOf(event, ["group", "value", "k"], ""));
950
- state.page = "settings";
951
- return {};
952
- },
953
-
954
- "switch-panel"(scope, event) {
955
- resetNotice();
956
- const keys = ["menu", "workbench", "commands", "vector", "external", "help", "facts", "choro"];
957
- state.sPanel = nextValue(keys, state.sPanel, valueOf(event, ["value", "panel", "k"], ""));
958
- state.page = "settings";
959
- return {};
960
- },
961
-
962
- "open-panel"(scope, event) {
963
- resetNotice();
964
- // 功能开关已经铺在设置页正文里,不再有 flags 子页面(#dsh-go 面板对齐电脑端分组)
965
- const keys = ["workbench", "commands", "vector", "external", "help", "facts", "choro"];
966
- const wanted = String(valueOf(event, ["k", "panel", "value"], "") ?? "");
967
- state.sPanel = keys.includes(wanted) ? wanted : "menu";
968
- state.page = "settings";
969
- // 子页面画在「设置」视图里:从「状态」视图点过来时若不把视图切回去,
970
- // 看到的还是状态页,什么都没有发生(评审 #14)。
971
- state.settingsView = "settings";
972
- return {};
973
- },
974
-
975
- "switch-settings-view"(scope, event) {
976
- resetNotice();
977
- state.settingsView = nextValue(["settings", "status"], state.settingsView, valueOf(event, ["value"], ""));
978
- if (state.settingsView === "settings") state.page = "settings";
979
- return {};
980
- },
981
-
982
- "open-profile"(scope, event) {
983
- resetNotice();
984
- const wanted = String(valueOf(event, ["k", "tab", "value"], state.profileTab) ?? "");
985
- state.profileTab = ["profile", "rules", "persona"].includes(wanted) ? wanted : "profile";
986
- state.page = "profile";
987
- return {};
988
- },
989
-
990
- "open-conflict"() {
991
- resetNotice();
992
- state.page = "conflict";
993
- // 冲突页同样属于「设置」视图(入口在设置页的状态区里),视图停在状态
994
- // 视图时切不到它(评审 #14)。
995
- state.settingsView = "settings";
996
- return {};
997
- },
998
-
999
- "clear-filter"() {
1000
- resetNotice();
1001
- state.q = "";
1002
- state.type = "all";
1003
- state.range = "all";
1004
- state.minImportance = 0;
1005
- state.scope = "all";
1006
- state.semantic = false;
1007
- state.heatSort = false;
1008
- state.offset = 0;
1009
- goMemory("list");
1010
- return {};
1011
- },
1012
-
1013
- "set-range"(scope, event) {
1014
- resetNotice();
1015
- state.range = nextValue(["all", "7d", "30d", "90d"], state.range, valueOf(event, ["value"], ""));
1016
- state.offset = 0;
1017
- goMemory("list");
1018
- return {};
1019
- },
1020
-
1021
- "set-minimp"(scope, event) {
1022
- resetNotice();
1023
- const picked = String(valueOf(event, ["value"], "0") ?? "0");
1024
- state.minImportance = ["0", "3", "4", "5"].includes(picked) ? Number(picked) : 0;
1025
- state.offset = 0;
1026
- goMemory("list");
1027
- return {};
1028
- },
1029
-
1030
- "set-scope"(scope, event) {
1031
- resetNotice();
1032
- state.scope = nextValue(["all", "deposited", "archived", "foreign"], state.scope, valueOf(event, ["value"], ""));
1033
- state.offset = 0;
1034
- goMemory("list");
1035
- return {};
1036
- },
1037
-
1038
- "set-view"(scope, event) {
1039
- resetNotice();
1040
- state.viewMode = nextValue(["flat", "month"], state.viewMode, valueOf(event, ["value"], ""));
1041
- return {};
1042
- },
1043
-
1044
- "toggle-semantic"(scope, event) {
1045
- resetNotice();
1046
- const value = event?.value;
1047
- state.semantic = typeof value === "boolean" ? value : !state.semantic;
1048
- return {};
1049
- },
1050
-
1051
- "toggle-heat"(scope, event) {
1052
- resetNotice();
1053
- const value = event?.value;
1054
- state.heatSort = typeof value === "boolean" ? value : !state.heatSort;
1055
- return {};
1056
- },
1057
-
1058
- async provenance() {
1059
- resetNotice();
1060
- const memo = state.current;
1061
- if (memo === null) return { toast: "先选一条记忆" };
1062
- try {
1063
- const res = await call(`/memory/provenance?id=${enc(memo.id)}&limit=5`);
1064
- const windows = Array.isArray(res?.windows) ? res.windows : [];
1065
- if (windows.length === 0) {
1066
- state.provenanceText = "mneme 里没有这条记忆的蒸馏来源记录。";
1067
- } else {
1068
- const first = windows[0] ?? {};
1069
- state.provenanceText = clip(
1070
- `${windows.length} 个来源窗口 · 最近来自会话 ${String(first.session_id ?? "—").slice(0, 12)}(${fmtTime(first.created_at)})`,
1071
- 300,
1072
- );
1073
- }
1074
- } catch (error) {
1075
- state.provenanceText = errorText(error).includes("distill-records-disabled")
1076
- ? "mneme 没开「保留蒸馏原文」,所以这条记忆没有可查的来源窗口。"
1077
- : errorText(error);
1078
- }
1079
- return {};
1080
- },
1081
-
1082
- refresh() {
1083
- resetNotice();
1084
- return {};
1085
- },
1086
-
1087
- back() {
1088
- resetNotice();
1089
- state.err = "";
1090
- if (state.page === "profile" || state.page === "conflict") {
1091
- state.page = "settings";
1092
- state.sPanel = "menu";
1093
- } else if (state.page === "entity") {
1094
- state.page = "list";
1095
- } else if (state.page === "settings") {
1096
- if (state.sPanel !== "menu") state.sPanel = "menu";
1097
- else state.page = "list";
1098
- } else if (state.page === "detail" || state.page === "editor") {
1099
- state.page = "list";
1100
- }
1101
- return {};
1102
- },
1103
-
1104
- search(scope, event) {
1105
- resetNotice();
1106
- state.q = String(valueOf(event, ["query", "q", "text"], state.q) ?? "").trim();
1107
- state.offset = 0;
1108
- goMemory("list");
1109
- return {};
1110
- },
1111
-
1112
- page(scope, event) {
1113
- resetNotice();
1114
- // chips 送来的是 "prev" / "next";早期的按钮组送 { d: ±1 },两条都认。
1115
- const raw = valueOf(event, ["value", "d", "delta", "page"], 0);
1116
- const delta = raw === "prev" ? -1 : raw === "next" ? 1 : num(raw, 0);
1117
- const pages = Math.max(1, Math.ceil(num(state.total, 0) / state.size));
1118
- const next = Math.min(pages, Math.max(1, Math.floor(state.offset / state.size) + 1 + delta));
1119
- state.offset = (next - 1) * state.size;
1120
- goMemory("list");
1121
- return {};
1122
- },
1123
-
1124
- async open(scope, event) {
1125
- resetNotice();
1126
- const id = String(valueOf(event, ["id"], "") ?? "");
1127
- if (id === "") return { toast: "这条记录没有 id" };
1128
- let found;
1129
- try {
1130
- found = await openMemoryById(id);
1131
- } catch (error) {
1132
- state.err = errorText(error);
1133
- return {};
1134
- }
1135
- if (found === undefined) {
1136
- state.err = "这条记忆已经不在列表里了。";
1137
- return {};
1138
- }
1139
- state.current = found;
1140
- openedBy.set(scopeKey(scope), found.id);
1141
- state.provenanceText = "尚未查看。点上面的按钮可向 mneme 查这条记忆的蒸馏来源。";
1142
- goMemory("detail");
1143
- return {};
1144
- },
1145
-
1146
- "open-entity"(scope, event) {
1147
- resetNotice();
1148
- const name = String(valueOf(event, ["name", "entity", "value"], "") ?? "");
1149
- if (name === "") return { toast: "没有选中实体" };
1150
- state.entityName = name;
1151
- state.entityDepth = 1;
1152
- state.page = "entity";
1153
- return {};
1154
- },
1155
-
1156
- "entity-search"(scope, event) {
1157
- resetNotice();
1158
- state.entityQuery = String(valueOf(event, ["entityQuery", "query", "q"], "") ?? "").trim();
1159
- state.page = "list";
1160
- // 让下一次 load() 补读实体清单(页面仍是列表,见 load 里的注释)。
1161
- state.entityDirty = true;
1162
- return {};
1163
- },
1164
-
1165
- "entity-depth"(scope, event) {
1166
- resetNotice();
1167
- const picked = String(valueOf(event, ["value", "entityDepth"], "1") ?? "1");
1168
- state.entityDepth = picked === "2" ? 2 : 1;
1169
- return {};
1170
- },
1171
-
1172
- edit(scope) {
1173
- resetNotice();
1174
- const memo = state.current;
1175
- if (memo === null) return { toast: "先选一条记忆" };
1176
- // 记下本面板打开的那条:save 也要按它核对(评审 #3)。
1177
- openedBy.set(scopeKey(scope), memo.id);
1178
- state.form = {
1179
- id: memo.id,
1180
- title: memo.title ?? "",
1181
- content: memo.content ?? "",
1182
- type: memo.type ?? "project",
1183
- importance: num(memo.importance, 3),
1184
- tags: joinTags(memo.tags),
1185
- agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "",
1186
- workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "",
1187
- };
1188
- state.editBase = editBaseOf(memo);
1189
- goMemory("editor");
1190
- return {};
1191
- },
1192
-
1193
- "type-filter"(scope, event) {
1194
- resetNotice();
1195
- const next = String(valueOf(event, ["value", "type"], "all") ?? "all");
1196
- state.type = next === "all" || TYPE_LABELS[next] !== undefined ? next : "all";
1197
- state.offset = 0;
1198
- goMemory("list");
1199
- return {};
1200
- },
1201
-
1202
- "form-type"(scope, event) {
1203
- const next = String(valueOf(event, ["value", "type"], state.form.type ?? "project") ?? "project");
1204
- state.form.type = TYPE_LABELS[next] === undefined ? "project" : next;
1205
- return {};
1206
- },
1207
-
1208
- new() {
1209
- resetNotice();
1210
- state.current = null;
1211
- state.form = {
1212
- id: "",
1213
- title: "",
1214
- content: "",
1215
- type: "project",
1216
- importance: 3,
1217
- tags: "",
1218
- agentScope: "",
1219
- workspaceScope: "",
1220
- };
1221
- goMemory("editor");
1222
- return {};
1223
- },
1224
-
1225
- async save(scope, event) {
1226
- resetNotice();
1227
- const value = event?.value;
1228
- const source =
1229
- value !== null && typeof value === "object" && !Array.isArray(value)
1230
- ? { ...state.form, ...value }
1231
- : { ...state.form };
1232
- const title = String(source.title ?? "").trim();
1233
- const content = String(source.content ?? "").trim();
1234
- const type = TYPE_LABELS[String(source.type ?? "")] === undefined ? "project" : String(source.type);
1235
- const importance = Math.min(5, Math.max(1, Math.round(num(source.importance, 3))));
1236
- const tags = splitTags(String(source.tags ?? ""));
1237
- if (title === "" || content === "") return { toast: "标题和正文都不能空" };
1238
- const id = state.form.id === "" || state.form.id === undefined ? undefined : String(state.form.id);
1239
- // 编辑既有条目也要核对面板 scope:state.form 是模块级共享的,两个面板
1240
- // 各开一条时,A 面板点保存会把内容写进 B 刚打开的那条(评审 #3)。
1241
- // 新建(id 为空)不涉及已有行,不核对。
1242
- if (id !== undefined) {
1243
- const mine = openedBy.get(scopeKey(scope));
1244
- if (mine === undefined || mine !== id) {
1245
- return { toast: "这条不是当前打开的记忆,已取消" };
1246
- }
1247
- }
1248
-
1249
- if (id === undefined) {
1250
- try {
1251
- const res = await call("/create", { method: "POST", body: { title, content, importance, tags, type } });
1252
- state.form = {};
1253
- state.editBase = null;
1254
- if (res !== null && typeof res === "object" && typeof res.id === "string") {
1255
- state.current = res;
1256
- openedBy.set(scopeKey(scope), res.id);
1257
- goMemory("detail");
1258
- notice(res.action === "merged" ? "已并入同标题的既有条目。" : "已新建一条记忆。");
1259
- } else {
1260
- goMemory("list");
1261
- notice("已保存。");
1262
- }
1263
- } catch (error) {
1264
- state.err = errorText(error);
1265
- }
1266
- return {};
1267
- }
1268
-
1269
- // 编辑既有条目:先回宿主要一份当前值。盘点窗口够长(跨了几分钟甚至几次
1270
- // 对话)时,缓存里的那份可能已经被工具或 Web 面板改过,而这里回填的表单
1271
- // 一旦整份提交就会把对方的写入盖掉;/update 没有版本号校验,所以这层
1272
- // 比较只能放在面板侧(评审 #4)。
1273
- let fresh;
1274
- try {
1275
- fresh = await openMemoryById(id);
1276
- } catch (error) {
1277
- state.err = errorText(error);
1278
- return {};
1279
- }
1280
- if (fresh === undefined) {
1281
- state.current = null;
1282
- state.form = {};
1283
- state.editBase = null;
1284
- goMemory("list");
1285
- notice("这条记忆已经不在了。", "warn");
1286
- return {};
1287
- }
1288
- const base = state.editBase;
1289
- if (base !== null && base.updatedAt !== "" && base.updatedAt !== String(fresh.updated_at ?? "")) {
1290
- state.current = fresh;
1291
- state.form = {
1292
- id: fresh.id,
1293
- title: fresh.title ?? "",
1294
- content: fresh.content ?? "",
1295
- type: fresh.type ?? "project",
1296
- importance: num(fresh.importance, 3),
1297
- tags: joinTags(fresh.tags),
1298
- agentScope: typeof fresh.agent_scope === "string" ? fresh.agent_scope : "",
1299
- workspaceScope: typeof fresh.workspace_scope === "string" ? fresh.workspace_scope : "",
1300
- };
1301
- state.editBase = editBaseOf(fresh);
1302
- state.err = "这条记忆刚在别处被改过,已换成最新内容,请再确认一次。";
1303
- return {};
1304
- }
1305
-
1306
- // 只提交真正改动的字段:把未改的 title/content 一起发回去,等于用旧值
1307
- // 覆盖对方的写入(同一问题的一半)。
1308
- const body = { id };
1309
- if (base === null || title !== base.title) body.title = title;
1310
- if (base === null || content !== base.content) body.content = content;
1311
- if (base === null || importance !== base.importance) body.importance = importance;
1312
- if (base === null || joinTags(tags) !== base.tags) body.tags = tags;
1313
- const agentScope = String(source.agentScope ?? "").trim();
1314
- const workspaceScope = String(source.workspaceScope ?? "").trim();
1315
- if (base === null || agentScope !== base.agentScope) body.agent_scope = agentScope === "" ? null : agentScope;
1316
- if (base === null || workspaceScope !== base.workspaceScope) {
1317
- body.workspace_scope = workspaceScope === "" ? null : workspaceScope;
1318
- }
1319
- if (Object.keys(body).length === 1) return { toast: "没有改动" };
1320
- try {
1321
- const res = await call("/update", { method: "POST", body });
1322
- const memo = res?.memory;
1323
- state.form = {};
1324
- state.editBase = null;
1325
- if (memo !== null && typeof memo === "object" && typeof memo.id === "string") {
1326
- state.current = memo;
1327
- openedBy.set(scopeKey(scope), memo.id);
1328
- goMemory("detail");
1329
- } else {
1330
- goMemory("list");
1331
- }
1332
- notice("已保存修改。");
1333
- } catch (error) {
1334
- state.err = errorText(error);
1335
- }
1336
- return {};
1337
- },
1338
-
1339
- async archive(scope, event) {
1340
- resetNotice();
1341
- return applyArchived(true, event, scope);
1342
- },
1343
-
1344
- async unarchive(scope, event) {
1345
- resetNotice();
1346
- return applyArchived(false, event, scope);
1347
- },
1348
-
1349
- async delete(scope, event) {
1350
- resetNotice();
1351
- if (valueOf(event, ["confirmed"], false) !== true) return {};
1352
- const memo = state.current;
1353
- if (memo === null) return { toast: "先选一条记忆" };
1354
- // 确认框本身不带参数(confirm 节点只有 id 与两个按钮文案),所以「删哪
1355
- // 一条」只能靠状态。状态是模块级的、被所有面板共用:两个面板各开一条时
1356
- // 后开的会盖掉前一个的 current,此时点确认就会删错行(评审 #5)。
1357
- // 打开记忆时按面板 scope 记一份(见 open)。只显示共享详情、没在本面板
1358
- // 走过 open 的(mine 为空)也不能删:state.current 可能是另一个面板的
1359
- // 条目(评审 #13)。
1360
- const mine = openedBy.get(scopeKey(scope));
1361
- if (mine === undefined || mine !== memo.id) {
1362
- return { toast: "这条不是当前打开的记忆,已取消" };
1363
- }
1364
- const id = mine;
1365
- try {
1366
- await call("/delete", { method: "POST", body: { id } });
1367
- cache.delete(id);
1368
- openedBy.delete(scopeKey(scope));
1369
- state.current = null;
1370
- goMemory("list");
1371
- notice("已删除。");
1372
- } catch (error) {
1373
- state.err = errorText(error);
1374
- }
1375
- return {};
1376
- },
1377
-
1378
- copy() {
1379
- const memo = state.current;
1380
- if (memo === null) return { toast: "先选一条记忆" };
1381
- return { copy: clip(`# ${memo.title}\n\n${memo.content}`, 8000) };
1382
- },
1383
-
1384
- insert() {
1385
- const memo = state.current;
1386
- if (memo === null) return { toast: "先选一条记忆" };
1387
- return { insert: clip(`【记忆】${memo.title}\n${memo.content}`, 2000) };
1388
- },
1389
-
1390
- "profile-tab"(scope, event) {
1391
- resetNotice();
1392
- const next = nextValue(
1393
- ["profile", "rules", "persona"],
1394
- state.profileTab,
1395
- valueOf(event, ["k", "tab", "value"], ""),
1396
- );
1397
- state.profileTab = next;
1398
- state.page = "profile";
1399
- return {};
1400
- },
1401
-
1402
- async "profile-save"(scope, event) {
1403
- resetNotice();
1404
- const body = String(valueOf(event, ["editText", "text", "value"], "") ?? "");
1405
- try {
1406
- if (state.profileTab === "rules") {
1407
- let parsed;
1408
- try {
1409
- parsed = JSON.parse(body);
1410
- } catch {
1411
- state.err = "规则必须是合法 JSON 数组。";
1412
- return {};
1413
- }
1414
- // 非数组({} / null / "text")不能落成空数组:这条路由是整份覆盖,
1415
- // 静默写 [] 会把用户已有的规则一次清空(评审 #10)。
1416
- if (!Array.isArray(parsed)) {
1417
- state.err = "规则必须是合法 JSON 数组。";
1418
- return {};
1419
- }
1420
- await call("/rules", { method: "PUT", body: { rules: parsed } });
1421
- } else {
1422
- await call("/profile", { method: "PUT", body: { profile: body } });
1423
- }
1424
- notice("已保存。");
1425
- } catch (error) {
1426
- state.err = errorText(error);
1427
- }
1428
- return {};
1429
- },
1430
-
1431
- async "profile-extract"() {
1432
- resetNotice();
1433
- if (state.profileTab !== "profile") return { toast: "先切到画像" };
1434
- try {
1435
- const res = await call("/profile/extract", { method: "POST", body: {} });
1436
- const draft = res?.draft ?? res ?? {};
1437
- const text =
1438
- typeof draft.profile === "string"
1439
- ? draft.profile
1440
- : typeof draft.text === "string"
1441
- ? draft.text
1442
- : JSON.stringify(draft);
1443
- state.extractText = clip(`草稿:${text}`, 600);
1444
- } catch (error) {
1445
- const raw = errorText(error);
1446
- state.extractText = raw.includes("disabled")
1447
- ? "mneme 没开「画像提取草稿」,先到「设置 › 功能开关 › 写入」里打开它。"
1448
- : raw.includes("no-route")
1449
- ? "没有可用的模型路由,提取不了草稿。"
1450
- : raw.includes("no-input")
1451
- ? "记忆还太少,提取不出画像。"
1452
- : raw;
1453
- }
1454
- return {};
1455
- },
1456
-
1457
- async mode(scope, event) {
1458
- resetNotice();
1459
- const next = String(valueOf(event, ["mode", "value"], "standard") ?? "standard");
1460
- if (next !== "standard" && next !== "light") {
1461
- state.err = "未知的模式。";
1462
- return {};
1463
- }
1464
- try {
1465
- await call("/mode", { method: "PUT", body: { mode: next } });
1466
- notice(`已切到${next === "light" ? "轻量" : "标准"}模式。`);
1467
- } catch (error) {
1468
- state.err = errorText(error);
1469
- }
1470
- return {};
1471
- },
1472
-
1473
- async reclaim() {
1474
- resetNotice();
1475
- try {
1476
- const res = await call("/maintenance/reclaim");
1477
- const report = res?.report ?? res ?? {};
1478
- const rows = Array.isArray(report.candidates) ? report.candidates.length : undefined;
1479
- const total = rows ?? report.reclaimable ?? report.total ?? report.rows ?? null;
1480
- state.reclaimText =
1481
- total === null ? "已检查(dry-run,未改动)。" : `可回收 ${String(total)} 条(dry-run,未改动)。`;
1482
- } catch (error) {
1483
- state.reclaimText = errorText(error);
1484
- }
1485
- return {};
1486
- },
1487
-
1488
- "reclaim-prepare"() {
1489
- resetNotice();
1490
- state.reclaimConfirm = true;
1491
- state.reclaimText = "回收会丢弃无法再召回的条目,确认后再执行。";
1492
- return {};
1493
- },
1494
-
1495
- async "reclaim-run"(scope, event) {
1496
- resetNotice();
1497
- if (valueOf(event, ["confirmed"], false) !== true) return {};
1498
- try {
1499
- const res = await call("/maintenance/reclaim", {
1500
- method: "POST",
1501
- body: { confirm: true, vacuum: true },
1502
- });
1503
- const report = res?.report ?? res ?? {};
1504
- state.reclaimText = `已回收 ${String(report.reclaimed ?? report.total ?? 0)} 条。`;
1505
- state.reclaimConfirm = false;
1506
- } catch (error) {
1507
- state.reclaimText = errorText(error);
1508
- }
1509
- return {};
1510
- },
1511
-
1512
- "conflict-pick-row"(scope, event) {
1513
- resetNotice();
1514
- state.conflictId = String(valueOf(event, ["id"], "") ?? "");
1515
- conflictBy.set(scopeKey(scope), state.conflictId);
1516
- state.conflictPick = "";
1517
- state.page = "conflict";
1518
- return {};
1519
- },
1520
-
1521
- "conflict-pick"(scope, event) {
1522
- resetNotice();
1523
- const picked = String(valueOf(event, ["winner", "value"], "") ?? "");
1524
- state.conflictPick = picked === "a" || picked === "b" ? picked : "none";
1525
- state.page = "conflict";
1526
- return {};
1527
- },
1528
-
1529
- async "conflict-win"(scope, event) {
1530
- resetNotice();
1531
- if (valueOf(event, ["confirmed"], false) !== true) return {};
1532
- const id = state.conflictId;
1533
- if (id === "") return { toast: "先选一条冲突" };
1534
- // 冲突 id 也是共享状态:只裁决本面板自己选中的那条(评审 #3)。
1535
- if (conflictBy.get(scopeKey(scope)) !== id) {
1536
- return { toast: "这条不是当前打开的冲突,已取消" };
1537
- }
1538
- const winner = state.conflictPick === "a" ? "a" : state.conflictPick === "b" ? "b" : null;
1539
- try {
1540
- await call("/conflicts/resolve", { method: "POST", body: { id, winner, apply: true } });
1541
- conflictBy.delete(scopeKey(scope));
1542
- state.conflictId = "";
1543
- state.conflictPick = "";
1544
- notice("已按裁决合并。");
1545
- } catch (error) {
1546
- state.err = errorText(error);
1547
- }
1548
- return {};
1549
- },
1550
-
1551
- async "wb-restore"(scope, event) {
1552
- resetNotice();
1553
- const id = String(valueOf(event, ["id"], "") ?? "");
1554
- if (id === "") return {};
1555
- try {
1556
- await call("/update", { method: "POST", body: { id, archived: false } });
1557
- notice("已恢复这条记忆。");
1558
- } catch (error) {
1559
- state.err = errorText(error);
1560
- }
1561
- return {};
1562
- },
1563
-
1564
- "cmd-pick"(scope, event) {
1565
- resetNotice();
1566
- state.sCmdPick = String(valueOf(event, ["value"], "") ?? "");
1567
- state.page = "settings";
1568
- return {};
1569
- },
1570
-
1571
- "cmd-add"(scope, event) {
1572
- resetNotice();
1573
- const value = event?.value;
1574
- const source =
1575
- value !== null && typeof value === "object" && !Array.isArray(value)
1576
- ? { sCmdName: value.sCmdName, sCmdDesc: value.sCmdDesc, sCmdIns: value.sCmdIns }
1577
- : {};
1578
- const name = String(source.sCmdName ?? state.sCmdName ?? "").trim();
1579
- const description = String(source.sCmdDesc ?? state.sCmdDesc ?? "").trim();
1580
- const instruction = String(source.sCmdIns ?? state.sCmdIns ?? "").trim();
1581
- if (name === "" || instruction === "") return { toast: "命令名和指令正文都不能空" };
1582
- return call("/commands", { method: "POST", body: { name, description, instruction } }).then(
1583
- () => {
1584
- state.sCmdName = "";
1585
- state.sCmdDesc = "";
1586
- state.sCmdIns = "";
1587
- notice(`已添加命令 ${name}。`);
1588
- return {};
1589
- },
1590
- (error) => {
1591
- state.err = errorText(error);
1592
- return {};
1593
- },
1594
- );
1595
- },
1596
-
1597
- async "cmd-delete"(scope, event) {
1598
- resetNotice();
1599
- if (valueOf(event, ["confirmed"], false) !== true) return {};
1600
- const id = state.sCmdPick;
1601
- if (id === "") return { toast: "先选一条命令" };
1602
- try {
1603
- await call(`/commands?id=${enc(id)}`, { method: "DELETE" });
1604
- state.sCmdPick = "";
1605
- notice("已删除命令。");
1606
- } catch (error) {
1607
- state.err = errorText(error);
1608
- }
1609
- return {};
1610
- },
1611
-
1612
- "vector-toggle"(scope, event) {
1613
- resetNotice();
1614
- // 没读到真实配置时 state.vector 只是默认值,整体 PUT 会覆盖服务端(评审 #2)。
1615
- if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" };
1616
- const value = event?.value;
1617
- state.vector.enabled = typeof value === "boolean" ? value : !state.vector.enabled;
1618
- return call("/vector-config", {
1619
- method: "PUT",
1620
- body: { enabled: state.vector.enabled, baseUrl: state.vector.baseUrl, model: state.vector.model },
1621
- }).then(
1622
- () => {
1623
- notice(`向量检索:${state.vector.enabled ? "已开启" : "已关闭"}`);
1624
- return {};
1625
- },
1626
- (error) => {
1627
- state.err = errorText(error);
1628
- return {};
1629
- },
1630
- );
1631
- },
1632
-
1633
- "vector-save"(scope, event) {
1634
- resetNotice();
1635
- if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" };
1636
- const value = event?.value;
1637
- const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1638
- const baseUrl = String(source.sVecUrl ?? state.vector.baseUrl ?? "").trim();
1639
- const model = String(source.sVecModel ?? state.vector.model ?? "").trim();
1640
- state.vector.baseUrl = baseUrl;
1641
- state.vector.model = model;
1642
- return call("/vector-config", {
1643
- method: "PUT",
1644
- body: { enabled: state.vector.enabled, baseUrl, model },
1645
- }).then(
1646
- () => {
1647
- notice("已保存向量配置。");
1648
- return {};
1649
- },
1650
- (error) => {
1651
- state.err = errorText(error);
1652
- return {};
1653
- },
1654
- );
1655
- },
1656
-
1657
- "vector-reindex"() {
1658
- resetNotice();
1659
- const run = call("/vector-reindex", { timeoutMs: 60000 }).then(
1660
- (res) => {
1661
- notice(`索引完成:新增 ${String(res?.indexed ?? 0)} 条,跳过 ${String(res?.skipped ?? 0)} 条。`);
1662
- return {};
1663
- },
1664
- (error) => {
1665
- state.err = errorText(error);
1666
- return {};
1667
- },
1668
- );
1669
- return {
1670
- pending: { label: "正在重建向量索引", timeoutMs: 60000 },
1671
- result: async () => run,
1672
- };
1673
- },
1674
-
1675
- "external-toggle"(scope, event) {
1676
- resetNotice();
1677
- if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" };
1678
- const value = event?.value;
1679
- state.external.enabled = typeof value === "boolean" ? value : !state.external.enabled;
1680
- return call("/external-api", {
1681
- method: "PUT",
1682
- body: { enabled: state.external.enabled, port: state.external.port, host: state.external.host },
1683
- }).then(
1684
- () => {
1685
- notice(`外部访问:${state.external.enabled ? "已开启" : "已关闭"}`);
1686
- return {};
1687
- },
1688
- (error) => {
1689
- state.err = errorText(error);
1690
- return {};
1691
- },
1692
- );
1693
- },
1694
-
1695
- "external-save"(scope, event) {
1696
- resetNotice();
1697
- if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" };
1698
- const value = event?.value;
1699
- const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1700
- const port = Math.round(num(source.sExtPort ?? state.external.port, 8790));
1701
- const host = String(source.sExtHost ?? state.external.host ?? "").trim();
1702
- if (port < 1 || port > 65535) return { toast: "端口要在 1–65535 之间" };
1703
- if (host === "" || host.includes("://")) return { toast: "监听地址不能带协议前缀" };
1704
- state.external.port = port;
1705
- state.external.host = host;
1706
- return call("/external-api", {
1707
- method: "PUT",
1708
- body: { enabled: state.external.enabled, port, host },
1709
- }).then(
1710
- () => {
1711
- notice("已保存外部访问配置。");
1712
- return {};
1713
- },
1714
- (error) => {
1715
- state.err = errorText(error);
1716
- return {};
1717
- },
1718
- );
1719
- },
1720
-
1721
- async "check-version"() {
1722
- resetNotice();
1723
- try {
1724
- const res = await call("/version-check");
1725
- const status = String(res?.status ?? "unknown");
1726
- state.versionNote =
1727
- status === "outdated"
1728
- ? `有新版本 ${String(res?.latest ?? "")}(当前 ${String(res?.version ?? "")})`
1729
- : status === "latest"
1730
- ? `已是最新版本 ${String(res?.version ?? "")}`
1731
- : "没能连上 npm registry,稍后再试。";
1732
- if (status === "outdated" && res?.latest) notice(`mneme 有新版本 ${res.latest}`);
1733
- } catch (error) {
1734
- state.versionNote = errorText(error);
1735
- }
1736
- return {};
1737
- },
1738
- };
1739
-
1740
- // One action per feature switch: the app tells us which action fired, and the
1741
- // new value arrives as `event.value` (boolean) or is derived by flipping the
1742
- // current effective value.
1743
- for (const key of FLAG_LABELS.keys()) {
1744
- actions[flagAction(key)] = async (scope, event) => {
1745
- resetNotice();
1746
- const value = event?.value;
1747
- // state.flags 是宿主原样的扁平点号键(scopeState.enabled);嵌套形态只
1748
- // 出现在视图绑定里(payload.flags = nestFlags(effective))。这里若按嵌套
1749
- // 去取值会恒为 undefined,于是「已经打开的开关」永远翻转不回去(评审 #7)。
1750
- const current = state.flags?.[key];
1751
- // 布尔项翻转、枚举项(chorographyInject 的 light/full/off)直接用宿主给的
1752
- // 字符串值:同一个动作 id 同时服务开关与下拉。
1753
- const next =
1754
- typeof value === "boolean" || typeof value === "string"
1755
- ? value
1756
- : typeof current === "string"
1757
- ? current
1758
- : !(current === true);
1759
- try {
1760
- await call("/features", { method: "PUT", body: { [key]: next } });
1761
- const features = await call("/features");
1762
- state.flagOverrides = features?.overrides ?? {};
1763
- state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
1764
- state.flags = features?.effective ?? state.flags;
1765
- state.settingsDirty = true;
1766
- notice(
1767
- `${FLAG_LABELS.get(key) ?? key}:${
1768
- typeof next === "string" ? next : next ? "已开启" : "已关闭"
1769
- }`,
1770
- );
1771
- } catch (error) {
1772
- state.err = errorText(error);
1773
- }
1774
- // 开关只改一个绑定值:数据已经推过去了,别让 App 重建视图(否则设置页
1775
- // 会被弹回第一个页签)。
1776
- return { refresh: false };
1777
- };
1778
- }
1779
-
1780
- // ---- 「Agent 眼中的你」自动事实(0.11.0 的面板卡) ----------------------
1781
- // 写入只有一个入口:POST /profile-facts/apply,op 形状与自动轮共用(add 必须
1782
- // 带 content、update/archive 必须带 id)。这里不自己拼 SQL,也不碰 user_settings
1783
- // ——手填画像仍然优先。
1784
- const applyFacts = (ops) =>
1785
- call("/profile-facts/apply", { method: "POST", body: { agent: state.factsAgent, ops } });
1786
-
1787
- Object.assign(actions, {
1788
- "facts-agent"(scope, event) {
1789
- resetNotice();
1790
- state.factsAgent = String(valueOf(event, ["value", "agent"], "") ?? "");
1791
- state.factEdit = null;
1792
- factBy.delete(scopeKey(scope));
1793
- return {};
1794
- },
1795
-
1796
- "facts-edit"(scope, event) {
1797
- resetNotice();
1798
- const id = String(valueOf(event, ["id"], "") ?? "");
1799
- const fact = state.factsItems.find((item) => String(item.id) === id);
1800
- if (fact === undefined) return { toast: "这条事实已经不在了" };
1801
- state.factEdit = { id, content: String(fact.content ?? "") };
1802
- factBy.set(scopeKey(scope), id);
1803
- return {};
1804
- },
1805
-
1806
- "facts-add"(scope, event) {
1807
- resetNotice();
1808
- const value = event?.value;
1809
- const source = value !== null && typeof value === "object" ? value : {};
1810
- const text = String(source.sFactNew ?? state.form.factNew ?? "").trim();
1811
- if (text === "") return { toast: "写一句话再添加" };
1812
- return applyFacts([{ op: "add", content: text }]).then(
1813
- (result) => {
1814
- state.form.factNew = "";
1815
- notice(`已加 ${String(result?.applied ?? 1)} 条事实。`);
1816
- return {};
1817
- },
1818
- (error) => {
1819
- state.err = errorText(error);
1820
- return {};
1821
- },
1822
- );
1823
- },
1824
-
1825
- "facts-save"(scope, event) {
1826
- resetNotice();
1827
- const target = state.factEdit;
1828
- if (target === null) return { toast: "先点一条事实" };
1829
- if (factBy.get(scopeKey(scope)) !== target.id) {
1830
- return { toast: "这条不是当前打开的事实,已取消" };
1831
- }
1832
- const value = event?.value;
1833
- const source = value !== null && typeof value === "object" ? value : {};
1834
- const text = String(source.sFactEdit ?? target.content ?? "").trim();
1835
- if (text === "") return { toast: "事实正文不能空" };
1836
- if (text === target.content) return { toast: "没有改动" };
1837
- return applyFacts([{ op: "update", id: target.id, content: text }]).then(
1838
- (result) => {
1839
- const failed = Array.isArray(result?.failures) ? result.failures : [];
1840
- if (failed.length > 0) {
1841
- state.err = "这条事实已被改过或删掉了,刷新后再试。";
1842
- return {};
1843
- }
1844
- state.factEdit = null;
1845
- factBy.delete(scopeKey(scope));
1846
- notice("事实已更新。");
1847
- return {};
1848
- },
1849
- (error) => {
1850
- state.err = errorText(error);
1851
- return {};
1852
- },
1853
- );
1854
- },
1855
-
1856
- "facts-archive"(scope, event) {
1857
- resetNotice();
1858
- if (valueOf(event, ["confirmed"], false) !== true) return {};
1859
- const target = state.factEdit;
1860
- if (target === null) return { toast: "先点一条事实" };
1861
- if (factBy.get(scopeKey(scope)) !== target.id) {
1862
- return { toast: "这条不是当前打开的事实,已取消" };
1863
- }
1864
- return applyFacts([{ op: "archive", id: target.id }]).then(
1865
- () => {
1866
- state.factEdit = null;
1867
- factBy.delete(scopeKey(scope));
1868
- notice("已归档这条事实。");
1869
- return {};
1870
- },
1871
- (error) => {
1872
- state.err = errorText(error);
1873
- return {};
1874
- },
1875
- );
1876
- },
1877
-
1878
- "facts-run"(scope, event) {
1879
- resetNotice();
1880
- // 维护轮要跑一次 LLM(模型读最近记忆、提 op、落库),慢且不可并发,所以
1881
- // 走 pending:面板先挂一条「正在更新」,最长等 60 秒。
1882
- const run = call("/profile-facts/run", {
1883
- method: "POST",
1884
- body: { agent: state.factsAgent },
1885
- timeoutMs: 60000,
1886
- }).then(
1887
- (receipt) => {
1888
- const status = String(receipt?.status ?? "ok");
1889
- state.factsRunText =
1890
- status === "ok"
1891
- ? `本轮改了 ${String(receipt?.opsApplied ?? 0)} 条事实。`
1892
- : status === "noop"
1893
- ? "没有新记忆可读,这轮没动。"
1894
- : `这轮结束了:${status}`;
1895
- notice("事实维护轮已结束。");
1896
- return { toast: "事实已更新" };
1897
- },
1898
- (error) => {
1899
- const message = errorText(error);
1900
- // 409 = 功能关着,503 = 没配模型,502 = 模型答得不成形:三种都得说人话。
1901
- state.err = /\b409\b/u.test(message)
1902
- ? "「Agent 眼中的你」还关着:先在上面把开关打开。"
1903
- : /\b503\b/u.test(message)
1904
- ? "还没配后台模型:先在 mneme 设置里选一个 dream 模型。"
1905
- : message;
1906
- return { toast: "这轮没成功" };
1907
- },
1908
- );
1909
- return { pending: { label: "正在维护事实", timeoutMs: 60000 }, result: async () => run };
1910
- },
1911
-
1912
- // ---- 代码库地志(chorography)-----------------------------------------
1913
- "choro-repo"(scope, event) {
1914
- resetNotice();
1915
- state.choroRepo = String(valueOf(event, ["value"], "") ?? "");
1916
- state.choroPageText = "";
1917
- return {};
1918
- },
1919
-
1920
- "choro-page"(scope, event) {
1921
- resetNotice();
1922
- const slug = String(valueOf(event, ["slug"], "") ?? "");
1923
- if (slug === "" || state.choroRepo === "") return { toast: "先选一个仓库" };
1924
- return call(`/choro/page?repo=${encodeURIComponent(state.choroRepo)}&slug=${encodeURIComponent(slug)}`).then(
1925
- (res) => {
1926
- const page = res?.page;
1927
- state.choroPageText =
1928
- page === undefined
1929
- ? "这一页读不出来,可能刚被重建过。"
1930
- : clip(String(page?.content ?? page?.relPath ?? ""), 1600);
1931
- notice(`已读取 ${String(page?.title ?? slug)} 的骨架页。`);
1932
- return {};
1933
- },
1934
- (error) => {
1935
- const message = errorText(error);
1936
- state.err = /\b409\b/u.test(message) ? "索引还在、盘上没了:点「重建骨架」重跑一次。" : message;
1937
- return {};
1938
- },
1939
- );
1940
- },
1941
-
1942
- "choro-run"(scope, event) {
1943
- resetNotice();
1944
- if (valueOf(event, ["confirmed"], false) !== true) return {};
1945
- if (state.choroRepo === "") return { toast: "还没有登记任何仓库" };
1946
- const run = call("/choro/run", {
1947
- method: "POST",
1948
- body: { repo: state.choroRepo },
1949
- timeoutMs: 60000,
1950
- }).then(
1951
- (res) => {
1952
- const receipt = res?.receipt ?? {};
1953
- state.choroPageText = "";
1954
- notice(
1955
- `骨架已重建:${String(receipt.pagesWritten ?? receipt.modules ?? 0)} 页` +
1956
- (receipt.t1Loss === true ? "(已富化的语义段被退回骨架)" : ""),
1957
- );
1958
- return { toast: "重建完成" };
1959
- },
1960
- (error) => {
1961
- state.err = errorText(error);
1962
- return { toast: "重建失败" };
1963
- },
1964
- );
1965
- return { pending: { label: "正在重建骨架", timeoutMs: 60000 }, result: async () => run };
1966
- },
1967
- });
1968
-
1969
- // 未收录的布尔旗标(「其它」组里的数据行):行里带着 key,点一下翻转。
1970
- actions["flag-set"] = async (scope, event) => {
1971
- resetNotice();
1972
- const key = String(valueOf(event, ["key", "value"], "") ?? "");
1973
- if (key === "") {
1974
- state.err = "没有拿到要切换的键。";
1975
- return {};
1976
- }
1977
- const next = state.flags?.[key] !== true;
1978
- try {
1979
- await call("/features", { method: "PUT", body: { [key]: next } });
1980
- const features = await call("/features");
1981
- state.flagOverrides = features?.overrides ?? {};
1982
- state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
1983
- state.flags = features?.effective ?? state.flags;
1984
- state.settingsDirty = true;
1985
- notice(`${key}:${next ? "已开启" : "已关闭"}`);
1986
- } catch (error) {
1987
- state.err = errorText(error);
1988
- }
1989
- return { refresh: false };
1990
- };
1991
-
1992
- // 只改绑定值的动作不申请重画视图。App 收到 refresh 会重新拉一次视图树,
1993
- // 而顶层 tabs 的选中页签是 App 的本地状态:树一重建就回到第一个页签,用户在
1994
- // 设置页翻开关会被弹回记忆页。这些动作的结果都在 data 里(开关值、列表行、
1995
- // 文案),推一次数据就够了。
1996
- const VALUE_ONLY = new Set([
1997
- "mode",
1998
- "vector-toggle",
1999
- "vector-save",
2000
- "external-toggle",
2001
- "external-save",
2002
- "profile-save",
2003
- "facts-agent",
2004
- "facts-edit",
2005
- "facts-add",
2006
- "facts-save",
2007
- "facts-archive",
2008
- "facts-run",
2009
- "flag-set",
2010
- "choro-repo",
2011
- "choro-page",
2012
- "choro-run",
2013
- ]);
2014
-
2015
- // Every action may change what the panel shows, so push fresh data before
2016
- // asking the app to re-render (the refresh effect alone would re-render a
2017
- // stale feed).
2018
- const wrapped = {};
2019
- for (const [id, handler] of Object.entries(actions)) {
2020
- wrapped[id] = async (scope, event) => {
2021
- let result;
2022
- try {
2023
- result = await handler(scope, event);
2024
- } catch (error) {
2025
- state.err = errorText(error);
2026
- result = undefined;
2027
- }
2028
- // 默认重画视图:多数动作会改变「哪些节点存在」(进子页、空态、列表)。
2029
- // 只改一个绑定值的动作(开关、档位、外部访问的开关)要显式关掉重画:
2030
- // App 的 refresh 会让它重新拉一次视图树,而页签选择是 App 的本地状态,
2031
- // 重建后回到第一个页签——用户正在设置页翻开关,会被弹回记忆页。
2032
- const wanted = result ?? {};
2033
- const skip = wanted.refresh === false || VALUE_ONLY.has(id) || id.startsWith("flag.");
2034
- const effect = { ...wanted, refresh: !skip };
2035
- // pending 动作(维护轮、地志重建)把真正的活儿放在 result 里,handler
2036
- // 返回时它还没跑:此时立刻 broadcast 推出去的仍是这一轮之前的事实与
2037
- // 地志列表。改成等 result 跑完再推(评审 #15)。
2038
- if (typeof wanted.result === "function") {
2039
- const inner = wanted.result;
2040
- effect.result = async (...args) => {
2041
- try {
2042
- return await inner(...args);
2043
- } finally {
2044
- await broadcast();
2045
- }
2046
- };
2047
- return effect;
2048
- }
2049
- await broadcast();
2050
- return effect;
2051
- };
2052
- }
2053
-
2054
- // ----------------------------------------------------------------- panel
2055
- ctx.dshGo.panel("mneme", {
2056
- view() {
2057
- return buildView(state);
2058
- },
2059
- data(scope, emit) {
2060
- emitters.add(emit);
2061
- // 挂载时读全:之后每个动作只重读当前页那一块(见 load 的注释)。
2062
- load({ full: true }).then(
2063
- (payload) => {
2064
- if (emitters.has(emit)) emit(payload);
2065
- },
2066
- (error) => {
2067
- if (emitters.has(emit)) emit({ notice: state.notice, err: errorText(error) });
2068
- },
2069
- );
2070
- return () => {
2071
- emitters.delete(emit);
2072
- };
2073
- },
2074
- actions: wrapped,
2075
- });
2076
- }
2077
-
2078
- /**
2079
- * 清掉模块级状态与缓存。在引擎重新挂载面板、以及测试之间切换场景时用它:
2080
- * 这些表(列表缓存、按 scope 打开的记忆、提示语)是模块级的,跨 mount 复用会
2081
- * 把上一个场景的残留带进下一个。
2082
- */
2083
- export function resetPanelState() {
2084
- cache.clear();
2085
- emitters.clear();
2086
- openedBy.clear();
2087
- conflictBy.clear();
2088
- factBy.clear();
2089
- lastPayload = null;
2090
- Object.assign(state, defaultState());
2091
- }
1
+ // local-mneme-panel — a dsh-go native panel over the installed dsh-mneme store.
2
+ //
3
+ // The panel never talks HTTP: src/dsh-go/host.js dispatches mneme's own registered
4
+ // route handlers inside this engine process. Reads are mneme's own GET routes;
5
+ // writes go through mneme's own routes (create/update/delete/features/profile/
6
+ // rules/mode/conflicts/commands/vector/external/reclaim), so its apiToken policy
7
+ // still applies if one is ever set.
8
+ import { createHostCall } from "./host.js";
9
+ import { buildView } from "./views/root.js";
10
+ import {
11
+ FLAG_LABELS,
12
+ RANGE_DAYS,
13
+ TYPE_LABELS,
14
+ clip,
15
+ errorText,
16
+ flagAction,
17
+ fmtDay,
18
+ fmtMonth,
19
+ fmtTime,
20
+ joinTags,
21
+ scopeText,
22
+ splitTags,
23
+ entityIcon,
24
+ entityTypeLabel,
25
+ typeIcon,
26
+ typeLabel,
27
+ typeTone,
28
+ } from "./text.js";
29
+
30
+ const PAGE_SIZE = 20;
31
+ const cache = new Map();
32
+ // Live panel feeds, one emit per open panel/locale. dsh-go feeds are push-based:
33
+ // data() runs once and later state changes must be pushed through these emits
34
+ // (a `refresh` effect only re-renders the view; it never re-runs data()).
35
+ const emitters = new Set();
36
+ // 每个面板 scope 打开过哪条记忆。删除确认(confirm 节点)不携带任何参数,
37
+ // 只能靠状态决定删谁;状态又是所有面板共用的,所以按 scope 记一份,删除前
38
+ // 与本面板打开的那条核对,不匹配就拒绝(评审 #5)。
39
+ const openedBy = new Map();
40
+ // 冲突页的裁决同样改数据,而 state.conflictId 是模块级共享的:两个面板
41
+ // 各看一条冲突时,后开的会盖掉前一个的 id。按面板 scope 记一份再核对
42
+ // (评审 #3 的 conflict-win 部分)。
43
+ const conflictBy = new Map();
44
+ // 「Agent 眼中的你」的编辑态 state.factEdit 同样是共享的;按面板 scope 记
45
+ // 一份打开的事实 id,保存与归档前核对(评审 #3 的 facts 部分)。
46
+ const factBy = new Map();
47
+ // 上一次推给 App 的完整数据:未重新读取的块从这里并回去,保证每份 data 都
48
+ // 带着全部绑定键(见 load 的注释)。
49
+ let lastPayload = null;
50
+ const scopeKey = (scope) => [scope?.sessionId ?? "", scope?.turnId ?? "", scope?.callId ?? ""].join("|");
51
+
52
+ const defaultState = () => ({
53
+ page: "list", // list|detail|editor|entity|conflict|settings|profile
54
+ flagGroup: "basic",
55
+ extraRows: [], // 上游新增、面板还没中文名的布尔旗标
56
+ factsCards: [],
57
+ factsAgent: "",
58
+ factsRows: [],
59
+ factsItems: [],
60
+ factEdit: null,
61
+ factsRunText: "",
62
+ choroRepos: [],
63
+ choroRepo: "",
64
+ choroRows: [],
65
+ choroNote: "",
66
+ choroPageText: "",
67
+ settingsView: "settings", // settings|status
68
+ sPanel: "menu", // menu|workbench|commands|vector|external|help|facts|choro
69
+ profileTab: "profile",
70
+ q: "",
71
+ type: "all",
72
+ range: "all",
73
+ minImportance: 0,
74
+ scope: "all",
75
+ semantic: false,
76
+ viewMode: "flat",
77
+ heatSort: false,
78
+ filtersOpen: false,
79
+ offset: 0,
80
+ size: PAGE_SIZE,
81
+ current: null,
82
+ form: {},
83
+ // 编辑页打开时的那份快照:只有和它不同的字段才会写回 /update,
84
+ // 并且用它挡住「别处已经改过这条记忆」(评审 #4)。
85
+ editBase: null,
86
+ entityTotal: -1,
87
+ entityTypeTotal: 0,
88
+ entityQuery: "",
89
+ entityName: "",
90
+ entityDepth: 1,
91
+ entityRelationCount: 0,
92
+ entityMemoryCount: 0,
93
+ flags: {},
94
+ flagOverrides: {},
95
+ pendingRestart: [],
96
+ pendingConflicts: 0,
97
+ pendingIds: new Set(),
98
+ rowsCount: 0,
99
+ total: 0, // 当前筛选下的总条数,翻页时用它算页数
100
+ monthGroups: [],
101
+ detailEntities: [],
102
+ detailEntityCount: 0,
103
+ detailConflict: false,
104
+ provenanceText: "",
105
+ conflictCount: 0,
106
+ conflictId: "",
107
+ conflictPick: "",
108
+ conflictA: null,
109
+ conflictB: null,
110
+ conflictReady: false,
111
+ wbKv: [],
112
+ wbDeposited: [],
113
+ wbArchived: [],
114
+ commands: [],
115
+ cmdCount: 0,
116
+ sCmdPick: "",
117
+ sCmdName: "",
118
+ sCmdDesc: "",
119
+ sCmdIns: "",
120
+ vector: { enabled: false, baseUrl: "", model: "" },
121
+ external: { enabled: false, port: 8790, host: "127.0.0.1" },
122
+ // 读成功才允许整体写回:读失败时上面的 vector/external 会落成默认值,
123
+ // 此时 PUT 会把用户在 Web 面板里配好的地址/端口覆盖掉(评审 #2)。
124
+ vectorLoaded: false,
125
+ externalLoaded: false,
126
+ // 实体搜索要重读的是「实体清单」而不是整页;load() 按这个标记补读
127
+ // (评审 #4)。
128
+ entityDirty: false,
129
+ // 顶层页签由 App 本地切换,state.page 可能还停在 "list";设置项的动作
130
+ // 改完数据后也要重读设置块,否则推到面板上的仍是旧值(评审 #6)。
131
+ settingsDirty: false,
132
+ versionNote: "",
133
+ extractText: "",
134
+ injectPreviewText: "",
135
+ tokenCategories: [],
136
+ tokenSeries: [],
137
+ tokenKv: [],
138
+ heatSeries: [],
139
+ heatNote: "",
140
+ reclaimText: "尚未检查。",
141
+ reclaimConfirm: false,
142
+ notice: "",
143
+ noticeTone: "ok",
144
+ err: "",
145
+ });
146
+
147
+ const state = defaultState();
148
+
149
+ function resetNotice() {
150
+ state.notice = "";
151
+ state.err = "";
152
+ }
153
+
154
+ function notice(text, tone = "ok") {
155
+ state.notice = clip(text, 160);
156
+ state.noticeTone = tone;
157
+ }
158
+
159
+ /** Read the resolved action arguments/values the app sends with an invoke. */
160
+ function valueOf(event, keys, fallback) {
161
+ const value = event?.value;
162
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
163
+ for (const key of keys) {
164
+ if (value[key] !== undefined && value[key] !== null) return value[key];
165
+ }
166
+ }
167
+ if (typeof value === "string" && value !== "") return value;
168
+ if (typeof value === "boolean") return value;
169
+ return fallback;
170
+ }
171
+
172
+ function num(value, fallback) {
173
+ const n = Number(value);
174
+ return Number.isFinite(n) ? n : fallback;
175
+ }
176
+
177
+ function rowOf(row) {
178
+ const heat = typeof row.heat === "number" ? row.heat : Number.NaN;
179
+ const pending = state.pendingIds.has(row.id);
180
+ const marks = [];
181
+ if (row.archived === true) marks.push("已归档");
182
+ if (pending) marks.push("待确认");
183
+ return {
184
+ title: clip(row.title, 60),
185
+ subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at ?? row.created_at)}${marks.length === 0 ? "" : ` · ${marks.join(" · ")}`}`,
186
+ trailing: Number.isFinite(heat) ? `★${row.importance ?? 1} · 热度 ${heat.toFixed(2)}` : `★${row.importance ?? 1}`,
187
+ ...(typeIcon(row.type) === undefined ? {} : { icon: typeIcon(row.type) }),
188
+ tone: typeTone(row.type),
189
+ action: { id: "open", args: { id: row.id } },
190
+ };
191
+ }
192
+
193
+ /** Flat dotted flag keys -> nested object so `data.flags.a.b` bindings resolve. */
194
+ function nestFlags(effective) {
195
+ const out = {};
196
+ for (const [key, value] of Object.entries(effective ?? {})) {
197
+ const parts = key.split(".");
198
+ let node = out;
199
+ for (const part of parts.slice(0, -1)) {
200
+ if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
201
+ node = node[part];
202
+ }
203
+ // 原样透传:开关是布尔,枚举型旗标(chorographyInject 的 light/full/off)
204
+ // 是字符串。以前这里一律写成 `value === true`,下拉永远只看到 true/false。
205
+ node[parts[parts.length - 1]] = value;
206
+ }
207
+ return out;
208
+ }
209
+
210
+ const enc = (value) => encodeURIComponent(String(value ?? ""));
211
+
212
+ /**
213
+ * Mount the panel on a context that already provides the dsh-go `dshGo` service.
214
+ * Called by src/index.js from `ctx.inject(['dshGo'], ...)`: without the dsh-go
215
+ * bridge (desktop / headless hosts) this module never runs.
216
+ */
217
+ export function mountDshGoPanel(ctx, options = {}) {
218
+ // options.apiToken 由 src/index.js 传入:路由注册器给 token / token-write
219
+ // 路由包了鉴权,进程内调用不带这个头就会被自家 API 拦成 401(评审 #2)。
220
+ const call = createHostCall(ctx, options);
221
+
222
+ // ---------------------------------------------------------------- loaders
223
+ function groupByMonth(rows, raw) {
224
+ const buckets = new Map();
225
+ rows.forEach((row, index) => {
226
+ const label = fmtMonth(raw[index]?.updated_at ?? raw[index]?.created_at);
227
+ if (!buckets.has(label)) buckets.set(label, []);
228
+ buckets.get(label).push(row);
229
+ });
230
+ return [...buckets.entries()].map(([label, list]) => ({ label, rows: list }));
231
+ }
232
+
233
+ async function loadMemory() {
234
+ const statsPromise = call("/recall-stats?window=30").catch(() => ({}));
235
+ const conflictsPromise = call("/conflicts").catch(() => ({}));
236
+ const dreamPromise = call("/dream-status").catch(() => ({}));
237
+ const payload = {
238
+ query: state.q,
239
+ filter: state.type,
240
+ fRange: state.range,
241
+ fMin: String(state.minImportance),
242
+ fScope: state.scope,
243
+ fSemantic: state.semantic,
244
+ fView: state.viewMode,
245
+ fHeat: state.heatSort,
246
+ };
247
+ let items = [];
248
+ if (state.q !== "") {
249
+ const mode = state.semantic ? "hybrid" : "auto";
250
+ const res = await call(`/search?q=${enc(state.q)}&topK=20&mode=${mode}`);
251
+ // /search 没有 type 参数(它是全库语义检索),类型筛选只能在拿到结果后
252
+ // 再收一遍,否则搜索会把用户选好的类型丢掉(评审 #8)。
253
+ const hits = res?.items ?? [];
254
+ items = state.type === "all" || state.type === "" ? hits : hits.filter((item) => item?.type === state.type);
255
+ payload.total = String(items.length);
256
+ // 空结果不再靠一块 emptyState 表达:那个结构在首屏就定了,数据晚一步
257
+ // 到达时就会和真实列表同时出现。文案跟着数据走,结构不跟着动。
258
+ payload.pageText =
259
+ items.length === 0
260
+ ? "搜索无结果"
261
+ : `搜索 · ${items.length} 条`;
262
+ } else {
263
+ const query = new URLSearchParams({
264
+ limit: String(state.size),
265
+ offset: String(state.offset),
266
+ order: "chrono",
267
+ });
268
+ if (state.type !== "all" && state.type !== "") query.set("type", state.type);
269
+ if (state.minImportance > 0) query.set("minImportance", String(state.minImportance));
270
+ const days = RANGE_DAYS[state.range];
271
+ if (days !== undefined) query.set("updatedFrom", new Date(Date.now() - days * 86400000).toISOString());
272
+ if (state.scope === "deposited") query.set("deposited", "only");
273
+ if (state.scope === "archived") query.set("archived", "only");
274
+ if (state.scope === "foreign") query.set("source", "imported");
275
+ const res = await call(`/list?${query.toString()}`);
276
+ items = res?.items ?? [];
277
+ const total = num(res?.total, items.length);
278
+ const pages = Math.max(1, Math.ceil(total / state.size));
279
+ const pageNo = Math.min(pages, Math.floor(state.offset / state.size) + 1);
280
+ payload.total = String(total);
281
+ // 这行文案同时是左胶囊的标签,列宽只有约 142dp,越长越容易折行。
282
+ payload.pageText = total === 0 ? "没有匹配的记忆" : `${total} 条 · ${pageNo}/${pages} 页`;
283
+ }
284
+ for (const row of items) cache.set(row.id, row);
285
+ const dream = await dreamPromise;
286
+ state.pendingIds = new Set(Array.isArray(dream?.pendingMemoryIds) ? dream.pendingMemoryIds : []);
287
+ const ordered = state.heatSort ? [...items].sort((a, b2) => num(b2.heat, -1) - num(a.heat, -1)) : items;
288
+ payload.rows = ordered.map(rowOf);
289
+ state.monthGroups = groupByMonth(ordered, ordered);
290
+ const [stats, conflicts] = await Promise.all([statsPromise, conflictsPromise]);
291
+ payload.injected = String(stats?.injection?.injectedCount ?? 0);
292
+ payload.slotRate = String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10);
293
+ payload.conflicts = String(conflicts?.total ?? 0);
294
+ // metric values render in one style, so units ride along in the value
295
+ payload.totalText = `${payload.total ?? "0"} 条`;
296
+ payload.injectedText = `${payload.injected} 次`;
297
+ payload.slotRateText = `${payload.slotRate}%`;
298
+ payload.conflictsText = `${payload.conflicts} 条`;
299
+ state.pendingConflicts = num(conflicts?.total, 0);
300
+ payload.pendingConflicts = state.pendingConflicts;
301
+ // 翻页用的是 chips(原生最小 48dp,两个一定同行),点完立刻把绑定清空,
302
+ // 免得选中的那枚一直带着勾。
303
+ payload.pagePick = "";
304
+ state.rowsCount = (payload.rows ?? []).length;
305
+ state.total = num(payload.total, state.rowsCount);
306
+ return payload;
307
+ }
308
+
309
+ async function loadDetail() {
310
+ const memo = state.current ?? {};
311
+ const entities = await call(`/memories/entities?memoryId=${enc(memo.id ?? "")}`).then((res) => res?.entities ?? []);
312
+ state.detailEntityCount = entities.length;
313
+ state.detailConflict = state.pendingIds.has(memo.id);
314
+ return {
315
+ detailTitle: clip(memo.title ?? "(无标题)", 120),
316
+ detailMeta: [
317
+ { label: "类型", value: typeLabel(memo.type) },
318
+ { label: "重要度", value: `★${memo.importance ?? 1}` },
319
+ { label: "归属", value: scopeText(memo) },
320
+ { label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) },
321
+ { label: "标签", value: clip(joinTags(memo.tags) || "—", 40) },
322
+ { label: "来源", value: clip(memo.source ?? "—", 40) },
323
+ { label: "状态", value: memo.archived ? "已归档" : "正常", tone: memo.archived ? "muted" : "good" },
324
+ ],
325
+ detailContent: clip(memo.content ?? "", 2000),
326
+ detailType: typeLabel(memo.type),
327
+ detailTypeTone: typeTone(memo.type) ?? "info",
328
+ detailStars: `重要度 ★${memo.importance ?? 1}`,
329
+ detailEntities: entities.slice(0, 8).map((item) => ({
330
+ title: clip(item.name ?? item.title ?? item.id ?? "—", 40),
331
+ subtitle: clip(item.type ?? "实体", 20),
332
+ icon: "user",
333
+ })),
334
+ detailEntityCount: entities.length,
335
+ detailConflict: state.detailConflict,
336
+ provenanceText: state.provenanceText,
337
+ };
338
+ }
339
+
340
+ function loadEditor() {
341
+ return {
342
+ form: {
343
+ id: state.form.id ?? "",
344
+ title: state.form.title ?? "",
345
+ content: state.form.content ?? "",
346
+ type: state.form.type ?? "project",
347
+ importance: num(state.form.importance, 3),
348
+ tags: state.form.tags ?? "",
349
+ agentScope: state.form.agentScope ?? "",
350
+ workspaceScope: state.form.workspaceScope ?? "",
351
+ },
352
+ };
353
+ }
354
+
355
+ async function loadEntity() {
356
+ const needle = state.entityQuery.trim();
357
+ // 名字过滤交给宿主(/entities?q=):本地过滤只能看见 limit 那一条切片,
358
+ // 排在后面的实体搜不到(评审 #9)。类型数只在不搜索时重算——它代表整库
359
+ // 有几种类型,不该随搜索词一起抖动。
360
+ const [directory, matched] = await Promise.all([
361
+ needle === "" ? null : call("/entities?limit=200").catch(() => null),
362
+ call(`/entities?limit=${needle === "" ? 200 : 30}${needle === "" ? "" : `&q=${enc(needle)}`}`),
363
+ ]);
364
+ const all = directory?.entities ?? matched?.entities ?? [];
365
+ state.entityTotal = num(matched?.total, all.length);
366
+ if (needle === "") {
367
+ const types = new Set();
368
+ for (const item of all) {
369
+ if (typeof item?.type === "string" && item.type !== "") types.add(item.type);
370
+ }
371
+ state.entityTypeTotal = types.size;
372
+ }
373
+ const shown = (needle === "" ? all : (matched?.entities ?? [])).slice(0, 30);
374
+ return {
375
+ entityTotal: String(state.entityTotal),
376
+ entityTypeTotal: String(state.entityTypeTotal),
377
+ entityTotalText: `${String(state.entityTotal)} 个`,
378
+ entityTypeTotalText: `${String(state.entityTypeTotal)} 类`,
379
+ entityQuery: state.entityQuery,
380
+ // 结构固定,空库的提示走这一行文案(见 views/entity.js 的注释)。
381
+ entityStatus:
382
+ shown.length === 0
383
+ ? needle === ""
384
+ ? "还没有实体 · 在「设置 › 记忆引擎」的功能开关里打开「抽取人物与项目」"
385
+ : `没有名字含「${clip(needle, 20)}」的实体`
386
+ : needle === ""
387
+ ? `共 ${String(state.entityTotal)} 个实体`
388
+ : `搜索「${clip(needle, 20)}」· ${String(shown.length)} 个`,
389
+ entities: shown.map((item) => ({
390
+ title: clip(item.name ?? item.title ?? item.id ?? "—", 40),
391
+ subtitle: clip(entityTypeLabel(item.type ?? "—"), 20),
392
+ trailing: item.mention_count === undefined ? undefined : `${item.mention_count} 次`,
393
+ ...(entityIcon(item.type) === undefined ? {} : { icon: entityIcon(item.type) }),
394
+ action: { id: "open-entity", args: { name: String(item.name ?? "") } },
395
+ })),
396
+ };
397
+ }
398
+
399
+ async function loadEntityDetail() {
400
+ const name = state.entityName;
401
+ const ego = await call(`/semantic/graph/ego?entity=${enc(name)}&depth=${state.entityDepth}&limit=40`);
402
+ const attrs = await call(`/semantic/graph/entity-attrs?entity=${enc(name)}`).catch(() => null);
403
+ const nodes = Array.isArray(ego?.nodes) ? ego.nodes : [];
404
+ const byId = new Map(nodes.map((node) => [node.id, node]));
405
+ const root = ego?.root ?? {};
406
+ const relations = (Array.isArray(ego?.edges) ? ego.edges : []).slice(0, 30).map((edge) => {
407
+ const from = byId.get(edge.from_entity);
408
+ const to = byId.get(edge.to_entity);
409
+ const other = edge.from_entity === root.id ? edge.to_entity : edge.from_entity;
410
+ const otherNode = byId.get(other);
411
+ return {
412
+ title: `${clip(from?.name ?? "—", 16)} → ${clip(to?.name ?? "—", 16)}`,
413
+ subtitle: clip(edge.relation_type ?? "关系", 24),
414
+ tone: "info",
415
+ ...(otherNode === undefined
416
+ ? {}
417
+ : { action: { id: "open-entity", args: { name: String(otherNode.name ?? "") } } }),
418
+ };
419
+ });
420
+ const memories = new Map();
421
+ for (const edge of Array.isArray(ego?.edges) ? ego.edges : []) {
422
+ if (typeof edge.memory_id === "string" && edge.memory_id !== "") {
423
+ const memo = cache.get(edge.memory_id);
424
+ memories.set(edge.memory_id, {
425
+ title: clip(memo?.title ?? `记忆 ${edge.memory_id.slice(0, 8)}`, 40),
426
+ subtitle: memo === undefined ? "未在本地缓存" : typeLabel(memo.type),
427
+ action: { id: "open", args: { id: edge.memory_id } },
428
+ });
429
+ }
430
+ }
431
+ state.entityRelationCount = relations.length;
432
+ state.entityMemoryCount = memories.size;
433
+ return {
434
+ entityName: clip(root.name ?? name, 40),
435
+ entityType: clip(entityTypeLabel(root.type ?? "实体"), 20),
436
+ entityMentionText: `被提到 ${String(root.mention_count ?? 0)} 次 · 关系 ${relations.length} 条`,
437
+ entityDepth: String(state.entityDepth),
438
+ entityAttrs: (Array.isArray(attrs?.attrs) ? attrs.attrs : []).slice(0, 24).map((attr) => ({
439
+ label: clip(attr.key ?? "—", 30),
440
+ value: clip(String(attr.value ?? "—"), 60),
441
+ })),
442
+ entityRelations: relations,
443
+ entityMemories: [...memories.values()].slice(0, 20),
444
+ entityRelationCount: relations.length,
445
+ entityMemoryCount: memories.size,
446
+ entityHint: "点关系可以跳到相邻实体;点记忆可以跳到详情。",
447
+ };
448
+ }
449
+
450
+ async function loadSettings() {
451
+ const [features, mode] = await Promise.all([call("/features"), call("/mode")]);
452
+ const effective = features?.effective ?? {};
453
+ state.flags = effective;
454
+ state.flagOverrides = features?.overrides ?? {};
455
+ state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
456
+ const changed = Object.keys(state.flagOverrides).length;
457
+ const pending = state.pendingRestart.length;
458
+ // 上游不断加旗标:没给中文名的布尔项仍要出现在面板上,否则新功能在手机上
459
+ // 静默消失。数值/字符串键(模型名、预算)不放这里——它们需要输入框,不是开关。
460
+ state.extraRows = Object.keys(effective)
461
+ .filter((key) => !FLAG_LABELS.has(key) && typeof effective[key] === "boolean")
462
+ .sort()
463
+ .map((key) => ({
464
+ title: key,
465
+ subtitle: effective[key] === true ? "已开启 · 点一下关闭" : "已关闭 · 点一下开启",
466
+ icon: effective[key] === true ? "toggle-on" : "toggle-off",
467
+ action: { id: "flag-set", args: { key } },
468
+ }));
469
+ const payload = {
470
+ extraRows: state.extraRows,
471
+ settingsView: state.settingsView,
472
+ sPanel: state.sPanel,
473
+ flagGroup: state.flagGroup,
474
+ flags: nestFlags(effective),
475
+ mode: String(mode?.mode ?? "standard"),
476
+ flagsMeta: `已改 ${changed} 项 · 待重启生效 ${pending} 项(改动即时写回 mneme,重启引擎后彻底生效)`,
477
+ };
478
+ if (state.sPanel === "menu") return payload;
479
+ if (state.sPanel === "workbench") {
480
+ const [deposited, archived] = await Promise.all([
481
+ call("/list?deposited=only&limit=8&order=chrono").catch(() => ({})),
482
+ call("/list?archived=only&limit=5&order=chrono").catch(() => ({})),
483
+ ]);
484
+ const depItems = deposited?.items ?? [];
485
+ const arcItems = archived?.items ?? [];
486
+ for (const row of [...depItems, ...arcItems]) cache.set(row.id, row);
487
+ state.wbDeposited = depItems.map((row) => ({
488
+ title: clip(row.title, 50),
489
+ subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at)}`,
490
+ action: { id: "open", args: { id: row.id } },
491
+ }));
492
+ state.wbArchived = arcItems.map((row) => ({
493
+ title: clip(row.title, 50),
494
+ subtitle: fmtTime(row.updated_at),
495
+ trailing: "点按恢复",
496
+ action: { id: "wb-restore", args: { id: row.id } },
497
+ }));
498
+ Object.assign(payload, {
499
+ wbKv: [
500
+ { label: "已沉淀", value: `${String(deposited?.total ?? depItems.length)} 条` },
501
+ { label: "已归档", value: `${String(archived?.total ?? arcItems.length)} 条` },
502
+ ],
503
+ wbDeposited: state.wbDeposited,
504
+ wbArchived: state.wbArchived,
505
+ });
506
+ } else if (state.sPanel === "commands") {
507
+ const res = await call("/commands").catch(() => ({ commands: [] }));
508
+ state.commands = Array.isArray(res?.commands) ? res.commands : [];
509
+ state.cmdCount = state.commands.length;
510
+ Object.assign(payload, {
511
+ cmdRows: state.commands.map((cmd) => ({
512
+ title: clip(cmd.name ?? cmd.id ?? "—", 30),
513
+ subtitle: clip(cmd.description ?? "", 60),
514
+ })),
515
+ cmdCount: state.cmdCount,
516
+ sCmdPick: state.sCmdPick,
517
+ sCmdName: state.sCmdName,
518
+ sCmdDesc: state.sCmdDesc,
519
+ sCmdIns: state.sCmdIns,
520
+ });
521
+ } else if (state.sPanel === "vector") {
522
+ const res = await call("/vector-config").catch(() => null);
523
+ // 读到才算 loaded:读失败时下面会落成默认值,不能再拿它整体覆盖
524
+ // 服务端配置(评审 #2)。
525
+ state.vectorLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object";
526
+ const config = res?.config ?? {};
527
+ state.vector = {
528
+ enabled: config.enabled === true,
529
+ baseUrl: String(config.baseUrl ?? ""),
530
+ model: String(config.model ?? ""),
531
+ };
532
+ const keySet = typeof config.apiKey === "string" && config.apiKey !== "";
533
+ Object.assign(payload, {
534
+ sVecEnabled: state.vector.enabled,
535
+ sVecUrl: state.vector.baseUrl,
536
+ sVecModel: state.vector.model,
537
+ vectorKv: [
538
+ {
539
+ label: "状态",
540
+ value: state.vector.enabled ? "已启用" : "未启用",
541
+ tone: state.vector.enabled ? "good" : "muted",
542
+ },
543
+ { label: "接口", value: state.vector.baseUrl === "" ? "未配置" : clip(state.vector.baseUrl, 60) },
544
+ { label: "模型", value: state.vector.model === "" ? "未配置" : state.vector.model },
545
+ { label: "密钥", value: keySet ? "已配置(面板不显示)" : "未配置" },
546
+ ],
547
+ vectorNote: "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。",
548
+ });
549
+ } else if (state.sPanel === "external") {
550
+ // 读一次真实配置,否则面板永远拿默认值渲染,保存时又把默认值写回去,
551
+ // 把用户已经在 Web 面板里配好的端口/地址覆盖掉(评审 #6)。
552
+ // 必须带 token=omit:不带的话这条 GET 会顺手生成并落库一个 Bearer。
553
+ const res = await call("/external-api?token=omit").catch(() => null);
554
+ state.externalLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object";
555
+ const config = res?.config ?? {};
556
+ state.external = {
557
+ enabled: config.enabled === true,
558
+ port: num(config.port, 8790),
559
+ host: typeof config.host === "string" && config.host !== "" ? config.host : "127.0.0.1",
560
+ };
561
+ Object.assign(payload, {
562
+ sExtEnabled: state.external.enabled,
563
+ sExtPort: String(state.external.port),
564
+ sExtHost: state.external.host,
565
+ });
566
+ } else if (state.sPanel === "facts") {
567
+ // 「Agent 眼中的你」:一次请求喂饱这张卡(facts / journal / runs)。flag 关着
568
+ // 也照读——历史上开过又关掉的库里事实还在,得让人看得见。
569
+ const query = state.factsAgent === "" ? "" : `?agent=${encodeURIComponent(state.factsAgent)}`;
570
+ const facts = await call(`/profile-facts${query}`).catch(() => ({}));
571
+ state.factsCards = Array.isArray(facts?.cards) ? facts.cards.map(String) : [];
572
+ const rows = Array.isArray(facts?.facts) ? facts.facts : [];
573
+ state.factsItems = rows;
574
+ const active = rows.filter((fact) => fact.status !== "archived");
575
+ state.factsRows = rows.slice(0, 20).map((fact) => ({
576
+ title: clip(String(fact.content ?? ""), 60),
577
+ subtitle:
578
+ fact.status === "archived"
579
+ ? `已归档 · ${fmtTime(fact.updated_at)}`
580
+ : `更新于 ${fmtTime(fact.updated_at)}`,
581
+ trailing: fact.status === "archived" ? "已归档" : "点一下修改",
582
+ action: { id: "facts-edit", args: { id: String(fact.id ?? "") } },
583
+ }));
584
+ const runs = Array.isArray(facts?.runs) ? facts.runs : [];
585
+ const last = runs[0];
586
+ state.factsRunText =
587
+ last === undefined
588
+ ? "还没跑过维护轮。点「立即更新」会让模型读最近的记忆补事实。"
589
+ : `最近一轮:${String(last.status ?? "?")} · 改 ${String(last.ops_applied ?? 0)} 条 · 读 ${String(last.input_count ?? 0)} 条记忆 · ${fmtTime(last.started_at)}`;
590
+ Object.assign(payload, {
591
+ factsAgent: state.factsAgent,
592
+ factsCardOptions: [
593
+ { label: "默认", value: "" },
594
+ // 宿主给的 cards 里常含空串(默认卡),别再和「默认」重复一次。
595
+ ...state.factsCards.filter((key) => key !== "").map((key) => ({ label: key, value: key })),
596
+ ],
597
+ factsRows: state.factsRows,
598
+ factsRunText: state.factsRunText,
599
+ sFactNew: state.form.factNew ?? "",
600
+ sFactEdit: state.factEdit?.content ?? "",
601
+ factsEditHint:
602
+ state.factEdit === null
603
+ ? ""
604
+ : `正在修改:${clip(String(state.factEdit.content ?? ""), 40)}`,
605
+ });
606
+ if (active.length === 0) state.factsRows.push({ title: "还没有事实", subtitle: "点下面的「立即更新」让模型生成一批。" });
607
+ } else if (state.sPanel === "choro") {
608
+ // 地志是「按仓库」的:先列已登记仓库,再列该仓库的模块骨架页。
609
+ const repos = await call("/choro/repos").catch(() => ({}));
610
+ state.choroRepos = (Array.isArray(repos?.repos) ? repos.repos : []).map((repo) => ({
611
+ id: String(repo.id ?? ""),
612
+ name: clip(String(repo.name ?? repo.root_path ?? repo.id ?? ""), 40),
613
+ }));
614
+ if (state.choroRepo === "" && state.choroRepos.length > 0) state.choroRepo = state.choroRepos[0].id;
615
+ const known = state.choroRepos.some((repo) => repo.id === state.choroRepo);
616
+ if (!known) state.choroRepo = state.choroRepos[0]?.id ?? "";
617
+ let note = "先在地志里登记仓库(命令行 dsh-mneme-choro <repo> --apply)。";
618
+ let rows = [];
619
+ if (state.choroRepo !== "") {
620
+ const map = await call(`/choro/modules?repo=${encodeURIComponent(state.choroRepo)}`).catch(() => ({}));
621
+ const modules = Array.isArray(map?.modules) ? map.modules : [];
622
+ rows = modules.slice(0, 30).map((item) => ({
623
+ title: clip(String(item.title ?? item.slug ?? ""), 40),
624
+ subtitle: `${String(item.file_count ?? 0)} 个文件 · ${String(item.page_status ?? "无页")}`,
625
+ trailing: "看页",
626
+ action: { id: "choro-page", args: { slug: String(item.slug ?? "") } },
627
+ }));
628
+ const repo = state.choroRepos.find((item) => item.id === state.choroRepo);
629
+ const baseline = map?.repo?.gitBaseline;
630
+ note =
631
+ modules.length === 0
632
+ ? "这个仓库还没有骨架页:点下面的「重建骨架」生成一次。"
633
+ : `${modules.length} 个模块${baseline ? ` · 基线 ${String(baseline).slice(0, 7)}` : ""} · ${repo?.name ?? ""}`;
634
+ }
635
+ state.choroRows = rows;
636
+ state.choroNote = note;
637
+ Object.assign(payload, {
638
+ choroRepo: state.choroRepo,
639
+ choroRepoOptions:
640
+ state.choroRepos.length === 0
641
+ ? [{ label: "还没有登记任何仓库", value: "" }]
642
+ : state.choroRepos.map((repo) => ({ label: repo.name, value: repo.id })),
643
+ choroRows: state.choroRows,
644
+ choroNote: state.choroNote,
645
+ choroPageText: state.choroPageText,
646
+ });
647
+ } else if (state.sPanel === "help") {
648
+ const info = await call("/info").catch(() => ({}));
649
+ Object.assign(payload, {
650
+ helpKv: [
651
+ { label: "插件", value: `@modusensus/dsh-mneme ${String(info?.version ?? "—")}` },
652
+ { label: "记忆库", value: "本机 SQLite(与本机 Web 面板同一份,不跨设备同步)" },
653
+ { label: "面板", value: "dsh-go 原生面板" },
654
+ ],
655
+ versionNote: state.versionNote,
656
+ });
657
+ }
658
+ return payload;
659
+ }
660
+
661
+ async function loadProfile() {
662
+ if (state.profileTab === "persona") {
663
+ const res = await call("/persona");
664
+ const persona = res?.persona ?? {};
665
+ const preview = res?.preview ?? {};
666
+ return {
667
+ personaText: clip(
668
+ JSON.stringify({ default: persona.default ?? {}, byAgent: persona.byAgent ?? {} }, null, 2),
669
+ 700,
670
+ ),
671
+ previewItems: [
672
+ { label: "生效来源", value: String(preview.source ?? "—") },
673
+ { label: "注入内容", value: clip(preview.text === "" ? "(空)" : preview.text, 300) },
674
+ ],
675
+ };
676
+ }
677
+ if (state.profileTab === "rules") {
678
+ const res = await call("/rules");
679
+ return { editText: clip(JSON.stringify(res?.rules ?? [], null, 2), 2000) };
680
+ }
681
+ const res = await call("/profile");
682
+ return { editText: clip(res?.profile ?? "", 2000), extractText: state.extractText };
683
+ }
684
+
685
+ async function loadConflict() {
686
+ const res = await call("/conflicts");
687
+ const items = Array.isArray(res?.items) ? res.items : [];
688
+ state.conflictCount = num(res?.total, items.length);
689
+ const rows = items.slice(0, 20).map((item) => ({
690
+ title: clip(item.a?.title ?? item.b?.title ?? item.id ?? "—", 40),
691
+ subtitle: `A:${clip(item.a?.title ?? "—", 20)} · B:${clip(item.b?.title ?? "—", 20)}`,
692
+ tone: "warn",
693
+ action: { id: "conflict-pick-row", args: { id: String(item.id ?? "") } },
694
+ }));
695
+ const selected = items.find((item) => item.id === state.conflictId) ?? null;
696
+ state.conflictA = selected?.a ?? null;
697
+ state.conflictB = selected?.b ?? null;
698
+ state.conflictReady = selected !== null;
699
+ const side = (memo) =>
700
+ memo === null
701
+ ? []
702
+ : [
703
+ { label: "类型", value: typeLabel(memo.type) },
704
+ { label: "重要度", value: `★${memo.importance ?? 1}` },
705
+ { label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) },
706
+ ];
707
+ return {
708
+ conflictRows: rows,
709
+ conflictCount: state.conflictCount,
710
+ conflictReady: state.conflictReady,
711
+ conflictPick: state.conflictPick,
712
+ conflictATitle: clip(state.conflictA?.title ?? "", 120),
713
+ conflictAMeta: side(state.conflictA),
714
+ conflictAContent: clip(state.conflictA?.content ?? "", 800),
715
+ conflictBTitle: clip(state.conflictB?.title ?? "", 120),
716
+ conflictBMeta: side(state.conflictB),
717
+ conflictBContent: clip(state.conflictB?.content ?? "", 800),
718
+ pendingConflicts: state.conflictCount,
719
+ };
720
+ }
721
+
722
+ async function loadStatus() {
723
+ const [info, health, dream, inject, stats, conflicts, list, entities, mode, preview, audit, features, recent] =
724
+ await Promise.all([
725
+ call("/info").catch(() => ({})),
726
+ call("/health").catch(() => ({})),
727
+ call("/dream-status").catch(() => ({})),
728
+ call("/inject-status").catch(() => ({})),
729
+ call("/recall-stats?window=30").catch(() => ({})),
730
+ call("/conflicts").catch(() => ({})),
731
+ call("/list?limit=1").catch(() => ({})),
732
+ call("/entities?limit=1").catch(() => ({})),
733
+ call("/mode").catch(() => ({})),
734
+ call("/inject-preview").catch(() => ({})),
735
+ call("/semantic/llm-audit/stats?days=28").catch(() => ({})),
736
+ call("/features").catch(() => ({})),
737
+ call("/list?limit=200&order=chrono").catch(() => ({})),
738
+ ]);
739
+ const run = dream?.lastRun ?? {};
740
+ const mirror = health?.mirror ?? {};
741
+ const snapshot = preview?.snapshot ?? null;
742
+ state.injectPreviewText =
743
+ snapshot === null
744
+ ? "还没有渲染过注入预览:下一轮对话开始时 mneme 会写一份快照。"
745
+ : clip(typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot), 500);
746
+ const byDay = Array.isArray(audit?.by_day) ? audit.by_day : [];
747
+ state.tokenCategories = byDay.map((day) => fmtDay(day.day));
748
+ state.tokenSeries = byDay.length === 0 ? [] : [{ name: "调用", values: byDay.map((day) => num(day.count, 0)) }];
749
+ state.tokenKv = [
750
+ { label: "调用次数", value: `${String(audit?.total_calls ?? 0)} 次` },
751
+ { label: "Token", value: String(audit?.total_tokens ?? 0) },
752
+ { label: "估算成本", value: `$${Number(audit?.total_cost_usd ?? 0).toFixed(4)}` },
753
+ ];
754
+ const heatEnabled = (features?.effective ?? {}).heatEnabled === true;
755
+ const heated = (recent?.items ?? []).filter((row) => typeof row.heat === "number");
756
+ if (heatEnabled && heated.length > 0) {
757
+ const hot = heated.filter((row) => row.heat >= 0.7).length;
758
+ const warm = heated.filter((row) => row.heat >= 0.3 && row.heat < 0.7).length;
759
+ state.heatSeries = [{ name: "条数", values: [hot, warm, heated.length - hot - warm] }];
760
+ state.heatNote = `统计最近 ${heated.length} 条记忆的热度。`;
761
+ } else {
762
+ state.heatSeries = [];
763
+ state.heatNote = "mneme 没有开启“热度衰减”,所以看不到热度分布。";
764
+ }
765
+ state.pendingConflicts = num(conflicts?.total, 0);
766
+ return {
767
+ // metric cards live on 状态, mirroring mneme's own Status page
768
+ totalText: `${String(list?.total ?? 0)} 条`,
769
+ injectedText: `${String(stats?.injection?.injectedCount ?? 0)} 次`,
770
+ slotRateText: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%`,
771
+ conflictsText: `${String(conflicts?.total ?? 0)} 条`,
772
+ statusKv: [
773
+ { label: "mneme 版本", value: String(info?.version ?? "—") },
774
+ { label: "记忆", value: `${String(list?.total ?? 0)} 条` },
775
+ { label: "实体", value: `${String(entities?.total ?? 0)} 个` },
776
+ { label: "运行模式", value: String(mode?.mode ?? "—") },
777
+ { label: "镜像", value: String(mirror.status ?? "—"), tone: mirror.status === "ok" ? "good" : "warn" },
778
+ { label: "上次成功", value: fmtTime(mirror.success_at) },
779
+ ],
780
+ injectKv: [
781
+ {
782
+ label: "自动注入",
783
+ value: inject?.autoInject ? "已开启" : "已关闭",
784
+ tone: inject?.autoInject ? "good" : "muted",
785
+ },
786
+ { label: "注入抑制", value: inject?.suppressed ? "是" : "否" },
787
+ { label: "会话预设", value: String(inject?.agentPreset ?? "—") },
788
+ { label: "近 30 天注入", value: `${String(stats?.injection?.injectedCount ?? 0)} 次` },
789
+ { label: "命中率", value: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%` },
790
+ ],
791
+ topRows: (stats?.topRecalled ?? []).slice(0, 5).map((row) => ({
792
+ title: clip(row.title, 40),
793
+ subtitle: typeLabel(row.type),
794
+ trailing: `${row.count} 次`,
795
+ })),
796
+ dreamKv: [
797
+ { label: "上次巩固", value: fmtTime(dream?.lastConsolidation) },
798
+ { label: "上次运行", value: `${fmtTime(run.created_at)} ${run.status ?? "—"}` },
799
+ {
800
+ label: "待确认",
801
+ value: `${String(conflicts?.total ?? 0)} 条`,
802
+ tone: num(conflicts?.total, 0) > 0 ? "warn" : "good",
803
+ },
804
+ ],
805
+ runRows: (dream?.runs ?? []).slice(0, 5).map((row) => ({
806
+ title: `${fmtTime(row.created_at)} · ${String(row.status ?? "—")}`,
807
+ subtitle: clip(row.summary ?? row.mode ?? "", 40),
808
+ })),
809
+ injectPreviewText: state.injectPreviewText,
810
+ tokenCategories: state.tokenCategories,
811
+ tokenSeries: state.tokenSeries,
812
+ tokenKv: state.tokenKv,
813
+ heatSeries: state.heatSeries,
814
+ heatNote: state.heatNote,
815
+ reclaimText: state.reclaimText,
816
+ reclaimConfirm: state.reclaimConfirm,
817
+ pendingConflicts: state.pendingConflicts,
818
+ };
819
+ }
820
+
821
+ // The app's `tabs` bar switches locally, so the payload always carries every
822
+ // section (a binding whose key is missing renders as 「—」). That does not mean
823
+ // every section must be re-read after every tap: 实体 / 设置 / 状态 only change
824
+ // while their own page is open, so a tap on the list used to cost four reads
825
+ // (评审 #14). Load the active page's section, keep the rest from the previous
826
+ // payload; a full read happens on mount.
827
+ async function load(options = {}) {
828
+ const full = options.full === true;
829
+ const payload = {
830
+ page: state.page,
831
+ profileTab: state.profileTab,
832
+ notice: state.notice,
833
+ err: state.err,
834
+ };
835
+ const jobs = [];
836
+ const merge = (promise) => jobs.push(promise.then((part) => Object.assign(payload, part)));
837
+ const page = state.page;
838
+ const memoryPage = !["entity", "settings", "profile"].includes(page);
839
+ if (full || memoryPage) {
840
+ const memory = page === "detail" ? loadDetail() : page === "editor" ? loadEditor() : loadMemory();
841
+ merge(Promise.resolve(memory));
842
+ }
843
+ const entityPage = page === "entity";
844
+ // 实体搜索要重读实体清单;但搜索后 state.page 仍是 "list"(视图里实体
845
+ // 区就是列表),旧条件只看页面,于是搜索完推的仍是搜索前那份(评审 #4)。
846
+ if (full || entityPage || state.entityDirty) merge(entityPage ? loadEntityDetail() : loadEntity());
847
+ state.entityDirty = false;
848
+ if (full || page === "settings" || page === "profile" || state.settingsDirty) {
849
+ merge(loadSettings());
850
+ merge(loadStatus());
851
+ }
852
+ state.settingsDirty = false;
853
+ if (full || (memoryPage && page === "conflict")) merge(loadConflict());
854
+ if (full || page === "profile") merge(loadProfile());
855
+ const settled = await Promise.allSettled(jobs);
856
+ const failed = settled.find((item) => item.status === "rejected");
857
+ if (failed !== undefined) payload.err = errorText(failed.reason);
858
+ // 未重新读取的块沿用上一次的完整结果;刷新过的那块覆盖上去。
859
+ const merged = lastPayload === null ? payload : { ...lastPayload, ...payload };
860
+ lastPayload = merged;
861
+ return merged;
862
+ }
863
+
864
+ /** Re-run the loaders and push the result into every live panel feed. */
865
+ async function broadcast() {
866
+ if (emitters.size === 0) return;
867
+ const payload = await load();
868
+ for (const emit of [...emitters]) {
869
+ try {
870
+ emit(payload);
871
+ } catch {
872
+ // a closing panel detaches its emitter; nothing to do
873
+ }
874
+ }
875
+ }
876
+
877
+ // ---------------------------------------------------------------- actions
878
+ function goMemory(page) {
879
+ state.page = page;
880
+ state.err = "";
881
+ }
882
+
883
+ function nextValue(list, current, value) {
884
+ const picked = typeof value === "string" && list.includes(value) ? value : null;
885
+ if (picked !== null) return picked;
886
+ const index = list.indexOf(current);
887
+ return list[(index + 1) % list.length];
888
+ }
889
+
890
+ async function applyArchived(archived, event, scope) {
891
+ const memo = state.current;
892
+ if (memo === null) return { toast: "先选一条记忆" };
893
+ // 归档/恢复改的是「正在看」的那一条,参数只能从状态里拿;状态是模块级
894
+ // 共享的,两个面板各开一条时后开的会盖掉前一个的 current。与删除同一条
895
+ // 规矩:只动本面板自己打开的那条,对不上就拒绝(评审 #10)。
896
+ const mine = openedBy.get(scopeKey(scope));
897
+ if (mine === undefined || mine !== memo.id) {
898
+ return { toast: "这条不是当前打开的记忆,已取消" };
899
+ }
900
+ try {
901
+ const res = await call("/update", { method: "POST", body: { id: mine, archived } });
902
+ if (res?.memory !== null && typeof res.memory === "object") {
903
+ state.current = res.memory;
904
+ openedBy.set(scopeKey(scope), String(res.memory.id ?? mine));
905
+ }
906
+ notice(archived ? "已归档。" : "已恢复。");
907
+ } catch (error) {
908
+ state.err = errorText(error);
909
+ }
910
+ return {};
911
+ }
912
+
913
+ // 编辑页的比对基准:点「编辑」时看到的那一份快照。冲突后必须用最新的
914
+ // 记忆重建它,否则下一次保存会因为 base 为 null 而整份提交、直接跳过版本
915
+ // 检查(评审 #11)。
916
+ function editBaseOf(memo) {
917
+ return {
918
+ title: String(memo.title ?? ""),
919
+ content: String(memo.content ?? ""),
920
+ importance: num(memo.importance, 3),
921
+ tags: joinTags(memo.tags),
922
+ agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "",
923
+ workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "",
924
+ updatedAt: typeof memo.updated_at === "string" ? memo.updated_at : "",
925
+ };
926
+ }
927
+
928
+ // 每次打开都回宿主重读,而不是先看 cache:cache 是列表加载时写下的,
929
+ // 可能已经几分钟没更新;编辑页会拿它预填表单,随后整份提交回 /update,
930
+ // 用陈旧内容覆盖掉工具或 Web 面板刚写进去的新值(评审 #4)。
931
+ // /list 支持按 id 取单条(归档行也取得到),不再用 limit=500 扫列表再拿缓存
932
+ // 兜底:500 条之外或已归档的条目根本扫不到,兜底等于拿旧值去比对 updated_at
933
+ // (评审 #12)。网络失败就让它抛,调用处会报错,绝不悄悄退回缓存。
934
+ async function openMemoryById(id) {
935
+ const res = await call(`/list?id=${encodeURIComponent(String(id))}`);
936
+ const row = (res?.items ?? [])[0];
937
+ if (row === undefined) {
938
+ cache.delete(id);
939
+ return undefined;
940
+ }
941
+ cache.set(row.id, row);
942
+ return row;
943
+ }
944
+
945
+ const actions = {
946
+ "switch-group"(scope, event) {
947
+ resetNotice();
948
+ const keys = ["basic", "record", "write", "workflow", "retrieval", "scope", "extra"];
949
+ state.flagGroup = nextValue(keys, state.flagGroup, valueOf(event, ["group", "value", "k"], ""));
950
+ state.page = "settings";
951
+ return {};
952
+ },
953
+
954
+ "switch-panel"(scope, event) {
955
+ resetNotice();
956
+ const keys = ["menu", "workbench", "commands", "vector", "external", "help", "facts", "choro"];
957
+ state.sPanel = nextValue(keys, state.sPanel, valueOf(event, ["value", "panel", "k"], ""));
958
+ state.page = "settings";
959
+ return {};
960
+ },
961
+
962
+ "open-panel"(scope, event) {
963
+ resetNotice();
964
+ // 功能开关已经铺在设置页正文里,不再有 flags 子页面(#dsh-go 面板对齐电脑端分组)
965
+ const keys = ["workbench", "commands", "vector", "external", "help", "facts", "choro"];
966
+ const wanted = String(valueOf(event, ["k", "panel", "value"], "") ?? "");
967
+ state.sPanel = keys.includes(wanted) ? wanted : "menu";
968
+ state.page = "settings";
969
+ // 子页面画在「设置」视图里:从「状态」视图点过来时若不把视图切回去,
970
+ // 看到的还是状态页,什么都没有发生(评审 #14)。
971
+ state.settingsView = "settings";
972
+ return {};
973
+ },
974
+
975
+ "switch-settings-view"(scope, event) {
976
+ resetNotice();
977
+ state.settingsView = nextValue(["settings", "status"], state.settingsView, valueOf(event, ["value"], ""));
978
+ if (state.settingsView === "settings") state.page = "settings";
979
+ return {};
980
+ },
981
+
982
+ "open-profile"(scope, event) {
983
+ resetNotice();
984
+ const wanted = String(valueOf(event, ["k", "tab", "value"], state.profileTab) ?? "");
985
+ state.profileTab = ["profile", "rules", "persona"].includes(wanted) ? wanted : "profile";
986
+ state.page = "profile";
987
+ return {};
988
+ },
989
+
990
+ "open-conflict"() {
991
+ resetNotice();
992
+ state.page = "conflict";
993
+ // 冲突页同样属于「设置」视图(入口在设置页的状态区里),视图停在状态
994
+ // 视图时切不到它(评审 #14)。
995
+ state.settingsView = "settings";
996
+ return {};
997
+ },
998
+
999
+ "clear-filter"() {
1000
+ resetNotice();
1001
+ state.q = "";
1002
+ state.type = "all";
1003
+ state.range = "all";
1004
+ state.minImportance = 0;
1005
+ state.scope = "all";
1006
+ state.semantic = false;
1007
+ state.heatSort = false;
1008
+ state.offset = 0;
1009
+ goMemory("list");
1010
+ return {};
1011
+ },
1012
+
1013
+ "set-range"(scope, event) {
1014
+ resetNotice();
1015
+ state.range = nextValue(["all", "7d", "30d", "90d"], state.range, valueOf(event, ["value"], ""));
1016
+ state.offset = 0;
1017
+ goMemory("list");
1018
+ return {};
1019
+ },
1020
+
1021
+ "set-minimp"(scope, event) {
1022
+ resetNotice();
1023
+ const picked = String(valueOf(event, ["value"], "0") ?? "0");
1024
+ state.minImportance = ["0", "3", "4", "5"].includes(picked) ? Number(picked) : 0;
1025
+ state.offset = 0;
1026
+ goMemory("list");
1027
+ return {};
1028
+ },
1029
+
1030
+ "set-scope"(scope, event) {
1031
+ resetNotice();
1032
+ state.scope = nextValue(["all", "deposited", "archived", "foreign"], state.scope, valueOf(event, ["value"], ""));
1033
+ state.offset = 0;
1034
+ goMemory("list");
1035
+ return {};
1036
+ },
1037
+
1038
+ "set-view"(scope, event) {
1039
+ resetNotice();
1040
+ state.viewMode = nextValue(["flat", "month"], state.viewMode, valueOf(event, ["value"], ""));
1041
+ return {};
1042
+ },
1043
+
1044
+ "toggle-semantic"(scope, event) {
1045
+ resetNotice();
1046
+ const value = event?.value;
1047
+ state.semantic = typeof value === "boolean" ? value : !state.semantic;
1048
+ return {};
1049
+ },
1050
+
1051
+ "toggle-heat"(scope, event) {
1052
+ resetNotice();
1053
+ const value = event?.value;
1054
+ state.heatSort = typeof value === "boolean" ? value : !state.heatSort;
1055
+ return {};
1056
+ },
1057
+
1058
+ async provenance() {
1059
+ resetNotice();
1060
+ const memo = state.current;
1061
+ if (memo === null) return { toast: "先选一条记忆" };
1062
+ try {
1063
+ const res = await call(`/memory/provenance?id=${enc(memo.id)}&limit=5`);
1064
+ const windows = Array.isArray(res?.windows) ? res.windows : [];
1065
+ if (windows.length === 0) {
1066
+ state.provenanceText = "mneme 里没有这条记忆的蒸馏来源记录。";
1067
+ } else {
1068
+ const first = windows[0] ?? {};
1069
+ state.provenanceText = clip(
1070
+ `${windows.length} 个来源窗口 · 最近来自会话 ${String(first.session_id ?? "—").slice(0, 12)}(${fmtTime(first.created_at)})`,
1071
+ 300,
1072
+ );
1073
+ }
1074
+ } catch (error) {
1075
+ state.provenanceText = errorText(error).includes("distill-records-disabled")
1076
+ ? "mneme 没开「保留蒸馏原文」,所以这条记忆没有可查的来源窗口。"
1077
+ : errorText(error);
1078
+ }
1079
+ return {};
1080
+ },
1081
+
1082
+ refresh() {
1083
+ resetNotice();
1084
+ return {};
1085
+ },
1086
+
1087
+ back() {
1088
+ resetNotice();
1089
+ state.err = "";
1090
+ if (state.page === "profile" || state.page === "conflict") {
1091
+ state.page = "settings";
1092
+ state.sPanel = "menu";
1093
+ } else if (state.page === "entity") {
1094
+ state.page = "list";
1095
+ } else if (state.page === "settings") {
1096
+ if (state.sPanel !== "menu") state.sPanel = "menu";
1097
+ else state.page = "list";
1098
+ } else if (state.page === "detail" || state.page === "editor") {
1099
+ state.page = "list";
1100
+ }
1101
+ return {};
1102
+ },
1103
+
1104
+ search(scope, event) {
1105
+ resetNotice();
1106
+ state.q = String(valueOf(event, ["query", "q", "text"], state.q) ?? "").trim();
1107
+ state.offset = 0;
1108
+ goMemory("list");
1109
+ return {};
1110
+ },
1111
+
1112
+ page(scope, event) {
1113
+ resetNotice();
1114
+ // chips 送来的是 "prev" / "next";早期的按钮组送 { d: ±1 },两条都认。
1115
+ const raw = valueOf(event, ["value", "d", "delta", "page"], 0);
1116
+ const delta = raw === "prev" ? -1 : raw === "next" ? 1 : num(raw, 0);
1117
+ const pages = Math.max(1, Math.ceil(num(state.total, 0) / state.size));
1118
+ const next = Math.min(pages, Math.max(1, Math.floor(state.offset / state.size) + 1 + delta));
1119
+ state.offset = (next - 1) * state.size;
1120
+ goMemory("list");
1121
+ return {};
1122
+ },
1123
+
1124
+ async open(scope, event) {
1125
+ resetNotice();
1126
+ const id = String(valueOf(event, ["id"], "") ?? "");
1127
+ if (id === "") return { toast: "这条记录没有 id" };
1128
+ let found;
1129
+ try {
1130
+ found = await openMemoryById(id);
1131
+ } catch (error) {
1132
+ state.err = errorText(error);
1133
+ return {};
1134
+ }
1135
+ if (found === undefined) {
1136
+ state.err = "这条记忆已经不在列表里了。";
1137
+ return {};
1138
+ }
1139
+ state.current = found;
1140
+ openedBy.set(scopeKey(scope), found.id);
1141
+ state.provenanceText = "尚未查看。点上面的按钮可向 mneme 查这条记忆的蒸馏来源。";
1142
+ goMemory("detail");
1143
+ return {};
1144
+ },
1145
+
1146
+ "open-entity"(scope, event) {
1147
+ resetNotice();
1148
+ const name = String(valueOf(event, ["name", "entity", "value"], "") ?? "");
1149
+ if (name === "") return { toast: "没有选中实体" };
1150
+ state.entityName = name;
1151
+ state.entityDepth = 1;
1152
+ state.page = "entity";
1153
+ return {};
1154
+ },
1155
+
1156
+ "entity-search"(scope, event) {
1157
+ resetNotice();
1158
+ state.entityQuery = String(valueOf(event, ["entityQuery", "query", "q"], "") ?? "").trim();
1159
+ state.page = "list";
1160
+ // 让下一次 load() 补读实体清单(页面仍是列表,见 load 里的注释)。
1161
+ state.entityDirty = true;
1162
+ return {};
1163
+ },
1164
+
1165
+ "entity-depth"(scope, event) {
1166
+ resetNotice();
1167
+ const picked = String(valueOf(event, ["value", "entityDepth"], "1") ?? "1");
1168
+ state.entityDepth = picked === "2" ? 2 : 1;
1169
+ return {};
1170
+ },
1171
+
1172
+ edit(scope) {
1173
+ resetNotice();
1174
+ const memo = state.current;
1175
+ if (memo === null) return { toast: "先选一条记忆" };
1176
+ // 记下本面板打开的那条:save 也要按它核对(评审 #3)。
1177
+ openedBy.set(scopeKey(scope), memo.id);
1178
+ state.form = {
1179
+ id: memo.id,
1180
+ title: memo.title ?? "",
1181
+ content: memo.content ?? "",
1182
+ type: memo.type ?? "project",
1183
+ importance: num(memo.importance, 3),
1184
+ tags: joinTags(memo.tags),
1185
+ agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "",
1186
+ workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "",
1187
+ };
1188
+ state.editBase = editBaseOf(memo);
1189
+ goMemory("editor");
1190
+ return {};
1191
+ },
1192
+
1193
+ "type-filter"(scope, event) {
1194
+ resetNotice();
1195
+ const next = String(valueOf(event, ["value", "type"], "all") ?? "all");
1196
+ state.type = next === "all" || TYPE_LABELS[next] !== undefined ? next : "all";
1197
+ state.offset = 0;
1198
+ goMemory("list");
1199
+ return {};
1200
+ },
1201
+
1202
+ "form-type"(scope, event) {
1203
+ const next = String(valueOf(event, ["value", "type"], state.form.type ?? "project") ?? "project");
1204
+ state.form.type = TYPE_LABELS[next] === undefined ? "project" : next;
1205
+ return {};
1206
+ },
1207
+
1208
+ new() {
1209
+ resetNotice();
1210
+ state.current = null;
1211
+ state.form = {
1212
+ id: "",
1213
+ title: "",
1214
+ content: "",
1215
+ type: "project",
1216
+ importance: 3,
1217
+ tags: "",
1218
+ agentScope: "",
1219
+ workspaceScope: "",
1220
+ };
1221
+ goMemory("editor");
1222
+ return {};
1223
+ },
1224
+
1225
+ async save(scope, event) {
1226
+ resetNotice();
1227
+ const value = event?.value;
1228
+ const source =
1229
+ value !== null && typeof value === "object" && !Array.isArray(value)
1230
+ ? { ...state.form, ...value }
1231
+ : { ...state.form };
1232
+ const title = String(source.title ?? "").trim();
1233
+ const content = String(source.content ?? "").trim();
1234
+ const type = TYPE_LABELS[String(source.type ?? "")] === undefined ? "project" : String(source.type);
1235
+ const importance = Math.min(5, Math.max(1, Math.round(num(source.importance, 3))));
1236
+ const tags = splitTags(String(source.tags ?? ""));
1237
+ if (title === "" || content === "") return { toast: "标题和正文都不能空" };
1238
+ const id = state.form.id === "" || state.form.id === undefined ? undefined : String(state.form.id);
1239
+ // 编辑既有条目也要核对面板 scope:state.form 是模块级共享的,两个面板
1240
+ // 各开一条时,A 面板点保存会把内容写进 B 刚打开的那条(评审 #3)。
1241
+ // 新建(id 为空)不涉及已有行,不核对。
1242
+ if (id !== undefined) {
1243
+ const mine = openedBy.get(scopeKey(scope));
1244
+ if (mine === undefined || mine !== id) {
1245
+ return { toast: "这条不是当前打开的记忆,已取消" };
1246
+ }
1247
+ }
1248
+
1249
+ if (id === undefined) {
1250
+ try {
1251
+ const res = await call("/create", { method: "POST", body: { title, content, importance, tags, type } });
1252
+ state.form = {};
1253
+ state.editBase = null;
1254
+ if (res !== null && typeof res === "object" && typeof res.id === "string") {
1255
+ state.current = res;
1256
+ openedBy.set(scopeKey(scope), res.id);
1257
+ goMemory("detail");
1258
+ notice(res.action === "merged" ? "已并入同标题的既有条目。" : "已新建一条记忆。");
1259
+ } else {
1260
+ goMemory("list");
1261
+ notice("已保存。");
1262
+ }
1263
+ } catch (error) {
1264
+ state.err = errorText(error);
1265
+ }
1266
+ return {};
1267
+ }
1268
+
1269
+ // 编辑既有条目:先回宿主要一份当前值。盘点窗口够长(跨了几分钟甚至几次
1270
+ // 对话)时,缓存里的那份可能已经被工具或 Web 面板改过,而这里回填的表单
1271
+ // 一旦整份提交就会把对方的写入盖掉;/update 没有版本号校验,所以这层
1272
+ // 比较只能放在面板侧(评审 #4)。
1273
+ let fresh;
1274
+ try {
1275
+ fresh = await openMemoryById(id);
1276
+ } catch (error) {
1277
+ state.err = errorText(error);
1278
+ return {};
1279
+ }
1280
+ if (fresh === undefined) {
1281
+ state.current = null;
1282
+ state.form = {};
1283
+ state.editBase = null;
1284
+ goMemory("list");
1285
+ notice("这条记忆已经不在了。", "warn");
1286
+ return {};
1287
+ }
1288
+ const base = state.editBase;
1289
+ if (base !== null && base.updatedAt !== "" && base.updatedAt !== String(fresh.updated_at ?? "")) {
1290
+ state.current = fresh;
1291
+ state.form = {
1292
+ id: fresh.id,
1293
+ title: fresh.title ?? "",
1294
+ content: fresh.content ?? "",
1295
+ type: fresh.type ?? "project",
1296
+ importance: num(fresh.importance, 3),
1297
+ tags: joinTags(fresh.tags),
1298
+ agentScope: typeof fresh.agent_scope === "string" ? fresh.agent_scope : "",
1299
+ workspaceScope: typeof fresh.workspace_scope === "string" ? fresh.workspace_scope : "",
1300
+ };
1301
+ state.editBase = editBaseOf(fresh);
1302
+ state.err = "这条记忆刚在别处被改过,已换成最新内容,请再确认一次。";
1303
+ return {};
1304
+ }
1305
+
1306
+ // 只提交真正改动的字段:把未改的 title/content 一起发回去,等于用旧值
1307
+ // 覆盖对方的写入(同一问题的一半)。
1308
+ const body = { id };
1309
+ if (base === null || title !== base.title) body.title = title;
1310
+ if (base === null || content !== base.content) body.content = content;
1311
+ if (base === null || importance !== base.importance) body.importance = importance;
1312
+ if (base === null || joinTags(tags) !== base.tags) body.tags = tags;
1313
+ const agentScope = String(source.agentScope ?? "").trim();
1314
+ const workspaceScope = String(source.workspaceScope ?? "").trim();
1315
+ if (base === null || agentScope !== base.agentScope) body.agent_scope = agentScope === "" ? null : agentScope;
1316
+ if (base === null || workspaceScope !== base.workspaceScope) {
1317
+ body.workspace_scope = workspaceScope === "" ? null : workspaceScope;
1318
+ }
1319
+ if (Object.keys(body).length === 1) return { toast: "没有改动" };
1320
+ try {
1321
+ const res = await call("/update", { method: "POST", body });
1322
+ const memo = res?.memory;
1323
+ state.form = {};
1324
+ state.editBase = null;
1325
+ if (memo !== null && typeof memo === "object" && typeof memo.id === "string") {
1326
+ state.current = memo;
1327
+ openedBy.set(scopeKey(scope), memo.id);
1328
+ goMemory("detail");
1329
+ } else {
1330
+ goMemory("list");
1331
+ }
1332
+ notice("已保存修改。");
1333
+ } catch (error) {
1334
+ state.err = errorText(error);
1335
+ }
1336
+ return {};
1337
+ },
1338
+
1339
+ async archive(scope, event) {
1340
+ resetNotice();
1341
+ return applyArchived(true, event, scope);
1342
+ },
1343
+
1344
+ async unarchive(scope, event) {
1345
+ resetNotice();
1346
+ return applyArchived(false, event, scope);
1347
+ },
1348
+
1349
+ async delete(scope, event) {
1350
+ resetNotice();
1351
+ if (valueOf(event, ["confirmed"], false) !== true) return {};
1352
+ const memo = state.current;
1353
+ if (memo === null) return { toast: "先选一条记忆" };
1354
+ // 确认框本身不带参数(confirm 节点只有 id 与两个按钮文案),所以「删哪
1355
+ // 一条」只能靠状态。状态是模块级的、被所有面板共用:两个面板各开一条时
1356
+ // 后开的会盖掉前一个的 current,此时点确认就会删错行(评审 #5)。
1357
+ // 打开记忆时按面板 scope 记一份(见 open)。只显示共享详情、没在本面板
1358
+ // 走过 open 的(mine 为空)也不能删:state.current 可能是另一个面板的
1359
+ // 条目(评审 #13)。
1360
+ const mine = openedBy.get(scopeKey(scope));
1361
+ if (mine === undefined || mine !== memo.id) {
1362
+ return { toast: "这条不是当前打开的记忆,已取消" };
1363
+ }
1364
+ const id = mine;
1365
+ try {
1366
+ await call("/delete", { method: "POST", body: { id } });
1367
+ cache.delete(id);
1368
+ openedBy.delete(scopeKey(scope));
1369
+ state.current = null;
1370
+ goMemory("list");
1371
+ notice("已删除。");
1372
+ } catch (error) {
1373
+ state.err = errorText(error);
1374
+ }
1375
+ return {};
1376
+ },
1377
+
1378
+ copy() {
1379
+ const memo = state.current;
1380
+ if (memo === null) return { toast: "先选一条记忆" };
1381
+ return { copy: clip(`# ${memo.title}\n\n${memo.content}`, 8000) };
1382
+ },
1383
+
1384
+ insert() {
1385
+ const memo = state.current;
1386
+ if (memo === null) return { toast: "先选一条记忆" };
1387
+ return { insert: clip(`【记忆】${memo.title}\n${memo.content}`, 2000) };
1388
+ },
1389
+
1390
+ "profile-tab"(scope, event) {
1391
+ resetNotice();
1392
+ const next = nextValue(
1393
+ ["profile", "rules", "persona"],
1394
+ state.profileTab,
1395
+ valueOf(event, ["k", "tab", "value"], ""),
1396
+ );
1397
+ state.profileTab = next;
1398
+ state.page = "profile";
1399
+ return {};
1400
+ },
1401
+
1402
+ async "profile-save"(scope, event) {
1403
+ resetNotice();
1404
+ const body = String(valueOf(event, ["editText", "text", "value"], "") ?? "");
1405
+ try {
1406
+ if (state.profileTab === "rules") {
1407
+ let parsed;
1408
+ try {
1409
+ parsed = JSON.parse(body);
1410
+ } catch {
1411
+ state.err = "规则必须是合法 JSON 数组。";
1412
+ return {};
1413
+ }
1414
+ // 非数组({} / null / "text")不能落成空数组:这条路由是整份覆盖,
1415
+ // 静默写 [] 会把用户已有的规则一次清空(评审 #10)。
1416
+ if (!Array.isArray(parsed)) {
1417
+ state.err = "规则必须是合法 JSON 数组。";
1418
+ return {};
1419
+ }
1420
+ await call("/rules", { method: "PUT", body: { rules: parsed } });
1421
+ } else {
1422
+ await call("/profile", { method: "PUT", body: { profile: body } });
1423
+ }
1424
+ notice("已保存。");
1425
+ } catch (error) {
1426
+ state.err = errorText(error);
1427
+ }
1428
+ return {};
1429
+ },
1430
+
1431
+ async "profile-extract"() {
1432
+ resetNotice();
1433
+ if (state.profileTab !== "profile") return { toast: "先切到画像" };
1434
+ try {
1435
+ const res = await call("/profile/extract", { method: "POST", body: {} });
1436
+ const draft = res?.draft ?? res ?? {};
1437
+ const text =
1438
+ typeof draft.profile === "string"
1439
+ ? draft.profile
1440
+ : typeof draft.text === "string"
1441
+ ? draft.text
1442
+ : JSON.stringify(draft);
1443
+ state.extractText = clip(`草稿:${text}`, 600);
1444
+ } catch (error) {
1445
+ const raw = errorText(error);
1446
+ state.extractText = raw.includes("disabled")
1447
+ ? "mneme 没开「画像提取草稿」,先到「设置 › 功能开关 › 写入」里打开它。"
1448
+ : raw.includes("no-route")
1449
+ ? "没有可用的模型路由,提取不了草稿。"
1450
+ : raw.includes("no-input")
1451
+ ? "记忆还太少,提取不出画像。"
1452
+ : raw;
1453
+ }
1454
+ return {};
1455
+ },
1456
+
1457
+ async mode(scope, event) {
1458
+ resetNotice();
1459
+ const next = String(valueOf(event, ["mode", "value"], "standard") ?? "standard");
1460
+ if (next !== "standard" && next !== "light") {
1461
+ state.err = "未知的模式。";
1462
+ return {};
1463
+ }
1464
+ try {
1465
+ await call("/mode", { method: "PUT", body: { mode: next } });
1466
+ notice(`已切到${next === "light" ? "轻量" : "标准"}模式。`);
1467
+ } catch (error) {
1468
+ state.err = errorText(error);
1469
+ }
1470
+ return {};
1471
+ },
1472
+
1473
+ async reclaim() {
1474
+ resetNotice();
1475
+ try {
1476
+ const res = await call("/maintenance/reclaim");
1477
+ const report = res?.report ?? res ?? {};
1478
+ const rows = Array.isArray(report.candidates) ? report.candidates.length : undefined;
1479
+ const total = rows ?? report.reclaimable ?? report.total ?? report.rows ?? null;
1480
+ state.reclaimText =
1481
+ total === null ? "已检查(dry-run,未改动)。" : `可回收 ${String(total)} 条(dry-run,未改动)。`;
1482
+ } catch (error) {
1483
+ state.reclaimText = errorText(error);
1484
+ }
1485
+ return {};
1486
+ },
1487
+
1488
+ "reclaim-prepare"() {
1489
+ resetNotice();
1490
+ state.reclaimConfirm = true;
1491
+ state.reclaimText = "回收会丢弃无法再召回的条目,确认后再执行。";
1492
+ return {};
1493
+ },
1494
+
1495
+ async "reclaim-run"(scope, event) {
1496
+ resetNotice();
1497
+ if (valueOf(event, ["confirmed"], false) !== true) return {};
1498
+ try {
1499
+ const res = await call("/maintenance/reclaim", {
1500
+ method: "POST",
1501
+ body: { confirm: true, vacuum: true },
1502
+ });
1503
+ const report = res?.report ?? res ?? {};
1504
+ state.reclaimText = `已回收 ${String(report.reclaimed ?? report.total ?? 0)} 条。`;
1505
+ state.reclaimConfirm = false;
1506
+ } catch (error) {
1507
+ state.reclaimText = errorText(error);
1508
+ }
1509
+ return {};
1510
+ },
1511
+
1512
+ "conflict-pick-row"(scope, event) {
1513
+ resetNotice();
1514
+ state.conflictId = String(valueOf(event, ["id"], "") ?? "");
1515
+ conflictBy.set(scopeKey(scope), state.conflictId);
1516
+ state.conflictPick = "";
1517
+ state.page = "conflict";
1518
+ return {};
1519
+ },
1520
+
1521
+ "conflict-pick"(scope, event) {
1522
+ resetNotice();
1523
+ const picked = String(valueOf(event, ["winner", "value"], "") ?? "");
1524
+ state.conflictPick = picked === "a" || picked === "b" ? picked : "none";
1525
+ state.page = "conflict";
1526
+ return {};
1527
+ },
1528
+
1529
+ async "conflict-win"(scope, event) {
1530
+ resetNotice();
1531
+ if (valueOf(event, ["confirmed"], false) !== true) return {};
1532
+ const id = state.conflictId;
1533
+ if (id === "") return { toast: "先选一条冲突" };
1534
+ // 冲突 id 也是共享状态:只裁决本面板自己选中的那条(评审 #3)。
1535
+ if (conflictBy.get(scopeKey(scope)) !== id) {
1536
+ return { toast: "这条不是当前打开的冲突,已取消" };
1537
+ }
1538
+ const winner = state.conflictPick === "a" ? "a" : state.conflictPick === "b" ? "b" : null;
1539
+ try {
1540
+ await call("/conflicts/resolve", { method: "POST", body: { id, winner, apply: true } });
1541
+ conflictBy.delete(scopeKey(scope));
1542
+ state.conflictId = "";
1543
+ state.conflictPick = "";
1544
+ notice("已按裁决合并。");
1545
+ } catch (error) {
1546
+ state.err = errorText(error);
1547
+ }
1548
+ return {};
1549
+ },
1550
+
1551
+ async "wb-restore"(scope, event) {
1552
+ resetNotice();
1553
+ const id = String(valueOf(event, ["id"], "") ?? "");
1554
+ if (id === "") return {};
1555
+ try {
1556
+ await call("/update", { method: "POST", body: { id, archived: false } });
1557
+ notice("已恢复这条记忆。");
1558
+ } catch (error) {
1559
+ state.err = errorText(error);
1560
+ }
1561
+ return {};
1562
+ },
1563
+
1564
+ "cmd-pick"(scope, event) {
1565
+ resetNotice();
1566
+ state.sCmdPick = String(valueOf(event, ["value"], "") ?? "");
1567
+ state.page = "settings";
1568
+ return {};
1569
+ },
1570
+
1571
+ "cmd-add"(scope, event) {
1572
+ resetNotice();
1573
+ const value = event?.value;
1574
+ const source =
1575
+ value !== null && typeof value === "object" && !Array.isArray(value)
1576
+ ? { sCmdName: value.sCmdName, sCmdDesc: value.sCmdDesc, sCmdIns: value.sCmdIns }
1577
+ : {};
1578
+ const name = String(source.sCmdName ?? state.sCmdName ?? "").trim();
1579
+ const description = String(source.sCmdDesc ?? state.sCmdDesc ?? "").trim();
1580
+ const instruction = String(source.sCmdIns ?? state.sCmdIns ?? "").trim();
1581
+ if (name === "" || instruction === "") return { toast: "命令名和指令正文都不能空" };
1582
+ return call("/commands", { method: "POST", body: { name, description, instruction } }).then(
1583
+ () => {
1584
+ state.sCmdName = "";
1585
+ state.sCmdDesc = "";
1586
+ state.sCmdIns = "";
1587
+ notice(`已添加命令 ${name}。`);
1588
+ return {};
1589
+ },
1590
+ (error) => {
1591
+ state.err = errorText(error);
1592
+ return {};
1593
+ },
1594
+ );
1595
+ },
1596
+
1597
+ async "cmd-delete"(scope, event) {
1598
+ resetNotice();
1599
+ if (valueOf(event, ["confirmed"], false) !== true) return {};
1600
+ const id = state.sCmdPick;
1601
+ if (id === "") return { toast: "先选一条命令" };
1602
+ try {
1603
+ await call(`/commands?id=${enc(id)}`, { method: "DELETE" });
1604
+ state.sCmdPick = "";
1605
+ notice("已删除命令。");
1606
+ } catch (error) {
1607
+ state.err = errorText(error);
1608
+ }
1609
+ return {};
1610
+ },
1611
+
1612
+ "vector-toggle"(scope, event) {
1613
+ resetNotice();
1614
+ // 没读到真实配置时 state.vector 只是默认值,整体 PUT 会覆盖服务端(评审 #2)。
1615
+ if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" };
1616
+ const value = event?.value;
1617
+ state.vector.enabled = typeof value === "boolean" ? value : !state.vector.enabled;
1618
+ return call("/vector-config", {
1619
+ method: "PUT",
1620
+ body: { enabled: state.vector.enabled, baseUrl: state.vector.baseUrl, model: state.vector.model },
1621
+ }).then(
1622
+ () => {
1623
+ notice(`向量检索:${state.vector.enabled ? "已开启" : "已关闭"}`);
1624
+ return {};
1625
+ },
1626
+ (error) => {
1627
+ state.err = errorText(error);
1628
+ return {};
1629
+ },
1630
+ );
1631
+ },
1632
+
1633
+ "vector-save"(scope, event) {
1634
+ resetNotice();
1635
+ if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" };
1636
+ const value = event?.value;
1637
+ const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1638
+ const baseUrl = String(source.sVecUrl ?? state.vector.baseUrl ?? "").trim();
1639
+ const model = String(source.sVecModel ?? state.vector.model ?? "").trim();
1640
+ state.vector.baseUrl = baseUrl;
1641
+ state.vector.model = model;
1642
+ return call("/vector-config", {
1643
+ method: "PUT",
1644
+ body: { enabled: state.vector.enabled, baseUrl, model },
1645
+ }).then(
1646
+ () => {
1647
+ notice("已保存向量配置。");
1648
+ return {};
1649
+ },
1650
+ (error) => {
1651
+ state.err = errorText(error);
1652
+ return {};
1653
+ },
1654
+ );
1655
+ },
1656
+
1657
+ "vector-reindex"() {
1658
+ resetNotice();
1659
+ const run = call("/vector-reindex", { timeoutMs: 60000 }).then(
1660
+ (res) => {
1661
+ notice(`索引完成:新增 ${String(res?.indexed ?? 0)} 条,跳过 ${String(res?.skipped ?? 0)} 条。`);
1662
+ return {};
1663
+ },
1664
+ (error) => {
1665
+ state.err = errorText(error);
1666
+ return {};
1667
+ },
1668
+ );
1669
+ return {
1670
+ pending: { label: "正在重建向量索引", timeoutMs: 60000 },
1671
+ result: async () => run,
1672
+ };
1673
+ },
1674
+
1675
+ "external-toggle"(scope, event) {
1676
+ resetNotice();
1677
+ if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" };
1678
+ const value = event?.value;
1679
+ state.external.enabled = typeof value === "boolean" ? value : !state.external.enabled;
1680
+ return call("/external-api", {
1681
+ method: "PUT",
1682
+ body: { enabled: state.external.enabled, port: state.external.port, host: state.external.host },
1683
+ }).then(
1684
+ () => {
1685
+ notice(`外部访问:${state.external.enabled ? "已开启" : "已关闭"}`);
1686
+ return {};
1687
+ },
1688
+ (error) => {
1689
+ state.err = errorText(error);
1690
+ return {};
1691
+ },
1692
+ );
1693
+ },
1694
+
1695
+ "external-save"(scope, event) {
1696
+ resetNotice();
1697
+ if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" };
1698
+ const value = event?.value;
1699
+ const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1700
+ const port = Math.round(num(source.sExtPort ?? state.external.port, 8790));
1701
+ const host = String(source.sExtHost ?? state.external.host ?? "").trim();
1702
+ if (port < 1 || port > 65535) return { toast: "端口要在 1–65535 之间" };
1703
+ if (host === "" || host.includes("://")) return { toast: "监听地址不能带协议前缀" };
1704
+ state.external.port = port;
1705
+ state.external.host = host;
1706
+ return call("/external-api", {
1707
+ method: "PUT",
1708
+ body: { enabled: state.external.enabled, port, host },
1709
+ }).then(
1710
+ () => {
1711
+ notice("已保存外部访问配置。");
1712
+ return {};
1713
+ },
1714
+ (error) => {
1715
+ state.err = errorText(error);
1716
+ return {};
1717
+ },
1718
+ );
1719
+ },
1720
+
1721
+ async "check-version"() {
1722
+ resetNotice();
1723
+ try {
1724
+ const res = await call("/version-check");
1725
+ const status = String(res?.status ?? "unknown");
1726
+ state.versionNote =
1727
+ status === "outdated"
1728
+ ? `有新版本 ${String(res?.latest ?? "")}(当前 ${String(res?.version ?? "")})`
1729
+ : status === "latest"
1730
+ ? `已是最新版本 ${String(res?.version ?? "")}`
1731
+ : "没能连上 npm registry,稍后再试。";
1732
+ if (status === "outdated" && res?.latest) notice(`mneme 有新版本 ${res.latest}`);
1733
+ } catch (error) {
1734
+ state.versionNote = errorText(error);
1735
+ }
1736
+ return {};
1737
+ },
1738
+ };
1739
+
1740
+ // One action per feature switch: the app tells us which action fired, and the
1741
+ // new value arrives as `event.value` (boolean) or is derived by flipping the
1742
+ // current effective value.
1743
+ for (const key of FLAG_LABELS.keys()) {
1744
+ actions[flagAction(key)] = async (scope, event) => {
1745
+ resetNotice();
1746
+ const value = event?.value;
1747
+ // state.flags 是宿主原样的扁平点号键(scopeState.enabled);嵌套形态只
1748
+ // 出现在视图绑定里(payload.flags = nestFlags(effective))。这里若按嵌套
1749
+ // 去取值会恒为 undefined,于是「已经打开的开关」永远翻转不回去(评审 #7)。
1750
+ const current = state.flags?.[key];
1751
+ // 布尔项翻转、枚举项(chorographyInject 的 light/full/off)直接用宿主给的
1752
+ // 字符串值:同一个动作 id 同时服务开关与下拉。
1753
+ const next =
1754
+ typeof value === "boolean" || typeof value === "string"
1755
+ ? value
1756
+ : typeof current === "string"
1757
+ ? current
1758
+ : !(current === true);
1759
+ try {
1760
+ await call("/features", { method: "PUT", body: { [key]: next } });
1761
+ const features = await call("/features");
1762
+ state.flagOverrides = features?.overrides ?? {};
1763
+ state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
1764
+ state.flags = features?.effective ?? state.flags;
1765
+ state.settingsDirty = true;
1766
+ notice(
1767
+ `${FLAG_LABELS.get(key) ?? key}:${
1768
+ typeof next === "string" ? next : next ? "已开启" : "已关闭"
1769
+ }`,
1770
+ );
1771
+ } catch (error) {
1772
+ state.err = errorText(error);
1773
+ }
1774
+ // 开关只改一个绑定值:数据已经推过去了,别让 App 重建视图(否则设置页
1775
+ // 会被弹回第一个页签)。
1776
+ return { refresh: false };
1777
+ };
1778
+ }
1779
+
1780
+ // ---- 「Agent 眼中的你」自动事实(0.11.0 的面板卡) ----------------------
1781
+ // 写入只有一个入口:POST /profile-facts/apply,op 形状与自动轮共用(add 必须
1782
+ // 带 content、update/archive 必须带 id)。这里不自己拼 SQL,也不碰 user_settings
1783
+ // ——手填画像仍然优先。
1784
+ const applyFacts = (ops) =>
1785
+ call("/profile-facts/apply", { method: "POST", body: { agent: state.factsAgent, ops } });
1786
+
1787
+ Object.assign(actions, {
1788
+ "facts-agent"(scope, event) {
1789
+ resetNotice();
1790
+ state.factsAgent = String(valueOf(event, ["value", "agent"], "") ?? "");
1791
+ state.factEdit = null;
1792
+ factBy.delete(scopeKey(scope));
1793
+ return {};
1794
+ },
1795
+
1796
+ "facts-edit"(scope, event) {
1797
+ resetNotice();
1798
+ const id = String(valueOf(event, ["id"], "") ?? "");
1799
+ const fact = state.factsItems.find((item) => String(item.id) === id);
1800
+ if (fact === undefined) return { toast: "这条事实已经不在了" };
1801
+ state.factEdit = { id, content: String(fact.content ?? "") };
1802
+ factBy.set(scopeKey(scope), id);
1803
+ return {};
1804
+ },
1805
+
1806
+ "facts-add"(scope, event) {
1807
+ resetNotice();
1808
+ const value = event?.value;
1809
+ const source = value !== null && typeof value === "object" ? value : {};
1810
+ const text = String(source.sFactNew ?? state.form.factNew ?? "").trim();
1811
+ if (text === "") return { toast: "写一句话再添加" };
1812
+ return applyFacts([{ op: "add", content: text }]).then(
1813
+ (result) => {
1814
+ state.form.factNew = "";
1815
+ notice(`已加 ${String(result?.applied ?? 1)} 条事实。`);
1816
+ return {};
1817
+ },
1818
+ (error) => {
1819
+ state.err = errorText(error);
1820
+ return {};
1821
+ },
1822
+ );
1823
+ },
1824
+
1825
+ "facts-save"(scope, event) {
1826
+ resetNotice();
1827
+ const target = state.factEdit;
1828
+ if (target === null) return { toast: "先点一条事实" };
1829
+ if (factBy.get(scopeKey(scope)) !== target.id) {
1830
+ return { toast: "这条不是当前打开的事实,已取消" };
1831
+ }
1832
+ const value = event?.value;
1833
+ const source = value !== null && typeof value === "object" ? value : {};
1834
+ const text = String(source.sFactEdit ?? target.content ?? "").trim();
1835
+ if (text === "") return { toast: "事实正文不能空" };
1836
+ if (text === target.content) return { toast: "没有改动" };
1837
+ return applyFacts([{ op: "update", id: target.id, content: text }]).then(
1838
+ (result) => {
1839
+ const failed = Array.isArray(result?.failures) ? result.failures : [];
1840
+ if (failed.length > 0) {
1841
+ state.err = "这条事实已被改过或删掉了,刷新后再试。";
1842
+ return {};
1843
+ }
1844
+ state.factEdit = null;
1845
+ factBy.delete(scopeKey(scope));
1846
+ notice("事实已更新。");
1847
+ return {};
1848
+ },
1849
+ (error) => {
1850
+ state.err = errorText(error);
1851
+ return {};
1852
+ },
1853
+ );
1854
+ },
1855
+
1856
+ "facts-archive"(scope, event) {
1857
+ resetNotice();
1858
+ if (valueOf(event, ["confirmed"], false) !== true) return {};
1859
+ const target = state.factEdit;
1860
+ if (target === null) return { toast: "先点一条事实" };
1861
+ if (factBy.get(scopeKey(scope)) !== target.id) {
1862
+ return { toast: "这条不是当前打开的事实,已取消" };
1863
+ }
1864
+ return applyFacts([{ op: "archive", id: target.id }]).then(
1865
+ () => {
1866
+ state.factEdit = null;
1867
+ factBy.delete(scopeKey(scope));
1868
+ notice("已归档这条事实。");
1869
+ return {};
1870
+ },
1871
+ (error) => {
1872
+ state.err = errorText(error);
1873
+ return {};
1874
+ },
1875
+ );
1876
+ },
1877
+
1878
+ "facts-run"(scope, event) {
1879
+ resetNotice();
1880
+ // 维护轮要跑一次 LLM(模型读最近记忆、提 op、落库),慢且不可并发,所以
1881
+ // 走 pending:面板先挂一条「正在更新」,最长等 60 秒。
1882
+ const run = call("/profile-facts/run", {
1883
+ method: "POST",
1884
+ body: { agent: state.factsAgent },
1885
+ timeoutMs: 60000,
1886
+ }).then(
1887
+ (receipt) => {
1888
+ const status = String(receipt?.status ?? "ok");
1889
+ state.factsRunText =
1890
+ status === "ok"
1891
+ ? `本轮改了 ${String(receipt?.opsApplied ?? 0)} 条事实。`
1892
+ : status === "noop"
1893
+ ? "没有新记忆可读,这轮没动。"
1894
+ : `这轮结束了:${status}`;
1895
+ notice("事实维护轮已结束。");
1896
+ return { toast: "事实已更新" };
1897
+ },
1898
+ (error) => {
1899
+ const message = errorText(error);
1900
+ // 409 = 功能关着,503 = 没配模型,502 = 模型答得不成形:三种都得说人话。
1901
+ state.err = /\b409\b/u.test(message)
1902
+ ? "「Agent 眼中的你」还关着:先在上面把开关打开。"
1903
+ : /\b503\b/u.test(message)
1904
+ ? "还没配后台模型:先在 mneme 设置里选一个 dream 模型。"
1905
+ : message;
1906
+ return { toast: "这轮没成功" };
1907
+ },
1908
+ );
1909
+ return { pending: { label: "正在维护事实", timeoutMs: 60000 }, result: async () => run };
1910
+ },
1911
+
1912
+ // ---- 代码库地志(chorography)-----------------------------------------
1913
+ "choro-repo"(scope, event) {
1914
+ resetNotice();
1915
+ state.choroRepo = String(valueOf(event, ["value"], "") ?? "");
1916
+ state.choroPageText = "";
1917
+ return {};
1918
+ },
1919
+
1920
+ "choro-page"(scope, event) {
1921
+ resetNotice();
1922
+ const slug = String(valueOf(event, ["slug"], "") ?? "");
1923
+ if (slug === "" || state.choroRepo === "") return { toast: "先选一个仓库" };
1924
+ return call(`/choro/page?repo=${encodeURIComponent(state.choroRepo)}&slug=${encodeURIComponent(slug)}`).then(
1925
+ (res) => {
1926
+ const page = res?.page;
1927
+ state.choroPageText =
1928
+ page === undefined
1929
+ ? "这一页读不出来,可能刚被重建过。"
1930
+ : clip(String(page?.content ?? page?.relPath ?? ""), 1600);
1931
+ notice(`已读取 ${String(page?.title ?? slug)} 的骨架页。`);
1932
+ return {};
1933
+ },
1934
+ (error) => {
1935
+ const message = errorText(error);
1936
+ state.err = /\b409\b/u.test(message) ? "索引还在、盘上没了:点「重建骨架」重跑一次。" : message;
1937
+ return {};
1938
+ },
1939
+ );
1940
+ },
1941
+
1942
+ "choro-run"(scope, event) {
1943
+ resetNotice();
1944
+ if (valueOf(event, ["confirmed"], false) !== true) return {};
1945
+ if (state.choroRepo === "") return { toast: "还没有登记任何仓库" };
1946
+ const run = call("/choro/run", {
1947
+ method: "POST",
1948
+ body: { repo: state.choroRepo },
1949
+ timeoutMs: 60000,
1950
+ }).then(
1951
+ (res) => {
1952
+ const receipt = res?.receipt ?? {};
1953
+ state.choroPageText = "";
1954
+ notice(
1955
+ `骨架已重建:${String(receipt.pagesWritten ?? receipt.modules ?? 0)} 页` +
1956
+ (receipt.t1Loss === true ? "(已富化的语义段被退回骨架)" : ""),
1957
+ );
1958
+ return { toast: "重建完成" };
1959
+ },
1960
+ (error) => {
1961
+ state.err = errorText(error);
1962
+ return { toast: "重建失败" };
1963
+ },
1964
+ );
1965
+ return { pending: { label: "正在重建骨架", timeoutMs: 60000 }, result: async () => run };
1966
+ },
1967
+ });
1968
+
1969
+ // 未收录的布尔旗标(「其它」组里的数据行):行里带着 key,点一下翻转。
1970
+ actions["flag-set"] = async (scope, event) => {
1971
+ resetNotice();
1972
+ const key = String(valueOf(event, ["key", "value"], "") ?? "");
1973
+ if (key === "") {
1974
+ state.err = "没有拿到要切换的键。";
1975
+ return {};
1976
+ }
1977
+ const next = state.flags?.[key] !== true;
1978
+ try {
1979
+ await call("/features", { method: "PUT", body: { [key]: next } });
1980
+ const features = await call("/features");
1981
+ state.flagOverrides = features?.overrides ?? {};
1982
+ state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
1983
+ state.flags = features?.effective ?? state.flags;
1984
+ state.settingsDirty = true;
1985
+ notice(`${key}:${next ? "已开启" : "已关闭"}`);
1986
+ } catch (error) {
1987
+ state.err = errorText(error);
1988
+ }
1989
+ return { refresh: false };
1990
+ };
1991
+
1992
+ // 只改绑定值的动作不申请重画视图。App 收到 refresh 会重新拉一次视图树,
1993
+ // 而顶层 tabs 的选中页签是 App 的本地状态:树一重建就回到第一个页签,用户在
1994
+ // 设置页翻开关会被弹回记忆页。这些动作的结果都在 data 里(开关值、列表行、
1995
+ // 文案),推一次数据就够了。
1996
+ const VALUE_ONLY = new Set([
1997
+ "mode",
1998
+ "vector-toggle",
1999
+ "vector-save",
2000
+ "external-toggle",
2001
+ "external-save",
2002
+ "profile-save",
2003
+ "facts-agent",
2004
+ "facts-edit",
2005
+ "facts-add",
2006
+ "facts-save",
2007
+ "facts-archive",
2008
+ "facts-run",
2009
+ "flag-set",
2010
+ "choro-repo",
2011
+ "choro-page",
2012
+ "choro-run",
2013
+ ]);
2014
+
2015
+ // Every action may change what the panel shows, so push fresh data before
2016
+ // asking the app to re-render (the refresh effect alone would re-render a
2017
+ // stale feed).
2018
+ const wrapped = {};
2019
+ for (const [id, handler] of Object.entries(actions)) {
2020
+ wrapped[id] = async (scope, event) => {
2021
+ let result;
2022
+ try {
2023
+ result = await handler(scope, event);
2024
+ } catch (error) {
2025
+ state.err = errorText(error);
2026
+ result = undefined;
2027
+ }
2028
+ // 默认重画视图:多数动作会改变「哪些节点存在」(进子页、空态、列表)。
2029
+ // 只改一个绑定值的动作(开关、档位、外部访问的开关)要显式关掉重画:
2030
+ // App 的 refresh 会让它重新拉一次视图树,而页签选择是 App 的本地状态,
2031
+ // 重建后回到第一个页签——用户正在设置页翻开关,会被弹回记忆页。
2032
+ const wanted = result ?? {};
2033
+ const skip = wanted.refresh === false || VALUE_ONLY.has(id) || id.startsWith("flag.");
2034
+ const effect = { ...wanted, refresh: !skip };
2035
+ // pending 动作(维护轮、地志重建)把真正的活儿放在 result 里,handler
2036
+ // 返回时它还没跑:此时立刻 broadcast 推出去的仍是这一轮之前的事实与
2037
+ // 地志列表。改成等 result 跑完再推(评审 #15)。
2038
+ if (typeof wanted.result === "function") {
2039
+ const inner = wanted.result;
2040
+ effect.result = async (...args) => {
2041
+ try {
2042
+ return await inner(...args);
2043
+ } finally {
2044
+ await broadcast();
2045
+ }
2046
+ };
2047
+ return effect;
2048
+ }
2049
+ await broadcast();
2050
+ return effect;
2051
+ };
2052
+ }
2053
+
2054
+ // ----------------------------------------------------------------- panel
2055
+ ctx.dshGo.panel("mneme", {
2056
+ view() {
2057
+ return buildView(state);
2058
+ },
2059
+ data(scope, emit) {
2060
+ emitters.add(emit);
2061
+ // 挂载时读全:之后每个动作只重读当前页那一块(见 load 的注释)。
2062
+ load({ full: true }).then(
2063
+ (payload) => {
2064
+ if (emitters.has(emit)) emit(payload);
2065
+ },
2066
+ (error) => {
2067
+ if (emitters.has(emit)) emit({ notice: state.notice, err: errorText(error) });
2068
+ },
2069
+ );
2070
+ return () => {
2071
+ emitters.delete(emit);
2072
+ };
2073
+ },
2074
+ actions: wrapped,
2075
+ });
2076
+ }
2077
+
2078
+ /**
2079
+ * 清掉模块级状态与缓存。在引擎重新挂载面板、以及测试之间切换场景时用它:
2080
+ * 这些表(列表缓存、按 scope 打开的记忆、提示语)是模块级的,跨 mount 复用会
2081
+ * 把上一个场景的残留带进下一个。
2082
+ */
2083
+ export function resetPanelState() {
2084
+ cache.clear();
2085
+ emitters.clear();
2086
+ openedBy.clear();
2087
+ conflictBy.clear();
2088
+ factBy.clear();
2089
+ lastPayload = null;
2090
+ Object.assign(state, defaultState());
2091
+ }