aethergraph 0.1.0a2__py3-none-any.whl → 0.1.0a4__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 (114) hide show
  1. aethergraph/__main__.py +3 -0
  2. aethergraph/api/v1/artifacts.py +23 -4
  3. aethergraph/api/v1/schemas.py +7 -0
  4. aethergraph/api/v1/session.py +123 -4
  5. aethergraph/config/config.py +2 -0
  6. aethergraph/config/search.py +49 -0
  7. aethergraph/contracts/services/channel.py +18 -1
  8. aethergraph/contracts/services/execution.py +58 -0
  9. aethergraph/contracts/services/llm.py +26 -0
  10. aethergraph/contracts/services/memory.py +10 -4
  11. aethergraph/contracts/services/planning.py +53 -0
  12. aethergraph/contracts/storage/event_log.py +8 -0
  13. aethergraph/contracts/storage/search_backend.py +47 -0
  14. aethergraph/contracts/storage/vector_index.py +73 -0
  15. aethergraph/core/graph/action_spec.py +76 -0
  16. aethergraph/core/graph/graph_fn.py +75 -2
  17. aethergraph/core/graph/graphify.py +74 -2
  18. aethergraph/core/runtime/graph_runner.py +2 -1
  19. aethergraph/core/runtime/node_context.py +66 -3
  20. aethergraph/core/runtime/node_services.py +8 -0
  21. aethergraph/core/runtime/run_manager.py +263 -271
  22. aethergraph/core/runtime/run_types.py +54 -1
  23. aethergraph/core/runtime/runtime_env.py +35 -14
  24. aethergraph/core/runtime/runtime_services.py +308 -18
  25. aethergraph/plugins/agents/default_chat_agent.py +266 -74
  26. aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
  27. aethergraph/plugins/channel/adapters/webui.py +69 -21
  28. aethergraph/plugins/channel/routes/webui_routes.py +8 -48
  29. aethergraph/runtime/__init__.py +12 -0
  30. aethergraph/server/app_factory.py +10 -1
  31. aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
  32. aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
  33. aethergraph/server/ui_static/index.html +2 -2
  34. aethergraph/services/artifacts/facade.py +157 -21
  35. aethergraph/services/artifacts/types.py +35 -0
  36. aethergraph/services/artifacts/utils.py +42 -0
  37. aethergraph/services/channel/channel_bus.py +3 -1
  38. aethergraph/services/channel/event_hub copy.py +55 -0
  39. aethergraph/services/channel/event_hub.py +81 -0
  40. aethergraph/services/channel/factory.py +3 -2
  41. aethergraph/services/channel/session.py +709 -74
  42. aethergraph/services/container/default_container.py +69 -7
  43. aethergraph/services/execution/__init__.py +0 -0
  44. aethergraph/services/execution/local_python.py +118 -0
  45. aethergraph/services/indices/__init__.py +0 -0
  46. aethergraph/services/indices/global_indices.py +21 -0
  47. aethergraph/services/indices/scoped_indices.py +292 -0
  48. aethergraph/services/llm/generic_client.py +342 -46
  49. aethergraph/services/llm/generic_embed_client.py +359 -0
  50. aethergraph/services/llm/types.py +3 -1
  51. aethergraph/services/memory/distillers/llm_long_term.py +60 -109
  52. aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
  53. aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
  54. aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
  55. aethergraph/services/memory/distillers/long_term.py +48 -131
  56. aethergraph/services/memory/distillers/long_term_v1.py +170 -0
  57. aethergraph/services/memory/facade/chat.py +18 -8
  58. aethergraph/services/memory/facade/core.py +159 -19
  59. aethergraph/services/memory/facade/distillation.py +86 -31
  60. aethergraph/services/memory/facade/retrieval.py +100 -1
  61. aethergraph/services/memory/factory.py +4 -1
  62. aethergraph/services/planning/__init__.py +0 -0
  63. aethergraph/services/planning/action_catalog.py +271 -0
  64. aethergraph/services/planning/bindings.py +56 -0
  65. aethergraph/services/planning/dependency_index.py +65 -0
  66. aethergraph/services/planning/flow_validator.py +263 -0
  67. aethergraph/services/planning/graph_io_adapter.py +150 -0
  68. aethergraph/services/planning/input_parser.py +312 -0
  69. aethergraph/services/planning/missing_inputs.py +28 -0
  70. aethergraph/services/planning/node_planner.py +613 -0
  71. aethergraph/services/planning/orchestrator.py +112 -0
  72. aethergraph/services/planning/plan_executor.py +506 -0
  73. aethergraph/services/planning/plan_types.py +321 -0
  74. aethergraph/services/planning/planner.py +617 -0
  75. aethergraph/services/planning/planner_service.py +369 -0
  76. aethergraph/services/planning/planning_context_builder.py +43 -0
  77. aethergraph/services/planning/quick_actions.py +29 -0
  78. aethergraph/services/planning/routers/__init__.py +0 -0
  79. aethergraph/services/planning/routers/simple_router.py +26 -0
  80. aethergraph/services/rag/facade.py +0 -3
  81. aethergraph/services/scope/scope.py +30 -30
  82. aethergraph/services/scope/scope_factory.py +15 -7
  83. aethergraph/services/skills/__init__.py +0 -0
  84. aethergraph/services/skills/skill_registry.py +465 -0
  85. aethergraph/services/skills/skills.py +220 -0
  86. aethergraph/services/skills/utils.py +194 -0
  87. aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
  88. aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
  89. aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
  90. aethergraph/storage/memory/event_persist.py +42 -2
  91. aethergraph/storage/memory/fs_persist.py +32 -2
  92. aethergraph/storage/search_backend/__init__.py +0 -0
  93. aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
  94. aethergraph/storage/search_backend/null_backend.py +34 -0
  95. aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
  96. aethergraph/storage/search_backend/utils.py +31 -0
  97. aethergraph/storage/search_factory.py +75 -0
  98. aethergraph/storage/vector_index/faiss_index.py +72 -4
  99. aethergraph/storage/vector_index/sqlite_index.py +521 -52
  100. aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
  101. aethergraph/storage/vector_index/utils.py +22 -0
  102. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
  103. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +108 -64
  104. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
  105. aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
  106. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
  107. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
  108. aethergraph/services/eventhub/event_hub.py +0 -76
  109. aethergraph/services/llm/generic_client copy.py +0 -691
  110. aethergraph/services/prompts/file_store.py +0 -41
  111. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
  112. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
  113. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
  114. {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ import time
5
+ from typing import Any
6
+
7
+ from aethergraph.contracts.services.memory import Distiller, Event, HotLog
8
+
9
+ # re-use stable_event_id from the MemoryFacade module
10
+ from aethergraph.contracts.storage.doc_store import DocStore
11
+ from aethergraph.services.memory.utils import _summary_doc_id
12
+
13
+
14
+ def _now_iso() -> str:
15
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
16
+
17
+
18
+ def ar_summary_uri_by_run_id(run_id: str, tag: str, ts: str) -> str:
19
+ """
20
+ NOTE: To deprecate this function in favor of ar_summary_uri below.
21
+
22
+ Save summaries under the same base "mem/<run_id>/..." tree as append_event,
23
+ but using a file:// URI so FSPersistence can handle it.
24
+ """
25
+ safe_ts = ts.replace(":", "-")
26
+ return f"file://mem/{run_id}/summaries/{tag}/{safe_ts}.json"
27
+
28
+
29
+ def ar_summary_uri(scope_id: str, tag: str, ts: str) -> str:
30
+ """
31
+ Scope summaries by a logical memory scope, not by run_id.
32
+ In simple setups, scope_id == run_id. For long-lived companions, scope_id
33
+ might be something like "user:zcliu:persona:companion_v1".
34
+ """
35
+ safe_ts = ts.replace(":", "-")
36
+ return f"file://mem/{scope_id}/summaries/{tag}/{safe_ts}.json"
37
+
38
+
39
+ class LongTermSummarizer(Distiller):
40
+ """
41
+ Generic long-term summarizer.
42
+
43
+ Goal:
44
+ - Take a slice of recent events (by kind and/or tag).
45
+ - Build a compact textual digest plus small structured metadata.
46
+ - Persist the summary as JSON via Persistence.save_json(...).
47
+ - Emit a summary Event with kind=summary_kind and data["summary_uri"].
48
+
49
+ This does NOT call an LLM by itself; it's a structural/logical summarizer.
50
+ An LLM-based distiller can be layered on top later (using the same URI scheme).
51
+
52
+ Typical usage:
53
+ - Kinds: ["chat_user", "chat_assistant"] or app-specific kinds.
54
+ - Tag: "session", "daily", "episode:<id>", etc.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ summary_kind: str = "long_term_summary",
61
+ summary_tag: str = "session",
62
+ include_kinds: list[str] | None = None,
63
+ include_tags: list[str] | None = None,
64
+ max_events: int = 200,
65
+ min_signal: float = 0.0,
66
+ ):
67
+ self.summary_kind = summary_kind
68
+ self.summary_tag = summary_tag
69
+ self.include_kinds = include_kinds
70
+ self.include_tags = include_tags
71
+ self.max_events = max_events
72
+ self.min_signal = min_signal
73
+
74
+ def _filter_events(self, events: Iterable[Event]) -> list[Event]:
75
+ out: list[Event] = []
76
+ kinds = set(self.include_kinds) if self.include_kinds else None
77
+ tags = set(self.include_tags) if self.include_tags else None
78
+
79
+ for e in events:
80
+ if kinds is not None and e.kind not in kinds:
81
+ continue
82
+ if tags is not None:
83
+ if not e.tags:
84
+ continue
85
+ if not tags.issubset(set(e.tags)):
86
+ continue
87
+ if (e.signal or 0.0) < self.min_signal:
88
+ continue
89
+ out.append(e)
90
+ return out
91
+
92
+ async def distill(
93
+ self,
94
+ run_id: str,
95
+ timeline_id: str,
96
+ scope_id: str = None,
97
+ *,
98
+ hotlog: HotLog,
99
+ docs: DocStore,
100
+ **kw: Any,
101
+ ) -> dict[str, Any]:
102
+ """
103
+ Steps:
104
+ 1) Grab recent events from HotLog for this run.
105
+ 2) Filter by kinds/tags/min_signal.
106
+ 3) Build a digest:
107
+ - simple text transcript (role: text)
108
+ - metadata: ts range, num events
109
+ 4) Save JSON summary via DocStore.put(...).
110
+ 5) Log a summary Event to hotlog + persistence, with data.summary_uri.
111
+ """
112
+ # 1) fetch more than we might keep to give filter some slack
113
+ raw = await hotlog.recent(timeline_id, kinds=None, limit=self.max_events * 2)
114
+ kept = self._filter_events(raw)
115
+ if not kept:
116
+ return {}
117
+
118
+ # keep only max_events most recent
119
+ kept = kept[-self.max_events :]
120
+
121
+ # 2) Build digest text (simple transcript-like format)
122
+ lines: list[str] = []
123
+ src_ids: list[str] = []
124
+ first_ts = kept[0].ts
125
+ last_ts = kept[-1].ts
126
+
127
+ for e in kept:
128
+ role = e.stage or e.kind or "event"
129
+ if e.text:
130
+ lines.append(f"[{role}] {e.text}")
131
+ src_ids.append(e.event_id)
132
+
133
+ digest_text = "\n".join(lines)
134
+ ts = _now_iso()
135
+
136
+ # 3) Summary JSON shape
137
+ summary = {
138
+ "type": self.summary_kind,
139
+ "version": 1,
140
+ "run_id": run_id,
141
+ "scope_id": scope_id or run_id,
142
+ "summary_tag": self.summary_tag,
143
+ "ts": ts,
144
+ "time_window": {
145
+ "from": first_ts,
146
+ "to": last_ts,
147
+ },
148
+ "num_events": len(kept),
149
+ "source_event_ids": src_ids,
150
+ "text": digest_text,
151
+ }
152
+
153
+ # 4) Persist JSON summary via DocStore
154
+ scope = scope_id or run_id
155
+ doc_id = _summary_doc_id(scope, self.summary_tag, ts)
156
+ await docs.put(doc_id, summary)
157
+
158
+ # 5) Emit summary Event
159
+ # NOTE: we only store a preview in text and full summary in data["summary_uri"]
160
+ preview = digest_text[:2000] + (" …[truncated]" if len(digest_text) > 2000 else "")
161
+
162
+ return {
163
+ "summary_doc_id": doc_id,
164
+ "summary_kind": self.summary_kind,
165
+ "summary_tag": self.summary_tag,
166
+ "time_window": summary["time_window"],
167
+ "num_events": len(kept),
168
+ "preview": preview,
169
+ "ts": ts,
170
+ }
@@ -71,6 +71,7 @@ class ChatMixin:
71
71
  Event: The fully persisted `Event` object containing the generated ID and timestamp.
72
72
  """
