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,504 @@
1
+ """Memory slots — pinned context windows with read/append/replace/delete."""
2
+
3
+ import datetime
4
+ import re
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from ..db import StateKV
8
+ from .audit_log import safe_audit
9
+ from .config import commit_if_enabled, get_agent_id
10
+ from .kv_scopes import KV
11
+ from .privacy import strip_private_data
12
+
13
+ DEFAULT_SLOTS = [
14
+ {
15
+ "label": "persona",
16
+ "content": "",
17
+ "sizeLimit": 1000,
18
+ "description": "How the agent should see itself: role, tone, behavioural guidelines.",
19
+ "pinned": True,
20
+ "readOnly": False,
21
+ "scope": "global",
22
+ },
23
+ {
24
+ "label": "user_preferences",
25
+ "content": "",
26
+ "sizeLimit": 2000,
27
+ "description": "Coding style, tool preferences, naming conventions, and other habits the user wants preserved across sessions.",
28
+ "pinned": True,
29
+ "readOnly": False,
30
+ "scope": "global",
31
+ },
32
+ {
33
+ "label": "tool_guidelines",
34
+ "content": "",
35
+ "sizeLimit": 1500,
36
+ "description": "Rules the agent should follow when picking or sequencing tools (e.g. prefer X over Y, never run Z without confirmation).",
37
+ "pinned": True,
38
+ "readOnly": False,
39
+ "scope": "global",
40
+ },
41
+ {
42
+ "label": "project_context",
43
+ "content": "",
44
+ "sizeLimit": 3000,
45
+ "description": "Architecture decisions, codebase conventions, build/test commands, and cross-cutting constraints for the current project.",
46
+ "pinned": True,
47
+ "readOnly": False,
48
+ "scope": "project",
49
+ },
50
+ {
51
+ "label": "guidance",
52
+ "content": "",
53
+ "sizeLimit": 1500,
54
+ "description": "Active advice for the next session: what to focus on, what to avoid, open risks.",
55
+ "pinned": True,
56
+ "readOnly": False,
57
+ "scope": "project",
58
+ },
59
+ {
60
+ "label": "pending_items",
61
+ "content": "",
62
+ "sizeLimit": 2000,
63
+ "description": "Unfinished work, explicit TODOs, and promises made but not yet delivered.",
64
+ "pinned": True,
65
+ "readOnly": False,
66
+ "scope": "project",
67
+ },
68
+ {
69
+ "label": "session_patterns",
70
+ "content": "",
71
+ "sizeLimit": 1500,
72
+ "description": "Recurring behaviours and common struggles observed across recent sessions.",
73
+ "pinned": False,
74
+ "readOnly": False,
75
+ "scope": "project",
76
+ },
77
+ {
78
+ "label": "self_notes",
79
+ "content": "",
80
+ "sizeLimit": 1500,
81
+ "description": "Free-form notes the agent keeps for itself: hypotheses, dead ends, things to revisit.",
82
+ "pinned": False,
83
+ "readOnly": False,
84
+ "scope": "project",
85
+ },
86
+ ]
87
+
88
+
89
+ def get_current_project(kv: StateKV) -> Optional[str]:
90
+ try:
91
+ sessions = kv.list(KV.sessions)
92
+ if not sessions:
93
+ return None
94
+ active_sessions = [s for s in sessions if s.get("status") == "active"]
95
+ if active_sessions:
96
+ active_sessions.sort(key=lambda s: s.get("updatedAt", ""), reverse=True)
97
+ return active_sessions[0].get("project")
98
+ sessions.sort(key=lambda s: s.get("updatedAt", ""), reverse=True)
99
+ return sessions[0].get("project")
100
+ except Exception:
101
+ return None
102
+
103
+
104
+ def project_slots_scope(kv: StateKV, project: Optional[str] = None) -> str:
105
+ if not project:
106
+ project = get_current_project(kv)
107
+ if not project:
108
+ return KV.slots
109
+ return f"mem:slots:{project}"
110
+
111
+
112
+ def seed_defaults(kv: StateKV) -> None:
113
+ now = (
114
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
115
+ )
116
+ for tmpl in DEFAULT_SLOTS:
117
+ scope = tmpl["scope"]
118
+ target = KV.globalSlots if scope == "global" else KV.slots
119
+ existing = kv.get(target, tmpl["label"])
120
+ if existing:
121
+ continue
122
+ slot = dict(tmpl)
123
+ slot["createdAt"] = now
124
+ slot["updatedAt"] = now
125
+ kv.set(target, tmpl["label"], slot)
126
+
127
+
128
+ def list_pinned_slots(
129
+ kv: StateKV, project: Optional[str] = None
130
+ ) -> List[Dict[str, Any]]:
131
+ p_slots = kv.list(project_slots_scope(kv, project))
132
+ g_slots = kv.list(KV.globalSlots)
133
+ merged = {}
134
+ for s in g_slots:
135
+ merged[s["label"]] = s
136
+ for s in p_slots:
137
+ merged[s["label"]] = s
138
+ pinned = [
139
+ s for s in merged.values() if s.get("pinned") and s.get("content", "").strip()
140
+ ]
141
+ pinned.sort(key=lambda s: s["label"])
142
+ return pinned
143
+
144
+
145
+ def render_pinned_context(slots: List[Dict[str, Any]]) -> str:
146
+ if not slots:
147
+ return ""
148
+ lines = ["# agentcache pinned slots", ""]
149
+ for s in slots:
150
+ lines.append(f"## {s['label']}")
151
+ lines.append(s["content"].strip())
152
+ lines.append("")
153
+ return "\n".join(lines)
154
+
155
+
156
+ def slot_list(kv: StateKV, project: Optional[str] = None) -> Dict[str, Any]:
157
+ p_slots = kv.list(project_slots_scope(kv, project))
158
+ g_slots = kv.list(KV.globalSlots)
159
+ merged = {}
160
+ for s in g_slots:
161
+ merged[s["label"]] = s
162
+ for s in p_slots:
163
+ merged[s["label"]] = s
164
+ slots = sorted(list(merged.values()), key=lambda s: s["label"])
165
+ return {"success": True, "slots": slots}
166
+
167
+
168
+ def slot_get(kv: StateKV, label: str, project: Optional[str] = None) -> Dict[str, Any]:
169
+ p_scope = project_slots_scope(kv, project)
170
+ project_s = kv.get(p_scope, label)
171
+ if project_s:
172
+ return {"success": True, "slot": project_s, "scope": "project"}
173
+ global_s = kv.get(KV.globalSlots, label)
174
+ if global_s:
175
+ return {"success": True, "slot": global_s, "scope": "global"}
176
+ return {"success": False, "error": "slot not found"}
177
+
178
+
179
+ def slot_create(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
180
+ label = data.get("label")
181
+ if not label or not re.match(r"^[a-z][a-z0-9_]*$", label):
182
+ return {
183
+ "success": False,
184
+ "error": "label required (lowercase, starts with letter, [a-z0-9_])",
185
+ }
186
+
187
+ scope = data.get("scope") or "project"
188
+ if scope not in ("project", "global"):
189
+ return {"success": False, "error": "scope must be 'project' or 'global'"}
190
+
191
+ limit = data.get("sizeLimit") or 2000
192
+ if not isinstance(limit, int) or limit < 1 or limit > 20000:
193
+ return {
194
+ "success": False,
195
+ "error": "sizeLimit must be an integer between 1 and 20000",
196
+ }
197
+
198
+ content = strip_private_data(data.get("content") or "")
199
+ if len(content) > limit:
200
+ return {
201
+ "success": False,
202
+ "error": f"content exceeds sizeLimit ({len(content)} > {limit})",
203
+ }
204
+
205
+ description = data.get("description") or ""
206
+ pinned = data.get("pinned", True)
207
+ project = data.get("project")
208
+
209
+ target_kv = (
210
+ KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
211
+ )
212
+ existing = kv.get(target_kv, label)
213
+ if existing:
214
+ return {"success": False, "error": f"slot already exists in {scope} scope"}
215
+
216
+ now = (
217
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
218
+ )
219
+ slot = {
220
+ "label": label,
221
+ "content": content,
222
+ "sizeLimit": limit,
223
+ "description": description,
224
+ "pinned": pinned,
225
+ "readOnly": False,
226
+ "scope": scope,
227
+ "createdAt": now,
228
+ "updatedAt": now,
229
+ }
230
+ kv.set(target_kv, label, slot)
231
+ safe_audit(
232
+ kv,
233
+ "slot_create",
234
+ "mem::slot-create",
235
+ [label],
236
+ {"scope": scope, "sizeLimit": limit, "pinned": pinned},
237
+ )
238
+
239
+ agent_id = data.get("agentId") or get_agent_id()
240
+ commit_if_enabled(kv, f"Create slot: {label}", agent_id)
241
+
242
+ return {"success": True, "slot": slot}
243
+
244
+
245
+ def slot_append(
246
+ kv: StateKV,
247
+ label: str,
248
+ text: str,
249
+ agent_id: Optional[str] = None,
250
+ project: Optional[str] = None,
251
+ ) -> Dict[str, Any]:
252
+ res = slot_get(kv, label, project)
253
+ if not res.get("success"):
254
+ return {"success": False, "error": "slot not found"}
255
+
256
+ slot = res["slot"]
257
+ scope = res["scope"]
258
+ target_kv = (
259
+ KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
260
+ )
261
+
262
+ if slot.get("readOnly"):
263
+ return {"success": False, "error": "slot is read-only"}
264
+
265
+ content = slot.get("content") or ""
266
+ sep = "\n" if content and not content.endswith("\n") else ""
267
+ next_content = content + sep + strip_private_data(text)
268
+
269
+ limit = slot.get("sizeLimit") or 2000
270
+ if len(next_content) > limit:
271
+ return {
272
+ "success": False,
273
+ "error": f"append would exceed sizeLimit ({len(next_content)} > {limit})",
274
+ "currentSize": len(content),
275
+ "sizeLimit": limit,
276
+ }
277
+
278
+ slot["content"] = next_content
279
+ slot["updatedAt"] = (
280
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
281
+ )
282
+ kv.set(target_kv, label, slot)
283
+
284
+ safe_audit(
285
+ kv,
286
+ "slot_append",
287
+ "mem::slot-append",
288
+ [label],
289
+ {"scope": scope, "added": len(text), "total": len(next_content)},
290
+ )
291
+
292
+ commit_if_enabled(kv, f"Append slot: {label}", agent_id or get_agent_id())
293
+
294
+ return {"success": True, "slot": slot, "size": len(next_content)}
295
+
296
+
297
+ def slot_replace(
298
+ kv: StateKV,
299
+ label: str,
300
+ content: str,
301
+ agent_id: Optional[str] = None,
302
+ project: Optional[str] = None,
303
+ ) -> Dict[str, Any]:
304
+ res = slot_get(kv, label, project)
305
+ if not res.get("success"):
306
+ return {"success": False, "error": "slot not found"}
307
+
308
+ slot = res["slot"]
309
+ scope = res["scope"]
310
+ target_kv = (
311
+ KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
312
+ )
313
+
314
+ if slot.get("readOnly"):
315
+ return {"success": False, "error": "slot is read-only"}
316
+
317
+ content = strip_private_data(content)
318
+ limit = slot.get("sizeLimit") or 2000
319
+ if len(content) > limit:
320
+ return {
321
+ "success": False,
322
+ "error": f"content exceeds sizeLimit ({len(content)} > {limit})",
323
+ "sizeLimit": limit,
324
+ }
325
+
326
+ before_len = len(slot.get("content") or "")
327
+ slot["content"] = content
328
+ slot["updatedAt"] = (
329
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
330
+ )
331
+ kv.set(target_kv, label, slot)
332
+
333
+ safe_audit(
334
+ kv,
335
+ "slot_replace",
336
+ "mem::slot-replace",
337
+ [label],
338
+ {"scope": scope, "before": before_len, "after": len(content)},
339
+ )
340
+
341
+ commit_if_enabled(kv, f"Replace slot: {label}", agent_id or get_agent_id())
342
+
343
+ return {"success": True, "slot": slot, "size": len(content)}
344
+
345
+
346
+ def slot_delete(
347
+ kv: StateKV,
348
+ label: str,
349
+ agent_id: Optional[str] = None,
350
+ project: Optional[str] = None,
351
+ ) -> Dict[str, Any]:
352
+ res = slot_get(kv, label, project)
353
+ if not res.get("success"):
354
+ return {"success": False, "error": "slot not found"}
355
+
356
+ slot = res["slot"]
357
+ scope = res["scope"]
358
+ target_kv = (
359
+ KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
360
+ )
361
+
362
+ if slot.get("readOnly"):
363
+ return {"success": False, "error": "slot is read-only"}
364
+
365
+ kv.delete(target_kv, label)
366
+ safe_audit(
367
+ kv,
368
+ "slot_delete",
369
+ "mem::slot-delete",
370
+ [label],
371
+ {"scope": scope, "size": len(slot.get("content") or "")},
372
+ )
373
+
374
+ commit_if_enabled(kv, f"Delete slot: {label}", agent_id or get_agent_id())
375
+
376
+ return {"success": True}
377
+
378
+
379
+ def slot_reflect(kv: StateKV, session_id: str, max_obs: int = 50) -> Dict[str, Any]:
380
+ session = kv.get(KV.sessions, session_id)
381
+ project = session.get("project") if session else None
382
+
383
+ observations = kv.list(KV.observations(session_id))
384
+ if not observations:
385
+ return {"success": True, "applied": 0, "reason": "no observations for session"}
386
+
387
+ recent = sorted(observations, key=lambda x: x.get("timestamp", ""), reverse=True)[
388
+ :max_obs
389
+ ]
390
+
391
+ pending_lines = []
392
+ pattern_counts = {}
393
+ files = set()
394
+
395
+ for obs in recent:
396
+ title = (obs.get("title") or "").lower()
397
+ narrative = (obs.get("narrative") or "").lower()
398
+ if "todo" in narrative or "todo" in title:
399
+ pending_lines.append(f"- {obs.get('title') or obs['id']}")
400
+ if obs.get("type") == "error":
401
+ pattern_counts["errors"] = pattern_counts.get("errors", 0) + 1
402
+ if obs.get("type") == "command_run":
403
+ pattern_counts["commands"] = pattern_counts.get("commands", 0) + 1
404
+ for f in obs.get("files") or []:
405
+ files.add(f)
406
+
407
+ applied = 0
408
+ now = (
409
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
410
+ )
411
+
412
+ if pending_lines:
413
+ res = slot_get(kv, "pending_items", project)
414
+ if res.get("success"):
415
+ slot = res["slot"]
416
+ scope = res["scope"]
417
+ target_kv = (
418
+ KV.globalSlots
419
+ if scope == "global"
420
+ else project_slots_scope(kv, project)
421
+ )
422
+ already = set((slot.get("content") or "").split("\n"))
423
+ fresh = [line for line in pending_lines if line not in already]
424
+ if fresh:
425
+ sep = (
426
+ "\n"
427
+ if slot.get("content") and not slot["content"].endswith("\n")
428
+ else ""
429
+ )
430
+ next_content = (slot.get("content") or "") + sep + "\n".join(fresh)
431
+ limit = slot.get("sizeLimit") or 2000
432
+ if len(next_content) > limit:
433
+ next_content = next_content[-limit:]
434
+ slot["content"] = next_content
435
+ slot["updatedAt"] = now
436
+ kv.set(target_kv, "pending_items", slot)
437
+ applied += 1
438
+
439
+ if pattern_counts:
440
+ res = slot_get(kv, "session_patterns", project)
441
+ if res.get("success"):
442
+ slot = res["slot"]
443
+ scope = res["scope"]
444
+ target_kv = (
445
+ KV.globalSlots
446
+ if scope == "global"
447
+ else project_slots_scope(kv, project)
448
+ )
449
+ summary = [f"last reflection: {now}"]
450
+ for k, v in pattern_counts.items():
451
+ summary.append(f"- {k}: {v} in last {len(recent)} observations")
452
+ next_content = "\n".join(summary)
453
+ limit = slot.get("sizeLimit") or 2000
454
+ if len(next_content) > limit:
455
+ next_content = next_content[:limit]
456
+ slot["content"] = next_content
457
+ slot["updatedAt"] = now
458
+ kv.set(target_kv, "session_patterns", slot)
459
+ applied += 1
460
+
461
+ if files:
462
+ res = slot_get(kv, "project_context", project)
463
+ if res.get("success"):
464
+ slot = res["slot"]
465
+ scope = res["scope"]
466
+ target_kv = (
467
+ KV.globalSlots
468
+ if scope == "global"
469
+ else project_slots_scope(kv, project)
470
+ )
471
+ already = slot.get("content") or ""
472
+ fresh = [f for f in files if f not in already][:20]
473
+ if fresh:
474
+ header_line = "Files touched in recent sessions:" if not already else ""
475
+ sep = "\n" if already and not already.endswith("\n") else ""
476
+ lines = [already]
477
+ if header_line:
478
+ lines.append(header_line)
479
+ for f in fresh:
480
+ lines.append(f"- {f}")
481
+ next_content = sep.join([line for line in lines if line])
482
+ limit = slot.get("sizeLimit") or 2000
483
+ if len(next_content) > limit:
484
+ next_content = next_content[-limit:]
485
+ slot["content"] = next_content
486
+ slot["updatedAt"] = now
487
+ kv.set(target_kv, "project_context", slot)
488
+ applied += 1
489
+
490
+ if applied > 0:
491
+ safe_audit(
492
+ kv,
493
+ "slot_reflect",
494
+ "mem::slot-reflect",
495
+ [session_id],
496
+ {"observationCount": len(recent), "slotsUpdated": applied},
497
+ )
498
+ commit_if_enabled(
499
+ kv,
500
+ f"Slot reflect: updated {applied} slots in session {session_id[:8]}",
501
+ "system",
502
+ )
503
+
504
+ return {"success": True, "applied": applied, "observationsReviewed": len(recent)}