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.
- agent_memory_os/__init__.py +17 -0
- agent_memory_os/cache.py +33 -0
- agent_memory_os/candidates.py +39 -0
- agent_memory_os/cli.py +136 -0
- agent_memory_os/client.py +434 -0
- agent_memory_os/context_pack.py +231 -0
- agent_memory_os/db.py +1520 -0
- agent_memory_os/golden_recall.py +220 -0
- agent_memory_os/hermes_importer.py +193 -0
- agent_memory_os/mcp_server.py +85 -0
- agent_memory_os/memory_resonance.py +225 -0
- agent_memory_os/providers/__init__.py +5 -0
- agent_memory_os/providers/turbovec.py +190 -0
- agent_memory_os/schema.py +172 -0
- agent_memory_os/scoring.py +48 -0
- agent_memory_os/shadow_mode.py +261 -0
- agent_memory_os/web_app.py +423 -0
- agent_memory_os/web_ui.py +1063 -0
- agent_memory_os-0.2.3.dist-info/METADATA +148 -0
- agent_memory_os-0.2.3.dist-info/RECORD +24 -0
- agent_memory_os-0.2.3.dist-info/WHEEL +4 -0
- agent_memory_os-0.2.3.dist-info/entry_points.txt +3 -0
- agent_memory_os-0.2.3.dist-info/licenses/LICENSE +201 -0
- agent_memory_os-0.2.3.dist-info/licenses/NOTICE +5 -0
|
@@ -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
|
+
]
|
agent_memory_os/cache.py
ADDED
|
@@ -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
|