73
73
  extra_tags = ["chat"]
74
+
74
75
  if tags:
75
76
  extra_tags.extend(tags)
76
77
  payload: dict[str, Any] = {"role": role, "text": text}
@@ -252,6 +253,7 @@ class ChatMixin:
252
253
  *,
253
254
  limit: int = 50,
254
255
  roles: Sequence[str] | None = None,
256
+ tags: Sequence[str] | None = None,
255
257
  ) -> list[dict[str, Any]]:
256
258
  """
257
259
  Retrieve the most recent chat turns as a normalized list.
@@ -259,7 +261,9 @@ class ChatMixin:
259
261
  This method fetches the last `limit` chat events of type `chat.turn`
260
262
  and returns them in a standardized format. Each item in the returned
261
263
  list contains the timestamp, role, text, and tags associated with the
262
- chat event.
264
+ chat event. If `tags` is provided, over-fetch and filter because HotLog doesn't filter by tags.
265
+ Returned messages are chronological, with the most recent last.
266
+
263
267
 
264
268
  Examples:
265
269
  Fetch the last 10 chat turns:
@@ -286,23 +290,28 @@ class ChatMixin:
286
290
  - "text": The text content of the chat message.
287
291
  - "tags": A list of tags associated with the event.
288
292
  """
289
- events = await self.recent(kinds=["chat.turn"], limit=limit)
293
+ fetch_n = limit
294
+ if tags:
295
+ fetch_n = max(limit * 5, 100)
296
+
297
+ events = await self.recent(kinds=["chat.turn"], limit=fetch_n)
298
+
299
+ want = set(tags or [])
290
300
  out: list[dict[str, Any]] = []
