aethergraph 0.1.0a1__py3-none-any.whl → 0.1.0a3__py3-none-any.whl

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 (267) hide show
  1. aethergraph/__init__.py +4 -10
  2. aethergraph/__main__.py +296 -0
  3. aethergraph/api/v1/__init__.py +0 -0
  4. aethergraph/api/v1/agents.py +46 -0
  5. aethergraph/api/v1/apps.py +70 -0
  6. aethergraph/api/v1/artifacts.py +415 -0
  7. aethergraph/api/v1/channels.py +89 -0
  8. aethergraph/api/v1/deps.py +168 -0
  9. aethergraph/api/v1/graphs.py +259 -0
  10. aethergraph/api/v1/identity.py +25 -0
  11. aethergraph/api/v1/memory.py +353 -0
  12. aethergraph/api/v1/misc.py +47 -0
  13. aethergraph/api/v1/pagination.py +29 -0
  14. aethergraph/api/v1/runs.py +568 -0
  15. aethergraph/api/v1/schemas.py +535 -0
  16. aethergraph/api/v1/session.py +323 -0
  17. aethergraph/api/v1/stats.py +201 -0
  18. aethergraph/api/v1/viz.py +152 -0
  19. aethergraph/config/config.py +22 -0
  20. aethergraph/config/loader.py +3 -2
  21. aethergraph/config/storage.py +209 -0
  22. aethergraph/contracts/__init__.py +0 -0
  23. aethergraph/contracts/services/__init__.py +0 -0
  24. aethergraph/contracts/services/artifacts.py +27 -14
  25. aethergraph/contracts/services/memory.py +45 -17
  26. aethergraph/contracts/services/metering.py +129 -0
  27. aethergraph/contracts/services/runs.py +50 -0
  28. aethergraph/contracts/services/sessions.py +87 -0
  29. aethergraph/contracts/services/state_stores.py +3 -0
  30. aethergraph/contracts/services/viz.py +44 -0
  31. aethergraph/contracts/storage/artifact_index.py +88 -0
  32. aethergraph/contracts/storage/artifact_store.py +99 -0
  33. aethergraph/contracts/storage/async_kv.py +34 -0
  34. aethergraph/contracts/storage/blob_store.py +50 -0
  35. aethergraph/contracts/storage/doc_store.py +35 -0
  36. aethergraph/contracts/storage/event_log.py +31 -0
  37. aethergraph/contracts/storage/vector_index.py +48 -0
  38. aethergraph/core/__init__.py +0 -0
  39. aethergraph/core/execution/forward_scheduler.py +13 -2
  40. aethergraph/core/execution/global_scheduler.py +21 -15
  41. aethergraph/core/execution/step_forward.py +10 -1
  42. aethergraph/core/graph/__init__.py +0 -0
  43. aethergraph/core/graph/graph_builder.py +8 -4
  44. aethergraph/core/graph/graph_fn.py +156 -15
  45. aethergraph/core/graph/graph_spec.py +8 -0
  46. aethergraph/core/graph/graphify.py +146 -27
  47. aethergraph/core/graph/node_spec.py +0 -2
  48. aethergraph/core/graph/node_state.py +3 -0
  49. aethergraph/core/graph/task_graph.py +39 -1
  50. aethergraph/core/runtime/__init__.py +0 -0
  51. aethergraph/core/runtime/ad_hoc_context.py +64 -4
  52. aethergraph/core/runtime/base_service.py +28 -4
  53. aethergraph/core/runtime/execution_context.py +13 -15
  54. aethergraph/core/runtime/graph_runner.py +222 -37
  55. aethergraph/core/runtime/node_context.py +510 -6
  56. aethergraph/core/runtime/node_services.py +12 -5
  57. aethergraph/core/runtime/recovery.py +15 -1
  58. aethergraph/core/runtime/run_manager.py +783 -0
  59. aethergraph/core/runtime/run_manager_local.py +204 -0
  60. aethergraph/core/runtime/run_registration.py +2 -2
  61. aethergraph/core/runtime/run_types.py +89 -0
  62. aethergraph/core/runtime/runtime_env.py +136 -7
  63. aethergraph/core/runtime/runtime_metering.py +71 -0
  64. aethergraph/core/runtime/runtime_registry.py +36 -13
  65. aethergraph/core/runtime/runtime_services.py +194 -6
  66. aethergraph/core/tools/builtins/toolset.py +1 -1
  67. aethergraph/core/tools/toolkit.py +5 -0
  68. aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
  69. aethergraph/plugins/agents/default_chat_agent.py +171 -0
  70. aethergraph/plugins/agents/shared.py +81 -0
  71. aethergraph/plugins/channel/adapters/webui.py +112 -112
  72. aethergraph/plugins/channel/routes/webui_routes.py +367 -102
  73. aethergraph/plugins/channel/utils/slack_utils.py +115 -59
  74. aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
  75. aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
  76. aethergraph/runtime/__init__.py +15 -0
  77. aethergraph/server/app_factory.py +196 -34
  78. aethergraph/server/clients/channel_client.py +202 -0
  79. aethergraph/server/http/channel_http_routes.py +116 -0
  80. aethergraph/server/http/channel_ws_routers.py +45 -0
  81. aethergraph/server/loading.py +117 -0
  82. aethergraph/server/server.py +131 -0
  83. aethergraph/server/server_state.py +240 -0
  84. aethergraph/server/start.py +227 -66
  85. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  86. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  87. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  88. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  89. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  90. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  91. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  92. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  93. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  94. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  95. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  96. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  97. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  98. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  99. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  100. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  101. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  102. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  103. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  104. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  105. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  106. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  107. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  108. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  109. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  110. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  111. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  112. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  113. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  114. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  115. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  116. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  117. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  118. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  119. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  120. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  121. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  122. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  123. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  124. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  125. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  126. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  127. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  128. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  129. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  130. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  131. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  132. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  133. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  134. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  135. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  136. aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  137. aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  138. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  139. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  140. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  141. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  142. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  143. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  144. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
  145. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
  146. aethergraph/server/ui_static/index.html +15 -0
  147. aethergraph/server/ui_static/logo.png +0 -0
  148. aethergraph/services/artifacts/__init__.py +0 -0
  149. aethergraph/services/artifacts/facade.py +1239 -132
  150. aethergraph/services/auth/{dev.py → authn.py} +0 -8
  151. aethergraph/services/auth/authz.py +100 -0
  152. aethergraph/services/channel/__init__.py +0 -0
  153. aethergraph/services/channel/channel_bus.py +19 -1
  154. aethergraph/services/channel/factory.py +13 -1
  155. aethergraph/services/channel/ingress.py +311 -0
  156. aethergraph/services/channel/queue_adapter.py +75 -0
  157. aethergraph/services/channel/session.py +502 -19
  158. aethergraph/services/container/default_container.py +122 -43
  159. aethergraph/services/continuations/continuation.py +6 -0
  160. aethergraph/services/continuations/stores/fs_store.py +19 -0
  161. aethergraph/services/eventhub/event_hub.py +76 -0
  162. aethergraph/services/kv/__init__.py +0 -0
  163. aethergraph/services/kv/ephemeral.py +244 -0
  164. aethergraph/services/llm/__init__.py +0 -0
  165. aethergraph/services/llm/generic_client copy.py +691 -0
  166. aethergraph/services/llm/generic_client.py +1288 -187
  167. aethergraph/services/llm/providers.py +3 -1
  168. aethergraph/services/llm/types.py +47 -0
  169. aethergraph/services/llm/utils.py +284 -0
  170. aethergraph/services/logger/std.py +3 -0
  171. aethergraph/services/mcp/__init__.py +9 -0
  172. aethergraph/services/mcp/http_client.py +38 -0
  173. aethergraph/services/mcp/service.py +225 -1
  174. aethergraph/services/mcp/stdio_client.py +41 -6
  175. aethergraph/services/mcp/ws_client.py +44 -2
  176. aethergraph/services/memory/__init__.py +0 -0
  177. aethergraph/services/memory/distillers/llm_long_term.py +234 -0
  178. aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
  179. aethergraph/services/memory/distillers/long_term.py +225 -0
  180. aethergraph/services/memory/facade/__init__.py +3 -0
  181. aethergraph/services/memory/facade/chat.py +440 -0
  182. aethergraph/services/memory/facade/core.py +447 -0
  183. aethergraph/services/memory/facade/distillation.py +424 -0
  184. aethergraph/services/memory/facade/rag.py +410 -0
  185. aethergraph/services/memory/facade/results.py +315 -0
  186. aethergraph/services/memory/facade/retrieval.py +139 -0
  187. aethergraph/services/memory/facade/types.py +77 -0
  188. aethergraph/services/memory/facade/utils.py +43 -0
  189. aethergraph/services/memory/facade_dep.py +1539 -0
  190. aethergraph/services/memory/factory.py +9 -3
  191. aethergraph/services/memory/utils.py +10 -0
  192. aethergraph/services/metering/eventlog_metering.py +470 -0
  193. aethergraph/services/metering/noop.py +25 -4
  194. aethergraph/services/rag/__init__.py +0 -0
  195. aethergraph/services/rag/facade.py +279 -23
  196. aethergraph/services/rag/index_factory.py +2 -2
  197. aethergraph/services/rag/node_rag.py +317 -0
  198. aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
  199. aethergraph/services/registry/__init__.py +0 -0
  200. aethergraph/services/registry/agent_app_meta.py +419 -0
  201. aethergraph/services/registry/registry_key.py +1 -1
  202. aethergraph/services/registry/unified_registry.py +74 -6
  203. aethergraph/services/scope/scope.py +159 -0
  204. aethergraph/services/scope/scope_factory.py +164 -0
  205. aethergraph/services/state_stores/serialize.py +5 -0
  206. aethergraph/services/state_stores/utils.py +2 -1
  207. aethergraph/services/viz/__init__.py +0 -0
  208. aethergraph/services/viz/facade.py +413 -0
  209. aethergraph/services/viz/viz_service.py +69 -0
  210. aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
  211. aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
  212. aethergraph/storage/artifacts/cas_store.py +422 -0
  213. aethergraph/storage/artifacts/fs_cas.py +18 -0
  214. aethergraph/storage/artifacts/s3_cas.py +14 -0
  215. aethergraph/storage/artifacts/utils.py +124 -0
  216. aethergraph/storage/blob/fs_blob.py +86 -0
  217. aethergraph/storage/blob/s3_blob.py +115 -0
  218. aethergraph/storage/continuation_store/fs_cont.py +283 -0
  219. aethergraph/storage/continuation_store/inmem_cont.py +146 -0
  220. aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
  221. aethergraph/storage/docstore/fs_doc.py +63 -0
  222. aethergraph/storage/docstore/sqlite_doc.py +31 -0
  223. aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
  224. aethergraph/storage/eventlog/fs_event.py +136 -0
  225. aethergraph/storage/eventlog/sqlite_event.py +47 -0
  226. aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
  227. aethergraph/storage/factory.py +432 -0
  228. aethergraph/storage/fs_utils.py +28 -0
  229. aethergraph/storage/graph_state_store/state_store.py +64 -0
  230. aethergraph/storage/kv/inmem_kv.py +103 -0
  231. aethergraph/storage/kv/layered_kv.py +52 -0
  232. aethergraph/storage/kv/sqlite_kv.py +39 -0
  233. aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
  234. aethergraph/storage/memory/event_persist.py +68 -0
  235. aethergraph/storage/memory/fs_persist.py +118 -0
  236. aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
  237. aethergraph/{services → storage}/memory/indices.py +31 -7
  238. aethergraph/storage/metering/meter_event.py +55 -0
  239. aethergraph/storage/runs/doc_store.py +280 -0
  240. aethergraph/storage/runs/inmen_store.py +82 -0
  241. aethergraph/storage/runs/sqlite_run_store.py +403 -0
  242. aethergraph/storage/sessions/doc_store.py +183 -0
  243. aethergraph/storage/sessions/inmem_store.py +110 -0
  244. aethergraph/storage/sessions/sqlite_session_store.py +399 -0
  245. aethergraph/storage/vector_index/chroma_index.py +138 -0
  246. aethergraph/storage/vector_index/faiss_index.py +179 -0
  247. aethergraph/storage/vector_index/sqlite_index.py +187 -0
  248. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/METADATA +138 -31
  249. aethergraph-0.1.0a3.dist-info/RECORD +356 -0
  250. aethergraph-0.1.0a3.dist-info/entry_points.txt +3 -0
  251. aethergraph/services/artifacts/factory.py +0 -35
  252. aethergraph/services/artifacts/fs_store.py +0 -656
  253. aethergraph/services/artifacts/jsonl_index.py +0 -123
  254. aethergraph/services/artifacts/sqlite_index.py +0 -209
  255. aethergraph/services/memory/distillers/episode.py +0 -116
  256. aethergraph/services/memory/distillers/rolling.py +0 -74
  257. aethergraph/services/memory/facade.py +0 -633
  258. aethergraph/services/memory/persist_fs.py +0 -40
  259. aethergraph/services/rag/index/base.py +0 -27
  260. aethergraph/services/rag/index/faiss_index.py +0 -121
  261. aethergraph/services/rag/index/sqlite_index.py +0 -134
  262. aethergraph-0.1.0a1.dist-info/RECORD +0 -182
  263. aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
  264. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/WHEEL +0 -0
  265. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/LICENSE +0 -0
  266. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/NOTICE +0 -0
  267. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1539 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import time
