dsh-biocore 0.1.91 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/README.md +11 -0
  2. package/lib/core-worker.js +268 -0
  3. package/lib/index.js +160 -70
  4. package/lib/modules/autonomy.js +125 -0
  5. package/lib/modules/cache-stats.js +48 -0
  6. package/lib/modules/core-turn.js +24 -6
  7. package/lib/modules/expression-inject.js +0 -10
  8. package/lib/modules/expression-refresh.js +0 -9
  9. package/lib/modules/memory-capture.js +2 -1
  10. package/lib/modules/memory-inject.js +50 -5
  11. package/lib/modules/memory-tools.js +7 -4
  12. package/lib/modules/python-env.js +46 -0
  13. package/lib/modules/safety-gate.js +0 -3
  14. package/lib/modules/series.js +54 -0
  15. package/lib/modules/switch.js +16 -0
  16. package/lib/py-bridge.js +81 -15
  17. package/package.json +1 -1
  18. package/python/biocore_memory/core/config.py +138 -127
  19. package/python/biocore_memory/engine.py +2653 -2476
  20. package/python/biocore_memory/recall/embedding.py +129 -0
  21. package/python/biocore_memory/recall/feedback.py +202 -0
  22. package/python/biocore_model/preference_drift.py +255 -0
  23. package/python/biocore_model/tests/__init__.py +1 -0
  24. package/python/biocore_model/tests/core_mode_ui_check.mjs +63 -0
  25. package/python/biocore_model/tests/core_status_check.mjs +29 -0
  26. package/python/biocore_model/tests/dom_safe_check.mjs +22 -0
  27. package/python/biocore_model/tests/leak_samples_run.py +18 -0
  28. package/python/biocore_model/tests/orb_ring_check.mjs +20 -0
  29. package/python/biocore_model/tests/panel_html_check.mjs +63 -0
  30. package/python/biocore_model/tests/persona_flow_check.mjs +17 -0
  31. package/python/biocore_model/tests/render_guard_check.mjs +19 -0
  32. package/python/biocore_model/tests/switch_groups_check.mjs +94 -0
  33. package/python/biocore_model/tests/tabs_state_check.mjs +24 -0
  34. package/python/biocore_model/tests/test_affective_labor.py +102 -0
  35. package/python/biocore_model/tests/test_appraisal.py +137 -0
  36. package/python/biocore_model/tests/test_appraisal_action.py +55 -0
  37. package/python/biocore_model/tests/test_arch_convergence.py +35 -0
  38. package/python/biocore_model/tests/test_attachment_behavior.py +95 -0
  39. package/python/biocore_model/tests/test_attachment_regulation.py +63 -0
  40. package/python/biocore_model/tests/test_attachment_state.py +29 -0
  41. package/python/biocore_model/tests/test_attribution.py +56 -0
  42. package/python/biocore_model/tests/test_attributor.py +108 -0
  43. package/python/biocore_model/tests/test_autonomic.py +69 -0
  44. package/python/biocore_model/tests/test_autonomy_accumulator.py +84 -0
  45. package/python/biocore_model/tests/test_autonomy_affect.py +62 -0
  46. package/python/biocore_model/tests/test_autonomy_blame.py +44 -0
  47. package/python/biocore_model/tests/test_autonomy_caps.py +29 -0
  48. package/python/biocore_model/tests/test_autonomy_check.py +40 -0
  49. package/python/biocore_model/tests/test_autonomy_clingy.py +41 -0
  50. package/python/biocore_model/tests/test_autonomy_cooldown_selfcontact.py +138 -0
  51. package/python/biocore_model/tests/test_autonomy_frustration.py +42 -0
  52. package/python/biocore_model/tests/test_autonomy_hourly_limit.py +46 -0
  53. package/python/biocore_model/tests/test_autonomy_loop.py +64 -0
  54. package/python/biocore_model/tests/test_autonomy_mode.py +37 -0
  55. package/python/biocore_model/tests/test_autonomy_model.py +60 -0
  56. package/python/biocore_model/tests/test_autonomy_model_v2.py +68 -0
  57. package/python/biocore_model/tests/test_autonomy_module_text.py +104 -0
  58. package/python/biocore_model/tests/test_autonomy_params.py +163 -0
  59. package/python/biocore_model/tests/test_autonomy_reason.py +34 -0
  60. package/python/biocore_model/tests/test_autonomy_self.py +153 -0
  61. package/python/biocore_model/tests/test_autonomy_send_gate.py +38 -0
  62. package/python/biocore_model/tests/test_autonomy_silence.py +37 -0
  63. package/python/biocore_model/tests/test_autonomy_status.py +35 -0
  64. package/python/biocore_model/tests/test_autonomy_test_trigger.py +24 -0
  65. package/python/biocore_model/tests/test_autonomy_tick.py +148 -0
  66. package/python/biocore_model/tests/test_autonomy_v4.py +64 -0
  67. package/python/biocore_model/tests/test_autonomy_v5.py +43 -0
  68. package/python/biocore_model/tests/test_autonomy_v7.py +48 -0
  69. package/python/biocore_model/tests/test_autonomy_v_theta.py +85 -0
  70. package/python/biocore_model/tests/test_batch1_wiring.py +231 -0
  71. package/python/biocore_model/tests/test_batch2_wiring.py +231 -0
  72. package/python/biocore_model/tests/test_batch3_wiring.py +223 -0
  73. package/python/biocore_model/tests/test_bio_tick.py +202 -0
  74. package/python/biocore_model/tests/test_boundary.py +183 -0
  75. package/python/biocore_model/tests/test_boundary_gate.py +295 -0
  76. package/python/biocore_model/tests/test_brain_split.py +99 -0
  77. package/python/biocore_model/tests/test_co_recall.py +350 -0
  78. package/python/biocore_model/tests/test_commitment_extractor.py +197 -0
  79. package/python/biocore_model/tests/test_compassion_fatigue.py +84 -0
  80. package/python/biocore_model/tests/test_connection_bids.py +69 -0
  81. package/python/biocore_model/tests/test_core_bridge.py +88 -0
  82. package/python/biocore_model/tests/test_core_bridge_episode.py +14 -0
  83. package/python/biocore_model/tests/test_core_bridge_forget.py +17 -0
  84. package/python/biocore_model/tests/test_core_bridge_identity.py +15 -0
  85. package/python/biocore_model/tests/test_core_bridge_media.py +17 -0
  86. package/python/biocore_model/tests/test_core_bridge_relation.py +17 -0
  87. package/python/biocore_model/tests/test_core_bridge_summary.py +17 -0
  88. package/python/biocore_model/tests/test_core_client.py +156 -0
  89. package/python/biocore_model/tests/test_core_commands.py +146 -0
  90. package/python/biocore_model/tests/test_core_mode.py +161 -0
  91. package/python/biocore_model/tests/test_core_state.py +240 -0
  92. package/python/biocore_model/tests/test_criticality.py +61 -0
  93. package/python/biocore_model/tests/test_cross_session_copy.py +67 -0
  94. package/python/biocore_model/tests/test_debug_values.py +169 -0
  95. package/python/biocore_model/tests/test_defense_mechanisms.py +177 -0
  96. package/python/biocore_model/tests/test_delta_budget.py +157 -0
  97. package/python/biocore_model/tests/test_diag_trace.py +201 -0
  98. package/python/biocore_model/tests/test_directives_compat.py +698 -0
  99. package/python/biocore_model/tests/test_dissonance.py +97 -0
  100. package/python/biocore_model/tests/test_drift_sign.py +87 -0
  101. package/python/biocore_model/tests/test_embedding_fallback.py +277 -0
  102. package/python/biocore_model/tests/test_embedding_local.py +279 -0
  103. package/python/biocore_model/tests/test_embedding_mode.py +175 -0
  104. package/python/biocore_model/tests/test_embedding_modes.py +342 -0
  105. package/python/biocore_model/tests/test_emotion_geometry.py +114 -0
  106. package/python/biocore_model/tests/test_emotion_probe.py +214 -0
  107. package/python/biocore_model/tests/test_emotion_probe_concurrency.py +128 -0
  108. package/python/biocore_model/tests/test_entity_graph.py +198 -0
  109. package/python/biocore_model/tests/test_entity_resolve.py +446 -0
  110. package/python/biocore_model/tests/test_envy.py +173 -0
  111. package/python/biocore_model/tests/test_exit_or_silence.py +209 -0
  112. package/python/biocore_model/tests/test_expr_guard.py +1 -0
  113. package/python/biocore_model/tests/test_expr_intent.py +66 -0
  114. package/python/biocore_model/tests/test_expr_state.py +66 -0
  115. package/python/biocore_model/tests/test_expr_template.py +71 -0
  116. package/python/biocore_model/tests/test_expr_tone.py +41 -0
  117. package/python/biocore_model/tests/test_extreme_scenarios.py +147 -0
  118. package/python/biocore_model/tests/test_feedback_flow.py +185 -0
  119. package/python/biocore_model/tests/test_feedback_guard.py +65 -0
  120. package/python/biocore_model/tests/test_feedback_llm.py +59 -0
  121. package/python/biocore_model/tests/test_feedback_llm_inferred.py +140 -0
  122. package/python/biocore_model/tests/test_feedback_memory.py +63 -0
  123. package/python/biocore_model/tests/test_feedback_user.py +61 -0
  124. package/python/biocore_model/tests/test_frustration_curve.py +66 -0
  125. package/python/biocore_model/tests/test_global_bio_state.py +116 -0
  126. package/python/biocore_model/tests/test_governance.py +181 -0
  127. package/python/biocore_model/tests/test_granularity.py +133 -0
  128. package/python/biocore_model/tests/test_graph_extract.py +224 -0
  129. package/python/biocore_model/tests/test_graph_recall.py +226 -0
  130. package/python/biocore_model/tests/test_graph_sqlite.py +298 -0
  131. package/python/biocore_model/tests/test_growth.py +301 -0
  132. package/python/biocore_model/tests/test_intent_render.py +60 -0
  133. package/python/biocore_model/tests/test_intero_regulation.py +224 -0
  134. package/python/biocore_model/tests/test_intero_style.py +146 -0
  135. package/python/biocore_model/tests/test_item3_numeric.py +105 -0
  136. package/python/biocore_model/tests/test_item6_numeric.py +116 -0
  137. package/python/biocore_model/tests/test_layered_memory.py +174 -0
  138. package/python/biocore_model/tests/test_leak_guard.py +75 -0
  139. package/python/biocore_model/tests/test_learning_probe.py +93 -0
  140. package/python/biocore_model/tests/test_life_loop.py +222 -0
  141. package/python/biocore_model/tests/test_life_loop_10s.py +131 -0
  142. package/python/biocore_model/tests/test_local_embedding_health.py +258 -0
  143. package/python/biocore_model/tests/test_memory_affect_encoding.py +79 -0
  144. package/python/biocore_model/tests/test_memory_backend.py +332 -0
  145. package/python/biocore_model/tests/test_memory_dedup.py +316 -0
  146. package/python/biocore_model/tests/test_memory_dual.py +219 -0
  147. package/python/biocore_model/tests/test_memory_dual_wiring.py +219 -0
  148. package/python/biocore_model/tests/test_memory_feedback.py +241 -0
  149. package/python/biocore_model/tests/test_memory_fixes.py +84 -0
  150. package/python/biocore_model/tests/test_memory_forgetting.py +58 -0
  151. package/python/biocore_model/tests/test_memory_inject.py +211 -0
  152. package/python/biocore_model/tests/test_memory_mood_recall.py +118 -0
  153. package/python/biocore_model/tests/test_memory_store.py +372 -0
  154. package/python/biocore_model/tests/test_metacog.py +103 -0
  155. package/python/biocore_model/tests/test_migrate.py +245 -0
  156. package/python/biocore_model/tests/test_migrate_store.py +194 -0
  157. package/python/biocore_model/tests/test_mind_wander.py +65 -0
  158. package/python/biocore_model/tests/test_mood.py +35 -0
  159. package/python/biocore_model/tests/test_narrative.py +114 -0
  160. package/python/biocore_model/tests/test_needs_esteem.py +51 -0
  161. package/python/biocore_model/tests/test_needs_events.py +205 -0
  162. package/python/biocore_model/tests/test_needs_sleep.py +54 -0
  163. package/python/biocore_model/tests/test_noise_bank.py +54 -0
  164. package/python/biocore_model/tests/test_numeric_schema.py +98 -0
  165. package/python/biocore_model/tests/test_output_protocol.py +65 -0
  166. package/python/biocore_model/tests/test_page_debug.py +147 -0
  167. package/python/biocore_model/tests/test_page_status_autonomy.py +215 -0
  168. package/python/biocore_model/tests/test_pipeline.py +345 -0
  169. package/python/biocore_model/tests/test_pipeline_api.py +192 -0
  170. package/python/biocore_model/tests/test_pipeline_mode.py +222 -0
  171. package/python/biocore_model/tests/test_plugin_config.py +85 -0
  172. package/python/biocore_model/tests/test_policy_learning.py +116 -0
  173. package/python/biocore_model/tests/test_possible_selves.py +88 -0
  174. package/python/biocore_model/tests/test_predictor.py +227 -0
  175. package/python/biocore_model/tests/test_preference_drift.py +142 -0
  176. package/python/biocore_model/tests/test_preset.py +142 -0
  177. package/python/biocore_model/tests/test_proactive_context.py +70 -0
  178. package/python/biocore_model/tests/test_proactive_history.py +90 -0
  179. package/python/biocore_model/tests/test_proactive_memory.py +141 -0
  180. package/python/biocore_model/tests/test_proactive_send_record.py +88 -0
  181. package/python/biocore_model/tests/test_probe_contract.py +71 -0
  182. package/python/biocore_model/tests/test_prospective.py +154 -0
  183. package/python/biocore_model/tests/test_provider_fallback.py +89 -0
  184. package/python/biocore_model/tests/test_reconstruction.py +111 -0
  185. package/python/biocore_model/tests/test_regulation_strategies.py +228 -0
  186. package/python/biocore_model/tests/test_relation_stage.py +114 -0
  187. package/python/biocore_model/tests/test_relationship_stage.py +59 -0
  188. package/python/biocore_model/tests/test_release_140.py +55 -0
  189. package/python/biocore_model/tests/test_repair_after_boundary.py +249 -0
  190. package/python/biocore_model/tests/test_reply_limit.py +86 -0
  191. package/python/biocore_model/tests/test_reply_limit_persist.py +48 -0
  192. package/python/biocore_model/tests/test_reply_split.py +52 -0
  193. package/python/biocore_model/tests/test_reply_split_astrbot.py +66 -0
  194. package/python/biocore_model/tests/test_reply_split_inject.py +53 -0
  195. package/python/biocore_model/tests/test_rerank.py +229 -0
  196. package/python/biocore_model/tests/test_rollback.py +175 -0
  197. package/python/biocore_model/tests/test_runtime.py +175 -0
  198. package/python/biocore_model/tests/test_runtime_active.py +205 -0
  199. package/python/biocore_model/tests/test_runtime_pipeline.py +1583 -0
  200. package/python/biocore_model/tests/test_runtime_proactive.py +231 -0
  201. package/python/biocore_model/tests/test_scheduler.py +79 -0
  202. package/python/biocore_model/tests/test_secrecy.py +105 -0
  203. package/python/biocore_model/tests/test_self_esteem.py +45 -0
  204. package/python/biocore_model/tests/test_self_intent.py +347 -0
  205. package/python/biocore_model/tests/test_self_interest.py +183 -0
  206. package/python/biocore_model/tests/test_self_layer_wiring.py +198 -0
  207. package/python/biocore_model/tests/test_self_verification.py +113 -0
  208. package/python/biocore_model/tests/test_situation.py +142 -0
  209. package/python/biocore_model/tests/test_sleep_circadian.py +41 -0
  210. package/python/biocore_model/tests/test_social_pain.py +49 -0
  211. package/python/biocore_model/tests/test_social_repair.py +67 -0
  212. package/python/biocore_model/tests/test_social_theory.py +78 -0
  213. package/python/biocore_model/tests/test_source_monitoring.py +204 -0
  214. package/python/biocore_model/tests/test_speak_like_group.py +36 -0
  215. package/python/biocore_model/tests/test_stages.py +226 -0
  216. package/python/biocore_model/tests/test_state_tag.py +86 -0
  217. package/python/biocore_model/tests/test_template_expr.py +65 -0
  218. package/python/biocore_model/tests/test_text_inference.py +70 -0
  219. package/python/biocore_model/tests/test_theta_params.py +115 -0
  220. package/python/biocore_model/tests/test_topic_control.py +200 -0
  221. package/python/biocore_model/tests/test_turn_memory.py +105 -0
  222. package/python/biocore_model/tests/test_turn_memory_panel.py +174 -0
  223. package/python/biocore_model/tests/test_ui_settings.py +84 -0
  224. package/python/biocore_model/tests/test_ui_settings_authority.py +179 -0
  225. package/python/biocore_model/tests/test_user_appraisal_async.py +155 -0
  226. package/python/biocore_model/tests/test_user_classify.py +122 -0
  227. package/python/biocore_model/tests/test_value_order.py +117 -0
  228. package/python/biocore_model/tests/test_voice_bank.py +105 -0
  229. package/python/biocore_model/tests/test_waiting_sense.py +216 -0
  230. package/python/biocore_model/tests/test_x_directives.py +101 -0
  231. package/python/biocore_model/tests/ui_compat_check.mjs +22 -0
  232. package/python/biocore_model/tests/view_mode_check.mjs +17 -0
  233. package/python/biocore_runtime/autonomy.py +208 -0
  234. package/python/biocore_runtime/core_state.py +302 -0
  235. package/python/biocore_runtime/governance.py +495 -0
  236. package/python/biocore_runtime/growth.py +528 -0
  237. package/python/biocore_runtime/pipeline.py +850 -23
  238. package/python/biocore_runtime/relationship_state.py +214 -0
  239. package/python/biocore_runtime/self_layer.py +103 -0
  240. package/python/biocore_runtime/service.py +162 -0
  241. package/python/biocore_runtime/standby.py +472 -0
  242. package/ui/panel-layout.js +83 -39
  243. package/ui/panel.css +885 -743
  244. package/ui/panel.html +356 -296
  245. package/ui/panel.js +739 -865