291
301
 
292
302
  for e in events:
293
- # 1) Resolve role (from stage or data)
303
+ etags = set(e.tags or [])
304
+ if want and not want.issubset(etags):
305
+ continue
306
+
294
307
  role = (
295
308
  getattr(e, "stage", None)
296
309
  or ((e.data or {}).get("role") if getattr(e, "data", None) else None)
297
310
  or "user"
298
311
  )
299
-
300
312
  if roles is not None and role not in roles:
301
313
  continue
302
314
 
303
- # 2) Resolve text:
304
- # - prefer Event.text
305
- # - fall back to data["text"]
306
315
  raw_text = getattr(e, "text", "") or ""
307
316
  if not raw_text and getattr(e, "data", None):
308
317
  raw_text = (e.data or {}).get("text", "") or ""
@@ -316,7 +325,8 @@ class ChatMixin:
316
325
  }
317
326
  )
318
327
 
319
- return out
328
+ # IMPORTANT: keep the most recent `limit` messages
329
+ return out[-limit:] if limit else []
320
330
 
321
331
  async def chat_history_for_llm(
322
332
  self: MemoryFacadeInterface,
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import logging
5
+ import time
5
6
  from typing import Any
6
7
 
7
8
  from aethergraph.contracts.services.llm import LLMClientProtocol
@@ -9,8 +10,10 @@ from aethergraph.contracts.services.memory import Event, HotLog, Indices, Persis
9
10
  from aethergraph.contracts.storage.artifact_store import AsyncArtifactStore
10
11
  from aethergraph.contracts.storage.doc_store import DocStore
11
12
  from aethergraph.core.runtime.runtime_metering import current_metering
13
+ from aethergraph.services.indices.scoped_indices import ScopedIndices
12
14
  from aethergraph.services.rag.facade import RAGFacade
13
15
  from aethergraph.services.scope.scope import Scope
16
+ from aethergraph.storage.vector_index.utils import build_index_meta_from_scope
14
17
 
15
18
  from .chat import ChatMixin
16
19
  from .distillation import DistillationMixin
@@ -20,6 +23,57 @@ from .retrieval import RetrievalMixin
20
23
  from .utils import now_iso, stable_event_id
21
24
 
22
25
 
26
+ def _normalize_tags(tags: list[str] | None) -> list[str]:
27
+ """
28
+ Normalize a list of tags by stripping whitespace, removing empties,
29
+ and deduplicating while preserving order.
30
+ """
31
+ if not tags:
32
+ return []
33
+ seen: set[str] = set()
34
+ out: list[str] = []
35
+ for t in tags:
36
+ if not t:
37
+ continue
38
+ tt = t.strip()
39
+ if not tt or tt in seen:
40
+ continue
41
+ seen.add(tt)
42
+ out.append(tt)
43
+ return out
44
+
45
+
46
+ def derive_timeline_id(
47
+ *,
48
+ memory_scope_id: str | None,
49
+ run_id: str,
50
+ org_id: str | None = None,
51
+ sep: str = "|",
52
+ ) -> str:
53
+ """
54
+ Derive the storage partition key for timeline events.
55
+
56
+ - If org_id is present, prefix with `org:{org_id}|...` to prevent cross-org mixing.
57
+ - Keep fallback behavior: if memory_scope_id is missing, fall back to run_id.
58
+ - Avoid redundant `org:{org_id}|org:{org_id}` when the bucket is already org-scoped.
59
+ """
60
+ bucket = (memory_scope_id or "").strip()
61
+ if not bucket:
62
+ bucket = run_id
63
+
64
+ if org_id:
65
+ org_prefix = f"org:{org_id}"
66
+
67
+ # If the bucket is already exactly org-scoped, don't double-prefix
68
+ if bucket == org_prefix:
69
+ return org_prefix
70
+
71
+ return f"{org_prefix}{sep}{bucket}"
72
+
73
+ # No org context -> behave like current local mode
74
+ return bucket
75
+
76
+
23
77
  class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RAGMixin):
