@modusensus/dsh-mneme 0.13.0 → 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 -828
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -254
  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 -402
  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 -3394
  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 -13436
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1317
  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 -907
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -1887
  54. package/lib/dream/triage.js +139 -0
  55. package/lib/dream.js +1879 -1764
  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 -107
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -822
  74. package/lib/inject.js +656 -639
  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 -584
  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 -3196
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -802
  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 -5662
  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 -0
  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 -474
  142. package/lib/wiki/render.js +261 -187
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -463
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -502
  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 +15 -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 -204
  162. package/scripts/release-notes.mjs +164 -161
  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 -3394
  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 -1317
  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 -907
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -1887
  196. package/src/dream/triage.js +139 -0
  197. package/src/dream.js +1879 -1764
  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 -107
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -822
  216. package/src/inject.js +656 -639
  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 -584
  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 -3196
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -802
  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 -5662
  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 -0
  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 -474
  284. package/src/wiki/render.js +261 -187
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -463
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -502
  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 -2988
  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 -4184
  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 -0
  318. package/test/conflict-ledger.test.js +242 -0
  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 -320
  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 -0
  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 -209
  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 -211
  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 -106
  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 -494
  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 -0
  407. package/test/panel-tokens.test.js +245 -0
  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 -136
  453. package/test/release-notes.test.js +265 -240
  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 -178
  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 -1418
  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 -627
  529. package/test/wiki-claims.test.js +194 -0
  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 -0
  535. package/test/wiki-queries.test.js +398 -277
  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 -380
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -235
  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
