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.
- aethergraph/__main__.py +3 -0
- aethergraph/api/v1/artifacts.py +23 -4
- aethergraph/api/v1/schemas.py +7 -0
- aethergraph/api/v1/session.py +123 -4
- aethergraph/config/config.py +2 -0
- aethergraph/config/search.py +49 -0
- aethergraph/contracts/services/channel.py +18 -1
- aethergraph/contracts/services/execution.py +58 -0
- aethergraph/contracts/services/llm.py +26 -0
- aethergraph/contracts/services/memory.py +10 -4
- aethergraph/contracts/services/planning.py +53 -0
- aethergraph/contracts/storage/event_log.py +8 -0
- aethergraph/contracts/storage/search_backend.py +47 -0
- aethergraph/contracts/storage/vector_index.py +73 -0
- aethergraph/core/graph/action_spec.py +76 -0
- aethergraph/core/graph/graph_fn.py +75 -2
- aethergraph/core/graph/graphify.py +74 -2
- aethergraph/core/runtime/graph_runner.py +2 -1
- aethergraph/core/runtime/node_context.py +66 -3
- aethergraph/core/runtime/node_services.py +8 -0
- aethergraph/core/runtime/run_manager.py +263 -271
- aethergraph/core/runtime/run_types.py +54 -1
- aethergraph/core/runtime/runtime_env.py +35 -14
- aethergraph/core/runtime/runtime_services.py +308 -18
- aethergraph/plugins/agents/default_chat_agent.py +266 -74
- aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
- aethergraph/plugins/channel/adapters/webui.py +69 -21
- aethergraph/plugins/channel/routes/webui_routes.py +8 -48
- aethergraph/runtime/__init__.py +12 -0
- aethergraph/server/app_factory.py +10 -1
- aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
- aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
- aethergraph/server/ui_static/index.html +2 -2
- aethergraph/services/artifacts/facade.py +157 -21
- aethergraph/services/artifacts/types.py +35 -0
- aethergraph/services/artifacts/utils.py +42 -0
- aethergraph/services/channel/channel_bus.py +3 -1
- aethergraph/services/channel/event_hub copy.py +55 -0
- aethergraph/services/channel/event_hub.py +81 -0
- aethergraph/services/channel/factory.py +3 -2
- aethergraph/services/channel/session.py +709 -74
- aethergraph/services/container/default_container.py +69 -7
- aethergraph/services/execution/__init__.py +0 -0
- aethergraph/services/execution/local_python.py +118 -0
- aethergraph/services/indices/__init__.py +0 -0
- aethergraph/services/indices/global_indices.py +21 -0
- aethergraph/services/indices/scoped_indices.py +292 -0
- aethergraph/services/llm/generic_client.py +342 -46
- aethergraph/services/llm/generic_embed_client.py +359 -0
- aethergraph/services/llm/types.py +3 -1
- aethergraph/services/memory/distillers/llm_long_term.py +60 -109
- aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
- aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
- aethergraph/services/memory/distillers/long_term.py +48 -131
- aethergraph/services/memory/distillers/long_term_v1.py +170 -0
- aethergraph/services/memory/facade/chat.py +18 -8
- aethergraph/services/memory/facade/core.py +159 -19
- aethergraph/services/memory/facade/distillation.py +86 -31
- aethergraph/services/memory/facade/retrieval.py +100 -1
- aethergraph/services/memory/factory.py +4 -1
- aethergraph/services/planning/__init__.py +0 -0
- aethergraph/services/planning/action_catalog.py +271 -0
- aethergraph/services/planning/bindings.py +56 -0
- aethergraph/services/planning/dependency_index.py +65 -0
- aethergraph/services/planning/flow_validator.py +263 -0
- aethergraph/services/planning/graph_io_adapter.py +150 -0
- aethergraph/services/planning/input_parser.py +312 -0
- aethergraph/services/planning/missing_inputs.py +28 -0
- aethergraph/services/planning/node_planner.py +613 -0
- aethergraph/services/planning/orchestrator.py +112 -0
- aethergraph/services/planning/plan_executor.py +506 -0
- aethergraph/services/planning/plan_types.py +321 -0
- aethergraph/services/planning/planner.py +617 -0
- aethergraph/services/planning/planner_service.py +369 -0
- aethergraph/services/planning/planning_context_builder.py +43 -0
- aethergraph/services/planning/quick_actions.py +29 -0
- aethergraph/services/planning/routers/__init__.py +0 -0
- aethergraph/services/planning/routers/simple_router.py +26 -0
- aethergraph/services/rag/facade.py +0 -3
- aethergraph/services/scope/scope.py +30 -30
- aethergraph/services/scope/scope_factory.py +15 -7
- aethergraph/services/skills/__init__.py +0 -0
- aethergraph/services/skills/skill_registry.py +465 -0
- aethergraph/services/skills/skills.py +220 -0
- aethergraph/services/skills/utils.py +194 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
- aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
- aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
- aethergraph/storage/memory/event_persist.py +42 -2
- aethergraph/storage/memory/fs_persist.py +32 -2
- aethergraph/storage/search_backend/__init__.py +0 -0
- aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
- aethergraph/storage/search_backend/null_backend.py +34 -0
- aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
- aethergraph/storage/search_backend/utils.py +31 -0
- aethergraph/storage/search_factory.py +75 -0
- aethergraph/storage/vector_index/faiss_index.py +72 -4
- aethergraph/storage/vector_index/sqlite_index.py +521 -52
- aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
- aethergraph/storage/vector_index/utils.py +22 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +108 -64
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
- aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
- aethergraph/services/eventhub/event_hub.py +0 -76
- aethergraph/services/llm/generic_client copy.py +0 -691
- aethergraph/services/prompts/file_store.py +0 -41
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
- {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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
392
|
-
"message": e
|
|
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 []),
|