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,495 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-222: DSH memory governance (decay / index / consolidate / graph / aggregate).
3
+
4
+ Operating on the DSH runtime store (<dataDir>/memory/sessions/...), never on
5
+ the engine store. Everything here is additive sidecar state: memory entries
6
+ are only read, never deleted or rewritten. "Decay" lowers the weight used for
7
+ recall ordering, it does not erase anything.
8
+
9
+ Sidecar files under <store>/:
10
+ governance.json per-entry retention score/tier/weight + counters
11
+ index.json token -> entry ids (cue index)
12
+ graph.json concept co-occurrence graph (nodes/edges)
13
+ consolidate.json repeated-topic candidates (hash => idempotent)
14
+ aggregate.json cross-session counts and top topics
15
+
16
+ Public:
17
+ run(store, now) -> summary counters (called once per turn_end)
18
+ lookup(store, query) -> entry ids from the index (counts index hits)
19
+ weight_of(store, entry_id) -> retention weight (1.0 when unknown)
20
+ summary(store) -> counters for /state and /stats
21
+ enabled(store) -> governance flag (default ON, file override)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import math
28
+ import os
29
+ import re
30
+ import time
31
+ from pathlib import Path
32
+
33
+ GOVERNANCE_FILE = "governance.json"
34
+ INDEX_FILE = "index.json"
35
+ GRAPH_FILE = "graph.json"
36
+ CONSOLIDATE_FILE = "consolidate.json"
37
+ AGGREGATE_FILE = "aggregate.json"
38
+ SCHEMA = 1
39
+ MAX_ENTRIES = 400
40
+ COLD_SCORE = 0.35
41
+ WARM_SCORE = 0.55
42
+ COLD_WEIGHT = 0.4
43
+ WARM_WEIGHT = 0.7
44
+ TOPIC_MIN = 2
45
+ MAX_TOPICS = 8
46
+ GRAPH_MIN_COUNT = 2
47
+ MAX_GRAPH_NODES = 80
48
+ MAX_GRAPH_EDGES = 160
49
+ RECENT_EPISODES = 40
50
+ HALF_LIFE_DAYS = 30.0
51
+ THROTTLE_SECONDS = 30.0
52
+
53
+ _WS = re.compile(r"\s+")
54
+ _LATIN = re.compile(r"[a-z][a-z0-9_']*")
55
+ _CJK = re.compile(r"[\u4e00-\u9fff]+")
56
+ _STOP = frozenset((
57
+ "我们", "你们", "他们", "这个", "那个", "就是", "不是", "一个", "什么",
58
+ "怎么", "可以", "没有", "因为", "所以", "然后", "还是", "现在", "今天",
59
+ "明天", "知道", "觉得", "已经", "这样", "那样", "东西", "自己",
60
+ ))
61
+
62
+
63
+ def _num(value, default=0.0):
64
+ try:
65
+ number = float(value)
66
+ except Exception:
67
+ return default
68
+ return number if number == number else default
69
+
70
+
71
+ def _root(store):
72
+ if isinstance(store, dict):
73
+ raw = store.get("root") or store.get("store") or store.get("path")
74
+ else:
75
+ raw = store
76
+ text = str(raw or "").strip()
77
+ return Path(text) if text else None
78
+
79
+
80
+ def _data_dir(store):
81
+ root = _root(store)
82
+ if root is None:
83
+ return None
84
+ return root.parent if root.name == "memory" else root
85
+
86
+
87
+ def _flags(store):
88
+ data_dir = _data_dir(store)
89
+ raw = {}
90
+ if data_dir is not None:
91
+ try:
92
+ raw = json.loads((data_dir / "flags.json").read_text(encoding="utf-8"))
93
+ except Exception:
94
+ raw = {}
95
+ if not isinstance(raw, dict):
96
+ raw = {}
97
+ return {"governance": raw.get("governance", True) is not False,
98
+ "evict_delete": raw.get("evict_delete") is True,
99
+ "relationship_governance":
100
+ raw.get("relationship_governance", True) is not False}
101
+
102
+
103
+ def enabled(store):
104
+ if str(os.environ.get("BIOCORE_GOVERNANCE") or "").strip().lower() in (
105
+ "0", "false", "off", "no"):
106
+ return False
107
+ return _flags(store)["governance"]
108
+
109
+
110
+ def _read(path, fallback):
111
+ try:
112
+ if path is None or not Path(path).exists():
113
+ return fallback
114
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
115
+ return data if isinstance(data, dict) else fallback
116
+ except Exception:
117
+ return fallback
118
+
119
+
120
+ def _write(path, doc):
121
+ path = Path(path)
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ tmp = path.with_name(path.name + ".tmp")
124
+ tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2),
125
+ encoding="utf-8")
126
+ os.replace(tmp, path)
127
+
128
+
129
+ def _tokens(text):
130
+ lowered = _WS.sub(" ", str(text or "").lower())
131
+ out = []
132
+ for run in _CJK.findall(lowered):
133
+ if len(run) < 2:
134
+ continue
135
+ for index in range(len(run) - 1):
136
+ token = run[index:index + 2]
137
+ if token not in _STOP:
138
+ out.append(token)
139
+ out.extend(word for word in _LATIN.findall(lowered) if len(word) >= 3)
140
+ return out
141
+
142
+
143
+ def extract_concepts(text, limit=8):
144
+ """Stable concepts for one entry (unique tokens, first-seen order)."""
145
+ seen = []
146
+ for token in _tokens(text):
147
+ if token not in seen:
148
+ seen.append(token)
149
+ return seen[:max(1, int(limit))]
150
+
151
+
152
+ def _entries(store):
153
+ """Visible entries from every session, newest first."""
154
+ root = _root(store)
155
+ base = (root / "sessions") if root is not None else None
156
+ rows = []
157
+ if base is None or not base.exists():
158
+ return rows
159
+ try:
160
+ sessions = [child for child in base.iterdir() if child.is_dir()]
161
+ except Exception:
162
+ return rows
163
+ for directory in sessions:
164
+ doc = _read(directory / "memory.json", {})
165
+ for entry in doc.get("entries") or []:
166
+ if not isinstance(entry, dict):
167
+ continue
168
+ if entry.get("deleted") or entry.get("archived"):
169
+ continue
170
+ item = dict(entry)
171
+ item["_session"] = directory.name
172
+ rows.append(item)
173
+ rows.sort(key=lambda row: _num(row.get("ts"), 0.0), reverse=True)
174
+ return rows[:MAX_ENTRIES]
175
+
176
+
177
+ def _entry_id(entry):
178
+ return str(entry.get("id") or "").strip()
179
+
180
+
181
+ def retention(entry, now):
182
+ """0..1 retention: importance + recency + usage, never negative."""
183
+ ts = _num(entry.get("ts"), 0.0)
184
+ age_days = max(0.0, (now - ts) / 86400.0) if ts > 0 else 0.0
185
+ recency = 0.5 ** (age_days / HALF_LIFE_DAYS)
186
+ importance = max(0.0, min(1.0, _num(entry.get("importance"), 0.5)))
187
+ hits = max(0.0, _num(entry.get("hits"), 1.0))
188
+ usage = min(1.0, hits / 5.0)
189
+ value = max(0.0, min(1.0, _num(entry.get("value"), 0.5)))
190
+ score = 0.40 * importance + 0.30 * recency + 0.20 * usage + 0.10 * value
191
+ return round(max(0.0, min(1.0, score)), 4)
192
+
193
+
194
+ def _tier(score):
195
+ if score < COLD_SCORE:
196
+ return "cold", COLD_WEIGHT
197
+ if score < WARM_SCORE:
198
+ return "warm", WARM_WEIGHT
199
+ return "hot", 1.0
200
+
201
+
202
+ def decay(store, now=None, previous=None):
203
+ """Recompute retention weights; never deletes or rewrites an entry."""
204
+ stamp = _num(now, time.time())
205
+ prev = previous if isinstance(previous, dict) else {}
206
+ prev_entries = prev.get("entries") if isinstance(prev.get("entries"), dict) else {}
207
+ rows = _entries(store)
208
+ table = {}
209
+ cold = warm = hot = demoted = 0
210
+ for entry in rows:
211
+ entry_id = _entry_id(entry)
212
+ if not entry_id:
213
+ continue
214
+ score = retention(entry, stamp)
215
+ tier, weight = _tier(score)
216
+ old_tier = str((prev_entries.get(entry_id) or {}).get("tier") or "")
217
+ if old_tier and old_tier != "cold" and tier == "cold":
218
+ demoted += 1
219
+ table[entry_id] = {"score": score, "tier": tier,
220
+ "weight": round(weight, 3),
221
+ "session": str(entry.get("_session") or ""),
222
+ "ts": round(_num(entry.get("ts"), 0.0), 3)}
223
+ if tier == "cold":
224
+ cold += 1
225
+ elif tier == "warm":
226
+ warm += 1
227
+ else:
228
+ hot += 1
229
+ return {
230
+ "entries": table,
231
+ "stats": {"total": len(table), "cold": cold, "warm": warm,
232
+ "hot": hot, "demoted": demoted,
233
+ "deleted": 0, "updated_at": round(stamp, 3)},
234
+ }
235
+
236
+
237
+ def build_index(store):
238
+ """token -> entry ids; bounded and deterministic."""
239
+ index = {}
240
+ for entry in _entries(store):
241
+ entry_id = _entry_id(entry)
242
+ if not entry_id:
243
+ continue
244
+ for token in dict.fromkeys(_tokens(entry.get("text"))):
245
+ index.setdefault(token, [])
246
+ if entry_id not in index[token]:
247
+ index[token].append(entry_id)
248
+ # keep the index bounded: tokens seen in one entry are noise
249
+ kept = {token: ids for token, ids in index.items() if len(ids) >= 1}
250
+ topics = [token for token, ids in sorted(kept.items(),
251
+ key=lambda item: (-len(item[1]), item[0]))
252
+ if len(ids) >= 2][:MAX_TOPICS]
253
+ latest_ts = max((_num(entry.get("ts"), 0.0) for entry in _entries(store)),
254
+ default=0.0)
255
+ return {"schema": SCHEMA, "terms": kept, "topics": topics,
256
+ "latest_ts": round(latest_ts, 3)}
257
+
258
+
259
+ def lookup(store, query):
260
+ """Entry ids matching the query tokens (read; caller counts the hit)."""
261
+ doc = _read((_root(store) or Path(".")) / INDEX_FILE, {})
262
+ terms = doc.get("terms") if isinstance(doc.get("terms"), dict) else {}
263
+ hits = []
264
+ for token in dict.fromkeys(_tokens(query)):
265
+ for entry_id in terms.get(token) or []:
266
+ if entry_id not in hits:
267
+ hits.append(entry_id)
268
+ return hits
269
+
270
+
271
+ def weight_of(store, entry_id):
272
+ doc = _read((_root(store) or Path(".")) / GOVERNANCE_FILE, {})
273
+ record = (doc.get("entries") or {}).get(str(entry_id))
274
+ return _num((record or {}).get("weight"), 1.0)
275
+
276
+
277
+ def consolidate(store, now=None):
278
+ """Repeated-topic candidates; hash keeps repeated runs idempotent."""
279
+ stamp = _num(now, time.time())
280
+ candidates = []
281
+ try: # task-223: keep one rule set with the growth report
282
+ from . import growth as _growth
283
+ for row in _growth.candidates(store, RECENT_EPISODES):
284
+ topic = str(row.get("topic") or "")
285
+ if not topic:
286
+ continue
287
+ candidates.append({"topic": topic,
288
+ "count": int(row.get("count") or 0),
289
+ "evidence": list(row.get("evidence") or [])[:5]})
290
+ except Exception:
291
+ candidates = []
292
+ if not candidates:
293
+ episodes = _entries(store)[:RECENT_EPISODES]
294
+ counts = {}
295
+ evidence = {}
296
+ for entry in episodes:
297
+ for token in dict.fromkeys(_tokens(entry.get("text"))):
298
+ counts[token] = counts.get(token, 0) + 1
299
+ evidence.setdefault(token, [])
300
+ entry_id = _entry_id(entry)
301
+ if entry_id and entry_id not in evidence[token]:
302
+ evidence[token].append(entry_id)
303
+ for token, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])):
304
+ if count < TOPIC_MIN:
305
+ continue
306
+ candidates.append({"topic": token, "count": int(count),
307
+ "evidence": evidence.get(token, [])[:5]})
308
+ if len(candidates) >= MAX_TOPICS:
309
+ break
310
+ candidates = candidates[:MAX_TOPICS]
311
+ digest = hashlib.sha256(json.dumps(candidates, ensure_ascii=False,
312
+ sort_keys=True).encode("utf-8")).hexdigest()
313
+ previous = _read((_root(store) or Path(".")) / CONSOLIDATE_FILE, {})
314
+ runs = int(_num(previous.get("runs"), 0)) + 1
315
+ changed = digest != str(previous.get("hash") or "")
316
+ return {"schema": SCHEMA, "ts": round(stamp, 3), "hash": digest,
317
+ "runs": runs, "changed": changed, "candidates": candidates,
318
+ "count": len(candidates)}
319
+
320
+
321
+ def build_graph(store):
322
+ """Concept co-occurrence graph over the same bounded entry set."""
323
+ counts = {}
324
+ pairs = {}
325
+ for entry in _entries(store):
326
+ tokens = list(dict.fromkeys(_tokens(entry.get("text"))))
327
+ for token in tokens:
328
+ counts[token] = counts.get(token, 0) + 1
329
+ for index, left in enumerate(tokens):
330
+ for right in tokens[index + 1:]:
331
+ key = tuple(sorted((left, right)))
332
+ pairs[key] = pairs.get(key, 0) + 1
333
+ nodes = [{"id": token, "count": count}
334
+ for token, count in sorted(counts.items(),
335
+ key=lambda item: (-item[1], item[0]))
336
+ if count >= GRAPH_MIN_COUNT][:MAX_GRAPH_NODES]
337
+
338
+ def _is_node(token):
339
+ return any(node["id"] == token for node in nodes)
340
+
341
+ edges = [{"a": left, "b": right, "weight": count}
342
+ for (left, right), count in sorted(
343
+ pairs.items(), key=lambda item: (-item[1], item[0]))
344
+ if count >= GRAPH_MIN_COUNT and _is_node(left) and _is_node(right)]
345
+ return {"schema": SCHEMA, "nodes": nodes, "edges": edges[:MAX_GRAPH_EDGES]}
346
+
347
+
348
+ def aggregate(store):
349
+ """Cross-session counts (read-only projection, one row per session)."""
350
+ root = _root(store)
351
+ base = (root / "sessions") if root is not None else None
352
+ sessions = {}
353
+ topics = {}
354
+ if base is not None and base.exists():
355
+ try:
356
+ for directory in base.iterdir():
357
+ if not directory.is_dir():
358
+ continue
359
+ entries = [entry for entry in
360
+ (_read(directory / "memory.json", {}).get("entries") or [])
361
+ if isinstance(entry, dict) and not entry.get("deleted")
362
+ and not entry.get("archived")]
363
+ sessions[directory.name] = len(entries)
364
+ for entry in entries:
365
+ for token in dict.fromkeys(_tokens(entry.get("text"))):
366
+ topics[token] = topics.get(token, 0) + 1
367
+ except Exception:
368
+ pass
369
+ top = [{"topic": token, "count": count}
370
+ for token, count in sorted(topics.items(),
371
+ key=lambda item: (-item[1], item[0]))[:5]]
372
+ return {"schema": SCHEMA, "sessions": sessions,
373
+ "session_count": len(sessions),
374
+ "entries": sum(sessions.values()), "top_topics": top}
375
+
376
+
377
+ def summary(store):
378
+ gov = _read((_root(store) or Path(".")) / GOVERNANCE_FILE, {})
379
+ index = _read((_root(store) or Path(".")) / INDEX_FILE, {})
380
+ graph = _read((_root(store) or Path(".")) / GRAPH_FILE, {})
381
+ consolidate_doc = _read((_root(store) or Path(".")) / CONSOLIDATE_FILE, {})
382
+ aggregate_doc = _read((_root(store) or Path(".")) / AGGREGATE_FILE, {})
383
+ stats = gov.get("stats") if isinstance(gov.get("stats"), dict) else {}
384
+ return {
385
+ "decay": {"total": int(_num(stats.get("total"), 0)),
386
+ "cold": int(_num(stats.get("cold"), 0)),
387
+ "warm": int(_num(stats.get("warm"), 0)),
388
+ "hot": int(_num(stats.get("hot"), 0)),
389
+ "decayed": (int(_num(stats.get("cold"), 0))
390
+ + int(_num(stats.get("warm"), 0))),
391
+ "demoted": int(_num(stats.get("demoted"), 0)),
392
+ "deleted": 0},
393
+ "index": {"terms": len(index.get("terms") or {}),
394
+ "topics": len(index.get("topics") or []),
395
+ "latest_ts": _num(index.get("latest_ts"), 0.0),
396
+ "hits": int(_num(gov.get("index_hits"), 0))},
397
+ "concepts": {"entries": sum(1 for row in _entries(store)
398
+ if row.get("concepts")),
399
+ "filtered": int(_num(gov.get("concept_filters"), 0)),
400
+ "kept": int(_num(gov.get("concept_kept"), 0))},
401
+ "consolidate": {"candidates": len(consolidate_doc.get("candidates") or []),
402
+ "runs": int(_num(consolidate_doc.get("runs"), 0))},
403
+ "graph": {"nodes": len(graph.get("nodes") or []),
404
+ "edges": len(graph.get("edges") or [])},
405
+ "aggregate": {"sessions": int(_num(aggregate_doc.get("session_count"), 0)),
406
+ "entries": int(_num(aggregate_doc.get("entries"), 0))},
407
+ "enabled": enabled(store),
408
+ }
409
+
410
+
411
+ def run(store, now=None, force=False):
412
+ """One governance pass; writes sidecars, never touches memory entries."""
413
+ if not enabled(store):
414
+ return {"enabled": False, "skipped": True}
415
+ root = _root(store)
416
+ if root is None:
417
+ return {"enabled": False, "skipped": True}
418
+ stamp = _num(now, time.time())
419
+ gov_path = root / GOVERNANCE_FILE
420
+ previous = _read(gov_path, {})
421
+ last = _num((previous.get("stats") or {}).get("updated_at"), 0.0)
422
+ if not force and last > 0 and stamp - last < THROTTLE_SECONDS:
423
+ summary_doc = summary(store)
424
+ summary_doc["skipped"] = True
425
+ summary_doc["reason"] = "throttle"
426
+ return summary_doc
427
+ gov = decay(store, now=stamp, previous=previous)
428
+ gov["index_hits"] = int(_num(previous.get("index_hits"), 0))
429
+ gov["schema"] = SCHEMA
430
+ gov.setdefault("stats", {})["updated_at"] = round(stamp, 3)
431
+ try:
432
+ _write(gov_path, gov)
433
+ except Exception:
434
+ pass
435
+ index = build_index(store)
436
+ try:
437
+ _write(root / INDEX_FILE, index)
438
+ except Exception:
439
+ pass
440
+ graph = build_graph(store)
441
+ try:
442
+ _write(root / GRAPH_FILE, graph)
443
+ except Exception:
444
+ pass
445
+ consolidation = consolidate(store, now=stamp)
446
+ try:
447
+ _write(root / CONSOLIDATE_FILE, consolidation)
448
+ except Exception:
449
+ pass
450
+ agg = aggregate(store)
451
+ try:
452
+ _write(root / AGGREGATE_FILE, agg)
453
+ except Exception:
454
+ pass
455
+ return {"enabled": True, "decay": gov["stats"],
456
+ "index": {"terms": len(index.get("terms") or {}),
457
+ "hits": gov["index_hits"]},
458
+ "consolidate": {"candidates": consolidation["count"],
459
+ "runs": consolidation["runs"],
460
+ "changed": consolidation["changed"]},
461
+ "graph": {"nodes": len(graph.get("nodes") or []),
462
+ "edges": len(graph.get("edges") or [])},
463
+ "aggregate": {"sessions": agg["session_count"],
464
+ "entries": agg["entries"]}}
465
+
466
+
467
+ def note_concept_filter(store, kept):
468
+ """Count how many recalls were narrowed by concepts."""
469
+ root = _root(store)
470
+ if root is None:
471
+ return
472
+ path = root / GOVERNANCE_FILE
473
+ doc = _read(path, {})
474
+ doc["concept_filters"] = int(_num(doc.get("concept_filters"), 0)) + 1
475
+ doc["concept_kept"] = int(_num(doc.get("concept_kept"), 0)) + max(0, int(kept))
476
+ doc["schema"] = SCHEMA
477
+ try:
478
+ _write(path, doc)
479
+ except Exception:
480
+ pass
481
+
482
+
483
+ def note_index_hit(store):
484
+ """Increment the index-hit counter once per query recall."""
485
+ root = _root(store)
486
+ if root is None:
487
+ return
488
+ path = root / GOVERNANCE_FILE
489
+ doc = _read(path, {})
490
+ doc["index_hits"] = int(_num(doc.get("index_hits"), 0)) + 1
491
+ doc["schema"] = SCHEMA
492
+ try:
493
+ _write(path, doc)
494
+ except Exception:
495
+ pass