@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/settings.js CHANGED
@@ -1,509 +1,517 @@
1
- // User-configurable settings: profile (user self-description), rules (behavior
2
- // rules the agent must follow), and custom slash commands. Stored in the same
3
- // SQLite database via dedicated tables, isolated from the memories store.
4
- import { randomUUID } from "node:crypto";
5
-
6
- const SCHEMA = `
7
- CREATE TABLE IF NOT EXISTS user_settings (
8
- key TEXT PRIMARY KEY,
9
- value TEXT NOT NULL
10
- );
11
- CREATE TABLE IF NOT EXISTS custom_commands (
12
- id TEXT PRIMARY KEY,
13
- name TEXT NOT NULL UNIQUE,
14
- description TEXT NOT NULL DEFAULT '',
15
- instruction TEXT NOT NULL,
16
- created_at TEXT NOT NULL,
17
- updated_at TEXT NOT NULL
18
- );
19
- `;
20
-
21
- // DSH command names must match this (lowercase, start with a letter).
22
- const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
23
-
24
- /** Parse a JSON array out of a stored string, tolerant of corruption. */
25
- function parseList(raw) {
26
- try {
27
- const value = JSON.parse(raw);
28
- return Array.isArray(value) ? value : [];
29
- } catch {
30
- return [];
31
- }
32
- }
33
-
34
- // --- feature flags(功能开关)白名单 -----------------------------------------
35
- // 面板可逐项开关的后端能力。设计约束:
36
- // 1. 键名与类型必须和 config.js schema 同名同型,这份白名单是唯一校验源
37
- // (api.js 复用它计算 effective),schema 增删能力键时要同步改这里。
38
- // 2. 持久化(kv "feature_flags")只落白名单键;读到未知键、类型损坏或越界的
39
- // 值一律丢弃而不是报错——kv 会残留旧版本写入的键,读路径必须向前兼容。
40
- // 3. 写入是逐键校验的合并写:未知键/类型/范围不符抛 TypeError(消息含键名,
41
- // 供 API 透传给前端定位),校验不通过不落库,坏值永远进不了 kv。
42
- const FEATURE_FLAG_BOOLEANS = [
43
- "autoInject",
44
- "autoSummarize",
45
- "hotMemoryEnabled",
46
- // Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入当前时间。
47
- // 顶层扁平键,随白名单回面板(FEATURE_GROUPS 的 group.core)。
48
- "injectTimePrefix",
49
- // Issue #239 第 5 项:注入条数的查询自适应(确定性强则收缩注入条数,默认关)。
50
- "injectUncertaintyAdaptive",
51
- // Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
52
- // 注入父开关 autoInject 的子项、默认开(父关时不生效,闸门见 config.js)。
53
- "injectGuidanceEnabled",
54
- // Issue #249 N3:压缩边缘双落点——上下文即将精简前落一条连续性提案 + 往
55
- // 序列末尾追加同一份快照。默认关(新注入表面,见 config.js),父开关 autoInject。
56
- "continuityRescueEnabled",
57
- "entityExtractionEnabled",
58
- // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
59
- // (默认关;依赖实体抽取产出,lightMode 强制关闭)。
60
- "entityRecallEnabled",
61
- // Issue #24 块1:图谱锚定层级联——命中实体作种子、沿关系表向邻居扩散
62
- // (默认关;依赖实体抽取产出,lightMode 强制关闭)。
63
- "graphAnchoringEnabled",
64
- // Issue #24 块2:边权重演化总闸——关着时任何触达都不改边权(默认关;
65
- // 依赖实体抽取产出,lightMode 强制关闭)。块4 的被动确认是它底下的一个通道。
66
- "graphWeightEnabled",
67
- // Issue #24 块3:关联提示防幻觉——图召回线索行以「[检索线索]」标注进注入
68
- // (默认关=线索只参与排序不进块;依赖实体抽取产出,lightMode 强制关闭)。
69
- "graphInjectHint",
70
- // Issue #24 块4:被动确认通道——正常触达即对该记忆挂联的边 bump 一格
71
- // (默认关;需总闸 graphWeightEnabled 同开)。
72
- "graphPassiveConfirm",
73
- // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source=
74
- // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。
75
- "dreamNarrativeEnabled",
76
- // Issue #230:document 型记忆——agent 产长文档的指针行(注册校验/摘要+
77
- // doc_path 落库/C2 去重/supersede 记账;全文归 agent;默认关,lightMode 强制关)。
78
- "documentMemoryEnabled",
79
- "codingRetrospect",
80
- "autoDream",
81
- // Issue #292:autoDream 连续失败退避(默认关)。基数是 dreamMinIntervalMinutes,
82
- // 有效间隔 = 基数 × 2^连续失败数(成功清零),封顶 30 分钟;面板可启停。
83
- "autoDreamFailureBackoff",
84
- "sleepModeEnabled",
85
- "heatEnabled",
86
- "hybridInject",
87
- "selectiveInjectEnabled",
88
- "searchSemanticDedup",
89
- "rerankEnabled",
90
- "resilientModelDownload",
91
- "adaptiveThresholdEnabled",
92
- "reflectionUpdateEnabled",
93
- "reflectionFailureTracking",
94
- "bm25SearchEnabled",
95
- "conflictFreezeEnabled",
96
- "trustEpistemicWeighting",
97
- // Plan #2: attach per-source {keyword, vector, bm25, final} signals to each
98
- // search result for transparency/debugging. Default off, purely decorative.
99
- "signalTransparency",
100
- // Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
101
- "dreamSkipInvalid",
102
- "allowCrossTypeMerge",
103
- // Issue #339 / E8:guarded 类型 merge 护栏(默认关)——合并对象命中
104
- // 长保留类型的 merge 决策整条跳过。
105
- "dreamMergeGuard",
106
- // Issue #17(v0.8.0 A1):scope 隔离存储层总开关。开启后写入标注
107
- // agent_scope/workspace_scope、去重键扩展(含 sensitivity);检索侧过滤在
108
- // A2/A3。默认关=行为与 A1 前逐字节一致。
109
- "scopeEnabled",
110
- // Issue #17(v0.8.0 A3):strictScope 硬过滤——他 scope 完全不可见(关闭时
111
- // 为 A2 软隔离:降权保留可见)。
112
- "strictScope",
113
- // 工具暴露开关(v0.8.5):记忆已每轮自动注入,memory_search/memory_archive
114
- // 在慢/轻量模型上是多余往返,面板可关(默认关=行为不变)。
115
- "disableMemorySearch",
116
- "disableMemoryArchive",
117
- // 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
118
- // 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
119
- // 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
120
- "memoryQualityFilter.enabled",
121
- "llmAudit.enabled",
122
- // Issue #254:写入准入(第 1 级确定性拒绝)。两个键分层——enabled 跑判据、
123
- // enforce 真拦;都默认关。同上,点号键平铺存、合并时展开回
124
- // writeAdmission 对象。
125
- "writeAdmission.enabled",
126
- "writeAdmission.enforce",
127
- // Issue #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。与上面两个
128
- // 键分层——本键决定「这类判据参不参与」(默认关),命中之后是仅告警还是真拦仍由
129
- // writeAdmission.enforce 决定(#164 口径:默认仅告警、拦截 opt-in)。
130
- "sensitiveScanEnabled"
131
- ];
132
- // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
133
- const FEATURE_FLAG_INT_RANGES = {
134
- distillRateLimitIntervalMs: [0, 60000],
135
- distillRateLimitRetries: [0, 10],
136
- distillRateLimitBaseDelayMs: [100, 60000],
137
- distillMaxChars: [1000, 200000],
138
- codingBoostFactor: [1, 5],
139
- dreamMinIntervalMinutes: [0, 10080],
140
- dreamMaxTokens: [256, 131072],
141
- // Issue #127:autoSummarize 节流三键(0 = 零行为变化,等同现状)。
142
- summarizeMinIntervalMinutes: [0, 10080],
143
- summarizeMaxEntriesPerRun: [0, 50],
144
- summarizeDedupeWindowHours: [0, 168],
145
- // Issue #239:蒸馏零 LLM 预判(窗口最小字符数)与每会话 run 预算(0 = 零行为变化)。
146
- summarizeMinWindowChars: [0, 100000],
147
- summarizeMaxRunsPerSession: [0, 1000],
148
- // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。
149
- summarizePeakMaxDeferMinutes: [0, 1440],
150
- // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。
151
- dreamPeakMaxDeferMinutes: [0, 1440],
152
- // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
153
- dreamCandidateMax: [0, 5000],
154
- // Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。
155
- injectContentMaxChars: [60, 4000],
156
- // Issue #164:叙述条成簇门槛(共享同一 tag 的记忆数下限)。
157
- dreamNarrativeMinCluster: [2, 20],
158
- // Issue #230:document 摘要行的注入预算(次优先档内最多几条指针行)。
159
- documentInjectBudget: [1, 5],
160
- // Issue #249 第一批:B1 pin 池(约束/偏好)的独立条数预算(0 = 关闭/现状)。
161
- pinnedInjectBudget: [0, 5],
162
- // Issue #257:sleep 冲突/模式阶段的 LLM 输出预算(原硬编码 2048,实测不足)。
163
- sleepMaxTokens: [256, 131072],
164
- // Issue #258:总览(dream_summarize)输入条数硬上限(0 = 不设上限)。
165
- dreamSummaryMaxInputs: [0, 100000],
166
- // Issue #24 块1:锚定种子上限与级联深度(闭区间与 config.js 的 z.natural() 对齐)。
167
- graphSeedCap: [1, 30],
168
- graphCascadeDepth: [1, 3],
169
- // Issue #24 块3:图线索行的独立注入预算(0 = 线索不进块)。
170
- graphInjectBudget: [0, 5]
171
- };
172
- // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关
173
- // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。
174
- const FEATURE_FLAG_NUMBER_RANGES = {
175
- // Issue #127:vector 去重档的并入阈值。
176
- summarizeDedupeMinSim: [0.5, 0.99],
177
- // Issue #125:hybrid 判"高相似"的阈值。
178
- dreamCandidateMinSim: [0.5, 0.99],
179
- // Issue #24 块2:单次有效触达的边权抬升幅度(与 config.js 的闭区间对齐)。
180
- graphWeightDelta: [0, 1]
181
- };
182
- // 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
183
- // 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
184
- const FEATURE_FLAG_STRINGS = [
185
- "dreamProvider",
186
- "dreamModel",
187
- // 睡眠侧专用路由(sleep.js 的 config-first 第三层):面板下拉随
188
- // /llm-providers 端点一起提供,留空 = 用巩固模型或当前模型。
189
- "sleepProvider",
190
- "sleepModel",
191
- // 实体抽取侧专用路由(issue #109):provider/model 显式指定,
192
- // 留空 = 用当前默认模型。
193
- "entityExtractionProvider",
194
- "entityExtractionModel",
195
- // Issue #258:总览(dream_summarize)专用路由,留空 = 用巩固模型。
196
- "dreamSummaryProvider",
197
- "dreamSummaryModel",
198
- "localEmbedModel",
199
- "ollamaModel",
200
- // Issue #239 第 4 项:高峰时段串("09:00-18:00",空串 = 关闭)。
201
- "summarizePeakHours",
202
- // Issue #239 第 4 项镜像到巩固:同一份时段语法,空串 = 关闭(行为与现状一致)。
203
- "dreamPeakHours"
204
- ];
205
- // URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议,
206
- // 拒绝其余协议——这是 SSRF 防线的一部分)。
207
- const FEATURE_FLAG_URLS = ["ollamaBaseUrl"];
208
- // 枚举开关(与 config.js 的 z.union(z.const(...)) 对齐):仅允许列出的值。
209
- const FEATURE_FLAG_ENUMS = {
210
- embedProvider: ["openai", "local", "ollama"],
211
- // Plan #1: recall fusion recipe. blend = legacy (default); rrf / minmax are
212
- // rank/scale-aware alternatives selected by the panel.
213
- recallFusion: ["blend", "rrf", "minmax"],
214
- // 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
215
- entityExtractionReasoning: ["low", "medium", "high", "none"],
216
- // 蒸馏思考强度(issue #315):与 entityExtractionReasoning 枚举对齐,
217
- // 多一个 off(显式关思考,思考型模型蒸馏防推理烧预算)。
218
- summarizeReasoningEffort: ["off", "low", "medium", "high", "none"],
219
- // Issue #127:落库前去重档位(off 默认,等同现状)。
220
- summarizeDedupeMode: ["off", "title", "vector"],
221
- // Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
222
- sleepActionSet: ["conflict", "full"],
223
- // Issue #125:dream 候选集构造方式(window 默认 = 现状;hybrid 并入向量组)。
224
- dreamCandidateMode: ["window", "hybrid"],
225
- // 本地嵌入池化:auto = 按模型族判定(BGE → cls),可显式覆盖为 cls / mean。
226
- localEmbedPooling: ["auto", "cls", "mean"]
227
- };
228
- const FEATURE_FLAG_STRING_MAX = 200;
229
-
230
- // 供 api.js 复用同一份白名单(effective 只在白名单键上计算)。
231
- export const FEATURE_FLAG_SPEC = {
232
- booleans: FEATURE_FLAG_BOOLEANS,
233
- ints: FEATURE_FLAG_INT_RANGES,
234
- numbers: FEATURE_FLAG_NUMBER_RANGES,
235
- strings: FEATURE_FLAG_STRINGS,
236
- urls: FEATURE_FLAG_URLS,
237
- enums: FEATURE_FLAG_ENUMS
238
- };
239
-
240
- /** ollamaBaseUrl 的协议白名单:只接受 http/https(SSRF 防线的一部分)。 */
241
- function isHttpUrl(value) {
242
- try {
243
- const protocol = new URL(value).protocol;
244
- return protocol === "http:" || protocol === "https:";
245
- } catch {
246
- return false;
247
- }
248
- }
249
-
250
- /** 校验单个开关值;不合法抛 TypeError(消息含键名)。 */
251
- function validateFlag(key, value) {
252
- if (FEATURE_FLAG_BOOLEANS.includes(key)) {
253
- if (typeof value !== "boolean") {
254
- throw new TypeError(`feature flag "${key}" must be a boolean`);
255
- }
256
- return value;
257
- }
258
- const range = FEATURE_FLAG_INT_RANGES[key];
259
- if (range) {
260
- const [min, max] = range;
261
- if (!Number.isInteger(value) || value < min || value > max) {
262
- throw new TypeError(`feature flag "${key}" must be an integer in [${min}, ${max}]`);
263
- }
264
- return value;
265
- }
266
- const numRange = FEATURE_FLAG_NUMBER_RANGES[key];
267
- if (numRange) {
268
- const [min, max] = numRange;
269
- if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
270
- throw new TypeError(`feature flag "${key}" must be a number in [${min}, ${max}]`);
271
- }
272
- return value;
273
- }
274
- if (FEATURE_FLAG_STRINGS.includes(key)) {
275
- if (typeof value !== "string") {
276
- throw new TypeError(`feature flag "${key}" must be a string`);
277
- }
278
- const trimmed = value.trim();
279
- if (trimmed.length > FEATURE_FLAG_STRING_MAX) {
280
- throw new TypeError(`feature flag "${key}" must be at most ${FEATURE_FLAG_STRING_MAX} characters`);
281
- }
282
- return trimmed; // 空串合法 = 跟随默认
283
- }
284
- if (FEATURE_FLAG_URLS.includes(key)) {
285
- if (typeof value !== "string") {
286
- throw new TypeError(`feature flag "${key}" must be a string`);
287
- }
288
- const trimmed = value.trim();
289
- if (trimmed && !isHttpUrl(trimmed)) {
290
- throw new TypeError(`feature flag "${key}" must be empty or a valid http(s) URL`);
291
- }
292
- return trimmed; // 空串合法 = 跟随默认
293
- }
294
- const allowed = FEATURE_FLAG_ENUMS[key];
295
- if (allowed) {
296
- if (typeof value !== "string" || !allowed.includes(value)) {
297
- throw new TypeError(`feature flag "${key}" must be one of: ${allowed.join(", ")}`);
298
- }
299
- return value;
300
- }
301
- throw new TypeError(`unknown feature flag "${key}"`);
302
- }
303
-
304
- /** 清洗已存的 feature_flags 对象:只保留白名单键,类型/范围损坏的键丢弃。 */
305
- function sanitizeFlags(raw) {
306
- const out = {};
307
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return out;
308
- for (const key of FEATURE_FLAG_BOOLEANS) {
309
- if (typeof raw[key] === "boolean") out[key] = raw[key];
310
- }
311
- for (const [key, [min, max]] of Object.entries(FEATURE_FLAG_INT_RANGES)) {
312
- if (Number.isInteger(raw[key]) && raw[key] >= min && raw[key] <= max) out[key] = raw[key];
313
- }
314
- for (const [key, [min, max]] of Object.entries(FEATURE_FLAG_NUMBER_RANGES)) {
315
- if (typeof raw[key] === "number" && Number.isFinite(raw[key]) && raw[key] >= min && raw[key] <= max) {
316
- out[key] = raw[key];
317
- }
318
- }
319
- for (const key of FEATURE_FLAG_STRINGS) {
320
- if (typeof raw[key] === "string" && raw[key].trim().length <= FEATURE_FLAG_STRING_MAX) {
321
- out[key] = raw[key].trim();
322
- }
323
- }
324
- for (const key of FEATURE_FLAG_URLS) {
325
- if (typeof raw[key] === "string") {
326
- const trimmed = raw[key].trim();
327
- if (!trimmed || isHttpUrl(trimmed)) out[key] = trimmed;
328
- }
329
- }
330
- for (const [key, allowed] of Object.entries(FEATURE_FLAG_ENUMS)) {
331
- if (allowed.includes(raw[key])) out[key] = raw[key];
332
- }
333
- return out;
334
- }
335
-
336
- export function createSettings(db) {
337
- db.exec(SCHEMA);
338
-
339
- function getSetting(key) {
340
- const row = db.prepare("SELECT value FROM user_settings WHERE key = ?").get(key);
341
- return row?.value ?? undefined;
342
- }
343
-
344
- function setSetting(key, value) {
345
- db.prepare(
346
- `INSERT INTO user_settings (key, value) VALUES (?, ?)
347
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`
348
- ).run(key, value);
349
- }
350
-
351
- function toCommand(row) {
352
- if (!row) return undefined;
353
- return {
354
- id: row.id,
355
- name: row.name,
356
- description: row.description,
357
- instruction: row.instruction,
358
- created_at: row.created_at,
359
- updated_at: row.updated_at
360
- };
361
- }
362
-
363
- return {
364
- /** The user's self-description (free text) or "" when unset. */
365
- getProfile() {
366
- return getSetting("profile") ?? "";
367
- },
368
- setProfile(text) {
369
- setSetting("profile", String(text ?? ""));
370
- },
371
-
372
- /** Behavior rules as an array of strings. */
373
- getRules() {
374
- return parseList(getSetting("rules") ?? "[]").filter((r) => typeof r === "string");
375
- },
376
- setRules(rules) {
377
- const list = Array.isArray(rules) ? rules.filter((r) => typeof r === "string") : [];
378
- setSetting("rules", JSON.stringify(list));
379
- },
380
-
381
- /** All custom commands, sorted by name. */
382
- listCommands() {
383
- const rows = db.prepare("SELECT * FROM custom_commands ORDER BY name ASC").all();
384
- return rows.map(toCommand);
385
- },
386
-
387
- /**
388
- * Add or replace a custom command by name.
389
- * @returns the stored command.
390
- * @throws when name is invalid or does not match DSH's command-name grammar.
391
- */
392
- addCommand({ name, description = "", instruction }) {
393
- const cmdName = String(name ?? "").trim();
394
- if (!COMMAND_NAME.test(cmdName)) {
395
- throw new Error(`invalid command name "${cmdName}": must match /^[a-z][a-z0-9_-]*$/`);
396
- }
397
- if (typeof instruction !== "string" || !instruction.trim()) {
398
- throw new Error("command instruction must be a non-empty string");
399
- }
400
- const now = new Date().toISOString();
401
- const existing = db.prepare("SELECT id FROM custom_commands WHERE name = ?").get(cmdName);
402
- if (existing) {
403
- db.prepare(
404
- "UPDATE custom_commands SET description = ?, instruction = ?, updated_at = ? WHERE id = ?"
405
- ).run(String(description ?? ""), instruction, now, existing.id);
406
- return toCommand(db.prepare("SELECT * FROM custom_commands WHERE id = ?").get(existing.id));
407
- }
408
- const id = randomUUID();
409
- db.prepare(
410
- `INSERT INTO custom_commands (id, name, description, instruction, created_at, updated_at)
411
- VALUES (?, ?, ?, ?, ?, ?)`
412
- ).run(id, cmdName, String(description ?? ""), instruction, now, now);
413
- return toCommand(db.prepare("SELECT * FROM custom_commands WHERE id = ?").get(id));
414
- },
415
-
416
- /** Remove a custom command by id; returns true when removed. */
417
- removeCommand(id) {
418
- const result = db.prepare("DELETE FROM custom_commands WHERE id = ?").run(id);
419
- return result.changes > 0;
420
- },
421
-
422
- /** Vector-search provider config (OpenAI-compatible embeddings endpoint). */
423
- getVectorConfig() {
424
- const raw = getSetting("vector");
425
- if (!raw) return undefined;
426
- try {
427
- const cfg = JSON.parse(raw);
428
- return typeof cfg === "object" && cfg !== null ? cfg : undefined;
429
- } catch {
430
- return undefined;
431
- }
432
- },
433
- setVectorConfig({ enabled, baseUrl, apiKey, model }) {
434
- const cfg = {
435
- enabled: enabled === true || enabled === 1,
436
- baseUrl: String(baseUrl ?? "").trim().replace(/\/+$/, ""),
437
- apiKey: String(apiKey ?? "").trim(),
438
- model: String(model ?? "").trim()
439
- };
440
- setSetting("vector", JSON.stringify(cfg));
441
- return cfg;
442
- },
443
-
444
- /**
445
- * Standalone external API settings (kv "external_api"): {enabled, port,
446
- * token}. The Bearer token is auto-generated on first boot and persisted
447
- * here. Partial writes preserve the keys they don't mention.
448
- */
449
- getExternalApi() {
450
- const raw = getSetting("external_api");
451
- if (!raw) return undefined;
452
- try {
453
- const cfg = JSON.parse(raw);
454
- return typeof cfg === "object" && cfg !== null ? cfg : undefined;
455
- } catch {
456
- return undefined;
457
- }
458
- },
459
- setExternalApi(patch = {}) {
460
- const prev = this.getExternalApi() ?? {};
461
- const port = Number(patch.port ?? prev.port);
462
- const host = typeof patch.host === "string" && patch.host.trim() ? patch.host.trim() : (prev.host ?? "127.0.0.1");
463
- const cfg = {
464
- enabled: patch.enabled !== undefined ? patch.enabled === true : prev.enabled === true,
465
- port: Number.isInteger(port) && port > 0 ? port : 8790,
466
- host,
467
- token: String(patch.token ?? prev.token ?? "")
468
- };
469
- setSetting("external_api", JSON.stringify(cfg));
470
- return cfg;
471
- },
472
-
473
- /**
474
- * Web panel mode (kv "panel_mode"): "light" (low-resource preset) or
475
- * "standard" (full feature set). Unset reads as "standard".
476
- */
477
- getPanelMode() {
478
- return getSetting("panel_mode") === "light" ? "light" : "standard";
479
- },
480
- setPanelMode(mode) {
481
- setSetting("panel_mode", mode === "light" ? "light" : "standard");
482
- },
483
-
484
- /**
485
- * Feature flags(kv "feature_flags"):面板对后端能力的显式覆盖。读取只
486
- * 返回白名单内的合法键(默认 {}),写入是逐键校验后的合并持久化。
487
- */
488
- getFeatureFlags() {
489
- const raw = getSetting("feature_flags");
490
- if (!raw) return {};
491
- try {
492
- return sanitizeFlags(JSON.parse(raw));
493
- } catch {
494
- return {};
495
- }
496
- },
497
- setFeatureFlags(patch) {
498
- if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
499
- throw new TypeError("feature flags patch must be a plain object");
500
- }
501
- const merged = this.getFeatureFlags();
502
- for (const [key, value] of Object.entries(patch)) {
503
- merged[key] = validateFlag(key, value);
504
- }
505
- setSetting("feature_flags", JSON.stringify(merged));
506
- return merged;
507
- }
508
- };
509
- }
1
+ // User-configurable settings: profile (user self-description), rules (behavior
2
+ // rules the agent must follow), and custom slash commands. Stored in the same
3
+ // SQLite database via dedicated tables, isolated from the memories store.
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ const SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS user_settings (
8
+ key TEXT PRIMARY KEY,
9
+ value TEXT NOT NULL
10
+ );
11
+ CREATE TABLE IF NOT EXISTS custom_commands (
12
+ id TEXT PRIMARY KEY,
13
+ name TEXT NOT NULL UNIQUE,
14
+ description TEXT NOT NULL DEFAULT '',
15
+ instruction TEXT NOT NULL,
16
+ created_at TEXT NOT NULL,
17
+ updated_at TEXT NOT NULL
18
+ );
19
+ `;
20
+
21
+ // DSH command names must match this (lowercase, start with a letter).
22
+ const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
23
+
24
+ /** Parse a JSON array out of a stored string, tolerant of corruption. */
25
+ function parseList(raw) {
26
+ try {
27
+ const value = JSON.parse(raw);
28
+ return Array.isArray(value) ? value : [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ // --- feature flags(功能开关)白名单 -----------------------------------------
35
+ // 面板可逐项开关的后端能力。设计约束:
36
+ // 1. 键名与类型必须和 config.js schema 同名同型,这份白名单是唯一校验源
37
+ // (api.js 复用它计算 effective),schema 增删能力键时要同步改这里。
38
+ // 2. 持久化(kv "feature_flags")只落白名单键;读到未知键、类型损坏或越界的
39
+ // 值一律丢弃而不是报错——kv 会残留旧版本写入的键,读路径必须向前兼容。
40
+ // 3. 写入是逐键校验的合并写:未知键/类型/范围不符抛 TypeError(消息含键名,
41
+ // 供 API 透传给前端定位),校验不通过不落库,坏值永远进不了 kv。
42
+ const FEATURE_FLAG_BOOLEANS = [
43
+ "autoInject",
44
+ "autoSummarize",
45
+ "hotMemoryEnabled",
46
+ // Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入当前时间。
47
+ // 顶层扁平键,随白名单回面板(FEATURE_GROUPS 的 group.core)。
48
+ "injectTimePrefix",
49
+ // Issue #239 第 5 项:注入条数的查询自适应(确定性强则收缩注入条数,默认关)。
50
+ "injectUncertaintyAdaptive",
51
+ // Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
52
+ // 注入父开关 autoInject 的子项、默认开(父关时不生效,闸门见 config.js)。
53
+ "injectGuidanceEnabled",
54
+ // Issue #249 N3:压缩边缘双落点——上下文即将精简前落一条连续性提案 + 往
55
+ // 序列末尾追加同一份快照。默认关(新注入表面,见 config.js),父开关 autoInject。
56
+ "continuityRescueEnabled",
57
+ "entityExtractionEnabled",
58
+ // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
59
+ // (默认关;依赖实体抽取产出,lightMode 强制关闭)。
60
+ "entityRecallEnabled",
61
+ // Issue #24 块1:图谱锚定层级联——命中实体作种子、沿关系表向邻居扩散
62
+ // (默认关;依赖实体抽取产出,lightMode 强制关闭)。
63
+ "graphAnchoringEnabled",
64
+ // Issue #24 块2:边权重演化总闸——关着时任何触达都不改边权(默认关;
65
+ // 依赖实体抽取产出,lightMode 强制关闭)。块4 的被动确认是它底下的一个通道。
66
+ "graphWeightEnabled",
67
+ // Issue #24 块3:关联提示防幻觉——图召回线索行以「[检索线索]」标注进注入
68
+ // (默认关=线索只参与排序不进块;依赖实体抽取产出,lightMode 强制关闭)。
69
+ "graphInjectHint",
70
+ // Issue #24 块4:被动确认通道——正常触达即对该记忆挂联的边 bump 一格
71
+ // (默认关;需总闸 graphWeightEnabled 同开)。
72
+ "graphPassiveConfirm",
73
+ // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source=
74
+ // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。
75
+ "dreamNarrativeEnabled",
76
+ // Issue #230:document 型记忆——agent 产长文档的指针行(注册校验/摘要+
77
+ // doc_path 落库/C2 去重/supersede 记账;全文归 agent;默认关,lightMode 强制关)。
78
+ "documentMemoryEnabled",
79
+ "codingRetrospect",
80
+ "autoDream",
81
+ // Issue #292:autoDream 连续失败退避(默认关)。基数是 dreamMinIntervalMinutes,
82
+ // 有效间隔 = 基数 × 2^连续失败数(成功清零),封顶 30 分钟;面板可启停。
83
+ "autoDreamFailureBackoff",
84
+ "sleepModeEnabled",
85
+ "heatEnabled",
86
+ // issue #218 / E5:注入侧 heat 独立开关(默认关,老用户 injectHeatEnabled=true 回滚)。
87
+ "injectHeatEnabled",
88
+ "hybridInject",
89
+ "selectiveInjectEnabled",
90
+ "searchSemanticDedup",
91
+ "rerankEnabled",
92
+ "resilientModelDownload",
93
+ "adaptiveThresholdEnabled",
94
+ "reflectionUpdateEnabled",
95
+ "reflectionFailureTracking",
96
+ "bm25SearchEnabled",
97
+ "conflictFreezeEnabled",
98
+ "trustEpistemicWeighting",
99
+ // Plan #2: attach per-source {keyword, vector, bm25, final} signals to each
100
+ // search result for transparency/debugging. Default off, purely decorative.
101
+ "signalTransparency",
102
+ // Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
103
+ "dreamSkipInvalid",
104
+ "allowCrossTypeMerge",
105
+ // Issue #339 / E8:guarded 类型 merge 护栏(默认开)——合并对象命中
106
+ // 长保留类型的 merge 决策整条跳过。关掉 = guarded 类型的 merge 不再被跳过,
107
+ // 「更精炼的摘要」重新成为约束失真的主通道(面板可关,供需要旧行为的库回退)。
108
+ "dreamMergeGuard",
109
+ // Issue #17(v0.8.0 A1):scope 隔离存储层总开关。开启后写入标注
110
+ // agent_scope/workspace_scope、去重键扩展(含 sensitivity);检索侧过滤在
111
+ // A2/A3。默认关=行为与 A1 前逐字节一致。
112
+ "scopeEnabled",
113
+ // Issue #17(v0.8.0 A3):strictScope 硬过滤——他 scope 完全不可见(关闭时
114
+ // 为 A2 软隔离:降权保留可见)。
115
+ "strictScope",
116
+ // 工具暴露开关(v0.8.5):记忆已每轮自动注入,memory_search/memory_archive
117
+ // 在慢/轻量模型上是多余往返,面板可关(默认关=行为不变)。
118
+ "disableMemorySearch",
119
+ "disableMemoryArchive",
120
+ // 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
121
+ // 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
122
+ // 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
123
+ "memoryQualityFilter.enabled",
124
+ "llmAudit.enabled",
125
+ // Issue #254:写入准入(第 1 级确定性拒绝)。两个键分层——enabled 跑判据、
126
+ // enforce 真拦;都默认关。同上,点号键平铺存、合并时展开回
127
+ // writeAdmission 对象。
128
+ "writeAdmission.enabled",
129
+ "writeAdmission.enforce",
130
+ // Issue #380:注入前判定(preInjectGate)。两级语义同 writeAdmission——
131
+ // enabled 跑判定(观察档)、enforce 缓存命中帧真滤除;都默认关。点号键平铺存、
132
+ // 合并时展开回 preInjectGate 对象。
133
+ "preInjectGate.enabled",
134
+ "preInjectGate.enforce",
135
+ // Issue #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。与上面两个
136
+ // 键分层——本键决定「这类判据参不参与」(默认关),命中之后是仅告警还是真拦仍由
137
+ // writeAdmission.enforce 决定(#164 口径:默认仅告警、拦截 opt-in)。
138
+ "sensitiveScanEnabled"
139
+ ];
140
+ // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
141
+ const FEATURE_FLAG_INT_RANGES = {
142
+ distillRateLimitIntervalMs: [0, 60000],
143
+ distillRateLimitRetries: [0, 10],
144
+ distillRateLimitBaseDelayMs: [100, 60000],
145
+ distillMaxChars: [1000, 200000],
146
+ codingBoostFactor: [1, 5],
147
+ dreamMinIntervalMinutes: [0, 10080],
148
+ dreamMaxTokens: [256, 131072],
149
+ // Issue #127:autoSummarize 节流三键(0 = 零行为变化,等同现状)。
150
+ summarizeMinIntervalMinutes: [0, 10080],
151
+ summarizeMaxEntriesPerRun: [0, 50],
152
+ summarizeDedupeWindowHours: [0, 168],
153
+ // Issue #239:蒸馏零 LLM 预判(窗口最小字符数)与每会话 run 预算(0 = 零行为变化)。
154
+ summarizeMinWindowChars: [0, 100000],
155
+ summarizeMaxRunsPerSession: [0, 1000],
156
+ // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。
157
+ summarizePeakMaxDeferMinutes: [0, 1440],
158
+ // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。
159
+ dreamPeakMaxDeferMinutes: [0, 1440],
160
+ // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
161
+ dreamCandidateMax: [0, 5000],
162
+ // Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。
163
+ injectContentMaxChars: [60, 4000],
164
+ // Issue #164:叙述条成簇门槛(共享同一 tag 的记忆数下限)。
165
+ dreamNarrativeMinCluster: [2, 20],
166
+ // Issue #230:document 摘要行的注入预算(次优先档内最多几条指针行)。
167
+ documentInjectBudget: [1, 5],
168
+ // Issue #249 第一批:B1 pin 池(约束/偏好)的独立条数预算(0 = 关闭/现状)。
169
+ pinnedInjectBudget: [0, 5],
170
+ // Issue #257:sleep 冲突/模式阶段的 LLM 输出预算(原硬编码 2048,实测不足)。
171
+ sleepMaxTokens: [256, 131072],
172
+ // Issue #258:总览(dream_summarize)输入条数硬上限(0 = 不设上限)。
173
+ dreamSummaryMaxInputs: [0, 100000],
174
+ // Issue #24 块1:锚定种子上限与级联深度(闭区间与 config.js 的 z.natural() 对齐)。
175
+ graphSeedCap: [1, 30],
176
+ graphCascadeDepth: [1, 3],
177
+ // Issue #24 块3:图线索行的独立注入预算(0 = 线索不进块)。
178
+ graphInjectBudget: [0, 5]
179
+ };
180
+ // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关
181
+ // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。
182
+ const FEATURE_FLAG_NUMBER_RANGES = {
183
+ // Issue #127:vector 去重档的并入阈值。
184
+ summarizeDedupeMinSim: [0.5, 0.99],
185
+ // Issue #125:hybrid 判"高相似"的阈值。
186
+ dreamCandidateMinSim: [0.5, 0.99],
187
+ // Issue #24 块2:单次有效触达的边权抬升幅度(与 config.js 的闭区间对齐)。
188
+ graphWeightDelta: [0, 1]
189
+ };
190
+ // 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
191
+ // 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
192
+ const FEATURE_FLAG_STRINGS = [
193
+ "dreamProvider",
194
+ "dreamModel",
195
+ // 睡眠侧专用路由(sleep.js 的 config-first 第三层):面板下拉随
196
+ // /llm-providers 端点一起提供,留空 = 用巩固模型或当前模型。
197
+ "sleepProvider",
198
+ "sleepModel",
199
+ // 实体抽取侧专用路由(issue #109):provider/model 显式指定,
200
+ // 留空 = 用当前默认模型。
201
+ "entityExtractionProvider",
202
+ "entityExtractionModel",
203
+ // Issue #258:总览(dream_summarize)专用路由,留空 = 用巩固模型。
204
+ "dreamSummaryProvider",
205
+ "dreamSummaryModel",
206
+ "localEmbedModel",
207
+ "ollamaModel",
208
+ // Issue #239 第 4 项:高峰时段串("09:00-18:00",空串 = 关闭)。
209
+ "summarizePeakHours",
210
+ // Issue #239 第 4 项镜像到巩固:同一份时段语法,空串 = 关闭(行为与现状一致)。
211
+ "dreamPeakHours"
212
+ ];
213
+ // URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议,
214
+ // 拒绝其余协议——这是 SSRF 防线的一部分)。
215
+ const FEATURE_FLAG_URLS = ["ollamaBaseUrl"];
216
+ // 枚举开关(与 config.js 的 z.union(z.const(...)) 对齐):仅允许列出的值。
217
+ const FEATURE_FLAG_ENUMS = {
218
+ embedProvider: ["openai", "local", "ollama"],
219
+ // Plan #1: recall fusion recipe. blend = legacy (default); rrf / minmax are
220
+ // rank/scale-aware alternatives selected by the panel.
221
+ recallFusion: ["blend", "rrf", "minmax"],
222
+ // 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
223
+ entityExtractionReasoning: ["low", "medium", "high", "none"],
224
+ // 蒸馏思考强度(issue #315):与 entityExtractionReasoning 枚举对齐,
225
+ // 多一个 off(显式关思考,思考型模型蒸馏防推理烧预算)。
226
+ summarizeReasoningEffort: ["off", "low", "medium", "high", "none"],
227
+ // Issue #127:落库前去重档位(off 默认,等同现状)。
228
+ summarizeDedupeMode: ["off", "title", "vector"],
229
+ // Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
230
+ sleepActionSet: ["conflict", "full"],
231
+ // Issue #125:dream 候选集构造方式(window 默认 = 现状;hybrid 并入向量组)。
232
+ dreamCandidateMode: ["window", "hybrid"],
233
+ // 本地嵌入池化:auto = 按模型族判定(BGE → cls),可显式覆盖为 cls / mean。
234
+ localEmbedPooling: ["auto", "cls", "mean"]
235
+ };
236
+ const FEATURE_FLAG_STRING_MAX = 200;
237
+
238
+ // 供 api.js 复用同一份白名单(effective 只在白名单键上计算)。
239
+ export const FEATURE_FLAG_SPEC = {
240
+ booleans: FEATURE_FLAG_BOOLEANS,
241
+ ints: FEATURE_FLAG_INT_RANGES,
242
+ numbers: FEATURE_FLAG_NUMBER_RANGES,
243
+ strings: FEATURE_FLAG_STRINGS,
244
+ urls: FEATURE_FLAG_URLS,
245
+ enums: FEATURE_FLAG_ENUMS
246
+ };
247
+
248
+ /** ollamaBaseUrl 的协议白名单:只接受 http/https(SSRF 防线的一部分)。 */
249
+ function isHttpUrl(value) {
250
+ try {
251
+ const protocol = new URL(value).protocol;
252
+ return protocol === "http:" || protocol === "https:";
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
257
+
258
+ /** 校验单个开关值;不合法抛 TypeError(消息含键名)。 */
259
+ function validateFlag(key, value) {
260
+ if (FEATURE_FLAG_BOOLEANS.includes(key)) {
261
+ if (typeof value !== "boolean") {
262
+ throw new TypeError(`feature flag "${key}" must be a boolean`);
263
+ }
264
+ return value;
265
+ }
266
+ const range = FEATURE_FLAG_INT_RANGES[key];
267
+ if (range) {
268
+ const [min, max] = range;
269
+ if (!Number.isInteger(value) || value < min || value > max) {
270
+ throw new TypeError(`feature flag "${key}" must be an integer in [${min}, ${max}]`);
271
+ }
272
+ return value;
273
+ }
274
+ const numRange = FEATURE_FLAG_NUMBER_RANGES[key];
275
+ if (numRange) {
276
+ const [min, max] = numRange;
277
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
278
+ throw new TypeError(`feature flag "${key}" must be a number in [${min}, ${max}]`);
279
+ }
280
+ return value;
281
+ }
282
+ if (FEATURE_FLAG_STRINGS.includes(key)) {
283
+ if (typeof value !== "string") {
284
+ throw new TypeError(`feature flag "${key}" must be a string`);
285
+ }
286
+ const trimmed = value.trim();
287
+ if (trimmed.length > FEATURE_FLAG_STRING_MAX) {
288
+ throw new TypeError(`feature flag "${key}" must be at most ${FEATURE_FLAG_STRING_MAX} characters`);
289
+ }
290
+ return trimmed; // 空串合法 = 跟随默认
291
+ }
292
+ if (FEATURE_FLAG_URLS.includes(key)) {
293
+ if (typeof value !== "string") {
294
+ throw new TypeError(`feature flag "${key}" must be a string`);
295
+ }
296
+ const trimmed = value.trim();
297
+ if (trimmed && !isHttpUrl(trimmed)) {
298
+ throw new TypeError(`feature flag "${key}" must be empty or a valid http(s) URL`);
299
+ }
300
+ return trimmed; // 空串合法 = 跟随默认
301
+ }
302
+ const allowed = FEATURE_FLAG_ENUMS[key];
303
+ if (allowed) {
304
+ if (typeof value !== "string" || !allowed.includes(value)) {
305
+ throw new TypeError(`feature flag "${key}" must be one of: ${allowed.join(", ")}`);
306
+ }
307
+ return value;
308
+ }
309
+ throw new TypeError(`unknown feature flag "${key}"`);
310
+ }
311
+
312
+ /** 清洗已存的 feature_flags 对象:只保留白名单键,类型/范围损坏的键丢弃。 */
313
+ function sanitizeFlags(raw) {
314
+ const out = {};
315
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return out;
316
+ for (const key of FEATURE_FLAG_BOOLEANS) {
317
+ if (typeof raw[key] === "boolean") out[key] = raw[key];
318
+ }
319
+ for (const [key, [min, max]] of Object.entries(FEATURE_FLAG_INT_RANGES)) {
320
+ if (Number.isInteger(raw[key]) && raw[key] >= min && raw[key] <= max) out[key] = raw[key];
321
+ }
322
+ for (const [key, [min, max]] of Object.entries(FEATURE_FLAG_NUMBER_RANGES)) {
323
+ if (typeof raw[key] === "number" && Number.isFinite(raw[key]) && raw[key] >= min && raw[key] <= max) {
324
+ out[key] = raw[key];
325
+ }
326
+ }
327
+ for (const key of FEATURE_FLAG_STRINGS) {
328
+ if (typeof raw[key] === "string" && raw[key].trim().length <= FEATURE_FLAG_STRING_MAX) {
329
+ out[key] = raw[key].trim();
330
+ }
331
+ }
332
+ for (const key of FEATURE_FLAG_URLS) {
333
+ if (typeof raw[key] === "string") {
334
+ const trimmed = raw[key].trim();
335
+ if (!trimmed || isHttpUrl(trimmed)) out[key] = trimmed;
336
+ }
337
+ }
338
+ for (const [key, allowed] of Object.entries(FEATURE_FLAG_ENUMS)) {
339
+ if (allowed.includes(raw[key])) out[key] = raw[key];
340
+ }
341
+ return out;
342
+ }
343
+
344
+ export function createSettings(db) {
345
+ db.exec(SCHEMA);
346
+
347
+ function getSetting(key) {
348
+ const row = db.prepare("SELECT value FROM user_settings WHERE key = ?").get(key);
349
+ return row?.value ?? undefined;
350
+ }
351
+
352
+ function setSetting(key, value) {
353
+ db.prepare(
354
+ `INSERT INTO user_settings (key, value) VALUES (?, ?)
355
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
356
+ ).run(key, value);
357
+ }
358
+
359
+ function toCommand(row) {
360
+ if (!row) return undefined;
361
+ return {
362
+ id: row.id,
363
+ name: row.name,
364
+ description: row.description,
365
+ instruction: row.instruction,
366
+ created_at: row.created_at,
367
+ updated_at: row.updated_at
368
+ };
369
+ }
370
+
371
+ return {
372
+ /** The user's self-description (free text) or "" when unset. */
373
+ getProfile() {
374
+ return getSetting("profile") ?? "";
375
+ },
376
+ setProfile(text) {
377
+ setSetting("profile", String(text ?? ""));
378
+ },
379
+
380
+ /** Behavior rules as an array of strings. */
381
+ getRules() {
382
+ return parseList(getSetting("rules") ?? "[]").filter((r) => typeof r === "string");
383
+ },
384
+ setRules(rules) {
385
+ const list = Array.isArray(rules) ? rules.filter((r) => typeof r === "string") : [];
386
+ setSetting("rules", JSON.stringify(list));
387
+ },
388
+
389
+ /** All custom commands, sorted by name. */
390
+ listCommands() {
391
+ const rows = db.prepare("SELECT * FROM custom_commands ORDER BY name ASC").all();
392
+ return rows.map(toCommand);
393
+ },
394
+
395
+ /**
396
+ * Add or replace a custom command by name.
397
+ * @returns the stored command.
398
+ * @throws when name is invalid or does not match DSH's command-name grammar.
399
+ */
400
+ addCommand({ name, description = "", instruction }) {
401
+ const cmdName = String(name ?? "").trim();
402
+ if (!COMMAND_NAME.test(cmdName)) {
403
+ throw new Error(`invalid command name "${cmdName}": must match /^[a-z][a-z0-9_-]*$/`);
404
+ }
405
+ if (typeof instruction !== "string" || !instruction.trim()) {
406
+ throw new Error("command instruction must be a non-empty string");
407
+ }
408
+ const now = new Date().toISOString();
409
+ const existing = db.prepare("SELECT id FROM custom_commands WHERE name = ?").get(cmdName);
410
+ if (existing) {
411
+ db.prepare(
412
+ "UPDATE custom_commands SET description = ?, instruction = ?, updated_at = ? WHERE id = ?"
413
+ ).run(String(description ?? ""), instruction, now, existing.id);
414
+ return toCommand(db.prepare("SELECT * FROM custom_commands WHERE id = ?").get(existing.id));
415
+ }
416
+ const id = randomUUID();
417
+ db.prepare(
418
+ `INSERT INTO custom_commands (id, name, description, instruction, created_at, updated_at)
419
+ VALUES (?, ?, ?, ?, ?, ?)`
420
+ ).run(id, cmdName, String(description ?? ""), instruction, now, now);
421
+ return toCommand(db.prepare("SELECT * FROM custom_commands WHERE id = ?").get(id));
422
+ },
423
+
424
+ /** Remove a custom command by id; returns true when removed. */
425
+ removeCommand(id) {
426
+ const result = db.prepare("DELETE FROM custom_commands WHERE id = ?").run(id);
427
+ return result.changes > 0;
428
+ },
429
+
430
+ /** Vector-search provider config (OpenAI-compatible embeddings endpoint). */
431
+ getVectorConfig() {
432
+ const raw = getSetting("vector");
433
+ if (!raw) return undefined;
434
+ try {
435
+ const cfg = JSON.parse(raw);
436
+ return typeof cfg === "object" && cfg !== null ? cfg : undefined;
437
+ } catch {
438
+ return undefined;
439
+ }
440
+ },
441
+ setVectorConfig({ enabled, baseUrl, apiKey, model }) {
442
+ const cfg = {
443
+ enabled: enabled === true || enabled === 1,
444
+ baseUrl: String(baseUrl ?? "").trim().replace(/\/+$/, ""),
445
+ apiKey: String(apiKey ?? "").trim(),
446
+ model: String(model ?? "").trim()
447
+ };
448
+ setSetting("vector", JSON.stringify(cfg));
449
+ return cfg;
450
+ },
451
+
452
+ /**
453
+ * Standalone external API settings (kv "external_api"): {enabled, port,
454
+ * token}. The Bearer token is auto-generated on first boot and persisted
455
+ * here. Partial writes preserve the keys they don't mention.
456
+ */
457
+ getExternalApi() {
458
+ const raw = getSetting("external_api");
459
+ if (!raw) return undefined;
460
+ try {
461
+ const cfg = JSON.parse(raw);
462
+ return typeof cfg === "object" && cfg !== null ? cfg : undefined;
463
+ } catch {
464
+ return undefined;
465
+ }
466
+ },
467
+ setExternalApi(patch = {}) {
468
+ const prev = this.getExternalApi() ?? {};
469
+ const port = Number(patch.port ?? prev.port);
470
+ const host = typeof patch.host === "string" && patch.host.trim() ? patch.host.trim() : (prev.host ?? "127.0.0.1");
471
+ const cfg = {
472
+ enabled: patch.enabled !== undefined ? patch.enabled === true : prev.enabled === true,
473
+ port: Number.isInteger(port) && port > 0 ? port : 8790,
474
+ host,
475
+ token: String(patch.token ?? prev.token ?? "")
476
+ };
477
+ setSetting("external_api", JSON.stringify(cfg));
478
+ return cfg;
479
+ },
480
+
481
+ /**
482
+ * Web panel mode (kv "panel_mode"): "light" (low-resource preset) or
483
+ * "standard" (full feature set). Unset reads as "standard".
484
+ */
485
+ getPanelMode() {
486
+ return getSetting("panel_mode") === "light" ? "light" : "standard";
487
+ },
488
+ setPanelMode(mode) {
489
+ setSetting("panel_mode", mode === "light" ? "light" : "standard");
490
+ },
491
+
492
+ /**
493
+ * Feature flags(kv "feature_flags"):面板对后端能力的显式覆盖。读取只
494
+ * 返回白名单内的合法键(默认 {}),写入是逐键校验后的合并持久化。
495
+ */
496
+ getFeatureFlags() {
497
+ const raw = getSetting("feature_flags");
498
+ if (!raw) return {};
499
+ try {
500
+ return sanitizeFlags(JSON.parse(raw));
501
+ } catch {
502
+ return {};
503
+ }
504
+ },
505
+ setFeatureFlags(patch) {
506
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
507
+ throw new TypeError("feature flags patch must be a plain object");
508
+ }
509
+ const merged = this.getFeatureFlags();
510
+ for (const [key, value] of Object.entries(patch)) {
511
+ merged[key] = validateFlag(key, value);
512
+ }
513
+ setSetting("feature_flags", JSON.stringify(merged));
514
+ return merged;
515
+ }
516
+ };
517
+ }