9
+ from typing import Any, Literal
10
+ import unicodedata
11
+
12
+ from aethergraph.contracts.services.llm import LLMClientProtocol
13
+ from aethergraph.contracts.services.memory import Event, HotLog, Indices, Persistence
14
+ from aethergraph.contracts.storage.artifact_store import AsyncArtifactStore
15
+ from aethergraph.contracts.storage.doc_store import DocStore
16
+ from aethergraph.core.runtime.runtime_metering import current_metering
17
+ from aethergraph.services.rag.facade import RAGFacade
18
+ from aethergraph.services.scope.scope import Scope
19
+
20
+ from .utils import _summary_prefix
21
+
22
+ """
23
+ MemoryFacade coordinates core memory services for a specific run/session.
24
+
25
+ ┌───────────────────────────┐
26
+ │ Agent / Graph │
27
+ │ (tools, flows, chat) │
28
+ └───────────┬───────────────┘
29
+ │ emits Event
30
+
31
+ ┌─────────────────┐
32
+ │ MemoryFacade │
33
+ │ (per run_id) │
34
+ └───────┬─────────┘
35
+ record_raw/record/write_result
36
+
37
+ ┌──────────────┼─────────────────┐
38
+ ▼ ▼ ▼
39
+ ┌────────────┐ ┌─────────────┐ ┌──────────────┐
40
+ │ HotLog │ │ FSPersistence│ │ Indices │
41
+ │ (KV ring) │ │ (JSONL, FS) │ │ (name/topic) │
42
+ └────┬───────┘ └──────┬──────┘ └──────┬───────┘
43
+ │ │ │
44
+ │ │ distillers read │
45
+ │ ▼ │
46
+ │ ┌───────────────────┐ │
47
+ │ │ Distillers │ │
48
+ │ │ (LongTerm, LLM) │ │
49
+ │ └─────────┬─────────┘ │
50
+ │ │ │
51
+ │ save_json() │ │ update()
52
+ │ ▼ │
53
+ │ ┌─────────────────────┐ │
54
+ │ │ Summary JSON (FS) │ │
55
+ │ └─────────────────────┘ │
56
+ │ │ │
57
+ │ │ (optional) │
58
+ │ ▼ │
59
+ │ ┌────────────────────┐ │
60
+ └────────▶│ Summary Event │◀──┘
61
+ │ (kind=long_term_*) │
62
+ └────────────────────┘
63
+ """
64
+
65
+ _SAFE = re.compile(r"[^A-Za-z0-9._-]+")
66
+
67
+
68
+ def now_iso() -> str:
69
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
70
+
71
+
72
+ def stable_event_id(parts: dict[str, Any]) -> str:
73
+ blob = json.dumps(parts, sort_keys=True, ensure_ascii=False).encode("utf-8")
74
+ return hashlib.sha256(blob).hexdigest()[:24]
75
+
76
+
77
+ def _short_hash(s: str, n: int = 8) -> str:
78
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()[:n]
79
+
80
+
81
+ def _slug(s: str) -> str:
82
+ s = unicodedata.normalize("NFKC", str(s)).strip()
83
+ s = s.replace(" ", "-")
84
+ s = _SAFE.sub("-", s)
85
+ return s.strip("-") or "default"
86
+
87
+
88
+ def _load_sticky(path: str) -> dict:
89
+ try:
90
+ with open(path, encoding="utf-8") as f:
91
+ return json.load(f)
92
+ except Exception:
93
+ return {}
94
+
95
+
96
+ def _save_sticky(path: str, m: dict):
97
+ os.makedirs(os.path.dirname(path), exist_ok=True)
98
+ with open(path, "w", encoding="utf-8") as f:
99
+ json.dump(m, f, ensure_ascii=False, indent=2)
100
+
101
+
102
+ class MemoryFacade:
103
+ def __init__(
104
+ self,
105
+ *,
106
+ run_id: str,
107
+ session_id: str | None,
108
+ graph_id: str | None,
109
+ node_id: str | None,
110
+ scope: Scope | None = None,
111
+ hotlog: HotLog,
112
+ persistence: Persistence,
113
+ indices: Indices,
114
+ docs: DocStore,
115
+ artifact_store: AsyncArtifactStore,
116
+ hot_limit: int = 1000,
117
+ hot_ttl_s: int = 7 * 24 * 3600,
118
+ default_signal_threshold: float = 0.0,
119
+ logger=None,
120
+ rag: RAGFacade | None = None,
121
+ llm: LLMClientProtocol | None = None,
122
+ ):
123
+ self.run_id = run_id
124
+ self.session_id = session_id
125
+ self.graph_id = graph_id
126
+ self.node_id = node_id
127
+ self.scope = scope
128
+ self.hotlog = hotlog
129
+ self.persistence = persistence
130
+ self.indices = indices
131
+ self.docs = docs
132
+ self.artifacts = artifact_store
133
+ self.hot_limit = hot_limit
134
+ self.hot_ttl_s = hot_ttl_s
135
+ self.default_signal_threshold = default_signal_threshold
136
+ self.logger = logger
137
+ self.rag = rag
138
+ self.llm = llm # optional LLM service for RAG answering, etc.
139
+
140
+ # order of precedence for memory scope ID:
141
+ self.memory_scope_id = (
142
+ self.scope.memory_scope_id() if self.scope else self.session_id or self.run_id
143
+ )
144
+ self.timeline_id = self.memory_scope_id or self.run_id # key for timeline events
145
+
146
+ # ---------- recording ----------
147
+ async def record_raw(
148
+ self,
149
+ *,
150
+ base: dict[str, Any],
151
+ text: str | None = None,
152
+ metrics: dict[str, float] | None = None,
153
+ ) -> Event:
154
+ ts = now_iso()
155
+
156
+ # 1) Derive identity/execution dimentions from Scope
157
+ dims: dict[str, str] = {}
158
+ if self.scope is not None:
159
+ dims = self.scope.metering_dimensions()
160
+
161
+ run_id = base.get("run_id") or dims.get("run_id") or self.run_id
162
+ graph_id = base.get("graph_id") or dims.get("graph_id") or self.graph_id
163
+ node_id = base.get("node_id") or dims.get("node_id") or self.node_id
164
+ session_id = base.get("session_id") or dims.get("session_id") or self.session_id
165
+
166
+ user_id = base.get("user_id") or dims.get("user_id")
167
+ org_id = base.get("org_id") or dims.get("org_id")
168
+ client_id = base.get("client_id") or dims.get("client_id")
169
+ app_id = base.get("app_id") or dims.get("app_id")
170
+
171
+ # Memory scope key (for multi-tenant memory within a run)
172
+ scope_id = base.get("scope_id") or self.memory_scope_id or session_id or run_id
173
+
174
+ base.setdefault("run_id", run_id)
175
+ base.setdefault("graph_id", graph_id)
176
+ base.setdefault("node_id", node_id)
177
+ base.setdefault("scope_id", scope_id)
178
+ base.setdefault("user_id", user_id)
179
+ base.setdefault("org_id", org_id)
180
+ base.setdefault("client_id", client_id)
181
+ base.setdefault("app_id", app_id)
182
+ base.setdefault("session_id", session_id)
183
+
184
+ severity = int(base.get("severity", 2))
185
+ signal = base.get("signal")
186
+ if signal is None:
187
+ signal = self._estimate_signal(text=text, metrics=metrics, severity=severity)
188
+
189
+ # ensure kind is always present
190
+ kind = base.get("kind") or "misc"
191
+
192
+ eid = stable_event_id(
193
+ {
194
+ "ts": ts,
195
+ "run_id": base["run_id"],
196
+ "graph_id": base.get("graph_id"),
197
+ "node_id": base.get("node_id"),
198
+ "tool": base.get("tool"),
199
+ "kind": kind,
200
+ "stage": base.get("stage"),
201
+ "severity": severity,
202
+ "text": (text or "")[:6000],
203
+ "metrics_present": bool(metrics),
204
+ }
205
+ )
206
+
207
+ evt = Event(
208
+ event_id=eid,
209
+ ts=ts,
210
+ run_id=run_id,
211
+ scope_id=scope_id,
212
+ user_id=user_id,
213
+ org_id=org_id,
214
+ client_id=client_id,
215
+ app_id=app_id,
216
+ session_id=session_id,
217
+ kind=kind,
218
+ stage=base.get("stage"),
219
+ text=text,
220
+ tags=base.get("tags"),
221
+ data=base.get("data"),
222
+ metrics=metrics,
223
+ graph_id=graph_id,
224
+ node_id=node_id,
225
+ tool=base.get("tool"),
226
+ topic=base.get("topic"),
227
+ severity=severity,
228
+ signal=signal,
229
+ inputs=base.get("inputs"),
230
+ outputs=base.get("outputs"),
231
+ embedding=base.get("embedding"),
232
+ pii_flags=base.get("pii_flags"),
233
+ version=2,
234
+ )
235
+
236
+ # 2) persist to HotLog + Persistence
237
+ await self.hotlog.append(self.timeline_id, evt, ttl_s=self.hot_ttl_s, limit=self.hot_limit)
238
+ await self.persistence.append_event(self.timeline_id, evt)
239
+
240
+ # Metering hook
241
+ try:
242
+ meter = current_metering()
243
+ await meter.record_event(
244
+ scope=self.scope,
245
+ scope_id=scope_id,
246
+ kind=f"memory.{kind}",
247
+ )
248
+ except Exception:
249
+ if self.logger:
250
+ self.logger.exception("Error recording metering event in MemoryFacade.record_raw")
251
+ return evt
252
+
253
+ async def record(
254
+ self,
255
+ kind: str,
256
+ data: Any,
257
+ tags: list[str] | None = None,
258
+ severity: int = 2,
259
+ stage: str | None = None,
260
+ inputs_ref=None,
261
+ outputs_ref=None,
262
+ metrics: dict[str, float] | None = None,
263
+ signal: float | None = None,
264
+ text: str | None = None, # optional override
265
+ ) -> Event:
266
+ """
267
+ Convenience wrapper around record_raw() with common fields.
268
+
269
+ - kind : logical kind (e.g. "user_msg", "tool_call", "chat_turn")
270
+ - data : JSON-serializable content, or string
271
+ - tags : optional list of labels
272
+ - severity : 1=low, 2=medium, 3=high
273
+ - stage : optional stage (user/assistant/system/etc.)
274
+ - inputs_ref / outputs_ref : optional Value[] references
275
+ - metrics : numeric map (latency, tokens, etc.)
276
+ - signal : optional override for signal strength
277
+ - text : optional preview text override (if None, derived from data)
278
+ """
279
+
280
+ # 1) derive short preview text
281
+ if text is None and data is not None:
282
+ if isinstance(data, str):
283
+ text = data
284
+ else:
285
+ try:
286
+ raw = json.dumps(data, ensure_ascii=False)
287
+ text = raw
288
+ except Exception as e:
289
+ text = f"<unserializable data: {e!s}>"
290
+ if self.logger:
291
+ self.logger.warning(text)
292
+
293
+ # 2) optionally truncate preview text (enforce token discipline)
294
+ if text and len(text) > 2000:
295
+ text = text[:2000] + " …[truncated]"
296
+
297
+ # 3) full structured payload in Event.data when possible
298
+ data_field: dict[str, Any] | None = None
299
+ if isinstance(data, dict):
300
+ data_field = data
301
+ elif data is not None and not isinstance(data, str):
302
+ # store under "value" if it's JSON-serializable
303
+ try:
304
+ json.dumps(data, ensure_ascii=False)
305
+ data_field = {"value": data}
306
+ except Exception:
307
+ data_field = {"repr": repr(data)}
308
+
309
+ base: dict[str, Any] = dict(
310
+ kind=kind,
311
+ stage=stage,
312
+ severity=severity,
313
+ tags=tags or [],
314
+ data=data_field,
315
+ inputs=inputs_ref,
316
+ outputs=outputs_ref,
317
+ )
318
+ if signal is not None:
319
+ base["signal"] = signal
320
+
321
+ return await self.record_raw(base=base, text=text, metrics=metrics)
322
+
323
+ # ------------ chat recording ------------
324
+ async def record_chat(
325
+ self,
326
+ role: Literal["user", "assistant", "system", "tool"],
327
+ text: str,
328
+ *,
329
+ tags: list[str] | None = None,
330
+ data: dict[str, Any] | None = None,
331
+ severity: int = 2,
332
+ signal: float | None = None,
333
+ ) -> Event:
334
+ """
335
+ Record a single chat turn in a normalized way.
336
+
337
+ - role: "user" | "assistant" | "system" | "tool"
338
+ - text: primary message text
339
+ - tags: optional extra tags (we always add "chat")
340
+ - data: extra JSON payload merged into {"role", "text"}
341
+ """
342
+ extra_tags = ["chat"]
343
+ if tags:
344
+ extra_tags.extend(tags)
345
+ payload: dict[str, Any] = {"role": role, "text": text}
346
+ if data:
347
+ payload.update(data)
348
+
349
+ return await self.record(
350
+ kind="chat.turn",
351
+ text=text,
352
+ data=payload,
353
+ tags=extra_tags,
354
+ severity=severity,
355
+ stage=role,
356
+ signal=signal,
357
+ )
358
+
359
+ async def record_chat_user(
360
+ self,
361
+ text: str,
362
+ *,
363
+ tags: list[str] | None = None,
364
+ data: dict[str, Any] | None = None,
365
+ severity: int = 2,
366
+ signal: float | None = None,
367
+ ) -> Event:
368
+ """DX sugar: record a user chat turn."""
369
+ return await self.record_chat(
370
+ "user",
371
+ text,
372
+ tags=tags,
373
+ data=data,
374
+ severity=severity,
375
+ signal=signal,
376
+ )
377
+
378
+ async def record_chat_assistant(
379
+ self,
380
+ text: str,
381
+ *,
382
+ tags: list[str] | None = None,
383
+ data: dict[str, Any] | None = None,
384
+ severity: int = 2,
385
+ signal: float | None = None,
386
+ ) -> Event:
387
+ """DX sugar: record an assistant chat turn."""
388
+ return await self.record_chat(
389
+ "assistant",
390
+ text,
391
+ tags=tags,
392
+ data=data,
393
+ severity=severity,
394
+ signal=signal,
395
+ )
396
+
397
+ async def record_chat_system(
398
+ self,
399
+ text: str,
400
+ *,
401
+ tags: list[str] | None = None,
402
+ data: dict[str, Any] | None = None,
403
+ severity: int = 1,
404
+ signal: float | None = None,
405
+ ) -> Event:
406
+ """DX sugar: record a system message."""
407
+ return await self.record_chat(
408
+ "system",
409
+ text,
410
+ tags=tags,
411
+ data=data,
412
+ severity=severity,
413
+ signal=signal,
414
+ )
415
+
416
+ async def record_chat_tool(
417
+ self,
418
+ tool_name: str,
419
+ text: str,
420
+ *,
421
+ tags: list[str] | None = None,
422
+ data: dict[str, Any] | None = None,
423
+ severity: int = 2,
424
+ signal: float | None = None,
425
+ ) -> Event:
426
+ """
427
+ DX sugar: record a tool-related message as a chat turn.
428
+
429
+ Adds tag "tool:<tool_name>" and records tool_name in data.
430
+ """
431
+ tool_tags = list(tags or [])
432
+ tool_tags.append(f"tool:{tool_name}")
433
+ payload: dict[str, Any] = {"tool_name": tool_name}
434
+ if data:
435
+ payload.update(data)
436
+
437
+ return await self.record_chat(
438
+ "tool",
439
+ text,
440
+ tags=tool_tags,
441
+ data=payload,
442
+ severity=severity,
443
+ signal=signal,
444
+ )
445
+
446
+ async def recent_chat(
447
+ self,
448
+ *,
449
+ limit: int = 50,
450
+ roles: Sequence[str] | None = None,
451
+ ) -> list[dict[str, Any]]:
452
+ """
453
+ Return the last `limit` chat.turns as a normalized list.
454
+
455
+ Each item: {"ts", "role", "text", "tags"}.
456
+
457
+ - roles: optional filter on role (e.g. {"user", "assistant"}).
458
+ """
459
+ events = await self.recent(kinds=["chat.turn"], limit=limit)
460
+ out: list[dict[str, Any]] = []
461
+
462
+ for e in events:
463
+ # 1) Resolve role (from stage or data)
464
+ role = (
465
+ getattr(e, "stage", None)
466
+ or ((e.data or {}).get("role") if getattr(e, "data", None) else None)
467
+ or "user"
468
+ )
469
+
470
+ if roles is not None and role not in roles:
471
+ continue
472
+
473
+ # 2) Resolve text:
474
+ # - prefer Event.text
475
+ # - fall back to data["text"]
476
+ raw_text = getattr(e, "text", "") or ""
477
+ if not raw_text and getattr(e, "data", None):
478
+ raw_text = (e.data or {}).get("text", "") or ""
479
+
480
+ out.append(
481
+ {
482
+ "ts": getattr(e, "ts", None),
483
+ "role": role,
484
+ "text": raw_text,
485
+ "tags": list(e.tags or []),
486
+ }
487
+ )
488
+
489
+ return out
490
+
491
+ async def chat_history_for_llm(
492
+ self,
493
+ *,
494
+ limit: int = 20,
495
+ include_system_summary: bool = True,
496
+ summary_tag: str = "session",
497
+ summary_scope_id: str | None = None,
498
+ max_summaries: int = 3,
499
+ ) -> dict[str, Any]:
500
+ """
501
+ Build a ready-to-send OpenAI-style chat message list.
502
+
503
+ Returns:
504
+ {
505
+ "summary": "<combined long-term summary or ''>",
506
+ "messages": [
507
+ {"role": "system", "content": "..."},
508
+ {"role": "user", "content": "..."},
509
+ ...
510
+ ]
511
+ }
512
+
513
+ Long-term summary handling:
514
+ - We load up to `max_summaries` recent summaries for the tag,
515
+ oldest → newest, and join their text with blank lines.
516
+ """
517
+ messages: list[dict[str, str]] = []
518
+ summary_text = ""
519
+
520
+ if include_system_summary:
521
+ try:
522
+ summaries = await self.load_recent_summaries(
523
+ scope_id=summary_scope_id,
524
+ summary_tag=summary_tag,
525
+ limit=max_summaries,
526
+ )
527
+ except Exception:
528
+ summaries = []
529
+
530
+ parts: list[str] = []
531
+ for s in summaries:
532
+ st = s.get("summary") or s.get("text") or s.get("body") or s.get("value") or ""
533
+ if st:
534
+ parts.append(st)
535
+
536
+ if parts:
537
+ summary_text = "\n\n".join(parts)
538
+ messages.append(
539
+ {
540
+ "role": "system",
541
+ "content": f"Summary of previous context:\n{summary_text}",
542
+ }
543
+ )
544
+
545
+ # Append recent chat turns
546
+ for item in await self.recent_chat(limit=limit):
547
+ role = item["role"]
548
+ # Map unknown roles (e.g. "tool") to "assistant" by default
549
+ mapped_role = role if role in {"user", "assistant", "system"} else "assistant"
550
+ messages.append({"role": mapped_role, "content": item["text"]})
551
+
552
+ return {"summary": summary_text, "messages": messages}
553
+
554
+ async def build_prompt_segments(
555
+ self,
556
+ *,
557
+ recent_chat_limit: int = 12,
558
+ include_long_term: bool = True,
559
+ summary_tag: str = "session",
560
+ max_summaries: int = 3,
561
+ include_recent_tools: bool = False,
562
+ tool: str | None = None,
563
+ tool_limit: int = 10,
564
+ ) -> dict[str, Any]:
565
+ """
566
+ High-level helper to assemble memory context for prompts.
567
+
568
+ Returns:
569
+ {
570
+ "long_term": "<combined summary text or ''>",
571
+ "recent_chat": [ {ts, role, text, tags}, ... ],
572
+ "recent_tools": [ {ts, tool, message, inputs, outputs, tags}, ... ]
573
+ }
574
+ """
575
+ long_term_text = ""
576
+ if include_long_term:
577
+ try:
578
+ summaries = await self.load_recent_summaries(
579
+ summary_tag=summary_tag,
580
+ limit=max_summaries,
581
+ )
582
+ except Exception:
583
+ summaries = []
584
+
585
+ parts: list[str] = []
586
+ for s in summaries:
587
+ st = s.get("summary") or s.get("text") or s.get("body") or s.get("value") or ""
588
+ if st:
589
+ parts.append(st)
590
+
591
+ if parts:
592
+ # multiple long-term summaries → concatenate oldest→newest
593
+ long_term_text = "\n\n".join(parts)
594
+
595
+ recent_chat = await self.recent_chat(limit=recent_chat_limit)
596
+
597
+ recent_tools: list[dict[str, Any]] = []
598
+ if include_recent_tools:
599
+ events = await self.recent_tool_results(
600
+ tool=tool,
601
+ limit=tool_limit,
602
+ )
603
+ for e in events:
604
+ recent_tools.append(
605
+ {
606
+ "ts": getattr(e, "ts", None),
607
+ "tool": e.tool,
608
+ "message": e.text,
609
+ "inputs": getattr(e, "inputs", None),
610
+ "outputs": getattr(e, "outputs", None),
611
+ "tags": list(e.tags or []),
612
+ }
613
+ )
614
+
615
+ return {
616
+ "long_term": long_term_text,
617
+ "recent_chat": recent_chat,
618
+ "recent_tools": recent_tools,
619
+ }
620
+
621
+ # ---------- typed result recording ----------
622
+ async def write_result(
623
+ self,
624
+ *,
625
+ tool: str | None = None, # back compatibility with 'topic'
626
+ inputs: list[dict[str, Any]] | None = None,
627
+ outputs: list[dict[str, Any]] | None = None,
628
+ tags: list[str] | None = None,
629
+ metrics: dict[str, float] | None = None,
630
+ message: str | None = None,
631
+ severity: int = 3,
632
+ topic: str | None = None, # alias for tool, backwards compatibility
633
+ ) -> Event:
634
+ """
635
+ Convenience for recording a “tool/agent/flow result” with typed I/O.
636
+
637
+ `tool` : tool/agent/flow identifier (also used by KVIndices.last_outputs_by_topic)
638
+ `inputs` : List[Value]-like dicts
639
+ `outputs` : List[Value]-like dicts
640
+ `tags` : labels like ["rag","qa"] for filtering/search
641
+ """
642
+ if tool is None and topic is not None:
643
+ tool = topic
644
+ if tool is None:
645
+ raise ValueError("write_result requires a 'tool' (or legacy 'topic') name")
646
+
647
+ inputs = inputs or []
648
+ outputs = outputs or []
649
+
650
+ evt = await self.record_raw(
651
+ base=dict(
652
+ tool=tool,
653
+ kind="tool_result",
654
+ severity=severity,
655
+ tags=tags or [],
656
+ inputs=inputs,
657
+ outputs=outputs,
658
+ ),
659
+ text=message,
660
+ metrics=metrics,
661
+ )
662
+ await self.indices.update(self.timeline_id, evt)
663
+ return evt
664
+
665
+ async def write_tool_result(
666
+ self,
667
+ *,
668
+ tool: str,
669
+ inputs: list[dict[str, Any]] | None = None,
670
+ outputs: list[dict[str, Any]] | None = None,
671
+ tags: list[str] | None = None,
672
+ metrics: dict[str, float] | None = None,
673
+ message: str | None = None,
674
+ severity: int = 3,
675
+ ) -> Event:
676
+ """
677
+ Convenience wrapper around write_result() for tool results.
678
+ """
679
+ return await self.write_result(
680
+ tool=tool,
681
+ inputs=inputs,
682
+ outputs=outputs,
683
+ tags=tags,
684
+ metrics=metrics,
685
+ message=message,
686
+ severity=severity,
687
+ )
688
+
689
+ async def record_tool_result(
690
+ self,
691
+ *,
692
+ tool: str,
693
+ inputs: list[dict[str, Any]] | None = None,
694
+ outputs: list[dict[str, Any]] | None = None,
695
+ tags: list[str] | None = None,
696
+ metrics: dict[str, float] | None = None,
697
+ message: str | None = None,
698
+ severity: int = 3,
699
+ ) -> Event:
700
+ """
701
+ DX-friendly alias for write_tool_result(); prefer this in new code.
702
+ """
703
+ return await self.write_tool_result(
704
+ tool=tool,
705
+ inputs=inputs,
706
+ outputs=outputs,
707
+ tags=tags,
708
+ metrics=metrics,
709
+ message=message,
710
+ severity=severity,
711
+ )
712
+
713
+ async def record_result(
714
+ self,
715
+ *,
716
+ tool: str | None = None,
717
+ inputs: list[dict[str, Any]] | None = None,
718
+ outputs: list[dict[str, Any]] | None = None,
719
+ tags: list[str] | None = None,
720
+ metrics: dict[str, float] | None = None,
721
+ message: str | None = None,
722
+ severity: int = 3,
723
+ ) -> Event:
724
+ """
725
+ Alias for write_result(); symmetric with record_tool_result().
726
+
727
+ Use this when you conceptually have a "result" but don't care whether
728
+ it's a tool vs agent vs flow.
729
+ """
730
+ return await self.write_result(
731
+ tool=tool,
732
+ inputs=inputs,
733
+ outputs=outputs,
734
+ tags=tags,
735
+ metrics=metrics,
736
+ message=message,
737
+ severity=severity,
738
+ )
739
+
740
+ async def last_tool_result(self, tool: str) -> Event | None:
741
+ """
742
+ Convenience: return the most recent tool_result Event for a given tool.
743
+ """
744
+ events = await self.recent_tool_results(tool=tool, limit=1)
745
+ return events[-1] if events else None
746
+
747
+ async def recent_tool_result_data(
748
+ self,
749
+ *,
750
+ tool: str,
751
+ limit: int = 10,
752
+ ) -> list[dict[str, Any]]:
753
+ """
754
+ Return a simplified view over recent tool_result events.
755
+
756
+ Each item:
757
+ {"ts", "tool", "message", "inputs", "outputs", "tags"}.
758
+ """
759
+ events = await self.recent_tool_results(tool=tool, limit=limit)
760
+ out: list[dict[str, Any]] = []
761
+ for e in events:
762
+ out.append(
763
+ {
764
+ "ts": getattr(e, "ts", None),
765
+ "tool": e.tool,
766
+ "message": e.text,
767
+ "inputs": getattr(e, "inputs", None),
768
+ "outputs": getattr(e, "outputs", None),
769
+ "tags": list(e.tags or []),
770
+ }
771
+ )
772
+ return out
773
+
774
+ # ---------- retrieval ----------
775
+ async def recent(self, *, kinds: list[str] | None = None, limit: int = 50) -> list[Event]:
776
+ """Return recent events from HotLog (most recent last), optionally filtered by kind."""
777
+ return await self.hotlog.recent(self.timeline_id, kinds=kinds, limit=limit)
778
+
779
+ async def recent_data(
780
+ self,
781
+ *,
782
+ kinds: list[str] | None = None,
783
+ tags: list[str] | None = None,
784
+ limit: int = 50,
785
+ ) -> list[Any]:
786
+ evts = await self.recent(kinds=kinds, limit=limit)
787
+ if tags:
788
+ want = set(tags)
789
+ evts = [e for e in evts if want.issubset(set(e.tags or []))]
790
+
791
+ out: list[Any] = []
792
+ for e in evts:
793
+ if e.data is not None:
794
+ out.append(e.data)
795
+ elif e.text:
796
+ # last-resort: treat text as JSON if it looks like it, else raw string
797
+ t = e.text.strip()
798
+ if (t.startswith("{") and t.endswith("}")) or (
799
+ t.startswith("[") and t.endswith("]")
800
+ ):
801
+ try:
802
+ out.append(json.loads(t))
803
+ continue
804
+ except Exception:
805
+ pass
806
+ out.append(e.text)
807
+ return out
808
+
809
+ async def last_by_name(self, name: str):
810
+ """Return the last output value by `name` from Indices (fast path)."""
811
+ return await self.indices.last_by_name(self.timeline_id, name)
812
+
813
+ async def last_output_by_name(self, name: str):
814
+ """Return the last output value (Value.value) by `name` from Indices (fast path)."""
815
+ out = await self.indices.last_by_name(self.timeline_id, name)
816
+ if out is None:
817
+ return None
818
+ return out.get("value") # type: ignore
819
+
820
+ async def last_outputs_by_topic(self, topic: str):
821
+ """Return the last output map for a given topic (tool/flow/agent) from Indices."""
822
+ return await self.indices.last_outputs_by_topic(self.timeline_id, topic)
823
+
824
+ # replace last_tool_result_outputs
825
+ async def last_tool_result_outputs(self, tool: str) -> dict[str, Any] | None:
826
+ """
827
+ Convenience wrapper around KVIndices.last_outputs_by_topic for this run.
828
+ Returns the last outputs map for a given tool, or None.
829
+ """
830
+ return await self.indices.last_outputs_by_topic(self.timeline_id, tool)
831
+
832
+ async def recent_tool_results(
833
+ self,
834
+ *,
835
+ tool: str | None = None,
836
+ tags: list[str] | None = None,
837
+ limit: int = 50,
838
+ ) -> list[Event]:
839
+ """
840
+ Return recent tool_result events from HotLog, optionally filtered by tool name and tags.
841
+ """
842
+ events = await self.recent(kinds=["tool_result"], limit=limit)
843
+ if tool is not None:
844
+ events = [e for e in events if e.tool == tool]
845
+ if tags:
846
+ want = set(tags)
847
+ events = [e for e in events if want.issubset(set(e.tags or []))]
848
+ return events
849
+
850
+ async def latest_refs_by_kind(self, kind: str, *, limit: int = 50):
851
+ """Return latest ref outputs by ref.kind (fast path, KV-backed)."""
852
+ return await self.indices.latest_refs_by_kind(self.timeline_id, kind, limit=limit)
853
+
854
+ async def search(
855
+ self,
856
+ *,
857
+ query: str,
858
+ kinds: list[str] | None = None,
859
+ tags: list[str] | None = None,
860
+ limit: int = 100,
861
+ use_embedding: bool = True,
862
+ ) -> list[Event]:
863
+ """
864
+ Search recent events by lexical matching and optional embedding similarity.
865
+ - kinds: optional filter by event kinds
866
+ - tags: optional filter by tags (AND semantics)
867
+ - limit: max number of results to return
868
+ - use_embedding: whether to use embedding-based ranking (requires LLM client)
869
+
870
+ NOTE: This is an in-memory scan of recent events. No indexing is done yet.
871
+ """
872
+ events = await self.recent(kinds=kinds, limit=limit)
873
+ if tags:
874
+ want = set(tags)
875
+ events = [e for e in events if want.issubset(set(e.tags or []))]
876
+
877
+ query_l = query.lower()
878
+
879
+ # 1) simple fallback: lexical
880
+ lexical_hits = [e for e in events if (e.text or "").lower().find(query_l) >= 0]
881
+
882
+ if not use_embedding:
883
+ return lexical_hits or events
884
+
885
+ raise NotImplementedError("Embedding-based search not implemented yet")
886
+
887
+ # 2) optional: embedding-based ranking (if you embed query + have e.embedding) [stub]
888
+ if not (self.llm and any(e.embedding for e in events)):
889
+ return lexical_hits or events
890
+
891
+ q_emb = await self.llm.embed(query) # TODO: adapt to LLMClientProtocol
892
+
893
+ # compute cosine similarity in Python for now
894
+ def sim(e: Event) -> float:
895
+ if not e.embedding:
896
+ return -1.0
897
+ # naive dot product
898
+ return sum(a * b for a, b in zip(q_emb, e.embedding, strict=False))
899
+
900
+ scored = sorted(events, key=sim, reverse=True)
901
+ return scored[:limit]
902
+
903
+ # ---------- distillation (plug strategies) ----------
904
+
905
+ # ---------- distillation helpers ----------
906
+ async def distill_long_term(
907
+ self,
908
+ scope_id: str | None = None,
909
+ *,
910
+ summary_tag: str = "session",
911
+ summary_kind: str = "long_term_summary",
912
+ include_kinds: list[str] | None = None,
913
+ include_tags: list[str] | None = None,
914
+ max_events: int = 200,
915
+ min_signal: float | None = None,
916
+ use_llm: bool = False,
917
+ ) -> dict[str, Any]:
918
+ """
919
+ Run the generic LongTermSummarizer over this run's memory and persist a summary.
920
+
921
+ Returns a descriptor like:
922
+ {
923
+ "uri": "file://mem/<run_id>/summaries/<tag>/<ts>.json",
924
+ "summary_kind": "...",
925
+ "summary_tag": "...",
926
+ "time_window": {...},
927
+ "num_events": N,
928
+ }
929
+
930
+ This is suitable for:
931
+ - soft re-hydration (load summary into a new run),
932
+ - RAG promotion,
933
+ - or analytics.
934
+ """
935
+ scope_id = scope_id or self.memory_scope_id # order of precedence
936
+ if use_llm:
937
+ if not self.llm:
938
+ raise RuntimeError("LLM client not configured in MemoryFacade for LLM distillation")
939
+ from aethergraph.services.memory.distillers.llm_long_term import LLMLongTermSummarizer
940
+
941
+ d = LLMLongTermSummarizer(
942
+ llm=self.llm,
943
+ summary_kind=summary_kind,
944
+ summary_tag=summary_tag,
945
+ include_kinds=include_kinds,
946
+ include_tags=include_tags,
947
+ max_events=max_events,
948
+ min_signal=min_signal if min_signal is not None else self.default_signal_threshold,
949
+ )
950
+ return await d.distill(
951
+ run_id=self.run_id,
952
+ timeline_id=self.timeline_id,
953
+ scope_id=scope_id or self.memory_scope_id,
954
+ hotlog=self.hotlog,
955
+ persistence=self.persistence,
956
+ indices=self.indices,
957
+ docs=self.docs,
958
+ )
959
+
960
+ from aethergraph.services.memory.distillers.long_term import LongTermSummarizer
961
+
962
+ # non-LLM path -- structured digest
963
+ d = LongTermSummarizer(
964
+ summary_kind=summary_kind,
965
+ summary_tag=summary_tag,
966
+ include_kinds=include_kinds,
967
+ include_tags=include_tags,
968
+ max_events=max_events,
969
+ min_signal=min_signal if min_signal is not None else self.default_signal_threshold,
970
+ )
971
+ return await d.distill(
972
+ run_id=self.run_id,
973
+ timeline_id=self.timeline_id,
974
+ scope_id=scope_id or self.memory_scope_id,
975
+ hotlog=self.hotlog,
976
+ persistence=self.persistence,
977
+ indices=self.indices,
978
+ docs=self.docs,
979
+ )
980
+
981
+ async def distill_meta_summary(
982
+ self,
983
+ scope_id: str | None = None,
984
+ *,
985
+ source_kind: str = "long_term_summary",
986
+ source_tag: str = "session",
987
+ summary_kind: str = "meta_summary",
988
+ summary_tag: str = "meta",
989
+ max_summaries: int = 20,
990
+ min_signal: float | None = None,
991
+ use_llm: bool = True,
992
+ ) -> dict[str, Any]:
993
+ """
994
+ Run an LLM-based meta summarizer over existing summary events.
995
+
996
+ Typical usage:
997
+ - source_kind="long_term_summary", source_tag="session"
998
+ - summary_kind="meta_summary", summary_tag="weekly" or "meta"
999
+
1000
+ Returns a descriptor like:
1001
+ {
1002
+ "uri": "file://mem/<scope_id>/summaries/<summary_tag>/<ts>.json",
1003
+ "summary_kind": "...",
1004
+ "summary_tag": "...",
1005
+ "time_window": {...},
1006
+ "num_source_summaries": N,
1007
+ }
1008
+ """
1009
+ scope_id = scope_id or self.memory_scope_id # order of precedence
1010
+
1011
+ if not use_llm:
1012
+ # Placeholder for a future non-LLM meta summarizer if desired.
1013
+ raise NotImplementedError("Non-LLM meta summarization is not implemented yet")
1014
+
1015
+ if not self.llm:
1016
+ raise RuntimeError("LLM client not configured in MemoryFacade for meta distillation")
1017
+
1018
+ from aethergraph.services.memory.distillers.llm_meta_summary import (
1019
+ LLMMetaSummaryDistiller,
1020
+ )
1021
+
1022
+ d = LLMMetaSummaryDistiller(
1023
+ llm=self.llm,
1024
+ source_kind=source_kind,
1025
+ source_tag=source_tag,
1026
+ summary_kind=summary_kind,
1027
+ summary_tag=summary_tag,
1028
+ max_summaries=max_summaries,
1029
+ min_signal=min_signal if min_signal is not None else self.default_signal_threshold,
1030
+ )
1031
+ return await d.distill(
1032
+ run_id=self.run_id,
1033
+ timeline_id=self.timeline_id,
1034
+ scope_id=scope_id or self.memory_scope_id,
1035
+ hotlog=self.hotlog,
1036
+ persistence=self.persistence,
1037
+ indices=self.indices,
1038
+ docs=self.docs,
1039
+ )
1040
+
1041
+ # ---------- RAG facade ----------
1042
+ async def rag_upsert(
1043
+ self, *, corpus_id: str, docs: Sequence[dict[str, Any]], topic: str | None = None
1044
+ ) -> dict[str, Any]:
1045
+ """Upsert documents into RAG corpus via RAG facade, if configured."""
1046
+ if not self.rag:
1047
+ raise RuntimeError("RAG facade not configured in MemoryFacade")
1048
+ stats = await self.rag.upsert_docs(corpus_id=corpus_id, docs=list(docs))
1049
+ # Optional write result -- disable for now
1050
+ # self.write_result(
1051
+ # topic=topic or f"rag.upsert.{corpus_id}",
1052
+ # outputs=[{"name": "stats", "kind": "json", "value": stats}],
1053
+ # tags=["rag", "ingest"],
1054
+ # message=f"Upserted {stats.get('chunks',0)} chunks into {corpus_id}"
1055
+ # )
1056
+ return stats
1057
+
1058
+ # ---------- helpers ----------
1059
+ def _estimate_signal(
1060
+ self, *, text: str | None, metrics: dict[str, Any] | None, severity: int
1061
+ ) -> float:
1062
+ """
1063
+ Cheap heuristic to gauge “signal” of an event (0.0–1.0).
1064
+ - Rewards presence/length of text and presence of metrics.
1065
+ - Used as a noise gate in rolling summaries; can be overridden by caller.
1066
+ """
1067
+ score = 0.15 + 0.1 * severity
1068
+ if text:
1069
+ score += min(len(text) / 400.0, 0.4)
1070
+ if metrics:
1071
+ score += 0.2
1072
+ return max(0.0, min(1.0, score))
1073
+
1074
+ def resolve(self, params: dict[str, Any]) -> dict[str, Any]:
1075
+ """
1076
+ Synchronous version of parameter resolution (for sync contexts).
1077
+ See `aethergraph.services.memory.resolver.resolve_params` for details.
1078
+ """
1079
+ from aethergraph.services.memory.resolver import ResolverContext, resolve_params
1080
+
1081
+ rctx = ResolverContext(mem=self)
1082
+ return resolve_params(params, rctx)
1083
+
1084
+ # ----------- RAG: corpus binding & status -----------
1085
+ async def rag_bind(
1086
+ self,
1087
+ *,
1088
+ corpus_id: str | None = None,
1089
+ key: str | None = None,
1090
+ create_if_missing: bool = True,
1091
+ labels: dict | None = None,
1092
+ ) -> str:
1093
+ if not self.rag:
1094
+ raise RuntimeError("RAG facade not configured")
1095
+
1096
+ mem_scope = self.memory_scope_id # derived from Scope
1097
+ # dims = self.scope.metering_dimensions() if self.scope else {}
1098
+
1099
+ if corpus_id:
1100
+ cid = corpus_id
1101
+ else:
1102
+ logical_key = key or "default"
1103
+ base = f"{mem_scope}:{logical_key}"
1104
+ cid = f"mem:{_slug(mem_scope)}:{_slug(logical_key)}-{_short_hash(base, 8)}"
1105
+
1106
+ scope_labels = {}
1107
+ if self.scope:
1108
+ scope_labels = self.scope.rag_labels(scope_id=mem_scope)
1109
+
1110
+ meta = {"scope": scope_labels, **(labels or {})}
1111
+ if create_if_missing:
1112
+ await self.rag.add_corpus(cid, meta=meta, scope_labels=scope_labels)
1113
+ return cid
1114
+
1115
+ async def rag_status(self, *, corpus_id: str) -> dict:
1116
+ """Quick stats about a corpus."""
1117
+ if not self.rag:
1118
+ raise RuntimeError("RAG facade not configured in MemoryFacade")
1119
+ # lightweight: count docs/chunks by scanning the jsonl (fast enough for now)
1120
+ return await self.rag.stats(corpus_id)
1121
+
1122
+ async def rag_snapshot(self, *, corpus_id: str, title: str, labels: dict | None = None) -> dict:
1123
+ """Export corpus into an artifact bundle and return its URI."""
1124
+ if not self.rag:
1125
+ raise RuntimeError("RAG facade not configured in MemoryFacade")
1126
+ bundle = await self.rag.export(corpus_id)
1127
+ # Optionally log a tool_result
1128
+ await self.write_result(
1129
+ tool=f"rag.snapshot.{corpus_id}",
1130
+ outputs=[{"name": "bundle_uri", "kind": "uri", "value": bundle.get("uri")}],
1131
+ tags=["rag", "snapshot"],
1132
+ message=title,
1133
+ severity=2,
1134
+ )
1135
+ return bundle
1136
+
1137
+ async def rag_compact(self, *, corpus_id: str, policy: dict | None = None) -> dict:
1138
+ """
1139
+ Simple compaction policy:
1140
+ - Optionally drop docs by label or min_score
1141
+ - Optional re-embed with a new model
1142
+ For now we just expose reembed() plumbing and a placeholder for pruning.
1143
+
1144
+ NOTE: this function is a placeholder for future compaction strategies.
1145
+ """
1146
+ if not self.rag:
1147
+ raise RuntimeError("RAG facade not configured in MemoryFacade")
1148
+ policy = policy or {}
1149
+ model = policy.get("reembed_model")
1150
+ pruned = 0 # placeholder
1151
+ if model:
1152
+ await self.rag.reembed(corpus_id, model=model)
1153
+ return {"pruned_docs": pruned, "reembedded": bool(model)}
1154
+
1155
+ # ----------- RAG: event → doc promotion -----------
1156
+ async def rag_promote_events(
1157
+ self,
1158
+ *,
1159
+ corpus_id: str,
1160
+ events: list[Event] | None = None,
1161
+ where: dict | None = None,
1162
+ policy: dict | None = None,
1163
+ ) -> dict:
1164
+ """
1165
+ Convert events to documents and upsert.
1166
+ where: optional filter like {"kinds": ["tool_result"], "min_signal": 0.25, "limit": 200}
1167
+ policy: {"min_signal": float} In the future may support more (chunksize, overlap, etc.)
1168
+ """
1169
+ if not self.rag:
1170
+ raise RuntimeError("RAG facade not configured in MemoryFacade")
1171
+ policy = policy or {}
1172
+ min_signal = policy.get("min_signal", self.default_signal_threshold)
1173
+
1174
+ # Select events if not provided
1175
+ if events is None:
1176
+ kinds = (where or {}).get("kinds")
1177
+ limit = int((where or {}).get("limit", 200))
1178
+ recent = await self.recent(kinds=kinds, limit=limit)
1179
+ events = [e for e in recent if (getattr(e, "signal", 0.0) or 0.0) >= float(min_signal)]
1180
+
1181
+ docs: list[dict] = []
1182
+ for e in events:
1183
+ title = f"{e.kind}:{(e.tool or e.stage or 'n/a')}:{e.ts}"
1184
+ scope_labels = (
1185
+ self.scope.rag_labels(scope_id=self.memory_scope_id) if self.scope else {}
1186
+ )
1187
+ labels = {
1188
+ **scope_labels,
1189
+ "kind": e.kind,
1190
+ "tool": e.tool,
1191
+ "stage": e.stage,
1192
+ "severity": e.severity,
1193
+ "run_id": e.run_id,
1194
+ "graph_id": e.graph_id,
1195
+ "node_id": e.node_id,
1196
+ "scope_id": e.scope_id,
1197
+ "tags": list(e.tags or []),
1198
+ }
1199
+ body = e.text
1200
+ if not body:
1201
+ # Fallback to compact JSON of I/O + metrics
1202
+ body = json.dumps(
1203
+ {"inputs": e.inputs, "outputs": e.outputs, "metrics": e.metrics},
1204
+ ensure_ascii=False,
1205
+ )
1206
+ docs.append({"text": body, "title": title, "labels": labels})
1207
+
1208
+ if not docs:
1209
+ return {
1210
+ "added": 0,
1211
+ "chunks": 0,
1212
+ "index": getattr(self.rag.index, "__class__", type("X", (object,), {})).__name__,
1213
+ }
1214
+
1215
+ stats = await self.rag.upsert_docs(corpus_id=corpus_id, docs=docs)
1216
+ # (Optional) write a result for traceability
1217
+ await self.write_result(
1218
+ tool=f"rag.promote.{corpus_id}",
1219
+ outputs=[
1220
+ {"name": "added_docs", "kind": "number", "value": stats.get("added", 0)},
1221
+ {"name": "chunks", "kind": "number", "value": stats.get("chunks", 0)},
1222
+ ],
1223
+ tags=["rag", "ingest"],
1224
+ message=f"Promoted {stats.get('added', 0)} events into {corpus_id}",
1225
+ severity=2,
1226
+ )
1227
+ return stats
1228
+
1229
+ # ----------- RAG: search & answer -----------
1230
+ async def rag_search(
1231
+ self,
1232
+ *,
1233
+ corpus_id: str,
1234
+ query: str,
1235
+ k: int = 8,
1236
+ filters: dict | None = None,
1237
+ mode: Literal["hybrid", "dense"] = "hybrid",
1238
+ ) -> list[dict]:
1239
+ """Thin pass-through, but returns serializable dicts."""
1240
+ if not self.rag:
1241
+ raise RuntimeError("RAG facade not configured in MemoryFacade")
1242
+
1243
+ scope = self.scope
1244
+ s_filters = scope.rag_filter(scope_id=self.memory_scope_id) if scope else {}
1245
+ if filters:
1246
+ s_filters.update(filters)
1247
+ hits = await self.rag.search(corpus_id, query, k=k, filters=s_filters, mode=mode)
1248
+ return [
1249
+ dict(
1250
+ chunk_id=h.chunk_id,
1251
+ doc_id=h.doc_id,
1252
+ corpus_id=h.corpus_id,
1253
+ score=h.score,
1254
+ text=h.text,
1255
+ meta=h.meta,
1256
+ )
1257
+ for h in hits
1258
+ ]
1259
+
1260
+ async def rag_answer(
1261
+ self,
1262
+ *,
1263
+ corpus_id: str,
1264
+ question: str,
1265
+ style: Literal["concise", "detailed"] = "concise",
1266
+ with_citations: bool = True,
1267
+ k: int = 6,
1268
+ ) -> dict:
1269
+ """Answer with citations, then log as a tool_result."""
1270
+ if not self.rag:
1271
+ raise RuntimeError("RAG facade not configured in MemoryFacade")
1272
+ ans = await self.rag.answer(
1273
+ corpus_id=corpus_id,
1274
+ question=question,
1275
+ llm=self.llm,
1276
+ style=style,
1277
+ with_citations=with_citations,
1278
+ k=k,
1279
+ )
1280
+ # Flatten citations into outputs for indices
1281
+ outs = [{"name": "answer", "kind": "text", "value": ans.get("answer", "")}]
1282
+ for i, rc in enumerate(ans.get("resolved_citations", []), start=1):
1283
+ outs.append({"name": f"cite_{i}", "kind": "json", "value": rc})
1284
+ await self.write_result(
1285
+ tool=f"rag.answer.{corpus_id}",
1286
+ outputs=outs,
1287
+ tags=["rag", "qa"],
1288
+ message=f"Q: {question}",
1289
+ metrics=ans.get("usage", {}),
1290
+ severity=2,
1291
+ )
1292
+ return ans
1293
+
1294
+ async def load_last_summary(
1295
+ self,
1296
+ scope_id: str | None = None,
1297
+ *,
1298
+ summary_tag: str = "session",
1299
+ ) -> dict[str, Any] | None:
1300
+ """
1301
+ Load the most recent JSON summary for this memory scope and tag.
1302
+
1303
+ Uses DocStore IDs:
1304
+ mem/{scope_id}/summaries/{summary_tag}/{ts}
1305
+ so it works regardless of persistence backend.
1306
+ """
1307
+ scope_id = scope_id or self.memory_scope_id
1308
+ prefix = _summary_prefix(scope_id, summary_tag)
1309
+
1310
+ try:
1311
+ ids = await self.docs.list()
1312
+ except Exception as e:
1313
+ self.logger and self.logger.warning("load_last_summary: doc_store.list() failed: %s", e)
1314
+ return None
1315
+
1316
+ # Filter and take the latest
1317
+ candidates = [d for d in ids if d.startswith(prefix)]
1318
+ if not candidates:
1319
+ return None
1320
+
1321
+ latest_id = sorted(candidates)[-1]
1322
+ try:
1323
+ return await self.docs.get(latest_id) # type: ignore[return-value]
1324
+ except Exception as e:
1325
+ self.logger and self.logger.warning(
1326
+ "load_last_summary: failed to load %s: %s", latest_id, e
1327
+ )
1328
+ return None
1329
+
1330
+ async def load_recent_summaries(
1331
+ self,
1332
+ scope_id: str | None = None,
1333
+ *,
1334
+ summary_tag: str = "session",
1335
+ limit: int = 3,
1336
+ ) -> list[dict[str, Any]]:
1337
+ """
1338
+ Load up to `limit` most recent JSON summaries for this scope+tag.
1339
+
1340
+ Ordered oldest→newest (so the last item is the most recent).
1341
+ """
1342
+ scope_id = scope_id or self.memory_scope_id
1343
+ prefix = _summary_prefix(scope_id, summary_tag)
1344
+
1345
+ try:
1346
+ ids = await self.docs.list()
1347
+ except Exception as e:
1348
+ self.logger and self.logger.warning(
1349
+ "load_recent_summaries: doc_store.list() failed: %s", e
1350
+ )
1351
+ return []
1352
+
1353
+ candidates = sorted(d for d in ids if d.startswith(prefix))
1354
+ if not candidates:
1355
+ return []
1356
+
1357
+ chosen = candidates[-limit:]
1358
+ out: list[dict[str, Any]] = []
1359
+ for doc_id in chosen:
1360
+ try:
1361
+ doc = await self.docs.get(doc_id)
1362
+ if doc is not None:
1363
+ out.append(doc) # type: ignore[arg-type]
1364
+ except Exception:
1365
+ continue
1366
+ return out
1367
+
1368
+ async def soft_hydrate_last_summary(
1369
+ self,
1370
+ scope_id: str | None = None,
1371
+ *,
1372
+ summary_tag: str = "session",
1373
+ summary_kind: str = "long_term_summary",
1374
+ ) -> dict[str, Any] | None:
1375
+ """
1376
+ Load the last summary JSON for this tag (if any) and log a small hydrate Event
1377
+ into the current run's HotLog. Returns the loaded summary dict, or None.
1378
+ """
1379
+ scope_id = scope_id or self.memory_scope_id
1380
+ summary = await self.load_last_summary(scope_id=scope_id, summary_tag=summary_tag)
1381
+ if not summary:
1382
+ return None
1383
+
1384
+ text = summary.get("text") or ""
1385
+ preview = text[:2000] + (" …[truncated]" if len(text) > 2000 else "")
1386
+
1387
+ evt = Event(
1388
+ scope_id=self.memory_scope_id or self.run_id,
1389
+ event_id=stable_event_id(
1390
+ {
1391
+ "ts": now_iso(),
1392
+ "run_id": self.run_id,
1393
+ "kind": f"{summary_kind}_hydrate",
1394
+ "summary_tag": summary_tag,
1395
+ "preview": preview[:200],
1396
+ }
1397
+ ),
1398
+ ts=now_iso(),
1399
+ run_id=self.run_id,
1400
+ kind=f"{summary_kind}_hydrate",
1401
+ stage="hydrate",
1402
+ text=preview,
1403
+ tags=["summary", "hydrate", summary_tag],
1404
+ data={"summary": summary},
1405
+ metrics={"num_events": summary.get("num_events", 0)},
1406
+ severity=1,
1407
+ signal=0.4,
1408
+ )
1409
+
1410
+ await self.hotlog.append(self.timeline_id, evt, ttl_s=self.hot_ttl_s, limit=self.hot_limit)
1411
+ await self.persistence.append_event(self.timeline_id, evt)
1412
+ return summary
1413
+
1414
+ # ----- Stubs for future memory facade features -----
1415
+ async def mark_event_important(
1416
+ self,
1417
+ event_id: str,
1418
+ *,
1419
+ reason: str | None = None,
1420
+ topic: str | None = None,
1421
+ ) -> None:
1422
+ """
1423
+ Stub / placeholder:
1424
+
1425
+ Mark a given event as "important" / "core_fact" for future policies.
1426
+
1427
+ Intended future behavior (not implemented yet):
1428
+ - Look up the Event by event_id (via Persistence).
1429
+ - Re-emit an updated Event with an added tag (e.g. "core_fact" or "pinned").
1430
+ - Optionally promote to a fact artifact or RAG doc.
1431
+
1432
+ For now, this is a no-op / NotImplementedError to avoid surprise behavior.
1433
+ """
1434
+ raise NotImplementedError("mark_event_important is reserved for future memory policy")
1435
+
1436
+ async def save_core_fact_artifact(
1437
+ self,
1438
+ *,
1439
+ scope_id: str,
1440
+ topic: str,
1441
+ fact_id: str,
1442
+ content: dict[str, Any],
1443
+ ):
1444
+ """
1445
+ Stub / placeholder:
1446
+
1447
+ Save a canonical, long-lived fact as a pinned artifact.
1448
+ Intended future behavior:
1449
+ - Use artifacts.save_json(...) to write the fact payload under a
1450
+ stable path like file://mem/<scope_id>/facts/<topic>/<fact_id>.json
1451
+ - Mark the artifact pinned in the index.
1452
+ - Optionally write a tool_result Event referencing this artifact.
1453
+
1454
+ Not implemented yet; provided as an explicit extension hook.
1455
+ """
1456
+ raise NotImplementedError("save_core_fact_artifact is reserved for future memory policy")
1457
+
1458
+ # ----------- RAG: DX helpers (key-based) -----------
1459
+ async def rag_remember_events(
1460
+ self,
1461
+ *,
1462
+ key: str = "default",
1463
+ where: dict | None = None,
1464
+ policy: dict | None = None,
1465
+ ) -> dict:
1466
+ """
1467
+ High-level: bind a RAG corpus by logical key and promote events into it.
1468
+
1469
+ Example:
1470
+ await mem.rag_remember_events(
1471
+ key="session",
1472
+ where={"kinds": ["tool_result"], "limit": 200},
1473
+ policy={"min_signal": 0.25},
1474
+ )
1475
+ """
1476
+ corpus_id = await self.rag_bind(key=key, create_if_missing=True)
1477
+ return await self.rag_promote_events(
1478
+ corpus_id=corpus_id,
1479
+ events=None,
1480
+ where=where,
1481
+ policy=policy,
1482
+ )
1483
+
1484
+ async def rag_remember_docs(
1485
+ self,
1486
+ docs: Sequence[dict[str, Any]],
1487
+ *,
1488
+ key: str = "default",
1489
+ labels: dict | None = None,
1490
+ ) -> dict[str, Any]:
1491
+ """
1492
+ High-level: bind a RAG corpus by key and upsert docs into it.
1493
+ """
1494
+ corpus_id = await self.rag_bind(key=key, create_if_missing=True, labels=labels)
1495
+ return await self.rag_upsert(corpus_id=corpus_id, docs=list(docs))
1496
+
1497
+ async def rag_search_by_key(
1498
+ self,
1499
+ *,
1500
+ key: str = "default",
1501
+ query: str,
1502
+ k: int = 8,
1503
+ filters: dict | None = None,
1504
+ mode: Literal["hybrid", "dense"] = "hybrid",
1505
+ ) -> list[dict]:
1506
+ """
1507
+ High-level: resolve corpus by logical key and run rag_search() on it.
1508
+ """
1509
+ corpus_id = await self.rag_bind(key=key, create_if_missing=False)
1510
+ return await self.rag_search(
1511
+ corpus_id=corpus_id,
1512
+ query=query,
1513
+ k=k,
1514
+ filters=filters,
1515
+ mode=mode,
1516
+ )
1517
+
1518
+ async def rag_answer_by_key(
1519
+ self,
1520
+ *,
1521
+ key: str = "default",
1522
+ question: str,
1523
+ style: Literal["concise", "detailed"] = "concise",
1524
+ with_citations: bool = True,
1525
+ k: int = 6,
1526
+ ) -> dict:
1527
+ """
1528
+ High-level: RAG QA over a corpus referenced by logical key.
1529
+
1530
+ Internally calls rag_bind(..., create_if_missing=False) and rag_answer().
1531
+ """
1532
+ corpus_id = await self.rag_bind(key=key, create_if_missing=False)
1533
+ return await self.rag_answer(
1534
+ corpus_id=corpus_id,
1535
+ question=question,
1536
+ style=style,
1537
+ with_citations=with_citations,
1538
+ k=k,
1539
+ )