aethergraph 0.1.0a1__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 (182) hide show
  1. aethergraph/__init__.py +49 -0
  2. aethergraph/config/__init__.py +0 -0
  3. aethergraph/config/config.py +121 -0
  4. aethergraph/config/context.py +16 -0
  5. aethergraph/config/llm.py +26 -0
  6. aethergraph/config/loader.py +60 -0
  7. aethergraph/config/runtime.py +9 -0
  8. aethergraph/contracts/errors/errors.py +44 -0
  9. aethergraph/contracts/services/artifacts.py +142 -0
  10. aethergraph/contracts/services/channel.py +72 -0
  11. aethergraph/contracts/services/continuations.py +23 -0
  12. aethergraph/contracts/services/eventbus.py +12 -0
  13. aethergraph/contracts/services/kv.py +24 -0
  14. aethergraph/contracts/services/llm.py +17 -0
  15. aethergraph/contracts/services/mcp.py +22 -0
  16. aethergraph/contracts/services/memory.py +108 -0
  17. aethergraph/contracts/services/resume.py +28 -0
  18. aethergraph/contracts/services/state_stores.py +33 -0
  19. aethergraph/contracts/services/wakeup.py +28 -0
  20. aethergraph/core/execution/base_scheduler.py +77 -0
  21. aethergraph/core/execution/forward_scheduler.py +777 -0
  22. aethergraph/core/execution/global_scheduler.py +634 -0
  23. aethergraph/core/execution/retry_policy.py +22 -0
  24. aethergraph/core/execution/step_forward.py +411 -0
  25. aethergraph/core/execution/step_result.py +18 -0
  26. aethergraph/core/execution/wait_types.py +72 -0
  27. aethergraph/core/graph/graph_builder.py +192 -0
  28. aethergraph/core/graph/graph_fn.py +219 -0
  29. aethergraph/core/graph/graph_io.py +67 -0
  30. aethergraph/core/graph/graph_refs.py +154 -0
  31. aethergraph/core/graph/graph_spec.py +115 -0
  32. aethergraph/core/graph/graph_state.py +59 -0
  33. aethergraph/core/graph/graphify.py +128 -0
  34. aethergraph/core/graph/interpreter.py +145 -0
  35. aethergraph/core/graph/node_handle.py +33 -0
  36. aethergraph/core/graph/node_spec.py +46 -0
  37. aethergraph/core/graph/node_state.py +63 -0
  38. aethergraph/core/graph/task_graph.py +747 -0
  39. aethergraph/core/graph/task_node.py +82 -0
  40. aethergraph/core/graph/utils.py +37 -0
  41. aethergraph/core/graph/visualize.py +239 -0
  42. aethergraph/core/runtime/ad_hoc_context.py +61 -0
  43. aethergraph/core/runtime/base_service.py +153 -0
  44. aethergraph/core/runtime/bind_adapter.py +42 -0
  45. aethergraph/core/runtime/bound_memory.py +69 -0
  46. aethergraph/core/runtime/execution_context.py +220 -0
  47. aethergraph/core/runtime/graph_runner.py +349 -0
  48. aethergraph/core/runtime/lifecycle.py +26 -0
  49. aethergraph/core/runtime/node_context.py +203 -0
  50. aethergraph/core/runtime/node_services.py +30 -0
  51. aethergraph/core/runtime/recovery.py +159 -0
  52. aethergraph/core/runtime/run_registration.py +33 -0
  53. aethergraph/core/runtime/runtime_env.py +157 -0
  54. aethergraph/core/runtime/runtime_registry.py +32 -0
  55. aethergraph/core/runtime/runtime_services.py +224 -0
  56. aethergraph/core/runtime/wakeup_watcher.py +40 -0
  57. aethergraph/core/tools/__init__.py +10 -0
  58. aethergraph/core/tools/builtins/channel_tools.py +194 -0
  59. aethergraph/core/tools/builtins/toolset.py +134 -0
  60. aethergraph/core/tools/toolkit.py +510 -0
  61. aethergraph/core/tools/waitable.py +109 -0
  62. aethergraph/plugins/channel/__init__.py +0 -0
  63. aethergraph/plugins/channel/adapters/__init__.py +0 -0
  64. aethergraph/plugins/channel/adapters/console.py +106 -0
  65. aethergraph/plugins/channel/adapters/file.py +102 -0
  66. aethergraph/plugins/channel/adapters/slack.py +285 -0
  67. aethergraph/plugins/channel/adapters/telegram.py +302 -0
  68. aethergraph/plugins/channel/adapters/webhook.py +104 -0
  69. aethergraph/plugins/channel/adapters/webui.py +134 -0
  70. aethergraph/plugins/channel/routes/__init__.py +0 -0
  71. aethergraph/plugins/channel/routes/console_routes.py +86 -0
  72. aethergraph/plugins/channel/routes/slack_routes.py +49 -0
  73. aethergraph/plugins/channel/routes/telegram_routes.py +26 -0
  74. aethergraph/plugins/channel/routes/webui_routes.py +136 -0
  75. aethergraph/plugins/channel/utils/__init__.py +0 -0
  76. aethergraph/plugins/channel/utils/slack_utils.py +278 -0
  77. aethergraph/plugins/channel/utils/telegram_utils.py +324 -0
  78. aethergraph/plugins/channel/websockets/slack_ws.py +68 -0
  79. aethergraph/plugins/channel/websockets/telegram_polling.py +151 -0
  80. aethergraph/plugins/mcp/fs_server.py +128 -0
  81. aethergraph/plugins/mcp/http_server.py +101 -0
  82. aethergraph/plugins/mcp/ws_server.py +180 -0
  83. aethergraph/plugins/net/http.py +10 -0
  84. aethergraph/plugins/utils/data_io.py +359 -0
  85. aethergraph/runner/__init__.py +5 -0
  86. aethergraph/runtime/__init__.py +62 -0
  87. aethergraph/server/__init__.py +3 -0
  88. aethergraph/server/app_factory.py +84 -0
  89. aethergraph/server/start.py +122 -0
  90. aethergraph/services/__init__.py +10 -0
  91. aethergraph/services/artifacts/facade.py +284 -0
  92. aethergraph/services/artifacts/factory.py +35 -0
  93. aethergraph/services/artifacts/fs_store.py +656 -0
  94. aethergraph/services/artifacts/jsonl_index.py +123 -0
  95. aethergraph/services/artifacts/paths.py +23 -0
  96. aethergraph/services/artifacts/sqlite_index.py +209 -0
  97. aethergraph/services/artifacts/utils.py +124 -0
  98. aethergraph/services/auth/dev.py +16 -0
  99. aethergraph/services/channel/channel_bus.py +293 -0
  100. aethergraph/services/channel/factory.py +44 -0
  101. aethergraph/services/channel/session.py +511 -0
  102. aethergraph/services/channel/wait_helpers.py +57 -0
  103. aethergraph/services/clock/clock.py +9 -0
  104. aethergraph/services/container/default_container.py +320 -0
  105. aethergraph/services/continuations/continuation.py +56 -0
  106. aethergraph/services/continuations/factory.py +34 -0
  107. aethergraph/services/continuations/stores/fs_store.py +264 -0
  108. aethergraph/services/continuations/stores/inmem_store.py +95 -0
  109. aethergraph/services/eventbus/inmem.py +21 -0
  110. aethergraph/services/features/static.py +10 -0
  111. aethergraph/services/kv/ephemeral.py +90 -0
  112. aethergraph/services/kv/factory.py +27 -0
  113. aethergraph/services/kv/layered.py +41 -0
  114. aethergraph/services/kv/sqlite_kv.py +128 -0
  115. aethergraph/services/llm/factory.py +157 -0
  116. aethergraph/services/llm/generic_client.py +542 -0
  117. aethergraph/services/llm/providers.py +3 -0
  118. aethergraph/services/llm/service.py +105 -0
  119. aethergraph/services/logger/base.py +36 -0
  120. aethergraph/services/logger/compat.py +50 -0
  121. aethergraph/services/logger/formatters.py +106 -0
  122. aethergraph/services/logger/std.py +203 -0
  123. aethergraph/services/mcp/helpers.py +23 -0
  124. aethergraph/services/mcp/http_client.py +70 -0
  125. aethergraph/services/mcp/mcp_tools.py +21 -0
  126. aethergraph/services/mcp/registry.py +14 -0
  127. aethergraph/services/mcp/service.py +100 -0
  128. aethergraph/services/mcp/stdio_client.py +70 -0
  129. aethergraph/services/mcp/ws_client.py +115 -0
  130. aethergraph/services/memory/bound.py +106 -0
  131. aethergraph/services/memory/distillers/episode.py +116 -0
  132. aethergraph/services/memory/distillers/rolling.py +74 -0
  133. aethergraph/services/memory/facade.py +633 -0
  134. aethergraph/services/memory/factory.py +78 -0
  135. aethergraph/services/memory/hotlog_kv.py +27 -0
  136. aethergraph/services/memory/indices.py +74 -0
  137. aethergraph/services/memory/io_helpers.py +72 -0
  138. aethergraph/services/memory/persist_fs.py +40 -0
  139. aethergraph/services/memory/resolver.py +152 -0
  140. aethergraph/services/metering/noop.py +4 -0
  141. aethergraph/services/prompts/file_store.py +41 -0
  142. aethergraph/services/rag/chunker.py +29 -0
  143. aethergraph/services/rag/facade.py +593 -0
  144. aethergraph/services/rag/index/base.py +27 -0
  145. aethergraph/services/rag/index/faiss_index.py +121 -0
  146. aethergraph/services/rag/index/sqlite_index.py +134 -0
  147. aethergraph/services/rag/index_factory.py +52 -0
  148. aethergraph/services/rag/parsers/md.py +7 -0
  149. aethergraph/services/rag/parsers/pdf.py +14 -0
  150. aethergraph/services/rag/parsers/txt.py +7 -0
  151. aethergraph/services/rag/utils/hybrid.py +39 -0
  152. aethergraph/services/rag/utils/make_fs_key.py +62 -0
  153. aethergraph/services/redactor/simple.py +16 -0
  154. aethergraph/services/registry/key_parsing.py +44 -0
  155. aethergraph/services/registry/registry_key.py +19 -0
  156. aethergraph/services/registry/unified_registry.py +185 -0
  157. aethergraph/services/resume/multi_scheduler_resume_bus.py +65 -0
  158. aethergraph/services/resume/router.py +73 -0
  159. aethergraph/services/schedulers/registry.py +41 -0
  160. aethergraph/services/secrets/base.py +7 -0
  161. aethergraph/services/secrets/env.py +8 -0
  162. aethergraph/services/state_stores/externalize.py +135 -0
  163. aethergraph/services/state_stores/graph_observer.py +131 -0
  164. aethergraph/services/state_stores/json_store.py +67 -0
  165. aethergraph/services/state_stores/resume_policy.py +119 -0
  166. aethergraph/services/state_stores/serialize.py +249 -0
  167. aethergraph/services/state_stores/utils.py +91 -0
  168. aethergraph/services/state_stores/validate.py +78 -0
  169. aethergraph/services/tracing/noop.py +18 -0
  170. aethergraph/services/waits/wait_registry.py +91 -0
  171. aethergraph/services/wakeup/memory_queue.py +57 -0
  172. aethergraph/services/wakeup/scanner_producer.py +56 -0
  173. aethergraph/services/wakeup/worker.py +31 -0
  174. aethergraph/tools/__init__.py +25 -0
  175. aethergraph/utils/optdeps.py +8 -0
  176. aethergraph-0.1.0a1.dist-info/METADATA +410 -0
  177. aethergraph-0.1.0a1.dist-info/RECORD +182 -0
  178. aethergraph-0.1.0a1.dist-info/WHEEL +5 -0
  179. aethergraph-0.1.0a1.dist-info/entry_points.txt +2 -0
  180. aethergraph-0.1.0a1.dist-info/licenses/LICENSE +176 -0
  181. aethergraph-0.1.0a1.dist-info/licenses/NOTICE +31 -0
  182. aethergraph-0.1.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ from typing import Any
