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
agentcache/db.py ADDED
@@ -0,0 +1,359 @@
1
+ import atexit
2
+ import json
3
+ import os
4
+ import sqlite3
5
+ import threading
6
+ import time
7
+ from typing import Any, Dict, List, Optional, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+ DB_PATH = os.path.join(os.path.expanduser("~"), ".agentcache", "agentcache.db")
12
+
13
+
14
+ class StateKV:
15
+ def __init__(self, db_path: Optional[str] = None):
16
+ self.db_path = db_path or os.getenv("AGENTCACHE_DB_PATH") or DB_PATH
17
+ self._lock = threading.Lock()
18
+ # Per-thread persistent connection pool (A3.1)
19
+ self._local = threading.local()
20
+ self._init_db()
21
+ # Register WAL checkpoint on graceful shutdown (A3.2)
22
+ atexit.register(self._wal_checkpoint)
23
+
24
+ def _get_conn(self) -> sqlite3.Connection:
25
+ """Return a per-thread persistent connection, creating one if needed (A3.1)."""
26
+ conn = getattr(self._local, "conn", None)
27
+ if conn is None:
28
+ conn = sqlite3.connect(self.db_path, check_same_thread=False, timeout=30.0)
29
+ conn.row_factory = sqlite3.Row
30
+ conn.execute("PRAGMA journal_mode=WAL")
31
+ conn.execute("PRAGMA synchronous=NORMAL")
32
+ conn.execute("PRAGMA foreign_keys=ON")
33
+ conn.execute("PRAGMA journal_size_limit = 67108864") # 64MB WAL limit
34
+ conn.execute("PRAGMA mmap_size = 268435456") # 256MB mmap
35
+ self._local.conn = conn
36
+ return conn
37
+
38
+ def _wal_checkpoint(self) -> None:
39
+ """Flush WAL frames to the main database file on shutdown (A3.2)."""
40
+ try:
41
+ conn = getattr(self._local, "conn", None)
42
+ if conn:
43
+ conn.execute("PRAGMA wal_checkpoint(FULL)")
44
+ conn.commit()
45
+ else:
46
+ # Open a temporary connection just for the checkpoint
47
+ tmp = sqlite3.connect(self.db_path, check_same_thread=False, timeout=10)
48
+ try:
49
+ tmp.execute("PRAGMA wal_checkpoint(FULL)")
50
+ tmp.commit()
51
+ finally:
52
+ tmp.close()
53
+ print("[db] WAL checkpoint completed on shutdown.")
54
+ except Exception as e:
55
+ print(f"[db] WAL checkpoint failed: {e}")
56
+
57
+ def teardown(self) -> None:
58
+ """Close the per-thread connection and flush WAL (for explicit cleanup)."""
59
+ self._wal_checkpoint()
60
+ conn = getattr(self._local, "conn", None)
61
+ if conn:
62
+ try:
63
+ conn.close()
64
+ except Exception:
65
+ pass
66
+ self._local.conn = None
67
+
68
+ def stats(self) -> Dict[str, Any]:
69
+ """Return DB statistics for the /health endpoint (A3.3).
70
+
71
+ Returns:
72
+ {
73
+ "db_size_bytes": int,
74
+ "kv_row_count": int,
75
+ "audit_row_count": int,
76
+ "wal_size_bytes": int,
77
+ }
78
+ """
79
+ result: Dict[str, Any] = {
80
+ "db_size_bytes": 0,
81
+ "kv_row_count": 0,
82
+ "audit_row_count": 0,
83
+ "wal_size_bytes": 0,
84
+ }
85
+ try:
86
+ if os.path.exists(self.db_path):
87
+ result["db_size_bytes"] = os.path.getsize(self.db_path)
88
+ wal_path = self.db_path + "-wal"
89
+ if os.path.exists(wal_path):
90
+ result["wal_size_bytes"] = os.path.getsize(wal_path)
91
+ except Exception:
92
+ pass
93
+ try:
94
+ conn = self._get_conn()
95
+ result["kv_row_count"] = conn.execute(
96
+ "SELECT COUNT(*) FROM kv_store"
97
+ ).fetchone()[0]
98
+ result["audit_row_count"] = conn.execute(
99
+ "SELECT COUNT(*) FROM audit_log"
100
+ ).fetchone()[0]
101
+ except Exception:
102
+ pass
103
+ return result
104
+
105
+ def _init_db(self):
106
+ try:
107
+ os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
108
+ # Use a temporary direct connection for initialization (before _local is set)
109
+ conn = sqlite3.connect(self.db_path, check_same_thread=False, timeout=30.0)
110
+ conn.row_factory = sqlite3.Row
111
+ conn.execute("PRAGMA journal_mode=WAL")
112
+ conn.execute("PRAGMA synchronous=NORMAL")
113
+ conn.execute("PRAGMA foreign_keys=ON")
114
+ conn.execute("PRAGMA journal_size_limit = 67108864") # 64MB WAL limit
115
+ conn.execute("PRAGMA mmap_size = 268435456") # 256MB mmap
116
+ try:
117
+ conn.execute("""
118
+ CREATE TABLE IF NOT EXISTS kv_store (
119
+ scope TEXT NOT NULL,
120
+ key TEXT NOT NULL,
121
+ value TEXT NOT NULL,
122
+ PRIMARY KEY (scope, key)
123
+ )
124
+ """)
125
+ conn.execute(
126
+ "CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv_store(scope)"
127
+ )
128
+ conn.execute("""
129
+ CREATE TABLE IF NOT EXISTS audit_log (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ ts INTEGER NOT NULL,
132
+ agent_id TEXT NOT NULL,
133
+ message TEXT NOT NULL
134
+ )
135
+ """)
136
+ conn.execute("""
137
+ CREATE TABLE IF NOT EXISTS sync_state_metadata (
138
+ key TEXT PRIMARY KEY,
139
+ value TEXT NOT NULL
140
+ )
141
+ """)
142
+ conn.commit()
143
+ finally:
144
+ conn.close()
145
+ print(f"[db] SQLite database initialized at {self.db_path}")
146
+ except Exception as e:
147
+ print(f"[db] WARNING initializing SQLite database: {e}")
148
+
149
+ def get(self, scope: str, key: str) -> Optional[Any]:
150
+ try:
151
+ conn = self._get_conn()
152
+ row = conn.execute(
153
+ "SELECT value FROM kv_store WHERE scope = ? AND key = ?", (scope, key)
154
+ ).fetchone()
155
+ if row:
156
+ val = json.loads(row["value"])
157
+ if isinstance(val, dict) and "id" not in val:
158
+ val["id"] = key
159
+ return val
160
+ return None
161
+ except Exception as e:
162
+ print(f"[db] get failed (scope={scope}, key={key}): {e}")
163
+ return None
164
+
165
+ def _execute_write_with_retry(self, action_func):
166
+ """Helper to run a write transaction with exponential backoff on lock/busy errors."""
167
+ max_retries = 5
168
+ delay = 0.05
169
+ with self._lock:
170
+ for attempt in range(max_retries):
171
+ try:
172
+ return action_func()
173
+ except sqlite3.OperationalError as e:
174
+ err_msg = str(e).lower()
175
+ if (
176
+ "locked" in err_msg or "busy" in err_msg
177
+ ) and attempt < max_retries - 1:
178
+ time.sleep(delay)
179
+ delay *= 2
180
+ continue
181
+ raise
182
+
183
+ def set(self, scope: str, key: str, value: Any) -> Any:
184
+ def action():
185
+ conn = self._get_conn()
186
+ conn.execute(
187
+ "INSERT OR REPLACE INTO kv_store (scope, key, value) VALUES (?, ?, ?)",
188
+ (scope, key, json.dumps(value)),
189
+ )
190
+ conn.commit()
191
+ return value
192
+
193
+ try:
194
+ return self._execute_write_with_retry(action)
195
+ except Exception as e:
196
+ print(f"[db] set failed (scope={scope}, key={key}): {e}")
197
+ return value
198
+
199
+ def delete(self, scope: str, key: str) -> bool:
200
+ def action():
201
+ conn = self._get_conn()
202
+ cur = conn.execute(
203
+ "DELETE FROM kv_store WHERE scope = ? AND key = ?", (scope, key)
204
+ )
205
+ conn.commit()
206
+ return cur.rowcount > 0
207
+
208
+ try:
209
+ return self._execute_write_with_retry(action)
210
+ except Exception as e:
211
+ print(f"[db] delete failed (scope={scope}, key={key}): {e}")
212
+ return False
213
+
214
+ def list(self, scope: str) -> List[Any]:
215
+ try:
216
+ conn = self._get_conn()
217
+ rows = conn.execute(
218
+ "SELECT key, value FROM kv_store WHERE scope = ?", (scope,)
219
+ ).fetchall()
220
+ results = []
221
+ for r in rows:
222
+ val = json.loads(r["value"])
223
+ if isinstance(val, dict) and "id" not in val:
224
+ val["id"] = r["key"]
225
+ results.append(val)
226
+ return results
227
+ except Exception as e:
228
+ print(f"[db] list failed (scope={scope}): {e}")
229
+ return []
230
+
231
+ def update(self, scope: str, key: str, ops: List[Dict[str, Any]]) -> Optional[Any]:
232
+ def action():
233
+ conn = self._get_conn()
234
+ row = conn.execute(
235
+ "SELECT value FROM kv_store WHERE scope = ? AND key = ?",
236
+ (scope, key),
237
+ ).fetchone()
238
+ obj = json.loads(row["value"]) if row else {}
239
+ if not isinstance(obj, dict):
240
+ obj = {}
241
+ if "id" not in obj:
242
+ obj["id"] = key
243
+
244
+ for op in ops:
245
+ op_type = op.get("type")
246
+ path = op.get("path")
247
+ val = op.get("value")
248
+ if not path:
249
+ continue
250
+ if op_type == "set":
251
+ if "." in path:
252
+ parts = path.split(".")
253
+ curr = obj
254
+ for part in parts[:-1]:
255
+ if part not in curr or not isinstance(curr[part], dict):
256
+ curr[part] = {}
257
+ curr = curr[part]
258
+ curr[parts[-1]] = val
259
+ else:
260
+ obj[path] = val
261
+ elif op_type == "delete":
262
+ if "." in path:
263
+ parts = path.split(".")
264
+ curr = obj
265
+ for part in parts[:-1]:
266
+ if part not in curr or not isinstance(curr[part], dict):
267
+ break
268
+ curr = curr[part]
269
+ else:
270
+ curr.pop(parts[-1], None)
271
+ else:
272
+ obj.pop(path, None)
273
+
274
+ conn.execute(
275
+ "INSERT OR REPLACE INTO kv_store (scope, key, value) VALUES (?, ?, ?)",
276
+ (scope, key, json.dumps(obj)),
277
+ )
278
+ conn.commit()
279
+ return obj
280
+
281
+ try:
282
+ return self._execute_write_with_retry(action)
283
+ except Exception as e:
284
+ print(f"[db] update failed (scope={scope}, key={key}): {e}")
285
+ return None
286
+
287
+ def commit_version(self, message: str, agent_id: str) -> Optional[str]:
288
+ """Write an audit log entry instead of a Dolt commit."""
289
+ author = agent_id or "unknown-agent"
290
+
291
+ def action():
292
+ conn = self._get_conn()
293
+ cur = conn.execute(
294
+ "INSERT INTO audit_log (ts, agent_id, message) VALUES (?, ?, ?)",
295
+ (int(time.time() * 1000), author, message),
296
+ )
297
+ conn.commit()
298
+ row_id = str(cur.lastrowid)
299
+ print(f"[audit] {author}: {message} (id={row_id})")
300
+ return row_id
301
+
302
+ try:
303
+ return self._execute_write_with_retry(action)
304
+ except Exception as e:
305
+ print(f"[audit] commit_version failed: {e}")
306
+ return None
307
+
308
+ def get_audit_log(self, limit: int = 50) -> List[Dict[str, Any]]:
309
+ try:
310
+ conn = self._get_conn()
311
+ rows = conn.execute(
312
+ "SELECT id, ts, agent_id, message FROM audit_log ORDER BY ts DESC LIMIT ?",
313
+ (limit,),
314
+ ).fetchall()
315
+ return [dict(r) for r in rows]
316
+ except Exception as e:
317
+ print(f"[db] get_audit_log failed: {e}")
318
+ return []
319
+
320
+ def acquire_lock(self, lock_name: str, lease_seconds: int = 300) -> bool:
321
+ """Try to acquire a distributed lock in the database, atomic across processes."""
322
+ now = int(time.time())
323
+ lock_key = f"lock:{lock_name}"
324
+
325
+ def action():
326
+ conn = self._get_conn()
327
+ row = conn.execute(
328
+ "SELECT value FROM kv_store WHERE scope = ? AND key = ?",
329
+ ("mem:locks", lock_key),
330
+ ).fetchone()
331
+ if row:
332
+ val = json.loads(row["value"])
333
+ expires = val.get("expires", 0)
334
+ if now < expires:
335
+ return False
336
+
337
+ # Lock is expired or doesn't exist. Write new lock.
338
+ lock_data = {
339
+ "expires": now + lease_seconds,
340
+ "acquired_at": now,
341
+ "owner": f"pid-{os.getpid()}",
342
+ }
343
+ conn.execute(
344
+ "INSERT OR REPLACE INTO kv_store (scope, key, value) VALUES (?, ?, ?)",
345
+ ("mem:locks", lock_key, json.dumps(lock_data)),
346
+ )
347
+ conn.commit()
348
+ return True
349
+
350
+ try:
351
+ return self._execute_write_with_retry(action)
352
+ except Exception as e:
353
+ print(f"[db] acquire_lock failed ({lock_name}): {e}")
354
+ return False
355
+
356
+ def release_lock(self, lock_name: str) -> None:
357
+ """Release a distributed lock in the database."""
358
+ lock_key = f"lock:{lock_name}"
359
+ self.delete("mem:locks", lock_key)
@@ -0,0 +1,86 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import urllib.parse
5
+
6
+ from .db import StateKV
7
+
8
+
9
+ def import_old_data(old_db_path: str, kv: StateKV) -> bool:
10
+ if not os.path.exists(old_db_path):
11
+ print(f"[import] Error: Path {old_db_path} does not exist.")
12
+ return False
13
+
14
+ print(f"[import] Starting migration from: {old_db_path}")
15
+ count = 0
16
+
17
+ for filename in os.listdir(old_db_path):
18
+ if not filename.endswith(".bin"):
19
+ continue
20
+ if not filename.startswith("mem%3A"):
21
+ continue
22
+
23
+ # URL decode the scope name (e.g. mem%3Asessions.bin -> mem:sessions)
24
+ decoded_name = urllib.parse.unquote(filename)
25
+ scope = decoded_name[:-4] if decoded_name.endswith(".bin") else decoded_name
26
+
27
+ filepath = os.path.join(old_db_path, filename)
28
+ try:
29
+ with open(filepath, "rb") as f:
30
+ data = f.read()
31
+
32
+ if not data:
33
+ continue
34
+
35
+ # Find the last matching JSON curly brace to strip binary padding/footers
36
+ try:
37
+ last_brace = data.rindex(b"}")
38
+ json_str = data[: last_brace + 1].decode("utf-8")
39
+ records = json.loads(json_str)
40
+ except Exception as e:
41
+ print(f"[import] Skipping {filename}: could not parse JSON ({e})")
42
+ continue
43
+
44
+ print(f"[import] Scope '{scope}': found {len(records)} items. Migrating...")
45
+
46
+ for key, val in records.items():
47
+ kv.set(scope, key, val)
48
+ count += 1
49
+
50
+ except Exception as e:
51
+ print(f"[import] Failed to process {filename}: {e}")
52
+
53
+ if count > 0:
54
+ print("[import] Creating Dolt version commit...")
55
+ kv.commit_version(
56
+ "Import legacy AgentCache database from Hugging Face", "system"
57
+ )
58
+
59
+ print(f"[import] Finished! Migrated {count} total records into Dolt SQL.")
60
+ return True
61
+
62
+
63
+ if __name__ == "__main__":
64
+ # Prioritize the restored HF data in the user's home folder, falling back to local workspace data
65
+ home_path = os.path.expanduser(os.path.join("~", ".agentcache", "state_store.db"))
66
+ workspace_path = r"d:\Downloads\Projects\Other Projects\agentcache\agentcache\data\state_store.db"
67
+
68
+ if os.path.exists(home_path):
69
+ default_path = home_path
70
+ print(f"[import] Found restored HF data at: {default_path}")
71
+ else:
72
+ default_path = workspace_path
73
+ print(
74
+ f"[import] HF data not found at home path, falling back to workspace: {default_path}"
75
+ )
76
+
77
+ # Allow custom path via command line argument
78
+ if len(sys.argv) > 1:
79
+ default_path = sys.argv[1]
80
+ print(f"[import] Overriding path with CLI argument: {default_path}")
81
+
82
+ # Initialize connection
83
+ print("[import] Initializing Dolt StateKV...")
84
+ kv = StateKV()
85
+
86
+ import_old_data(default_path, kv)
agentcache/legacy.py ADDED
@@ -0,0 +1,94 @@
1
+ """Re-export shim — behaviour lives in core/*. See ADR-0001."""
2
+
3
+ import threading
4
+ from typing import Any, Dict, Optional
5
+
6
+ from .core.audit_log import * # noqa: F401,F403
7
+ from .core.config import * # noqa: F401,F403
8
+ from .core.context_builder import * # noqa: F401,F403
9
+ from .core.graph import * # noqa: F401,F403
10
+ from .core.image_store import * # noqa: F401,F403
11
+ from .core.infer import * # noqa: F401,F403
12
+ from .core.kv_scopes import KV # noqa: F401 re-exported for backward compat
13
+ from .core.lessons import * # noqa: F401,F403
14
+ from .core.llm import * # noqa: F401,F403
15
+ from .core.memory_store import * # noqa: F401,F403
16
+ from .core.observation_store import ( # noqa: F401
17
+ normalize_folder_path,
18
+ validate_agent_id,
19
+ )
20
+ from .core.privacy import * # noqa: F401,F403
21
+ from .core.project_profile import * # noqa: F401,F403
22
+ from .core.search_service import IndexPersistence # noqa: F401
23
+ from .core.session_store import * # noqa: F401,F403
24
+ from .core.slots import * # noqa: F401,F403
25
+ from .storage.paths import fingerprint_id, generate_id # noqa: F401
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Module-level state — intentionally kept here (see ADR-0001 "Out of Scope").
29
+ # core/* modules access these via lazy `from .. import legacy as _legacy`.
30
+ # ---------------------------------------------------------------------------
31
+ _search_service = None # type: ignore[assignment] SearchService | None
32
+ _stream_broadcaster = None # Callable: (payload) -> None
33
+ _dedup_locks: Dict[str, threading.Lock] = {}
34
+ _dedup_locks_meta = threading.Lock()
35
+
36
+
37
+ def set_search_service(service) -> None:
38
+ """Register the SearchService instance created by app.init_services()."""
39
+ global _search_service
40
+ _search_service = service
41
+
42
+
43
+ def set_stream_broadcaster(broadcaster) -> None:
44
+ global _stream_broadcaster
45
+ _stream_broadcaster = broadcaster
46
+
47
+
48
+ def broadcast_stream(payload: Dict[str, Any]) -> None:
49
+ if _stream_broadcaster:
50
+ try:
51
+ _stream_broadcaster(payload)
52
+ except Exception as e:
53
+ print(f"[broadcaster] Failed: {e}")
54
+
55
+
56
+ def backfill_obs_lookup_if_needed(kv) -> None:
57
+ """Ensure every folder observation has an entry in KV.obs_lookup."""
58
+ from .core.session_store import _get_observation_store
59
+
60
+ store = _get_observation_store(kv)
61
+ store.backfill_lookup()
62
+
63
+
64
+ def verify_index_sync_on_boot(kv, search_service: Optional[Any] = None) -> bool:
65
+ """Check if the search index size matches the database counts."""
66
+ try:
67
+ svc = search_service or _search_service
68
+ if svc is None:
69
+ from . import app as app_module
70
+
71
+ svc = getattr(app_module, "search_service", None)
72
+
73
+ folders = kv.list(KV.folders)
74
+ folder_obs_count = sum(int(f.get("obsCount", 0)) for f in folders)
75
+
76
+ memories = kv.list(KV.memories)
77
+ latest_memories_count = len(
78
+ [m for m in memories if m.get("isLatest") is not False]
79
+ )
80
+
81
+ total_db_count = folder_obs_count + latest_memories_count
82
+ index_size = svc.bm25_size if svc else 0
83
+
84
+ if total_db_count != index_size:
85
+ print(
86
+ f"[persistence] Index out of sync with DB (DB={total_db_count}, Index={index_size}). Rebuild required."
87
+ )
88
+ return False
89
+
90
+ print(f"[persistence] Index is in sync with DB (size={index_size}).")
91
+ return True
92
+ except Exception as e:
93
+ print(f"[persistence] verify_index_sync_on_boot failed: {e}")
94
+ return False
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ stdio MCP wrapper — bridges the MCP stdio protocol to the
4
+ agentmemory Flask HTTP API running on localhost.
5
+
6
+ Usage: python mcp_stdio.py
7
+
8
+ Antigravity sync functions have been moved to examples/antigravity_sync.py (D1.3).
9
+ All tool calls are proxied to the HTTP API.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+
16
+ import requests
17
+
18
+ BASE = (
19
+ os.getenv("AGENTCACHE_URL")
20
+ or os.getenv("AGENTMEMORY_URL")
21
+ or "http://127.0.0.1:3111"
22
+ ).rstrip("/")
23
+ if not BASE.endswith("/agentcache") and not BASE.endswith("/agentmemory"):
24
+ BASE = f"{BASE}/agentcache"
25
+
26
+ _secret = os.getenv("AGENTCACHE_SECRET") or os.getenv("AGENTMEMORY_SECRET")
27
+
28
+
29
+ def headers():
30
+ h = {"Content-Type": "application/json"}
31
+ if _secret:
32
+ h["Authorization"] = f"Bearer {_secret}"
33
+ return h
34
+
35
+
36
+ def send(msg):
37
+ line = json.dumps(msg, separators=(",", ":"))
38
+ sys.stdout.write(line + "\n")
39
+ sys.stdout.flush()
40
+
41
+
42
+ def handle(req):
43
+ method = req.get("method", "")
44
+ req_id = req.get("id")
45
+ params = req.get("params") or {}
46
+
47
+ if method == "initialize":
48
+ send(
49
+ {
50
+ "jsonrpc": "2.0",
51
+ "id": req_id,
52
+ "result": {
53
+ "protocolVersion": "2024-11-05",
54
+ "capabilities": {"tools": {}},
55
+ "serverInfo": {"name": "agentcache-local", "version": "0.9.9"},
56
+ },
57
+ }
58
+ )
59
+
60
+ elif method == "initialized":
61
+ pass # notification — no response
62
+
63
+ elif method == "ping":
64
+ send({"jsonrpc": "2.0", "id": req_id, "result": {}})
65
+
66
+ elif method == "tools/list":
67
+ try:
68
+ r = requests.get(f"{BASE}/mcp/tools", headers=headers(), timeout=5)
69
+ tools = r.json().get("tools", [])
70
+ send({"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools}})
71
+ except Exception as e:
72
+ send(
73
+ {
74
+ "jsonrpc": "2.0",
75
+ "id": req_id,
76
+ "error": {
77
+ "code": -32000,
78
+ "message": f"agentmemory unreachable: {e}",
79
+ },
80
+ }
81
+ )
82
+
83
+ elif method == "tools/call":
84
+ name = params.get("name")
85
+ args = params.get("arguments") or {}
86
+ try:
87
+ r = requests.post(
88
+ f"{BASE}/mcp/tools",
89
+ headers=headers(),
90
+ json={"name": name, "arguments": args},
91
+ timeout=30,
92
+ )
93
+ result = r.json()
94
+ # MCP expects content array
95
+ if "content" not in result:
96
+ result = {"content": [{"type": "text", "text": json.dumps(result)}]}
97
+ send({"jsonrpc": "2.0", "id": req_id, "result": result})
98
+ except Exception as e:
99
+ send(
100
+ {
101
+ "jsonrpc": "2.0",
102
+ "id": req_id,
103
+ "error": {"code": -32000, "message": str(e)},
104
+ }
105
+ )
106
+
107
+ elif req_id is not None:
108
+ send(
109
+ {
110
+ "jsonrpc": "2.0",
111
+ "id": req_id,
112
+ "error": {"code": -32601, "message": "Method not found"},
113
+ }
114
+ )
115
+
116
+
117
+ def main():
118
+ for raw_line in sys.stdin:
119
+ raw_line = raw_line.strip()
120
+ if not raw_line:
121
+ continue
122
+ try:
123
+ req = json.loads(raw_line)
124
+ except json.JSONDecodeError:
125
+ continue
126
+ try:
127
+ handle(req)
128
+ except Exception as e:
129
+ req_id = req.get("id") if isinstance(req, dict) else None
130
+ if req_id is not None:
131
+ send(
132
+ {
133
+ "jsonrpc": "2.0",
134
+ "id": req_id,
135
+ "error": {"code": -32603, "message": f"Internal error: {e}"},
136
+ }
137
+ )
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
agentcache/py.typed ADDED
File without changes