24
78
  """
25
79
  MemoryFacade coordinates core memory services for a specific run/session.
@@ -36,7 +90,8 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
36
90
  scope: Scope | None = None,
37
91
  hotlog: HotLog,
38
92
  persistence: Persistence,
39
- indices: Indices,
93
+ mem_indices: Indices,
94
+ scoped_indices: ScopedIndices | None = None,
40
95
  docs: DocStore,
41
96
  artifact_store: AsyncArtifactStore,
42
97
  hot_limit: int = 1000,
@@ -53,7 +108,8 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
53
108
  self.scope = scope
54
109
  self.hotlog = hotlog
55
110
  self.persistence = persistence
56
- self.indices = indices
111
+ self.indices = mem_indices
112
+ self.scoped_indices = scoped_indices
57
113
  self.docs = docs
58
114
  self.artifacts = artifact_store
59
115
  self.hot_limit = hot_limit
@@ -66,7 +122,11 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
66
122
  self.memory_scope_id = (
67
123
  self.scope.memory_scope_id() if self.scope else self.session_id or self.run_id
68
124
  )
69
- self.timeline_id = self.memory_scope_id or self.run_id
125
+ self.timeline_id = derive_timeline_id(
126
+ memory_scope_id=self.memory_scope_id,
127
+ run_id=self.run_id,
128
+ org_id=self.scope.org_id if self.scope else None,
129
+ )
70
130
 
71
131
  async def record_raw(
72
132
  self,
@@ -112,7 +172,8 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
112
172
  Returns:
113
173
  Event: The fully constructed and persisted `Event` object.
114
174
  """
