cortexlayer 0.1.0__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.
cortexlayer/memory.py ADDED
@@ -0,0 +1,372 @@
1
+ """``Memory`` — the embedded, in-process Cortex engine (no server).
2
+
3
+ >>> from cortexlayer import Memory # doctest: +SKIP
4
+ >>> m = Memory() # doctest: +SKIP
5
+ >>> m.add("I moved to Lisbon in March", user_id="alice") # doctest: +SKIP
6
+ >>> m.search("where does alice live?", user_id="alice") # doctest: +SKIP
7
+
8
+ Same method names and result types as :class:`~cortexlayer.CortexClient`, so
9
+ switching between local and hosted is a change of constructor. Memories live in
10
+ an embedded Chroma store under ``data_dir``; each ``user_id`` gets its own
11
+ collection, so users can never see each other's pages.
12
+
13
+ Needs ``pip install "cortexlayer[local]"``. Importing this module does not —
14
+ the engine is only loaded when a ``Memory`` is constructed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import threading
21
+ from typing import Any, Callable, Dict, List, Optional
22
+
23
+ from ._validate import need_int, need_str
24
+ from .errors import CortexConfigError, InvalidRequestError, LocalDependencyError, NotFoundError
25
+ from .types import Answer, AddResult, Page, PageList, SearchResult
26
+
27
+ DEFAULT_USER_ID = "default"
28
+ DATA_DIR_ENV = "CORTEXLAYER_DATA_DIR"
29
+ MAX_SEARCH_LIMIT = 20
30
+ MAX_LIST_LIMIT = 500
31
+ BACKENDS = ("raw", "facts")
32
+ _CONFIG_KEYS = {
33
+ "backend", "data_dir", "entity_extractor", "spacy_model",
34
+ "default_user_id", "auto_relink", "llm", "embedder", "custom_instructions",
35
+ "observation_date_from_timestamp", "keyword_scoring",
36
+ }
37
+
38
+
39
+ def _default_data_dir() -> str:
40
+ return os.environ.get(DATA_DIR_ENV) or os.path.join(
41
+ os.path.expanduser("~"), ".cortexlayer"
42
+ )
43
+
44
+
45
+ def _load_engine(backend: str = "raw") -> Any:
46
+ """Import the engine, or explain how to install it."""
47
+ try:
48
+ from ._engine import compression, ingestion, linking, nlp, retrieval, shaping, storage
49
+ if backend == "facts":
50
+ from ._engine.facts import backends as fact_backends
51
+ from ._engine.facts import engine as fact_engine
52
+ except ImportError as e:
53
+ raise LocalDependencyError(
54
+ "The embedded engine needs extra packages. Run: "
55
+ 'pip install "cortexlayer[local]" '
56
+ f"(missing: {getattr(e, 'name', None) or e})."
57
+ ) from e
58
+
59
+ class _Engine:
60
+ pass
61
+
62
+ eng = _Engine()
63
+ for mod in (compression, ingestion, linking, nlp, retrieval, shaping, storage):
64
+ setattr(eng, mod.__name__.rsplit(".", 1)[-1], mod)
65
+ if backend == "facts":
66
+ eng.fact_backends, eng.fact_engine = fact_backends, fact_engine
67
+ return eng
68
+
69
+
70
+ class Memory:
71
+ """Embedded memory store.
72
+
73
+ Args:
74
+ data_dir: where the embedded store lives (default ``~/.cortexlayer``,
75
+ or ``$CORTEXLAYER_DATA_DIR``). It is created on first use.
76
+ entity_extractor: ``"auto"`` (default: spaCy if available, else a
77
+ simpler regex extractor with a one-time warning), ``"spacy"``,
78
+ ``"regex"``, or your own object with ``entities(text)`` and
79
+ ``sentences(text)`` methods. Entities drive linking, so a weaker
80
+ extractor means fewer links.
81
+ spacy_model: spaCy model name (default ``en_core_web_sm``).
82
+ default_user_id: used when a call omits ``user_id``.
83
+ auto_relink: re-run the linking pass after every ``add``. Off by
84
+ default — linking is a batch pass over all pages, so prefer
85
+ calling :meth:`relink` after adding several memories.
86
+ backend: ``"raw"`` (default) stores your text as small pages — adding
87
+ never needs an LLM. ``"facts"`` asks an LLM to distil each ``add``
88
+ into self-contained facts (Mem0-style), stores those, and boosts
89
+ search by shared entities; it needs an LLM (``llm=``, Ollama by
90
+ default) and an embedder (``embedder=``, Chroma's built-in by
91
+ default).
92
+ llm: (``"facts"``) ``None`` = Ollama at ``$OLLAMA_HOST``; a dict such
93
+ as ``{"model": "qwen3.5:9b", "host": "..."}``; a callable
94
+ ``fn(system, user) -> str``; or any object with ``generate()``.
95
+ embedder: (``"facts"``) ``None``/``"chroma"`` (default), ``"ollama"``,
96
+ a dict like ``{"provider": "ollama", "model": "qwen3-embedding:8b"}``,
97
+ or an object with ``embed_batch(texts, action)`` and ``name``. A
98
+ store remembers its embedder and refuses a different one.
99
+ custom_instructions: (``"facts"``) extra extraction rules appended to
100
+ the prompt.
101
+ observation_date_from_timestamp: (``"facts"``) pass ``add(timestamp=)``
102
+ to the extractor as the conversation's Observation Date, so
103
+ "last week" resolves against that date. Off by default, which
104
+ matches Mem0 (it resolves relative dates against today).
105
+ keyword_scoring: (``"facts"``) fuse a BM25 keyword score into search so
106
+ a fact that literally contains a query word is not outranked by
107
+ generic facts with slightly closer vectors. ``True`` (weight 1, as
108
+ Mem0's design; the default) or a float weight; ``False`` is plain
109
+ semantic + entity scoring, identical to Mem0 on a Chroma store.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ data_dir: Optional[str] = None,
115
+ *,
116
+ entity_extractor: Any = "auto",
117
+ spacy_model: str = "en_core_web_sm",
118
+ default_user_id: str = DEFAULT_USER_ID,
119
+ auto_relink: bool = False,
120
+ backend: str = "raw",
121
+ llm: Any = None,
122
+ embedder: Any = None,
123
+ custom_instructions: Optional[str] = None,
124
+ observation_date_from_timestamp: bool = False,
125
+ keyword_scoring: Any = True,
126
+ ) -> None:
127
+ if backend not in BACKENDS:
128
+ raise CortexConfigError(f"backend must be one of {BACKENDS}, got {backend!r}")
129
+ self._backend = backend
130
+ self._e = _load_engine(backend)
131
+ self._data_dir = os.path.abspath(data_dir or _default_data_dir())
132
+ self._default_user = self._check_uid(default_user_id)
133
+ self._auto_relink = bool(auto_relink)
134
+ self._nlp = self._e.nlp.resolve_nlp(entity_extractor, spacy_model=spacy_model)
135
+ self._client = self._e.storage.get_client(self._data_dir)
136
+ self._collections: Dict[str, Any] = {}
137
+ self._lock = threading.Lock()
138
+ self._facts = None
139
+ if backend == "facts":
140
+ self._facts = self._e.fact_engine.FactEngine(
141
+ self._client, self._data_dir,
142
+ self._e.fact_backends.resolve_llm(llm),
143
+ self._e.fact_backends.resolve_embedder(embedder),
144
+ self._nlp, custom_instructions=custom_instructions,
145
+ observation_date_from_timestamp=observation_date_from_timestamp,
146
+ keyword_weight=float(keyword_scoring),
147
+ )
148
+
149
+ @classmethod
150
+ def from_config(cls, config: Dict[str, Any]) -> "Memory":
151
+ """Build from a plain dict (keys: ``data_dir``, ``entity_extractor``,
152
+ ``spacy_model``, ``default_user_id``, ``auto_relink``, ``backend``,
153
+ ``llm``, ``embedder``, ``custom_instructions``).
154
+ """
155
+ if not isinstance(config, dict):
156
+ raise CortexConfigError("config must be a dict")
157
+ unknown = set(config) - _CONFIG_KEYS
158
+ if unknown:
159
+ raise CortexConfigError(f"unknown config keys: {', '.join(sorted(unknown))}")
160
+ backend = config.get("backend", "raw")
161
+ if backend not in BACKENDS:
162
+ raise CortexConfigError(
163
+ f"backend {backend!r} is not available; choose one of {BACKENDS}"
164
+ )
165
+ return cls(**config)
166
+
167
+ def __repr__(self) -> str:
168
+ return f"Memory(data_dir={self._data_dir!r}, backend={self._backend!r})"
169
+
170
+ # --- internals ---
171
+
172
+ def _check_uid(self, uid: str) -> str:
173
+ try:
174
+ return self._e.storage.validate_user_id(uid)
175
+ except ValueError as e:
176
+ raise InvalidRequestError(str(e)) from e
177
+
178
+ def _uid(self, user_id: Optional[str]) -> str:
179
+ """``None`` means the instance's default user."""
180
+ return self._check_uid(self._default_user if user_id is None else user_id)
181
+
182
+ def _col(self, user_id: Optional[str]):
183
+ uid = self._uid(user_id)
184
+ with self._lock:
185
+ col = self._collections.get(uid)
186
+ if col is None:
187
+ col = self._collections[uid] = (
188
+ self._facts.collection(uid) if self._facts is not None
189
+ else self._e.storage.get_collection(self._client, uid)
190
+ )
191
+ return col
192
+
193
+ def collection(self, user_id: Optional[str] = None) -> Any:
194
+ """The user's underlying Chroma collection (raw pages, or extracted
195
+ facts for ``backend="facts"``; both share one page schema).
196
+
197
+ For embedders that serve or browse the store directly, e.g. the Cortex
198
+ server. Treat it as read-only: writing through it bypasses embedding,
199
+ entity extraction and the facts pipeline."""
200
+ return self._col(user_id)
201
+
202
+ def _passages(self, user_id, col, query: str, limit: int, expand_links: bool) -> List[dict]:
203
+ """Seeds (+ optional link expansion) as ``{page_id, text, score, via, ...}``."""
204
+ if self._facts is not None:
205
+ seeds = self._facts.seeds(self._uid(user_id), query, limit)
206
+ else:
207
+ seeds = self._e.storage.query(col, query, n_results=limit)
208
+ if expand_links:
209
+ return self._e.retrieval.expand_links(col, seeds)
210
+ return [
211
+ {"page_id": p["id"], "text": p["text"], "score": p.get("score", 0.0), "via": "direct"}
212
+ for p in seeds
213
+ ]
214
+
215
+ def _all_pages(self, col) -> List[dict]:
216
+ return self._e.shaping.scan_all(col, self._e.storage.list_pages)
217
+
218
+ # --- memory ---
219
+
220
+ def add(
221
+ self,
222
+ text: str,
223
+ *,
224
+ user_id: Optional[str] = None,
225
+ timestamp: Optional[str] = None,
226
+ ) -> AddResult:
227
+ """Save ``text`` as memory. Long text is chunked into several small
228
+ pages (one fact each works best). ``timestamp`` (e.g. ``"8 May, 2023"``)
229
+ is stored as the date. Does not relink unless ``auto_relink`` is on."""
230
+ need_str(text, "text")
231
+ if timestamp is not None:
232
+ need_str(timestamp, "timestamp")
233
+ col = self._col(user_id)
234
+ if self._facts is not None:
235
+ ids = [f["id"] for f in self._facts.add(self._uid(user_id), text, timestamp)]
236
+ else:
237
+ ids = self._e.ingestion.add_text(col, text, self._nlp, timestamp=timestamp)
238
+ if self._auto_relink and ids:
239
+ self._e.linking.run_linking_pass(col)
240
+ return AddResult(page_ids=ids)
241
+
242
+ def search(
243
+ self,
244
+ query: str,
245
+ *,
246
+ user_id: Optional[str] = None,
247
+ limit: int = 4,
248
+ expand_links: bool = True,
249
+ ) -> List[SearchResult]:
250
+ """Semantic search. With ``expand_links`` (default) related pages are
251
+ pulled in via links — those results have ``via == "link"``. An empty
252
+ store returns ``[]``."""
253
+ need_str(query, "query")
254
+ need_int(limit, "limit", 1, MAX_SEARCH_LIMIT)
255
+ if not isinstance(expand_links, bool):
256
+ raise InvalidRequestError("expand_links must be True or False")
257
+ col = self._col(user_id)
258
+ if col.count() == 0:
259
+ return []
260
+ passages = self._passages(user_id, col, query, limit, expand_links)
261
+ return [self._e.shaping.search_result(p) for p in passages]
262
+
263
+ def answer(
264
+ self,
265
+ query: str,
266
+ *,
267
+ user_id: Optional[str] = None,
268
+ limit: int = 4,
269
+ model: Optional[str] = None,
270
+ chat: Optional[Callable[[str, str], str]] = None,
271
+ ) -> Answer:
272
+ """Retrieve, then have an LLM distil a short direct answer plus the ids
273
+ of the pages that support it.
274
+
275
+ Needs an LLM: by default a local Ollama at ``$OLLAMA_HOST``
276
+ (``http://localhost:11434``). Pass ``chat=fn(prompt, model) -> str`` to
277
+ use any other model. An unreachable LLM raises (this call has no
278
+ degraded mode — use :meth:`search` for raw passages).
279
+ """
280
+ need_str(query, "query")
281
+ need_int(limit, "limit", 1, MAX_SEARCH_LIMIT)
282
+ col = self._col(user_id)
283
+ if col.count() == 0:
284
+ return Answer(answer="", source_page_ids=[])
285
+ passages = self._passages(user_id, col, query, limit, True)
286
+ kwargs: Dict[str, Any] = {"_chat": chat}
287
+ if model:
288
+ kwargs["model"] = model
289
+ out = self._e.compression.compress(query, passages, **kwargs)
290
+ return Answer(answer=out["answer"], source_page_ids=list(out["source_page_ids"]))
291
+
292
+ def get(self, id: str, *, user_id: Optional[str] = None) -> Page:
293
+ """One page by id. Unknown ids — including another user's — raise
294
+ :class:`NotFoundError`."""
295
+ need_str(id, "id")
296
+ col = self._col(user_id)
297
+ stored = self._e.storage.get_page(col, id)
298
+ if stored is None:
299
+ raise NotFoundError(f"page not found: {id}", status=404)
300
+ out, incoming = self._e.shaping.link_context(self._all_pages(col))
301
+ return self._e.shaping.page(stored, out, incoming)
302
+
303
+ def get_all(
304
+ self,
305
+ *,
306
+ user_id: Optional[str] = None,
307
+ query: Optional[str] = None,
308
+ limit: int = 50,
309
+ offset: int = 0,
310
+ ) -> PageList:
311
+ """Browse pages; ``query`` is a case-insensitive substring filter (use
312
+ :meth:`search` for semantic search)."""
313
+ need_int(limit, "limit", 1, MAX_LIST_LIMIT)
314
+ need_int(offset, "offset", 0)
315
+ if query is not None:
316
+ need_str(query, "query")
317
+ col = self._col(user_id)
318
+ everything = self._all_pages(col)
319
+ needle = query.strip().lower() if query else None
320
+ matches = [
321
+ p for p in everything if needle is None or needle in p.get("text", "").lower()
322
+ ]
323
+ out, incoming = self._e.shaping.link_context(everything)
324
+ window = matches[offset:offset + limit]
325
+ return PageList(
326
+ pages=[self._e.shaping.page(p, out, incoming) for p in window],
327
+ total=len(matches), limit=limit, offset=offset,
328
+ )
329
+
330
+ def update(self, id: str, text: str, *, user_id: Optional[str] = None) -> None:
331
+ """Replace a page's text (re-embeds, re-extracts entities, keeps links).
332
+ Links can go stale — call :meth:`relink` after batch edits."""
333
+ need_str(id, "id")
334
+ need_str(text, "text")
335
+ col = self._col(user_id)
336
+ if self._e.storage.get_page(col, id) is None:
337
+ raise NotFoundError(f"page not found: {id}", status=404)
338
+ if self._facts is not None:
339
+ self._facts.update(self._uid(user_id), id, text)
340
+ else:
341
+ self._e.storage.update_page_text(col, id, text, self._nlp.entities(text))
342
+
343
+ def delete(self, id: str, *, user_id: Optional[str] = None) -> None:
344
+ """Delete a page. Links from other pages that pointed at it are
345
+ skipped by retrieval and cleaned up by the next :meth:`relink`."""
346
+ need_str(id, "id")
347
+ col = self._col(user_id)
348
+ if self._e.storage.get_page(col, id) is None:
349
+ raise NotFoundError(f"page not found: {id}", status=404)
350
+ self._e.storage.delete_page(col, id)
351
+
352
+ def delete_all(self, *, user_id: Optional[str] = None) -> int:
353
+ """Delete every page of one user. Returns how many were removed."""
354
+ uid = self._uid(user_id)
355
+ col = self._col(uid)
356
+ removed = col.count()
357
+ with self._lock:
358
+ self._collections.pop(uid, None)
359
+ if self._facts is not None:
360
+ self._facts.delete_all(uid)
361
+ else:
362
+ self._client.delete_collection(col.name)
363
+ return removed
364
+
365
+ def relink(self, *, user_id: Optional[str] = None) -> Dict[str, Any]:
366
+ """Re-run the batch linking pass (idempotent). Returns
367
+ ``{"pages": n, "links_written": m}``."""
368
+ return self._e.linking.run_linking_pass(self._col(user_id))
369
+
370
+ def count(self, *, user_id: Optional[str] = None) -> int:
371
+ """Number of pages stored for one user."""
372
+ return self._col(user_id).count()
cortexlayer/py.typed ADDED
File without changes
cortexlayer/types.py ADDED
@@ -0,0 +1,199 @@
1
+ """Result types. Plain frozen dataclasses (no pydantic) built tolerantly from
2
+ server JSON: unknown fields are ignored so a newer server never breaks an
3
+ older client."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Optional
9
+
10
+
11
+ def _s(d: Dict[str, Any], key: str, default: str = "") -> str:
12
+ v = d.get(key, default)
13
+ return v if isinstance(v, str) else default
14
+
15
+
16
+ def _list(d: Dict[str, Any], key: str) -> List[str]:
17
+ v = d.get(key) or []
18
+ return [x for x in v if isinstance(x, str)] if isinstance(v, list) else []
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SearchResult:
23
+ """One hit from :meth:`CortexClient.search`.
24
+
25
+ ``text`` is the full memory text when the source provides it (local
26
+ ``Memory`` always does); ``snippet`` is its first ~200 characters.
27
+
28
+ ``via`` is ``"direct"`` (vector hit) or ``"link"`` (pulled in by
29
+ link-expansion; ``linked_from`` is the seed page that led to it). ``score``
30
+ semantics depend on the backend (a distance, lower = closer, on raw; the
31
+ fused semantic + keyword + entity score, higher = closer, on facts) —
32
+ compare within one store or server, not across.
33
+ """
34
+
35
+ id: str
36
+ title: str
37
+ snippet: str
38
+ score: float
39
+ via: str = "direct"
40
+ linked_from: Optional[str] = None
41
+ source: str = "private"
42
+ text: str = "" # the full memory text (local ``Memory`` fills it; a server may omit it)
43
+
44
+ @classmethod
45
+ def from_dict(cls, d: Dict[str, Any]) -> "SearchResult":
46
+ lf = d.get("linked_from")
47
+ return cls(
48
+ id=_s(d, "id"),
49
+ title=_s(d, "title"),
50
+ snippet=_s(d, "snippet"),
51
+ score=float(d.get("score") or 0.0),
52
+ via=_s(d, "via", "direct"),
53
+ linked_from=lf if isinstance(lf, str) else None,
54
+ source=_s(d, "source", "private"),
55
+ text=_s(d, "text"),
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Page:
61
+ """A stored memory page. ``links`` point out, ``linked_from`` point in."""
62
+
63
+ id: str
64
+ title: str
65
+ content: str
66
+ snippet: str = ""
67
+ links: List[str] = field(default_factory=list)
68
+ linked_from: List[str] = field(default_factory=list)
69
+ degree: int = 0
70
+ created_at: str = ""
71
+
72
+ @classmethod
73
+ def from_dict(cls, d: Dict[str, Any]) -> "Page":
74
+ return cls(
75
+ id=_s(d, "id"),
76
+ title=_s(d, "title"),
77
+ content=_s(d, "content"),
78
+ snippet=_s(d, "snippet"),
79
+ links=_list(d, "links"),
80
+ linked_from=_list(d, "linked_from"),
81
+ degree=int(d.get("degree") or 0),
82
+ created_at=_s(d, "created_at"),
83
+ )
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class PageList:
88
+ """One window of :meth:`CortexClient.get_all` (``total`` counts all matches)."""
89
+
90
+ pages: List[Page]
91
+ total: int
92
+ limit: int
93
+ offset: int
94
+
95
+ @classmethod
96
+ def from_dict(cls, d: Dict[str, Any]) -> "PageList":
97
+ return cls(
98
+ pages=[Page.from_dict(p) for p in d.get("pages") or []],
99
+ total=int(d.get("total") or 0),
100
+ limit=int(d.get("limit") or 0),
101
+ offset=int(d.get("offset") or 0),
102
+ )
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class AddResult:
107
+ """Ids of the pages created by :meth:`CortexClient.add` (long text is
108
+ chunked into several small pages)."""
109
+
110
+ page_ids: List[str]
111
+
112
+ @classmethod
113
+ def from_dict(cls, d: Dict[str, Any]) -> "AddResult":
114
+ return cls(page_ids=_list(d, "page_ids"))
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Account:
119
+ user_id: str
120
+ account_name: str
121
+
122
+ @classmethod
123
+ def from_dict(cls, d: Dict[str, Any]) -> "Account":
124
+ return cls(user_id=_s(d, "user_id"), account_name=_s(d, "account_name"))
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class UsageBucket:
129
+ key: str
130
+ calls: int
131
+ errors: int
132
+ avg_latency_ms: Optional[float] = None
133
+ label: Optional[str] = None
134
+
135
+ @classmethod
136
+ def from_dict(cls, d: Dict[str, Any]) -> "UsageBucket":
137
+ avg = d.get("avg_latency_ms")
138
+ lab = d.get("label")
139
+ return cls(
140
+ key=_s(d, "key"),
141
+ calls=int(d.get("calls") or 0),
142
+ errors=int(d.get("errors") or 0),
143
+ avg_latency_ms=float(avg) if isinstance(avg, (int, float)) else None,
144
+ label=lab if isinstance(lab, str) else None,
145
+ )
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class Usage:
150
+ """Your own API usage, aggregated (``group_by``: day | key | operation)."""
151
+
152
+ group_by: str
153
+ since: str
154
+ until: str
155
+ total_calls: int
156
+ total_errors: int
157
+ buckets: List[UsageBucket]
158
+
159
+ @classmethod
160
+ def from_dict(cls, d: Dict[str, Any]) -> "Usage":
161
+ return cls(
162
+ group_by=_s(d, "group_by"),
163
+ since=_s(d, "since"),
164
+ until=_s(d, "until"),
165
+ total_calls=int(d.get("total_calls") or 0),
166
+ total_errors=int(d.get("total_errors") or 0),
167
+ buckets=[UsageBucket.from_dict(b) for b in d.get("buckets") or []],
168
+ )
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class Graph:
173
+ """Raw graph payload (``nodes`` / ``edges`` as the server sends them)."""
174
+
175
+ nodes: List[Dict[str, Any]]
176
+ edges: List[Dict[str, Any]]
177
+ truncated: bool = False
178
+ stale: bool = False
179
+ start: Optional[str] = None
180
+
181
+ @classmethod
182
+ def from_dict(cls, d: Dict[str, Any]) -> "Graph":
183
+ start = d.get("start")
184
+ return cls(
185
+ nodes=list(d.get("nodes") or []),
186
+ edges=list(d.get("edges") or []),
187
+ truncated=bool(d.get("truncated", False)),
188
+ stale=bool(d.get("stale", False)),
189
+ start=start if isinstance(start, str) else None,
190
+ )
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class Answer:
195
+ """A short direct answer distilled from retrieved pages
196
+ (:meth:`Memory.answer`; needs an LLM — Ollama by default)."""
197
+
198
+ answer: str
199
+ source_page_ids: List[str]