agentcache-core 0.9.9__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.
- agentcache/__init__.py +29 -0
- agentcache/app.py +312 -0
- agentcache/cli.py +346 -0
- agentcache/connect.py +724 -0
- agentcache/core/__init__.py +19 -0
- agentcache/core/audit_log.py +104 -0
- agentcache/core/config.py +51 -0
- agentcache/core/context_builder.py +209 -0
- agentcache/core/graph.py +120 -0
- agentcache/core/image_store.py +111 -0
- agentcache/core/infer.py +142 -0
- agentcache/core/kv_scopes.py +86 -0
- agentcache/core/lessons.py +242 -0
- agentcache/core/llm.py +596 -0
- agentcache/core/memory_store.py +207 -0
- agentcache/core/observation_store.py +576 -0
- agentcache/core/privacy.py +34 -0
- agentcache/core/project_profile.py +625 -0
- agentcache/core/search_service.py +444 -0
- agentcache/core/session_store.py +382 -0
- agentcache/core/slots.py +504 -0
- agentcache/db.py +359 -0
- agentcache/import_data.py +86 -0
- agentcache/legacy.py +94 -0
- agentcache/mcp_stdio.py +141 -0
- agentcache/py.typed +0 -0
- agentcache/replay_import.py +680 -0
- agentcache/routes/__init__.py +29 -0
- agentcache/routes/_deps.py +46 -0
- agentcache/routes/auth.py +68 -0
- agentcache/routes/graph.py +81 -0
- agentcache/routes/health.py +149 -0
- agentcache/routes/mcp.py +614 -0
- agentcache/routes/memories.py +116 -0
- agentcache/routes/migration.py +32 -0
- agentcache/routes/observations.py +253 -0
- agentcache/routes/search.py +80 -0
- agentcache/search.py +935 -0
- agentcache/storage/__init__.py +29 -0
- agentcache/storage/images.py +102 -0
- agentcache/storage/paths.py +116 -0
- agentcache/storage/scopes.py +9 -0
- agentcache/viewer/favicon.svg +1 -0
- agentcache/viewer/index.html +4235 -0
- agentcache/viewer_helpers.py +61 -0
- agentcache/workers.py +192 -0
- agentcache_core-0.9.9.dist-info/METADATA +194 -0
- agentcache_core-0.9.9.dist-info/RECORD +52 -0
- agentcache_core-0.9.9.dist-info/WHEEL +5 -0
- agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
- agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
- agentcache_core-0.9.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentcache.core — deep, injectable modules for the agentcache system.
|
|
3
|
+
|
|
4
|
+
Modules:
|
|
5
|
+
kv_scopes — KV scope key registry (shared by all stores and routes)
|
|
6
|
+
search_service — SearchService (BM25 + vector indexing and querying)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .kv_scopes import KV
|
|
10
|
+
from .observation_store import ObservationEvents, ObservationStore
|
|
11
|
+
from .search_service import IndexPersistence, SearchService
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"KV",
|
|
15
|
+
"SearchService",
|
|
16
|
+
"IndexPersistence",
|
|
17
|
+
"ObservationStore",
|
|
18
|
+
"ObservationEvents",
|
|
19
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Audit log — record, query, and safe-fire audit entries."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from ..db import StateKV
|
|
7
|
+
from ..storage.paths import generate_id
|
|
8
|
+
from .kv_scopes import KV
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def record_audit(
|
|
12
|
+
kv: StateKV,
|
|
13
|
+
operation: str,
|
|
14
|
+
function_id: str,
|
|
15
|
+
target_ids: List[str],
|
|
16
|
+
details: Dict[str, Any] = {},
|
|
17
|
+
quality_score: Optional[float] = None,
|
|
18
|
+
user_id: Optional[str] = None,
|
|
19
|
+
) -> Dict[str, Any]:
|
|
20
|
+
entry = {
|
|
21
|
+
"id": generate_id("aud"),
|
|
22
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc)
|
|
23
|
+
.isoformat()
|
|
24
|
+
.replace("+00:00", "Z"),
|
|
25
|
+
"operation": operation,
|
|
26
|
+
"userId": user_id,
|
|
27
|
+
"functionId": function_id,
|
|
28
|
+
"targetIds": target_ids,
|
|
29
|
+
"details": details,
|
|
30
|
+
"qualityScore": quality_score,
|
|
31
|
+
}
|
|
32
|
+
kv.set(KV.audit, entry["id"], entry)
|
|
33
|
+
return entry
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def safe_audit(
|
|
37
|
+
kv: StateKV,
|
|
38
|
+
operation: str,
|
|
39
|
+
function_id: str,
|
|
40
|
+
target_ids: List[str],
|
|
41
|
+
details: Dict[str, Any] = {},
|
|
42
|
+
quality_score: Optional[float] = None,
|
|
43
|
+
user_id: Optional[str] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
try:
|
|
46
|
+
record_audit(
|
|
47
|
+
kv, operation, function_id, target_ids, details, quality_score, user_id
|
|
48
|
+
)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
print(f"[audit] Failed to write audit: {e}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def query_audit(
|
|
54
|
+
kv: StateKV, filter_opts: Optional[Dict[str, Any]] = None
|
|
55
|
+
) -> List[Dict[str, Any]]:
|
|
56
|
+
all_entries = kv.list(KV.audit)
|
|
57
|
+
entries = sorted(all_entries, key=lambda x: x.get("timestamp", ""), reverse=True)
|
|
58
|
+
if not filter_opts:
|
|
59
|
+
return entries[:100]
|
|
60
|
+
|
|
61
|
+
op = filter_opts.get("operation")
|
|
62
|
+
if op:
|
|
63
|
+
entries = [e for e in entries if e.get("operation") == op]
|
|
64
|
+
|
|
65
|
+
import dateutil.parser
|
|
66
|
+
|
|
67
|
+
date_from = filter_opts.get("dateFrom")
|
|
68
|
+
if date_from:
|
|
69
|
+
try:
|
|
70
|
+
dt_from = dateutil.parser.parse(date_from).replace(tzinfo=None)
|
|
71
|
+
filtered_entries = []
|
|
72
|
+
for e in entries:
|
|
73
|
+
ts = e.get("timestamp")
|
|
74
|
+
if ts:
|
|
75
|
+
try:
|
|
76
|
+
dt_ts = dateutil.parser.parse(ts).replace(tzinfo=None)
|
|
77
|
+
if dt_ts >= dt_from:
|
|
78
|
+
filtered_entries.append(e)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
entries = filtered_entries
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
date_to = filter_opts.get("dateTo")
|
|
86
|
+
if date_to:
|
|
87
|
+
try:
|
|
88
|
+
dt_to = dateutil.parser.parse(date_to).replace(tzinfo=None)
|
|
89
|
+
filtered_entries = []
|
|
90
|
+
for e in entries:
|
|
91
|
+
ts = e.get("timestamp")
|
|
92
|
+
if ts:
|
|
93
|
+
try:
|
|
94
|
+
dt_ts = dateutil.parser.parse(ts).replace(tzinfo=None)
|
|
95
|
+
if dt_ts <= dt_to:
|
|
96
|
+
filtered_entries.append(e)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
entries = filtered_entries
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
limit = filter_opts.get("limit", 100)
|
|
104
|
+
return entries[:limit]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Agent configuration helpers — env-var readers and Dolt commit helper."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..db import StateKV
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_agent_id() -> Optional[str]:
|
|
10
|
+
return os.getenv("AGENT_ID") or None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def commit_if_enabled(
|
|
14
|
+
kv: StateKV, message: str, agent_id: Optional[str]
|
|
15
|
+
) -> Optional[str]:
|
|
16
|
+
return kv.commit_version(message, agent_id or "unknown-agent")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_agent_scope_isolated() -> bool:
|
|
20
|
+
return (
|
|
21
|
+
os.getenv("AGENTCACHE_AGENT_SCOPE") or os.getenv("AGENTMEMORY_AGENT_SCOPE")
|
|
22
|
+
) == "isolated"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_auto_compress_enabled() -> bool:
|
|
26
|
+
return (
|
|
27
|
+
os.getenv("AGENTCACHE_AUTO_COMPRESS") or os.getenv("AGENTMEMORY_AUTO_COMPRESS")
|
|
28
|
+
) == "true"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_slots_enabled() -> bool:
|
|
32
|
+
return (os.getenv("AGENTCACHE_SLOTS") or os.getenv("AGENTMEMORY_SLOTS")) == "true"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_reflect_enabled() -> bool:
|
|
36
|
+
return (
|
|
37
|
+
os.getenv("AGENTCACHE_REFLECT") or os.getenv("AGENTMEMORY_REFLECT")
|
|
38
|
+
) == "true"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_graph_extraction_enabled() -> bool:
|
|
42
|
+
return os.getenv("GRAPH_EXTRACTION_ENABLED") == "true"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_consolidation_enabled() -> bool:
|
|
46
|
+
val = os.getenv("CONSOLIDATION_ENABLED")
|
|
47
|
+
if val in ("false", "0"):
|
|
48
|
+
return False
|
|
49
|
+
if val in ("true", "1"):
|
|
50
|
+
return True
|
|
51
|
+
return bool(os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"))
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Prompt context compilation — assembles memory blocks into XML context string."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..db import StateKV
|
|
9
|
+
from .kv_scopes import KV
|
|
10
|
+
from .slots import list_pinned_slots, render_pinned_context
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def estimate_tokens(text: str) -> int:
|
|
14
|
+
return int(len(text) / 3)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def escape_xml_attr(s: str) -> str:
|
|
18
|
+
return (
|
|
19
|
+
s.replace("&", "&")
|
|
20
|
+
.replace('"', """)
|
|
21
|
+
.replace("<", "<")
|
|
22
|
+
.replace(">", ">")
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_xml_wrappers(raw: str) -> str:
|
|
27
|
+
if not raw:
|
|
28
|
+
return ""
|
|
29
|
+
cleaned = raw.strip()
|
|
30
|
+
cleaned = re.sub(r"```xml\s*\n?", "", cleaned, flags=re.IGNORECASE)
|
|
31
|
+
cleaned = re.sub(r"```", "", cleaned)
|
|
32
|
+
cleaned = cleaned.strip()
|
|
33
|
+
root_match = re.search(
|
|
34
|
+
r"(<[a-zA-Z_][a-zA-Z0-9_-]*>[\s\S]*<\/[a-zA-Z_][a-zA-Z0-9_-]*>)", cleaned
|
|
35
|
+
)
|
|
36
|
+
if root_match:
|
|
37
|
+
return root_match.group(1).strip()
|
|
38
|
+
return cleaned
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_xml_tag(text: str, tag: str) -> Optional[str]:
|
|
42
|
+
cleaned = strip_xml_wrappers(text)
|
|
43
|
+
pattern = rf"<{tag}>(.*?)</{tag}>"
|
|
44
|
+
match = re.search(pattern, cleaned, re.DOTALL)
|
|
45
|
+
return match.group(1).strip() if match else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_xml_children(text: str, parent_tag: str, child_tag: str) -> List[str]:
|
|
49
|
+
parent_content = get_xml_tag(text, parent_tag)
|
|
50
|
+
if not parent_content:
|
|
51
|
+
return []
|
|
52
|
+
pattern = rf"<{child_tag}>(.*?)</{child_tag}>"
|
|
53
|
+
return [m.strip() for m in re.findall(pattern, parent_content, re.DOTALL)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def context(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
57
|
+
session_id = data.get("sessionId")
|
|
58
|
+
project = data.get("project")
|
|
59
|
+
budget = data.get("budget") or int(os.getenv("TOKEN_BUDGET", "2000"))
|
|
60
|
+
|
|
61
|
+
if not session_id or not project:
|
|
62
|
+
raise ValueError("sessionId and project are required")
|
|
63
|
+
|
|
64
|
+
blocks = []
|
|
65
|
+
|
|
66
|
+
# 1. Pinned Slots
|
|
67
|
+
pinned_slots = list_pinned_slots(kv, project)
|
|
68
|
+
slot_content = render_pinned_context(pinned_slots)
|
|
69
|
+
if slot_content:
|
|
70
|
+
blocks.append(
|
|
71
|
+
{
|
|
72
|
+
"type": "memory",
|
|
73
|
+
"content": slot_content,
|
|
74
|
+
"tokens": estimate_tokens(slot_content),
|
|
75
|
+
"recency": int(time.time() * 1000),
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 2. Profile
|
|
80
|
+
profile = kv.get(KV.profiles, project)
|
|
81
|
+
if profile:
|
|
82
|
+
profile_parts = []
|
|
83
|
+
if profile.get("topConcepts"):
|
|
84
|
+
profile_parts.append(
|
|
85
|
+
"Concepts: "
|
|
86
|
+
+ ", ".join([c["concept"] for c in profile["topConcepts"][:8]])
|
|
87
|
+
)
|
|
88
|
+
if profile.get("topFiles"):
|
|
89
|
+
profile_parts.append(
|
|
90
|
+
"Key files: " + ", ".join([f["file"] for f in profile["topFiles"][:5]])
|
|
91
|
+
)
|
|
92
|
+
if profile.get("conventions"):
|
|
93
|
+
profile_parts.append("Conventions: " + "; ".join(profile["conventions"]))
|
|
94
|
+
if profile.get("commonErrors"):
|
|
95
|
+
profile_parts.append(
|
|
96
|
+
"Common errors: " + "; ".join(profile["commonErrors"][:3])
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if profile_parts:
|
|
100
|
+
profile_content = "## Project Profile\n" + "\n".join(profile_parts)
|
|
101
|
+
blocks.append(
|
|
102
|
+
{
|
|
103
|
+
"type": "memory",
|
|
104
|
+
"content": profile_content,
|
|
105
|
+
"tokens": estimate_tokens(profile_content),
|
|
106
|
+
"recency": int(time.time() * 1000),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# 3. Lessons
|
|
111
|
+
lessons = kv.list(KV.lessons)
|
|
112
|
+
relevant_lessons = [
|
|
113
|
+
les
|
|
114
|
+
for les in lessons
|
|
115
|
+
if not les.get("deleted")
|
|
116
|
+
and (not les.get("project") or les["project"] == project)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def lesson_score(les):
|
|
120
|
+
factor = 1.5 if les.get("project") == project else 1.0
|
|
121
|
+
return factor * les.get("confidence", 0.5)
|
|
122
|
+
|
|
123
|
+
relevant_lessons.sort(key=lesson_score, reverse=True)
|
|
124
|
+
relevant_lessons = relevant_lessons[:10]
|
|
125
|
+
|
|
126
|
+
if relevant_lessons:
|
|
127
|
+
items = []
|
|
128
|
+
for les in relevant_lessons:
|
|
129
|
+
desc = f"- ({les['confidence']:.2f}) {les['content']}"
|
|
130
|
+
if les.get("context"):
|
|
131
|
+
desc += f" — {les['context']}"
|
|
132
|
+
items.append(desc)
|
|
133
|
+
lessons_content = "## Lessons Learned\n" + "\n".join(items)
|
|
134
|
+
blocks.append(
|
|
135
|
+
{
|
|
136
|
+
"type": "memory",
|
|
137
|
+
"content": lessons_content,
|
|
138
|
+
"tokens": estimate_tokens(lessons_content),
|
|
139
|
+
"recency": int(time.time() * 1000),
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# 4. Sessions & Summaries
|
|
144
|
+
all_sessions = kv.list(KV.sessions)
|
|
145
|
+
sessions = [
|
|
146
|
+
s for s in all_sessions if s.get("project") == project and s["id"] != session_id
|
|
147
|
+
]
|
|
148
|
+
sessions.sort(key=lambda s: s.get("startedAt", ""), reverse=True)
|
|
149
|
+
sessions = sessions[:10]
|
|
150
|
+
|
|
151
|
+
for s in sessions:
|
|
152
|
+
summary = kv.get(KV.summaries, s["id"])
|
|
153
|
+
if summary:
|
|
154
|
+
content = (
|
|
155
|
+
f"## {summary.get('title', 'Session summary')}\n{summary.get('narrative', '')}\n"
|
|
156
|
+
f"Decisions: {'; '.join(summary.get('keyDecisions', []))}\n"
|
|
157
|
+
f"Files: {', '.join(summary.get('filesModified', []))}"
|
|
158
|
+
)
|
|
159
|
+
blocks.append(
|
|
160
|
+
{
|
|
161
|
+
"type": "summary",
|
|
162
|
+
"content": content,
|
|
163
|
+
"tokens": estimate_tokens(content),
|
|
164
|
+
"recency": int(time.time() * 1000),
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
obs_list = kv.list(KV.observations(s["id"]))
|
|
169
|
+
important = [
|
|
170
|
+
o for o in obs_list if o.get("title") and o.get("importance", 0) >= 5
|
|
171
|
+
]
|
|
172
|
+
if important:
|
|
173
|
+
important.sort(key=lambda o: o.get("importance", 0), reverse=True)
|
|
174
|
+
top = important[:5]
|
|
175
|
+
items = [
|
|
176
|
+
f"- [{o.get('type')}] {o.get('title')}: {o.get('narrative')}"
|
|
177
|
+
for o in top
|
|
178
|
+
]
|
|
179
|
+
content = (
|
|
180
|
+
f"## Session {s['id'][:8]} ({s.get('startedAt')})\n"
|
|
181
|
+
+ "\n".join(items)
|
|
182
|
+
)
|
|
183
|
+
blocks.append(
|
|
184
|
+
{
|
|
185
|
+
"type": "observation",
|
|
186
|
+
"content": content,
|
|
187
|
+
"tokens": estimate_tokens(content),
|
|
188
|
+
"recency": int(time.time() * 1000),
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
blocks.sort(key=lambda b: b.get("recency", 0), reverse=True)
|
|
193
|
+
|
|
194
|
+
header = f'<agentcache-context project="{escape_xml_attr(project)}">'
|
|
195
|
+
footer = "</agentcache-context>"
|
|
196
|
+
used_tokens = estimate_tokens(header) + estimate_tokens(footer)
|
|
197
|
+
|
|
198
|
+
selected = []
|
|
199
|
+
for b in blocks:
|
|
200
|
+
if used_tokens + b["tokens"] > budget:
|
|
201
|
+
continue
|
|
202
|
+
selected.append(b["content"])
|
|
203
|
+
used_tokens += b["tokens"]
|
|
204
|
+
|
|
205
|
+
if not selected:
|
|
206
|
+
return {"context": "", "blocks": 0, "tokens": 0}
|
|
207
|
+
|
|
208
|
+
res_context = f"{header}\n" + "\n\n".join(selected) + f"\n{footer}"
|
|
209
|
+
return {"context": res_context, "blocks": len(selected), "tokens": used_tokens}
|
agentcache/core/graph.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Folder graph builder for the viewer's Graph tab."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, List, Set, Tuple
|
|
5
|
+
|
|
6
|
+
from ..db import StateKV
|
|
7
|
+
from .config import get_agent_id, is_agent_scope_isolated
|
|
8
|
+
from .kv_scopes import KV
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def folder_color(path: str) -> str:
|
|
12
|
+
"""Hash a folder path string to an HSL color string.
|
|
13
|
+
|
|
14
|
+
Replicates the JS ``folderColor(id)`` function in src/viewer/index.html
|
|
15
|
+
exactly, using the light-mode lightness range (38 + h%14).
|
|
16
|
+
"""
|
|
17
|
+
h = 0
|
|
18
|
+
for ch in path:
|
|
19
|
+
h = (h * 31 + ord(ch)) & 0xFFFFFFF
|
|
20
|
+
|
|
21
|
+
hue = (h % 360 + 360) % 360
|
|
22
|
+
sat_pct = 55 + (h % 25)
|
|
23
|
+
lig_pct = 38 + (h % 14)
|
|
24
|
+
|
|
25
|
+
return f"hsl({hue}, {sat_pct}%, {lig_pct}%)"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def folder_graph_build(kv: StateKV) -> Dict[str, Any]:
|
|
29
|
+
"""Build graph data for the viewer's Graph tab."""
|
|
30
|
+
index_entries = kv.list(KV.folders)
|
|
31
|
+
if is_agent_scope_isolated():
|
|
32
|
+
aid = get_agent_id()
|
|
33
|
+
if aid:
|
|
34
|
+
index_entries = [e for e in index_entries if e.get("agentId") == aid]
|
|
35
|
+
|
|
36
|
+
folder_map: Dict[str, Dict[str, Any]] = {}
|
|
37
|
+
pair_obs_texts: Dict[Tuple[str, str], str] = {}
|
|
38
|
+
|
|
39
|
+
for entry in index_entries:
|
|
40
|
+
fp = entry.get("folderPath", "")
|
|
41
|
+
aid = entry.get("agentId", "")
|
|
42
|
+
if not fp:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
if fp not in folder_map:
|
|
46
|
+
folder_map[fp] = {
|
|
47
|
+
"folderPath": fp,
|
|
48
|
+
"agentIds": set(),
|
|
49
|
+
"obsCount": 0,
|
|
50
|
+
"color": folder_color(fp),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
folder_map[fp]["agentIds"].add(aid)
|
|
54
|
+
folder_map[fp]["obsCount"] += entry.get("obsCount", 0)
|
|
55
|
+
|
|
56
|
+
obs_scope = KV.folder_obs(fp, aid)
|
|
57
|
+
obs_list = kv.list(obs_scope)
|
|
58
|
+
combined_parts = []
|
|
59
|
+
for obs in obs_list:
|
|
60
|
+
text = obs.get("text") or ""
|
|
61
|
+
title = obs.get("title") or ""
|
|
62
|
+
combined_parts.append(f"{text} {title}")
|
|
63
|
+
pair_obs_texts[(fp, aid)] = " ".join(combined_parts)
|
|
64
|
+
|
|
65
|
+
nodes = []
|
|
66
|
+
for fp, info in folder_map.items():
|
|
67
|
+
nodes.append(
|
|
68
|
+
{
|
|
69
|
+
"id": fp,
|
|
70
|
+
"label": os.path.basename(fp) or fp,
|
|
71
|
+
"folderPath": fp,
|
|
72
|
+
"agentIds": sorted(info["agentIds"]),
|
|
73
|
+
"obsCount": info["obsCount"],
|
|
74
|
+
"color": info["color"],
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
edges: List[Dict[str, Any]] = []
|
|
79
|
+
seen_edges: Set[Tuple[Any, str]] = set()
|
|
80
|
+
|
|
81
|
+
def add_edge(edge: Dict[str, Any]) -> None:
|
|
82
|
+
key = (frozenset([edge["source"], edge["target"]]), edge["type"])
|
|
83
|
+
if key not in seen_edges:
|
|
84
|
+
seen_edges.add(key)
|
|
85
|
+
edges.append(edge)
|
|
86
|
+
|
|
87
|
+
folder_paths = list(folder_map.keys())
|
|
88
|
+
|
|
89
|
+
for i in range(len(folder_paths)):
|
|
90
|
+
for j in range(i + 1, len(folder_paths)):
|
|
91
|
+
a = folder_paths[i]
|
|
92
|
+
b = folder_paths[j]
|
|
93
|
+
if a.rsplit("/", 1)[0] == b.rsplit("/", 1)[0] and "/" in a and "/" in b:
|
|
94
|
+
add_edge({"source": a, "target": b, "type": "same-parent"})
|
|
95
|
+
elif os.path.dirname(a) == os.path.dirname(b) and os.path.dirname(a) != "":
|
|
96
|
+
add_edge({"source": a, "target": b, "type": "same-parent"})
|
|
97
|
+
|
|
98
|
+
for (fp_a, _agent_a), text_a in pair_obs_texts.items():
|
|
99
|
+
for fp_b in folder_paths:
|
|
100
|
+
if fp_b != fp_a and fp_b in text_a:
|
|
101
|
+
add_edge({"source": fp_a, "target": fp_b, "type": "cross-ref"})
|
|
102
|
+
|
|
103
|
+
agent_to_folders: Dict[str, List[str]] = {}
|
|
104
|
+
for fp, info in folder_map.items():
|
|
105
|
+
for aid in info["agentIds"]:
|
|
106
|
+
agent_to_folders.setdefault(aid, []).append(fp)
|
|
107
|
+
|
|
108
|
+
for aid, fps in agent_to_folders.items():
|
|
109
|
+
for i in range(len(fps)):
|
|
110
|
+
for j in range(i + 1, len(fps)):
|
|
111
|
+
add_edge(
|
|
112
|
+
{
|
|
113
|
+
"source": fps[i],
|
|
114
|
+
"target": fps[j],
|
|
115
|
+
"type": "agent-shared",
|
|
116
|
+
"agentId": aid,
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return {"nodes": nodes, "edges": edges}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Image storage helpers — disk persistence and extraction from payloads."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
IMAGES_DIR = os.path.join(os.path.expanduser("~"), ".agentcache", "images")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_max_bytes() -> int:
|
|
11
|
+
return int(
|
|
12
|
+
os.getenv("AGENTCACHE_IMAGE_STORE_MAX_BYTES")
|
|
13
|
+
or os.getenv("AGENTMEMORY_IMAGE_STORE_MAX_BYTES")
|
|
14
|
+
or 500 * 1024 * 1024
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_managed_image_path(file_path: str) -> bool:
|
|
19
|
+
if not file_path:
|
|
20
|
+
return False
|
|
21
|
+
resolved = os.path.abspath(file_path)
|
|
22
|
+
normalized_images_dir = os.path.abspath(IMAGES_DIR)
|
|
23
|
+
return (
|
|
24
|
+
resolved.startswith(normalized_images_dir + os.sep)
|
|
25
|
+
or resolved == normalized_images_dir
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_image_to_disk(base64_data: str) -> Tuple[str, int]:
|
|
30
|
+
if not base64_data:
|
|
31
|
+
return "", 0
|
|
32
|
+
|
|
33
|
+
if not os.path.exists(IMAGES_DIR):
|
|
34
|
+
os.makedirs(IMAGES_DIR, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
clean_base64 = base64_data
|
|
37
|
+
ext = "png"
|
|
38
|
+
|
|
39
|
+
if base64_data.startswith("data:image/"):
|
|
40
|
+
comma_idx = base64_data.find(",")
|
|
41
|
+
if comma_idx != -1:
|
|
42
|
+
meta = base64_data[:comma_idx]
|
|
43
|
+
if "jpeg" in meta or "jpg" in meta:
|
|
44
|
+
ext = "jpg"
|
|
45
|
+
elif "webp" in meta:
|
|
46
|
+
ext = "webp"
|
|
47
|
+
elif "gif" in meta:
|
|
48
|
+
ext = "gif"
|
|
49
|
+
clean_base64 = base64_data[comma_idx + 1 :]
|
|
50
|
+
elif base64_data.startswith("/9j/"):
|
|
51
|
+
ext = "jpg"
|
|
52
|
+
|
|
53
|
+
h = hashlib.sha256(clean_base64.encode("utf-8")).hexdigest()
|
|
54
|
+
file_path = os.path.join(IMAGES_DIR, f"{h}.{ext}")
|
|
55
|
+
|
|
56
|
+
if os.path.exists(file_path):
|
|
57
|
+
return file_path, 0
|
|
58
|
+
|
|
59
|
+
import base64
|
|
60
|
+
|
|
61
|
+
buffer = base64.b64decode(clean_base64)
|
|
62
|
+
with open(file_path, "wb") as f:
|
|
63
|
+
f.write(buffer)
|
|
64
|
+
|
|
65
|
+
size = os.path.getsize(file_path)
|
|
66
|
+
return file_path, size
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def delete_image(file_path: Optional[str]) -> int:
|
|
70
|
+
if not file_path or not is_managed_image_path(file_path):
|
|
71
|
+
return 0
|
|
72
|
+
try:
|
|
73
|
+
if os.path.exists(file_path):
|
|
74
|
+
size = os.path.getsize(file_path)
|
|
75
|
+
os.remove(file_path)
|
|
76
|
+
return size
|
|
77
|
+
except Exception as e:
|
|
78
|
+
print(f"[agentcache] Failed to delete image context: {e}")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def touch_image(file_path: str) -> None:
|
|
83
|
+
if not file_path or not is_managed_image_path(file_path):
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
if os.path.exists(file_path):
|
|
87
|
+
os.utime(file_path, None)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_image(d: Any) -> Optional[str]:
|
|
93
|
+
if not d:
|
|
94
|
+
return None
|
|
95
|
+
if isinstance(d, str):
|
|
96
|
+
if (
|
|
97
|
+
d.startswith("data:image/")
|
|
98
|
+
or d.startswith("iVBORw0KGgo")
|
|
99
|
+
or d.startswith("/9j/")
|
|
100
|
+
):
|
|
101
|
+
return d
|
|
102
|
+
return None
|
|
103
|
+
if isinstance(d, dict):
|
|
104
|
+
for k in ["image_data", "image_path", "imageBase64", "imagePath"]:
|
|
105
|
+
if isinstance(d.get(k), str):
|
|
106
|
+
return d[k]
|
|
107
|
+
for key, val in d.items():
|
|
108
|
+
match = extract_image(val)
|
|
109
|
+
if match:
|
|
110
|
+
return match
|
|
111
|
+
return None
|