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,142 @@
1
+ """Type inference, text extraction, and synthetic compression helpers."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ def clip_embed_input(text: str) -> str:
9
+ EMBED_MAX_CHARS = 16000
10
+ if len(text) <= EMBED_MAX_CHARS:
11
+ return text
12
+ return text[:EMBED_MAX_CHARS]
13
+
14
+
15
+ def infer_type(tool_name: Optional[str], hook_type: str) -> str:
16
+ if hook_type == "post_tool_failure":
17
+ return "error"
18
+ if hook_type == "prompt_submit":
19
+ return "conversation"
20
+ if hook_type in ("subagent_stop", "task_completed"):
21
+ return "subagent"
22
+ if hook_type == "notification":
23
+ return "notification"
24
+
25
+ if not tool_name:
26
+ return "other"
27
+
28
+ n = re.sub(r"([a-z])([A-Z])", r"\1_\2", tool_name)
29
+ n = re.sub(r"[-\s]+", "_", n).lower()
30
+
31
+ def has_word(word: str) -> bool:
32
+ return (
33
+ bool(re.search(rf"(^|_){word}(_|$)", n))
34
+ or n == word
35
+ or n.endswith(word)
36
+ or n.startswith(word)
37
+ )
38
+
39
+ if any(has_word(w) for w in ["fetch", "http", "web"]):
40
+ return "web_fetch"
41
+ if any(has_word(w) for w in ["grep", "search", "glob", "find"]):
42
+ return "search"
43
+ if any(has_word(w) for w in ["bash", "shell", "exec", "run"]):
44
+ return "command_run"
45
+ if any(has_word(w) for w in ["edit", "update", "patch", "replace"]):
46
+ return "file_edit"
47
+ if any(has_word(w) for w in ["write", "create"]):
48
+ return "file_write"
49
+ if any(has_word(w) for w in ["read", "view"]):
50
+ return "file_read"
51
+ if any(has_word(w) for w in ["task", "agent"]):
52
+ return "subagent"
53
+ return "other"
54
+
55
+
56
+ def extract_files(input_data: Any) -> List[str]:
57
+ if not input_data or not isinstance(input_data, dict):
58
+ return []
59
+ out = set()
60
+ for key in ["file_path", "filepath", "path", "filePath", "file", "pattern"]:
61
+ v = input_data.get(key)
62
+ if isinstance(v, str) and 0 < len(v) < 512:
63
+ out.add(v)
64
+ return list(out)
65
+
66
+
67
+ def stringify_for_narrative(v: Any) -> str:
68
+ if v is None:
69
+ return ""
70
+ if isinstance(v, str):
71
+ return v
72
+ try:
73
+ return json.dumps(v)
74
+ except Exception:
75
+ return str(v)
76
+
77
+
78
+ def vector_index_add_guarded(
79
+ obs_id: str, session_id: str, text: str, context: Dict[str, Any]
80
+ ) -> bool:
81
+ from .. import legacy as _legacy
82
+
83
+ if _legacy._search_service is None:
84
+ return False
85
+ ep = _legacy._search_service.embedding_provider
86
+ vi = _legacy._search_service.vector
87
+ if not vi or not ep:
88
+ return False
89
+ try:
90
+ clipped = clip_embed_input(text)
91
+ embedding = ep.embed(clipped)
92
+ if len(embedding) != ep.dimensions:
93
+ print(
94
+ f"[vector-index] Dimension mismatch: expected {ep.dimensions}, got {len(embedding)}"
95
+ )
96
+ return False
97
+ vi.add(obs_id, session_id, embedding)
98
+ return True
99
+ except Exception as e:
100
+ print(f"[vector-index] Embed failed: {e}")
101
+ return False
102
+
103
+
104
+ def build_synthetic_compression(raw: Dict[str, Any]) -> Dict[str, Any]:
105
+ tool_name = raw.get("toolName") or raw.get("hookType")
106
+ input_str = stringify_for_narrative(raw.get("toolInput"))
107
+ output_str = stringify_for_narrative(raw.get("toolOutput"))
108
+ prompt_str = raw.get("userPrompt") or ""
109
+
110
+ parts = [s for s in [prompt_str, input_str, output_str] if len(s) > 0]
111
+ narrative = " | ".join(parts)
112
+ if len(narrative) > 400:
113
+ narrative = narrative[:399] + "…"
114
+
115
+ title = tool_name or "observation"
116
+ if len(title) > 80:
117
+ title = title[:79] + "…"
118
+
119
+ subtitle = None
120
+ if input_str:
121
+ subtitle = input_str
122
+ if len(subtitle) > 120:
123
+ subtitle = subtitle[:119] + "…"
124
+
125
+ res = {
126
+ "id": raw["id"],
127
+ "sessionId": raw["sessionId"],
128
+ "timestamp": raw["timestamp"],
129
+ "type": infer_type(raw.get("toolName"), raw["hookType"]),
130
+ "title": title,
131
+ "subtitle": subtitle,
132
+ "facts": [],
133
+ "narrative": narrative,
134
+ "concepts": [],
135
+ "files": extract_files(raw.get("toolInput")),
136
+ "importance": 5,
137
+ "confidence": 0.3,
138
+ }
139
+ for k in ["modality", "imageData", "agentId"]:
140
+ if raw.get(k) is not None:
141
+ res[k] = raw[k]
142
+ return res
@@ -0,0 +1,86 @@
1
+ """
2
+ KV scope key registry.
3
+
4
+ Single source of truth for every SQLite scope string used in the system.
5
+ Import this module wherever a KV scope key is needed — routes, stores, workers.
6
+ """
7
+
8
+
9
+ class KV:
10
+ # ---- Folder memory scopes ----
11
+
12
+ # Global index of all (folder_path, agent_id) pairs known to the system.
13
+ # Key = "{safe_folder_path}:{agent_id}", value = FolderIndexEntry dict.
14
+ folders = "mem:folders"
15
+
16
+ # Lookup index for O(1) observation hydration.
17
+ # Scope = "mem:obs_lookup", Key = obs_id, Value = {"folderPath": ..., "agentId": ...}
18
+ obs_lookup = "mem:obs_lookup"
19
+
20
+ @staticmethod
21
+ def folder_obs(folder_path: str, agent_id: str) -> str:
22
+ """Per-(folder, agent) observations scope.
23
+ Key = obs_id, value = FolderObservation dict.
24
+ """
25
+ safe_path = folder_path.replace("\\", "/").strip("/")
26
+ safe_agent = agent_id.strip()
27
+ return f"mem:folder:{safe_path}:{safe_agent}"
28
+
29
+ @staticmethod
30
+ def folder_meta(folder_path: str, agent_id: str) -> str:
31
+ """Per-(folder, agent) metadata scope.
32
+ Key = "meta", value = FolderMeta dict (obsCount, lastUpdated, summary).
33
+ """
34
+ safe_path = folder_path.replace("\\", "/").strip("/")
35
+ safe_agent = agent_id.strip()
36
+ return f"mem:foldermeta:{safe_path}:{safe_agent}"
37
+
38
+ @staticmethod
39
+ def obs_dedup(folder_path: str, agent_id: str) -> str:
40
+ """Deduplication index scope for (folder, agent) pairs.
41
+ Key = SHA-256 fingerprint hex of normalized text.
42
+ Value = {"obsId": str, "timestamp": str}
43
+ """
44
+ safe_path = folder_path.replace("\\", "/").strip("/")
45
+ safe_agent = agent_id.strip()
46
+ return f"mem:obs_dedup:{safe_path}:{safe_agent}"
47
+
48
+ # ---- Global / shared scopes ----
49
+
50
+ # Long-term memories.
51
+ memories = "mem:memories"
52
+
53
+ # BM25 index shards.
54
+ bm25Index = "mem:index:bm25"
55
+
56
+ # Audit log.
57
+ audit = "mem:audit"
58
+
59
+ # Graph edges.
60
+ relations = "mem:relations"
61
+
62
+ # ---- Legacy scopes (read-only; kept for migration and backward compat) ----
63
+
64
+ # Legacy session store.
65
+ sessions = "mem:sessions"
66
+
67
+ @staticmethod
68
+ def observations(session_id: str) -> str:
69
+ """Legacy per-session observations scope."""
70
+ return f"mem:obs:{session_id}"
71
+
72
+ # Lessons — confidence-scored learning entries.
73
+ lessons = "mem:lessons"
74
+
75
+ # Legacy summary / profile / slot / image-ref scopes.
76
+ summaries = "mem:summaries"
77
+ profiles = "mem:profiles"
78
+ slots = "mem:slots"
79
+ imageRefs = "mem:image-refs"
80
+
81
+ # Global (cross-project) pinned slots.
82
+ globalSlots = "mem:global-slots"
83
+
84
+ # Semantic and procedural memory scopes (used by consolidate).
85
+ semantic = "mem:semantic"
86
+ procedural = "mem:procedural"
@@ -0,0 +1,242 @@
1
+ """Lessons learned — save, recall, reinforce, decay."""
2
+
3
+ import datetime
4
+ from typing import Any, Dict
5
+
6
+ from ..db import StateKV
7
+ from ..storage.paths import fingerprint_id
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
+
14
+ def reinforce_lesson(lesson: Dict[str, Any]) -> None:
15
+ now = (
16
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
17
+ )
18
+ lesson["reinforcements"] = lesson.get("reinforcements", 0) + 1
19
+ conf = lesson.get("confidence", 0.5)
20
+ lesson["confidence"] = min(1.0, conf + 0.1 * (1 - conf))
21
+ lesson["lastReinforcedAt"] = now
22
+ lesson["updatedAt"] = now
23
+
24
+
25
+ def lesson_save(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
26
+ content = data.get("content")
27
+ if not content or not content.strip():
28
+ return {"success": False, "error": "content is required"}
29
+ content = strip_private_data(content)
30
+ context_str = strip_private_data(data.get("context") or "")
31
+
32
+ agent_id = data.get("agentId") or get_agent_id()
33
+ fp = fingerprint_id("lsn", content)
34
+ existing = kv.get(KV.lessons, fp)
35
+
36
+ if existing and not existing.get("deleted"):
37
+ reinforce_lesson(existing)
38
+ if context_str and not existing.get("context"):
39
+ existing["context"] = context_str
40
+ kv.set(KV.lessons, existing["id"], existing)
41
+ safe_audit(kv, "lesson_strengthen", "mem::lesson-save", [existing["id"]])
42
+
43
+ commit_if_enabled(
44
+ kv, f"Strengthen lesson: {existing.get('content', '')[:60]}", agent_id
45
+ )
46
+
47
+ return {"success": True, "action": "strengthened", "lesson": existing}
48
+
49
+ confidence = data.get("confidence")
50
+ if not isinstance(confidence, (int, float)) or confidence < 0 or confidence > 1:
51
+ confidence = 0.5
52
+
53
+ now = (
54
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
55
+ )
56
+ lesson = {
57
+ "id": fp,
58
+ "content": content.strip(),
59
+ "context": context_str.strip(),
60
+ "confidence": confidence,
61
+ "reinforcements": 0,
62
+ "source": data.get("source") or "manual",
63
+ "sourceIds": data.get("sourceIds") or [],
64
+ "project": data.get("project"),
65
+ "tags": data.get("tags") or [],
66
+ "createdAt": now,
67
+ "updatedAt": now,
68
+ "decayRate": 0.05,
69
+ }
70
+ kv.set(KV.lessons, lesson["id"], lesson)
71
+ safe_audit(kv, "lesson_save", "mem::lesson-save", [lesson["id"]])
72
+
73
+ commit_if_enabled(kv, f"Create lesson: {lesson['content'][:60]}", agent_id)
74
+
75
+ return {"success": True, "action": "created", "lesson": lesson}
76
+
77
+
78
+ def lesson_list(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
79
+ limit = data.get("limit") or 50
80
+ min_confidence = data.get("minConfidence") or 0.0
81
+ all_lessons = kv.list(KV.lessons)
82
+
83
+ lessons = [
84
+ les
85
+ for les in all_lessons
86
+ if not les.get("deleted") and les.get("confidence", 0.5) >= min_confidence
87
+ ]
88
+
89
+ project = data.get("project")
90
+ if project:
91
+ lessons = [les for les in lessons if les.get("project") == project]
92
+ source = data.get("source")
93
+ if source:
94
+ lessons = [les for les in lessons if les.get("source") == source]
95
+
96
+ lessons.sort(key=lambda x: x.get("confidence", 0.5), reverse=True)
97
+ return {"success": True, "lessons": lessons[:limit]}
98
+
99
+
100
+ def lesson_recall(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
101
+ query = data.get("query")
102
+ if not query or not query.strip():
103
+ return {"success": False, "error": "query is required"}
104
+
105
+ query_lower = query.lower()
106
+ min_confidence = data.get("minConfidence") or 0.1
107
+ limit = data.get("limit") or 10
108
+
109
+ all_lessons = kv.list(KV.lessons)
110
+ lessons = [
111
+ les
112
+ for les in all_lessons
113
+ if not les.get("deleted") and les.get("confidence", 0.5) >= min_confidence
114
+ ]
115
+
116
+ project = data.get("project")
117
+ if project:
118
+ lessons = [les for les in lessons if les.get("project") == project]
119
+
120
+ scored = []
121
+ terms = [t for t in query_lower.split() if len(t) > 1]
122
+
123
+ for les in lessons:
124
+ text = f"{les.get('content', '')} {les.get('context', '')} {' '.join(les.get('tags') or [])}".lower()
125
+ match_count = sum(1 for t in terms if t in text)
126
+ if match_count == 0:
127
+ continue
128
+
129
+ relevance = match_count / len(terms)
130
+ baseline = les.get("lastReinforcedAt") or les.get("createdAt")
131
+ import dateutil.parser
132
+
133
+ dt = dateutil.parser.parse(baseline)
134
+ days = (
135
+ datetime.datetime.now(datetime.timezone.utc)
136
+ - dt.replace(tzinfo=datetime.timezone.utc)
137
+ ).total_seconds() / (3600 * 24)
138
+ recency_boost = 1 / (1 + days * 0.01)
139
+ score = les.get("confidence", 0.5) * relevance * recency_boost
140
+ scored.append({"lesson": les, "score": score})
141
+
142
+ scored.sort(key=lambda x: x["score"], reverse=True)
143
+ results = []
144
+ for s in scored[:limit]:
145
+ item = dict(s["lesson"])
146
+ item["score"] = round(s["score"], 3)
147
+ results.append(item)
148
+
149
+ safe_audit(
150
+ kv,
151
+ "lesson_recall",
152
+ "mem::lesson-recall",
153
+ [],
154
+ {"query": query, "resultCount": len(results)},
155
+ )
156
+ return {"success": True, "lessons": results}
157
+
158
+
159
+ def lesson_strengthen(kv: StateKV, lesson_id: str) -> Dict[str, Any]:
160
+ lesson = kv.get(KV.lessons, lesson_id)
161
+ if not lesson or lesson.get("deleted"):
162
+ return {"success": False, "error": "lesson not found"}
163
+
164
+ reinforce_lesson(lesson)
165
+ kv.set(KV.lessons, lesson["id"], lesson)
166
+ safe_audit(kv, "lesson_strengthen", "mem::lesson-strengthen", [lesson["id"]])
167
+
168
+ commit_if_enabled(
169
+ kv, f"Strengthen lesson: {lesson.get('content', '')[:60]}", get_agent_id()
170
+ )
171
+
172
+ return {"success": True, "lesson": lesson}
173
+
174
+
175
+ def lesson_decay_sweep(kv: StateKV) -> Dict[str, Any]:
176
+ all_lessons = kv.list(KV.lessons)
177
+ decayed = 0
178
+ soft_deleted = 0
179
+ now = datetime.datetime.now(datetime.timezone.utc)
180
+ timestamp = now.isoformat().replace("+00:00", "Z")
181
+
182
+ for les in all_lessons:
183
+ if les.get("deleted"):
184
+ continue
185
+ baseline_str = (
186
+ les.get("lastDecayedAt") or les.get("lastReinforcedAt") or les["createdAt"]
187
+ )
188
+ import dateutil.parser
189
+
190
+ dt = dateutil.parser.parse(baseline_str)
191
+ weeks = (now - dt.replace(tzinfo=datetime.timezone.utc)).total_seconds() / (
192
+ 3600 * 24 * 7
193
+ )
194
+ if weeks < 1.0:
195
+ continue
196
+
197
+ decay = les.get("decayRate", 0.05) * weeks
198
+ new_conf = max(0.05, les.get("confidence", 0.5) - decay)
199
+
200
+ if new_conf != les.get("confidence"):
201
+ before = les.get("confidence", 0.5)
202
+ les["confidence"] = round(new_conf, 3)
203
+ les["lastDecayedAt"] = timestamp
204
+ les["updatedAt"] = timestamp
205
+
206
+ if les["confidence"] <= 0.1 and les.get("reinforcements", 0) == 0:
207
+ les["deleted"] = True
208
+ soft_deleted += 1
209
+ else:
210
+ decayed += 1
211
+
212
+ kv.set(KV.lessons, les["id"], les)
213
+ safe_audit(
214
+ kv,
215
+ "lesson_strengthen",
216
+ "mem::lesson-decay-sweep",
217
+ [les["id"]],
218
+ {
219
+ "action": "soft-delete" if les.get("deleted") else "decay",
220
+ "actor": "system",
221
+ "reason": "decay-sweep",
222
+ "before": {"confidence": before, "deleted": False},
223
+ "after": {
224
+ "confidence": les["confidence"],
225
+ "deleted": bool(les.get("deleted")),
226
+ },
227
+ },
228
+ )
229
+
230
+ if decayed > 0 or soft_deleted > 0:
231
+ commit_if_enabled(
232
+ kv,
233
+ f"Lesson decay sweep: decayed {decayed}, soft-deleted {soft_deleted}",
234
+ "system",
235
+ )
236
+
237
+ return {
238
+ "success": True,
239
+ "decayed": decayed,
240
+ "softDeleted": soft_deleted,
241
+ "total": len(all_lessons),
242
+ }