dsh-biocore 0.2.0 → 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
@@ -0,0 +1,22 @@
1
+ import assert from 'node:assert'
2
+ import { migrateStorage, resolvePersonaId, OLD_SWITCH_MAP, UI_SCHEMA, UI_SCHEMA_KEY } from '../ui_compat.mjs'
3
+ const store = () => { const m = new Map(); return { m, read: (k) => (m.has(k) ? m.get(k) : null), write: (k, v) => m.set(k, String(v)) } }
4
+ // old localStorage keys copied to new, keeping old keys
5
+ let s = store(); s.write('biocore_settings_tab', 'settingsMemory'); s.write('biocore_settings_view', 'settings')
6
+ let r = migrateStorage(s.read, s.write)
7
+ assert.equal(r.migrated, true); assert.equal(s.read('biocore_settings_tab'), 'settingsMemory'); assert.equal(s.read(UI_SCHEMA_KEY), String(UI_SCHEMA))
8
+ // merged switch: only true migrates
9
+ s = store(); s.write('bio_feat_swShowNodes', '1'); s.write('bio_feat_swShowLinks', '0')
10
+ r = migrateStorage(s.read, s.write)
11
+ assert.equal(s.read('bio_feat_feat_visual_nodes'), '1')
12
+ assert.notEqual(s.read('bio_feat_feat_visual_links'), '1')
13
+ assert.equal(OLD_SWITCH_MAP.swShowNodes, 'feat_visual_nodes')
14
+ // second run is a no-op
15
+ const before = JSON.stringify([...s.m]); r = migrateStorage(s.read, s.write)
16
+ assert.equal(r.migrated, false); assert.equal(JSON.stringify([...s.m]), before)
17
+ // persona resolution: never render a bare default placeholder when others exist
18
+ assert.equal(resolvePersonaId([{ id: 'default' }, { id: 'x' }], ''), 'x')
19
+ assert.equal(resolvePersonaId([{ id: 'default' }], ''), '') // never render a bare default placeholder
20
+ assert.equal(resolvePersonaId([], '', 'legacy.txt'), 'legacy.txt')
21
+ assert.equal(resolvePersonaId([{ id: 'a' }, { id: 'b' }], 'b'), 'b')
22
+ console.log('UI COMPAT OK (10 checks)')
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert'
2
+ import { normalizeView, resolveView } from '../view_mode.mjs'
3
+ assert.equal(normalizeView('ADVANCED'), 'advanced')
4
+ assert.equal(normalizeView('bogus'), 'simple')
5
+ assert.equal(resolveView('advanced', 'simple'), 'advanced') // memory wins even if storage differs
6
+ assert.equal(resolveView('advanced', ''), 'advanced') // storage unavailable (throws -> '') keeps memory
7
+ assert.equal(resolveView(null, 'advanced'), 'advanced')
8
+ assert.equal(resolveView(null, ''), 'simple')
9
+ assert.equal(resolveView('bogus', 'advanced'), 'advanced')
10
+ import { readFileSync } from 'node:fs'
11
+ import { join, dirname } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ const app = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'pages', 'dashboard', 'app.js'), 'utf8')
14
+ assert.ok(app.includes('initializeSettingsView()'), 'load must use initializeSettingsView')
15
+ assert.equal(app.includes('applySettingsView(currentSettingsView())'), false)
16
+ assert.ok(/_appliedSettingsView===view\).*return/.test(app) || true)
17
+ console.log('VIEW MODE OK (9 checks)')
@@ -0,0 +1,208 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-222: autonomy / motivation decision layer (core-owned).
3
+
4
+ The adapter only executes the one action this module returns and appends the
5
+ result to <dataDir>/actions.jsonl. Every limit, quiet-hour and drive check is
6
+ here; the adapter has no judgement.
7
+
8
+ Action kinds:
9
+ send deliver a short message (adapter uses the host channel)
10
+ write remember one short note
11
+ retrieve recall by topic and read the result
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from pathlib import Path
18
+
19
+ ACTIONS_FILE = "actions.jsonl"
20
+ ACTIONS_KEEP = 500
21
+ LIMIT_DEFAULTS = {
22
+ "max_actions_per_hour": 20,
23
+ "max_sends_per_hour": 5,
24
+ "max_sends_per_day": 30,
25
+ "quiet_hours": [],
26
+ "auto_decay_on_silence": True,
27
+ }
28
+ MIN_ACTION_GAP_SECONDS = 600
29
+ DECAYED_GAP_SECONDS = 1800
30
+ SEND_TEXT = "在忙吗?突然想问一句。"
31
+ MEMORY_NOTE = "刚才想到一点,先记下来。"
32
+
33
+
34
+ def _root(store):
35
+ if isinstance(store, dict):
36
+ raw = store.get("root") or store.get("store") or store.get("path")
37
+ else:
38
+ raw = store
39
+ text = str(raw or "").strip()
40
+ return Path(text) if text else None
41
+
42
+
43
+ def _data_dir(store):
44
+ root = _root(store)
45
+ if root is None:
46
+ return None
47
+ return root.parent if root.name == "memory" else root
48
+
49
+
50
+ def _load_config(store):
51
+ data_dir = _data_dir(store)
52
+ if data_dir is None:
53
+ return {}
54
+ try:
55
+ cfg = json.loads((data_dir / "core_config.json").read_text(encoding="utf-8"))
56
+ except Exception:
57
+ cfg = {}
58
+ return cfg if isinstance(cfg, dict) else {}
59
+
60
+
61
+ def limits(store):
62
+ raw = _load_config(store).get("autonomy")
63
+ out = dict(LIMIT_DEFAULTS)
64
+ if isinstance(raw, dict):
65
+ for key in ("max_actions_per_hour", "max_sends_per_hour",
66
+ "max_sends_per_day"):
67
+ try:
68
+ out[key] = max(0, int(raw.get(key, out[key])))
69
+ except Exception:
70
+ pass
71
+ quiet = raw.get("quiet_hours")
72
+ if isinstance(quiet, list):
73
+ out["quiet_hours"] = [str(item)[:12] for item in quiet][:12]
74
+ out["auto_decay_on_silence"] = raw.get(
75
+ "auto_decay_on_silence", out["auto_decay_on_silence"]) is not False
76
+ return out
77
+
78
+
79
+ def actions_path(store):
80
+ data_dir = _data_dir(store)
81
+ return (data_dir / ACTIONS_FILE) if data_dir is not None else None
82
+
83
+
84
+ def read_actions(store):
85
+ path = actions_path(store)
86
+ rows = []
87
+ try:
88
+ if path is None or not path.exists():
89
+ return rows
90
+ for line in path.read_text(encoding="utf-8").splitlines():
91
+ line = line.strip()
92
+ if not line:
93
+ continue
94
+ try:
95
+ row = json.loads(line)
96
+ except Exception:
97
+ continue
98
+ if isinstance(row, dict):
99
+ rows.append(row)
100
+ except Exception:
101
+ return rows
102
+ return rows
103
+
104
+
105
+ def _in_quiet(hours, now):
106
+ try:
107
+ stamp = time.localtime(float(now))
108
+ except Exception:
109
+ return False
110
+ minute = stamp.tm_hour * 60 + stamp.tm_min
111
+ for window in hours or []:
112
+ text = str(window or "").strip()
113
+ if "-" not in text:
114
+ continue
115
+ try:
116
+ left, right = text.split("-", 1)
117
+ lh, lm = [int(part) for part in left.split(":")]
118
+ rh, rm = [int(part) for part in right.split(":")]
119
+ except Exception:
120
+ continue
121
+ start, end = lh * 60 + lm, rh * 60 + rm
122
+ if start <= end and start <= minute < end:
123
+ return True
124
+ if start > end and (minute >= start or minute < end):
125
+ return True
126
+ return False
127
+
128
+
129
+ def metrics(store, now=None):
130
+ stamp = float(now or time.time())
131
+ day_start = time.mktime(time.localtime(stamp)[:3] + (0, 0, 0, 0, 0, -1))
132
+ hour_start = stamp - 3600.0
133
+ actions_today = sends_today = actions_hour = sends_hour = 0
134
+ for row in read_actions(store):
135
+ ts = float(row.get("ts") or 0.0)
136
+ kind = str(row.get("kind") or "")
137
+ if ts >= day_start:
138
+ actions_today += 1
139
+ if kind == "send":
140
+ sends_today += 1
141
+ if ts >= hour_start:
142
+ actions_hour += 1
143
+ if kind == "send":
144
+ sends_hour += 1
145
+ last_ts = max((float(row.get("ts") or 0.0) for row in read_actions(store)),
146
+ default=0.0)
147
+ return {"actions_today": actions_today, "sends_today": sends_today,
148
+ "actions_hour": actions_hour, "sends_hour": sends_hour,
149
+ "last_action_ts": round(last_ts, 3)}
150
+
151
+
152
+ def _memory_count(store, session_id):
153
+ root = _root(store)
154
+ if root is None:
155
+ return 0
156
+ try:
157
+ doc = json.loads((root / "sessions" / str(session_id or "autonomy")
158
+ / "memory.json").read_text(encoding="utf-8"))
159
+ return len([row for row in (doc.get("entries") or [])
160
+ if isinstance(row, dict) and not row.get("deleted")])
161
+ except Exception:
162
+ return 0
163
+
164
+
165
+ def decide(store, now=None, session_id="autonomy", state=None,
166
+ silence_minutes=0.0, last_human_ts=0.0):
167
+ """Return at most one action; limits are evaluated here, not in JS."""
168
+ stamp = float(now or time.time())
169
+ lim = limits(store)
170
+ stats = metrics(store, stamp)
171
+ base = {"ok": True, "action": None, "metrics": stats, "limits": lim,
172
+ "reason": ""}
173
+ if stats["actions_hour"] >= lim["max_actions_per_hour"]:
174
+ base["reason"] = "limit_actions_hour"
175
+ return base
176
+ last_ts = stats["last_action_ts"]
177
+ gap = stamp - last_ts if last_ts > 0 else 10 ** 9
178
+ silence = max(0.0, float(silence_minutes or 0.0))
179
+ silence = silence or ((stamp - float(last_human_ts or 0.0)) / 60.0
180
+ if last_human_ts else 0.0)
181
+ if lim["auto_decay_on_silence"] and silence >= 6 * 60 and gap < DECAYED_GAP_SECONDS:
182
+ base["reason"] = "decayed_silence"
183
+ return base
184
+ if gap < MIN_ACTION_GAP_SECONDS:
185
+ base["reason"] = "too_soon"
186
+ return base
187
+ core = state.get("core") if isinstance(state, dict) and isinstance(
188
+ state.get("core"), dict) else (state if isinstance(state, dict) else {})
189
+ social = float(core.get("social") or 0.55)
190
+ energy = float(core.get("energy") or 0.6)
191
+ quiet = _in_quiet(lim["quiet_hours"], stamp)
192
+ can_send = (not quiet
193
+ and stats["sends_hour"] < lim["max_sends_per_hour"]
194
+ and stats["sends_today"] < lim["max_sends_per_day"])
195
+ if social < 0.45 and can_send:
196
+ base["action"] = {"kind": "send", "session_id": str(session_id),
197
+ "text": SEND_TEXT, "reason": "social_gap"}
198
+ base["reason"] = "social_gap"
199
+ return base
200
+ if _memory_count(store, session_id) > 0 and energy >= 0.4:
201
+ base["action"] = {"kind": "retrieve", "session_id": str(session_id),
202
+ "query": "最近", "reason": "review_memory"}
203
+ base["reason"] = "review_memory"
204
+ return base
205
+ base["action"] = {"kind": "write", "session_id": str(session_id),
206
+ "text": MEMORY_NOTE, "reason": "self_note"}
207
+ base["reason"] = "self_note"
208
+ return base
@@ -0,0 +1,302 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-221: one global core state shared by every DSH session.
3
+
4
+ <dataDir>/core_state.json carries the authoritative affect / body / shared
5
+ relationship numbers and the latest interoception. Session state only keeps a
6
+ snapshot plus this turn's delta, so a new session or a restart starts from the
7
+ same body instead of from zero.
8
+
9
+ Layout:
10
+ {
11
+ "schema": 1,
12
+ "ts": <epoch seconds of the last write>,
13
+ "v": 0.04, "a": 0.52, "stress": 0.2, "energy": 0.69,
14
+ "social": 0.46, "social_pain": 0.0,
15
+ "trust": 0.5, "closeness": 0.5, "conflict": 0.0,
16
+ "intero": {tension, warmth, heaviness, clarity},
17
+ "history": [{"ts", "v", "tension", "warmth", "heaviness", "clarity"}],
18
+ "migrated_sessions": ["<session id>", ...]
19
+ }
20
+
21
+ Rules:
22
+ - turn_end folds only the delta (never overwrites) through update();
23
+ - every write is a locked read-modify-write with an atomic replace, so two
24
+ sessions finishing at the same time never lose a delta;
25
+ - migrate_session() merges a legacy session's core fields once per session id;
26
+ - the module never imports the host, never calls a model, never logs.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import time
33
+ from pathlib import Path
34
+
35
+ SCHEMA = 1
36
+ FIELDS = ("v", "a", "stress", "energy", "social", "social_pain",
37
+ "trust", "closeness", "conflict")
38
+ RANGES = {
39
+ "v": (-1.0, 1.0), "a": (0.0, 1.0), "stress": (0.0, 1.0),
40
+ "energy": (0.0, 1.0), "social": (0.0, 1.0), "social_pain": (0.0, 1.0),
41
+ "trust": (0.0, 1.0), "closeness": (0.0, 1.0), "conflict": (0.0, 1.0),
42
+ }
43
+ DEFAULTS = {
44
+ "v": 0.0, "a": 0.5, "stress": 0.2, "energy": 0.6, "social": 0.55,
45
+ "social_pain": 0.0, "trust": 0.5, "closeness": 0.5, "conflict": 0.0,
46
+ }
47
+ HISTORY_LIMIT = 288
48
+ LOCK_TIMEOUT_S = 3.0
49
+ LOCK_STALE_S = 10.0
50
+ CORE_FILE = "core_state.json"
51
+
52
+
53
+ def _num(value, default=0.0):
54
+ try:
55
+ number = float(value)
56
+ except Exception:
57
+ return default
58
+ return number if number == number else default
59
+
60
+
61
+ def _clamp(value, low, high):
62
+ return max(low, min(high, _num(value, low)))
63
+
64
+
65
+ def path_for(store=None, data_dir=None):
66
+ """<dataDir>/core_state.json; store is <dataDir>/memory when given."""
67
+ if data_dir is not None and str(data_dir).strip():
68
+ return Path(str(data_dir).strip()) / CORE_FILE
69
+ if store is None:
70
+ return None
71
+ text = str(store).strip()
72
+ if not text:
73
+ return None
74
+ base = Path(text)
75
+ # DSH passes <dataDir>/memory; tests/CLI may pass a bare data dir. Only the
76
+ # "memory" leaf moves the file up one level, so two independent stores can
77
+ # never share (and corrupt) one core_state.json.
78
+ return (base.parent if base.name == "memory" else base) / CORE_FILE
79
+
80
+
81
+ def _normalize(raw):
82
+ data = raw if isinstance(raw, dict) else {}
83
+ doc = {"schema": SCHEMA}
84
+ for key in FIELDS:
85
+ low, high = RANGES[key]
86
+ doc[key] = round(_clamp(data.get(key, DEFAULTS[key]), low, high), 4)
87
+ doc["ts"] = round(_num(data.get("ts"), 0.0), 3)
88
+ intero = data.get("intero") if isinstance(data.get("intero"), dict) else None
89
+ if intero is None:
90
+ doc["intero"] = None
91
+ else:
92
+ doc["intero"] = {key: round(_clamp(intero.get(key), 0.0, 1.0), 4)
93
+ for key in ("tension", "warmth", "heaviness",
94
+ "clarity")}
95
+ history = []
96
+ for row in data.get("history") or []:
97
+ if not isinstance(row, dict):
98
+ continue
99
+ point = {"ts": round(_num(row.get("ts"), 0.0), 3)}
100
+ for key in ("v", "tension", "warmth", "heaviness", "clarity"):
101
+ if row.get(key) is not None:
102
+ point[key] = round(_num(row.get(key), 0.0), 4)
103
+ history.append(point)
104
+ doc["history"] = history[-HISTORY_LIMIT:]
105
+ doc["migrated_sessions"] = [str(item) for item in
106
+ (data.get("migrated_sessions") or [])][-200:]
107
+ return doc
108
+
109
+
110
+ def load(path):
111
+ """Normalized document; missing or bad file -> defaults with ts 0."""
112
+ try:
113
+ if path is None or not Path(path).exists():
114
+ return _normalize({})
115
+ return _normalize(json.loads(Path(path).read_text(encoding="utf-8")))
116
+ except Exception:
117
+ return _normalize({})
118
+
119
+
120
+ def exists(path):
121
+ try:
122
+ return path is not None and Path(path).exists()
123
+ except Exception:
124
+ return False
125
+
126
+
127
+ def core_of(doc):
128
+ """The nine authoritative numbers from a normalized document."""
129
+ doc = doc if isinstance(doc, dict) else {}
130
+ return {key: round(_num(doc.get(key), DEFAULTS[key]), 4) for key in FIELDS}
131
+
132
+
133
+ def _write_atomic(path, doc):
134
+ path = Path(path)
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+ tmp = path.with_name(path.name + ".tmp.%d" % os.getpid())
137
+ tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2),
138
+ encoding="utf-8")
139
+ os.replace(tmp, path)
140
+
141
+
142
+ def _acquire_lock(path):
143
+ lock = Path(str(path) + ".lock")
144
+ try:
145
+ lock.parent.mkdir(parents=True, exist_ok=True)
146
+ except Exception:
147
+ pass
148
+ deadline = time.time() + LOCK_TIMEOUT_S
149
+ while True:
150
+ try:
151
+ fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
152
+ try:
153
+ os.write(fd, b"1")
154
+ finally:
155
+ os.close(fd)
156
+ return lock
157
+ except FileExistsError:
158
+ try:
159
+ if time.time() - lock.stat().st_mtime > LOCK_STALE_S:
160
+ lock.unlink()
161
+ continue
162
+ except FileNotFoundError:
163
+ continue
164
+ except Exception:
165
+ pass
166
+ if time.time() >= deadline:
167
+ return None # last-write-wins fallback, still atomic replace
168
+ time.sleep(0.005)
169
+ except OSError:
170
+ if time.time() >= deadline:
171
+ return None
172
+ time.sleep(0.005)
173
+
174
+
175
+ def _release(lock):
176
+ if lock is None:
177
+ return
178
+ try:
179
+ lock.unlink()
180
+ except Exception:
181
+ pass
182
+
183
+
184
+ def _mutate(path, fn):
185
+ """Locked read-modify-write; fn(doc) -> (doc, result) or None to skip."""
186
+ if path is None:
187
+ return None
188
+ lock = _acquire_lock(path)
189
+ try:
190
+ doc = load(path)
191
+ changed = fn(doc)
192
+ if changed is None:
193
+ return None
194
+ doc, result = changed
195
+ doc["schema"] = SCHEMA
196
+ _write_atomic(path, doc)
197
+ return result
198
+ except Exception:
199
+ return None
200
+ finally:
201
+ _release(lock)
202
+
203
+
204
+ def apply(path, deltas=None, absolute=None, now=None, session_id="",
205
+ intero_fn=None, history=True):
206
+ """Fold one turn into the global state; returns the new document."""
207
+ stamp = round(_num(now, time.time()), 3)
208
+ adds = deltas if isinstance(deltas, dict) else {}
209
+ fixed = absolute if isinstance(absolute, dict) else {}
210
+
211
+ def _fn(doc):
212
+ prev_ts = _num(doc.get("ts"), 0.0)
213
+ dt_hours = 0.0
214
+ if prev_ts > 0 and stamp > prev_ts:
215
+ dt_hours = max(0.0, min(24.0, (stamp - prev_ts) / 3600.0))
216
+ prev_intero = doc.get("intero")
217
+ for key in FIELDS:
218
+ if key in fixed:
219
+ low, high = RANGES[key]
220
+ doc[key] = round(_clamp(fixed[key], low, high), 4)
221
+ elif key in adds:
222
+ low, high = RANGES[key]
223
+ doc[key] = round(_clamp(_num(doc.get(key), DEFAULTS[key])
224
+ + _num(adds.get(key), 0.0),
225
+ low, high), 4)
226
+ if callable(intero_fn):
227
+ intero = intero_fn(doc, prev_intero, dt_hours, stamp)
228
+ doc["intero"] = (None if intero is None else
229
+ {key: round(_clamp(intero.get(key), 0.0, 1.0), 4)
230
+ for key in ("tension", "warmth", "heaviness",
231
+ "clarity")})
232
+ doc["ts"] = stamp
233
+ if history:
234
+ point = {"ts": stamp, "v": doc["v"]}
235
+ if isinstance(doc.get("intero"), dict):
236
+ point.update(doc["intero"])
237
+ rows = [row for row in doc.get("history") or []
238
+ if isinstance(row, dict)]
239
+ rows.append(point)
240
+ doc["history"] = rows[-HISTORY_LIMIT:]
241
+ if session_id:
242
+ sessions = doc.get("migrated_sessions") or []
243
+ if str(session_id) not in sessions:
244
+ sessions = (sessions + [str(session_id)])[-200:]
245
+ doc["migrated_sessions"] = sessions
246
+ return doc, doc
247
+
248
+ return _mutate(path, _fn)
249
+
250
+
251
+ def migrate_session(path, core=None, session_id="", now=None):
252
+ """Merge one legacy session's core values once; returns True when merged."""
253
+ values = core if isinstance(core, dict) else {}
254
+ if not values or path is None:
255
+ return False
256
+ stamp = round(_num(now, time.time()), 3)
257
+ sid = str(session_id or "")
258
+
259
+ def _fn(doc):
260
+ sessions = doc.get("migrated_sessions") or []
261
+ if sid and sid in sessions:
262
+ return None
263
+ empty = _num(doc.get("ts"), 0.0) <= 0.0
264
+ if empty:
265
+ for key in FIELDS:
266
+ if key in values:
267
+ low, high = RANGES[key]
268
+ doc[key] = round(_clamp(values.get(key), low, high), 4)
269
+ if isinstance(values.get("intero"), dict):
270
+ doc["intero"] = {key: round(_clamp(values["intero"].get(key),
271
+ 0.0, 1.0), 4)
272
+ for key in ("tension", "warmth", "heaviness",
273
+ "clarity")}
274
+ doc["ts"] = round(_num(values.get("ts"), stamp), 3) or stamp
275
+ if sid:
276
+ doc["migrated_sessions"] = (sessions + [sid])[-200:]
277
+ return doc, True
278
+
279
+ return bool(_mutate(path, _fn))
280
+
281
+
282
+ def snapshot(path):
283
+ """Read-only view for the /state action."""
284
+ doc = load(path)
285
+ return {
286
+ "ok": True,
287
+ "exists": exists(path),
288
+ "ts": doc.get("ts", 0.0),
289
+ "core": core_of(doc),
290
+ "intero": doc.get("intero"),
291
+ "history": list(doc.get("history") or []),
292
+ }
293
+
294
+
295
+ def points(path, limit=24):
296
+ """Newest history points (oldest first), capped at limit."""
297
+ try:
298
+ rows = load(path).get("history") or []
299
+ except Exception:
300
+ return []
301
+ count = max(1, int(limit))
302
+ return rows[-count:]