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/core/llm.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""LLM integration — Gemini calls for summarize and consolidate pipelines."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
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
|
|
13
|
+
from .context_builder import get_xml_children, get_xml_tag, strip_xml_wrappers
|
|
14
|
+
from .infer import vector_index_add_guarded
|
|
15
|
+
from .kv_scopes import KV
|
|
16
|
+
from .memory_store import memory_to_observation
|
|
17
|
+
from .session_store import list_sessions
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_content(system_instruction: str, prompt: str) -> str:
|
|
21
|
+
api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
|
22
|
+
if not api_key:
|
|
23
|
+
raise ValueError("No Gemini/Google API key found")
|
|
24
|
+
model = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
|
25
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
|
26
|
+
payload = {
|
|
27
|
+
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
|
28
|
+
"systemInstruction": {"parts": [{"text": system_instruction}]},
|
|
29
|
+
"generationConfig": {"temperature": 0.2},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
req_data = json.dumps(payload).encode("utf-8")
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
req = urllib.request.Request(
|
|
36
|
+
url, data=req_data, headers={"Content-Type": "application/json"}, method="POST"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
with urllib.request.urlopen(req, timeout=60.0) as response: # nosec B310
|
|
41
|
+
resp_data = json.loads(response.read().decode("utf-8"))
|
|
42
|
+
|
|
43
|
+
candidates = resp_data.get("candidates", [])
|
|
44
|
+
if not candidates:
|
|
45
|
+
raise RuntimeError("Gemini generateContent returned no candidates")
|
|
46
|
+
|
|
47
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
48
|
+
if not parts:
|
|
49
|
+
raise RuntimeError("Gemini generateContent candidate content had no parts")
|
|
50
|
+
|
|
51
|
+
return parts[0].get("text", "")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise RuntimeError(f"Gemini generateContent call failed: {e}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def summarize(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
57
|
+
session_id = data.get("sessionId")
|
|
58
|
+
if not session_id:
|
|
59
|
+
return {"success": False, "error": "sessionId is required"}
|
|
60
|
+
|
|
61
|
+
session = kv.get(KV.sessions, session_id)
|
|
62
|
+
if not session:
|
|
63
|
+
return {"success": False, "error": "session_not_found"}
|
|
64
|
+
|
|
65
|
+
observations = kv.list(KV.observations(session_id))
|
|
66
|
+
compressed = [o for o in observations if o.get("title")]
|
|
67
|
+
if not compressed:
|
|
68
|
+
return {"success": False, "error": "no_observations"}
|
|
69
|
+
|
|
70
|
+
SUMMARY_SYSTEM = """You are a session summarization assistant. Your job is to read all raw tool executions and outcomes from a coding session and produce a high-fidelity summary.
|
|
71
|
+
|
|
72
|
+
Output XML:
|
|
73
|
+
<summary>
|
|
74
|
+
<title>Concise title summarizing the session</title>
|
|
75
|
+
<narrative>1-2 paragraphs of narrative describing what was done, what succeeded, and what failed</narrative>
|
|
76
|
+
<decisions>
|
|
77
|
+
<decision>Architectural decision, key insight, or choice made</decision>
|
|
78
|
+
</decisions>
|
|
79
|
+
<files>
|
|
80
|
+
<file>path/to/modified/file</file>
|
|
81
|
+
</files>
|
|
82
|
+
<concepts>
|
|
83
|
+
<concept>important concept, library, tool, or command used</concept>
|
|
84
|
+
</concepts>
|
|
85
|
+
</summary>"""
|
|
86
|
+
|
|
87
|
+
chunk_size = 400
|
|
88
|
+
chunks = [
|
|
89
|
+
compressed[i : i + chunk_size] for i in range(0, len(compressed), chunk_size)
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
partial_summaries = []
|
|
93
|
+
last_error = ""
|
|
94
|
+
for idx, chunk in enumerate(chunks):
|
|
95
|
+
obs_text = ""
|
|
96
|
+
for o in chunk:
|
|
97
|
+
obs_text += f"[{o.get('type')}] {o.get('title')}\n{o.get('narrative') or ''}\nFiles: {', '.join(o.get('files') or [])}\n\n"
|
|
98
|
+
|
|
99
|
+
prompt = f"Summarize this chunk {idx + 1}/{len(chunks)} of observations:\n\n{obs_text}"
|
|
100
|
+
try:
|
|
101
|
+
response = generate_content(SUMMARY_SYSTEM, prompt)
|
|
102
|
+
cleaned = strip_xml_wrappers(response)
|
|
103
|
+
title = get_xml_tag(cleaned, "title")
|
|
104
|
+
if not title:
|
|
105
|
+
continue
|
|
106
|
+
partial_summaries.append(
|
|
107
|
+
{
|
|
108
|
+
"title": title,
|
|
109
|
+
"narrative": get_xml_tag(cleaned, "narrative") or "",
|
|
110
|
+
"keyDecisions": get_xml_children(cleaned, "decisions", "decision"),
|
|
111
|
+
"filesModified": get_xml_children(cleaned, "files", "file"),
|
|
112
|
+
"concepts": get_xml_children(cleaned, "concepts", "concept"),
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
last_error = str(e)
|
|
117
|
+
print(f"[summarize] Chunk {idx + 1} failed: {e}")
|
|
118
|
+
|
|
119
|
+
if not partial_summaries:
|
|
120
|
+
return {
|
|
121
|
+
"success": False,
|
|
122
|
+
"error": f"No chunks summarized successfully. Last error: {last_error}",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if len(partial_summaries) == 1:
|
|
126
|
+
final_summary = {
|
|
127
|
+
"sessionId": session_id,
|
|
128
|
+
"project": session.get("project"),
|
|
129
|
+
"createdAt": datetime.datetime.now(datetime.timezone.utc)
|
|
130
|
+
.isoformat()
|
|
131
|
+
.replace("+00:00", "Z"),
|
|
132
|
+
"title": partial_summaries[0]["title"],
|
|
133
|
+
"narrative": partial_summaries[0]["narrative"],
|
|
134
|
+
"keyDecisions": partial_summaries[0]["keyDecisions"],
|
|
135
|
+
"filesModified": partial_summaries[0]["filesModified"],
|
|
136
|
+
"concepts": partial_summaries[0]["concepts"],
|
|
137
|
+
"observationCount": len(compressed),
|
|
138
|
+
}
|
|
139
|
+
else:
|
|
140
|
+
REDUCE_SYSTEM = """You are a session summarization reducer. Reduce multiple partial chunk summaries into a single final summary.
|
|
141
|
+
|
|
142
|
+
Output XML:
|
|
143
|
+
<summary>
|
|
144
|
+
<title>Concise final title summarizing the entire session</title>
|
|
145
|
+
<narrative>Comprehensive narrative describing what was done, what succeeded, and what failed</narrative>
|
|
146
|
+
<decisions>
|
|
147
|
+
<decision>Architectural decision, key insight, or choice made</decision>
|
|
148
|
+
</decisions>
|
|
149
|
+
<files>
|
|
150
|
+
<file>path/to/modified/file</file>
|
|
151
|
+
</files>
|
|
152
|
+
<concepts>
|
|
153
|
+
<concept>important concept, library, tool, or command used</concept>
|
|
154
|
+
</concepts>
|
|
155
|
+
</summary>"""
|
|
156
|
+
|
|
157
|
+
reduce_prompt = "Reduce these partial summaries:\n\n"
|
|
158
|
+
for idx, ps in enumerate(partial_summaries):
|
|
159
|
+
reduce_prompt += f"[Chunk {idx + 1}]\nTitle: {ps['title']}\nNarrative: {ps['narrative']}\nDecisions: {', '.join(ps['keyDecisions'])}\nFiles: {', '.join(ps['filesModified'])}\nConcepts: {', '.join(ps['concepts'])}\n\n"
|
|
160
|
+
try:
|
|
161
|
+
response = generate_content(REDUCE_SYSTEM, reduce_prompt)
|
|
162
|
+
cleaned = strip_xml_wrappers(response)
|
|
163
|
+
final_summary = {
|
|
164
|
+
"sessionId": session_id,
|
|
165
|
+
"project": session.get("project"),
|
|
166
|
+
"createdAt": datetime.datetime.now(datetime.timezone.utc)
|
|
167
|
+
.isoformat()
|
|
168
|
+
.replace("+00:00", "Z"),
|
|
169
|
+
"title": get_xml_tag(cleaned, "title") or partial_summaries[0]["title"],
|
|
170
|
+
"narrative": get_xml_tag(cleaned, "narrative") or "",
|
|
171
|
+
"keyDecisions": get_xml_children(cleaned, "decisions", "decision"),
|
|
172
|
+
"filesModified": get_xml_children(cleaned, "files", "file"),
|
|
173
|
+
"concepts": get_xml_children(cleaned, "concepts", "concept"),
|
|
174
|
+
"observationCount": len(compressed),
|
|
175
|
+
}
|
|
176
|
+
except Exception as e:
|
|
177
|
+
return {"success": False, "error": f"Reduction failed: {e}"}
|
|
178
|
+
|
|
179
|
+
kv.set(KV.summaries, session_id, final_summary)
|
|
180
|
+
|
|
181
|
+
session = kv.get(KV.sessions, session_id)
|
|
182
|
+
if session:
|
|
183
|
+
session["title"] = final_summary["title"]
|
|
184
|
+
session["summary"] = final_summary["narrative"]
|
|
185
|
+
kv.set(KV.sessions, session_id, session)
|
|
186
|
+
|
|
187
|
+
safe_audit(
|
|
188
|
+
kv,
|
|
189
|
+
"compress",
|
|
190
|
+
"mem::summarize",
|
|
191
|
+
[session_id],
|
|
192
|
+
{"title": final_summary["title"], "observationCount": len(compressed)},
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return {"success": True, "summary": final_summary}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def consolidate(
|
|
199
|
+
kv: StateKV, project: Optional[str] = None, min_observations: int = 10
|
|
200
|
+
) -> Dict[str, Any]:
|
|
201
|
+
from .. import legacy as _legacy
|
|
202
|
+
|
|
203
|
+
sessions = list_sessions(kv)
|
|
204
|
+
if project:
|
|
205
|
+
sessions = [s for s in sessions if s.get("project") == project]
|
|
206
|
+
|
|
207
|
+
all_obs = []
|
|
208
|
+
for s in sessions:
|
|
209
|
+
obs_list = kv.list(KV.observations(s["id"]))
|
|
210
|
+
for o in obs_list:
|
|
211
|
+
if o.get("title") and o.get("importance", 5) >= 5:
|
|
212
|
+
all_obs.append((o, s["id"]))
|
|
213
|
+
|
|
214
|
+
if len(all_obs) < min_observations:
|
|
215
|
+
return {
|
|
216
|
+
"consolidated": 0,
|
|
217
|
+
"reason": "insufficient_observations",
|
|
218
|
+
"success": True,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
concept_groups: Dict[str, List] = {}
|
|
222
|
+
for obs, sid in all_obs:
|
|
223
|
+
concepts = obs.get("concepts") or []
|
|
224
|
+
for c in concepts:
|
|
225
|
+
key = c.lower().strip()
|
|
226
|
+
if not key:
|
|
227
|
+
continue
|
|
228
|
+
if key not in concept_groups:
|
|
229
|
+
concept_groups[key] = []
|
|
230
|
+
concept_groups[key].append((obs, sid))
|
|
231
|
+
|
|
232
|
+
sorted_groups = sorted(
|
|
233
|
+
[(k, g) for k, g in concept_groups.items() if len(g) >= 3],
|
|
234
|
+
key=lambda x: len(x[1]),
|
|
235
|
+
reverse=True,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
consolidated_count = 0
|
|
239
|
+
existing_memories = kv.list(KV.memories)
|
|
240
|
+
|
|
241
|
+
MAX_LLM_CALLS = 10
|
|
242
|
+
llm_calls = 0
|
|
243
|
+
|
|
244
|
+
CONSOLIDATION_SYSTEM = """You are a memory consolidation engine. Given a set of related observations from coding sessions, synthesize them into a single long-term memory.
|
|
245
|
+
|
|
246
|
+
Output XML:
|
|
247
|
+
<memory>
|
|
248
|
+
<type>pattern|preference|architecture|bug|workflow|fact</type>
|
|
249
|
+
<title>Concise memory title (max 80 chars)</title>
|
|
250
|
+
<content>2-4 sentence description of the learned insight</content>
|
|
251
|
+
<concepts>
|
|
252
|
+
<concept>key term</concept>
|
|
253
|
+
</concepts>
|
|
254
|
+
<files>
|
|
255
|
+
<file>relevant/file/path</file>
|
|
256
|
+
</files>
|
|
257
|
+
<strength>1-10 how confident/important this memory is</strength>
|
|
258
|
+
</memory>"""
|
|
259
|
+
|
|
260
|
+
for concept, obs_group in sorted_groups:
|
|
261
|
+
if llm_calls >= MAX_LLM_CALLS:
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
top = sorted(obs_group, key=lambda x: x[0].get("importance", 5), reverse=True)[
|
|
265
|
+
:8
|
|
266
|
+
]
|
|
267
|
+
session_ids = list(set([x[1] for x in top]))
|
|
268
|
+
obs_ids = list(set([x[0]["id"] for x in top]))
|
|
269
|
+
|
|
270
|
+
prompt_parts = []
|
|
271
|
+
for obs, sid in top:
|
|
272
|
+
prompt_parts.append(
|
|
273
|
+
f"[{obs.get('type')}] {obs.get('title')}\n{obs.get('narrative') or ''}\nFiles: {', '.join(obs.get('files') or [])}\nImportance: {obs.get('importance', 5)}"
|
|
274
|
+
)
|
|
275
|
+
obs_prompt = "\n\n".join(prompt_parts)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
response = generate_content(
|
|
279
|
+
CONSOLIDATION_SYSTEM,
|
|
280
|
+
f'Concept: "{concept}"\n\nObservations:\n{obs_prompt}',
|
|
281
|
+
)
|
|
282
|
+
llm_calls += 1
|
|
283
|
+
|
|
284
|
+
cleaned = strip_xml_wrappers(response)
|
|
285
|
+
m_type = get_xml_tag(cleaned, "type") or "fact"
|
|
286
|
+
m_title = get_xml_tag(cleaned, "title")
|
|
287
|
+
m_content = get_xml_tag(cleaned, "content")
|
|
288
|
+
|
|
289
|
+
if not m_title or not m_content:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
m_strength_str = get_xml_tag(cleaned, "strength") or "5"
|
|
293
|
+
try:
|
|
294
|
+
m_strength = max(1, min(10, int(m_strength_str)))
|
|
295
|
+
except Exception:
|
|
296
|
+
m_strength = 5
|
|
297
|
+
|
|
298
|
+
concepts_list = get_xml_children(cleaned, "concepts", "concept")
|
|
299
|
+
files_list = get_xml_children(cleaned, "files", "file")
|
|
300
|
+
|
|
301
|
+
now = (
|
|
302
|
+
datetime.datetime.now(datetime.timezone.utc)
|
|
303
|
+
.isoformat()
|
|
304
|
+
.replace("+00:00", "Z")
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
existing_match = None
|
|
308
|
+
for mem in existing_memories:
|
|
309
|
+
if (
|
|
310
|
+
mem.get("title", "").lower() == m_title.lower()
|
|
311
|
+
and mem.get("isLatest") is not False
|
|
312
|
+
):
|
|
313
|
+
if (
|
|
314
|
+
not project
|
|
315
|
+
or not mem.get("project")
|
|
316
|
+
or mem.get("project") == project
|
|
317
|
+
):
|
|
318
|
+
existing_match = mem
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
if existing_match:
|
|
322
|
+
existing_match["isLatest"] = False
|
|
323
|
+
kv.set(KV.memories, existing_match["id"], existing_match)
|
|
324
|
+
|
|
325
|
+
evolved = {
|
|
326
|
+
"id": generate_id("mem"),
|
|
327
|
+
"createdAt": now,
|
|
328
|
+
"updatedAt": now,
|
|
329
|
+
"type": m_type,
|
|
330
|
+
"title": m_title,
|
|
331
|
+
"content": m_content,
|
|
332
|
+
"concepts": concepts_list,
|
|
333
|
+
"files": files_list,
|
|
334
|
+
"sessionIds": session_ids,
|
|
335
|
+
"strength": m_strength,
|
|
336
|
+
"version": (existing_match.get("version") or 1) + 1,
|
|
337
|
+
"parentId": existing_match["id"],
|
|
338
|
+
"supersedes": [existing_match["id"]]
|
|
339
|
+
+ (existing_match.get("supersedes") or []),
|
|
340
|
+
"sourceObservationIds": obs_ids,
|
|
341
|
+
"isLatest": True,
|
|
342
|
+
}
|
|
343
|
+
if project:
|
|
344
|
+
evolved["project"] = project
|
|
345
|
+
kv.set(KV.memories, evolved["id"], evolved)
|
|
346
|
+
if _legacy._search_service:
|
|
347
|
+
try:
|
|
348
|
+
_legacy._search_service.bm25.add(memory_to_observation(evolved))
|
|
349
|
+
if existing_match:
|
|
350
|
+
_legacy._search_service.bm25.remove(existing_match["id"])
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
comb_text = evolved["title"] + " " + evolved["content"]
|
|
354
|
+
vector_index_add_guarded(
|
|
355
|
+
evolved["id"],
|
|
356
|
+
"memory",
|
|
357
|
+
comb_text,
|
|
358
|
+
{"kind": "memory", "logId": evolved["id"]},
|
|
359
|
+
)
|
|
360
|
+
if (
|
|
361
|
+
_legacy._search_service
|
|
362
|
+
and _legacy._search_service.vector
|
|
363
|
+
and existing_match
|
|
364
|
+
):
|
|
365
|
+
try:
|
|
366
|
+
_legacy._search_service.vector.remove(existing_match["id"])
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
consolidated_count += 1
|
|
370
|
+
else:
|
|
371
|
+
memory = {
|
|
372
|
+
"id": generate_id("mem"),
|
|
373
|
+
"createdAt": now,
|
|
374
|
+
"updatedAt": now,
|
|
375
|
+
"type": m_type,
|
|
376
|
+
"title": m_title,
|
|
377
|
+
"content": m_content,
|
|
378
|
+
"concepts": concepts_list,
|
|
379
|
+
"files": files_list,
|
|
380
|
+
"sessionIds": session_ids,
|
|
381
|
+
"strength": m_strength,
|
|
382
|
+
"version": 1,
|
|
383
|
+
"sourceObservationIds": obs_ids,
|
|
384
|
+
"isLatest": True,
|
|
385
|
+
}
|
|
386
|
+
if project:
|
|
387
|
+
memory["project"] = project
|
|
388
|
+
kv.set(KV.memories, memory["id"], memory)
|
|
389
|
+
if _legacy._search_service:
|
|
390
|
+
try:
|
|
391
|
+
_legacy._search_service.bm25.add(memory_to_observation(memory))
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
comb_text = memory["title"] + " " + memory["content"]
|
|
395
|
+
vector_index_add_guarded(
|
|
396
|
+
memory["id"],
|
|
397
|
+
"memory",
|
|
398
|
+
comb_text,
|
|
399
|
+
{"kind": "memory", "logId": memory["id"]},
|
|
400
|
+
)
|
|
401
|
+
consolidated_count += 1
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
print(f"[consolidate] Concept '{concept}' failed: {e}")
|
|
405
|
+
|
|
406
|
+
# === Semantic Memory Fact Merger ===
|
|
407
|
+
summaries = kv.list(KV.summaries)
|
|
408
|
+
new_facts_count = 0
|
|
409
|
+
if len(summaries) >= 5:
|
|
410
|
+
recent_summaries = sorted(
|
|
411
|
+
summaries, key=lambda s: s.get("createdAt", ""), reverse=True
|
|
412
|
+
)[:20]
|
|
413
|
+
|
|
414
|
+
SEMANTIC_MERGE_SYSTEM = """You are a memory consolidation engine. Given overlapping episodic memories (session summaries), extract stable factual knowledge.
|
|
415
|
+
|
|
416
|
+
Output format (XML):
|
|
417
|
+
<facts>
|
|
418
|
+
<fact confidence="0.0-1.0">Concise factual statement</fact>
|
|
419
|
+
</facts>
|
|
420
|
+
|
|
421
|
+
Rules:
|
|
422
|
+
- Extract only facts that appear in 2+ episodes or are highly confident
|
|
423
|
+
- Confidence reflects how well-supported the fact is across episodes
|
|
424
|
+
- Combine overlapping information into single concise facts
|
|
425
|
+
- Skip ephemeral details (specific error messages, temporary states)"""
|
|
426
|
+
|
|
427
|
+
prompt_parts = []
|
|
428
|
+
for i, s in enumerate(recent_summaries):
|
|
429
|
+
prompt_parts.append(
|
|
430
|
+
f"[Episode {i + 1}]\nTitle: {s.get('title')}\nNarrative: {s.get('narrative') or ''}\nConcepts: {', '.join(s.get('concepts') or [])}"
|
|
431
|
+
)
|
|
432
|
+
merge_prompt = (
|
|
433
|
+
"Consolidate these episodic memories into stable facts:\n\n"
|
|
434
|
+
+ "\n\n".join(prompt_parts)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
response = generate_content(SEMANTIC_MERGE_SYSTEM, merge_prompt)
|
|
439
|
+
fact_matches = re.findall(
|
|
440
|
+
r'<fact\s+confidence="([^"]+)">([^<]+)</fact>', response, re.DOTALL
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
existing_semantic = kv.list(KV.semantic)
|
|
444
|
+
now = (
|
|
445
|
+
datetime.datetime.now(datetime.timezone.utc)
|
|
446
|
+
.isoformat()
|
|
447
|
+
.replace("+00:00", "Z")
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
for conf_str, fact_text in fact_matches:
|
|
451
|
+
fact_text = fact_text.strip()
|
|
452
|
+
try:
|
|
453
|
+
confidence = float(conf_str)
|
|
454
|
+
except Exception:
|
|
455
|
+
confidence = 0.5
|
|
456
|
+
|
|
457
|
+
existing = None
|
|
458
|
+
for es in existing_semantic:
|
|
459
|
+
if es.get("fact", "").lower() == fact_text.lower():
|
|
460
|
+
existing = es
|
|
461
|
+
break
|
|
462
|
+
|
|
463
|
+
if existing:
|
|
464
|
+
existing["accessCount"] = (existing.get("accessCount") or 0) + 1
|
|
465
|
+
existing["lastAccessedAt"] = now
|
|
466
|
+
existing["updatedAt"] = now
|
|
467
|
+
existing["confidence"] = max(
|
|
468
|
+
existing.get("confidence", 0.5), confidence
|
|
469
|
+
)
|
|
470
|
+
kv.set(KV.semantic, existing["id"], existing)
|
|
471
|
+
else:
|
|
472
|
+
sem = {
|
|
473
|
+
"id": generate_id("sem"),
|
|
474
|
+
"fact": fact_text,
|
|
475
|
+
"confidence": confidence,
|
|
476
|
+
"sourceSessionIds": [
|
|
477
|
+
s["sessionId"] for s in recent_summaries if "sessionId" in s
|
|
478
|
+
],
|
|
479
|
+
"sourceMemoryIds": [],
|
|
480
|
+
"accessCount": 1,
|
|
481
|
+
"lastAccessedAt": now,
|
|
482
|
+
"strength": confidence,
|
|
483
|
+
"createdAt": now,
|
|
484
|
+
"updatedAt": now,
|
|
485
|
+
}
|
|
486
|
+
kv.set(KV.semantic, sem["id"], sem)
|
|
487
|
+
new_facts_count += 1
|
|
488
|
+
except Exception as e:
|
|
489
|
+
print(f"[consolidate] Semantic merge failed: {e}")
|
|
490
|
+
|
|
491
|
+
# === Procedural Memory Extraction ===
|
|
492
|
+
memories = kv.list(KV.memories)
|
|
493
|
+
new_procs_count = 0
|
|
494
|
+
patterns = []
|
|
495
|
+
for m in memories:
|
|
496
|
+
if m.get("isLatest") is not False and m.get("type") == "pattern":
|
|
497
|
+
freq = len(m.get("sessionIds") or [])
|
|
498
|
+
if freq >= 2:
|
|
499
|
+
patterns.append({"content": m.get("content", ""), "frequency": freq})
|
|
500
|
+
|
|
501
|
+
if len(patterns) >= 2:
|
|
502
|
+
PROCEDURAL_EXTRACTION_SYSTEM = """You are a procedural memory extractor. Given repeated patterns and workflows observed across sessions, extract reusable procedures.
|
|
503
|
+
|
|
504
|
+
Output format (XML):
|
|
505
|
+
<procedures>
|
|
506
|
+
<procedure name="short descriptive name" trigger="when to use this procedure">
|
|
507
|
+
<step>Step 1 description</step>
|
|
508
|
+
<step>Step 2 description</step>
|
|
509
|
+
</procedure>
|
|
510
|
+
</procedures>
|
|
511
|
+
|
|
512
|
+
Rules:
|
|
513
|
+
- Only extract procedures observed 2+ times
|
|
514
|
+
- Steps should be concrete and actionable
|
|
515
|
+
- Trigger condition should be specific enough to match automatically"""
|
|
516
|
+
|
|
517
|
+
prompt_parts = []
|
|
518
|
+
for i, p in enumerate(patterns):
|
|
519
|
+
prompt_parts.append(
|
|
520
|
+
f"[Pattern {i + 1}] (seen {p['frequency']}x)\n{p['content']}"
|
|
521
|
+
)
|
|
522
|
+
proc_prompt = (
|
|
523
|
+
"Extract reusable procedures from these recurring patterns:\n\n"
|
|
524
|
+
+ "\n\n".join(prompt_parts)
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
response = generate_content(PROCEDURAL_EXTRACTION_SYSTEM, proc_prompt)
|
|
529
|
+
proc_matches = re.findall(
|
|
530
|
+
r'<procedure\s+name="([^"]+)"\s+trigger="([^"]+)">([\s\S]*?)</procedure>',
|
|
531
|
+
response,
|
|
532
|
+
re.DOTALL,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
existing_procs = kv.list(KV.procedural)
|
|
536
|
+
now = (
|
|
537
|
+
datetime.datetime.now(datetime.timezone.utc)
|
|
538
|
+
.isoformat()
|
|
539
|
+
.replace("+00:00", "Z")
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
for name, trigger, steps_block in proc_matches:
|
|
543
|
+
steps = [
|
|
544
|
+
s.strip()
|
|
545
|
+
for s in re.findall(r"<step>([^<]+)</step>", steps_block, re.DOTALL)
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
existing = None
|
|
549
|
+
for ep in existing_procs:
|
|
550
|
+
if ep.get("name", "").lower() == name.lower():
|
|
551
|
+
existing = ep
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
if existing:
|
|
555
|
+
existing["frequency"] = (existing.get("frequency") or 1) + 1
|
|
556
|
+
existing["updatedAt"] = now
|
|
557
|
+
existing["strength"] = min(
|
|
558
|
+
1.0, (existing.get("strength") or 0.5) + 0.1
|
|
559
|
+
)
|
|
560
|
+
kv.set(KV.procedural, existing["id"], existing)
|
|
561
|
+
else:
|
|
562
|
+
proc = {
|
|
563
|
+
"id": generate_id("proc"),
|
|
564
|
+
"name": name,
|
|
565
|
+
"steps": steps,
|
|
566
|
+
"triggerCondition": trigger,
|
|
567
|
+
"frequency": 1,
|
|
568
|
+
"sourceSessionIds": [],
|
|
569
|
+
"strength": 0.5,
|
|
570
|
+
"createdAt": now,
|
|
571
|
+
"updatedAt": now,
|
|
572
|
+
}
|
|
573
|
+
kv.set(KV.procedural, proc["id"], proc)
|
|
574
|
+
new_procs_count += 1
|
|
575
|
+
except Exception as e:
|
|
576
|
+
print(f"[consolidate] Procedural extraction failed: {e}")
|
|
577
|
+
|
|
578
|
+
res_summary = {
|
|
579
|
+
"success": True,
|
|
580
|
+
"consolidated": consolidated_count,
|
|
581
|
+
"totalObservations": len(all_obs),
|
|
582
|
+
"semantic": {"newFacts": new_facts_count, "totalSummaries": len(summaries)},
|
|
583
|
+
"procedural": {
|
|
584
|
+
"newProcedures": new_procs_count,
|
|
585
|
+
"patternsAnalyzed": len(patterns),
|
|
586
|
+
},
|
|
587
|
+
}
|
|
588
|
+
if _legacy._search_service and consolidated_count > 0:
|
|
589
|
+
_legacy._search_service.schedule_persist()
|
|
590
|
+
safe_audit(kv, "consolidate", "mem::consolidate-pipeline", [], res_summary)
|
|
591
|
+
commit_if_enabled(
|
|
592
|
+
kv,
|
|
593
|
+
f"Consolidation complete: consolidated={consolidated_count}, facts={new_facts_count}, procs={new_procs_count}",
|
|
594
|
+
"system",
|
|
595
|
+
)
|
|
596
|
+
return res_summary
|