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
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
|
agentcache/mcp_stdio.py
ADDED
|
@@ -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
|