115
- ts = now_iso()
175
+ ts_iso = now_iso()
176
+ ts_num = time.time() # numeric timestamp for created_at_ts
116
177
 
117
178
  # Merge Scope dimensions
118
179
  dims: dict[str, str] = {}
@@ -123,12 +184,25 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
123
184
  session_id = base.get("session_id") or dims.get("session_id") or self.session_id
124
185
  scope_id = base.get("scope_id") or self.memory_scope_id or session_id or run_id
125
186
 
187
+ user_id = base.get("user_id") or dims.get("user_id")
188
+ org_id = base.get("org_id") or dims.get("org_id")
189
+ client_id = base.get("client_id") or dims.get("client_id")
190
+ graph_id = base.get("graph_id") or dims.get("graph_id") or self.graph_id
191
+ node_id = base.get("node_id") or dims.get("node_id") or self.node_id
192
+
126
193
  base.setdefault("run_id", run_id)
127
194
  base.setdefault("scope_id", scope_id)
128
195
  base.setdefault("session_id", session_id)
129
- # ... (populate other fields from dims if needed) ...
130
-
196
+ base.setdefault("run_id", run_id)
197
+ base.setdefault("graph_id", graph_id)
198
+ base.setdefault("node_id", node_id)
199
+ base.setdefault("scope_id", scope_id)
200
+ base.setdefault("user_id", user_id)
201
+ base.setdefault("org_id", org_id)
202
+ base.setdefault("client_id", client_id)
203
+ base.setdefault("session_id", session_id)
131
204
  severity = int(base.get("severity", 2))