7
+
8
+ import websockets
9
+ from websockets.client import WebSocketClientProtocol
10
+
11
+ from aethergraph.contracts.services.mcp import MCPClientProtocol, MCPResource, MCPTool
12
+
13
+
14
+ class WsMCPClient(MCPClientProtocol):
15
+ def __init__(
16
+ self,
17
+ url: str,
18
+ *,
19
+ headers: dict[str, str] | None = None,
20
+ timeout: float = 60.0,
21
+ ping_interval: float = 20.0,
22
+ ping_timeout: float = 10.0,
23
+ ):
24
+ self.url = url
25
+ self.headers = headers or {}
26
+ self.timeout = timeout
27
+ self.ping_interval = ping_interval
28
+ self.ping_timeout = ping_timeout
29
+
30
+ self._ws: WebSocketClientProtocol | None = None
31
+ self._id = 0
32
+ self._lock = asyncio.Lock()
33
+ self._ping_task: asyncio.Task | None = None
34
+
35
+ async def open(self):
36
+ if self._ws and not self._ws.closed:
37
+ return
38
+ try:
39
+ # websockets >=14
40
+ self._ws = await websockets.connect(
41
+ self.url, additional_headers=self.headers, open_timeout=self.timeout
42
+ )
43
+ except TypeError:
44
+ # likely on websockets <=13
45
+ self._ws = await websockets.connect(
46
+ self.url, extra_headers=self.headers, open_timeout=self.timeout
47
+ )
48
+
49
+ self._start_ping()
50
+
51
+ async def close(self):
52
+ if self._ping_task:
53
+ self._ping_task.cancel()
54
+ with contextlib.suppress(Exception):
55
+ await self._ping_task
56
+ self._ping_task = None
57
+ if self._ws and not self._ws.closed:
58
+ await self._ws.close()
59
+ self._ws = None
60
+
61
+ def _start_ping(self):
62
+ if self.ping_interval <= 0:
63
+ return
64
+
65
+ async def _pinger():
66
+ try:
67
+ while self._ws and not self._ws.closed:
68
+ await asyncio.sleep(self.ping_interval)
69
+ if not self._ws or self._ws.closed:
70
+ break
71
+ try:
72
+ await asyncio.wait_for(self._ws.ping(), timeout=self.ping_timeout) # type: ignore
73
+ except Exception:
74
+ break
75
+ except asyncio.CancelledError:
76
+ pass
77
+
78
+ self._ping_task = asyncio.create_task(_pinger())
79
+
80
+ async def _ensure(self):
81
+ if self._ws is None or self._ws.closed:
82
+ await self.open()
83
+
84
+ async def _rpc(self, method: str, params: dict[str, Any] | None = None) -> Any:
85
+ await self._ensure()
86
+ async with self._lock:
87
+ self._id += 1
88
+ req = {"jsonrpc": "2.0", "id": self._id, "method": method, "params": params or {}}
89
+ data = json.dumps(req)
90
+ try:
91
+ assert self._ws is not None
92
+ await asyncio.wait_for(self._ws.send(data), timeout=self.timeout)
93
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=self.timeout)
94
+ except Exception:
95
+ await self.close()
96
+ await self.open()
97
+ assert self._ws is not None
98
+ await asyncio.wait_for(self._ws.send(data), timeout=self.timeout)
99
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=self.timeout)
100
+ resp = json.loads(raw)
101
+ if "error" in resp:
102
+ raise RuntimeError(str(resp["error"]))
103
+ return resp.get("result")
104
+
105
+ async def list_tools(self) -> list[MCPTool]:
106
+ return await self._rpc("tools/list")
107
+
108
+ async def call(self, tool: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
109
+ return await self._rpc("tools/call", {"name": tool, "arguments": params or {}})
110
+
111
+ async def list_resources(self) -> list[MCPResource]:
112
+ return await self._rpc("resources/list")
113
+
114
+ async def read_resource(self, uri: str) -> dict[str, Any]:
115
+ return await self._rpc("resources/read", {"uri": uri})
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from aethergraph.contracts.services.memory import Event
7
+ from aethergraph.services.memory.facade import MemoryFacade
8
+ from aethergraph.services.memory.resolver import ResolverContext, resolve_params
9
+
10
+ Value = dict[str, Any]
11
+
12
+
13
+ @dataclass
14
+ class BoundMemory:
15
+ mem: MemoryFacade
16
+ defaults: dict[str, Any] = (
17
+ None # run_id, graph_id, node_id, agent_id (usually injected by NodeContext)
18
+ )
19
+
20
+ async def record(
21
+ self,
22
+ *,
23
+ kind: str,
24
+ text: str | None = None,
25
+ severity: int = 2,
26
+ stage: str | None = None,
27
+ tags: list[str] | None = None,
28
+ entities: list[str] | None = None,
29
+ metrics: dict[str, Any] | None = None,
30
+ inputs_ref: dict[str, Any] | None = None,
31
+ outputs_ref: dict[str, Any] | None = None,
32
+ sources: list[str] | None = None,
33
+ signal: float | None = None,
34
+ ) -> Event:
35
+ base = {
36
+ **(self.defaults or {}),
37
+ "kind": kind,
38
+ "stage": stage,
39
+ "severity": severity,
40
+ "tags": tags or [],
41
+ "entities": entities or [],
42
+ "inputs_ref": inputs_ref,
43
+ "outputs_ref": outputs_ref,
44
+ "sources": sources,
45
+ "signal": float(
46
+ signal
47
+ if signal is not None
48
+ else self._estimate_signal(text=text, metrics=metrics, severity=severity)
49
+ ),
50
+ }
51
+ return await self.mem.record_raw(base=base, text=text, metrics=metrics)
52
+
53
+ async def user(self, text: str):
54
+ return await self.record(kind="user_msg", text=text, stage="observe")
55
+
56
+ async def assistant(self, text: str):
57
+ return await self.record(kind="assistant_msg", text=text, stage="act")
58
+
59
+ async def tool_start(self, note=None):
60
+ return await self.record(kind="tool_start", text=note, stage="act")
61
+
62
+ async def tool_ok(self, note=None, metrics=None):
63
+ return await self.record(
64
+ kind="tool_result", text=note, metrics=metrics, stage="observe", severity=3
65
+ )
66
+
67
+ async def tool_error(self, err: Exception):
68
+ return await self.record(
69
+ kind="error", text=f"{type(err).__name__}: {err}", severity=5, stage="observe"
70
+ )
71
+
72
+ async def write_result(
73
+ self,
74
+ *,
75
+ topic: str,
76
+ inputs: list[Value] | None = None,
77
+ outputs: list[Value] | None = None,
78
+ tags: list[str] | None = None,
79
+ metrics: dict[str, float] | None = None,
80
+ message: str | None = None,
81
+ severity: int = 3,
82
+ ) -> Event:
83
+ return await self.mem.write_result(
84
+ topic=topic,
85
+ inputs=inputs or [],
86
+ outputs=outputs or [],
87
+ tags=tags,
88
+ metrics=metrics,
89
+ message=message,
90
+ severity=severity,
91
+ )
92
+
93
+ async def resolve(self, params: dict[str, Any]) -> dict[str, Any]:
94
+ rctx = ResolverContext(mem=self.mem)
95
+ return await resolve_params(params, rctx)
96
+
97
+ # ---- helper ----
98
+ def _estimate_signal(
99
+ self, *, text: str | None, metrics: dict[str, Any] | None, severity: int
100
+ ) -> float:
101
+ score = 0.15 + 0.1 * severity
102
+ if text:
103
+ score += min(len(text) / 400.0, 0.4)
104
+ if metrics:
105
+ score += 0.2
106
+ return max(0.0, min(1.0, score))
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import time
6
+ from typing import Any
7
+
8
+ from aethergraph.contracts.services.memory import Distiller, Event, HotLog, Indices, Persistence
9
+
10
+
11
+ def _now_iso() -> str:
12
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
13
+
14
+
15
+ def _stable_event_id(parts: dict[str, Any]) -> str:
16
+ blob = json.dumps(parts, sort_keys=True, ensure_ascii=False).encode("utf-8")
17
+ return hashlib.sha256(blob).hexdigest()[:24]
18
+
19
+
20
+ def _episode_uri(sessiorun_idn_id: str, tool: str, run_id: str) -> str:
21
+ safe = tool.replace("/", "_")
22
+ return f"file://mem/{run_id}/episodes/{safe}/{run_id}.json"
23
+
24
+
25
+ class EpisodeSummarizer(Distiller):
26
+ """
27
+ Aggregate all events for (tool, run_id) into a compact episode summary:
28
+ - sources: event_ids
29
+ - merged metrics (last-write-wins)
30
+ - notes: last N textual notes
31
+ Writes a JSON summary artifact and emits a lightweight run_summary event.
32
+ """
33
+
34
+ def __init__(
35
+ self, *, include_metrics: bool = True, note_limit: int = 8, note_chars: int = 2000
36
+ ):
37
+ self.include_metrics = include_metrics
38
+ self.note_limit = note_limit
39
+ self.note_chars = note_chars
40
+
41
+ async def distill(
42
+ self,
43
+ run_id: str,
44
+ *,
45
+ hotlog: HotLog,
46
+ persistence: Persistence,
47
+ indices: Indices,
48
+ tool: str,
49
+ **kw,
50
+ ) -> dict[str, Any]:
51
+ # Pull a reasonable window from hot memory; filter in-process.
52
+ # (If needed later, add a Persistence scan by day.)
53
+ events = await hotlog.recent(
54
+ run_id, kinds=["tool_start", "tool_result", "error", "run_summary"], limit=400
55
+ )
56
+
57
+ eps = [e for e in events if e.run_id == run_id and (e.tool or "") == tool]
58
+ if not eps:
59
+ return {}
60
+
61
+ srcs: list[str] = []
62
+ notes: list[str] = []
63
+ metrics: dict[str, float] = {}
64
+
65
+ for e in eps:
66
+ if e.event_id:
67
+ srcs.append(e.event_id)
68
+ if self.include_metrics and e.metrics:
69
+ metrics.update(e.metrics) # simple merge; last-write-wins
70
+ if e.text:
71
+ notes.append(e.text)
72
+
73
+ ts = _now_iso()
74
+ summary = {
75
+ "kind": "episode_summary",
76
+ "run_id": run_id,
77
+ "tool": tool,
78
+ "ts": ts,
79
+ "sources": srcs,
80
+ "metrics": metrics,
81
+ "notes": notes[-self.note_limit :],
82
+ }
83
+
84
+ uri = _episode_uri(run_id, tool, run_id)
85
+ await persistence.save_json(uri, summary)
86
+
87
+ # Emit a compact run_summary event
88
+ compact_text = "\n".join(summary["notes"][-self.note_limit :])[: self.note_chars]
89
+ evt_base = {
90
+ "run_id": run_id,
91
+ "tool": tool,
92
+ "kind": "run_summary",
93
+ "severity": 1,
94
+ "tags": ["summary", "episode"],
95
+ }
96
+ eid = _stable_event_id(
97
+ {
98
+ "ts": ts,
99
+ "run_id": run_id,
100
+ "tool": tool,
101
+ "kind": "run_summary",
102
+ "text": compact_text[:200],
103
+ }
104
+ )
105
+ evt = Event(
106
+ event_id=eid,
107
+ ts=ts,
108
+ text=compact_text,
109
+ metrics={"notes": len(notes)},
110
+ signal=0.5,
111
+ **evt_base,
112
+ )
113
+ await hotlog.append(run_id, evt, ttl_s=7 * 24 * 3600, limit=1000)
114
+ await persistence.append_event(run_id, evt)
115
+
116
+ return {"uri": uri, "sources": srcs, "metrics": metrics}
@@ -0,0 +1,74 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ from aethergraph.contracts.services.memory import Distiller, Event, HotLog, Indices, Persistence
5
+
6
+
7
+ def _now_iso():
8
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
9
+
10
+
11
+ def ar_summary_uri(run_id: str, tag: str, ts: str) -> str:
12
+ # Save summaries under the same base "mem/<run_id>/..." tree as append_event,
13
+ # but using a file:// URI so FSPersistence can handle it.
14
+ safe_ts = ts.replace(":", "-")
15
+ return f"file://mem/{run_id}/summaries/{tag}/{safe_ts}.json"
16
+
17
+
18
+ class RollingSummarizer(Distiller):
19
+ def __init__(
20
+ self, *, max_turns: int = 20, min_signal: float = 0.25, turn_kinds: list[str] | None = None
21
+ ):
22
+ self.max_turns = max_turns
23
+ self.min_signal = min_signal
24
+ self.turn_kinds = turn_kinds or ["user_msg", "assistant_msg"]
25
+
26
+ async def distill(
27
+ self, run_id: str, *, hotlog: HotLog, persistence: Persistence, indices: Indices, **kw
28
+ ) -> dict[str, Any]:
29
+ turns = await hotlog.recent(run_id, kinds=self.turn_kinds, limit=self.max_turns * 2)
30
+ kept = [t for t in turns if (t.signal or 0.0) >= self.min_signal]
31
+ if not kept:
32
+ return {}
33
+
34
+ lines = []
35
+ srcs: list[str] = []
36
+ for t in kept[-self.max_turns :]:
37
+ role = "User" if t.kind == "user_msg" else "Assistant"
38
+ if t.text:
39
+ lines.append(f"{role}: {t.text}")
40
+ srcs.append(t.event_id)
41
+ digest_text = "\n".join(lines)
42
+ ts = _now_iso()
43
+ summary = {
44
+ "kind": "rolling_summary",
45
+ "run_id": run_id,
46
+ "ts": ts,
47
+ "sources": srcs,
48
+ "text": digest_text,
49
+ }
50
+
51
+ uri = ar_summary_uri(run_id, "rolling", ts)
52
+
53
+ await persistence.save_json(uri=uri, obj=summary)
54
+
55
+ evt = Event(
56
+ event_id="",
57
+ ts=ts,
58
+ run_id=run_id,
59
+ kind="rolling_summary",
60
+ severity=1,
61
+ signal=0.5,
62
+ text=digest_text,
63
+ metrics={"num_turns": len(kept)},
64
+ tags=["summary"],
65
+ )
66
+
67
+ from aethergraph.services.memory.facade import stable_event_id
68
+
69
+ evt.event_id = stable_event_id(
70
+ {"ts": ts, "run_id": run_id, "kind": "rolling_summary", "text": digest_text[:200]}
71
+ )
72
+ await hotlog.append(run_id, evt, ttl_s=7 * 24 * 3600, limit=1000)
73
+ await persistence.append_event(run_id, evt)
74
+ return {"uri": uri, "sources": srcs}