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.
Files changed (52) hide show
  1. agentcache/__init__.py +29 -0
  2. agentcache/app.py +312 -0
  3. agentcache/cli.py +346 -0
  4. agentcache/connect.py +724 -0
  5. agentcache/core/__init__.py +19 -0
  6. agentcache/core/audit_log.py +104 -0
  7. agentcache/core/config.py +51 -0
  8. agentcache/core/context_builder.py +209 -0
  9. agentcache/core/graph.py +120 -0
  10. agentcache/core/image_store.py +111 -0
  11. agentcache/core/infer.py +142 -0
  12. agentcache/core/kv_scopes.py +86 -0
  13. agentcache/core/lessons.py +242 -0
  14. agentcache/core/llm.py +596 -0
  15. agentcache/core/memory_store.py +207 -0
  16. agentcache/core/observation_store.py +576 -0
  17. agentcache/core/privacy.py +34 -0
  18. agentcache/core/project_profile.py +625 -0
  19. agentcache/core/search_service.py +444 -0
  20. agentcache/core/session_store.py +382 -0
  21. agentcache/core/slots.py +504 -0
  22. agentcache/db.py +359 -0
  23. agentcache/import_data.py +86 -0
  24. agentcache/legacy.py +94 -0
  25. agentcache/mcp_stdio.py +141 -0
  26. agentcache/py.typed +0 -0
  27. agentcache/replay_import.py +680 -0
  28. agentcache/routes/__init__.py +29 -0
  29. agentcache/routes/_deps.py +46 -0
  30. agentcache/routes/auth.py +68 -0
  31. agentcache/routes/graph.py +81 -0
  32. agentcache/routes/health.py +149 -0
  33. agentcache/routes/mcp.py +614 -0
  34. agentcache/routes/memories.py +116 -0
  35. agentcache/routes/migration.py +32 -0
  36. agentcache/routes/observations.py +253 -0
  37. agentcache/routes/search.py +80 -0
  38. agentcache/search.py +935 -0
  39. agentcache/storage/__init__.py +29 -0
  40. agentcache/storage/images.py +102 -0
  41. agentcache/storage/paths.py +116 -0
  42. agentcache/storage/scopes.py +9 -0
  43. agentcache/viewer/favicon.svg +1 -0
  44. agentcache/viewer/index.html +4235 -0
  45. agentcache/viewer_helpers.py +61 -0
  46. agentcache/workers.py +192 -0
  47. agentcache_core-0.9.9.dist-info/METADATA +194 -0
  48. agentcache_core-0.9.9.dist-info/RECORD +52 -0
  49. agentcache_core-0.9.9.dist-info/WHEEL +5 -0
  50. agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
  51. agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
  52. 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("&", "&amp;")
20
+ .replace('"', "&quot;")
21
+ .replace("<", "&lt;")
22
+ .replace(">", "&gt;")
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}
@@ -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