package/bin/cli.mjs CHANGED
@@ -1,964 +1,964 @@
1
- #!/usr/bin/env node
2
- /**
3
- * dsh-mneme CLI —— @modusensus/dsh-mneme 外部 API 命令行客户端
4
- *
5
- * 零依赖 ESM(Node >= 20,使用全局 fetch)。
6
- * 配置优先级:命令行 --url/--token > 环境变量 DSH_MNEME_URL / DSH_MNEME_TOKEN
7
- * > 配置文件 ~/.dsh-mneme/cli.json({"url","token"})
8
- * 默认服务地址:http://127.0.0.1:8790
9
- */
10
-
11
- import fs from 'node:fs';
12
- import fsp from 'node:fs/promises';
13
- import os from 'node:os';
14
- import path from 'node:path';
15
- import process from 'node:process';
16
- import { fileURLToPath } from 'node:url';
17
-
18
- const CLI_NAME = 'dsh-mneme';
19
- const DEFAULT_URL = 'http://127.0.0.1:8790';
20
- const CONFIG_DIR = path.join(os.homedir(), '.dsh-mneme');
21
- const CONFIG_PATH = path.join(CONFIG_DIR, 'cli.json');
22
- // 与 src/store.js 的 TYPES 对齐(CLI 是零依赖 HTTP 客户端,不 import 服务端
23
- // 模块,所以这里是手写字面量)。document 是唯一例外:它只能经
24
- // memory_register_document 铸造,saveWithDedupe 会拒绝旁路写入,所以不作为
25
- // --type 的选项——test/cli-enums.test.js 锁住「差值恰好是 document」。
26
- const MEMORY_TYPES = ['preference', 'project', 'decision', 'history', 'summary', 'pattern', 'rejected_solution', 'pitfall', 'constraint'];
27
- // hybrid:API 的 searchMemories 支持(向量先、关键词补位、双路命中做分数混合,
28
- // 见 src/api.js 的 mode 注释)。此前漏列,search --mode hybrid 会被客户端自己拒掉。
29
- const SEARCH_MODES = ['keyword', 'vector', 'auto', 'hybrid'];
30
- const REQUEST_TIMEOUT_MS = 10_000;
31
-
32
- function readPkgVersion() {
33
- try {
34
- const pkgPath = path.resolve(
35
- path.dirname(fileURLToPath(import.meta.url)),
36
- '..',
37
- 'package.json'
38
- );
39
- return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? '?';
40
- } catch {
41
- return '?';
42
- }
43
- }
44
-
45
- const PKG_VERSION = readPkgVersion();
46
-
47
- const USAGE = `${CLI_NAME} —— DSH 记忆插件(@modusensus/dsh-mneme)外部 API 命令行客户端
48
-
49
- 用法:
50
- ${CLI_NAME} <命令> [参数] [选项]
51
-
52
- 配置(优先级从高到低):
53
- 1. 命令行参数 --url <url> / --token <token>
54
- 2. 环境变量 DSH_MNEME_URL / DSH_MNEME_TOKEN
55
- 3. 配置文件 ~/.dsh-mneme/cli.json(由 \`config set\` 写入)
56
- 未配置时使用默认地址:${DEFAULT_URL}
57
-
58
- 命令:
59
- config set <url> <token> 写入配置文件(token 省略时保留原值)
60
- config show 查看当前配置(token 打码显示)
61
- config path 打印配置文件路径
62
- status 服务状态(版本 / 记忆统计 / 实体 / 运行时长)
63
- list [--type t] [--min-importance n] [--source s]
64
- [--limit n] [--offset n] [--json]
65
- 列出记忆(type: preference | project | decision | summary | history)
66
- search <query> [--mode m] [--topk n] [--json]
67
- 搜索记忆(mode: keyword | vector | auto)
68
- get <id> [--json] 查看单条记忆
69
- add --type <t> --title <t> --content <c>
70
- [--importance n] [--tags a,b] [--source s] [--json]
71
- 新增记忆(type: preference | project | decision | summary | history)
72
- delete <id> 删除记忆
73
- reclaim [--older-than n] [--apply] [--vacuum] [--json]
74
- 存储无损回收(默认只报数字,不动数据)
75
- --older-than n 输入快照保留窗口(天,默认 7;0 = 全部)
76
- --apply 真的执行(不带就是 dry-run)
77
- --vacuum 执行后整库 VACUUM(需配 --apply)
78
- 只清「可由记忆库重建的输入快照」与「归档行的向量」,
79
- 一条记忆都不删;收益按 VACUUM 前后体积量
80
- share [--alias 名字] [--type a,b] [--tag 标签] --out <文件>
81
- 打一个可外发的脱敏分享包(不改库,纯读)
82
- --alias 名字 在包里署名,对方看到的是这个而不是你的路径
83
- --type/--tag 收窄到某几类/某个标签(超限分批用这两个)
84
- --out <文件> 写出包文件;--dry-run 只报数不落盘
85
- 跳过归档/已遗忘/只读指针行/summary,密钥 PII 整条不进包
86
- receive <包文件> [--apply]
87
- 把对方 share 出来的包吃进自己的库(默认干跑,一个字都不写)
88
- 外来行标 source=imported,不参与本机的巩固/降级,也不并入你的条目
89
- --apply 才真的写;重复吃同一个包不会多出第二份
90
- undo-share <包文件> [--apply]
91
- 按 pack_id 收起整包(物删:包还能重新导入,归档位会占住 UNIQUE)
92
-
93
- 选项:
94
- --url <url> 覆盖服务地址
95
- --token <token> 覆盖访问 token
96
- --json 输出原始 JSON
97
- -h, --help 显示本帮助
98
- -V, --version 显示版本号
99
-
100
- 说明:
101
- token 在 DSH 面板「设置 → 外部访问」或插件设置中查看;
102
- 除 GET /health 外,所有外部 API 路由均需 Bearer token。
103
-
104
- 示例:
105
- ${CLI_NAME} config set ${DEFAULT_URL} my-token
106
- ${CLI_NAME} status
107
- ${CLI_NAME} list --type project --limit 10
108
- ${CLI_NAME} search "部署流程" --mode vector --topk 5
109
- ${CLI_NAME} add --type decision --title "采用 SQLite" --content "存储层使用 node:sqlite" --importance 4 --tags 存储,决策
110
- ${CLI_NAME} get 42
111
- ${CLI_NAME} delete 42
112
- ${CLI_NAME} reclaim (先看:会清几行、库多大)
113
- ${CLI_NAME} reclaim --apply --vacuum (确认后执行,并把空间真的收回来)
114
- ${CLI_NAME} share --dry-run (先看:会进几条、为什么跳过那些,不落盘)
115
- ${CLI_NAME} share --out pack.json (写包;~/… 由 CLI 自己展开,PowerShell/cmd 不会)
116
- ${CLI_NAME} receive pack.json (吃包:默认干跑,只报账,一个字都不写)
117
- ${CLI_NAME} receive pack.json --apply (确认数字后真的吃进自己的库;重跑同一个包 created=0)
118
- ${CLI_NAME} undo-share pack.json --apply (按包里那个 pack_id 整包收回:物删,之后还能重新导入)`;
119
-
120
- /* ---------------------------------- 参数解析 ---------------------------------- */
121
-
122
- // `--flag value` 形态下,「下一个词以 -- 开头就别当值」这条规则有一个致命反例:
123
- // 值本身就可能是 `--` 开头的字符串。外部 API 的 token 由
124
- // `crypto.randomBytes(24).toString("base64url")` 生成(src/api-standalone.js),
125
- // base64url 的字母表含 `-`,所以约 1/4096 的 token 头两位恰好都是 `-`:
126
- //
127
- // dsh-mneme reclaim --url http://127.0.0.1:55423 --token --UYoOHxr6PtE2-bfqW...
128
- //
129
- // 旧规则把 `--UYoOHxr6PtE2-bfqW...` 当成一个新旗标(键名 `uyoohxr6pte2-bfqw...`),
130
- // 于是 token 解析成 `true`,flagValue 视作「没给」→ 退回环境变量/配置文件 → 全空
131
- // → 报「未配置访问 token」。而 CLI 生成 token 的另一半同样会撞:`--token=--abc`
132
- // 走等号分支没事,`--token --abc` 就废。这不是测试的随机性,是真实用户的随机性。
133
- //
134
- // 修法:区分「已知旗标」与「未知词」。下一个词以 `--` 开头时,只有它确实是一个
135
- // 已注册的旗标才当作旗标,否则当作当前 flag 的值。于是 `--limit --json` 仍按
136
- // 旧语义把 --json 当旗标,而 `--token --UYo...` 正确取到 token。
137
- // 已知旗标表必须与下面 flagValue/flagBool 的实际用法保持同步;漏登记的代价是
138
- // 该旗标被当成值吞掉,所以 test/cli-flag-value.test.js 用文本锁对齐两者。
139
- const KNOWN_FLAGS = new Set([
140
- // 取值的旗标
141
- 'url', 'token', 'type', 'title', 'content', 'importance', 'tags', 'source',
142
- 'limit', 'offset', 'mode', 'topk', 'top-k', 'min-importance', 'minimportance',
143
- 'older-than', 'olderthandays',
144
- 'alias', 'tag', 'out',
145
- // 布尔旗标
146
- 'apply', 'vacuum', 'json', 'help', 'version',
147
- 'dry-run', 'dryrun',
148
- ]);
149
-
150
- /** `--foo=bar` / `--foo` 取出小写键名;非旗标词返回 null。 */
151
- function flagKeyOf(word) {
152
- if (typeof word !== 'string' || !word.startsWith('--') || word === '--') return null;
153
- const key = word.slice(2);
154
- const eq = key.indexOf('=');
155
- return (eq >= 0 ? key.slice(0, eq) : key).toLowerCase();
156
- }
157
-
158
- function parseArgv(argv) {
159
- const positionals = [];
160
- const flags = {};
161
- for (let i = 0; i < argv.length; i++) {
162
- const arg = argv[i];
163
- if (arg === '--') {
164
- positionals.push(...argv.slice(i + 1));
165
- break;
166
- }
167
- if (arg.startsWith('--')) {
168
- let key = arg.slice(2);
169
- let value;
170
- const eq = key.indexOf('=');
171
- if (eq >= 0) {
172
- value = key.slice(eq + 1);
173
- key = key.slice(0, eq);
174
- } else {
175
- const next = argv[i + 1];
176
- const nextIsFlag = next !== undefined && flagKeyOf(next) !== null && KNOWN_FLAGS.has(flagKeyOf(next));
177
- if (next !== undefined && next !== '--' && !nextIsFlag) {
178
- value = argv[++i];
179
- } else {
180
- value = true;
181
- }
182
- }
183
- flags[key.toLowerCase()] = value;
184
- } else {
185
- positionals.push(arg);
186
- }
187
- }
188
- return { positionals, flags };
189
- }
190
-
191
- function flagValue(flags, ...names) {
192
- for (const name of names) {
193
- const value = flags[name.toLowerCase()];
194
- if (value !== undefined && value !== true && value !== '') return value;
195
- }
196
- return undefined;
197
- }
198
-
199
- function flagBool(flags, ...names) {
200
- for (const name of names) {
201
- const value = flags[name.toLowerCase()];
202
- if (value !== undefined) return value === true || value === 'true' || value === '';
203
- }
204
- return false;
205
- }
206
-
207
- /* ---------------------------------- 配置解析 ---------------------------------- */
208
-
209
- function readConfigFile() {
210
- try {
211
- const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
212
- return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
213
- } catch {
214
- return {};
215
- }
216
- }
217
-
218
- function firstNonEmpty(...values) {
219
- for (const value of values) {
220
- if (typeof value === 'string' && value.trim() !== '') return value.trim();
221
- }
222
- return undefined;
223
- }
224
-
225
- function resolveConfig(flags) {
226
- const file = readConfigFile();
227
- const url =
228
- firstNonEmpty(flagValue(flags, 'url'), process.env.DSH_MNEME_URL, file.url) ?? DEFAULT_URL;
229
- const token =
230
- firstNonEmpty(flagValue(flags, 'token'), process.env.DSH_MNEME_TOKEN, file.token) ?? '';
231
- return { url: url.replace(/\/+$/, ''), token, jsonMode: false };
232
- }
233
-
234
- function maskToken(token) {
235
- if (!token) return '(未设置)';
236
- if (token.length <= 8) return '****';
237
- return `${token.slice(0, 4)}****${token.slice(-4)}`;
238
- }
239
-
240
- /* ---------------------------------- 输出与错误 ---------------------------------- */
241
-
242
- function stringify(value) {
243
- return JSON.stringify(value, null, 2);
244
- }
245
-
246
- function fail(cfg, message, { rawJson } = {}) {
247
- if (cfg.jsonMode) {
248
- console.error(rawJson !== undefined ? rawJson : stringify({ error: message }));
249
- } else {
250
- console.error(message);
251
- }
252
- process.exit(1);
253
- }
254
-
255
- function failHttp(cfg, status, pathname, data, text) {
256
- const serverError =
257
- data && typeof data === 'object' && data.error
258
- ? String(data.error)
259
- : (text || '').slice(0, 200);
260
- let hint = '';
261
- if (status === 401) {
262
- hint =
263
- `\n访问 token 缺失或无效。token 可在 DSH 面板「设置 → 外部访问」或插件设置中查看,\n` +
264
- `然后用 \`${CLI_NAME} config set <url> <token>\`、环境变量 DSH_MNEME_TOKEN 或 --token 配置。`;
265
- } else if (status === 404) {
266
- hint = `\n资源不存在:${pathname}`;
267
- }
268
- // 拒答体自己带的下一步必须转出去,不然命令侧只能再写一份错误分支。撞上的这一刀是
269
- // share 的 413:响应体是 {error:"too-large", bytes, limitBytes, hint, report},
270
- // 旧实现只取 data.error,于是现场只剩「请求失败(HTTP 413):too-large」——「大了
271
- // 多少」与「用 --type/--tag 分批」那两句留在体里没人看得见(措辞只许有一份,就在
272
- // 拒发的那侧,同 RECLAIM_CONFIRM_HINT 的口径)。完整 report(含 skip_list)走
273
- // --json:下面 rawJson 把整个响应体原样交给 stderr,这里不复制第二份打印器。
274
- //
275
- // 已知取舍(C10 记档,见 docs/SHARE.md「已知还没做」):这里只转 hint / bytes /
276
- // limitBytes 三样,所以 receive 那侧 400 的 `detail`(闸门指到哪一格)与数据面 413 的
277
- // `limit` 到不了人类可读的输出。刻意不补:补一格就得在这里多一条按路由分支的判断,
278
- // 而「错误文案只许有一份」是 #411 那条纪律的另一半。要逐格看就加 --json——rawJson
279
- // 那行给的正是原样响应体,一格都不丢。
280
- if (data && typeof data === 'object') {
281
- if (typeof data.hint === 'string' && data.hint.trim()) hint += `\n${data.hint.trim()}`;
282
- if (Number.isFinite(data.bytes) && Number.isFinite(data.limitBytes)) {
283
- hint += `\n量到 ${formatBytes(data.bytes)},水位 ${formatBytes(data.limitBytes)}`;
284
- }
285
- }
286
- fail(cfg, `请求失败(HTTP ${status})${serverError ? `:${serverError}` : ''} [${pathname}]${hint}`, {
287
- rawJson: cfg.jsonMode && text ? text : undefined,
288
- });
289
- }
290
-
291
- function failNetwork(cfg, url, err) {
292
- const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
293
- const reason = timedOut
294
- ? `请求超时(${REQUEST_TIMEOUT_MS / 1000} 秒)`
295
- : `无法连接到服务(${err?.code ?? err?.cause?.code ?? err?.message ?? '未知错误'})`;
296
- fail(
297
- cfg,
298
- `${reason}:${url}\n` +
299
- `请确认 DSH 已启动、插件外部 API 已开启,且地址正确(默认 ${DEFAULT_URL})。`
300
- );
301
- }
302
-
303
- function requireToken(cfg) {
304
- if (cfg.token) return;
305
- fail(
306
- cfg,
307
- [
308
- '未配置访问 token,无法调用该命令(外部 API 需要 Bearer 鉴权)。',
309
- '',
310
- 'token 可在 DSH 面板「设置 → 外部访问」或插件设置中查看,然后任选其一配置:',
311
- ` 1. ${CLI_NAME} config set <url> <token>`,
312
- ' 2. 环境变量 DSH_MNEME_TOKEN=<token>',
313
- ' 3. 命令行参数 --token <token>',
314
- ].join('\n')
315
- );
316
- }
317
-
318
- function parseIntArg(cfg, value, label) {
319
- if (value === undefined || value === true || value === '') return undefined;
320
- const n = Number(value);
321
- if (!Number.isInteger(n) || n < 0) {
322
- fail(cfg, `无效的 ${label}: ${value}(应为非负整数)`);
323
- }
324
- return n;
325
- }
326
-
327
- /* ---------------------------------- HTTP 请求 ---------------------------------- */
328
-
329
- async function apiRequest(cfg, method, pathname, { query, body } = {}) {
330
- let url = cfg.url + pathname;
331
- if (query) {
332
- const params = new URLSearchParams();
333
- for (const [key, value] of Object.entries(query)) {
334
- if (value !== undefined && value !== null && value !== '') {
335
- params.set(key, String(value));
336
- }
337
- }
338
- const qs = params.toString();
339
- if (qs) url += `?${qs}`;
340
- }
341
-
342
- const headers = { Accept: 'application/json' };
343
- if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
344
- if (body !== undefined) headers['Content-Type'] = 'application/json';
345
-
346
- let res;
347
- try {
348
- res = await fetch(url, {
349
- method,
350
- headers,
351
- body: body !== undefined ? JSON.stringify(body) : undefined,
352
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
353
- });
354
- } catch (err) {
355
- failNetwork(cfg, url, err);
356
- }
357
-
358
- const text = await res.text();
359
- let data = null;
360
- try {
361
- data = text ? JSON.parse(text) : null;
362
- } catch {
363
- // 非 JSON 响应体,保留原始文本用于报错展示
364
- }
365
-
366
- if (!res.ok) {
367
- failHttp(cfg, res.status, pathname, data, text);
368
- }
369
- return data;
370
- }
371
-
372
- /* ---------------------------------- 结果格式化 ---------------------------------- */
373
-
374
- function formatTimestamp(value) {
375
- if (!value) return '';
376
- const s = String(value);
377
- return s.length >= 10 ? s.slice(0, 10) : s;
378
- }
379
-
380
- function formatMemoryLine(item) {
381
- const id = item?.id ?? '?';
382
- const type = item?.type ?? '?';
383
- const importance = item?.importance != null ? ` ★${item.importance}` : '';
384
- const title = item?.title ? ` ${item.title}` : '';
385
- const tags =
386
- Array.isArray(item?.tags) && item.tags.length ? ` [${item.tags.join(',')}]` : '';
387
- const date = formatTimestamp(item?.created_at ?? item?.createdAt);
388
- return ` #${id} [${type}]${importance}${title}${tags}${date ? ` (${date})` : ''}`;
389
- }
390
-
391
- function printMemoryItems(payload, { withMode = false } = {}) {
392
- const items = Array.isArray(payload?.items) ? payload.items : [];
393
- const total = payload?.total ?? items.length;
394
- if (withMode && payload?.mode) console.log(`搜索模式: ${payload.mode}`);
395
- console.log(`共 ${total} 条,本次显示 ${items.length} 条`);
396
- if (!items.length) return;
397
- console.log('');
398
- for (const item of items) {
399
- const score = item?.score ?? item?.similarity;
400
- const scoreText =
401
- typeof score === 'number' && Number.isFinite(score) ? ` (score ${score.toFixed(3)})` : '';
402
- console.log(formatMemoryLine(item) + scoreText);
403
- const content =
404
- typeof item?.content === 'string' ? item.content.replace(/\s+/g, ' ').trim() : '';
405
- if (content) {
406
- console.log(` ${content.length > 80 ? `${content.slice(0, 80)}…` : content}`);
407
- }
408
- }
409
- }
410
-
411
- function printMemoryDetail(item) {
412
- if (!item || typeof item !== 'object') {
413
- console.log(stringify(item ?? null));
414
- return;
415
- }
416
- const fields = [];
417
- if (item.id !== undefined) fields.push(`id: ${item.id}`);
418
- if (item.type !== undefined) fields.push(`类型: ${item.type}`);
419
- if (item.importance !== undefined) fields.push(`重要性: ${item.importance}`);
420
- if (item.title !== undefined) fields.push(`标题: ${item.title}`);
421
- if (item.tags !== undefined) {
422
- fields.push(`标签: ${Array.isArray(item.tags) ? item.tags.join(', ') : item.tags}`);
423
- }
424
- if (item.source !== undefined) fields.push(`来源: ${item.source}`);
425
- const created = item.created_at ?? item.createdAt;
426
- if (created) fields.push(`创建时间: ${created}`);
427
- for (const line of fields) console.log(line);
428
- if (item.content !== undefined) console.log(`\n${item.content}`);
429
- }
430
-
431
- function printStatus(data) {
432
- console.log(`服务版本: ${data?.version ?? '?'}`);
433
- console.log(`运行时长: ${data?.uptime_s ?? data?.uptimeS ?? '?'} 秒`);
434
- const memories = data?.memories ?? {};
435
- console.log(`记忆总数: ${memories.total ?? '?'}`);
436
- const byType = memories.byType;
437
- if (Array.isArray(byType)) {
438
- for (const row of byType) {
439
- console.log(` - ${row?.type ?? row?.name ?? '?'}: ${row?.count ?? row?.total ?? '?'}`);
440
- }
441
- } else if (byType && typeof byType === 'object') {
442
- for (const [type, count] of Object.entries(byType)) {
443
- console.log(` - ${type}: ${count}`);
444
- }
445
- }
446
- const entities = data?.entities;
447
- if (entities != null) {
448
- const total =
449
- typeof entities === 'object' && !Array.isArray(entities)
450
- ? entities.total
451
- : entities;
452
- console.log(`实体数量: ${total ?? '?'}`);
453
- }
454
- }
455
-
456
- /* ---------------------------------- 子命令 ---------------------------------- */
457
-
458
- async function cmdConfig(cfg, sub, rest) {
459
- const subCmd = sub || 'show';
460
-
461
- if (subCmd === 'set') {
462
- const [url, tokenArg] = rest;
463
- if (!url) {
464
- fail(
465
- cfg,
466
- `用法: ${CLI_NAME} config set <url> <token>\n示例: ${CLI_NAME} config set ${DEFAULT_URL} my-token`
467
- );
468
- }
469
- if (!/^https?:\/\//i.test(url)) {
470
- fail(cfg, `无效的服务地址: ${url}(应以 http:// 或 https:// 开头)`);
471
- }
472
- const existing = readConfigFile();
473
- const token = tokenArg ?? existing.token ?? '';
474
- await fsp.mkdir(CONFIG_DIR, { recursive: true });
475
- await fsp.writeFile(CONFIG_PATH, `${JSON.stringify({ url, token }, null, 2)}\n`, 'utf8');
476
- if (cfg.jsonMode) {
477
- console.log(stringify({ configPath: CONFIG_PATH, url, tokenConfigured: Boolean(token) }));
478
- } else {
479
- console.log(`已写入配置文件: ${CONFIG_PATH}`);
480
- console.log(` url: ${url}`);
481
- console.log(` token: ${maskToken(token)}`);
482
- }
483
- return;
484
- }
485
-
486
- if (subCmd === 'show') {
487
- const hasFile = fs.existsSync(CONFIG_PATH);
488
- const file = readConfigFile();
489
- if (cfg.jsonMode) {
490
- console.log(
491
- stringify({
492
- configPath: CONFIG_PATH,
493
- configExists: hasFile,
494
- url: file.url ?? DEFAULT_URL,
495
- token: maskToken(file.token),
496
- })
497
- );
498
- return;
499
- }
500
- if (!hasFile) {
501
- console.log(`尚未创建配置文件(未配置): ${CONFIG_PATH}`);
502
- console.log('');
503
- console.log(`可运行 \`${CLI_NAME} config set <url> <token>\` 创建配置;`);
504
- console.log('token 可在 DSH 面板「设置 → 外部访问」或插件设置中查看。');
505
- console.log(
506
- `未配置时使用默认地址 ${DEFAULT_URL},也可用环境变量 DSH_MNEME_URL / DSH_MNEME_TOKEN。`
507
- );
508
- return;
509
- }
510
- console.log(`配置文件: ${CONFIG_PATH}`);
511
- console.log(` url: ${file.url ?? `(未设置,默认 ${DEFAULT_URL})`}`);
512
- console.log(` token: ${maskToken(file.token)}`);
513
- console.log('');
514
- console.log('配置优先级: --url/--token > 环境变量 DSH_MNEME_URL / DSH_MNEME_TOKEN > 配置文件');
515
- return;
516
- }
517
-
518
- if (subCmd === 'path') {
519
- console.log(CONFIG_PATH);
520
- return;
521
- }
522
-
523
- fail(cfg, `未知的 config 子命令: ${subCmd}\n可用子命令: set / show / path`);
524
- }
525
-
526
- async function cmdStatus(cfg) {
527
- requireToken(cfg);
528
- const data = await apiRequest(cfg, 'GET', '/status');
529
- if (cfg.jsonMode) {
530
- console.log(stringify(data));
531
- } else {
532
- printStatus(data);
533
- }
534
- }
535
-
536
- async function cmdList(cfg, flags) {
537
- requireToken(cfg);
538
- const query = {
539
- type: flagValue(flags, 'type'),
540
- minImportance: parseIntArg(cfg, flagValue(flags, 'min-importance', 'minImportance'), '--min-importance'),
541
- source: flagValue(flags, 'source'),
542
- limit: parseIntArg(cfg, flagValue(flags, 'limit'), '--limit'),
543
- offset: parseIntArg(cfg, flagValue(flags, 'offset'), '--offset'),
544
- };
545
- const data = await apiRequest(cfg, 'GET', '/memories', { query });
546
- if (cfg.jsonMode) {
547
- console.log(stringify(data));
548
- } else {
549
- printMemoryItems(data);
550
- }
551
- }
552
-
553
- async function cmdSearch(cfg, rest, flags) {
554
- requireToken(cfg);
555
- const q = rest[0];
556
- if (!q) {
557
- fail(
558
- cfg,
559
- `用法: ${CLI_NAME} search <query> [--mode m] [--topk n]\n` +
560
- `示例: ${CLI_NAME} search "部署流程" --mode vector --topk 5`
561
- );
562
- }
563
- const mode = flagValue(flags, 'mode');
564
- if (mode !== undefined && !SEARCH_MODES.includes(mode)) {
565
- fail(cfg, `无效的 --mode: ${mode}(可选: ${SEARCH_MODES.join(' | ')})`);
566
- }
567
- const topK = parseIntArg(cfg, flagValue(flags, 'topk', 'top-k', 'topK'), '--topk');
568
- const data = await apiRequest(cfg, 'GET', '/search', { query: { q, mode, topK } });
569
- if (cfg.jsonMode) {
570
- console.log(stringify(data));
571
- } else {
572
- printMemoryItems(data, { withMode: true });
573
- }
574
- }
575
-
576
- async function cmdGet(cfg, rest) {
577
- requireToken(cfg);
578
- const id = rest[0];
579
- if (!id) {
580
- fail(cfg, `用法: ${CLI_NAME} get <id> [--json]`);
581
- }
582
- const data = await apiRequest(cfg, 'GET', `/memories/${encodeURIComponent(id)}`);
583
- if (cfg.jsonMode) {
584
- console.log(stringify(data));
585
- } else {
586
- printMemoryDetail(data);
587
- }
588
- }
589
-
590
- async function cmdAdd(cfg, flags) {
591
- requireToken(cfg);
592
- const type = flagValue(flags, 'type');
593
- const title = flagValue(flags, 'title');
594
- const content = flagValue(flags, 'content');
595
- if (!type || !title || !content) {
596
- fail(
597
- cfg,
598
- `用法: ${CLI_NAME} add --type <${MEMORY_TYPES.join('|')}> --title <标题> --content <内容>\n` +
599
- ` [--importance n] [--tags a,b] [--source s]\n\n` +
600
- `示例: ${CLI_NAME} add --type decision --title "采用 SQLite" --content "存储层使用 node:sqlite"`
601
- );
602
- }
603
- if (!MEMORY_TYPES.includes(type)) {
604
- fail(cfg, `无效的 --type: ${type}(可选: ${MEMORY_TYPES.join(' | ')})`);
605
- }
606
- const importanceRaw = flagValue(flags, 'importance');
607
- let importance;
608
- if (importanceRaw !== undefined) {
609
- importance = Number(importanceRaw);
610
- if (!Number.isInteger(importance) || importance < 1 || importance > 5) {
611
- fail(cfg, `无效的 --importance: ${importanceRaw}(应为 1-5 的整数)`);
612
- }
613
- }
614
- const tagsRaw = flagValue(flags, 'tags');
615
- const tags =
616
- tagsRaw !== undefined
617
- ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean)
618
- : undefined;
619
- const source = flagValue(flags, 'source');
620
-
621
- const body = { type, title, content };
622
- if (importance !== undefined) body.importance = importance;
623
- if (tags) body.tags = tags;
624
- if (source) body.source = source;
625
-
626
- const data = await apiRequest(cfg, 'POST', '/memories', { body });
627
- if (cfg.jsonMode) {
628
- console.log(stringify(data));
629
- } else {
630
- console.log('已添加记忆:');
631
- printMemoryDetail(data);
632
- }
633
- }
634
-
635
- async function cmdDelete(cfg, rest) {
636
- requireToken(cfg);
637
- const id = rest[0];
638
- if (!id) {
639
- fail(cfg, `用法: ${CLI_NAME} delete <id>`);
640
- }
641
- const data = await apiRequest(cfg, 'DELETE', `/memories/${encodeURIComponent(id)}`);
642
- if (cfg.jsonMode) {
643
- console.log(stringify(data));
644
- } else {
645
- console.log(`已删除记忆 ${id}`);
646
- }
647
- }
648
-
649
- function formatBytes(bytes) {
650
- if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return '?';
651
- const mb = bytes / (1024 * 1024);
652
- if (mb >= 1) return `${mb.toFixed(1)} MB`;
653
- return `${(bytes / 1024).toFixed(1)} KB`;
654
- }
655
-
656
- function printReclaim(report, { applied }) {
657
- const planned = report?.planned ?? {};
658
- const cleared = report?.cleared ?? {};
659
- const columnBytes = report?.column_bytes ?? {};
660
- const size = report?.size ?? {};
661
- console.log(`存储无损回收(${applied ? '已执行' : 'dry-run,未改动任何数据'})`);
662
- console.log(` 输入快照保留窗口: ${report?.olderThanDays ?? '?'} 天(cutoff ${report?.cutoff ?? '?'})`);
663
- // 第三项(#399):蒸馏原文的到期裁剪同样只删 distill_records,溯源链接刻意保留
664
- const rows = (o) =>
665
- `dream_runs.input ${o.dream_run_inputs ?? 0} 行 / 归档行向量 ${o.archived_embeddings ?? 0} 行 / 蒸馏原文 ${o.distill_records ?? 0} 行`;
666
- console.log(` ${applied ? '已清理' : '将清理'}: ${rows(applied ? cleared : planned)}`);
667
- console.log(
668
- ` 列文本大小(上界,非回收量): ${formatBytes(columnBytes.dream_run_inputs)} / ${formatBytes(columnBytes.archived_embeddings)} / ${formatBytes(columnBytes.distill_records)}`
669
- );
670
- if (!applied) {
671
- console.log(
672
- ` 库文件: ${formatBytes(size.before_bytes)}(空闲页 ${size.freelist_pages ?? '?'} 页,可立刻回收约 ${formatBytes(size.freelist_bytes)})`
673
- );
674
- console.log('');
675
- console.log(' 上面是 dry-run 的数字。确认后执行:');
676
- console.log(` ${CLI_NAME} reclaim --apply --vacuum`);
677
- return;
678
- }
679
- const freed =
680
- typeof size.before_bytes === 'number' && typeof size.after_bytes === 'number'
681
- ? size.before_bytes - size.after_bytes
682
- : null;
683
- if (report?.vacuum?.error) {
684
- console.log(` VACUUM: 失败(${String(report.vacuum.error).slice(0, 120)})`);
685
- console.log(' 清理已经完成且不可逆,文件暂时不会变小;库多半被别的进程占着锁,');
686
- console.log(' 稍后重跑一次 `reclaim --apply --vacuum` 即可(清理是幂等的)。');
687
- } else {
688
- console.log(
689
- ` VACUUM: ${report?.vacuum?.ran ? `已跑(${report.vacuum.duration_ms ?? '?'} ms)` : '未跑(未带 --vacuum,文件不会变小)'}`
690
- );
691
- }
692
- console.log(
693
- ` 库文件: ${formatBytes(size.before_bytes)} → ${formatBytes(size.after_bytes)}` +
694
- (freed !== null && freed > 0 ? `(释放 ${formatBytes(freed)})` : '')
695
- );
696
- console.log(` 耗时: ${report?.duration_ms ?? '?'} ms`);
697
- if (report?.receipt) console.log(` 回执: llm_audit_logs #${report.receipt}`);
698
- }
699
-
700
- async function cmdReclaim(cfg, flags) {
701
- requireToken(cfg);
702
- const applied = flagBool(flags, 'apply');
703
- const vacuum = flagBool(flags, 'vacuum');
704
- if (vacuum && !applied) {
705
- fail(
706
- cfg,
707
- `--vacuum 需要配 --apply:dry-run 不改数据,也就没有可回收的页。\n` +
708
- `先看数字,再执行:${CLI_NAME} reclaim --apply --vacuum`
709
- );
710
- }
711
- const olderThanDays = parseIntArg(cfg, flagValue(flags, 'older-than', 'olderThanDays'), '--older-than');
712
- const body = { dryRun: !applied };
713
- if (applied) {
714
- // 服务端要求显式确认:这两步不可逆,缺 confirm 只会拿回一份 dry-run 报告。
715
- body.confirm = true;
716
- }
717
- if (olderThanDays !== undefined) body.olderThanDays = olderThanDays;
718
- if (vacuum) body.vacuum = true;
719
- const data = await apiRequest(cfg, 'POST', '/maintenance/reclaim', { body });
720
- if (cfg.jsonMode) {
721
- console.log(stringify(data));
722
- } else {
723
- printReclaim(data, { applied });
724
- }
725
- }
726
-
727
- // --out 的例子写的是 `~/Desktop/pack.json`。Git Bash 会替她展开,PowerShell 与 cmd
728
- // 不会——那两侧 `~/…` 是字面量,writeFileSync 直接 ENOENT,现场只剩一坨堆栈。CLI 不
729
- // 接管 shell 的活,但引号里这一格得自己补(同 CONFIG_DIR 走 os.homedir())。
730
- function expandHome(p) {
731
- if (typeof p !== 'string') return p;
732
- if (p !== '~' && !p.startsWith('~/') && !p.startsWith('~\\')) return p;
733
- const rest = p.slice(1).replace(/^[/\\]+/, '');
734
- return path.join(os.homedir(), rest);
735
- }
736
-
737
- function printShare(pack, { dryRun }) {
738
- const r = pack?.report ?? {};
739
- const s = r.skipped ?? {};
740
- console.log(`${dryRun ? '干跑报告' : '分享包'}: ${r.included ?? 0} 条` +
741
- (pack?.alias ? ` 来自 ${pack.alias}` : ''));
742
- const named = [['archived', '归档'], ['forgotten', '已遗忘'], ['readonly_type', '只读指针行'],
743
- ['excluded_type', '不随包走'], ['blank_or_noise', '空白/噪声'], ['sensitive', '密钥/PII']];
744
- const parts = named.filter(([k]) => (s[k] ?? 0) > 0).map(([k, zh]) => `${zh} ${s[k]}`);
745
- console.log(` 跳过: ${parts.length ? parts.join(', ') : '无'}`);
746
- // 折叠不是跳过:那条记忆够格进包,只是包里已经有了它(同标题同正文同内容锚、scope
747
- // 不同的两条)。不印出来的话 `included` 会比她心里的「够格的条数」少几条,而那正好是
748
- // 会让人怀疑口径少发了的那类差额。src/share.js 的 report.deduped。
749
- if ((r.deduped ?? 0) > 0) console.log(` 包内重复已折叠: ${r.deduped} 条(对面只吃一条)`);
750
- for (const item of (r.skip_list ?? []).slice(0, 20)) {
751
- // 标题为空有两种:这条本来没标题,或它整行被密钥判据命中、标题在源头就留空了
752
- // (src/share.js:skip_list 随包外发,而凭据常常就写在标题里)。终端要让人看清
753
- // 后一种是「有意不给」,不是「数据缺失」。
754
- console.log(` - [${item.reason}] ${item.title || '(标题不外发)'}`);
755
- }
756
- // 判据用「六个计数之和 > 清单长度」而不是「清单等于 50」:前者是「账比清单长」这个事实本身,
757
- // 恰好 50 条被跳时后者会误报截断(面板侧同一条判据)。50 这个数不在这里出现,
758
- // 水位改了判据仍然对。
759
- const skippedTotal = ['archived', 'forgotten', 'readonly_type', 'excluded_type', 'blank_or_noise', 'sensitive']
760
- .reduce((n, k) => n + (r.skipped?.[k] ?? 0), 0);
761
- if (skippedTotal > (r.skip_list ?? []).length) console.log(' … 跳过清单已截断,计数仍为全量');
762
- if (dryRun) console.log(` 没写文件:加 --out <路径> 才会落盘`);
763
- else console.log(` 发给对方即可,她用自己的 ${CLI_NAME} receive 吃进去`);
764
- }
765
-
766
- async function cmdShare(cfg, flags) {
767
- requireToken(cfg);
768
- const out = expandHome(flagValue(flags, 'out'));
769
- const dryRun = flagBool(flags, 'dry-run') || flagBool(flags, 'dryrun');
770
- if (!out && !dryRun) {
771
- fail(
772
- cfg,
773
- `share 要么写文件(--out <路径>),要么只看报告(--dry-run):` +
774
- `不写文件又把整库打到 stdout 会让报告被 JSON 淹掉。\n` +
775
- `例子:${CLI_NAME} share --out ~/Desktop/pack.json`
776
- );
777
- }
778
- const typeRaw = flagValue(flags, 'type');
779
- const body = {};
780
- if (typeRaw) body.type = typeRaw.split(',').map((t) => t.trim()).filter(Boolean);
781
- const alias = flagValue(flags, 'alias');
782
- if (alias) body.alias = alias;
783
- const tag = flagValue(flags, 'tag');
784
- if (tag) body.tag = tag;
785
- // 超水位那次服务端答 413,apiRequest 对非 2xx 走的是 failHttp:它把响应体里的 hint
786
- // 与量到的字节转出去再 exit(1)。所以这里**没有**可达的 error 分支——加 `if
787
- // (pack?.error)` 就是第二份出口,措辞迟早跟拒发那一侧分家(#411 的同一口径)。
788
- const pack = await apiRequest(cfg, 'POST', '/share', { body });
789
- const r = pack?.report ?? {};
790
- if (cfg.jsonMode) {
791
- console.log(stringify(dryRun ? { report: r, pack } : { report: r, file: out }));
792
- } else {
793
- printShare(pack, { dryRun });
794
- }
795
- if (dryRun) return;
796
- fs.writeFileSync(out, JSON.stringify(pack), 'utf8');
797
- // --json 时 stdout 只许那一份 JSON:后面追一行「已写出」会把 JSON.parse 撞在
798
- // position 536(jq 同理)。落盘路径已在上面的 payload 里(file 键),这行只对
799
- // 人有用,就只给人看(同 cmdReclaim:json 模式打印完就结束)。
800
- if (!cfg.jsonMode) console.log(` 已写出: ${out}`);
801
- }
802
-
803
- // 入库面(车道 C)。包文件在**本地**读、把包内容整个送上去:服务端永远看不到路径,
804
- // 也就不需要任何文件系统权限——这是 receive 协议选「传包不传路径」的全部理由。
805
- // confirm 的脾气照 reclaim:缺省即干跑,写收件人的库(或物删整包)要 --apply 显式确认。
806
- async function cmdReceive(cfg, rest, flags) {
807
- requireToken(cfg);
808
- const file = rest[0];
809
- if (!file) {
810
- fail(cfg, `receive 需要包文件:${CLI_NAME} receive <包文件>(不带 --apply 只干跑,一个字都不写)`);
811
- }
812
- let pack;
813
- try {
814
- pack = JSON.parse(fs.readFileSync(expandHome(file), 'utf8'));
815
- } catch (e) {
816
- // 读坏/读不到都在发请求之前拒掉:半读出来的包发过去只会换回一个闸门错,
817
- // 而那一格现场已经说不清是「文件本来就这样」还是「传输坏了」。
818
- fail(cfg, `读不到包文件 ${file}:${String(e?.message ?? e)}\n包文件是对方 ${CLI_NAME} share --out 出来的那个 .json。`);
819
- }
820
- const applied = flagBool(flags, 'apply');
821
- const body = { pack };
822
- if (applied) body.confirm = true;
823
- const data = await apiRequest(cfg, 'POST', '/receive', { body });
824
- if (cfg.jsonMode) {
825
- console.log(stringify(data));
826
- return;
827
- }
828
- printReceive(data, { applied });
829
- if (!applied) console.log(' 没写库:确认数字之后加 --apply 真的导入');
830
- }
831
-
832
- // 撤销收的是**包文件**而不是裸 pack_id:让人抄 16 位十六进制去删自己库里的行,
833
- // 抄错一位就是「删了个没删成的包还自以为收掉了」。pack_id 就写在包文件第一层。
834
- async function cmdUndoShare(cfg, rest, flags) {
835
- requireToken(cfg);
836
- const file = rest[0];
837
- if (!file) {
838
- fail(cfg, `undo-share 需要当初那个包文件(pack_id 从文件里取):${CLI_NAME} undo-share <包文件> [--apply]`);
839
- }
840
- let pack;
841
- try {
842
- pack = JSON.parse(fs.readFileSync(expandHome(file), 'utf8'));
843
- } catch (e) {
844
- fail(cfg, `读不到包文件 ${file}:${String(e?.message ?? e)}\n要的是当初 ${CLI_NAME} share --out 出来、发给对面那个 .json。`);
845
- }
846
- // trim 在这儿做:只有空格的 pack_id 与「没有 pack_id」是同一种「这个文件收不了」,
847
- // 放它出门只是绕一圈换回 400 missing-pack-id,而那一格会被读成服务端的问题(服务端
848
- // parseUndoBody 同样 trim,这里不改它的行为,只是不必为空格 round-trip)。
849
- const packId = typeof pack?.pack_id === 'string' ? pack.pack_id.trim() : '';
850
- if (!packId) {
851
- fail(cfg, `这个文件里没有 pack_id,收起不了:${file}\nundo-share 收的是整包,编号就写在包文件的第一层,不用也不该手抄 16 位十六进制。`);
852
- }
853
- const applied = flagBool(flags, 'apply');
854
- const body = { packId };
855
- if (applied) body.confirm = true;
856
- const data = await apiRequest(cfg, 'POST', '/receive/undo', { body });
857
- if (cfg.jsonMode) {
858
- console.log(stringify(data));
859
- return;
860
- }
861
- printReceive({ ...data, created: data?.deleted, alias: '' }, { applied, undo: true });
862
- if (!applied) console.log(' 没删:确认条数之后加 --apply 真的收起这个包');
863
- }
864
-
865
- function printReceive(d, { applied, undo = false }) {
866
- const conflicts = Array.isArray(d?.title_conflicts) ? d.title_conflicts : [];
867
- console.log(`${undo ? (applied ? '已收起' : '将收起') : applied ? '已导入' : '干跑'}: ${d?.created ?? 0} 条` +
868
- (d?.alias ? ` 来自 ${d.alias}` : '') + (d?.skipped_existing ? ` 已存在 ${d.skipped_existing}` : ''));
869
- if (undo && applied) {
870
- // 服务端是物删不是归档(src/share-import.js 的 deleteSharePack):这句话是 --apply
871
- // 与人之间唯一的说明书,不许软化成「移除」。干跑那一笔一个字都没动,说「直接从库里
872
- // 带走」是假话,还会把下面的样例压成「已经删掉的这几条」——干跑那侧的话术是 cmdUndo-
873
- // Share 结尾的「没删:…加 --apply」,两句不许同时出现(同一件事说两遍就没人细看)。
874
- console.log(' 物删:这些行直接从库里带走,不是归档;包文件还在,重新 receive 一遍即可再来');
875
- }
876
- if (conflicts.length) {
877
- // 撞名是 advisory 读数(src/share-import.js:只对本轮真正会新建的条目判),
878
- // 所以这里只说「这几条要你看一眼」,永远不许说「除此之外没有别的撞名」。
879
- console.log(` 撞名提醒:和你自己的条目同名 ${conflicts.length} 条(同类型同标题,不会被并入,需要你处置):`);
880
- for (const c of conflicts.slice(0, 10)) console.log(` - [${c.type}] ${c.title}`);
881
- if (conflicts.length > 10) console.log(' … 只列前 10 条');
882
- }
883
- const sample = Array.isArray(d?.sample) ? d.sample : [];
884
- if (sample.length) {
885
- // 撞名那段有标题、有截断话,样例这段原先两样都没有:服务端把 sample 截到 20
886
- // (src/share-import.js 的 DELETE_SAMPLE_MAX),CLI 再截到 10,而 deleted 才是全集。
887
- // 一个 500 行的包就这样留下 10 条无名要点,人会当成「要收走的就是这些」。
888
- // 全集那一格只报服务端的数,不报「还剩几条」——两次截断混在一起就没人能对得上账。
889
- console.log(' 样例:下面只是这一批里的几条,不是全部清单');
890
- const shown = sample.slice(0, 10);
891
- for (const s of shown) console.log(` · [${s.type}] ${s.title}`);
892
- const total = typeof d?.deleted === 'number' ? d.deleted : (d?.created ?? 0);
893
- if (shown.length < total) console.log(` … 只列前 ${shown.length} 条;整批共 ${total} 条(这个数来自服务端计数)`);
894
- }
895
- }
896
-
897
- /* ---------------------------------- 入口 ---------------------------------- */
898
- async function main(argv) {
899
- const { positionals, flags } = parseArgv(argv);
900
- const cmd = positionals[0];
901
- const rest = positionals.slice(1);
902
-
903
- if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') {
904
- console.log(USAGE);
905
- return;
906
- }
907
- if (cmd === '-V' || cmd === '--version' || cmd === 'version') {
908
- console.log(PKG_VERSION);
909
- return;
910
- }
911
-
912
- const cfg = resolveConfig(flags);
913
- cfg.jsonMode = flagBool(flags, 'json');
914
-
915
- switch (cmd) {
916
- case 'config':
917
- return cmdConfig(cfg, rest[0], rest.slice(1));
918
- case 'status':
919
- return cmdStatus(cfg);
920
- case 'list':
921
- case 'ls':
922
- return cmdList(cfg, flags);
923
- case 'search':
924
- return cmdSearch(cfg, rest, flags);
925
- case 'get':
926
- return cmdGet(cfg, rest);
927
- case 'add':
928
- return cmdAdd(cfg, flags);
929
- case 'delete':
930
- case 'rm':
931
- return cmdDelete(cfg, rest);
932
- case 'reclaim':
933
- return cmdReclaim(cfg, flags);
934
- case 'share':
935
- return cmdShare(cfg, flags);
936
- case 'receive':
937
- return cmdReceive(cfg, rest, flags);
938
- case 'undo-share':
939
- return cmdUndoShare(cfg, rest, flags);
940
- default:
941
- fail(cfg, `未知命令: ${cmd}\n运行 \`${CLI_NAME} --help\` 查看帮助。`);
942
- }
943
- }
944
-
945
- // 直接运行才进 main(import 只取 parseArgv 做单测时必须安静):npm 全局安装走
946
- // node_modules/.bin 的符号链接,argv[1] 是链接路径而非真身,所以要 realpath 两侧
947
- // 再比,不能用字符串相等。
948
- const invokedDirectly = (() => {
949
- if (process.argv[1] == null) return false;
950
- try {
951
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
952
- } catch {
953
- return false;
954
- }
955
- })();
956
-
957
- if (invokedDirectly) {
958
- main(process.argv.slice(2)).catch((err) => {
959
- console.error(err?.stack ?? String(err));
960
- process.exit(1);
961
- });
962
- }
963
-
964
- export { parseArgv, KNOWN_FLAGS, flagValue, flagBool, flagKeyOf };
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-mneme CLI —— @modusensus/dsh-mneme 外部 API 命令行客户端
4
+ *
5
+ * 零依赖 ESM(Node >= 20,使用全局 fetch)。
6
+ * 配置优先级:命令行 --url/--token > 环境变量 DSH_MNEME_URL / DSH_MNEME_TOKEN
7
+ * > 配置文件 ~/.dsh-mneme/cli.json({"url","token"})
8
+ * 默认服务地址:http://127.0.0.1:8790
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import fsp from 'node:fs/promises';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import process from 'node:process';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const CLI_NAME = 'dsh-mneme';
19
+ const DEFAULT_URL = 'http://127.0.0.1:8790';
20
+ const CONFIG_DIR = path.join(os.homedir(), '.dsh-mneme');
21
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'cli.json');
22
+ // 与 src/store.js 的 TYPES 对齐(CLI 是零依赖 HTTP 客户端,不 import 服务端
23
+ // 模块,所以这里是手写字面量)。document 是唯一例外:它只能经
24
+ // memory_register_document 铸造,saveWithDedupe 会拒绝旁路写入,所以不作为
25
+ // --type 的选项——test/cli-enums.test.js 锁住「差值恰好是 document」。
26
+ const MEMORY_TYPES = ['preference', 'project', 'decision', 'history', 'summary', 'pattern', 'rejected_solution', 'pitfall', 'constraint'];
27
+ // hybrid:API 的 searchMemories 支持(向量先、关键词补位、双路命中做分数混合,
28
+ // 见 src/api.js 的 mode 注释)。此前漏列,search --mode hybrid 会被客户端自己拒掉。
29
+ const SEARCH_MODES = ['keyword', 'vector', 'auto', 'hybrid'];
30
+ const REQUEST_TIMEOUT_MS = 10_000;
31
+
32
+ function readPkgVersion() {
33
+ try {
34
+ const pkgPath = path.resolve(
35
+ path.dirname(fileURLToPath(import.meta.url)),
36
+ '..',
37
+ 'package.json'
38
+ );
39
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? '?';
40
+ } catch {
41
+ return '?';
42
+ }
43
+ }
44
+
45
+ const PKG_VERSION = readPkgVersion();
46
+
47
+ const USAGE = `${CLI_NAME} —— DSH 记忆插件(@modusensus/dsh-mneme)外部 API 命令行客户端
48
+
49
+ 用法:
50
+ ${CLI_NAME} <命令> [参数] [选项]
51
+
52
+ 配置(优先级从高到低):
53
+ 1. 命令行参数 --url <url> / --token <token>
54
+ 2. 环境变量 DSH_MNEME_URL / DSH_MNEME_TOKEN
55
+ 3. 配置文件 ~/.dsh-mneme/cli.json(由 \`config set\` 写入)
56
+ 未配置时使用默认地址:${DEFAULT_URL}
57
+
58
+ 命令:
59
+ config set <url> <token> 写入配置文件(token 省略时保留原值)
60
+ config show 查看当前配置(token 打码显示)
61
+ config path 打印配置文件路径
62
+ status 服务状态(版本 / 记忆统计 / 实体 / 运行时长)
63
+ list [--type t] [--min-importance n] [--source s]
64
+ [--limit n] [--offset n] [--json]
65
+ 列出记忆(type: preference | project | decision | summary | history)
66
+ search <query> [--mode m] [--topk n] [--json]
67
+ 搜索记忆(mode: keyword | vector | auto)
68
+ get <id> [--json] 查看单条记忆
69
+ add --type <t> --title <t> --content <c>
70
+ [--importance n] [--tags a,b] [--source s] [--json]
71
+ 新增记忆(type: preference | project | decision | summary | history)
72
+ delete <id> 删除记忆
73
+ reclaim [--older-than n] [--apply] [--vacuum] [--json]
74
+ 存储无损回收(默认只报数字,不动数据)
75
+ --older-than n 输入快照保留窗口(天,默认 7;0 = 全部)
76
+ --apply 真的执行(不带就是 dry-run)
77
+ --vacuum 执行后整库 VACUUM(需配 --apply)
78
+ 只清「可由记忆库重建的输入快照」与「归档行的向量」,
79
+ 一条记忆都不删;收益按 VACUUM 前后体积量
80
+ share [--alias 名字] [--type a,b] [--tag 标签] --out <文件>
81
+ 打一个可外发的脱敏分享包(不改库,纯读)
82
+ --alias 名字 在包里署名,对方看到的是这个而不是你的路径
83
+ --type/--tag 收窄到某几类/某个标签(超限分批用这两个)
84
+ --out <文件> 写出包文件;--dry-run 只报数不落盘
85
+ 跳过归档/已遗忘/只读指针行/summary,密钥 PII 整条不进包
86
+ receive <包文件> [--apply]
87
+ 把对方 share 出来的包吃进自己的库(默认干跑,一个字都不写)
88
+ 外来行标 source=imported,不参与本机的巩固/降级,也不并入你的条目
89
+ --apply 才真的写;重复吃同一个包不会多出第二份
90
+ undo-share <包文件> [--apply]
91
+ 按 pack_id 收起整包(物删:包还能重新导入,归档位会占住 UNIQUE)
92
+
93
+ 选项:
94
+ --url <url> 覆盖服务地址
95
+ --token <token> 覆盖访问 token
96
+ --json 输出原始 JSON
97
+ -h, --help 显示本帮助
98
+ -V, --version 显示版本号
99
+
100
+ 说明:
101
+ token 在 DSH 面板「设置 → 外部访问」或插件设置中查看;
102
+ 除 GET /health 外,所有外部 API 路由均需 Bearer token。
103
+
104
+ 示例:
105
+ ${CLI_NAME} config set ${DEFAULT_URL} my-token
106
+ ${CLI_NAME} status
107
+ ${CLI_NAME} list --type project --limit 10
108
+ ${CLI_NAME} search "部署流程" --mode vector --topk 5
109
+ ${CLI_NAME} add --type decision --title "采用 SQLite" --content "存储层使用 node:sqlite" --importance 4 --tags 存储,决策
110
+ ${CLI_NAME} get 42
111
+ ${CLI_NAME} delete 42
112
+ ${CLI_NAME} reclaim (先看:会清几行、库多大)
113
+ ${CLI_NAME} reclaim --apply --vacuum (确认后执行,并把空间真的收回来)
114
+ ${CLI_NAME} share --dry-run (先看:会进几条、为什么跳过那些,不落盘)
115
+ ${CLI_NAME} share --out pack.json (写包;~/… 由 CLI 自己展开,PowerShell/cmd 不会)
116
+ ${CLI_NAME} receive pack.json (吃包:默认干跑,只报账,一个字都不写)
117
+ ${CLI_NAME} receive pack.json --apply (确认数字后真的吃进自己的库;重跑同一个包 created=0)
118
+ ${CLI_NAME} undo-share pack.json --apply (按包里那个 pack_id 整包收回:物删,之后还能重新导入)`;
119
+
120
+ /* ---------------------------------- 参数解析 ---------------------------------- */
121
+
122
+ // `--flag value` 形态下,「下一个词以 -- 开头就别当值」这条规则有一个致命反例:
123
+ // 值本身就可能是 `--` 开头的字符串。外部 API 的 token 由
124
+ // `crypto.randomBytes(24).toString("base64url")` 生成(src/api-standalone.js),
125
+ // base64url 的字母表含 `-`,所以约 1/4096 的 token 头两位恰好都是 `-`:
126
+ //
127
+ // dsh-mneme reclaim --url http://127.0.0.1:55423 --token --UYoOHxr6PtE2-bfqW...
128
+ //
129
+ // 旧规则把 `--UYoOHxr6PtE2-bfqW...` 当成一个新旗标(键名 `uyoohxr6pte2-bfqw...`),
130
+ // 于是 token 解析成 `true`,flagValue 视作「没给」→ 退回环境变量/配置文件 → 全空
131
+ // → 报「未配置访问 token」。而 CLI 生成 token 的另一半同样会撞:`--token=--abc`
132
+ // 走等号分支没事,`--token --abc` 就废。这不是测试的随机性,是真实用户的随机性。
133
+ //
134
+ // 修法:区分「已知旗标」与「未知词」。下一个词以 `--` 开头时,只有它确实是一个
135
+ // 已注册的旗标才当作旗标,否则当作当前 flag 的值。于是 `--limit --json` 仍按
136
+ // 旧语义把 --json 当旗标,而 `--token --UYo...` 正确取到 token。
137
+ // 已知旗标表必须与下面 flagValue/flagBool 的实际用法保持同步;漏登记的代价是
138
+ // 该旗标被当成值吞掉,所以 test/cli-flag-value.test.js 用文本锁对齐两者。
139
+ const KNOWN_FLAGS = new Set([
140
+ // 取值的旗标
141
+ 'url', 'token', 'type', 'title', 'content', 'importance', 'tags', 'source',
142
+ 'limit', 'offset', 'mode', 'topk', 'top-k', 'min-importance', 'minimportance',
143
+ 'older-than', 'olderthandays',
144
+ 'alias', 'tag', 'out',
145
+ // 布尔旗标
146
+ 'apply', 'vacuum', 'json', 'help', 'version',
147
+ 'dry-run', 'dryrun',
148
+ ]);
149
+
150
+ /** `--foo=bar` / `--foo` 取出小写键名;非旗标词返回 null。 */
151
+ function flagKeyOf(word) {
152
+ if (typeof word !== 'string' || !word.startsWith('--') || word === '--') return null;
153
+ const key = word.slice(2);
154
+ const eq = key.indexOf('=');
155
+ return (eq >= 0 ? key.slice(0, eq) : key).toLowerCase();
156
+ }
157
+
158
+ function parseArgv(argv) {
159
+ const positionals = [];
160
+ const flags = {};
161
+ for (let i = 0; i < argv.length; i++) {
162
+ const arg = argv[i];
163
+ if (arg === '--') {
164
+ positionals.push(...argv.slice(i + 1));
165
+ break;
166
+ }
167
+ if (arg.startsWith('--')) {
168
+ let key = arg.slice(2);
169
+ let value;
170
+ const eq = key.indexOf('=');
171
+ if (eq >= 0) {
172
+ value = key.slice(eq + 1);
173
+ key = key.slice(0, eq);
174
+ } else {
175
+ const next = argv[i + 1];
176
+ const nextIsFlag = next !== undefined && flagKeyOf(next) !== null && KNOWN_FLAGS.has(flagKeyOf(next));
177
+ if (next !== undefined && next !== '--' && !nextIsFlag) {
178
+ value = argv[++i];
179
+ } else {
180
+ value = true;
181
+ }
182
+ }
183
+ flags[key.toLowerCase()] = value;
184
+ } else {
185
+ positionals.push(arg);
186
+ }
187
+ }
188
+ return { positionals, flags };
189
+ }
190
+
191
+ function flagValue(flags, ...names) {
192
+ for (const name of names) {
193
+ const value = flags[name.toLowerCase()];
194
+ if (value !== undefined && value !== true && value !== '') return value;
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ function flagBool(flags, ...names) {
200
+ for (const name of names) {
201
+ const value = flags[name.toLowerCase()];
202
+ if (value !== undefined) return value === true || value === 'true' || value === '';
203
+ }
204
+ return false;
205
+ }
206
+
207
+ /* ---------------------------------- 配置解析 ---------------------------------- */
208
+
209
+ function readConfigFile() {
210
+ try {
211
+ const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
212
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
213
+ } catch {
214
+ return {};
215
+ }
216
+ }
217
+
218
+ function firstNonEmpty(...values) {
219
+ for (const value of values) {
220
+ if (typeof value === 'string' && value.trim() !== '') return value.trim();
221
+ }
222
+ return undefined;
223
+ }
224
+
225
+ function resolveConfig(flags) {
226
+ const file = readConfigFile();
227
+ const url =
228
+ firstNonEmpty(flagValue(flags, 'url'), process.env.DSH_MNEME_URL, file.url) ?? DEFAULT_URL;
229
+ const token =
230
+ firstNonEmpty(flagValue(flags, 'token'), process.env.DSH_MNEME_TOKEN, file.token) ?? '';
231
+ return { url: url.replace(/\/+$/, ''), token, jsonMode: false };
232
+ }
233
+
234
+ function maskToken(token) {
235
+ if (!token) return '(未设置)';
236
+ if (token.length <= 8) return '****';
237
+ return `${token.slice(0, 4)}****${token.slice(-4)}`;
238
+ }
239
+
240
+ /* ---------------------------------- 输出与错误 ---------------------------------- */
241
+
242
+ function stringify(value) {
243
+ return JSON.stringify(value, null, 2);
244
+ }
245
+
246
+ function fail(cfg, message, { rawJson } = {}) {
247
+ if (cfg.jsonMode) {
248
+ console.error(rawJson !== undefined ? rawJson : stringify({ error: message }));
249
+ } else {
250
+ console.error(message);
251
+ }
252
+ process.exit(1);
253
+ }
254
+
255
+ function failHttp(cfg, status, pathname, data, text) {
256
+ const serverError =
257
+ data && typeof data === 'object' && data.error
258
+ ? String(data.error)
259
+ : (text || '').slice(0, 200);
260
+ let hint = '';
261
+ if (status === 401) {
262
+ hint =
263
+ `\n访问 token 缺失或无效。token 可在 DSH 面板「设置 → 外部访问」或插件设置中查看,\n` +
264
+ `然后用 \`${CLI_NAME} config set <url> <token>\`、环境变量 DSH_MNEME_TOKEN 或 --token 配置。`;
265
+ } else if (status === 404) {
266
+ hint = `\n资源不存在:${pathname}`;
267
+ }
268
+ // 拒答体自己带的下一步必须转出去,不然命令侧只能再写一份错误分支。撞上的这一刀是
269
+ // share 的 413:响应体是 {error:"too-large", bytes, limitBytes, hint, report},
270
+ // 旧实现只取 data.error,于是现场只剩「请求失败(HTTP 413):too-large」——「大了
271
+ // 多少」与「用 --type/--tag 分批」那两句留在体里没人看得见(措辞只许有一份,就在
272
+ // 拒发的那侧,同 RECLAIM_CONFIRM_HINT 的口径)。完整 report(含 skip_list)走
273
+ // --json:下面 rawJson 把整个响应体原样交给 stderr,这里不复制第二份打印器。
274
+ //
275
+ // 已知取舍(C10 记档,见 docs/SHARE.md「已知还没做」):这里只转 hint / bytes /
276
+ // limitBytes 三样,所以 receive 那侧 400 的 `detail`(闸门指到哪一格)与数据面 413 的
277
+ // `limit` 到不了人类可读的输出。刻意不补:补一格就得在这里多一条按路由分支的判断,
278
+ // 而「错误文案只许有一份」是 #411 那条纪律的另一半。要逐格看就加 --json——rawJson
279
+ // 那行给的正是原样响应体,一格都不丢。
280
+ if (data && typeof data === 'object') {
281
+ if (typeof data.hint === 'string' && data.hint.trim()) hint += `\n${data.hint.trim()}`;
282
+ if (Number.isFinite(data.bytes) && Number.isFinite(data.limitBytes)) {
283
+ hint += `\n量到 ${formatBytes(data.bytes)},水位 ${formatBytes(data.limitBytes)}`;
284
+ }
285
+ }
286
+ fail(cfg, `请求失败(HTTP ${status})${serverError ? `:${serverError}` : ''} [${pathname}]${hint}`, {
287
+ rawJson: cfg.jsonMode && text ? text : undefined,
288
+ });
289
+ }
290
+
291
+ function failNetwork(cfg, url, err) {
292
+ const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
293
+ const reason = timedOut
294
+ ? `请求超时(${REQUEST_TIMEOUT_MS / 1000} 秒)`
295
+ : `无法连接到服务(${err?.code ?? err?.cause?.code ?? err?.message ?? '未知错误'})`;
296
+ fail(
297
+ cfg,
298
+ `${reason}:${url}\n` +
299
+ `请确认 DSH 已启动、插件外部 API 已开启,且地址正确(默认 ${DEFAULT_URL})。`
300
+ );
301
+ }
302
+
303
+ function requireToken(cfg) {
304
+ if (cfg.token) return;
305
+ fail(
306
+ cfg,
307
+ [
308
+ '未配置访问 token,无法调用该命令(外部 API 需要 Bearer 鉴权)。',
309
+ '',
310
+ 'token 可在 DSH 面板「设置 → 外部访问」或插件设置中查看,然后任选其一配置:',
311
+ ` 1. ${CLI_NAME} config set <url> <token>`,
312
+ ' 2. 环境变量 DSH_MNEME_TOKEN=<token>',
313
+ ' 3. 命令行参数 --token <token>',
314
+ ].join('\n')
315
+ );
316
+ }
317
+
318
+ function parseIntArg(cfg, value, label) {
319
+ if (value === undefined || value === true || value === '') return undefined;
320
+ const n = Number(value);
321
+ if (!Number.isInteger(n) || n < 0) {
322
+ fail(cfg, `无效的 ${label}: ${value}(应为非负整数)`);
323
+ }
324
+ return n;
325
+ }
326
+
327
+ /* ---------------------------------- HTTP 请求 ---------------------------------- */
328
+
329
+ async function apiRequest(cfg, method, pathname, { query, body } = {}) {
330
+ let url = cfg.url + pathname;
331
+ if (query) {
332
+ const params = new URLSearchParams();
333
+ for (const [key, value] of Object.entries(query)) {
334
+ if (value !== undefined && value !== null && value !== '') {
335
+ params.set(key, String(value));
336
+ }
337
+ }
338
+ const qs = params.toString();
339
+ if (qs) url += `?${qs}`;
340
+ }
341
+
342
+ const headers = { Accept: 'application/json' };
343
+ if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
344
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
345
+
346
+ let res;
347
+ try {
348
+ res = await fetch(url, {
349
+ method,
350
+ headers,
351
+ body: body !== undefined ? JSON.stringify(body) : undefined,
352
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
353
+ });
354
+ } catch (err) {
355
+ failNetwork(cfg, url, err);
356
+ }
357
+
358
+ const text = await res.text();
359
+ let data = null;
360
+ try {
361
+ data = text ? JSON.parse(text) : null;
362
+ } catch {
363
+ // 非 JSON 响应体,保留原始文本用于报错展示
364
+ }
365
+
366
+ if (!res.ok) {
367
+ failHttp(cfg, res.status, pathname, data, text);
368
+ }
369
+ return data;
370
+ }
371
+
372
+ /* ---------------------------------- 结果格式化 ---------------------------------- */
373
+
374
+ function formatTimestamp(value) {
375
+ if (!value) return '';
376
+ const s = String(value);
377
+ return s.length >= 10 ? s.slice(0, 10) : s;
378
+ }
379
+
380
+ function formatMemoryLine(item) {
381
+ const id = item?.id ?? '?';
382
+ const type = item?.type ?? '?';
383
+ const importance = item?.importance != null ? ` ★${item.importance}` : '';
384
+ const title = item?.title ? ` ${item.title}` : '';
385
+ const tags =
386
+ Array.isArray(item?.tags) && item.tags.length ? ` [${item.tags.join(',')}]` : '';
387
+ const date = formatTimestamp(item?.created_at ?? item?.createdAt);
388
+ return ` #${id} [${type}]${importance}${title}${tags}${date ? ` (${date})` : ''}`;
389
+ }
390
+
391
+ function printMemoryItems(payload, { withMode = false } = {}) {
392
+ const items = Array.isArray(payload?.items) ? payload.items : [];
393
+ const total = payload?.total ?? items.length;
394
+ if (withMode && payload?.mode) console.log(`搜索模式: ${payload.mode}`);
395
+ console.log(`共 ${total} 条,本次显示 ${items.length} 条`);
396
+ if (!items.length) return;
397
+ console.log('');
398
+ for (const item of items) {
399
+ const score = item?.score ?? item?.similarity;
400
+ const scoreText =
401
+ typeof score === 'number' && Number.isFinite(score) ? ` (score ${score.toFixed(3)})` : '';
402
+ console.log(formatMemoryLine(item) + scoreText);
403
+ const content =
404
+ typeof item?.content === 'string' ? item.content.replace(/\s+/g, ' ').trim() : '';
405
+ if (content) {
406
+ console.log(` ${content.length > 80 ? `${content.slice(0, 80)}…` : content}`);
407
+ }
408
+ }
409
+ }
410
+
411
+ function printMemoryDetail(item) {
412
+ if (!item || typeof item !== 'object') {
413
+ console.log(stringify(item ?? null));
414
+ return;
415
+ }
416
+ const fields = [];
417
+ if (item.id !== undefined) fields.push(`id: ${item.id}`);
418
+ if (item.type !== undefined) fields.push(`类型: ${item.type}`);
419
+ if (item.importance !== undefined) fields.push(`重要性: ${item.importance}`);
420
+ if (item.title !== undefined) fields.push(`标题: ${item.title}`);
421
+ if (item.tags !== undefined) {
422
+ fields.push(`标签: ${Array.isArray(item.tags) ? item.tags.join(', ') : item.tags}`);
423
+ }
424
+ if (item.source !== undefined) fields.push(`来源: ${item.source}`);
425
+ const created = item.created_at ?? item.createdAt;
426
+ if (created) fields.push(`创建时间: ${created}`);
427
+ for (const line of fields) console.log(line);
428
+ if (item.content !== undefined) console.log(`\n${item.content}`);
429
+ }
430
+
431
+ function printStatus(data) {
432
+ console.log(`服务版本: ${data?.version ?? '?'}`);
433
+ console.log(`运行时长: ${data?.uptime_s ?? data?.uptimeS ?? '?'} 秒`);
434
+ const memories = data?.memories ?? {};
435
+ console.log(`记忆总数: ${memories.total ?? '?'}`);
436
+ const byType = memories.byType;
437
+ if (Array.isArray(byType)) {
438
+ for (const row of byType) {
439
+ console.log(` - ${row?.type ?? row?.name ?? '?'}: ${row?.count ?? row?.total ?? '?'}`);
440
+ }
441
+ } else if (byType && typeof byType === 'object') {
442
+ for (const [type, count] of Object.entries(byType)) {
443
+ console.log(` - ${type}: ${count}`);
444
+ }
445
+ }
446
+ const entities = data?.entities;
447
+ if (entities != null) {
448
+ const total =
449
+ typeof entities === 'object' && !Array.isArray(entities)
450
+ ? entities.total
451
+ : entities;
452
+ console.log(`实体数量: ${total ?? '?'}`);
453
+ }
454
+ }
455
+
456
+ /* ---------------------------------- 子命令 ---------------------------------- */
457
+
458
+ async function cmdConfig(cfg, sub, rest) {
459
+ const subCmd = sub || 'show';
460
+
461
+ if (subCmd === 'set') {
462
+ const [url, tokenArg] = rest;
463
+ if (!url) {
464
+ fail(
465
+ cfg,
466
+ `用法: ${CLI_NAME} config set <url> <token>\n示例: ${CLI_NAME} config set ${DEFAULT_URL} my-token`
467
+ );
468
+ }
469
+ if (!/^https?:\/\//i.test(url)) {
470
+ fail(cfg, `无效的服务地址: ${url}(应以 http:// 或 https:// 开头)`);
471
+ }
472
+ const existing = readConfigFile();
473
+ const token = tokenArg ?? existing.token ?? '';
474
+ await fsp.mkdir(CONFIG_DIR, { recursive: true });
475
+ await fsp.writeFile(CONFIG_PATH, `${JSON.stringify({ url, token }, null, 2)}\n`, 'utf8');
476
+ if (cfg.jsonMode) {
477
+ console.log(stringify({ configPath: CONFIG_PATH, url, tokenConfigured: Boolean(token) }));
478
+ } else {
479
+ console.log(`已写入配置文件: ${CONFIG_PATH}`);
480
+ console.log(` url: ${url}`);
481
+ console.log(` token: ${maskToken(token)}`);
482
+ }
483
+ return;
484
+ }
485
+
486
+ if (subCmd === 'show') {
487
+ const hasFile = fs.existsSync(CONFIG_PATH);
488
+ const file = readConfigFile();
489
+ if (cfg.jsonMode) {
490
+ console.log(
491
+ stringify({
492
+ configPath: CONFIG_PATH,
493
+ configExists: hasFile,
494
+ url: file.url ?? DEFAULT_URL,
495
+ token: maskToken(file.token),
496
+ })
497
+ );
498
+ return;
499
+ }
500
+ if (!hasFile) {
501
+ console.log(`尚未创建配置文件(未配置): ${CONFIG_PATH}`);
502
+ console.log('');
503
+ console.log(`可运行 \`${CLI_NAME} config set <url> <token>\` 创建配置;`);
504
+ console.log('token 可在 DSH 面板「设置 → 外部访问」或插件设置中查看。');
505
+ console.log(
506
+ `未配置时使用默认地址 ${DEFAULT_URL},也可用环境变量 DSH_MNEME_URL / DSH_MNEME_TOKEN。`
507
+ );
508
+ return;
509
+ }
510
+ console.log(`配置文件: ${CONFIG_PATH}`);
511
+ console.log(` url: ${file.url ?? `(未设置,默认 ${DEFAULT_URL})`}`);
512
+ console.log(` token: ${maskToken(file.token)}`);
513
+ console.log('');
514
+ console.log('配置优先级: --url/--token > 环境变量 DSH_MNEME_URL / DSH_MNEME_TOKEN > 配置文件');
515
+ return;
516
+ }
517
+
518
+ if (subCmd === 'path') {
519
+ console.log(CONFIG_PATH);
520
+ return;
521
+ }
522
+
523
+ fail(cfg, `未知的 config 子命令: ${subCmd}\n可用子命令: set / show / path`);
524
+ }
525
+
526
+ async function cmdStatus(cfg) {
527
+ requireToken(cfg);
528
+ const data = await apiRequest(cfg, 'GET', '/status');
529
+ if (cfg.jsonMode) {
530
+ console.log(stringify(data));
531
+ } else {
532
+ printStatus(data);
533
+ }
534
+ }
535
+
536
+ async function cmdList(cfg, flags) {
537
+ requireToken(cfg);
538
+ const query = {
539
+ type: flagValue(flags, 'type'),
540
+ minImportance: parseIntArg(cfg, flagValue(flags, 'min-importance', 'minImportance'), '--min-importance'),
541
+ source: flagValue(flags, 'source'),
542
+ limit: parseIntArg(cfg, flagValue(flags, 'limit'), '--limit'),
543
+ offset: parseIntArg(cfg, flagValue(flags, 'offset'), '--offset'),
544
+ };
545
+ const data = await apiRequest(cfg, 'GET', '/memories', { query });
546
+ if (cfg.jsonMode) {
547
+ console.log(stringify(data));
548
+ } else {
549
+ printMemoryItems(data);
550
+ }
551
+ }
552
+
553
+ async function cmdSearch(cfg, rest, flags) {
554
+ requireToken(cfg);
555
+ const q = rest[0];
556
+ if (!q) {
557
+ fail(
558
+ cfg,
559
+ `用法: ${CLI_NAME} search <query> [--mode m] [--topk n]\n` +
560
+ `示例: ${CLI_NAME} search "部署流程" --mode vector --topk 5`
561
+ );
562
+ }
563
+ const mode = flagValue(flags, 'mode');
564
+ if (mode !== undefined && !SEARCH_MODES.includes(mode)) {
565
+ fail(cfg, `无效的 --mode: ${mode}(可选: ${SEARCH_MODES.join(' | ')})`);
566
+ }
567
+ const topK = parseIntArg(cfg, flagValue(flags, 'topk', 'top-k', 'topK'), '--topk');
568
+ const data = await apiRequest(cfg, 'GET', '/search', { query: { q, mode, topK } });
569
+ if (cfg.jsonMode) {
570
+ console.log(stringify(data));
571
+ } else {
572
+ printMemoryItems(data, { withMode: true });
573
+ }
574
+ }
575
+
576
+ async function cmdGet(cfg, rest) {
577
+ requireToken(cfg);
578
+ const id = rest[0];
579
+ if (!id) {
580
+ fail(cfg, `用法: ${CLI_NAME} get <id> [--json]`);
581
+ }
582
+ const data = await apiRequest(cfg, 'GET', `/memories/${encodeURIComponent(id)}`);
583
+ if (cfg.jsonMode) {
584
+ console.log(stringify(data));
585
+ } else {
586
+ printMemoryDetail(data);
587
+ }
588
+ }
589
+
590
+ async function cmdAdd(cfg, flags) {
591
+ requireToken(cfg);
592
+ const type = flagValue(flags, 'type');
593
+ const title = flagValue(flags, 'title');
594
+ const content = flagValue(flags, 'content');
595
+ if (!type || !title || !content) {
596
+ fail(
597
+ cfg,
598
+ `用法: ${CLI_NAME} add --type <${MEMORY_TYPES.join('|')}> --title <标题> --content <内容>\n` +
599
+ ` [--importance n] [--tags a,b] [--source s]\n\n` +
600
+ `示例: ${CLI_NAME} add --type decision --title "采用 SQLite" --content "存储层使用 node:sqlite"`
601
+ );
602
+ }
603
+ if (!MEMORY_TYPES.includes(type)) {
604
+ fail(cfg, `无效的 --type: ${type}(可选: ${MEMORY_TYPES.join(' | ')})`);
605
+ }
606
+ const importanceRaw = flagValue(flags, 'importance');
607
+ let importance;
608
+ if (importanceRaw !== undefined) {
609
+ importance = Number(importanceRaw);
610
+ if (!Number.isInteger(importance) || importance < 1 || importance > 5) {
611
+ fail(cfg, `无效的 --importance: ${importanceRaw}(应为 1-5 的整数)`);
612
+ }
613
+ }
614
+ const tagsRaw = flagValue(flags, 'tags');
615
+ const tags =
616
+ tagsRaw !== undefined
617
+ ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean)
618
+ : undefined;
619
+ const source = flagValue(flags, 'source');
620
+
621
+ const body = { type, title, content };
622
+ if (importance !== undefined) body.importance = importance;
623
+ if (tags) body.tags = tags;
624
+ if (source) body.source = source;
625
+
626
+ const data = await apiRequest(cfg, 'POST', '/memories', { body });
627
+ if (cfg.jsonMode) {
628
+ console.log(stringify(data));
629
+ } else {
630
+ console.log('已添加记忆:');
631
+ printMemoryDetail(data);
632
+ }
633
+ }
634
+
635
+ async function cmdDelete(cfg, rest) {
636
+ requireToken(cfg);
637
+ const id = rest[0];
638
+ if (!id) {
639
+ fail(cfg, `用法: ${CLI_NAME} delete <id>`);
640
+ }
641
+ const data = await apiRequest(cfg, 'DELETE', `/memories/${encodeURIComponent(id)}`);
642
+ if (cfg.jsonMode) {
643
+ console.log(stringify(data));
644
+ } else {
645
+ console.log(`已删除记忆 ${id}`);
646
+ }
647
+ }
648
+
649
+ function formatBytes(bytes) {
650
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return '?';
651
+ const mb = bytes / (1024 * 1024);
652
+ if (mb >= 1) return `${mb.toFixed(1)} MB`;
653
+ return `${(bytes / 1024).toFixed(1)} KB`;
654
+ }
655
+
656
+ function printReclaim(report, { applied }) {
657
+ const planned = report?.planned ?? {};
658
+ const cleared = report?.cleared ?? {};
659
+ const columnBytes = report?.column_bytes ?? {};
660
+ const size = report?.size ?? {};
661
+ console.log(`存储无损回收(${applied ? '已执行' : 'dry-run,未改动任何数据'})`);
662
+ console.log(` 输入快照保留窗口: ${report?.olderThanDays ?? '?'} 天(cutoff ${report?.cutoff ?? '?'})`);
663
+ // 第三项(#399):蒸馏原文的到期裁剪同样只删 distill_records,溯源链接刻意保留
664
+ const rows = (o) =>
665
+ `dream_runs.input ${o.dream_run_inputs ?? 0} 行 / 归档行向量 ${o.archived_embeddings ?? 0} 行 / 蒸馏原文 ${o.distill_records ?? 0} 行`;
666
+ console.log(` ${applied ? '已清理' : '将清理'}: ${rows(applied ? cleared : planned)}`);
667
+ console.log(
668
+ ` 列文本大小(上界,非回收量): ${formatBytes(columnBytes.dream_run_inputs)} / ${formatBytes(columnBytes.archived_embeddings)} / ${formatBytes(columnBytes.distill_records)}`
669
+ );
670
+ if (!applied) {
671
+ console.log(
672
+ ` 库文件: ${formatBytes(size.before_bytes)}(空闲页 ${size.freelist_pages ?? '?'} 页,可立刻回收约 ${formatBytes(size.freelist_bytes)})`
673
+ );
674
+ console.log('');
675
+ console.log(' 上面是 dry-run 的数字。确认后执行:');
676
+ console.log(` ${CLI_NAME} reclaim --apply --vacuum`);
677
+ return;
678
+ }
679
+ const freed =
680
+ typeof size.before_bytes === 'number' && typeof size.after_bytes === 'number'
681
+ ? size.before_bytes - size.after_bytes
682
+ : null;
683
+ if (report?.vacuum?.error) {
684
+ console.log(` VACUUM: 失败(${String(report.vacuum.error).slice(0, 120)})`);
685
+ console.log(' 清理已经完成且不可逆,文件暂时不会变小;库多半被别的进程占着锁,');
686
+ console.log(' 稍后重跑一次 `reclaim --apply --vacuum` 即可(清理是幂等的)。');
687
+ } else {
688
+ console.log(
689
+ ` VACUUM: ${report?.vacuum?.ran ? `已跑(${report.vacuum.duration_ms ?? '?'} ms)` : '未跑(未带 --vacuum,文件不会变小)'}`
690
+ );
691
+ }
692
+ console.log(
693
+ ` 库文件: ${formatBytes(size.before_bytes)} → ${formatBytes(size.after_bytes)}` +
694
+ (freed !== null && freed > 0 ? `(释放 ${formatBytes(freed)})` : '')
695
+ );
696
+ console.log(` 耗时: ${report?.duration_ms ?? '?'} ms`);
697
+ if (report?.receipt) console.log(` 回执: llm_audit_logs #${report.receipt}`);
698
+ }
699
+
700
+ async function cmdReclaim(cfg, flags) {
701
+ requireToken(cfg);
702
+ const applied = flagBool(flags, 'apply');
703
+ const vacuum = flagBool(flags, 'vacuum');
704
+ if (vacuum && !applied) {
705
+ fail(
706
+ cfg,
707
+ `--vacuum 需要配 --apply:dry-run 不改数据,也就没有可回收的页。\n` +
708
+ `先看数字,再执行:${CLI_NAME} reclaim --apply --vacuum`
709
+ );
710
+ }
711
+ const olderThanDays = parseIntArg(cfg, flagValue(flags, 'older-than', 'olderThanDays'), '--older-than');
712
+ const body = { dryRun: !applied };
713
+ if (applied) {
714
+ // 服务端要求显式确认:这两步不可逆,缺 confirm 只会拿回一份 dry-run 报告。
715
+ body.confirm = true;
716
+ }
717
+ if (olderThanDays !== undefined) body.olderThanDays = olderThanDays;
718
+ if (vacuum) body.vacuum = true;
719
+ const data = await apiRequest(cfg, 'POST', '/maintenance/reclaim', { body });
720
+ if (cfg.jsonMode) {
721
+ console.log(stringify(data));
722
+ } else {
723
+ printReclaim(data, { applied });
724
+ }
725
+ }
726
+
727
+ // --out 的例子写的是 `~/Desktop/pack.json`。Git Bash 会替她展开,PowerShell 与 cmd
728
+ // 不会——那两侧 `~/…` 是字面量,writeFileSync 直接 ENOENT,现场只剩一坨堆栈。CLI 不
729
+ // 接管 shell 的活,但引号里这一格得自己补(同 CONFIG_DIR 走 os.homedir())。
730
+ function expandHome(p) {
731
+ if (typeof p !== 'string') return p;
732
+ if (p !== '~' && !p.startsWith('~/') && !p.startsWith('~\\')) return p;
733
+ const rest = p.slice(1).replace(/^[/\\]+/, '');
734
+ return path.join(os.homedir(), rest);
735
+ }
736
+
737
+ function printShare(pack, { dryRun }) {
738
+ const r = pack?.report ?? {};
739
+ const s = r.skipped ?? {};
740
+ console.log(`${dryRun ? '干跑报告' : '分享包'}: ${r.included ?? 0} 条` +
741
+ (pack?.alias ? ` 来自 ${pack.alias}` : ''));
742
+ const named = [['archived', '归档'], ['forgotten', '已遗忘'], ['readonly_type', '只读指针行'],
743
+ ['excluded_type', '不随包走'], ['blank_or_noise', '空白/噪声'], ['sensitive', '密钥/PII']];
744
+ const parts = named.filter(([k]) => (s[k] ?? 0) > 0).map(([k, zh]) => `${zh} ${s[k]}`);
745
+ console.log(` 跳过: ${parts.length ? parts.join(', ') : '无'}`);
746
+ // 折叠不是跳过:那条记忆够格进包,只是包里已经有了它(同标题同正文同内容锚、scope
747
+ // 不同的两条)。不印出来的话 `included` 会比她心里的「够格的条数」少几条,而那正好是
748
+ // 会让人怀疑口径少发了的那类差额。src/share.js 的 report.deduped。
749
+ if ((r.deduped ?? 0) > 0) console.log(` 包内重复已折叠: ${r.deduped} 条(对面只吃一条)`);
750
+ for (const item of (r.skip_list ?? []).slice(0, 20)) {
751
+ // 标题为空有两种:这条本来没标题,或它整行被密钥判据命中、标题在源头就留空了
752
+ // (src/share.js:skip_list 随包外发,而凭据常常就写在标题里)。终端要让人看清
753
+ // 后一种是「有意不给」,不是「数据缺失」。
754
+ console.log(` - [${item.reason}] ${item.title || '(标题不外发)'}`);
755
+ }
756
+ // 判据用「六个计数之和 > 清单长度」而不是「清单等于 50」:前者是「账比清单长」这个事实本身,
757
+ // 恰好 50 条被跳时后者会误报截断(面板侧同一条判据)。50 这个数不在这里出现,
758
+ // 水位改了判据仍然对。
759
+ const skippedTotal = ['archived', 'forgotten', 'readonly_type', 'excluded_type', 'blank_or_noise', 'sensitive']
760
+ .reduce((n, k) => n + (r.skipped?.[k] ?? 0), 0);
761
+ if (skippedTotal > (r.skip_list ?? []).length) console.log(' … 跳过清单已截断,计数仍为全量');
762
+ if (dryRun) console.log(` 没写文件:加 --out <路径> 才会落盘`);
763
+ else console.log(` 发给对方即可,她用自己的 ${CLI_NAME} receive 吃进去`);
764
+ }
765
+
766
+ async function cmdShare(cfg, flags) {
767
+ requireToken(cfg);
768
+ const out = expandHome(flagValue(flags, 'out'));
769
+ const dryRun = flagBool(flags, 'dry-run') || flagBool(flags, 'dryrun');
770
+ if (!out && !dryRun) {
771
+ fail(
772
+ cfg,
773
+ `share 要么写文件(--out <路径>),要么只看报告(--dry-run):` +
774
+ `不写文件又把整库打到 stdout 会让报告被 JSON 淹掉。\n` +
775
+ `例子:${CLI_NAME} share --out ~/Desktop/pack.json`
776
+ );
777
+ }
778
+ const typeRaw = flagValue(flags, 'type');
779
+ const body = {};
780
+ if (typeRaw) body.type = typeRaw.split(',').map((t) => t.trim()).filter(Boolean);
781
+ const alias = flagValue(flags, 'alias');
782
+ if (alias) body.alias = alias;
783
+ const tag = flagValue(flags, 'tag');
784
+ if (tag) body.tag = tag;
785
+ // 超水位那次服务端答 413,apiRequest 对非 2xx 走的是 failHttp:它把响应体里的 hint
786
+ // 与量到的字节转出去再 exit(1)。所以这里**没有**可达的 error 分支——加 `if
787
+ // (pack?.error)` 就是第二份出口,措辞迟早跟拒发那一侧分家(#411 的同一口径)。
788
+ const pack = await apiRequest(cfg, 'POST', '/share', { body });
789
+ const r = pack?.report ?? {};
790
+ if (cfg.jsonMode) {
791
+ console.log(stringify(dryRun ? { report: r, pack } : { report: r, file: out }));
792
+ } else {
793
+ printShare(pack, { dryRun });
794
+ }
795
+ if (dryRun) return;
796
+ fs.writeFileSync(out, JSON.stringify(pack), 'utf8');
797
+ // --json 时 stdout 只许那一份 JSON:后面追一行「已写出」会把 JSON.parse 撞在
798
+ // position 536(jq 同理)。落盘路径已在上面的 payload 里(file 键),这行只对
799
+ // 人有用,就只给人看(同 cmdReclaim:json 模式打印完就结束)。
800
+ if (!cfg.jsonMode) console.log(` 已写出: ${out}`);
801
+ }
802
+
803
+ // 入库面(车道 C)。包文件在**本地**读、把包内容整个送上去:服务端永远看不到路径,
804
+ // 也就不需要任何文件系统权限——这是 receive 协议选「传包不传路径」的全部理由。
805
+ // confirm 的脾气照 reclaim:缺省即干跑,写收件人的库(或物删整包)要 --apply 显式确认。
806
+ async function cmdReceive(cfg, rest, flags) {
807
+ requireToken(cfg);
808
+ const file = rest[0];
809
+ if (!file) {
810
+ fail(cfg, `receive 需要包文件:${CLI_NAME} receive <包文件>(不带 --apply 只干跑,一个字都不写)`);
811
+ }
812
+ let pack;
813
+ try {
814
+ pack = JSON.parse(fs.readFileSync(expandHome(file), 'utf8'));
815
+ } catch (e) {
816
+ // 读坏/读不到都在发请求之前拒掉:半读出来的包发过去只会换回一个闸门错,
817
+ // 而那一格现场已经说不清是「文件本来就这样」还是「传输坏了」。
818
+ fail(cfg, `读不到包文件 ${file}:${String(e?.message ?? e)}\n包文件是对方 ${CLI_NAME} share --out 出来的那个 .json。`);
819
+ }
820
+ const applied = flagBool(flags, 'apply');
821
+ const body = { pack };
822
+ if (applied) body.confirm = true;
823
+ const data = await apiRequest(cfg, 'POST', '/receive', { body });
824
+ if (cfg.jsonMode) {
825
+ console.log(stringify(data));
826
+ return;
827
+ }
828
+ printReceive(data, { applied });
829
+ if (!applied) console.log(' 没写库:确认数字之后加 --apply 真的导入');
830
+ }
831
+
832
+ // 撤销收的是**包文件**而不是裸 pack_id:让人抄 16 位十六进制去删自己库里的行,
833
+ // 抄错一位就是「删了个没删成的包还自以为收掉了」。pack_id 就写在包文件第一层。
834
+ async function cmdUndoShare(cfg, rest, flags) {
835
+ requireToken(cfg);
836
+ const file = rest[0];
837
+ if (!file) {
838
+ fail(cfg, `undo-share 需要当初那个包文件(pack_id 从文件里取):${CLI_NAME} undo-share <包文件> [--apply]`);
839
+ }
840
+ let pack;
841
+ try {
842
+ pack = JSON.parse(fs.readFileSync(expandHome(file), 'utf8'));
843
+ } catch (e) {
844
+ fail(cfg, `读不到包文件 ${file}:${String(e?.message ?? e)}\n要的是当初 ${CLI_NAME} share --out 出来、发给对面那个 .json。`);
845
+ }
846
+ // trim 在这儿做:只有空格的 pack_id 与「没有 pack_id」是同一种「这个文件收不了」,
847
+ // 放它出门只是绕一圈换回 400 missing-pack-id,而那一格会被读成服务端的问题(服务端
848
+ // parseUndoBody 同样 trim,这里不改它的行为,只是不必为空格 round-trip)。
849
+ const packId = typeof pack?.pack_id === 'string' ? pack.pack_id.trim() : '';
850
+ if (!packId) {
851
+ fail(cfg, `这个文件里没有 pack_id,收起不了:${file}\nundo-share 收的是整包,编号就写在包文件的第一层,不用也不该手抄 16 位十六进制。`);
852
+ }
853
+ const applied = flagBool(flags, 'apply');
854
+ const body = { packId };
855
+ if (applied) body.confirm = true;
856
+ const data = await apiRequest(cfg, 'POST', '/receive/undo', { body });
857
+ if (cfg.jsonMode) {
858
+ console.log(stringify(data));
859
+ return;
860
+ }
861
+ printReceive({ ...data, created: data?.deleted, alias: '' }, { applied, undo: true });
862
+ if (!applied) console.log(' 没删:确认条数之后加 --apply 真的收起这个包');
863
+ }
864
+
865
+ function printReceive(d, { applied, undo = false }) {
866
+ const conflicts = Array.isArray(d?.title_conflicts) ? d.title_conflicts : [];
867
+ console.log(`${undo ? (applied ? '已收起' : '将收起') : applied ? '已导入' : '干跑'}: ${d?.created ?? 0} 条` +
868
+ (d?.alias ? ` 来自 ${d.alias}` : '') + (d?.skipped_existing ? ` 已存在 ${d.skipped_existing}` : ''));
869
+ if (undo && applied) {
870
+ // 服务端是物删不是归档(src/share-import.js 的 deleteSharePack):这句话是 --apply
871
+ // 与人之间唯一的说明书,不许软化成「移除」。干跑那一笔一个字都没动,说「直接从库里
872
+ // 带走」是假话,还会把下面的样例压成「已经删掉的这几条」——干跑那侧的话术是 cmdUndo-
873
+ // Share 结尾的「没删:…加 --apply」,两句不许同时出现(同一件事说两遍就没人细看)。
874
+ console.log(' 物删:这些行直接从库里带走,不是归档;包文件还在,重新 receive 一遍即可再来');
875
+ }
876
+ if (conflicts.length) {
877
+ // 撞名是 advisory 读数(src/share-import.js:只对本轮真正会新建的条目判),
878
+ // 所以这里只说「这几条要你看一眼」,永远不许说「除此之外没有别的撞名」。
879
+ console.log(` 撞名提醒:和你自己的条目同名 ${conflicts.length} 条(同类型同标题,不会被并入,需要你处置):`);
880
+ for (const c of conflicts.slice(0, 10)) console.log(` - [${c.type}] ${c.title}`);
881
+ if (conflicts.length > 10) console.log(' … 只列前 10 条');
882
+ }
883
+ const sample = Array.isArray(d?.sample) ? d.sample : [];
884
+ if (sample.length) {
885
+ // 撞名那段有标题、有截断话,样例这段原先两样都没有:服务端把 sample 截到 20
886
+ // (src/share-import.js 的 DELETE_SAMPLE_MAX),CLI 再截到 10,而 deleted 才是全集。
887
+ // 一个 500 行的包就这样留下 10 条无名要点,人会当成「要收走的就是这些」。
888
+ // 全集那一格只报服务端的数,不报「还剩几条」——两次截断混在一起就没人能对得上账。
889
+ console.log(' 样例:下面只是这一批里的几条,不是全部清单');
890
+ const shown = sample.slice(0, 10);
891
+ for (const s of shown) console.log(` · [${s.type}] ${s.title}`);
892
+ const total = typeof d?.deleted === 'number' ? d.deleted : (d?.created ?? 0);
893
+ if (shown.length < total) console.log(` … 只列前 ${shown.length} 条;整批共 ${total} 条(这个数来自服务端计数)`);
894
+ }
895
+ }
896
+
897
+ /* ---------------------------------- 入口 ---------------------------------- */
898
+ async function main(argv) {
899
+ const { positionals, flags } = parseArgv(argv);
900
+ const cmd = positionals[0];
901
+ const rest = positionals.slice(1);
902
+
903
+ if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') {
904
+ console.log(USAGE);
905
+ return;
906
+ }
907
+ if (cmd === '-V' || cmd === '--version' || cmd === 'version') {
908
+ console.log(PKG_VERSION);
909
+ return;
910
+ }
911
+
912
+ const cfg = resolveConfig(flags);
913
+ cfg.jsonMode = flagBool(flags, 'json');
914
+
915
+ switch (cmd) {
916
+ case 'config':
917
+ return cmdConfig(cfg, rest[0], rest.slice(1));
918
+ case 'status':
919
+ return cmdStatus(cfg);
920
+ case 'list':
921
+ case 'ls':
922
+ return cmdList(cfg, flags);
923
+ case 'search':
924
+ return cmdSearch(cfg, rest, flags);
925
+ case 'get':
926
+ return cmdGet(cfg, rest);
927
+ case 'add':
928
+ return cmdAdd(cfg, flags);
929
+ case 'delete':
930
+ case 'rm':
931
+ return cmdDelete(cfg, rest);
932
+ case 'reclaim':
933
+ return cmdReclaim(cfg, flags);
934
+ case 'share':
935
+ return cmdShare(cfg, flags);
936
+ case 'receive':
937
+ return cmdReceive(cfg, rest, flags);
938
+ case 'undo-share':
939
+ return cmdUndoShare(cfg, rest, flags);
940
+ default:
941
+ fail(cfg, `未知命令: ${cmd}\n运行 \`${CLI_NAME} --help\` 查看帮助。`);
942
+ }
943
+ }
944
+
945
+ // 直接运行才进 main(import 只取 parseArgv 做单测时必须安静):npm 全局安装走
946
+ // node_modules/.bin 的符号链接,argv[1] 是链接路径而非真身,所以要 realpath 两侧
947
+ // 再比,不能用字符串相等。
948
+ const invokedDirectly = (() => {
949
+ if (process.argv[1] == null) return false;
950
+ try {
951
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
952
+ } catch {
953
+ return false;
954
+ }
955
+ })();
956
+
957
+ if (invokedDirectly) {
958
+ main(process.argv.slice(2)).catch((err) => {
959
+ console.error(err?.stack ?? String(err));
960
+ process.exit(1);
961
+ });
962
+ }
963
+
964
+ export { parseArgv, KNOWN_FLAGS, flagValue, flagBool, flagKeyOf };