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,625 @@
|
|
|
1
|
+
"""Project profile, data export, migration, relations, auto-forget, health check."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Dict, List, Optional, Set
|
|
8
|
+
|
|
9
|
+
from ..db import StateKV
|
|
10
|
+
from ..storage.paths import generate_id
|
|
11
|
+
from .audit_log import safe_audit
|
|
12
|
+
from .config import commit_if_enabled, get_agent_id, is_agent_scope_isolated
|
|
13
|
+
from .kv_scopes import KV
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_project_profile(kv: StateKV, project: str) -> Dict[str, Any]:
|
|
17
|
+
prof = kv.get(KV.profiles, project)
|
|
18
|
+
if not prof:
|
|
19
|
+
prof = {
|
|
20
|
+
"project": project,
|
|
21
|
+
"topConcepts": [],
|
|
22
|
+
"topFiles": [],
|
|
23
|
+
"conventions": [],
|
|
24
|
+
"commonErrors": [],
|
|
25
|
+
"updatedAt": datetime.datetime.now(datetime.timezone.utc)
|
|
26
|
+
.isoformat()
|
|
27
|
+
.replace("+00:00", "Z"),
|
|
28
|
+
}
|
|
29
|
+
if not prof.get("topConcepts") and not prof.get("topFiles"):
|
|
30
|
+
prof = build_project_profile(kv, project)
|
|
31
|
+
return prof
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_project_profile(kv: StateKV, project: str) -> Dict[str, Any]:
|
|
35
|
+
import os.path as _osp
|
|
36
|
+
import re as _re
|
|
37
|
+
from collections import Counter
|
|
38
|
+
|
|
39
|
+
prof = kv.get(KV.profiles, project)
|
|
40
|
+
if not prof:
|
|
41
|
+
prof = {
|
|
42
|
+
"project": project,
|
|
43
|
+
"topConcepts": [],
|
|
44
|
+
"topFiles": [],
|
|
45
|
+
"conventions": [],
|
|
46
|
+
"commonErrors": [],
|
|
47
|
+
"updatedAt": datetime.datetime.now(datetime.timezone.utc)
|
|
48
|
+
.isoformat()
|
|
49
|
+
.replace("+00:00", "Z"),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if not prof.get("topConcepts") and not prof.get("topFiles"):
|
|
53
|
+
sessions = kv.list(KV.sessions)
|
|
54
|
+
project_sessions = [s for s in sessions if s.get("project") == project]
|
|
55
|
+
concept_counts = Counter()
|
|
56
|
+
file_counts = Counter()
|
|
57
|
+
|
|
58
|
+
def _harvest_file(path, fc, cc):
|
|
59
|
+
if not isinstance(path, str) or not path:
|
|
60
|
+
return
|
|
61
|
+
fc[path] += 1
|
|
62
|
+
parts = _re.split(r"[\\/]", path)
|
|
63
|
+
fname = parts[-1] if parts else ""
|
|
64
|
+
skip = {"tmp", "temp", "claude", "appdata", "local", "users", "windows"}
|
|
65
|
+
for part in parts[:-1]:
|
|
66
|
+
p = part.lower().strip()
|
|
67
|
+
if (
|
|
68
|
+
p
|
|
69
|
+
and len(p) > 2
|
|
70
|
+
and p not in skip
|
|
71
|
+
and not _re.match(r"^[a-z]:|^\.|^--", p)
|
|
72
|
+
):
|
|
73
|
+
cc[p] += 1
|
|
74
|
+
stem = _osp.splitext(fname)[0]
|
|
75
|
+
if stem and len(stem) > 2:
|
|
76
|
+
cc[stem.lower()] += 1
|
|
77
|
+
ext = _osp.splitext(fname)[1].lstrip(".")
|
|
78
|
+
if ext in ("py", "ts", "js", "jsx", "tsx", "go", "rs", "java", "cs", "cpp"):
|
|
79
|
+
cc[ext] += 1
|
|
80
|
+
|
|
81
|
+
for s in project_sessions:
|
|
82
|
+
sid = s.get("id", "")
|
|
83
|
+
if not sid:
|
|
84
|
+
continue
|
|
85
|
+
for o in kv.list(KV.observations(sid)):
|
|
86
|
+
for c in o.get("concepts") or []:
|
|
87
|
+
if isinstance(c, str) and c:
|
|
88
|
+
concept_counts[c] += 1
|
|
89
|
+
for f in o.get("files") or []:
|
|
90
|
+
_harvest_file(f, file_counts, concept_counts)
|
|
91
|
+
tn = o.get("toolName")
|
|
92
|
+
if tn:
|
|
93
|
+
concept_counts[tn] += 1
|
|
94
|
+
ti = o.get("toolInput")
|
|
95
|
+
if isinstance(ti, str):
|
|
96
|
+
try:
|
|
97
|
+
ti = json.loads(ti)
|
|
98
|
+
except Exception:
|
|
99
|
+
ti = {}
|
|
100
|
+
if isinstance(ti, dict):
|
|
101
|
+
for fk in ("path", "file_path", "file", "filename"):
|
|
102
|
+
_harvest_file(ti.get(fk, ""), file_counts, concept_counts)
|
|
103
|
+
narr = o.get("narrative") or o.get("raw") or ""
|
|
104
|
+
if isinstance(narr, str) and narr.startswith("{"):
|
|
105
|
+
try:
|
|
106
|
+
nd = json.loads(narr)
|
|
107
|
+
if isinstance(nd, dict):
|
|
108
|
+
tn2 = nd.get("toolName") or nd.get("tool_name")
|
|
109
|
+
if tn2:
|
|
110
|
+
concept_counts[tn2] += 1
|
|
111
|
+
for fk in ("path", "file_path", "file", "filename"):
|
|
112
|
+
_harvest_file(
|
|
113
|
+
nd.get(fk, ""), file_counts, concept_counts
|
|
114
|
+
)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
for m in kv.list(KV.memories):
|
|
119
|
+
if m.get("project") == project:
|
|
120
|
+
for c in m.get("concepts") or []:
|
|
121
|
+
if c:
|
|
122
|
+
concept_counts[c] += 1
|
|
123
|
+
for f in m.get("files") or []:
|
|
124
|
+
_harvest_file(f, file_counts, concept_counts)
|
|
125
|
+
|
|
126
|
+
prof["topConcepts"] = [
|
|
127
|
+
{"concept": c, "frequency": n} for c, n in concept_counts.most_common(20)
|
|
128
|
+
]
|
|
129
|
+
prof["topFiles"] = [
|
|
130
|
+
{"file": f, "frequency": n} for f, n in file_counts.most_common(20)
|
|
131
|
+
]
|
|
132
|
+
prof["sessionCount"] = len(project_sessions)
|
|
133
|
+
|
|
134
|
+
return prof
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def set_project_profile(
|
|
138
|
+
kv: StateKV, project: str, profile: Dict[str, Any]
|
|
139
|
+
) -> Dict[str, Any]:
|
|
140
|
+
profile["updatedAt"] = (
|
|
141
|
+
datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
142
|
+
)
|
|
143
|
+
kv.set(KV.profiles, project, profile)
|
|
144
|
+
|
|
145
|
+
commit_if_enabled(kv, f"Set project profile for {project}", get_agent_id())
|
|
146
|
+
|
|
147
|
+
return profile
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def export_data(kv: StateKV, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
151
|
+
if data is None:
|
|
152
|
+
data = {}
|
|
153
|
+
|
|
154
|
+
exported_at = (
|
|
155
|
+
datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
isolated = is_agent_scope_isolated()
|
|
159
|
+
isolated_agent_id = get_agent_id()
|
|
160
|
+
|
|
161
|
+
folder_pairs = kv.list(KV.folders)
|
|
162
|
+
folders_export = []
|
|
163
|
+
for entry in folder_pairs:
|
|
164
|
+
fp = entry.get("folderPath")
|
|
165
|
+
aid = entry.get("agentId")
|
|
166
|
+
if not fp or not aid:
|
|
167
|
+
continue
|
|
168
|
+
if isolated and isolated_agent_id and aid != isolated_agent_id:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
meta = kv.get(KV.folder_meta(fp, aid), "meta") or {
|
|
172
|
+
"folderPath": fp,
|
|
173
|
+
"agentId": aid,
|
|
174
|
+
"lastUpdated": entry.get("lastUpdated", ""),
|
|
175
|
+
"obsCount": entry.get("obsCount", 0),
|
|
176
|
+
}
|
|
177
|
+
observations = kv.list(KV.folder_obs(fp, aid))
|
|
178
|
+
folders_export.append(
|
|
179
|
+
{
|
|
180
|
+
"folderPath": fp,
|
|
181
|
+
"agentId": aid,
|
|
182
|
+
"meta": meta,
|
|
183
|
+
"observations": observations,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
memories = kv.list(KV.memories)
|
|
188
|
+
if isolated and isolated_agent_id:
|
|
189
|
+
memories = [m for m in memories if m.get("agentId") == isolated_agent_id]
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"folders": folders_export,
|
|
193
|
+
"memories": memories,
|
|
194
|
+
"exportedAt": exported_at,
|
|
195
|
+
"version": "2.0",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def migrate_sessions_to_folders(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]:
|
|
200
|
+
"""Migrate legacy session-based observations to folder-based storage.
|
|
201
|
+
Non-destructive: old mem:sessions / mem:obs:* scopes are never deleted.
|
|
202
|
+
"""
|
|
203
|
+
from .observation_store import normalize_folder_path
|
|
204
|
+
|
|
205
|
+
_MAX_PATH_LEN = 512
|
|
206
|
+
|
|
207
|
+
sessions = kv.list(KV.sessions)
|
|
208
|
+
migrated_sessions = 0
|
|
209
|
+
migrated_observations = 0
|
|
210
|
+
errors = []
|
|
211
|
+
|
|
212
|
+
for session in sessions:
|
|
213
|
+
session_id = session.get("id")
|
|
214
|
+
if not session_id:
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
fp_raw = session.get("cwd") or session.get("project") or "unknown"
|
|
218
|
+
aid = (session.get("agentId") or "unknown").strip()[:_MAX_PATH_LEN]
|
|
219
|
+
try:
|
|
220
|
+
fp = normalize_folder_path(fp_raw)
|
|
221
|
+
except ValueError:
|
|
222
|
+
fp = "unknown"
|
|
223
|
+
|
|
224
|
+
obs_list = kv.list(KV.observations(session_id))
|
|
225
|
+
session_obs_count = 0
|
|
226
|
+
for obs in obs_list:
|
|
227
|
+
obs_id = obs.get("id", "")
|
|
228
|
+
if obs_id.endswith(":raw"):
|
|
229
|
+
continue
|
|
230
|
+
folder_obs = {
|
|
231
|
+
"id": obs_id,
|
|
232
|
+
"folderPath": fp,
|
|
233
|
+
"agentId": aid,
|
|
234
|
+
"timestamp": obs.get("timestamp", ""),
|
|
235
|
+
"text": obs.get("narrative")
|
|
236
|
+
or obs.get("raw")
|
|
237
|
+
or obs.get("title")
|
|
238
|
+
or "",
|
|
239
|
+
"type": obs.get("type", "other"),
|
|
240
|
+
"title": obs.get("title", ""),
|
|
241
|
+
"concepts": obs.get("concepts") or [],
|
|
242
|
+
"files": obs.get("files") or [],
|
|
243
|
+
"importance": obs.get("importance", 5),
|
|
244
|
+
}
|
|
245
|
+
if isinstance(folder_obs["text"], dict):
|
|
246
|
+
folder_obs["text"] = json.dumps(folder_obs["text"])[:4000]
|
|
247
|
+
folder_obs["text"] = str(folder_obs["text"])[:4000]
|
|
248
|
+
|
|
249
|
+
if not dry_run:
|
|
250
|
+
kv.set(KV.folder_obs(fp, aid), obs_id, folder_obs)
|
|
251
|
+
kv.set(
|
|
252
|
+
KV.obs_lookup,
|
|
253
|
+
obs_id,
|
|
254
|
+
{
|
|
255
|
+
"folderPath": fp,
|
|
256
|
+
"agentId": aid,
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
session_obs_count += 1
|
|
260
|
+
migrated_observations += 1
|
|
261
|
+
|
|
262
|
+
if not dry_run and session_obs_count > 0:
|
|
263
|
+
meta_scope = KV.folder_meta(fp, aid)
|
|
264
|
+
meta = kv.get(meta_scope, "meta") or {
|
|
265
|
+
"folderPath": fp,
|
|
266
|
+
"agentId": aid,
|
|
267
|
+
"obsCount": 0,
|
|
268
|
+
"lastUpdated": session.get("updatedAt", ""),
|
|
269
|
+
"summary": None,
|
|
270
|
+
}
|
|
271
|
+
meta["obsCount"] = meta.get("obsCount", 0) + session_obs_count
|
|
272
|
+
meta["lastUpdated"] = (
|
|
273
|
+
session.get("updatedAt", "") or meta["lastUpdated"]
|
|
274
|
+
)
|
|
275
|
+
kv.set(meta_scope, "meta", meta)
|
|
276
|
+
|
|
277
|
+
index_key = f"{fp}:{aid}"
|
|
278
|
+
kv.set(
|
|
279
|
+
KV.folders,
|
|
280
|
+
index_key,
|
|
281
|
+
{
|
|
282
|
+
"folderPath": fp,
|
|
283
|
+
"agentId": aid,
|
|
284
|
+
"lastUpdated": meta["lastUpdated"],
|
|
285
|
+
"obsCount": meta["obsCount"],
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
migrated_sessions += 1
|
|
290
|
+
except Exception as e:
|
|
291
|
+
errors.append({"sessionId": session_id, "error": str(e)})
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
"migrated_sessions": migrated_sessions,
|
|
295
|
+
"migrated_observations": migrated_observations,
|
|
296
|
+
"errors": errors,
|
|
297
|
+
"dry_run": dry_run,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def get_relations(kv: StateKV) -> List[Dict[str, Any]]:
|
|
302
|
+
return kv.list(KV.relations)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def add_relation(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
306
|
+
rel = {
|
|
307
|
+
"id": generate_id("rel"),
|
|
308
|
+
"sourceId": data["sourceId"],
|
|
309
|
+
"targetId": data["targetId"],
|
|
310
|
+
"type": data["type"],
|
|
311
|
+
"createdAt": datetime.datetime.now(datetime.timezone.utc)
|
|
312
|
+
.isoformat()
|
|
313
|
+
.replace("+00:00", "Z"),
|
|
314
|
+
}
|
|
315
|
+
kv.set(KV.relations, rel["id"], rel)
|
|
316
|
+
|
|
317
|
+
agent_id = data.get("agentId") or get_agent_id()
|
|
318
|
+
commit_if_enabled(
|
|
319
|
+
kv,
|
|
320
|
+
f"Add relation {rel['type']} between {rel['sourceId']} and {rel['targetId']}",
|
|
321
|
+
agent_id,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return rel
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def auto_forget(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]:
|
|
328
|
+
from .. import legacy as _legacy
|
|
329
|
+
|
|
330
|
+
now_dt = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
|
331
|
+
evicted_memories = []
|
|
332
|
+
evicted_observations = []
|
|
333
|
+
evicted_folder_observations = []
|
|
334
|
+
|
|
335
|
+
memories = kv.list(KV.memories)
|
|
336
|
+
for mem in memories:
|
|
337
|
+
forget_after = mem.get("forgetAfter")
|
|
338
|
+
if forget_after:
|
|
339
|
+
try:
|
|
340
|
+
import dateutil.parser
|
|
341
|
+
|
|
342
|
+
fa_dt = dateutil.parser.parse(forget_after)
|
|
343
|
+
if fa_dt.tzinfo:
|
|
344
|
+
fa_dt = fa_dt.replace(tzinfo=None)
|
|
345
|
+
if fa_dt < now_dt:
|
|
346
|
+
evicted_memories.append(mem["id"])
|
|
347
|
+
except Exception as e:
|
|
348
|
+
print(
|
|
349
|
+
f"[auto_forget] Failed to parse forgetAfter '{forget_after}': {e}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
sessions = kv.list(KV.sessions)
|
|
353
|
+
for sess in sessions:
|
|
354
|
+
sid = sess.get("id")
|
|
355
|
+
if not sid:
|
|
356
|
+
continue
|
|
357
|
+
obs_list = kv.list(KV.observations(sid))
|
|
358
|
+
for obs in obs_list:
|
|
359
|
+
importance = obs.get("importance")
|
|
360
|
+
ts = obs.get("timestamp")
|
|
361
|
+
if importance is not None and ts:
|
|
362
|
+
try:
|
|
363
|
+
import dateutil.parser
|
|
364
|
+
|
|
365
|
+
ts_dt = dateutil.parser.parse(ts)
|
|
366
|
+
if ts_dt.tzinfo:
|
|
367
|
+
ts_dt = ts_dt.replace(tzinfo=None)
|
|
368
|
+
age_days = (now_dt - ts_dt).days
|
|
369
|
+
if importance <= 2 and age_days > 180:
|
|
370
|
+
evicted_observations.append((sid, obs["id"]))
|
|
371
|
+
except Exception as e:
|
|
372
|
+
print(f"[auto_forget] Failed to parse timestamp '{ts}': {e}")
|
|
373
|
+
|
|
374
|
+
folder_pairs = kv.list(KV.folders)
|
|
375
|
+
for entry in folder_pairs:
|
|
376
|
+
fp = entry.get("folderPath")
|
|
377
|
+
aid = entry.get("agentId")
|
|
378
|
+
if not fp or not aid:
|
|
379
|
+
continue
|
|
380
|
+
obs_list = kv.list(KV.folder_obs(fp, aid))
|
|
381
|
+
for obs in obs_list:
|
|
382
|
+
obs_id = obs.get("id")
|
|
383
|
+
if not obs_id:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
forget_after = obs.get("forgetAfter")
|
|
387
|
+
is_expired = False
|
|
388
|
+
if forget_after:
|
|
389
|
+
try:
|
|
390
|
+
import dateutil.parser
|
|
391
|
+
|
|
392
|
+
fa_dt = dateutil.parser.parse(forget_after)
|
|
393
|
+
if fa_dt.tzinfo:
|
|
394
|
+
fa_dt = fa_dt.replace(tzinfo=None)
|
|
395
|
+
if fa_dt < now_dt:
|
|
396
|
+
is_expired = True
|
|
397
|
+
except Exception as e:
|
|
398
|
+
print(
|
|
399
|
+
f"[auto_forget] Failed to parse folder obs forgetAfter '{forget_after}': {e}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
is_stale_low_value = False
|
|
403
|
+
importance = obs.get("importance")
|
|
404
|
+
ts = obs.get("timestamp")
|
|
405
|
+
if importance is not None and ts:
|
|
406
|
+
try:
|
|
407
|
+
import dateutil.parser
|
|
408
|
+
|
|
409
|
+
ts_dt = dateutil.parser.parse(ts)
|
|
410
|
+
if ts_dt.tzinfo:
|
|
411
|
+
ts_dt = ts_dt.replace(tzinfo=None)
|
|
412
|
+
age_days = (now_dt - ts_dt).days
|
|
413
|
+
if importance <= 2 and age_days > 180:
|
|
414
|
+
is_stale_low_value = True
|
|
415
|
+
except Exception as e:
|
|
416
|
+
print(
|
|
417
|
+
f"[auto_forget] Failed to parse folder obs timestamp '{ts}': {e}"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if is_expired or is_stale_low_value:
|
|
421
|
+
evicted_folder_observations.append((fp, aid, obs_id, obs))
|
|
422
|
+
|
|
423
|
+
if not dry_run:
|
|
424
|
+
for mem_id in evicted_memories:
|
|
425
|
+
mem = kv.get(KV.memories, mem_id)
|
|
426
|
+
kv.delete(KV.memories, mem_id)
|
|
427
|
+
if mem and mem.get("imageRef"):
|
|
428
|
+
ref = mem["imageRef"]
|
|
429
|
+
refs = kv.get(KV.imageRefs, ref) or 0
|
|
430
|
+
if refs > 0:
|
|
431
|
+
kv.set(KV.imageRefs, ref, refs - 1)
|
|
432
|
+
if _legacy._search_service:
|
|
433
|
+
_legacy._search_service.remove(mem_id)
|
|
434
|
+
|
|
435
|
+
for sid, obs_id in evicted_observations:
|
|
436
|
+
base_oid = obs_id.replace(":raw", "")
|
|
437
|
+
obs = kv.get(KV.observations(sid), base_oid)
|
|
438
|
+
raw_obs = kv.get(KV.observations(sid), f"{base_oid}:raw")
|
|
439
|
+
|
|
440
|
+
kv.delete(KV.observations(sid), base_oid)
|
|
441
|
+
kv.delete(KV.observations(sid), f"{base_oid}:raw")
|
|
442
|
+
|
|
443
|
+
for o in (obs, raw_obs):
|
|
444
|
+
if o:
|
|
445
|
+
img = o.get("imageData") or o.get("imageRef")
|
|
446
|
+
if img:
|
|
447
|
+
refs = kv.get(KV.imageRefs, img) or 0
|
|
448
|
+
if refs > 0:
|
|
449
|
+
kv.set(KV.imageRefs, img, refs - 1)
|
|
450
|
+
|
|
451
|
+
if _legacy._search_service:
|
|
452
|
+
_legacy._search_service.remove(base_oid)
|
|
453
|
+
_legacy._search_service.remove(f"{base_oid}:raw")
|
|
454
|
+
|
|
455
|
+
folder_deletes = {}
|
|
456
|
+
for fp, aid, obs_id, obs in evicted_folder_observations:
|
|
457
|
+
kv.delete(KV.folder_obs(fp, aid), obs_id)
|
|
458
|
+
kv.delete(KV.obs_lookup, obs_id)
|
|
459
|
+
|
|
460
|
+
if obs and isinstance(obs, dict) and obs.get("text"):
|
|
461
|
+
fp_text = obs["text"][:4000]
|
|
462
|
+
dedup_fp = hashlib.sha256(
|
|
463
|
+
fp_text.strip().lower().encode("utf-8")
|
|
464
|
+
).hexdigest()
|
|
465
|
+
kv.delete(KV.obs_dedup(fp, aid), dedup_fp)
|
|
466
|
+
|
|
467
|
+
if _legacy._search_service:
|
|
468
|
+
_legacy._search_service.remove(obs_id)
|
|
469
|
+
|
|
470
|
+
pair_key = (fp, aid)
|
|
471
|
+
folder_deletes[pair_key] = folder_deletes.get(pair_key, 0) + 1
|
|
472
|
+
|
|
473
|
+
for (fp, aid), count in folder_deletes.items():
|
|
474
|
+
meta_scope = KV.folder_meta(fp, aid)
|
|
475
|
+
meta = kv.get(meta_scope, "meta")
|
|
476
|
+
if meta and isinstance(meta, dict):
|
|
477
|
+
current_count = meta.get("obsCount", 0)
|
|
478
|
+
meta["obsCount"] = max(0, current_count - count)
|
|
479
|
+
kv.set(meta_scope, "meta", meta)
|
|
480
|
+
|
|
481
|
+
index_key = f"{fp}:{aid}"
|
|
482
|
+
index_entry = kv.get(KV.folders, index_key)
|
|
483
|
+
if index_entry and isinstance(index_entry, dict):
|
|
484
|
+
index_entry["obsCount"] = meta["obsCount"]
|
|
485
|
+
kv.set(KV.folders, index_key, index_entry)
|
|
486
|
+
|
|
487
|
+
if evicted_memories or evicted_observations or evicted_folder_observations:
|
|
488
|
+
if _legacy._search_service:
|
|
489
|
+
_legacy._search_service.schedule_persist()
|
|
490
|
+
safe_audit(
|
|
491
|
+
kv,
|
|
492
|
+
"auto_forget",
|
|
493
|
+
"mem::auto_forget",
|
|
494
|
+
evicted_memories
|
|
495
|
+
+ [oid for _, oid in evicted_observations]
|
|
496
|
+
+ [oid for _, _, oid, _ in evicted_folder_observations],
|
|
497
|
+
{
|
|
498
|
+
"evictedMemoriesCount": len(evicted_memories),
|
|
499
|
+
"evictedObservationsCount": len(evicted_observations)
|
|
500
|
+
+ len(evicted_folder_observations),
|
|
501
|
+
"dryRun": False,
|
|
502
|
+
},
|
|
503
|
+
)
|
|
504
|
+
commit_if_enabled(
|
|
505
|
+
kv,
|
|
506
|
+
f"Auto forget: evicted {len(evicted_memories)} memories, {len(evicted_observations) + len(evicted_folder_observations)} observations",
|
|
507
|
+
"system",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
"success": True,
|
|
512
|
+
"evictedMemories": evicted_memories,
|
|
513
|
+
"evictedObservations": [oid for _, oid in evicted_observations]
|
|
514
|
+
+ [oid for _, _, oid, _ in evicted_folder_observations],
|
|
515
|
+
"evicted": len(evicted_memories)
|
|
516
|
+
+ len(evicted_observations)
|
|
517
|
+
+ len(evicted_folder_observations),
|
|
518
|
+
"dryRun": dry_run,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def health_check(kv: StateKV) -> Dict[str, Any]:
|
|
523
|
+
from .. import legacy as _legacy
|
|
524
|
+
|
|
525
|
+
db_status = "connected"
|
|
526
|
+
if kv is None:
|
|
527
|
+
db_status = "disconnected"
|
|
528
|
+
else:
|
|
529
|
+
try:
|
|
530
|
+
kv._get_conn()
|
|
531
|
+
except Exception:
|
|
532
|
+
db_status = "disconnected"
|
|
533
|
+
|
|
534
|
+
folder_count = 0
|
|
535
|
+
agent_count = 0
|
|
536
|
+
pair_count = 0
|
|
537
|
+
observation_count = 0
|
|
538
|
+
if kv is not None:
|
|
539
|
+
try:
|
|
540
|
+
folder_pairs = kv.list(KV.folders)
|
|
541
|
+
pair_count = len(folder_pairs)
|
|
542
|
+
unique_folders: Set[str] = set()
|
|
543
|
+
unique_agents: Set[str] = set()
|
|
544
|
+
for entry in folder_pairs:
|
|
545
|
+
fp = entry.get("folderPath")
|
|
546
|
+
aid = entry.get("agentId")
|
|
547
|
+
if fp:
|
|
548
|
+
unique_folders.add(fp)
|
|
549
|
+
if aid:
|
|
550
|
+
unique_agents.add(aid)
|
|
551
|
+
observation_count += int(entry.get("obsCount") or 0)
|
|
552
|
+
folder_count = len(unique_folders)
|
|
553
|
+
agent_count = len(unique_agents)
|
|
554
|
+
except Exception as e:
|
|
555
|
+
print(f"[health_check] folder count failed: {e}")
|
|
556
|
+
|
|
557
|
+
memory_count = 0
|
|
558
|
+
if kv is not None:
|
|
559
|
+
try:
|
|
560
|
+
memory_count = len(kv.list(KV.memories))
|
|
561
|
+
except Exception:
|
|
562
|
+
pass
|
|
563
|
+
|
|
564
|
+
bm25_index_size = 0
|
|
565
|
+
try:
|
|
566
|
+
bm25_index_size = (
|
|
567
|
+
_legacy._search_service.bm25.size if _legacy._search_service else 0
|
|
568
|
+
)
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
|
|
572
|
+
vector_index_size = 0
|
|
573
|
+
try:
|
|
574
|
+
if _legacy._search_service and _legacy._search_service.vector:
|
|
575
|
+
vector_index_size = _legacy._search_service.vector.size
|
|
576
|
+
except Exception:
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
sync_status = "never"
|
|
580
|
+
last_sync_at = None
|
|
581
|
+
db_size_bytes = 0
|
|
582
|
+
wal_size_bytes = 0
|
|
583
|
+
try:
|
|
584
|
+
sync_state_path = os.path.join(
|
|
585
|
+
os.path.expanduser("~"), ".agentcache", ".sync_state"
|
|
586
|
+
)
|
|
587
|
+
if os.path.exists(sync_state_path):
|
|
588
|
+
with open(sync_state_path, "r", encoding="utf-8") as _sf:
|
|
589
|
+
_sync = json.loads(_sf.read())
|
|
590
|
+
sync_status = _sync.get("sync_status", "never")
|
|
591
|
+
last_sync_at = _sync.get("last_sync_at")
|
|
592
|
+
except Exception:
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
if kv is not None:
|
|
596
|
+
try:
|
|
597
|
+
db_stats = kv.stats()
|
|
598
|
+
db_size_bytes = db_stats.get("db_size_bytes", 0)
|
|
599
|
+
wal_size_bytes = db_stats.get("wal_size_bytes", 0)
|
|
600
|
+
except Exception:
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
"status": "ok" if db_status == "connected" else "degraded",
|
|
605
|
+
"folderCount": folder_count,
|
|
606
|
+
"agentCount": agent_count,
|
|
607
|
+
"pairCount": pair_count,
|
|
608
|
+
"observationCount": observation_count,
|
|
609
|
+
"memoryCount": memory_count,
|
|
610
|
+
"bm25IndexSize": bm25_index_size,
|
|
611
|
+
"vectorIndexSize": vector_index_size,
|
|
612
|
+
"dbPath": kv.db_path if kv else "",
|
|
613
|
+
"dbSizeBytes": db_size_bytes,
|
|
614
|
+
"walSizeBytes": wal_size_bytes,
|
|
615
|
+
"syncStatus": sync_status,
|
|
616
|
+
"lastSyncAt": last_sync_at,
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def rebuild_index(kv: StateKV) -> int:
|
|
621
|
+
"""Clear and rebuild the search index from all stored observations."""
|
|
622
|
+
from .session_store import _get_observation_store
|
|
623
|
+
|
|
624
|
+
store = _get_observation_store(kv)
|
|
625
|
+
return store.rebuild_index()
|