package/README.md CHANGED
@@ -49,6 +49,17 @@ DSH `session/event` hook 每轮调用一次 `one_turn`,另有 `/bio sync <一
49
49
  2) **兜底命令 `/bio sync <一句话>`**:给没有 hook 的宿主/手动调试用,
50
50
  调同一个 bridge 更新当前会话,返回一行状态变化摘要(不产生模型消息)。
51
51
 
52
+ ## 子智能体(delegationDepth >= 1)
53
+
54
+ core-turn 在组装 -190 注入前读取 session.header.delegationDepth(缺失按 0):
55
+ - depth = 0 / 缺失:保持现行为,注入人设、状态块与思考规则。
56
+ - depth >= 1:仍调用 turn_start 维持状态/记忆/trace,但把本会话的 prompt_block
57
+ 置空,跳过人设/状态/思考规则注入;turn_end 的 gate/trace 照常。
58
+
59
+ persona preset 的常驻注入是宿主级(agent preset),插件无法按会话单独关掉;
60
+ 本插件只保证 turn 注入对子智能体跳过。若 preset 仍向子智能体注入,需要在
61
+ 宿主侧为 subagent 使用不含 persona 的 preset。
62
+
52
63
  ## 接口
53
64
 
54
65
  `GET /api/biocore/state?session=<id>`
@@ -0,0 +1,268 @@
1
+ /** task-223: one persistent python core per DSH plugin lifetime.
2
+ *
3
+ * start(dataDir) spawns python/biocore_runtime/service.py; call(action, payload)
4
+ * correlates requests by id; a crash restarts at most 3 times (0.5s/1s/2s) and
5
+ * every failure is logged as '__bioWorker ...'. stop() sends SIGTERM and
6
+ * force-kills after 5s. When the worker is down, callers fall back to the old
7
+ * spawn-per-call bridge (handled in py-bridge.js).
8
+ */
9
+ import { spawn } from 'node:child_process'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { join } from 'node:path'
12
+ import { embeddingEnv } from './modules/python-env.js'
13
+
14
+ export const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url))
15
+ export const PYTHON_DIR = join(PACKAGE_ROOT, 'python')
16
+ export const SERVICE_SCRIPT = join(PYTHON_DIR, 'biocore_runtime', 'service.py')
17
+ export const ADAPTER_DIR = join(PYTHON_DIR, 'dsh_adapter')
18
+ export const WORKER_LOG_PREFIX = '__bioWorker'
19
+ export const DEFAULT_TIMEOUT_MS = 10000
20
+ export const MAX_RESTARTS = 3
21
+ export const BACKOFF_MS = Object.freeze([500, 1000, 2000])
22
+ export const STOP_GRACE_MS = 5000
23
+
24
+ let worker = null
25
+ let exitHookInstalled = false
26
+
27
+ function log(message) {
28
+ try { console.error(WORKER_LOG_PREFIX + ' ' + message) } catch { /* ignore */ }
29
+ }
30
+
31
+ export class WorkerUnavailable extends Error {
32
+ constructor(reason, detail) {
33
+ super(String(reason || 'worker_unavailable'))
34
+ this.name = 'WorkerUnavailable'
35
+ this.reason = String(reason || 'worker_unavailable')
36
+ this.detail = detail ? String(detail) : ''
37
+ }
38
+ }
39
+
40
+ function pythonCommand() {
41
+ return process.env.BIOCORE_PYTHON || 'python'
42
+ }
43
+
44
+ function workerEnv(dataDir) {
45
+ let embedding = {}
46
+ try { embedding = dataDir ? embeddingEnv(dataDir) : {} } catch { embedding = {} }
47
+ return Object.assign({}, process.env, embedding, {
48
+ BIOCORE_PLUGIN_DIR: PYTHON_DIR,
49
+ BIOCORE_ADAPTER_DIR: ADAPTER_DIR,
50
+ PYTHONIOENCODING: 'utf-8',
51
+ })
52
+ }
53
+
54
+ function isAlive(handle) {
55
+ return Boolean(handle && handle.child && handle.child.exitCode === null
56
+ && handle.child.signalCode === null && !handle.stopping)
57
+ }
58
+
59
+ function rejectPending(handle, reason, detail) {
60
+ if (!handle || !handle.pending) return
61
+ for (const [id, entry] of handle.pending) {
62
+ clearTimeout(entry.timer)
63
+ entry.reject(new WorkerUnavailable(reason, detail))
64
+ handle.pending.delete(id)
65
+ }
66
+ }
67
+
68
+ function handleLine(handle, line) {
69
+ const text = String(line || '').trim()
70
+ if (!text) return
71
+ let message = null
72
+ try { message = JSON.parse(text) } catch {
73
+ log('bad-json ' + text.slice(0, 120))
74
+ return
75
+ }
76
+ if (!message || typeof message !== 'object') return
77
+ const id = message.id == null ? '' : String(message.id)
78
+ const entry = handle.pending.get(id)
79
+ if (!entry) return
80
+ handle.pending.delete(id)
81
+ clearTimeout(entry.timer)
82
+ if (message.ok === true) entry.resolve(message.result === undefined ? {} : message.result)
83
+ else entry.reject(new WorkerUnavailable(message.reason || 'service_error',
84
+ message.detail || ''))
85
+ }
86
+
87
+ function attach(handle) {
88
+ const child = handle.child
89
+ let buffer = ''
90
+ child.stdout.on('data', (chunk) => {
91
+ buffer += String(chunk)
92
+ let index = buffer.indexOf('\n')
93
+ while (index >= 0) {
94
+ const line = buffer.slice(0, index)
95
+ buffer = buffer.slice(index + 1)
96
+ handleLine(handle, line)
97
+ index = buffer.indexOf('\n')
98
+ }
99
+ if (buffer.length > 1024 * 1024) buffer = ''
100
+ })
101
+ child.stderr.on('data', (chunk) => {
102
+ const text = String(chunk).trim()
103
+ if (text) log('stderr ' + text.split('\n').slice(-3).join(' | ').slice(0, 500))
104
+ })
105
+ child.on('error', (error) => {
106
+ log('error ' + String(error && error.message || error).slice(0, 200))
107
+ onGone(handle, 'spawn_error', String(error && error.message || error))
108
+ })
109
+ // A sandbox may refuse piped stdio (EPIPE on write). Swallow the stream error
110
+ // so the host process survives; callers fall back to spawn-per-call.
111
+ try { child.stdin.on('error', (error) => onGone(handle, 'stdin_error', String(error && error.message || error))) } catch { /* noop */ }
112
+ try { child.stdout.on('error', (error) => onGone(handle, 'stdout_error', String(error && error.message || error))) } catch { /* noop */ }
113
+ child.on('exit', (code, signal) => {
114
+ onGone(handle, 'exit', 'code=' + code + ' signal=' + signal)
115
+ })
116
+ }
117
+
118
+ function onGone(handle, reason, detail) {
119
+ if (handle.gone) return
120
+ handle.gone = true
121
+ if (handle.stopping) {
122
+ rejectPending(handle, 'worker_stopped', '')
123
+ return
124
+ }
125
+ log('down reason=' + reason + ' ' + String(detail || '').slice(0, 160))
126
+ rejectPending(handle, 'worker_crashed', reason + ' ' + String(detail || ''))
127
+ if (handle.restarts >= MAX_RESTARTS) {
128
+ log('giving_up restarts=' + handle.restarts)
129
+ return
130
+ }
131
+ const delay = BACKOFF_MS[Math.min(handle.restarts, BACKOFF_MS.length - 1)]
132
+ handle.restarts += 1
133
+ log('restart=' + handle.restarts + ' delay_ms=' + delay)
134
+ handle.restartTimer = setTimeout(() => {
135
+ handle.restartTimer = null
136
+ if (handle.stopping || worker !== handle) return
137
+ spawnChild(handle)
138
+ }, delay)
139
+ if (typeof handle.restartTimer.unref === 'function') handle.restartTimer.unref()
140
+ }
141
+
142
+ function spawnChild(handle) {
143
+ handle.gone = false
144
+ let child = null
145
+ try {
146
+ child = spawn(pythonCommand(), [SERVICE_SCRIPT], {
147
+ cwd: ADAPTER_DIR,
148
+ env: workerEnv(handle.dataDir),
149
+ stdio: ['pipe', 'pipe', 'pipe'],
150
+ windowsHide: true,
151
+ })
152
+ } catch (error) {
153
+ log('spawn_failed ' + String(error && error.message || error))
154
+ handle.child = null
155
+ onGone(handle, 'spawn_failed', String(error && error.message || error))
156
+ return
157
+ }
158
+ handle.child = child
159
+ attach(handle)
160
+ log('started pid=' + (child.pid || '?') + ' dataDir=' + String(handle.dataDir || ''))
161
+ }
162
+
163
+ /** Start (or reuse) the singleton worker. Idempotent while it is alive. */
164
+ export function startCoreWorker(dataDir) {
165
+ const dir = String(dataDir || '')
166
+ if (worker && isAlive(worker) && worker.dataDir === dir) return workerApi(worker)
167
+ if (worker) stopCoreWorker()
168
+ worker = {
169
+ dataDir: dir,
170
+ child: null,
171
+ pending: new Map(),
172
+ seq: 0,
173
+ restarts: 0,
174
+ stopping: false,
175
+ gone: false,
176
+ restartTimer: null,
177
+ startedAt: Date.now(),
178
+ }
179
+ spawnChild(worker)
180
+ if (!exitHookInstalled) {
181
+ exitHookInstalled = true
182
+ try {
183
+ process.once('exit', () => {
184
+ try { if (worker && worker.child) worker.child.kill() } catch { /* ignore */ }
185
+ })
186
+ } catch { /* ignore */ }
187
+ }
188
+ return workerApi(worker)
189
+ }
190
+
191
+ export function isWorkerRunning() {
192
+ return isAlive(worker)
193
+ }
194
+
195
+ export function workerPid() {
196
+ return worker && worker.child ? worker.child.pid : null
197
+ }
198
+
199
+ export function callCoreWorker(action, payload, options) {
200
+ const opts = options || {}
201
+ const timeoutMs = Number.isFinite(Number(opts.timeoutMs))
202
+ ? Math.max(1, Number(opts.timeoutMs)) : DEFAULT_TIMEOUT_MS
203
+ return new Promise((resolve, reject) => {
204
+ if (!isAlive(worker)) {
205
+ reject(new WorkerUnavailable('worker_down', ''))
206
+ return
207
+ }
208
+ const id = 'w' + (++worker.seq)
209
+ const entry = { resolve, reject, timer: null }
210
+ entry.timer = setTimeout(() => {
211
+ worker.pending.delete(id)
212
+ reject(new WorkerUnavailable('timeout', 'action=' + action))
213
+ }, timeoutMs)
214
+ if (typeof entry.timer.unref === 'function') entry.timer.unref()
215
+ worker.pending.set(id, entry)
216
+ const line = JSON.stringify({
217
+ id,
218
+ action: String(action || ''),
219
+ payload: payload && typeof payload === 'object' ? payload : {},
220
+ }) + '\n'
221
+ try {
222
+ worker.child.stdin.write(line)
223
+ } catch (error) {
224
+ worker.pending.delete(id)
225
+ clearTimeout(entry.timer)
226
+ reject(new WorkerUnavailable('write_failed', String(error && error.message || error)))
227
+ }
228
+ })
229
+ }
230
+
231
+ /** Stop the worker: SIGTERM, then force-kill after 5s. Never throws. */
232
+ export function stopCoreWorker() {
233
+ const handle = worker
234
+ if (!handle) return
235
+ handle.stopping = true
236
+ if (handle.restartTimer) {
237
+ clearTimeout(handle.restartTimer)
238
+ handle.restartTimer = null
239
+ }
240
+ rejectPending(handle, 'worker_stopped', '')
241
+ const child = handle.child
242
+ worker = null
243
+ if (!child) return
244
+ try { child.kill('SIGTERM') } catch { /* ignore */ }
245
+ const timer = setTimeout(() => {
246
+ try { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') } catch { /* ignore */ }
247
+ }, STOP_GRACE_MS)
248
+ if (typeof timer.unref === 'function') timer.unref()
249
+ child.once('exit', () => clearTimeout(timer))
250
+ }
251
+
252
+ /** Test seam: kill the current process without marking it stopped (crash). */
253
+ export function killWorkerForTest(signal) {
254
+ if (!isAlive(worker)) return false
255
+ try { worker.child.kill(signal || 'SIGKILL') } catch { return false }
256
+ return true
257
+ }
258
+
259
+ function workerApi(handle) {
260
+ return {
261
+ start: () => startCoreWorker(handle.dataDir),
262
+ call: (action, payload, options) => callCoreWorker(action, payload, options),
263
+ stop: () => stopCoreWorker(),
264
+ get pid() { return handle.child ? handle.child.pid : null },
265
+ get running() { return isAlive(handle) },
266
+ get restarts() { return handle.restarts },
267
+ }
268
+ }
package/lib/index.js CHANGED
@@ -17,22 +17,23 @@ import { dirname, join } from 'node:path'
17
17
  import {
18
18
  cleanSessionId,
19
19
  payloadWithinLimit,
20
- projectState,
21
- readStateFile,
22
20
  sessionStateFile,
23
21
  STATE_LIMIT,
24
22
  } from './modules/state.js'
25
- import { compilePersona, ensureState, runTurn, runtimeAction } from './py-bridge.js'
23
+ import { compilePersona, configureBridge, configureCore, ensureState, memoryTool, runTurn, runtimeAction } from './py-bridge.js'
24
+ import { rerankConfig } from './modules/python-env.js'
26
25
  import { SWITCH_ROUTE_PATH, handleSwitchRoute, readSwitches } from './modules/switch.js'
27
26
  import { TRACE_ROUTE_PATH, defaultStoreDir, handleTraceRoute } from './modules/trace.js'
27
+ import { SERIES_ROUTE_PATH, handleSeriesRoute } from './modules/series.js'
28
+ import { readCacheStats } from './modules/cache-stats.js'
29
+ import { startCoreWorker, stopCoreWorker } from './core-worker.js'
30
+ import { runAutonomyTick } from './modules/autonomy.js'
28
31
  import { queueSessionTurn } from './modules/turn-queue.js'
29
- import { anySwitchOn } from './modules/expression-inject.js'
30
32
  import { makeCoreSessionListener, registerCoreTurn } from './modules/core-turn.js'
31
33
  import { migrateLegacyPluginData, PLUGIN_DATA_DIR, resolveDataDir } from './modules/data-dir.js'
32
34
  import {
33
35
  PANEL_MOUNT_PATH,
34
36
  PANEL_ROUTE_PREFIX,
35
- buildPanelExtras,
36
37
  servePanelPage,
37
38
  } from './modules/panel-data.js'
38
39
  import { resolveMemoryDir } from './modules/memory-store.js'
@@ -91,7 +92,7 @@ export function settingsPaths(dataDir) {
91
92
  export const EMBEDDING_MODES = Object.freeze(['auto', 'remote', 'local', 'off'])
92
93
 
93
94
  function emptyEmbedding() {
94
- return { mode: 'auto', base_url: '', model: '', api_key: '', local_path: '' }
95
+ return { mode: 'auto', base_url: '', model: '', api_key: '' }
95
96
  }
96
97
 
97
98
  function normalizeEmbedding(value) {
@@ -102,11 +103,35 @@ function normalizeEmbedding(value) {
102
103
  base_url: String(source.base_url || '').trim().slice(0, 2000),
103
104
  model: String(source.model || '').trim().slice(0, 200),
104
105
  api_key: typeof source.api_key === 'string' ? source.api_key.trim().slice(0, 4000) : '',
105
- local_path: String(source.local_path || '').trim().slice(0, 2000),
106
106
  }
107
107
  }
108
108
 
109
- export const RERANK_DEFAULTS = Object.freeze({
109
+ export const AUTONOMY_DEFAULTS = Object.freeze({
110
+ max_actions_per_hour: 20,
111
+ max_sends_per_hour: 5,
112
+ max_sends_per_day: 30,
113
+ quiet_hours: [],
114
+ auto_decay_on_silence: true,
115
+ })
116
+
117
+ function normalizeAutonomy(value) {
118
+ const source = value && typeof value === 'object' && !Array.isArray(value)
119
+ ? value : {}
120
+ const int = (raw, fallback) => {
121
+ const number = Number(raw)
122
+ return Number.isFinite(number) ? Math.max(0, Math.round(number)) : fallback
123
+ }
124
+ return {
125
+ max_actions_per_hour: int(source.max_actions_per_hour, AUTONOMY_DEFAULTS.max_actions_per_hour),
126
+ max_sends_per_hour: int(source.max_sends_per_hour, AUTONOMY_DEFAULTS.max_sends_per_hour),
127
+ max_sends_per_day: int(source.max_sends_per_day, AUTONOMY_DEFAULTS.max_sends_per_day),
128
+ quiet_hours: Array.isArray(source.quiet_hours)
129
+ ? source.quiet_hours.map((item) => String(item).slice(0, 12)).slice(0, 12) : [],
130
+ auto_decay_on_silence: source.auto_decay_on_silence !== false,
131
+ }
132
+ }
133
+
134
+ const RERANK_DEFAULTS = Object.freeze({
110
135
  enabled: false,
111
136
  base_url: 'https://api.jina.ai/v1',
112
137
  model: 'jina-reranker-v2-base-multilingual',
@@ -167,6 +192,7 @@ export function readFullConfig(dataDir) {
167
192
  embedding: storedEmbedding(source),
168
193
  rerank: normalizeRerank(source.rerank),
169
194
  expression: normalizeExpression(source.expression),
195
+ autonomy: normalizeAutonomy(source.autonomy),
170
196
  sanitize_reply: source.sanitize_reply !== false,
171
197
  }
172
198
  }
@@ -177,6 +203,7 @@ function writeFullConfig(file, config) {
177
203
  embedding: config.embedding,
178
204
  rerank: config.rerank,
179
205
  expression: config.expression,
206
+ autonomy: config.autonomy,
180
207
  sanitize_reply: config.sanitize_reply !== false,
181
208
  }, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
182
209
  try { chmodSync(file, 0o600) } catch { /* Windows may ignore POSIX modes */ }
@@ -193,6 +220,7 @@ export async function handleSettings(req, res, dataDir) {
193
220
  embedding: maskedEmbedding(config.embedding),
194
221
  rerank: maskedRerank(config.rerank),
195
222
  expression: config.expression,
223
+ autonomy: config.autonomy,
196
224
  sanitize_reply: config.sanitize_reply !== false,
197
225
  restart_required: false,
198
226
  })
@@ -217,7 +245,9 @@ export async function handleSettings(req, res, dataDir) {
217
245
  const hasRerank = body.rerank && typeof body.rerank === 'object'
218
246
  const hasExpression = body.expression && typeof body.expression === 'object'
219
247
  const hasSanitize = typeof body.sanitize_reply === 'boolean'
220
- if (!hasEmbedding && !hasRerank && !hasExpression && !hasSanitize) {
248
+ const hasAutonomy = body.autonomy && typeof body.autonomy === 'object'
249
+ if (!hasEmbedding && !hasRerank && !hasExpression && !hasSanitize
250
+ && !hasAutonomy) {
221
251
  return sendJson(res, 400, { ok: false, reason: 'no_settings' })
222
252
  }
223
253
  if (hasEmbedding) {
@@ -240,13 +270,16 @@ export async function handleSettings(req, res, dataDir) {
240
270
  config.rerank = rerank
241
271
  }
242
272
  if (hasExpression) config.expression = normalizeExpression(body.expression)
273
+ if (hasAutonomy) config.autonomy = normalizeAutonomy(body.autonomy)
243
274
  if (hasSanitize) config.sanitize_reply = body.sanitize_reply === true
244
275
  writeFullConfig(paths.file, config)
276
+ void configureCoreRuntime(paths.dir)
245
277
  return sendJson(res, 200, {
246
278
  ok: true,
247
279
  embedding: maskedEmbedding(config.embedding),
248
280
  rerank: maskedRerank(config.rerank),
249
281
  expression: config.expression,
282
+ autonomy: config.autonomy,
250
283
  sanitize_reply: config.sanitize_reply !== false,
251
284
  restart_required: true,
252
285
  hint: '重启 BioCore 生效',
@@ -342,6 +375,30 @@ export async function handleSettingsTest(req, res, options) {
342
375
  return sendJson(res, 200, result)
343
376
  }
344
377
 
378
+ /** task-223: the adapter only ships the config; the core decides everything. */
379
+ function coreConfigPayload(dataDir) {
380
+ const config = readFullConfig(dataDir)
381
+ let switches = {}
382
+ try { switches = readSwitches(dataDir).switches } catch { switches = {} }
383
+ let persona = ''
384
+ try { persona = readPersonaText(dataDir) } catch { persona = '' }
385
+ return {
386
+ data_dir: dataDir,
387
+ persona_text: persona,
388
+ embedding: config.embedding,
389
+ rerank: config.rerank,
390
+ expression: config.expression,
391
+ autonomy: config.autonomy,
392
+ sanitize_reply: config.sanitize_reply !== false,
393
+ switches,
394
+ recall_mode: 'query',
395
+ }
396
+ }
397
+
398
+ async function configureCoreRuntime(dataDir) {
399
+ try { await configureCore(coreConfigPayload(dataDir)) } catch { /* core decides defaults */ }
400
+ }
401
+
345
402
  const PERSONA_PREVIEW_FIELDS = [
346
403
  ['E', '外向', 2],
347
404
  ['A', '宜人', 2],
@@ -686,32 +743,31 @@ export async function fetchCoreState(sessionId, options) {
686
743
  return value
687
744
  }
688
745
 
689
- /** Overlay the core state action's fields onto the /state projection. */
690
- export function mergeCoreState(body, reply) {
691
- const base = body && typeof body === 'object' ? Object.assign({}, body) : { available: true }
692
- if (!reply || reply.ok !== true || !reply.state || typeof reply.state !== 'object') return base
693
- const core = reply.state
694
- if (core.session_id != null) base.session_id = String(core.session_id)
695
- const count = core.memory && Number(core.memory.count)
696
- if (Number.isFinite(count)) base.memory_count = count
697
- const intero = (core.intero && typeof core.intero === 'object')
698
- ? core.intero
699
- : ((core.core && core.core.intero && typeof core.core.intero === 'object') ? core.core.intero : null)
700
- if (intero) base.intero = Object.assign({}, base.intero || {}, intero)
701
- if (core.relationship && typeof core.relationship === 'object') {
702
- const rel = core.relationship
703
- const metrics = Object.assign({}, base.relationship_metrics || {})
704
- for (const key of ['closeness', 'trust', 'conflict', 'bids']) {
705
- const value = Number(rel[key])
706
- if (Number.isFinite(value)) metrics[key] = Math.round(value * 100) / 100
707
- }
708
- base.relationship_metrics = metrics
709
- }
710
- for (const key of ['v', 'intero', 'tone', 'relation', 'autonomy', 'history',
711
- 'memory_recent', 'narrative', 'body_metrics', 'motivation']) {
712
- if (core[key] !== undefined) base[key] = core[key]
746
+ export const STATS_ROUTE_PATH = '/api/biocore/stats'
747
+
748
+ /** task-223: /stats is a pure forward of the core reply (+ display-only cache). */
749
+ export async function handleStats(req, res, options) {
750
+ const opts = options || {}
751
+ const method = String((req && req.method) || 'GET').toUpperCase()
752
+ if (method !== 'GET' && method !== 'HEAD') {
753
+ return sendJson(res, 405, { ok: false, reason: 'method_not_allowed' })
713
754
  }
714
- return base
755
+ let reply = null
756
+ try {
757
+ const runAction = typeof opts.runAction === 'function' ? opts.runAction : runtimeAction
758
+ const store = (() => {
759
+ try { return typeof opts.getStore === 'function' ? opts.getStore() : '' } catch { return '' }
760
+ })()
761
+ reply = await runAction('stats', { store })
762
+ } catch { reply = null }
763
+ const body = Object.assign({ ok: false, reason: 'core_unavailable' },
764
+ reply && typeof reply === 'object' ? reply : {})
765
+ try {
766
+ if (typeof opts.dataDir === 'string' && opts.dataDir) {
767
+ body.cache = readCacheStats(opts.dataDir)
768
+ }
769
+ } catch { /* display only */ }
770
+ return sendJson(res, 200, body)
715
771
  }
716
772
 
717
773
  export async function handleState(req, res, stateDir, options) {
@@ -725,43 +781,29 @@ export async function handleState(req, res, stateDir, options) {
725
781
  if (!rawSession) return sendJson(res, 200, unavailable('no_session'))
726
782
  const sessionId = cleanSessionId(rawSession)
727
783
  if (!sessionId) return sendJson(res, 200, unavailable('bad_session'))
728
- const stateFile = sessionStateFile(stateDir, sessionId)
729
- const read = readStateFile(stateFile)
730
- let body = null
731
- if (read.missing) {
732
- // Fast path: never call Python when the session file exists. Only a
733
- // missing file + an enabled switch may fall back to runtimeAction('state').
734
- let core = null
735
- try {
736
- const getSwitches = typeof opts.getSwitches === 'function' ? opts.getSwitches : null
737
- if (getSwitches && anySwitchOn(getSwitches())) {
738
- core = await fetchCoreState(sessionId, {
739
- runAction: opts.runAction,
740
- store: opts.store,
741
- })
742
- }
743
- } catch { core = null }
744
- if (!core || core.ok !== true || !core.state || typeof core.state !== 'object') {
745
- return sendJson(res, 200, unavailable('no_state_yet'))
746
- }
747
- body = projectState(core.state, { mtimeMs: 0, sessionId })
748
- Object.assign(body, buildPanelExtras(core.state))
749
- body.source = 'runtime'
750
- } else if (read.error) {
751
- return sendJson(res, 200, unavailable(read.error))
752
- } else {
753
- body = projectState(read.state, { mtimeMs: read.mtimeMs, sessionId })
754
- Object.assign(body, buildPanelExtras(read.state))
755
- body.source = 'file'
756
- }
757
- if (!payloadWithinLimit(body)) {
784
+ // task-223: pure forward -- the core owns the whole /state payload.
785
+ let reply = null
786
+ try {
787
+ const runAction = typeof opts.runAction === 'function' ? opts.runAction : runtimeAction
788
+ reply = await runAction('state', { session_id: sessionId, store: opts.store })
789
+ } catch { reply = null }
790
+ const state = reply && reply.ok === true && reply.state
791
+ && typeof reply.state === 'object' ? reply.state : null
792
+ if (!state) return sendJson(res, 200, unavailable('core_unavailable'))
793
+ if (!payloadWithinLimit(state)) {
758
794
  return sendJson(res, 200, unavailable('payload_too_large'))
759
795
  }
760
- return sendJson(res, 200, body)
796
+ return sendJson(res, 200, state)
761
797
  }
762
798
 
763
799
  export function apply(ctx, config) {
764
800
  const dataDir = resolveDataDir(config)
801
+ configureBridge(dataDir)
802
+ // task-223: one persistent core process for this plugin lifetime.
803
+ try { startCoreWorker(dataDir) } catch (error) {
804
+ try { console.error('__bioWorker start_failed ' + String(error)) } catch { /* ignore */ }
805
+ }
806
+ void configureCoreRuntime(dataDir)
765
807
  try { migrateLegacyPluginData(dataDir) } catch { /* best-effort data migration */ }
766
808
  try { migratePersonaToShared(dataDir) } catch { /* best-effort migration */ }
767
809
  const disposers = []
@@ -799,14 +841,36 @@ export function apply(ctx, config) {
799
841
  path: STATE_ROUTE_PATH,
800
842
  handler: (req, res) => handleState(req, res, dataDir, { getSwitches, store: resolveMemoryDir(dataDir) }),
801
843
  }))
802
- // task-196: four switches persist to <dataDir>/flags.json (atomic write).
844
+ // task-221: 24-point mood/channel series (global core_state history).
803
845
  keep(webServer.register({
804
846
  kind: 'exact',
805
- path: SWITCH_ROUTE_PATH,
806
- handler: (req, res) => handleSwitchRoute(req, res, {
847
+ path: SERIES_ROUTE_PATH,
848
+ handler: (req, res) => handleSeriesRoute(req, res, {
849
+ store: resolveMemoryDir(dataDir), dataDir,
850
+ }),
851
+ }))
852
+ // embedding cache stats (action=stats; zeros when unavailable).
853
+ keep(webServer.register({
854
+ kind: 'exact',
855
+ path: STATS_ROUTE_PATH,
856
+ handler: (req, res) => handleStats(req, res, {
857
+ getSwitches,
858
+ getStore: () => resolveMemoryDir(dataDir),
807
859
  dataDir,
808
860
  }),
809
861
  }))
862
+ // task-196: four switches persist to <dataDir>/flags.json (atomic write).
863
+ keep(webServer.register({
864
+ kind: 'exact',
865
+ path: SWITCH_ROUTE_PATH,
866
+ handler: async (req, res) => {
867
+ const out = await handleSwitchRoute(req, res, { dataDir })
868
+ if (String((req && req.method) || '').toUpperCase() === 'POST') {
869
+ void configureCoreRuntime(dataDir)
870
+ }
871
+ return out
872
+ },
873
+ }))
810
874
  // task-210: read-only "why this turn" traces written by python.
811
875
  keep(webServer.register({
812
876
  kind: 'exact',
@@ -879,11 +943,16 @@ export function apply(ctx, config) {
879
943
  } catch { /* memory capture unavailable */ }
880
944
  try {
881
945
  const injection = registerMemoryInjection(ctx, () => config, memoryDir,
882
- injectionState)
946
+ injectionState, {
947
+ getRerankConfig: () => rerankConfig(dataDir),
948
+ getSwitches,
949
+ })
883
950
  if (injection) keep(injection)
884
951
  } catch { /* prompt service unavailable */ }
885
952
  try {
886
- const tools = registerMemoryTools(ctx, () => config, memoryDir)
953
+ const tools = registerMemoryTools(ctx, () => config, memoryDir, {
954
+ getRerankConfig: () => rerankConfig(dataDir),
955
+ })
887
956
  if (tools) keep(tools.dispose)
888
957
  } catch { /* tool service unavailable */ }
889
958
 
@@ -903,10 +972,31 @@ export function apply(ctx, config) {
903
972
  }
904
973
  } catch { /* headless / no host services */ }
905
974
 
975
+ // task-222: autonomy tick -- the core decides, this loop only executes.
976
+ if (!config || config.autonomyEnabled !== false) {
977
+ let timer = null
978
+ const tick = async () => {
979
+ try {
980
+ await runAutonomyTick(ctx, {
981
+ runAction: runtimeAction,
982
+ store: resolveMemoryDir(dataDir),
983
+ dataDir,
984
+ sessionId: 'autonomy',
985
+ memoryTool,
986
+ rerankConfig: rerankConfig(dataDir),
987
+ })
988
+ } catch { /* an autonomy tick never disturbs a turn */ }
989
+ }
990
+ timer = setInterval(() => { void tick() }, 5 * 60 * 1000)
991
+ if (typeof timer.unref === 'function') timer.unref()
992
+ keep(() => { if (timer !== null) { clearInterval(timer); timer = null } })
993
+ }
994
+
906
995
  return () => {
907
996
  for (const dispose of disposers) {
908
997
  try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
909
998
  }
999
+ try { stopCoreWorker() } catch { /* ignore */ }
910
1000
  }
911
1001
  }
912
1002