agent-memory-os 0.2.3__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.
@@ -0,0 +1,17 @@
1
+ """AgentMemoryOS public SDK."""
2
+
3
+ from .client import MemoryClient
4
+ from .golden_recall import evaluate_golden_queries, load_golden_query_cases
5
+ from .hermes_importer import import_hermes_memory_files
6
+ from .schema import MemoryLink, MemoryRecord, RecallProfile, SearchResult
7
+
8
+ __all__ = [
9
+ "MemoryClient",
10
+ "MemoryLink",
11
+ "MemoryRecord",
12
+ "RecallProfile",
13
+ "SearchResult",
14
+ "evaluate_golden_queries",
15
+ "import_hermes_memory_files",
16
+ "load_golden_query_cases",
17
+ ]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+ from typing import Generic, TypeVar
5
+
6
+ K = TypeVar("K")
7
+ V = TypeVar("V")
8
+
9
+
10
+ class LRUCache(Generic[K, V]):
11
+ def __init__(self, max_items: int = 512):
12
+ if max_items < 1:
13
+ raise ValueError("max_items must be >= 1")
14
+ self.max_items = max_items
15
+ self._items: OrderedDict[K, V] = OrderedDict()
16
+
17
+ def get(self, key: K) -> V | None:
18
+ if key not in self._items:
19
+ return None
20
+ self._items.move_to_end(key)
21
+ return self._items[key]
22
+
23
+ def set(self, key: K, value: V) -> None:
24
+ self._items[key] = value
25
+ self._items.move_to_end(key)
26
+ while len(self._items) > self.max_items:
27
+ self._items.popitem(last=False)
28
+
29
+ def clear(self) -> None:
30
+ self._items.clear()
31
+
32
+ def __len__(self) -> int:
33
+ return len(self._items)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable, Protocol
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class Candidate:
9
+ """Untrusted retrieval candidate produced by a disposable index/provider.
10
+
11
+ Candidate providers may suggest stable MemoryRecord IDs and scoring metadata.
12
+ They must not be treated as authoritative content sources; callers must rejoin
13
+ every candidate through SQLite and apply ACL/expiry hard gates before use.
14
+ """
15
+
16
+ memory_id: str
17
+ provider: str
18
+ score: float
19
+ rank: int | None = None
20
+ reason: str = ""
21
+
22
+
23
+ class CandidateProvider(Protocol):
24
+ """Protocol for untrusted retrieval candidate sidecars."""
25
+
26
+ name: str
27
+
28
+ def candidates(
29
+ self,
30
+ query: str,
31
+ *,
32
+ owner: str | None = None,
33
+ scope: str | None = None,
34
+ requester_agent_id: str | None = None,
35
+ requester_team_id: str | None = None,
36
+ limit: int = 10,
37
+ ) -> Iterable[Candidate]:
38
+ """Return untrusted candidate IDs and scores for later SQLite rejoin."""
39
+ ...
agent_memory_os/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from .client import MemoryClient
6
+ from .golden_recall import evaluate_golden_queries, load_golden_query_cases
7
+ from .hermes_importer import import_hermes_memory_files
8
+ from .shadow_mode import summarize_shadow_log
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ p = argparse.ArgumentParser(prog="agent-memory", description="Local-first AI agent memory runtime")
13
+ p.add_argument("--home", default=None, help="Memory home directory; defaults to AGENT_MEMORY_HOME or ~/.agent-memory")
14
+ sub = p.add_subparsers(dest="command", required=True)
15
+
16
+ add = sub.add_parser("add", help="Add a memory")
17
+ add.add_argument("content")
18
+ add.add_argument("--owner", default="default")
19
+ add.add_argument("--scope", default="user")
20
+ add.add_argument("--type", default="note")
21
+ add.add_argument("--summary")
22
+ add.add_argument("--tag", action="append", default=[])
23
+ add.add_argument("--confidence", type=float, default=0.8)
24
+ add.add_argument("--importance", type=float, default=0.5)
25
+
26
+ search = sub.add_parser("search", help="Search memories")
27
+ search.add_argument("query")
28
+ search.add_argument("--owner")
29
+ search.add_argument("--scope")
30
+ search.add_argument("--limit", type=int, default=10)
31
+ search.add_argument("--json", action="store_true")
32
+
33
+ pack = sub.add_parser("pack", help="Build a prompt-ready context pack")
34
+ pack.add_argument("query")
35
+ pack.add_argument("--owner")
36
+ pack.add_argument("--scope")
37
+ pack.add_argument("--limit", type=int, default=12)
38
+ pack.add_argument("--max-tokens", type=int, default=1200)
39
+
40
+ sub.add_parser("stats", help="Show database statistics")
41
+
42
+ imp = sub.add_parser("import-hermes", help="Import Hermes MEMORY.md/USER.md into AgentMemoryOS")
43
+ imp.add_argument("--profile", required=True, help="Hermes profile name / AgentMemoryOS owner")
44
+ imp.add_argument("--profile-home", required=True, help="Hermes profile home containing memories/MEMORY.md and USER.md")
45
+ imp.add_argument("--json", action="store_true", help="Emit JSON report")
46
+
47
+ shadow = sub.add_parser("shadow-summary", help="Summarize shadow-mode JSONL evidence")
48
+ shadow.add_argument("--log", required=True, help="Path to agent_memory_os_shadow.jsonl")
49
+ shadow.add_argument("--last", type=int, default=None, help="Only summarize the last N records")
50
+ shadow.add_argument("--json", action="store_true", help="Emit JSON evidence pack")
51
+
52
+ golden = sub.add_parser("golden-recall", help="Run golden-query recall cases against the memory store")
53
+ golden.add_argument("--cases", required=True, help="JSON/JSONL golden query case file")
54
+ golden.add_argument("--limit", type=int, default=10, help="Default search limit for cases without limit")
55
+ golden.add_argument("--recall-target", type=float, default=0.95, help="Required pass rate for GO")
56
+ golden.add_argument("--json", action="store_true", help="Emit JSON evidence report")
57
+ return p
58
+
59
+
60
+ def main(argv: list[str] | None = None) -> int:
61
+ args = build_parser().parse_args(argv)
62
+ if args.command == "shadow-summary":
63
+ summary = summarize_shadow_log(args.log, last_n=args.last)
64
+ if args.json:
65
+ print(json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True))
66
+ else:
67
+ print(
68
+ f"records={summary['records']} activation_gate={summary['activation_gate']} "
69
+ f"mean_top_k_hit_rate={summary['mean_top_k_hit_rate']} "
70
+ f"p99_candidate_latency_ms={summary['p99_candidate_latency_ms']} "
71
+ f"acl_leakage_count={summary['acl_leakage_count']} "
72
+ f"production_injection_count={summary['production_injection_count']}"
73
+ )
74
+ return 0
75
+
76
+ client = MemoryClient(home=args.home)
77
+ try:
78
+ if args.command == "golden-recall":
79
+ cases = load_golden_query_cases(args.cases)
80
+ report = evaluate_golden_queries(
81
+ client,
82
+ cases,
83
+ default_limit=args.limit,
84
+ recall_target=args.recall_target,
85
+ )
86
+ if args.json:
87
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
88
+ else:
89
+ print(
90
+ f"cases={report['cases']} passed={report['passed']} failed={report['failed']} "
91
+ f"golden_recall_rate={report['golden_recall_rate']} "
92
+ f"forbidden_hit_count={report['forbidden_hit_count']} "
93
+ f"activation_gate={report['activation_gate']}"
94
+ )
95
+ return 0
96
+ if args.command == "add":
97
+ rec = client.add(
98
+ args.content, owner=args.owner, scope=args.scope, type=args.type,
99
+ summary=args.summary, tags=args.tag, confidence=args.confidence, importance=args.importance,
100
+ )
101
+ print(rec.id)
102
+ return 0
103
+ if args.command == "search":
104
+ results = client.search(args.query, owner=args.owner, scope=args.scope, limit=args.limit)
105
+ if args.json:
106
+ print(json.dumps([
107
+ {"id": r.record.id, "score": r.score, "content": r.record.content, "scope": r.record.scope, "type": r.record.type}
108
+ for r in results
109
+ ], ensure_ascii=False, indent=2))
110
+ else:
111
+ for r in results:
112
+ print(f"{r.record.id}\t{r.score:.3f}\t{r.record.scope}/{r.record.type}\t{r.record.content}")
113
+ return 0
114
+ if args.command == "pack":
115
+ print(client.context_pack(args.query, owner=args.owner, scope=args.scope, limit=args.limit, max_tokens=args.max_tokens), end="")
116
+ return 0
117
+ if args.command == "stats":
118
+ print(json.dumps(client.stats(), ensure_ascii=False, indent=2))
119
+ return 0
120
+ if args.command == "import-hermes":
121
+ report = import_hermes_memory_files(client, profile=args.profile, profile_home=args.profile_home)
122
+ if args.json:
123
+ print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2))
124
+ else:
125
+ print(
126
+ f"profile={report.profile} scanned={report.scanned} inserted={report.inserted} "
127
+ f"updated={report.updated} skipped={report.skipped}"
128
+ )
129
+ return 0
130
+ finally:
131
+ client.close()
132
+ return 2
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Sequence
5
+ import os
6
+ from datetime import datetime, timezone
7
+ import json
8
+
9
+ from .candidates import CandidateProvider
10
+ from .cache import LRUCache
11
+ from .context_pack import ContextPackReport, build_context_pack, build_context_pack_report
12
+ from .db import MemoryStore
13
+ from .schema import MemoryLink, MemoryRecord, RecallProfile, SearchResult
14
+
15
+ class MemoryClient:
16
+ def __init__(
17
+ self,
18
+ home: str | Path | None = None,
19
+ *,
20
+ cache_items: int = 512,
21
+ candidate_providers: Sequence[CandidateProvider] | None = None,
22
+ resonance_hops: int = 1,
23
+ profile: RecallProfile | None = None,
24
+ check_same_thread: bool = True,
25
+ ):
26
+ home_path = Path(home or os.getenv("AGENT_MEMORY_HOME", "~/.agent-memory")).expanduser()
27
+ self.home = home_path
28
+ self.store = MemoryStore(
29
+ home_path / "memories.db",
30
+ candidate_providers=candidate_providers,
31
+ resonance_hops=resonance_hops,
32
+ check_same_thread=check_same_thread,
33
+ )
34
+ self.profile = profile
35
+ self.cache: LRUCache[tuple, object] = LRUCache(max_items=cache_items)
36
+ self._profile_cache: dict[str, RecallProfile | None] = {}
37
+
38
+ def add(self, content: str, *, auto_link: bool = False, **kwargs) -> MemoryRecord:
39
+ record = MemoryRecord(content=content, **kwargs)
40
+ saved = self.store.add(record)
41
+ if auto_link:
42
+ self.store.auto_link_similar(saved)
43
+ self.cache.clear()
44
+ return saved
45
+
46
+ def get(self, memory_id: str) -> MemoryRecord | None:
47
+ return self.store.get(memory_id)
48
+
49
+ def delete(self, memory_id: str) -> bool:
50
+ removed = self.store.delete(memory_id)
51
+ if removed:
52
+ self.cache.clear()
53
+ return removed
54
+
55
+ def update(self, memory_id: str, **fields) -> MemoryRecord:
56
+ updated = self.store.update_memory(memory_id, **fields)
57
+ self.cache.clear()
58
+ return updated
59
+
60
+ def dashboard_stats(self) -> dict[str, object]:
61
+ return self.store.dashboard_stats() | {"cache_items": len(self.cache)}
62
+
63
+ def list_recent(
64
+ self,
65
+ *,
66
+ owner: str | None = None,
67
+ scope: str | None = None,
68
+ memory_type: str | None = None,
69
+ requester_agent_id: str | None = None,
70
+ requester_team_id: str | None = None,
71
+ limit: int = 20,
72
+ offset: int = 0,
73
+ ) -> list[MemoryRecord]:
74
+ return self.store.list_recent(
75
+ owner=owner,
76
+ scope=scope,
77
+ memory_type=memory_type,
78
+ requester_agent_id=requester_agent_id,
79
+ requester_team_id=requester_team_id,
80
+ limit=limit,
81
+ offset=offset,
82
+ )
83
+
84
+ def graph_snapshot(
85
+ self,
86
+ *,
87
+ requester_agent_id: str | None = None,
88
+ requester_team_id: str | None = None,
89
+ limit: int = 300,
90
+ ) -> dict[str, list[dict]]:
91
+ return self.store.graph_snapshot(
92
+ requester_agent_id=requester_agent_id,
93
+ requester_team_id=requester_team_id,
94
+ limit=limit,
95
+ )
96
+
97
+ def link(
98
+ self,
99
+ src_id: str,
100
+ dst_id: str,
101
+ *,
102
+ relation: str = "related_to",
103
+ weight: float = 0.5,
104
+ source: dict | None = None,
105
+ ) -> MemoryLink:
106
+ saved = self.store.add_link(
107
+ MemoryLink(src_id=src_id, dst_id=dst_id, relation=relation, weight=weight, source=source or {})
108
+ )
109
+ self.cache.clear()
110
+ return saved
111
+
112
+ def unlink(self, src_id: str, dst_id: str, *, relation: str | None = None) -> bool:
113
+ removed = self.store.remove_link(src_id, dst_id, relation=relation)
114
+ if removed:
115
+ self.cache.clear()
116
+ return removed
117
+
118
+ def links(self, memory_id: str) -> list[MemoryLink]:
119
+ return self.store.links_for(memory_id)
120
+
121
+ def record_recall(
122
+ self,
123
+ memory_ids: Sequence[str],
124
+ *,
125
+ create_colinks: bool = False,
126
+ helpful: bool = True,
127
+ requester_agent_id: str | None = None,
128
+ requester_team_id: str | None = None,
129
+ ) -> dict[str, int]:
130
+ """Report that these memories were recalled together.
131
+
132
+ This is the repeated-recall feedback loop: with `helpful=True` it
133
+ reinforces each memory and strengthens the association edges between
134
+ them so future queries resonate along well-worn paths; with
135
+ `helpful=False` the recall misled the agent, so links weaken and
136
+ confidence drops — the self-correction path. Pass the requester when
137
+ the feedback comes from an untrusted surface: only memories visible to
138
+ that requester are affected.
139
+ """
140
+ result = self.store.record_recall(
141
+ memory_ids,
142
+ create_colinks=create_colinks,
143
+ helpful=helpful,
144
+ requester_agent_id=requester_agent_id,
145
+ requester_team_id=requester_team_id,
146
+ )
147
+ self.cache.clear()
148
+ return result
149
+
150
+ def import_links(
151
+ self,
152
+ pairs: Sequence[tuple[str, str, float]],
153
+ *,
154
+ relation: str = "related_to",
155
+ source: dict | None = None,
156
+ ) -> int:
157
+ """Bulk-import derived association edges (e.g. from the ERA index).
158
+
159
+ Pairs whose endpoints no longer exist are skipped. Pairs already
160
+ connected (in either direction) are left untouched so a periodic sync
161
+ can never clobber reinforcement-learned weights.
162
+ """
163
+ imported = 0
164
+ for src_id, dst_id, weight in pairs:
165
+ if self.store.link_exists(src_id, dst_id):
166
+ continue
167
+ try:
168
+ self.store.add_link(
169
+ MemoryLink(
170
+ src_id=src_id, dst_id=dst_id, relation=relation,
171
+ weight=weight, source=source or {"auto": "derived"},
172
+ )
173
+ )
174
+ imported += 1
175
+ except (KeyError, ValueError):
176
+ continue
177
+ if imported:
178
+ self.cache.clear()
179
+ return imported
180
+
181
+ def save_profile(self, profile: RecallProfile) -> RecallProfile:
182
+ """Persist a named agent recall profile in the memory database."""
183
+ saved = self.store.save_profile(profile)
184
+ self._profile_cache.pop(profile.agent_id, None)
185
+ self.cache.clear()
186
+ return saved
187
+
188
+ def load_profile(self, agent_id: str) -> RecallProfile | None:
189
+ # Only cache hits: caching a miss forever would blind a long-running
190
+ # server to profiles saved later by another process.
191
+ cached = self._profile_cache.get(agent_id)
192
+ if cached is not None:
193
+ return cached
194
+ profile = self.store.load_profile(agent_id)
195
+ if profile is not None:
196
+ self._profile_cache[agent_id] = profile
197
+ return profile
198
+
199
+ def consolidate(self, *, owner: str | None = None, scope: str | None = None) -> dict[str, int]:
200
+ """Run the write-side hygiene pass: merge duplicates, synthesize concepts."""
201
+ result = self.store.consolidate(owner=owner, scope=scope)
202
+ self.cache.clear()
203
+ return result
204
+
205
+ def _resolve_profile(
206
+ self, profile: RecallProfile | None, requester_agent_id: str | None
207
+ ) -> RecallProfile | None:
208
+ if profile is not None:
209
+ return profile
210
+ if self.profile is not None:
211
+ return self.profile
212
+ if requester_agent_id:
213
+ return self.load_profile(requester_agent_id)
214
+ return None
215
+
216
+ def search(
217
+ self,
218
+ query: str,
219
+ *,
220
+ owner: str | None = None,
221
+ scope: str | None = None,
222
+ requester_agent_id: str | None = None,
223
+ requester_team_id: str | None = None,
224
+ limit: int = 10,
225
+ profile: RecallProfile | None = None,
226
+ ) -> list[SearchResult]:
227
+ active_profile = self._resolve_profile(profile, requester_agent_id)
228
+ profile_key = active_profile.signature() if active_profile else None
229
+ key = ("search", query, owner, scope, requester_agent_id, requester_team_id, limit, profile_key)
230
+ cached = self.cache.get(key)
231
+ if cached is not None:
232
+ return cached # type: ignore[return-value]
233
+ results = self.store.search(
234
+ query,
235
+ owner=owner,
236
+ scope=scope,
237
+ requester_agent_id=requester_agent_id,
238
+ requester_team_id=requester_team_id,
239
+ limit=limit,
240
+ profile=active_profile,
241
+ )
242
+ self.cache.set(key, results)
243
+ return results
244
+
245
+ def context_pack(
246
+ self,
247
+ query: str,
248
+ *,
249
+ owner: str | None = None,
250
+ scope: str | None = None,
251
+ requester_agent_id: str | None = None,
252
+ requester_team_id: str | None = None,
253
+ limit: int = 12,
254
+ max_tokens: int = 1200,
255
+ profile: RecallProfile | None = None,
256
+ auto_reinforce: bool = False,
257
+ ) -> str:
258
+ if auto_reinforce:
259
+ return self.context_pack_report(
260
+ query,
261
+ owner=owner,
262
+ scope=scope,
263
+ requester_agent_id=requester_agent_id,
264
+ requester_team_id=requester_team_id,
265
+ limit=limit,
266
+ max_tokens=max_tokens,
267
+ profile=profile,
268
+ auto_reinforce=True,
269
+ ).text
270
+ active_profile = self._resolve_profile(profile, requester_agent_id)
271
+ profile_key = active_profile.signature() if active_profile else None
272
+ key = ("pack", query, owner, scope, requester_agent_id, requester_team_id, limit, max_tokens, profile_key)
273
+ cached = self.cache.get(key)
274
+ if cached is not None:
275
+ return cached # type: ignore[return-value]
276
+ results = self.search(
277
+ query,
278
+ owner=owner,
279
+ scope=scope,
280
+ requester_agent_id=requester_agent_id,
281
+ requester_team_id=requester_team_id,
282
+ limit=limit,
283
+ profile=active_profile,
284
+ )
285
+ pack = build_context_pack(results, max_tokens=max_tokens)
286
+ self.cache.set(key, pack)
287
+ return pack
288
+
289
+ def context_pack_report(
290
+ self,
291
+ query: str,
292
+ *,
293
+ owner: str | None = None,
294
+ scope: str | None = None,
295
+ requester_agent_id: str | None = None,
296
+ requester_team_id: str | None = None,
297
+ limit: int = 12,
298
+ max_tokens: int = 1200,
299
+ profile: RecallProfile | None = None,
300
+ auto_reinforce: bool = False,
301
+ ) -> ContextPackReport:
302
+ active_profile = self._resolve_profile(profile, requester_agent_id)
303
+ profile_key = active_profile.signature() if active_profile else None
304
+ key = (
305
+ "pack_report", query, owner, scope, requester_agent_id, requester_team_id,
306
+ limit, max_tokens, profile_key,
307
+ )
308
+ if not auto_reinforce:
309
+ cached = self.cache.get(key)
310
+ if cached is not None:
311
+ return cached # type: ignore[return-value]
312
+ results = self.search(
313
+ query,
314
+ owner=owner,
315
+ scope=scope,
316
+ requester_agent_id=requester_agent_id,
317
+ requester_team_id=requester_team_id,
318
+ limit=limit,
319
+ profile=active_profile,
320
+ )
321
+ report = build_context_pack_report(results, max_tokens=max_tokens)
322
+ if auto_reinforce:
323
+ # Selection is the recall event: close the reinforcement loop here
324
+ # so callers (especially MCP clients) don't have to remember to.
325
+ selected = [decision.memory_id for decision in report.decisions if decision.selected]
326
+ if selected:
327
+ self.store.record_recall(selected)
328
+ self.cache.clear()
329
+ return report
330
+ self.cache.set(key, report)
331
+ return report
332
+
333
+ def stats(self) -> dict[str, object]:
334
+ return self.store.stats() | {"cache_items": len(self.cache)}
335
+
336
+ def rebuild_indexes(self) -> dict[str, int]:
337
+ result = self.store.rebuild_indexes()
338
+ self.cache.clear()
339
+ return result
340
+
341
+ def close(self) -> None:
342
+ self.store.close()
343
+
344
+ def offload_context(
345
+ self,
346
+ snapshot_data: dict[str, Any],
347
+ session_id: str,
348
+ trigger: str = "manual",
349
+ ) -> str:
350
+ """
351
+ Saves the current agent state as a ContextSnapshot memory record.
352
+ """
353
+ from .schema import ContextSnapshot
354
+ snapshot = ContextSnapshot(
355
+ session_id=session_id,
356
+ snapshot_data=snapshot_data,
357
+ trigger=trigger,
358
+ )
359
+ record = snapshot.to_record()
360
+ # Ensure the session_id is in the content for FTS searchability
361
+ # ContextSnapshot.to_record currently only puts session_id in source.
362
+ # We add it to the content to ensure reload_context search works.
363
+ record.content = f"session_id:{session_id}\n{record.content}"
364
+
365
+ from dataclasses import asdict
366
+ record_dict = asdict(record)
367
+ content = record_dict.pop("content")
368
+ saved = self.add(content, **record_dict)
369
+ return saved.id
370
+
371
+ def reload_context(
372
+ self,
373
+ session_id: str,
374
+ snapshot_id: str | None = None,
375
+ ) -> dict[str, Any]:
376
+ """
377
+ Retrieves the specified or most recent snapshot for the given session.
378
+ """
379
+ if snapshot_id:
380
+ record = self.get(snapshot_id)
381
+ if not record:
382
+ raise ValueError(f"Snapshot {snapshot_id} not found")
383
+ else:
384
+ # Latest is a recency question, not a relevance question: FTS
385
+ # ranking picks an arbitrary snapshot when timestamps tie.
386
+ record = self.store.latest_snapshot_record(session_id)
387
+ if not record:
388
+ raise ValueError(f"No snapshots found for session {session_id}")
389
+
390
+ # The content carries a session_id prefix for FTS searchability
391
+ raw_content = record.content
392
+ if raw_content.startswith(f"session_id:{session_id}\n"):
393
+ raw_content = raw_content[len(f"session_id:{session_id}\n"):]
394
+
395
+ return json.loads(raw_content)
396
+
397
+ def resonance_search(
398
+ self,
399
+ query: str,
400
+ *,
401
+ limit: int = 5,
402
+ resonance_hops: int = 2,
403
+ ) -> list[SearchResult]:
404
+ """
405
+ Enhanced retrieval using Memory Resonance logic.
406
+ 1. Perform standard semantic search to find seed chunks.
407
+ 2. Expand cluster using resonance weights.
408
+ 3. Merge and rank results based on final resonance scores.
409
+ """
410
+ # 1. Get seed chunks via semantic search
411
+ seeds = self.search(query, limit=limit * 2)
412
+ if not seeds:
413
+ return []
414
+
415
+ seed_ids = [res.record.id for res in seeds]
416
+
417
+ # 2. Use ResonanceIndex to expand
418
+ from .memory_resonance import ERATripletIndex, MemoryChunk
419
+ idx = ERATripletIndex()
420
+
421
+ for res in seeds:
422
+ ts = datetime.fromisoformat(res.record.updated_at.replace('Z', '+00:00')).timestamp()
423
+ idx.add_chunk(MemoryChunk(id=res.record.id, text=res.record.content, timestamp=ts))
424
+
425
+ resonant_ids = idx.resonance_cluster(seed_ids, hops=resonance_hops)
426
+
427
+ final_results = []
428
+ id_map = {res.id: res for res in seeds}
429
+ for rid in resonant_ids:
430
+ if rid in id_map:
431
+ final_results.append(id_map[rid])
432
+ if len(final_results) >= limit:
433
+ break
434
+ return final_results