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,528 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-220: growth metrics + a read-only consolidation dry-run.
3
+
4
+ Two read-only products over the DSH runtime store (<store>/sessions/...,
5
+ traces/, relationship.json):
6
+
7
+ 1. metrics(store) -> one JSON object:
8
+ memory total / added_this_week / avg_importance / with_affect ratio
9
+ recall hits_total / mood_match_hits / avg_top_score
10
+ relation trust, closeness, change_7d (from growth_history.jsonl)
11
+ narrative intent distribution (top 3, from turn traces)
12
+ flags the four learning switches and where their value came from
13
+
14
+ 2. consolidate(store) -> candidate facts from the newest N memory entries
15
+ (the only per-turn record that carries text):
16
+ repeated topics (>= TOPIC_MIN_REPEAT episodes, reusing
17
+ biocore_model.preference_drift.topics)
18
+ stable relationship facts (trust/closeness above RELATION_STABLE_MIN)
19
+ The result is written to <store>/consolidate_report.json. It never calls
20
+ save_memory()/tool_call(), never edits an entry and never calls a model:
21
+ dry_run is always true and memory_written always false.
22
+
23
+ Both are OFF by default: {"growth_report": True} or
24
+ BIOCORE_GROWTH_REPORT=1 enables consolidate(); metrics() is pure read-only and
25
+ can be called without the switch (the switch is reported in the output).
26
+
27
+ Pure-ish: no network, no LLM, no host imports. The only writes are the report
28
+ file and one history line per day.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import math
34
+ import time
35
+ from collections import Counter
36
+ from pathlib import Path
37
+
38
+ try:
39
+ from . import pipeline as _pipeline
40
+ except Exception: # pragma: no cover - script mode
41
+ from biocore_runtime import pipeline as _pipeline
42
+
43
+ try:
44
+ from biocore_model import preference_drift as _drift
45
+ except Exception: # pragma: no cover - degraded checkout
46
+ _drift = None
47
+
48
+ LEARNING_FLAGS = ("mood_recall", "affect_encoding",
49
+ "memory_feedback", "preference_drift")
50
+ ENV_FLAGS = {
51
+ "mood_recall": "BIOCORE_MOOD_RECALL",
52
+ "affect_encoding": "BIOCORE_AFFECT_ENCODING",
53
+ "memory_feedback": "BIOCORE_MEMORY_FEEDBACK",
54
+ "preference_drift": "BIOCORE_PREFERENCE_DRIFT",
55
+ }
56
+ GROWTH_ENV = "BIOCORE_GROWTH_REPORT"
57
+ REPORT_FILE = "consolidate_report.json"
58
+ HISTORY_FILE = "growth_history.jsonl"
59
+ DEFAULT_EPISODES = 30
60
+ HISTORY_KEEP = 120
61
+ WEEK_SECONDS = 7 * 86400.0
62
+ SECTION_TOP = 3
63
+ TOPIC_MIN_REPEAT = 2
64
+ MAX_CANDIDATES = 8
65
+ RELATION_STABLE_MIN = 0.60
66
+
67
+
68
+ def _num(value, default=0.0):
69
+ try:
70
+ number = float(value)
71
+ except Exception:
72
+ return default
73
+ return number if number == number else default
74
+
75
+
76
+ def _root(store):
77
+ if isinstance(store, dict):
78
+ raw = store.get("root") or store.get("store") or store.get("path")
79
+ else:
80
+ raw = store
81
+ text = str(raw or "").strip()
82
+ return Path(text) if text else None
83
+
84
+
85
+ def _read_json(path, fallback):
86
+ try:
87
+ if path is None or not Path(path).exists():
88
+ return fallback
89
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
90
+ return data if isinstance(data, dict) else fallback
91
+ except Exception:
92
+ return fallback
93
+
94
+
95
+ def _atomic_write(path, data):
96
+ path = Path(path)
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ tmp = path.with_name(path.name + ".tmp")
99
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2),
100
+ encoding="utf-8")
101
+ tmp.replace(path)
102
+
103
+
104
+ def _truthy(raw):
105
+ return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
106
+
107
+
108
+ def enabled(flags=None, env=None):
109
+ """Flag or env switch; both default OFF."""
110
+ data = flags if isinstance(flags, dict) else {}
111
+ if "growth_report" in data:
112
+ return bool(data.get("growth_report"))
113
+ raw = env.get(GROWTH_ENV) if hasattr(env, "get") else None
114
+ return _truthy(raw)
115
+
116
+
117
+ # consolidate() takes an "enabled" argument, so keep a second name for the
118
+ # function itself (the parameter must not shadow the module's switch reader).
119
+ _enabled = enabled
120
+
121
+
122
+ def report_path(store):
123
+ root = _root(store)
124
+ return (root / REPORT_FILE) if root is not None else None
125
+
126
+
127
+ def history_path(store):
128
+ root = _root(store)
129
+ return (root / HISTORY_FILE) if root is not None else None
130
+
131
+
132
+ # ---------------------------------------------------------------- readers
133
+ def session_ids(store):
134
+ root = _root(store)
135
+ base = (root / "sessions") if root is not None else None
136
+ if base is None or not base.exists():
137
+ return []
138
+ try:
139
+ return sorted(child.name for child in base.iterdir()
140
+ if child.is_dir())
141
+ except Exception:
142
+ return []
143
+
144
+
145
+ def entries(store):
146
+ """Every visible memory entry, newest first, tagged with its session."""
147
+ rows = []
148
+ root = _root(store)
149
+ base = (root / "sessions") if root is not None else None
150
+ if base is None or not base.exists():
151
+ return rows
152
+ try:
153
+ sessions = sorted(child for child in base.iterdir() if child.is_dir())
154
+ except Exception:
155
+ return rows
156
+ for directory in sessions:
157
+ doc = _read_json(directory / "memory.json", {})
158
+ for entry in doc.get("entries") or []:
159
+ if not isinstance(entry, dict):
160
+ continue
161
+ if entry.get("deleted") or entry.get("archived"):
162
+ continue
163
+ item = dict(entry)
164
+ item["_session"] = directory.name
165
+ rows.append(item)
166
+ rows.sort(key=lambda row: _num(row.get("ts"), 0.0), reverse=True)
167
+ return rows
168
+
169
+
170
+ def episodes(store, limit=DEFAULT_EPISODES):
171
+ """Newest N memory entries: the per-turn record that carries text."""
172
+ count = max(1, int(_num(limit, DEFAULT_EPISODES)))
173
+ return entries(store)[:count]
174
+
175
+
176
+ def traces(store):
177
+ rows = []
178
+ for session_id in session_ids(store):
179
+ try:
180
+ rows.extend(_pipeline.trace_read(store, session_id,
181
+ limit=_pipeline.TRACE_KEEP))
182
+ except Exception:
183
+ continue
184
+ rows.sort(key=lambda row: _num(row.get("ts"), 0.0))
185
+ return rows
186
+
187
+
188
+ def relationship(store):
189
+ try:
190
+ return _pipeline.relationship_read(store)["relationship"]
191
+ except Exception:
192
+ return {"trust": 0.5, "closeness": 0.5, "conflict": 0.0,
193
+ "attachment_style": "secure", "bids": {},
194
+ "days_known": 0}
195
+
196
+
197
+ def read_history(store):
198
+ path = history_path(store)
199
+ rows = []
200
+ try:
201
+ if path is None or not path.exists():
202
+ return rows
203
+ for line in path.read_text(encoding="utf-8").splitlines():
204
+ line = line.strip()
205
+ if not line:
206
+ continue
207
+ try:
208
+ row = json.loads(line)
209
+ except Exception:
210
+ continue
211
+ if isinstance(row, dict):
212
+ rows.append(row)
213
+ except Exception:
214
+ return []
215
+ rows.sort(key=lambda row: _num(row.get("ts"), 0.0))
216
+ return rows
217
+
218
+
219
+ def _record_history(store, now, current):
220
+ path = history_path(store)
221
+ if path is None:
222
+ return False
223
+ rows = read_history(store)
224
+ day = time.strftime("%Y-%m-%d", time.gmtime(_num(now, time.time())))
225
+ last = rows[-1] if rows else {}
226
+ last_day = time.strftime("%Y-%m-%d", time.gmtime(_num(last.get("ts"), 0)))
227
+ if last and last_day == day:
228
+ return False
229
+ rows.append({"ts": round(_num(now, time.time()), 3),
230
+ "trust": round(_num(current.get("trust"), 0.5), 4),
231
+ "closeness": round(_num(current.get("closeness"), 0.5), 4)})
232
+ rows = rows[-HISTORY_KEEP:]
233
+ try:
234
+ _atomic_write_text(path, "\n".join(
235
+ json.dumps(row, ensure_ascii=False, separators=(",", ":"))
236
+ for row in rows) + "\n")
237
+ return True
238
+ except Exception:
239
+ return False
240
+
241
+
242
+ def _atomic_write_text(path, text):
243
+ path = Path(path)
244
+ path.parent.mkdir(parents=True, exist_ok=True)
245
+ tmp = path.with_name(path.name + ".tmp")
246
+ tmp.write_text(text, encoding="utf-8")
247
+ tmp.replace(path)
248
+
249
+
250
+ def _session_affect(store, session_id):
251
+ directory = _pipeline.session_dir(store, session_id)
252
+ if directory is None:
253
+ return None
254
+ state = _read_json(directory / "state.json", {})
255
+ core = state.get("core") if isinstance(state.get("core"), dict) else {}
256
+ for source in (state, core):
257
+ valence = source.get("v", source.get("valence"))
258
+ if valence is None:
259
+ continue
260
+ arousal = source.get("a", source.get("arousal", 0.5))
261
+ return (max(-1.0, min(1.0, _num(valence, 0.0))),
262
+ max(0.0, min(1.0, _num(arousal, 0.5))))
263
+ return None
264
+
265
+
266
+ def _entry_affect(entry):
267
+ affect = entry.get("affect") if isinstance(entry, dict) else None
268
+ if not isinstance(affect, dict):
269
+ return None
270
+ valence = affect.get("valence", affect.get("v"))
271
+ arousal = affect.get("arousal", affect.get("a", 0.5))
272
+ if valence is None:
273
+ return None
274
+ try:
275
+ return (max(-1.0, min(1.0, float(valence))),
276
+ max(0.0, min(1.0, float(arousal))))
277
+ except Exception:
278
+ return None
279
+
280
+
281
+ def _cosine(left, right):
282
+ lv, la = left
283
+ rv, ra = right
284
+ ln = math.sqrt(lv * lv + la * la)
285
+ rn = math.sqrt(rv * rv + ra * ra)
286
+ if not ln or not rn:
287
+ return 0.0
288
+ return (lv * rv + la * ra) / (ln * rn)
289
+
290
+
291
+ def _flags(store, flags=None, env=None):
292
+ data = flags if isinstance(flags, dict) else {}
293
+ environ = env if hasattr(env, "get") else {}
294
+ root = _root(store)
295
+ file_flags = {}
296
+ if root is not None:
297
+ # DSH keeps flags.json in the data dir above the memory store, so the
298
+ # store root is checked first and its parent second.
299
+ for path in (root / "flags.json", root.parent / "flags.json"):
300
+ if path.exists():
301
+ file_flags = _read_json(path, {})
302
+ break
303
+ out = {}
304
+ for key in LEARNING_FLAGS:
305
+ if key in data:
306
+ out[key] = {"enabled": bool(data[key]), "source": "flags"}
307
+ elif key in file_flags:
308
+ out[key] = {"enabled": bool(file_flags[key]), "source": "flags.json"}
309
+ elif ENV_FLAGS.get(key) and ENV_FLAGS[key] in environ:
310
+ out[key] = {"enabled": _truthy(environ.get(ENV_FLAGS[key])),
311
+ "source": "env"}
312
+ else:
313
+ out[key] = {"enabled": False, "source": "default"}
314
+ return out
315
+
316
+
317
+ # ---------------------------------------------------------------- metrics
318
+ def _change_7d(store, now, current):
319
+ rows = read_history(store)
320
+ if not rows:
321
+ return {"trust": 0.0, "closeness": 0.0}, "no_history", 0.0
322
+ target = _num(now, time.time()) - WEEK_SECONDS
323
+ older = [row for row in rows if _num(row.get("ts"), 0.0) <= target]
324
+ if older:
325
+ base = older[-1]
326
+ basis = "seven_day"
327
+ else:
328
+ base = rows[0]
329
+ basis = "oldest_sample"
330
+ return (
331
+ {"trust": round(_num(current.get("trust"), 0.5)
332
+ - _num(base.get("trust"), 0.5), 4),
333
+ "closeness": round(_num(current.get("closeness"), 0.5)
334
+ - _num(base.get("closeness"), 0.5), 4)},
335
+ basis,
336
+ round(_num(base.get("ts"), 0.0), 3),
337
+ )
338
+
339
+
340
+ def metrics(store, now=None, flags=None, env=None):
341
+ """Observable growth metrics; never writes anything."""
342
+ stamp = _num(now, time.time())
343
+ rows = entries(store)
344
+ total = len(rows)
345
+ week = stamp - WEEK_SECONDS
346
+ added = 0
347
+ importance = []
348
+ with_affect = 0
349
+ for entry in rows:
350
+ if _num(entry.get("ts"), 0.0) >= week:
351
+ added += 1
352
+ importance.append(_num(entry.get("importance"), 0.0))
353
+ if _entry_affect(entry) is not None:
354
+ with_affect += 1
355
+ avg_importance = (round(sum(importance) / total, 4) if total else 0.0)
356
+
357
+ by_id = {str(entry.get("id") or ""): entry for entry in rows}
358
+ trace_rows = traces(store)
359
+ hits_total = 0
360
+ mood_match_hits = 0
361
+ scores = []
362
+ for row in trace_rows:
363
+ memory = row.get("memory") if isinstance(row.get("memory"), dict) else {}
364
+ ids = memory.get("recalled_ids")
365
+ ids = [str(item) for item in ids] if isinstance(ids, list) else []
366
+ hits_total += len(ids)
367
+ top = memory.get("top_score")
368
+ if top is None:
369
+ listed = memory.get("scores")
370
+ top = listed[0] if isinstance(listed, list) and listed else None
371
+ if top is not None:
372
+ scores.append(_num(top, 0.0))
373
+ if not ids:
374
+ continue
375
+ current = _session_affect(store, row.get("session_id"))
376
+ if current is None:
377
+ continue
378
+ for entry_id in ids:
379
+ stored = _entry_affect(by_id.get(entry_id))
380
+ if stored is not None and _cosine(stored, current) >= 0.3:
381
+ mood_match_hits += 1
382
+ break
383
+
384
+ rel = relationship(store)
385
+ change, basis, base_ts = _change_7d(store, stamp, rel)
386
+
387
+ intents = Counter()
388
+ for row in trace_rows:
389
+ intent = row.get("intent") if isinstance(row.get("intent"), dict) else {}
390
+ kind = str(intent.get("kind") or "").strip()
391
+ if kind:
392
+ intents[kind] += 1
393
+ narrative = [{"kind": kind, "count": count}
394
+ for kind, count in intents.most_common(SECTION_TOP)]
395
+
396
+ return {
397
+ "memory": {
398
+ "total": total,
399
+ "added_this_week": added,
400
+ "avg_importance": avg_importance,
401
+ "with_affect": with_affect,
402
+ "with_affect_ratio": (round(with_affect / total, 4)
403
+ if total else 0.0),
404
+ },
405
+ "recall": {
406
+ "hits_total": hits_total,
407
+ "mood_match_hits": mood_match_hits,
408
+ "avg_top_score": (round(sum(scores) / len(scores), 4)
409
+ if scores else None),
410
+ "score_source": "trace" if scores else "unavailable",
411
+ "traces": len(trace_rows),
412
+ },
413
+ "relation": {
414
+ "trust": round(_num(rel.get("trust"), 0.5), 4),
415
+ "closeness": round(_num(rel.get("closeness"), 0.5), 4),
416
+ "change_7d": change,
417
+ "change_basis": basis,
418
+ "change_base_ts": base_ts,
419
+ "days_known": int(_num(rel.get("days_known"), 0)),
420
+ "attachment_style": str(rel.get("attachment_style") or ""),
421
+ },
422
+ "narrative": {"top_intents": narrative,
423
+ "traces": len(trace_rows)},
424
+ "flags": _flags(store, flags=flags, env=env),
425
+ }
426
+
427
+
428
+ # ---------------------------------------------------------------- dry-run
429
+ def _topics(text):
430
+ if _drift is not None:
431
+ try:
432
+ return _drift.topics(text)
433
+ except Exception:
434
+ pass
435
+ return []
436
+
437
+
438
+ def candidates(store, limit=DEFAULT_EPISODES):
439
+ """Candidate facts from the newest N episodes; never mutates anything."""
440
+ rows = episodes(store, limit)
441
+ counts = Counter()
442
+ evidence = {}
443
+ for entry in rows:
444
+ for topic in dict.fromkeys(_topics(str(entry.get("text") or ""))):
445
+ counts[topic] += 1
446
+ evidence.setdefault(topic, [])
447
+ entry_id = str(entry.get("id") or "")
448
+ if entry_id and entry_id not in evidence[topic]:
449
+ evidence[topic].append(entry_id)
450
+ found = []
451
+ for topic, count in counts.most_common():
452
+ if count < TOPIC_MIN_REPEAT:
453
+ continue
454
+ dimension = None
455
+ if _drift is not None:
456
+ dimension = _drift.dimension_of(topic)
457
+ found.append({
458
+ "kind": "repeated_topic",
459
+ "topic": topic,
460
+ "count": int(count),
461
+ "dimension": dimension,
462
+ "confidence": round(min(0.95, 0.4 + 0.1 * count), 3),
463
+ "evidence": evidence.get(topic, [])[:5],
464
+ })
465
+ # a theme that maps onto a value dimension is the useful candidate, so
466
+ # unmapped bigram noise sorts after it on a tie
467
+ found.sort(key=lambda item: (-item["count"], item["dimension"] is None))
468
+ found = found[:MAX_CANDIDATES]
469
+
470
+ rel = relationship(store)
471
+ trust = _num(rel.get("trust"), 0.5)
472
+ closeness = _num(rel.get("closeness"), 0.5)
473
+ conflict = _num(rel.get("conflict"), 0.0)
474
+ if trust >= RELATION_STABLE_MIN and closeness >= RELATION_STABLE_MIN:
475
+ days = int(_num(rel.get("days_known"), 0))
476
+ style = str(rel.get("attachment_style") or "secure")
477
+ fact = ("关系稳定:信任 %.2f、亲近 %.2f(%s)" % (trust, closeness, style))
478
+ if days > 0:
479
+ fact += ",已相处 %d 天" % days
480
+ found.append({
481
+ "kind": "stable_relation",
482
+ "fact": fact,
483
+ "trust": round(trust, 4),
484
+ "closeness": round(closeness, 4),
485
+ "conflict": round(conflict, 4),
486
+ "days_known": days,
487
+ "attachment_style": style,
488
+ "confidence": round(min(0.95, (trust + closeness) / 2.0), 3),
489
+ })
490
+ return found
491
+
492
+
493
+ def consolidate(store, limit=DEFAULT_EPISODES, now=None, enabled=None,
494
+ flags=None, env=None, write_report=True):
495
+ """Dry-run consolidation: candidates + report file, memory untouched."""
496
+ stamp = _num(now, time.time())
497
+ on = (bool(enabled) if enabled is not None
498
+ else _enabled(flags=flags, env=env))
499
+ if not on:
500
+ return {"ok": False, "reason": "disabled", "dry_run": True,
501
+ "report": None, "path": ""}
502
+ scanned = episodes(store, limit)
503
+ found = candidates(store, limit)
504
+ counts = Counter(item.get("kind") for item in found)
505
+ report = {
506
+ "schema": "bc.consolidate_report.v1",
507
+ "generated_ts": round(stamp, 3),
508
+ "dry_run": True,
509
+ "memory_written": False,
510
+ "episodes": {"source": "memory_entries", "scanned": len(scanned),
511
+ "limit": max(1, int(_num(limit, DEFAULT_EPISODES)))},
512
+ "candidates": found,
513
+ "counts": {kind: counts.get(kind, 0)
514
+ for kind in ("repeated_topic", "stable_relation")},
515
+ "flags": _flags(store, flags=flags, env=env),
516
+ }
517
+ path = report_path(store)
518
+ written = False
519
+ if write_report and path is not None:
520
+ try:
521
+ _atomic_write(path, report)
522
+ written = True
523
+ _record_history(store, stamp, relationship(store))
524
+ except Exception:
525
+ written = False
526
+ return {"ok": True, "reason": "dry_run", "dry_run": True,
527
+ "report": report, "path": str(path or ""),
528
+ "written": written}