@modusensus/dsh-mneme 0.8.13 → 0.8.15

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 (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -626
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -0
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -572
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -836
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -671
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -0
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -0
  65. package/lib/service.js +2663 -2596
  66. package/lib/settings.js +517 -509
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +8 -4
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -0
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -572
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -836
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -671
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -0
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -0
  137. package/src/service.js +2663 -2596
  138. package/src/settings.js +517 -509
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1688
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -0
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -61
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -343
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -0
  246. package/test/serve.test.js +196 -0
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -544
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
package/src/index.js CHANGED
@@ -1,671 +1,535 @@
1
- import { createStore } from "./store.js";
2
- import { createMirror, TYPE_FILE } from "./mirror.js";
3
- import { createDocumentIndex } from "./document-index.js";
4
- import { resolveDocumentDir } from "./document.js";
5
- import { createService } from "./service.js";
6
- // #254 写入准入(第一阶段只计量,不拦截):见 src/write-admission.js 的文件头。
7
- import { createWriteAdmission } from "./write-admission.js";
8
- // #164 A2:写入边界的密钥 / PII 判据,注入给上面的写入准入。
9
- import { createSensitiveScan } from "./sensitive-scan.js";
10
- import { createTools } from "./tools.js";
11
- import { createInjector } from "./inject.js";
12
- import { createContinuityRescue } from "./continuity.js";
13
- import { createSummarizer } from "./summarize.js";
14
- import { createDreamScheduler } from "./dream.js";
15
- import { createSleepScheduler, runSleep } from "./dream/sleep.js";
16
- import { createApi } from "./api.js";
17
- import { createStandaloneApi } from "./api-standalone.js";
18
- // #275 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
19
- import { createMaintenance } from "./maintenance.js";
20
- import { createSettings } from "./settings.js";
21
- import { createCommandManager } from "./commands.js";
22
- import { createEmbedder } from "./embedding.js";
23
- import { createEmbedderByProvider } from "./local-embedder.js";
24
- import { LocalReranker } from "./reranker.js";
25
- import { createVectorIndex } from "./vector-index.js";
26
- import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
27
- import { langOf } from "./lang.js";
28
- import { extractEntities } from "./entities/extractor.js";
29
- import { mkdirSync } from "node:fs";
30
- import { join } from "node:path";
31
- import { homedir } from "node:os";
32
-
33
- export const name = "dsh-mneme";
34
- // webServer 在 inject 声明中:cordis 会等宿主 webServer 服务激活后才 apply 本
35
- // 插件,保证 apply 时路由注册不落时序(v0.7.23 曾移出 inject 想支持 headless,
36
- // 结果 cordis 不再等待,apply 时宿主 webServer 未就绪 → 桌面端 "cannot get
37
- // property without inject" 崩溃)。守卫用 ctx.reflect.get(免 inject 读取,
38
- // 未提供返回 undefined)而非 if (ctx.webServer):直接访问未注入属性在 cordis
39
- // Proxy 下会抛错而非返回 undefined。
40
- export const inject = ["tools", "systemPrompt", "llm", "agentDefaultModel", "commands", "webServer"];
41
- export { Config };
42
-
43
- // Arrow (not function declaration): cordis 4 treats any apply with a
44
- // prototype as a class constructor (`new apply(...)`) and discards its return
45
- // value, so a `function apply` disposer would never run on unload. An arrow
46
- // has no prototype, is called normally, and its returned disposer is collected
47
- // and run by the fiber on unload.
48
- // Entity-extraction LLM adapter (issue #108/#109): maps the extractor's
49
- // options (provider/model override + reasoningEffort) onto a real dsh-llm
50
- // stream route and retries once without the effort when the first attempt is
51
- // rejected. Extracted from apply() so the effort-fallback branch is
52
- // unit-testable; the extractor only ever sees a callLLM(messages, options)
53
- // => Promise<string>. The route always carries a real provider/model (dsh-llm
54
- // GenerateOptions requires both) — never a bare stream.
55
- //
56
- // Issue #250: it also accounts for itself. Entity extraction is the background
57
- // LLM path that runs on every memory write, yet it never reached
58
- // llm_audit_logs — the adapter only ever held an llm handle, so it had no way
59
- // to call service.saveLlmAudit. It now takes service (+ config for the shared
60
- // llmAudit.enabled gate) and writes one row per stream attempt, matching the
61
- // contract of runAuditedLlm in dream.js: a rejected effort attempt records its
62
- // own error row and the retry records its own success row.
63
- export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, service, config }) {
64
- return async function streamEntityText(messages, options = {}) {
65
- let route = {};
66
- if (options.provider) route.provider = options.provider;
67
- if (options.model) route.model = options.model;
68
- if (!route.provider || !route.model) {
69
- try {
70
- const sel = agentDefaultModel?.currentSelection?.();
71
- if (sel?.provider && sel?.model) {
72
- route.provider ??= sel.provider;
73
- route.model ??= sel.model;
74
- }
75
- } catch { /* fall through to whatever route we already have */ }
76
- }
77
- const effort = options.reasoningEffort;
78
- // 没有解析出 provider/model 就没有可归属的模型——与 dream 的 resolveRoute
79
- // 无路由早退同口径,那种情况不写审计行。
80
- const modelId = route.provider && route.model ? `${route.provider}:${route.model}` : "";
81
- const tryStream = (withEffort) => {
82
- let text = "";
83
- let inputTokens = 0;
84
- let outputTokens = 0;
85
- const startedAt = Date.now();
86
- const timestamp = new Date(startedAt).toISOString();
87
- // 记账是 best-effort:写审计行失败只 warn,绝不反噬抽取本身(CONTRIBUTING
88
- // 的 fail-safe 硬约定)。
89
- const writeAudit = (status, errorMessage) => {
90
- if (config?.llmAudit?.enabled === false || !modelId || typeof service?.saveLlmAudit !== "function") return;
91
- try {
92
- service.saveLlmAudit({
93
- timestamp,
94
- trigger_source: "entityExtract",
95
- operation_type: "entity_extract",
96
- model_id: modelId,
97
- input_tokens: inputTokens,
98
- output_tokens: outputTokens,
99
- total_tokens: inputTokens + outputTokens,
100
- cost_usd: 0,
101
- duration_ms: Date.now() - startedAt,
102
- status,
103
- error_message: errorMessage,
104
- related_memory_ids: []
105
- });
106
- } catch (auditError) {
107
- logger?.warn?.(`dsh-mneme: entity extraction llm audit write failed: ${String(auditError)}`);
108
- }
109
- };
110
- return (async () => {
111
- for await (const chunk of llm.stream({
112
- ...route,
113
- maxTokens: 4096,
114
- ...(withEffort && effort ? { reasoningEffort: effort } : {}),
115
- messages
116
- })) {
117
- if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
118
- // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage);兼容直接
119
- // 平铺在 chunk 上的旧形态。归一放在这里,下面只需认平铺形状——与
120
- // dream.js 的 streamText 同一处理。
121
- if (chunk.type === "usage") {
122
- const u = chunk.usage ?? chunk;
123
- const i = u.input_tokens ?? u.inputTokens ?? u.prompt_tokens ?? u.promptTokens;
124
- const o = u.output_tokens ?? u.outputTokens ?? u.completion_tokens ?? u.completionTokens;
125
- if (Number.isFinite(i)) inputTokens = i;
126
- if (Number.isFinite(o)) outputTokens = o;
127
- }
128
- if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
129
- writeAudit("error", "llm stream aborted or errored");
130
- return undefined;
131
- }
132
- }
133
- writeAudit("success", null);
134
- return text;
135
- })().catch((err) => {
136
- logger?.warn?.(`[dsh-mneme] entity extraction llm stream failed: ${String(err)}`);
137
- writeAudit("error", String(err?.message ?? err));
138
- return undefined;
139
- });
140
- };
141
- let text = await tryStream(true);
142
- if (text === undefined && effort) {
143
- // Mirror dream's effort fallback: a provider rejecting the reasoning
144
- // effort must not sink the whole extraction — retry once without it.
145
- logger?.warn?.(`[dsh-mneme] entity extraction: reasoningEffort "${effort}" rejected, retrying without it`);
146
- text = await tryStream(false);
147
- }
148
- return text;
149
- };
150
- }
151
-
152
- /**
153
- * Issue #128: bounded backfill of rows still missing an embedding (active rows
154
- * only — needsEmbedding filters archived/forgotten). Exported for tests.
155
- *
156
- * Runs regardless of the model fingerprint: the old call-site gate returned
157
- * early when vector_meta already held the embedder's hash, permanently
158
- * orphaning rows whose embed failed at write time (embedder not ready /
159
- * provider rate limit) — one successful embed was enough to never backfill
160
- * again. markModel is idempotent when the fingerprint already matches, so
161
- * re-running costs nothing beyond the actually-missing rows.
162
- */
163
- export async function backfillMissingEmbeddings({
164
- store, embedder, vectorIndex, logger,
165
- maxTotal = 500, batchSize = 10, rateLimitMs = 200
166
- }) {
167
- let indexed = 0;
168
- for (let done = 0; done < maxTotal;) {
169
- const rows = store.needsEmbedding(batchSize);
170
- if (!rows.length) break;
171
- for (const row of rows) {
172
- try {
173
- const text = [row.title, row.content].filter(Boolean).join("\n");
174
- const vector = await embedder.embedSingle(text);
175
- if (vector?.length) {
176
- store.setEmbedding(row.id, vector);
177
- indexed++;
178
- }
179
- } catch { /* skip the bad row */ }
180
- }
181
- done += rows.length;
182
- // Rate limit: space out batches so the provider is not hammered.
183
- if (store.needsEmbedding(1).length) await new Promise((r) => setTimeout(r, rateLimitMs));
184
- }
185
- if (indexed > 0 && embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
186
- logger?.info?.(`[dsh-mneme] auto-reindex backfilled ${indexed} embeddings on boot`);
187
- return indexed;
188
- }
189
-
190
- export const apply = (ctx, config) => {
191
- const rawCfg = Config(config);
192
-
193
- // Resolve memoryDir: expand leading "~"
194
- const memoryDir = rawCfg.memoryDir.startsWith("~")
195
- ? join(homedir(), rawCfg.memoryDir.slice(1))
196
- : rawCfg.memoryDir;
197
- mkdirSync(memoryDir, { recursive: true });
198
-
199
- const store = createStore(join(memoryDir, "memory.db"));
200
- // Prune reflection failure rows older than 90 days on boot (best-effort, so
201
- // the failure table never grows unbounded).
202
- try {
203
- store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
204
- } catch { /* non-fatal */ }
205
-
206
- // User-configurable settings (profile, rules, panel mode, standalone API
207
- // token) share the same SQLite file in dedicated tables, isolated from
208
- // memories. Created before the config is finalized: the persisted
209
- // panel_mode participates in light-mode resolution below.
210
- const settings = createSettings(store.db);
211
-
212
- // Light mode (v0.7.12): the bundle config flag OR a persisted panel_mode of
213
- // "light" (the panel switch wins over the bundle config so it survives
214
- // config redeploys). applyLightModePreset turns every heavy background /
215
- // semantic feature off and keeps the core loop (autoInject, autoSummarize,
216
- // hot memory, quality filter).
217
- const lightMode = rawCfg.lightMode === true || settings.getPanelMode() === "light";
218
- // 功能开关合并顺序即优先级:用户显式开关(feature_flags kv,面板写入)>
219
- // 轻量预设(applyLightModePreset 批量置关的重型能力)> bundle 配置。预设必须
220
- // 先应用、用户开关后展开,否则 LIGHT_MODE_OFF 会把用户显式打开的开关再次
221
- // 压掉。合并结果只作用于本次启动:面板改开关后与 panel_mode 一样在下次
222
- // 启动生效。
223
- // 嵌套对象开关按首个点号拆开(kv 里平铺存的 "memoryQualityFilter.enabled" →
224
- // cfg.memoryQualityFilter.enabled),点号键不原样留在 cfg 顶层属性里。
225
- const flags = settings.getFeatureFlags();
226
- const flatFlags = {};
227
- const nestedFlags = {};
228
- for (const [key, value] of Object.entries(flags)) {
229
- const dot = key.indexOf(".");
230
- if (dot > 0) {
231
- const objKey = key.slice(0, dot);
232
- const subKey = key.slice(dot + 1);
233
- nestedFlags[objKey] = { ...(nestedFlags[objKey] ?? {}), [subKey]: value };
234
- } else {
235
- flatFlags[key] = value;
236
- }
237
- }
238
- const cfg = { ...applyLightModePreset({ ...rawCfg, lightMode }), ...flatFlags };
239
- for (const [objKey, sub] of Object.entries(nestedFlags)) {
240
- cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
241
- }
242
-
243
- // Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
244
- // cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
245
- // rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
246
- // (raw 说关 → 不清理,cfg 说开 → 照写,审计表就无保留期地长)。保留期默认 90 天、
247
- // 失败只 warn,与失败表清理同款:账本清理绝不许挡住插件启动。
248
- try {
249
- if (cfg.llmAudit?.enabled !== false) {
250
- const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
251
- store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
252
- }
253
- } catch { /* non-fatal */ }
254
-
255
- // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
256
- // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
257
- const mirror = createMirror(memoryDir, langOf(cfg));
258
-
259
- // #296 第二批:managed 文档目录。解析与 memoryDir 同一套(空 = 跟随 memoryDir 的
260
- // <memoryDir>/documents/,~ 展开,相对路径落在 memoryDir 下)。索引对象总是建:
261
- // 闸关时它只承担「清掉陈旧 index.md」这一件事,与 documents.md 在闸关时被镜像删
262
- // 掉同一口径。建目录与写索引失败都只 warn,不阻断插件加载(它们是机器产物)。
263
- const documentDir = resolveDocumentDir(memoryDir, cfg.documentDir);
264
- const documentIndex = createDocumentIndex(documentDir, langOf(cfg));
265
- if (cfg.documentMemoryEnabled === true) {
266
- const ensured = documentIndex.ensure();
267
- if (!ensured.ok) {
268
- ctx.logger?.warn?.(`[dsh-mneme] documentDir is not writable: ${documentDir}: ${ensured.error}`);
269
- }
270
- // 启动即渲染一次:索引不该等到第一次业务写才存在(删掉它之后重启也能自愈)。
271
- const synced = documentIndex.sync(store.list({ type: "document", limit: null }));
272
- if (!synced.ok) ctx.logger?.warn?.(`[dsh-mneme] document index sync failed: ${synced.error}`);
273
- } else {
274
- documentIndex.remove();
275
- }
276
-
277
- // 写入准入实例(#254)。两个开关 writeAdmission.enabled / .enforce 都默认关;
278
- // 关时它的返回与只计量那一阶段逐字段一致,写入路径完全不碰。既有的
279
- // llmAudit.enabled 关掉时它同样既不判也不写(那个开关连审计行的启动期清理一起
280
- // 关掉,在无保留期的表里按写入频次增长是不能接受的)。
281
- //
282
- // sensitiveScan 是密钥 / PII 那一类判据的注入点。按 #254 验收第 4 条它是 #164 A2
283
- // 的判据来源,现在由 src/sensitive-scan.js 实现(维护者 09-28 把 A2 的认领转给
284
- // 本侧)。这里只做接线:判据开不开由它自己的键 sensitiveScanEnabled 决定,工厂在
285
- // 关时返回 null,闸门的行为就与 #332 合并时逐字段一致(那一版根本没有这个函数);
286
- // 命中之后是仅告警还是真拦,仍是 writeAdmission.enforce 的事(#164 口径:
287
- // 默认仅告警、拦截 opt-in),判据不碰决策。
288
- const writeAdmission = createWriteAdmission({
289
- store,
290
- config: cfg,
291
- logger: ctx.logger,
292
- sensitiveScan: createSensitiveScan({ config: cfg })
293
- });
294
- const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
295
-
296
- // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
297
- // a safe re-render at boot so a stale mirror converges without needing a
298
- // business write. Bounded: single attempt; on failure dirty stays for the
299
- // next boot. Never throws.
300
- service.recoverMirror();
301
-
302
- // Recall-layer receipt: when searchMemories runs with recordRecall=true, the
303
- // retrieval scene (query/mode/topK/threshold + candidates) is persisted to
304
- // recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
305
- // Best-effort: a failed recall write must never break the search.
306
- service.setRecallRecorder((recall) => {
307
- try {
308
- store.saveRecallRun({
309
- query: recall.query,
310
- mode: recall.mode,
311
- topK: recall.topK,
312
- threshold: recall.threshold ?? null,
313
- candidates: recall.candidates ?? [],
314
- created_at: recall.createdAt
315
- });
316
- } catch { /* non-fatal: recall recording is bookkeeping */ }
317
- });
318
-
319
- // Semantic pipeline: a local/ollama embedder when configured, otherwise the
320
- // legacy OpenAI-compatible embedder (settings-driven). The vector index wraps
321
- // the store's embedding column and tracks the active model fingerprint. A
322
- // slow embedder init (model download) never blocks plugin boot — failures
323
- // degrade to keyword search.
324
- const vectorIndex = createVectorIndex({ store, logger: ctx.logger });
325
- service.setVectorIndex(vectorIndex);
326
-
327
- // Human edits in mirror files win on every sync; merge them back first.
328
- // TYPE_FILE maps each memory type to its mirror filename. Read every type's
329
- // edits up front: mergeHumanEdits re-renders ALL mirror files on success, so
330
- // a per-type read-then-merge loop would overwrite edits in files not yet read
331
- // (e.g. preferences.md merging would clobber unsynced projects.md edits).
332
- const humanEdits = new Map();
333
- for (const type of Object.keys(TYPE_FILE)) {
334
- humanEdits.set(type, mirror.readHumanEdits(type));
335
- }
336
- const applyHumanEdits = () => {
337
- for (const [type, edits] of humanEdits) {
338
- if (edits.length) service.mergeHumanEdits(type, edits);
339
- }
340
- };
341
-
342
- let embedder = null;
343
- let reranker = null;
344
- // #118: pending embedder-init retry timer, cleared on unload.
345
- let embedRetryTimer = null;
346
- if (lightMode) {
347
- // Light mode: the whole vector pipeline stays off — no embedder (nothing
348
- // pulls in ONNX/transformers), no reranker, no boot backfill (the preset
349
- // also cleared autoReindexOnBoot). Recall degrades to keyword search and
350
- // human mirror edits still merge on boot.
351
- applyHumanEdits();
352
- } else if (cfg.embedProvider === "openai") {
353
- // vectorIndex is passed so the legacy OpenAI embedder records the producing
354
- // model fingerprint after each successful embed (Bug3).
355
- embedder = createEmbedder({ store, settings, logger: ctx.logger, vectorIndex });
356
- service.setEmbedder(embedder);
357
- // issue #135: 未配置时明确告警一次。此前 legacy OpenAI embedder 恒报
358
- // ready=true,向量层「绿的但全哑」可以静默存在很久(本机持续了数周)。
359
- // 只记日志、不阻断启动:轻量模式与「先跑起来再补配置」都是正当用法。
360
- if (embedder.configured === false) {
361
- ctx.logger?.warn?.(
362
- "[dsh-mneme] 向量层未配置(vector-config 的 enabled/baseUrl/apiKey/model 有缺):"
363
- + "语义召回、语义去重、rerank、sleep 冲突检测将静默失效,"
364
- + "dream 的语义聚类会退化为全量窗口兜底。"
365
- + "请在设置面板补全 embedding 端点与模型,或把 embedProvider 改为 local/ollama。"
366
- );
367
- }
368
- // legacy OpenAI embedder needs no async init → human edits apply right away
369
- applyHumanEdits();
370
- } else {
371
- try {
372
- embedder = createEmbedderByProvider(cfg.embedProvider, {
373
- model: cfg.embedProvider === "ollama" ? cfg.ollamaModel : cfg.localEmbedModel,
374
- dimension: cfg.localEmbedDimension,
375
- device: cfg.localEmbedDevice,
376
- batchSize: cfg.localEmbedBatchSize,
377
- // 池化方式必须与模型的训练口径一致(BGE 系 = CLS)。它既进 embed() 的调用,
378
- // 也进 modelHash —— 池化改了就是换向量空间,既有索引会被判失配并重建。
379
- pooling: cfg.localEmbedPooling,
380
- cacheDir: cfg.embedModelCacheDir,
381
- runtimeDir: cfg.runtimeDir,
382
- // #188:embedModelMirror 接成 transformers 的下载镜像(此前死配置)。
383
- remoteHost: cfg.embedModelMirror,
384
- resilientModelDownload: cfg.resilientModelDownload,
385
- baseUrl: cfg.ollamaBaseUrl,
386
- logger: ctx.logger
387
- });
388
- service.setEmbedder(embedder);
389
- // issue #6: wait for extractor init before applying human edits, so
390
- // scheduled embeddings see a ready embedder.
391
- const bootEmbedder = () => embedder.init()
392
- .then(() => { applyHumanEdits(); return true; })
393
- .catch(() => false);
394
- // #118: the old one-shot probe permanently degraded search to keyword
395
- // when Ollama was briefly unreachable at boot (recoverable only by
396
- // restart). Retry briefly (5 attempts total: 1 initial + 4 × 15s);
397
- // search degrades to keyword meanwhile because per-query embed failures
398
- // are swallowed.
399
- bootEmbedder().then((ok) => {
400
- if (ok) return;
401
- let tries = 4;
402
- const retry = () => {
403
- if (tries-- <= 0) {
404
- ctx.logger?.warn?.("[dsh-mneme] embedder init retries exhausted, search degrades to keyword");
405
- service.setEmbedder(null);
406
- applyHumanEdits();
407
- return;
408
- }
409
- embedRetryTimer = setTimeout(async () => {
410
- if (await bootEmbedder()) return;
411
- retry();
412
- }, 15_000);
413
- };
414
- ctx.logger?.warn?.("[dsh-mneme] embedder init failed, retrying");
415
- retry();
416
- });
417
- } catch (error) {
418
- ctx.logger?.warn?.(`[dsh-mneme] embedder unavailable, search degrades to keyword: ${String(error)}`);
419
- applyHumanEdits();
420
- }
421
- }
422
-
423
- // Cross-encoder rerank over recall candidates. Best-effort: a failed model
424
- // load only disables reranking, never search itself. Explicit opt-in only
425
- // (rerankEnabled defaults to false): constructing LocalReranker is what pulls
426
- // in onnxruntime, so the default config never loads it (item ⑥).
427
- if (cfg.rerankEnabled && cfg.rerankProvider === "local") {
428
- try {
429
- reranker = new LocalReranker({
430
- model: cfg.rerankModel,
431
- batchSize: cfg.rerankBatchSize,
432
- maxCandidates: cfg.rerankMaxCandidates,
433
- scoreThreshold: cfg.rerankScoreThreshold,
434
- device: cfg.localEmbedDevice,
435
- cacheDir: cfg.embedModelCacheDir,
436
- runtimeDir: cfg.runtimeDir,
437
- // #188:量化档默认 q8(此前不传 dtype 会去要 1GB 级 fp32 模型);
438
- // embedModelMirror 此前是死配置,现接成 transformers 的下载镜像。
439
- useDtype: cfg.rerankDtype,
440
- remoteHost: cfg.embedModelMirror,
441
- resilientModelDownload: cfg.resilientModelDownload,
442
- logger: ctx.logger
443
- });
444
- service.setReranker(reranker);
445
- reranker.init().catch((error) => {
446
- ctx.logger?.warn?.(`[dsh-mneme] reranker init failed, rerank disabled: ${String(error)}`);
447
- service.setReranker(null);
448
- });
449
- } catch (error) {
450
- ctx.logger?.warn?.(`[dsh-mneme] reranker unavailable, rerank disabled: ${String(error)}`);
451
- }
452
- }
453
-
454
- // Bug2: lazy auto-backfill of missing embeddings on boot. When the vector API
455
- // is configured and rows still lack an embedding (e.g. written before vector
456
- // search was enabled), the backfill runs in the background after a short
457
- // delay. Gated on cfg.autoReindexOnBoot; rate-limited in small batches so a
458
- // large backlog never floods the provider. Failures degrade silently —
459
- // search stays keyword.
460
- function scheduleAutoReindex() {
461
- if (cfg.autoReindexOnBoot === false) return;
462
- const attempt = (tries) => {
463
- try {
464
- if (!embedder || typeof embedder.embedSingle !== "function") return;
465
- if ("ready" in embedder && embedder.ready !== true) {
466
- // Local/ollama embedders init asynchronously; give them a moment
467
- // before giving up on this boot (next boot retries).
468
- if (tries > 0) setTimeout(() => attempt(tries - 1), 2000);
469
- return;
470
- }
471
- if (!store.needsEmbedding(1).length) return; // nothing to backfill
472
- // Issue #128: no fingerprint gate here anymore — a matching fingerprint
473
- // used to return early and permanently orphan rows whose embed failed
474
- // at write time. See backfillMissingEmbeddings().
475
- backfillMissingEmbeddings({ store, embedder, vectorIndex, logger: ctx.logger })
476
- .catch((error) => {
477
- ctx.logger?.warn?.(`[dsh-mneme] auto-reindex failed: ${String(error)}`);
478
- });
479
- } catch (error) {
480
- ctx.logger?.warn?.(`[dsh-mneme] auto-reindex failed: ${String(error)}`);
481
- }
482
- };
483
- setTimeout(() => attempt(5), 5000);
484
- }
485
- scheduleAutoReindex();
486
-
487
- // Custom commands: register persisted commands into the DSH command registry
488
- // on boot; add/remove re-register live through the API.
489
- let commands = null;
490
- if (ctx.commands) {
491
- commands = createCommandManager({ ctx, settings, logger: ctx.logger, language: langOf(cfg) });
492
- commands.sync();
493
- }
494
-
495
- // Dream scheduler: automatic consolidation + summary runs, triggered by
496
- // store growth. Writes through the service fire the dream hook, which asks
497
- // the scheduler to (re)schedule a run once absolute and since-last-run
498
- // thresholds are both exceeded. onRun is deferred through `dream` so the
499
- // closure sees the assigned scheduler; the null guard keeps a run safe even
500
- // if the hook fires before assignment or after dispose.
501
- let dream = null;
502
- if (cfg.autoDream) {
503
- dream = createDreamScheduler({
504
- thresholdCount: cfg.dreamThresholdCount,
505
- thresholdChars: cfg.dreamThresholdChars,
506
- delayMs: cfg.dreamDelayMs,
507
- minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
508
- // Issue #292:连续失败指数退避(opt-in,默认关 = 行为不变)。
509
- failureBackoff: cfg.autoDreamFailureBackoff === true,
510
- logger: ctx.logger,
511
- semantic: { embedder, vectorIndex },
512
- lastRunAtSeed: store.lastDreamRunAt("auto"),
513
- // Issue #89(基线半边):阈值基线同样跨重启恢复——只恢复 lastRunAt 的话,
514
- // 重启后的第一条写入仍会绕过阈值(库里记忆多于阈值时 overBase 恒成立)开跑
515
- // 整轮。种子取上次成功轮的库规模,口径与运行期刷新一致;读不到时调度器退回
516
- // 零基线(= 升级前行为)。
517
- baselineSeed: store.lastDreamBaseline("auto"),
518
- // Issue #239(第 4 项)镜像到巩固:高峰期不做梦,顺延到最近的高峰结束时刻。
519
- peakHours: cfg.dreamPeakHours ?? "",
520
- peakMaxDeferMinutes: cfg.dreamPeakMaxDeferMinutes ?? 120,
521
- // 跳过时的审计行在这里落地(调度器只拿到 service,拿不到 config 的
522
- // llmAudit 开关与巩固模型路由)。口径与 runAuditedLlm 一致:审计关掉就
523
- // 不写;写失败只 warn,绝不反噬调度(CONTRIBUTING 的 fail-safe 硬约定)。
524
- auditPeakSkip: ({ count, chars }) => {
525
- if (cfg?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
526
- const modelId = cfg.dreamProvider && cfg.dreamModel ? `${cfg.dreamProvider}:${cfg.dreamModel}` : "";
527
- service.saveLlmAudit({
528
- timestamp: new Date().toISOString(),
529
- trigger_source: "autoDream",
530
- operation_type: "dream_consolidate",
531
- model_id: modelId,
532
- input_tokens: 0,
533
- output_tokens: 0,
534
- total_tokens: 0,
535
- cost_usd: 0,
536
- duration_ms: 0,
537
- status: "skipped",
538
- error_message: "peak-hours",
539
- related_memory_ids: [],
540
- // 观测用:跳过时窗口里积了多少(阈值继续累积,不是丢弃)。
541
- metadata: JSON.stringify({ count, chars })
542
- });
543
- },
544
- onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
545
- });
546
- service.setDreamHook(() => dream.maybeSchedule(service));
547
- }
548
-
549
- // Sleep scheduler (v0.4.0): idle-triggered deep maintenance. Fires when the
550
- // store has been quiet for sleepIdleMinutes and re-arms on every write via
551
- // noteWrite (hooked to the service's write path). Runs go through
552
- // service.enqueue so they serialize with autoDream — the two never overlap.
553
- // Abortable on user activity; audited into dream_runs with run_type='sleep'.
554
- let sleep = null;
555
- if (cfg.sleepModeEnabled) {
556
- sleep = createSleepScheduler({
557
- service,
558
- config: cfg,
559
- logger: ctx.logger,
560
- lastRunAtSeed: store.lastDreamRunAt("sleep"),
561
- onRun: (signal) => (sleep ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex }, signal) : Promise.resolve({ ok: true, skipped: true }))
562
- });
563
- service.setSleepHook(() => sleep.noteWrite());
564
- }
565
-
566
- // Entity gene extraction (v0.3.0): wire the extractor into the service as a
567
- // hook so saveWithDedupe can fire-and-forget an extraction pass on fresh
568
- // writes. The service never sees ctx.llm — index.js adapts it here into the
569
- // callLLM(messages, options) => Promise<string> contract the extractor
570
- // expects, reusing the same ctx.llm.stream consumption pattern as dream.js.
571
- // Explicit opt-in only (entityExtractionEnabled defaults to false); any LLM
572
- // failure degrades inside the extractor to { ok:false }, never a write error.
573
- if (cfg.entityExtractionEnabled) {
574
- if (!ctx.llm) {
575
- // Issue #108: an enabled-but-unwired extractor failed silently before —
576
- // zero entities, zero llm_audit_logs, no log line anywhere. Make the
577
- // missing dependency visible so a user can tell "extractor not installed"
578
- // from "extraction failed".
579
- ctx.logger?.warn?.("[dsh-mneme] entityExtractionEnabled=true but ctx.llm unavailable — entity extractor NOT installed");
580
- } else {
581
- const streamEntityText = createEntityStreamAdapter({
582
- llm: ctx.llm,
583
- agentDefaultModel: ctx.agentDefaultModel,
584
- logger: ctx.logger,
585
- // Issue #250: the adapter needs service to write its llm_audit_logs row
586
- // (it never had it) and config for the shared llmAudit.enabled gate.
587
- service,
588
- config: cfg
589
- });
590
- service.setEntityExtractor((memory) =>
591
- extractEntities(memory, { store, config: cfg, callLLM: streamEntityText, logger: ctx.logger })
592
- .catch((err) => {
593
- ctx.logger?.warn?.(`[dsh-mneme] entity extraction failed: ${String(err)}`);
594
- return { ok: false, error: String(err) };
595
- })
596
- );
597
- }
598
- }
599
-
600
- const disposers = [];
601
-
602
- // #118: never let a pending embedder init retry fire after unload and touch
603
- // a torn-down context.
604
- disposers.push(() => { if (embedRetryTimer !== null) clearTimeout(embedRetryTimer); });
605
-
606
- ctx.inject(["systemPrompt"], (promptCtx) => {
607
- if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg));
608
- });
609
-
610
- // #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加),
611
- // 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子
612
- // 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。
613
- if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) {
614
- disposers.push(createContinuityRescue(ctx, store));
615
- }
616
-
617
- ctx.inject(["tools"], (toolsCtx) => {
618
- disposers.push(createTools(toolsCtx, service, cfg, embedder));
619
- });
620
-
621
- const summarizer = createSummarizer(ctx, service, cfg);
622
- disposers.push(summarizer.dispose);
623
-
624
- // webServer 可选依赖:cordis 4 的 ctx 是 Proxy,直接访问未在 inject 声明的
625
- // 属性会抛 "cannot get property without inject"(不会返回 undefined),所以
626
- // 不能用 if (ctx.webServer) 守卫。ctx.reflect.get 是 cordis 提供的免 inject
627
- // 读取(未提供时返回 undefined);对象字面量 mock ctx(测试)没有 reflect,
628
- // 退回直接属性访问。headless/无 UI 宿主无 webServer 时跳过 API 注册,其余
629
- // 功能(工具/注入/dream)照常。
630
- const webServer = typeof ctx.reflect?.get === "function" ? ctx.reflect.get("webServer") : ctx.webServer;
631
- if (webServer) {
632
- const api = createApi(ctx, service, settings, commands ?? {
633
- add: () => { throw new Error("commands unavailable"); },
634
- remove: () => false,
635
- list: () => []
636
- }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg);
637
- disposers.push(api.dispose);
638
- }
639
-
640
- // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
641
- // 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、
642
- // 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主
643
- // 抢锁的那条路走不通)。
644
- const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
645
-
646
- // Standalone external API (v0.7.12): plain node:http server for ecosystem
647
- // integrations outside the DSH host. Persisted external_api settings win
648
- // over the bundle config (enabled/port); the Bearer token lives in the same
649
- // kv and is auto-generated on first boot by createStandaloneApi. Binding a
650
- // non-loopback host is the operator's documented responsibility.
651
- if ((settings.getExternalApi?.()?.enabled ?? cfg.externalApiEnabled) === true) {
652
- const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings, maintenance });
653
- disposers.push(() => standalone.server.close());
654
- standalone.ready.catch((error) => {
655
- ctx.logger?.warn?.(`[dsh-mneme] standalone API failed to start: ${String(error)}`);
656
- });
657
- }
658
-
659
- // Async disposer: cordis awaits the returned promise on unload (runDisposable),
660
- // so an in-flight dream run is allowed to finish before the SQLite store is
661
- // closed — dream.dispose() resolves only after its current run settles.
662
- return async () => {
663
- for (const dispose of disposers) {
664
- if (typeof dispose === "function") dispose();
665
- }
666
- commands?.dispose();
667
- if (dream) await dream.dispose();
668
- if (sleep) sleep.dispose();
669
- store.close();
670
- };
671
- };
1
+ import { createStore } from "./store.js";
2
+ import { createMirror, TYPE_FILE } from "./mirror.js";
3
+ import { createDocumentIndex } from "./document-index.js";
4
+ import { resolveDocumentDir } from "./document.js";
5
+ // #368 v1:profile 作用域落点检测(只提示,不改行为)。
6
+ import { findProfileScopeSegment } from "./placement.js";
7
+ import { createService } from "./service.js";
8
+ // #254 写入准入(第一阶段只计量,不拦截):见 src/write-admission.js 的文件头。
9
+ import { createWriteAdmission } from "./write-admission.js";
10
+ // #164 A2:写入边界的密钥 / PII 判据,注入给上面的写入准入。
11
+ import { createSensitiveScan } from "./sensitive-scan.js";
12
+ import { createTools } from "./tools.js";
13
+ import { createInjector } from "./inject.js";
14
+ import { createPreInjectGate } from "./pre-inject-gate.js";
15
+ import { createContinuityRescue } from "./continuity.js";
16
+ import { createSummarizer } from "./summarize.js";
17
+ import { createDreamScheduler } from "./dream.js";
18
+ import { createSleepScheduler, runSleep } from "./dream/sleep.js";
19
+ import { createApi } from "./api.js";
20
+ import { createStandaloneApi } from "./api-standalone.js";
21
+ // #275 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
22
+ import { createMaintenance } from "./maintenance.js";
23
+ import { createSettings } from "./settings.js";
24
+ import { createCommandManager } from "./commands.js";
25
+ import { createVectorIndex } from "./vector-index.js";
26
+ // semantic(embedder/reranker/boot 回填)纯搬移至 src/semantic.js(PR2):宿主与
27
+ // daemon(dsh-mneme-serve,#363)共用同一套装配,原文件保留 barrel 出口。
28
+ import { createSemantic } from "./semantic.js";
29
+ import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
30
+ import { langOf } from "./lang.js";
31
+ import { extractEntities } from "./entities/extractor.js";
32
+ import { mkdirSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { homedir } from "node:os";
35
+
36
+ export const name = "dsh-mneme";
37
+ // webServer 在 inject 声明中:cordis 会等宿主 webServer 服务激活后才 apply 本
38
+ // 插件,保证 apply 时路由注册不落时序(v0.7.23 曾移出 inject 想支持 headless,
39
+ // 结果 cordis 不再等待,apply 时宿主 webServer 未就绪 → 桌面端 "cannot get
40
+ // property without inject" 崩溃)。守卫用 ctx.reflect.get(免 inject 读取,
41
+ // 未提供返回 undefined)而非 if (ctx.webServer):直接访问未注入属性在 cordis
42
+ // Proxy 下会抛错而非返回 undefined。
43
+ export const inject = ["tools", "systemPrompt", "llm", "agentDefaultModel", "commands", "webServer"];
44
+ export { Config };
45
+
46
+ // Arrow (not function declaration): cordis 4 treats any apply with a
47
+ // prototype as a class constructor (`new apply(...)`) and discards its return
48
+ // value, so a `function apply` disposer would never run on unload. An arrow
49
+ // has no prototype, is called normally, and its returned disposer is collected
50
+ // and run by the fiber on unload.
51
+ // Entity-extraction LLM adapter (issue #108/#109): maps the extractor's
52
+ // options (provider/model override + reasoningEffort) onto a real dsh-llm
53
+ // stream route and retries once without the effort when the first attempt is
54
+ // rejected. Extracted from apply() so the effort-fallback branch is
55
+ // unit-testable; the extractor only ever sees a callLLM(messages, options)
56
+ // => Promise<string>. The route always carries a real provider/model (dsh-llm
57
+ // GenerateOptions requires both) — never a bare stream.
58
+ //
59
+ // Issue #250: it also accounts for itself. Entity extraction is the background
60
+ // LLM path that runs on every memory write, yet it never reached
61
+ // llm_audit_logs — the adapter only ever held an llm handle, so it had no way
62
+ // to call service.saveLlmAudit. It now takes service (+ config for the shared
63
+ // llmAudit.enabled gate) and writes one row per stream attempt, matching the
64
+ // contract of runAuditedLlm in dream.js: a rejected effort attempt records its
65
+ // own error row and the retry records its own success row.
66
+ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, service, config }) {
67
+ return async function streamEntityText(messages, options = {}) {
68
+ let route = {};
69
+ if (options.provider) route.provider = options.provider;
70
+ if (options.model) route.model = options.model;
71
+ if (!route.provider || !route.model) {
72
+ try {
73
+ const sel = agentDefaultModel?.currentSelection?.();
74
+ if (sel?.provider && sel?.model) {
75
+ route.provider ??= sel.provider;
76
+ route.model ??= sel.model;
77
+ }
78
+ } catch { /* fall through to whatever route we already have */ }
79
+ }
80
+ const effort = options.reasoningEffort;
81
+ // 没有解析出 provider/model 就没有可归属的模型——与 dream 的 resolveRoute
82
+ // 无路由早退同口径,那种情况不写审计行。
83
+ const modelId = route.provider && route.model ? `${route.provider}:${route.model}` : "";
84
+ const tryStream = (withEffort) => {
85
+ let text = "";
86
+ let inputTokens = 0;
87
+ let outputTokens = 0;
88
+ const startedAt = Date.now();
89
+ const timestamp = new Date(startedAt).toISOString();
90
+ // 记账是 best-effort:写审计行失败只 warn,绝不反噬抽取本身(CONTRIBUTING
91
+ // 的 fail-safe 硬约定)。
92
+ const writeAudit = (status, errorMessage) => {
93
+ if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
94
+ // #372: modelId 为空 = 路由解析失败——此前整个 writeAudit 静默 return,
95
+ // llm.stream 抛错也留不下任何行,正是 #108 要消灭的「零实体零日志」同款盲区。
96
+ // 空路由无法记账,但至少要留一条可解释的 warn,别让失败面完全不可见。
97
+ if (!modelId) {
98
+ if (status !== "ok") logger?.warn?.(`dsh-mneme: entity extraction ${status} without audit row (llm route unresolved: ${errorMessage ?? "no route"})`);
99
+ return;
100
+ }
101
+ try {
102
+ service.saveLlmAudit({
103
+ timestamp,
104
+ trigger_source: "entityExtract",
105
+ operation_type: "entity_extract",
106
+ model_id: modelId,
107
+ input_tokens: inputTokens,
108
+ output_tokens: outputTokens,
109
+ total_tokens: inputTokens + outputTokens,
110
+ cost_usd: 0,
111
+ duration_ms: Date.now() - startedAt,
112
+ status,
113
+ error_message: errorMessage,
114
+ related_memory_ids: []
115
+ });
116
+ } catch (auditError) {
117
+ logger?.warn?.(`dsh-mneme: entity extraction llm audit write failed: ${String(auditError)}`);
118
+ }
119
+ };
120
+ return (async () => {
121
+ for await (const chunk of llm.stream({
122
+ ...route,
123
+ maxTokens: 4096,
124
+ ...(withEffort && effort ? { reasoningEffort: effort } : {}),
125
+ messages
126
+ })) {
127
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
128
+ // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage);兼容直接
129
+ // 平铺在 chunk 上的旧形态。归一放在这里,下面只需认平铺形状——与
130
+ // dream.js 的 streamText 同一处理。
131
+ if (chunk.type === "usage") {
132
+ const u = chunk.usage ?? chunk;
133
+ const i = u.input_tokens ?? u.inputTokens ?? u.prompt_tokens ?? u.promptTokens;
134
+ const o = u.output_tokens ?? u.outputTokens ?? u.completion_tokens ?? u.completionTokens;
135
+ if (Number.isFinite(i)) inputTokens = i;
136
+ if (Number.isFinite(o)) outputTokens = o;
137
+ }
138
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
139
+ writeAudit("error", "llm stream aborted or errored");
140
+ return undefined;
141
+ }
142
+ }
143
+ writeAudit("success", null);
144
+ return text;
145
+ })().catch((err) => {
146
+ logger?.warn?.(`[dsh-mneme] entity extraction llm stream failed: ${String(err)}`);
147
+ writeAudit("error", String(err?.message ?? err));
148
+ return undefined;
149
+ });
150
+ };
151
+ let text = await tryStream(true);
152
+ if (text === undefined && effort) {
153
+ // Mirror dream's effort fallback: a provider rejecting the reasoning
154
+ // effort must not sink the whole extraction — retry once without it.
155
+ logger?.warn?.(`[dsh-mneme] entity extraction: reasoningEffort "${effort}" rejected, retrying without it`);
156
+ text = await tryStream(false);
157
+ }
158
+ return text;
159
+ };
160
+ }
161
+
162
+ // backfillMissingEmbeddings 已随语义装配整体搬至 src/semantic.js(纯搬移);
163
+ // 保留 barrel 再出口 —— test/reindex-backfill.test.js 仍从本模块 import,调用方零改动。
164
+ export { backfillMissingEmbeddings } from "./semantic.js";
165
+
166
+ export const apply = (ctx, config) => {
167
+ const rawCfg = Config(config);
168
+
169
+ // Resolve memoryDir: expand leading "~"
170
+ const memoryDir = rawCfg.memoryDir.startsWith("~")
171
+ ? join(homedir(), rawCfg.memoryDir.slice(1))
172
+ : rawCfg.memoryDir;
173
+ mkdirSync(memoryDir, { recursive: true });
174
+
175
+ // #218 末楼的实际事故:profile 被清理,落在其中的记忆库连带丢失。命中
176
+ // profile 作用域时给一条可操作的告警(只提示、不搬库、不加配置键),启发
177
+ // 式的边界与取舍见 src/placement.js 文件头。
178
+ const profileScope = findProfileScopeSegment(memoryDir);
179
+ if (profileScope) {
180
+ ctx.logger?.warn?.(
181
+ `[dsh-mneme] memoryDir is inside a profile scope ("${profileScope}"): ${memoryDir} — the library may be lost when the host cleans or migrates profiles. Prefer a location outside profiles/ (default: ~/.dsh/memory; config key: memoryDir).`
182
+ );
183
+ }
184
+
185
+ const store = createStore(join(memoryDir, "memory.db"));
186
+ // Prune reflection failure rows older than 90 days on boot (best-effort, so
187
+ // the failure table never grows unbounded).
188
+ try {
189
+ store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
190
+ } catch { /* non-fatal */ }
191
+
192
+ // User-configurable settings (profile, rules, panel mode, standalone API
193
+ // token) share the same SQLite file in dedicated tables, isolated from
194
+ // memories. Created before the config is finalized: the persisted
195
+ // panel_mode participates in light-mode resolution below.
196
+ const settings = createSettings(store.db);
197
+
198
+ // Light mode (v0.7.12): the bundle config flag OR a persisted panel_mode of
199
+ // "light" (the panel switch wins over the bundle config so it survives
200
+ // config redeploys). applyLightModePreset turns every heavy background /
201
+ // semantic feature off and keeps the core loop (autoInject, autoSummarize,
202
+ // hot memory, quality filter).
203
+ const lightMode = rawCfg.lightMode === true || settings.getPanelMode() === "light";
204
+ // 功能开关合并顺序即优先级:用户显式开关(feature_flags kv,面板写入)>
205
+ // 轻量预设(applyLightModePreset 批量置关的重型能力)> bundle 配置。预设必须
206
+ // 先应用、用户开关后展开,否则 LIGHT_MODE_OFF 会把用户显式打开的开关再次
207
+ // 压掉。合并结果只作用于本次启动:面板改开关后与 panel_mode 一样在下次
208
+ // 启动生效。
209
+ // 嵌套对象开关按首个点号拆开(kv 里平铺存的 "memoryQualityFilter.enabled" →
210
+ // cfg.memoryQualityFilter.enabled),点号键不原样留在 cfg 顶层属性里。
211
+ const flags = settings.getFeatureFlags();
212
+ const flatFlags = {};
213
+ const nestedFlags = {};
214
+ for (const [key, value] of Object.entries(flags)) {
215
+ const dot = key.indexOf(".");
216
+ if (dot > 0) {
217
+ const objKey = key.slice(0, dot);
218
+ const subKey = key.slice(dot + 1);
219
+ nestedFlags[objKey] = { ...(nestedFlags[objKey] ?? {}), [subKey]: value };
220
+ } else {
221
+ flatFlags[key] = value;
222
+ }
223
+ }
224
+ const cfg = { ...applyLightModePreset({ ...rawCfg, lightMode }), ...flatFlags };
225
+ for (const [objKey, sub] of Object.entries(nestedFlags)) {
226
+ cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
227
+ }
228
+
229
+ // Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
230
+ // cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
231
+ // rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
232
+ // (raw 说关 → 不清理,cfg 说开 → 照写,审计表就无保留期地长)。保留期默认 90 天、
233
+ // 失败只 warn,与失败表清理同款:账本清理绝不许挡住插件启动。
234
+ try {
235
+ if (cfg.llmAudit?.enabled !== false) {
236
+ const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
237
+ store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
238
+ }
239
+ } catch { /* non-fatal */ }
240
+
241
+ // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
242
+ // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
243
+ const mirror = createMirror(memoryDir, langOf(cfg));
244
+
245
+ // #296 第二批:managed 文档目录。解析与 memoryDir 同一套(空 = 跟随 memoryDir 的
246
+ // <memoryDir>/documents/,~ 展开,相对路径落在 memoryDir 下)。索引对象总是建:
247
+ // 闸关时它只承担「清掉陈旧 index.md」这一件事,与 documents.md 在闸关时被镜像删
248
+ // 掉同一口径。建目录与写索引失败都只 warn,不阻断插件加载(它们是机器产物)。
249
+ const documentDir = resolveDocumentDir(memoryDir, cfg.documentDir);
250
+ const documentIndex = createDocumentIndex(documentDir, langOf(cfg));
251
+ if (cfg.documentMemoryEnabled === true) {
252
+ const ensured = documentIndex.ensure();
253
+ if (!ensured.ok) {
254
+ ctx.logger?.warn?.(`[dsh-mneme] documentDir is not writable: ${documentDir}: ${ensured.error}`);
255
+ }
256
+ // 启动即渲染一次:索引不该等到第一次业务写才存在(删掉它之后重启也能自愈)。
257
+ const synced = documentIndex.sync(store.list({ type: "document", limit: null }));
258
+ if (!synced.ok) ctx.logger?.warn?.(`[dsh-mneme] document index sync failed: ${synced.error}`);
259
+ } else {
260
+ documentIndex.remove();
261
+ }
262
+
263
+ // 写入准入实例(#254)。两个开关 writeAdmission.enabled / .enforce 都默认关;
264
+ // 关时它的返回与只计量那一阶段逐字段一致,写入路径完全不碰。既有的
265
+ // llmAudit.enabled 关掉时它同样既不判也不写(那个开关连审计行的启动期清理一起
266
+ // 关掉,在无保留期的表里按写入频次增长是不能接受的)。
267
+ //
268
+ // sensitiveScan 是密钥 / PII 那一类判据的注入点。按 #254 验收第 4 条它是 #164 A2
269
+ // 的判据来源,现在由 src/sensitive-scan.js 实现(维护者 09-28 把 A2 的认领转给
270
+ // 本侧)。这里只做接线:判据开不开由它自己的键 sensitiveScanEnabled 决定,工厂在
271
+ // 关时返回 null,闸门的行为就与 #332 合并时逐字段一致(那一版根本没有这个函数);
272
+ // 命中之后是仅告警还是真拦,仍是 writeAdmission.enforce 的事(#164 口径:
273
+ // 默认仅告警、拦截 opt-in),判据不碰决策。
274
+ const writeAdmission = createWriteAdmission({
275
+ store,
276
+ config: cfg,
277
+ logger: ctx.logger,
278
+ sensitiveScan: createSensitiveScan({ config: cfg })
279
+ });
280
+ const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
281
+
282
+ // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
283
+ // a safe re-render at boot so a stale mirror converges without needing a
284
+ // business write. Bounded: single attempt; on failure dirty stays for the
285
+ // next boot. Never throws.
286
+ service.recoverMirror();
287
+
288
+ // Recall-layer receipt: when searchMemories runs with recordRecall=true, the
289
+ // retrieval scene (query/mode/topK/threshold + candidates) is persisted to
290
+ // recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
291
+ // Best-effort: a failed recall write must never break the search.
292
+ service.setRecallRecorder((recall) => {
293
+ try {
294
+ store.saveRecallRun({
295
+ query: recall.query,
296
+ mode: recall.mode,
297
+ topK: recall.topK,
298
+ threshold: recall.threshold ?? null,
299
+ candidates: recall.candidates ?? [],
300
+ created_at: recall.createdAt
301
+ });
302
+ } catch { /* non-fatal: recall recording is bookkeeping */ }
303
+ });
304
+
305
+ // Semantic pipeline: a local/ollama embedder when configured, otherwise the
306
+ // legacy OpenAI-compatible embedder (settings-driven). The vector index wraps
307
+ // the store's embedding column and tracks the active model fingerprint. A
308
+ // slow embedder init (model download) never blocks plugin boot — failures
309
+ // degrade to keyword search.
310
+ const vectorIndex = createVectorIndex({ store, logger: ctx.logger });
311
+ service.setVectorIndex(vectorIndex);
312
+
313
+ // Human edits in mirror files win on every sync; merge them back first.
314
+ // TYPE_FILE maps each memory type to its mirror filename. Read every type's
315
+ // edits up front: mergeHumanEdits re-renders ALL mirror files on success, so
316
+ // a per-type read-then-merge loop would overwrite edits in files not yet read
317
+ // (e.g. preferences.md merging would clobber unsynced projects.md edits).
318
+ const humanEdits = new Map();
319
+ for (const type of Object.keys(TYPE_FILE)) {
320
+ humanEdits.set(type, mirror.readHumanEdits(type));
321
+ }
322
+ const applyHumanEdits = () => {
323
+ for (const [type, edits] of humanEdits) {
324
+ if (edits.length) service.mergeHumanEdits(type, edits);
325
+ }
326
+ };
327
+
328
+ // embedder/reranker/boot 回填装配已整体搬至 src/semantic.js(纯搬移,宿主与 daemon
329
+ // 共用):调用时序(applyHumanEdits 在各分支的触发点、#118 重试、autoReindexOnBoot)
330
+ // 原样保留在 createSemantic 内部,这里只拿引用。init 全失败的 embedder 引用仍会进入
331
+ // dream/sleep 的 semantic 面 —— 与搬移前一致:检索侧 setEmbedder(null) 降级关键词。
332
+ const semantic = createSemantic({
333
+ store, service, settings, cfg, logger: ctx.logger, vectorIndex, applyHumanEdits, lightMode
334
+ });
335
+ const { embedder, reranker } = semantic;
336
+
337
+ // Custom commands: register persisted commands into the DSH command registry
338
+ // on boot; add/remove re-register live through the API.
339
+ let commands = null;
340
+ if (ctx.commands) {
341
+ commands = createCommandManager({ ctx, settings, logger: ctx.logger, language: langOf(cfg) });
342
+ commands.sync();
343
+ }
344
+
345
+ // Dream scheduler: automatic consolidation + summary runs, triggered by
346
+ // store growth. Writes through the service fire the dream hook, which asks
347
+ // the scheduler to (re)schedule a run once absolute and since-last-run
348
+ // thresholds are both exceeded. onRun is deferred through `dream` so the
349
+ // closure sees the assigned scheduler; the null guard keeps a run safe even
350
+ // if the hook fires before assignment or after dispose.
351
+ let dream = null;
352
+ if (cfg.autoDream) {
353
+ dream = createDreamScheduler({
354
+ thresholdCount: cfg.dreamThresholdCount,
355
+ thresholdChars: cfg.dreamThresholdChars,
356
+ delayMs: cfg.dreamDelayMs,
357
+ minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
358
+ // Issue #292:连续失败指数退避(opt-in,默认关 = 行为不变)。
359
+ failureBackoff: cfg.autoDreamFailureBackoff === true,
360
+ logger: ctx.logger,
361
+ semantic: { embedder, vectorIndex },
362
+ lastRunAtSeed: store.lastDreamRunAt("auto"),
363
+ // Issue #89(基线半边):阈值基线同样跨重启恢复——只恢复 lastRunAt 的话,
364
+ // 重启后的第一条写入仍会绕过阈值(库里记忆多于阈值时 overBase 恒成立)开跑
365
+ // 整轮。种子取上次成功轮的库规模,口径与运行期刷新一致;读不到时调度器退回
366
+ // 零基线(= 升级前行为)。
367
+ baselineSeed: store.lastDreamBaseline("auto"),
368
+ // Issue #239(第 4 项)镜像到巩固:高峰期不做梦,顺延到最近的高峰结束时刻。
369
+ peakHours: cfg.dreamPeakHours ?? "",
370
+ peakMaxDeferMinutes: cfg.dreamPeakMaxDeferMinutes ?? 120,
371
+ // 跳过时的审计行在这里落地(调度器只拿到 service,拿不到 config 的
372
+ // llmAudit 开关与巩固模型路由)。口径与 runAuditedLlm 一致:审计关掉就
373
+ // 不写;写失败只 warn,绝不反噬调度(CONTRIBUTING 的 fail-safe 硬约定)。
374
+ auditPeakSkip: ({ count, chars }) => {
375
+ if (cfg?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
376
+ const modelId = cfg.dreamProvider && cfg.dreamModel ? `${cfg.dreamProvider}:${cfg.dreamModel}` : "";
377
+ service.saveLlmAudit({
378
+ timestamp: new Date().toISOString(),
379
+ trigger_source: "autoDream",
380
+ operation_type: "dream_consolidate",
381
+ model_id: modelId,
382
+ input_tokens: 0,
383
+ output_tokens: 0,
384
+ total_tokens: 0,
385
+ cost_usd: 0,
386
+ duration_ms: 0,
387
+ status: "skipped",
388
+ error_message: "peak-hours",
389
+ related_memory_ids: [],
390
+ // 观测用:跳过时窗口里积了多少(阈值继续累积,不是丢弃)。
391
+ metadata: JSON.stringify({ count, chars })
392
+ });
393
+ },
394
+ onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
395
+ });
396
+ service.setDreamHook(() => dream.maybeSchedule(service));
397
+ }
398
+
399
+ // Sleep scheduler (v0.4.0): idle-triggered deep maintenance. Fires when the
400
+ // store has been quiet for sleepIdleMinutes and re-arms on every write via
401
+ // noteWrite (hooked to the service's write path). Runs go through
402
+ // service.enqueue so they serialize with autoDream — the two never overlap.
403
+ // Abortable on user activity; audited into dream_runs with run_type='sleep'.
404
+ let sleep = null;
405
+ if (cfg.sleepModeEnabled) {
406
+ sleep = createSleepScheduler({
407
+ service,
408
+ config: cfg,
409
+ logger: ctx.logger,
410
+ lastRunAtSeed: store.lastDreamRunAt("sleep"),
411
+ onRun: (signal) => (sleep ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex }, signal) : Promise.resolve({ ok: true, skipped: true }))
412
+ });
413
+ service.setSleepHook(() => sleep.noteWrite());
414
+ }
415
+
416
+ // Entity gene extraction (v0.3.0): wire the extractor into the service as a
417
+ // hook so saveWithDedupe can fire-and-forget an extraction pass on fresh
418
+ // writes. The service never sees ctx.llm — index.js adapts it here into the
419
+ // callLLM(messages, options) => Promise<string> contract the extractor
420
+ // expects, reusing the same ctx.llm.stream consumption pattern as dream.js.
421
+ // Explicit opt-in only (entityExtractionEnabled defaults to false); any LLM
422
+ // failure degrades inside the extractor to { ok:false }, never a write error.
423
+ if (cfg.entityExtractionEnabled) {
424
+ if (!ctx.llm) {
425
+ // Issue #108: an enabled-but-unwired extractor failed silently before —
426
+ // zero entities, zero llm_audit_logs, no log line anywhere. Make the
427
+ // missing dependency visible so a user can tell "extractor not installed"
428
+ // from "extraction failed".
429
+ ctx.logger?.warn?.("[dsh-mneme] entityExtractionEnabled=true but ctx.llm unavailable — entity extractor NOT installed");
430
+ } else {
431
+ const streamEntityText = createEntityStreamAdapter({
432
+ llm: ctx.llm,
433
+ agentDefaultModel: ctx.agentDefaultModel,
434
+ logger: ctx.logger,
435
+ // Issue #250: the adapter needs service to write its llm_audit_logs row
436
+ // (it never had it) and config for the shared llmAudit.enabled gate.
437
+ service,
438
+ config: cfg
439
+ });
440
+ service.setEntityExtractor((memory) =>
441
+ extractEntities(memory, { store, config: cfg, callLLM: streamEntityText, logger: ctx.logger })
442
+ .catch((err) => {
443
+ ctx.logger?.warn?.(`[dsh-mneme] entity extraction failed: ${String(err)}`);
444
+ return { ok: false, error: String(err) };
445
+ })
446
+ );
447
+ }
448
+ }
449
+
450
+ const disposers = [];
451
+
452
+ // Issue #380:注入前判定闸门。判定 LLM 走 entity adapter 同款装配(ctx.llm +
453
+ // agentDefaultModel 回退),gate 自持 enabled/usable 判定(未启用/llm 缺席/
454
+ // llmAudit 关闭时内部恒 off,注入行为与现状逐字节一致)。
455
+ const preInjectGate = createPreInjectGate({
456
+ llm: ctx.llm,
457
+ agentDefaultModel: ctx.agentDefaultModel,
458
+ service,
459
+ config: cfg,
460
+ logger: ctx.logger
461
+ });
462
+ disposers.push(() => preInjectGate.clear());
463
+
464
+ // #118: never let a pending embedder init retry fire after unload and touch
465
+ // a torn-down context.
466
+ disposers.push(() => semantic.dispose()); // #118 重试计时器 + boot 回填计时器(搬入 semantic.js 后由它自持)
467
+
468
+ ctx.inject(["systemPrompt"], (promptCtx) => {
469
+ if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate));
470
+ });
471
+
472
+ // #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加),
473
+ // 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子
474
+ // 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。
475
+ if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) {
476
+ disposers.push(createContinuityRescue(ctx, store));
477
+ }
478
+
479
+ ctx.inject(["tools"], (toolsCtx) => {
480
+ disposers.push(createTools(toolsCtx, service, cfg, embedder));
481
+ });
482
+
483
+ const summarizer = createSummarizer(ctx, service, cfg);
484
+ disposers.push(summarizer.dispose);
485
+
486
+ // webServer 可选依赖:cordis 4 的 ctx 是 Proxy,直接访问未在 inject 声明的
487
+ // 属性会抛 "cannot get property without inject"(不会返回 undefined),所以
488
+ // 不能用 if (ctx.webServer) 守卫。ctx.reflect.get 是 cordis 提供的免 inject
489
+ // 读取(未提供时返回 undefined);对象字面量 mock ctx(测试)没有 reflect,
490
+ // 退回直接属性访问。headless/无 UI 宿主无 webServer 时跳过 API 注册,其余
491
+ // 功能(工具/注入/dream)照常。
492
+ const webServer = typeof ctx.reflect?.get === "function" ? ctx.reflect.get("webServer") : ctx.webServer;
493
+ if (webServer) {
494
+ const api = createApi(ctx, service, settings, commands ?? {
495
+ add: () => { throw new Error("commands unavailable"); },
496
+ remove: () => false,
497
+ list: () => []
498
+ }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg);
499
+ disposers.push(api.dispose);
500
+ }
501
+
502
+ // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
503
+ // 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、
504
+ // 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主
505
+ // 抢锁的那条路走不通)。
506
+ const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
507
+
508
+ // Standalone external API (v0.7.12): plain node:http server for ecosystem
509
+ // integrations outside the DSH host. Persisted external_api settings win
510
+ // over the bundle config (enabled/port); the Bearer token lives in the same
511
+ // kv and is auto-generated on first boot by createStandaloneApi. Binding a
512
+ // non-loopback host is the operator's documented responsibility.
513
+ if ((settings.getExternalApi?.()?.enabled ?? cfg.externalApiEnabled) === true) {
514
+ // embedder 交给 /context 的查询嵌入(issue #370):与 service.setEmbedder 同一
515
+ // 实例(semantic.js 装配产物),lightMode 下为 null,路由侧降级规则档。
516
+ const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings, maintenance, embedder });
517
+ disposers.push(() => standalone.server.close());
518
+ standalone.ready.catch((error) => {
519
+ ctx.logger?.warn?.(`[dsh-mneme] standalone API failed to start: ${String(error)}`);
520
+ });
521
+ }
522
+
523
+ // Async disposer: cordis awaits the returned promise on unload (runDisposable),
524
+ // so an in-flight dream run is allowed to finish before the SQLite store is
525
+ // closed — dream.dispose() resolves only after its current run settles.
526
+ return async () => {
527
+ for (const dispose of disposers) {
528
+ if (typeof dispose === "function") dispose();
529
+ }
530
+ commands?.dispose();
531
+ if (dream) await dream.dispose();
532
+ if (sleep) sleep.dispose();
533
+ store.close();
534
+ };
535
+ };