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
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