205
+
132
206
  signal = base.get("signal")
133
207
  if signal is None:
134
208
  signal = self._estimate_signal(text=text, metrics=metrics, severity=severity)
@@ -137,7 +211,7 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
137
211
 
138
212
  eid = stable_event_id(
139
213
  {
140
- "ts": ts,
214
+ "ts": ts_iso,
141
215
  "run_id": base["run_id"],
142
216
  "kind": kind,
143
217
  "text": (text or "")[:6000],
@@ -147,26 +221,76 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
147
221
 
148
222
  evt = Event(
149
223
  event_id=eid,
150
- ts=ts,
224
+ ts=ts_iso,
151
225
  run_id=run_id,
152
226
  scope_id=scope_id,
227
+ user_id=user_id,
228
+ org_id=org_id,
229
+ client_id=client_id,
230
+ session_id=session_id,
153
231
  kind=kind,
232
+ stage=base.get("stage"),
154
233
  text=text,
155
- data=base.get("data"),
156
234
  tags=base.get("tags"),
235
+ data=base.get("data"),
157
236
  metrics=metrics,
237
+ graph_id=graph_id,
238
+ node_id=node_id,
158
239
  tool=base.get("tool"),
240
+ topic=base.get("topic"),
159
241
  severity=severity,
160
242
  signal=signal,
161
243
  inputs=base.get("inputs"),
162
244
  outputs=base.get("outputs"),
163
- # ... pass other fields ...
245
+ embedding=base.get("embedding"),
246
+ pii_flags=base.get("pii_flags"),
164
247
  version=2,
165
248
  )
166
249
 
167
250
  await self.hotlog.append(self.timeline_id, evt, ttl_s=self.hot_ttl_s, limit=self.hot_limit)
168
251
  await self.persistence.append_event(self.timeline_id, evt)
169
252
 
253
+ # wire memory event text into ScopedIndices for searchability
254
+ if self.scoped_indices is not None and self.scoped_indices.backend is not None:
255
+ try:
256
+ kind_val = getattr(evt.kind, "value", str(evt.kind))
257
+ preview = (text or "")[:500] if text else ""
258
+
259
+ extra_meta = {
260
+ "run_id": evt.run_id,
261
+ "scope_id": evt.scope_id,
262
+ "session_id": evt.session_id,
263
+ "graph_id": evt.graph_id,
264
+ "node_id": evt.node_id,
265
+ "stage": evt.stage,
266
+ "tags": evt.tags or [],
267
+ "severity": evt.severity,
268
+ "signal": evt.signal,
269
+ "tool": evt.tool,
270
+ "topic": evt.topic,
271
+ "preview": preview, # short text preview
272
+ "timeline_id": self.timeline_id,
273
+ }
274
+
275
+ meta = build_index_meta_from_scope(
276
+ kind=kind_val,
277
+ source="memory",
278
+ ts=ts_iso,
279
+ created_at_ts=ts_num,
280
+ extra=extra_meta,
281
+ )
282
+
283
+ await self.scoped_indices.upsert(
284
+ corpus="event",
285
+ item_id=evt.event_id,
286
+ text=evt.text or "",
287
+ metadata=meta,
288
+ )
289
+
290
+ except Exception:
291
+ if self.logger:
292
+ self.logger.exception("Error indexing memory event %s", evt.event_id)
293
+
170
294
  # Metering hook
171
295
  try:
172
296
  meter = current_metering()
@@ -264,6 +388,8 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
264
388
  except Exception:
265
389
  data_field = {"repr": repr(data)}
266
390
 
391
+ # 4) normalize tags to remove empties and duplicates
392
+ tags = _normalize_tags(tags)
267
393
  base: dict[str, Any] = dict(
268
394
  kind=kind,
269
395
  stage=stage,
@@ -298,6 +424,8 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
298
424
  include_recent_tools: bool = False,
299
425
  tool: str | None = None,
300
426
  tool_limit: int = 10,
427
+ recent_chat_tags: list[str] | None = None,
428
+ recent_tool_tags: list[str] | None = None,
301
429
  ) -> dict[str, Any]:
302
430
  """
303
431
  Assemble memory context for prompts, including long-term summaries,
@@ -371,25 +499,37 @@ class MemoryFacade(ChatMixin, ResultMixin, RetrievalMixin, DistillationMixin, RA
371
499
  st = s.get("summary") or s.get("text") or s.get("body") or s.get("value") or ""
372
500
  if st:
373
501
  parts.append(st)
374
-
375
502
  if parts:
376
- # multiple long-term summaries → concatenate oldest→newest
377
503
  long_term_text = "\n\n".join(parts)
378
504
 
379
- recent_chat = await self.recent_chat(limit=recent_chat_limit)
505
+ # 1) Recent chat (delegate tag filtering + correct "last N" to recent_chat)
506
+ recent_chat = await self.recent_chat(
507
+ limit=recent_chat_limit,
508
+ tags=recent_chat_tags,
509
+ )
380
510
 
511
+ # 2) Recent tools
381
512
  recent_tools: list[dict[str, Any]] = []
382
513
  if include_recent_tools:
383
- events = await self.recent_tool_results(
384
- tool=tool,
385
- limit=tool_limit,
386
- )
514
+ fetch_n = tool_limit
515
+ if recent_tool_tags:
516
+ fetch_n = max(tool_limit * 5, 50)
517
+
518
+ events = await self.recent_tool_results(tool=tool, limit=fetch_n)
519
+
520
+ if recent_tool_tags:
521
+ want = set(recent_tool_tags)
522
+ events = [e for e in events if want.issubset(set(e.tags or []))]
523
+
524
+ # IMPORTANT: keep the most recent tool events
525
+ events = events[-tool_limit:] if tool_limit else []
526
+
387
527
  for e in events:
388
528
  recent_tools.append(
389
529
  {
390
530
  "ts": getattr(e, "ts", None),
391
- "tool": e.tool,
392
- "message": e.text,
531
+ "tool": getattr(e, "tool", None),
532
+ "message": getattr(e, "text", None),
393
533
  "inputs": getattr(e, "inputs", None),
394
534
  "outputs": getattr(e, "outputs", None),
395
535
  "tags": list(e.tags or []),