hindsight-api 0.3.0__py3-none-any.whl → 0.4.1__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 (75) hide show
  1. hindsight_api/__init__.py +1 -1
  2. hindsight_api/admin/cli.py +59 -0
  3. hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
  4. hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
  5. hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
  6. hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
  7. hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
  8. hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
  9. hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
  10. hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
  11. hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
  12. hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
  13. hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
  14. hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
  15. hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
  16. hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
  17. hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
  18. hindsight_api/api/http.py +1120 -93
  19. hindsight_api/api/mcp.py +11 -191
  20. hindsight_api/config.py +174 -46
  21. hindsight_api/engine/consolidation/__init__.py +5 -0
  22. hindsight_api/engine/consolidation/consolidator.py +926 -0
  23. hindsight_api/engine/consolidation/prompts.py +77 -0
  24. hindsight_api/engine/cross_encoder.py +153 -22
  25. hindsight_api/engine/directives/__init__.py +5 -0
  26. hindsight_api/engine/directives/models.py +37 -0
  27. hindsight_api/engine/embeddings.py +136 -13
  28. hindsight_api/engine/interface.py +32 -13
  29. hindsight_api/engine/llm_wrapper.py +505 -43
  30. hindsight_api/engine/memory_engine.py +2101 -1094
  31. hindsight_api/engine/mental_models/__init__.py +14 -0
  32. hindsight_api/engine/mental_models/models.py +53 -0
  33. hindsight_api/engine/reflect/__init__.py +18 -0
  34. hindsight_api/engine/reflect/agent.py +933 -0
  35. hindsight_api/engine/reflect/models.py +109 -0
  36. hindsight_api/engine/reflect/observations.py +186 -0
  37. hindsight_api/engine/reflect/prompts.py +483 -0
  38. hindsight_api/engine/reflect/tools.py +437 -0
  39. hindsight_api/engine/reflect/tools_schema.py +250 -0
  40. hindsight_api/engine/response_models.py +130 -4
  41. hindsight_api/engine/retain/bank_utils.py +79 -201
  42. hindsight_api/engine/retain/fact_extraction.py +81 -48
  43. hindsight_api/engine/retain/fact_storage.py +5 -8
  44. hindsight_api/engine/retain/link_utils.py +5 -8
  45. hindsight_api/engine/retain/orchestrator.py +1 -55
  46. hindsight_api/engine/retain/types.py +2 -2
  47. hindsight_api/engine/search/graph_retrieval.py +2 -2
  48. hindsight_api/engine/search/link_expansion_retrieval.py +164 -29
  49. hindsight_api/engine/search/mpfp_retrieval.py +1 -1
  50. hindsight_api/engine/search/retrieval.py +14 -14
  51. hindsight_api/engine/search/think_utils.py +41 -140
  52. hindsight_api/engine/search/trace.py +0 -1
  53. hindsight_api/engine/search/tracer.py +2 -5
  54. hindsight_api/engine/search/types.py +0 -3
  55. hindsight_api/engine/task_backend.py +112 -196
  56. hindsight_api/engine/utils.py +0 -151
  57. hindsight_api/extensions/__init__.py +10 -1
  58. hindsight_api/extensions/builtin/tenant.py +11 -4
  59. hindsight_api/extensions/operation_validator.py +81 -4
  60. hindsight_api/extensions/tenant.py +26 -0
  61. hindsight_api/main.py +28 -5
  62. hindsight_api/mcp_local.py +12 -53
  63. hindsight_api/mcp_tools.py +494 -0
  64. hindsight_api/models.py +0 -2
  65. hindsight_api/worker/__init__.py +11 -0
  66. hindsight_api/worker/main.py +296 -0
  67. hindsight_api/worker/poller.py +486 -0
  68. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/METADATA +12 -6
  69. hindsight_api-0.4.1.dist-info/RECORD +112 -0
  70. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/entry_points.txt +1 -0
  71. hindsight_api/engine/retain/observation_regeneration.py +0 -254
  72. hindsight_api/engine/search/observation_utils.py +0 -125
  73. hindsight_api/engine/search/scoring.py +0 -159
  74. hindsight_api-0.3.0.dist-info/RECORD +0 -82
  75. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,926 @@
1
+ """Consolidation engine for automatic observation creation from memories.
2
+
3
+ The consolidation engine runs as a background job after retain operations complete.
4
+ It processes new memories and either:
5
+ - Creates new observations from novel facts
6
+ - Updates existing observations when new evidence supports/contradicts/refines them
7
+
8
+ Observations are stored in memory_units with fact_type='observation' and include:
9
+ - proof_count: Number of supporting memories
10
+ - source_memory_ids: Array of memory UUIDs that contribute to this observation
11
+ - history: JSONB tracking changes over time
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ import time
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from ..memory_engine import fq_table
22
+ from ..retain import embedding_utils
23
+ from .prompts import (
24
+ CONSOLIDATION_SYSTEM_PROMPT,
25
+ CONSOLIDATION_USER_PROMPT,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from asyncpg import Connection
30
+
31
+ from ...api.http import RequestContext
32
+ from ..memory_engine import MemoryEngine
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class ConsolidationPerfLog:
38
+ """Performance logging for consolidation operations."""
39
+
40
+ def __init__(self, bank_id: str):
41
+ self.bank_id = bank_id
42
+ self.start_time = time.time()
43
+ self.lines: list[str] = []
44
+ self.timings: dict[str, float] = {}
45
+
46
+ def log(self, message: str) -> None:
47
+ """Add a log line."""
48
+ self.lines.append(message)
49
+
50
+ def record_timing(self, key: str, duration: float) -> None:
51
+ """Record a timing measurement."""
52
+ if key in self.timings:
53
+ self.timings[key] += duration
54
+ else:
55
+ self.timings[key] = duration
56
+
57
+ def flush(self) -> None:
58
+ """Flush all log lines to the logger."""
59
+ total_time = time.time() - self.start_time
60
+ header = f"\n{'=' * 60}\nCONSOLIDATION for bank {self.bank_id}"
61
+ footer = f"{'=' * 60}\nCONSOLIDATION COMPLETE: {total_time:.3f}s total\n{'=' * 60}"
62
+
63
+ log_output = header + "\n" + "\n".join(self.lines) + "\n" + footer
64
+ logger.info(log_output)
65
+
66
+
67
+ async def run_consolidation_job(
68
+ memory_engine: "MemoryEngine",
69
+ bank_id: str,
70
+ request_context: "RequestContext",
71
+ ) -> dict[str, Any]:
72
+ """
73
+ Run consolidation job for a bank.
74
+
75
+ This is called after retain operations to consolidate new memories into mental models.
76
+
77
+ Args:
78
+ memory_engine: MemoryEngine instance
79
+ bank_id: Bank identifier
80
+ request_context: Request context for authentication
81
+
82
+ Returns:
83
+ Dict with consolidation results
84
+ """
85
+ from ...config import get_config
86
+
87
+ config = get_config()
88
+ perf = ConsolidationPerfLog(bank_id)
89
+ max_memories_per_batch = config.consolidation_batch_size
90
+
91
+ # Check if consolidation is enabled
92
+ if not config.enable_observations:
93
+ logger.debug(f"Consolidation disabled for bank {bank_id}")
94
+ return {"status": "disabled", "bank_id": bank_id}
95
+
96
+ pool = memory_engine._pool
97
+
98
+ # Get bank profile
99
+ async with pool.acquire() as conn:
100
+ t0 = time.time()
101
+ bank_row = await conn.fetchrow(
102
+ f"""
103
+ SELECT bank_id, name, mission
104
+ FROM {fq_table("banks")}
105
+ WHERE bank_id = $1
106
+ """,
107
+ bank_id,
108
+ )
109
+
110
+ if not bank_row:
111
+ logger.warning(f"Bank {bank_id} not found for consolidation")
112
+ return {"status": "bank_not_found", "bank_id": bank_id}
113
+
114
+ mission = bank_row["mission"] or "General memory consolidation"
115
+ perf.record_timing("fetch_bank", time.time() - t0)
116
+
117
+ # Count total unconsolidated memories for progress logging
118
+ total_count = await conn.fetchval(
119
+ f"""
120
+ SELECT COUNT(*)
121
+ FROM {fq_table("memory_units")}
122
+ WHERE bank_id = $1
123
+ AND consolidated_at IS NULL
124
+ AND fact_type IN ('experience', 'world')
125
+ """,
126
+ bank_id,
127
+ )
128
+
129
+ if total_count == 0:
130
+ logger.debug(f"No new memories to consolidate for bank {bank_id}")
131
+ return {"status": "no_new_memories", "bank_id": bank_id, "memories_processed": 0}
132
+
133
+ logger.info(f"[CONSOLIDATION] bank={bank_id} total_unconsolidated={total_count}")
134
+ perf.log(f"[1] Found {total_count} pending memories to consolidate")
135
+
136
+ # Process each memory with individual commits for crash recovery
137
+ stats = {
138
+ "memories_processed": 0,
139
+ "observations_created": 0,
140
+ "observations_updated": 0,
141
+ "observations_merged": 0,
142
+ "actions_executed": 0,
143
+ "skipped": 0,
144
+ }
145
+
146
+ batch_num = 0
147
+ while True:
148
+ batch_num += 1
149
+ batch_start = time.time()
150
+
151
+ # Fetch next batch of unconsolidated memories
152
+ async with pool.acquire() as conn:
153
+ t0 = time.time()
154
+ memories = await conn.fetch(
155
+ f"""
156
+ SELECT id, text, fact_type, occurred_start, occurred_end, event_date, tags, mentioned_at
157
+ FROM {fq_table("memory_units")}
158
+ WHERE bank_id = $1
159
+ AND consolidated_at IS NULL
160
+ AND fact_type IN ('experience', 'world')
161
+ ORDER BY created_at ASC
162
+ LIMIT $2
163
+ """,
164
+ bank_id,
165
+ max_memories_per_batch,
166
+ )
167
+ perf.record_timing("fetch_memories", time.time() - t0)
168
+
169
+ if not memories:
170
+ break # No more unconsolidated memories
171
+
172
+ for memory in memories:
173
+ mem_start = time.time()
174
+
175
+ # Process the memory (uses its own connection internally)
176
+ async with pool.acquire() as conn:
177
+ result = await _process_memory(
178
+ conn=conn,
179
+ memory_engine=memory_engine,
180
+ bank_id=bank_id,
181
+ memory=dict(memory),
182
+ mission=mission,
183
+ request_context=request_context,
184
+ perf=perf,
185
+ )
186
+
187
+ # Mark memory as consolidated (committed immediately)
188
+ await conn.execute(
189
+ f"""
190
+ UPDATE {fq_table("memory_units")}
191
+ SET consolidated_at = NOW()
192
+ WHERE id = $1
193
+ """,
194
+ memory["id"],
195
+ )
196
+
197
+ mem_time = time.time() - mem_start
198
+ perf.record_timing("process_memory_total", mem_time)
199
+
200
+ stats["memories_processed"] += 1
201
+
202
+ action = result.get("action")
203
+ if action == "created":
204
+ stats["observations_created"] += 1
205
+ stats["actions_executed"] += 1
206
+ elif action == "updated":
207
+ stats["observations_updated"] += 1
208
+ stats["actions_executed"] += 1
209
+ elif action == "merged":
210
+ stats["observations_merged"] += 1
211
+ stats["actions_executed"] += 1
212
+ elif action == "multiple":
213
+ stats["observations_created"] += result.get("created", 0)
214
+ stats["observations_updated"] += result.get("updated", 0)
215
+ stats["observations_merged"] += result.get("merged", 0)
216
+ stats["actions_executed"] += result.get("total_actions", 0)
217
+ elif action == "skipped":
218
+ stats["skipped"] += 1
219
+
220
+ # Log progress periodically
221
+ if stats["memories_processed"] % 10 == 0:
222
+ logger.info(
223
+ f"[CONSOLIDATION] bank={bank_id} progress: "
224
+ f"{stats['memories_processed']}/{total_count} memories processed"
225
+ )
226
+
227
+ batch_time = time.time() - batch_start
228
+ perf.log(
229
+ f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
230
+ f"(avg {batch_time / len(memories):.3f}s/memory)"
231
+ )
232
+
233
+ # Build summary
234
+ perf.log(
235
+ f"[3] Results: {stats['memories_processed']} memories -> "
236
+ f"{stats['actions_executed']} actions "
237
+ f"({stats['observations_created']} created, "
238
+ f"{stats['observations_updated']} updated, "
239
+ f"{stats['observations_merged']} merged, "
240
+ f"{stats['skipped']} skipped)"
241
+ )
242
+
243
+ # Add timing breakdown
244
+ timing_parts = []
245
+ if "recall" in perf.timings:
246
+ timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
247
+ if "llm" in perf.timings:
248
+ timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
249
+ if "embedding" in perf.timings:
250
+ timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
251
+ if "db_write" in perf.timings:
252
+ timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
253
+
254
+ if timing_parts:
255
+ perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
256
+
257
+ # Trigger mental model refreshes for models with refresh_after_consolidation=true
258
+ mental_models_refreshed = await _trigger_mental_model_refreshes(
259
+ memory_engine=memory_engine,
260
+ bank_id=bank_id,
261
+ request_context=request_context,
262
+ perf=perf,
263
+ )
264
+ stats["mental_models_refreshed"] = mental_models_refreshed
265
+
266
+ perf.flush()
267
+
268
+ return {"status": "completed", "bank_id": bank_id, **stats}
269
+
270
+
271
+ async def _trigger_mental_model_refreshes(
272
+ memory_engine: "MemoryEngine",
273
+ bank_id: str,
274
+ request_context: "RequestContext",
275
+ perf: ConsolidationPerfLog | None = None,
276
+ ) -> int:
277
+ """
278
+ Trigger refreshes for mental models with refresh_after_consolidation=true.
279
+
280
+ Args:
281
+ memory_engine: MemoryEngine instance
282
+ bank_id: Bank identifier
283
+ request_context: Request context for authentication
284
+ perf: Performance logging
285
+
286
+ Returns:
287
+ Number of mental models scheduled for refresh
288
+ """
289
+ pool = memory_engine._pool
290
+
291
+ # Find mental models with refresh_after_consolidation=true
292
+ async with pool.acquire() as conn:
293
+ rows = await conn.fetch(
294
+ f"""
295
+ SELECT id, name
296
+ FROM {fq_table("mental_models")}
297
+ WHERE bank_id = $1
298
+ AND (trigger->>'refresh_after_consolidation')::boolean = true
299
+ """,
300
+ bank_id,
301
+ )
302
+
303
+ if not rows:
304
+ return 0
305
+
306
+ if perf:
307
+ perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
308
+
309
+ # Submit refresh tasks for each mental model
310
+ refreshed_count = 0
311
+ for row in rows:
312
+ mental_model_id = row["id"]
313
+ try:
314
+ await memory_engine.submit_async_refresh_mental_model(
315
+ bank_id=bank_id,
316
+ mental_model_id=mental_model_id,
317
+ request_context=request_context,
318
+ )
319
+ refreshed_count += 1
320
+ logger.info(
321
+ f"[CONSOLIDATION] Triggered refresh for mental model {mental_model_id} "
322
+ f"(name: {row['name']}) in bank {bank_id}"
323
+ )
324
+ except Exception as e:
325
+ logger.warning(f"[CONSOLIDATION] Failed to trigger refresh for mental model {mental_model_id}: {e}")
326
+
327
+ return refreshed_count
328
+
329
+
330
+ async def _process_memory(
331
+ conn: "Connection",
332
+ memory_engine: "MemoryEngine",
333
+ bank_id: str,
334
+ memory: dict[str, Any],
335
+ mission: str,
336
+ request_context: "RequestContext",
337
+ perf: ConsolidationPerfLog | None = None,
338
+ ) -> dict[str, Any]:
339
+ """
340
+ Process a single memory for consolidation using a SINGLE LLM call.
341
+
342
+ This function:
343
+ 1. Finds related observations (can be empty)
344
+ 2. Uses ONE LLM call to extract durable knowledge AND decide on actions
345
+ 3. Executes array of actions (can be multiple creates/updates)
346
+
347
+ The LLM handles all cases:
348
+ - No related observations: returns create action(s) with extracted durable knowledge
349
+ - Related observations exist: returns update/create actions based on tag routing
350
+ - Purely ephemeral fact: returns empty array (skip)
351
+
352
+ Returns:
353
+ Dict with action summary: created/updated/merged counts
354
+ """
355
+ fact_text = memory["text"]
356
+ memory_id = memory["id"]
357
+ fact_tags = memory.get("tags") or []
358
+
359
+ # Find related observations using the full recall system (NO tag filtering)
360
+ t0 = time.time()
361
+ related_observations = await _find_related_observations(
362
+ conn=conn,
363
+ memory_engine=memory_engine,
364
+ bank_id=bank_id,
365
+ query=fact_text,
366
+ request_context=request_context,
367
+ )
368
+ if perf:
369
+ perf.record_timing("recall", time.time() - t0)
370
+
371
+ # Single LLM call handles ALL cases (with or without existing observations)
372
+ # Note: Tags are NOT passed to LLM - they are handled algorithmically
373
+ t0 = time.time()
374
+ actions = await _consolidate_with_llm(
375
+ memory_engine=memory_engine,
376
+ fact_text=fact_text,
377
+ observations=related_observations, # Can be empty list
378
+ mission=mission,
379
+ )
380
+ if perf:
381
+ perf.record_timing("llm", time.time() - t0)
382
+
383
+ if not actions:
384
+ # LLM returned empty array - fact is purely ephemeral, skip
385
+ return {"action": "skipped", "reason": "no_durable_knowledge"}
386
+
387
+ # Execute all actions and collect results
388
+ results = []
389
+ for action in actions:
390
+ action_type = action.get("action")
391
+ if action_type == "update":
392
+ result = await _execute_update_action(
393
+ conn=conn,
394
+ memory_engine=memory_engine,
395
+ bank_id=bank_id,
396
+ memory_id=memory_id,
397
+ action=action,
398
+ observations=related_observations,
399
+ source_fact_tags=fact_tags, # Pass source fact's tags for security
400
+ source_occurred_start=memory.get("occurred_start"),
401
+ source_occurred_end=memory.get("occurred_end"),
402
+ source_mentioned_at=memory.get("mentioned_at"),
403
+ perf=perf,
404
+ )
405
+ results.append(result)
406
+ elif action_type == "create":
407
+ result = await _execute_create_action(
408
+ conn=conn,
409
+ memory_engine=memory_engine,
410
+ bank_id=bank_id,
411
+ memory_id=memory_id,
412
+ action=action,
413
+ source_fact_tags=fact_tags, # Pass source fact's tags for security
414
+ event_date=memory.get("event_date"),
415
+ occurred_start=memory.get("occurred_start"),
416
+ occurred_end=memory.get("occurred_end"),
417
+ mentioned_at=memory.get("mentioned_at"),
418
+ perf=perf,
419
+ )
420
+ results.append(result)
421
+
422
+ if not results:
423
+ # No valid actions executed
424
+ return {"action": "skipped", "reason": "no_valid_actions"}
425
+
426
+ # Summarize results
427
+ created = sum(1 for r in results if r.get("action") == "created")
428
+ updated = sum(1 for r in results if r.get("action") == "updated")
429
+ merged = sum(1 for r in results if r.get("action") == "merged")
430
+
431
+ if len(results) == 1:
432
+ return results[0]
433
+
434
+ return {
435
+ "action": "multiple",
436
+ "created": created,
437
+ "updated": updated,
438
+ "merged": merged,
439
+ "total_actions": len(results),
440
+ }
441
+
442
+
443
+ async def _execute_update_action(
444
+ conn: "Connection",
445
+ memory_engine: "MemoryEngine",
446
+ bank_id: str,
447
+ memory_id: uuid.UUID,
448
+ action: dict[str, Any],
449
+ observations: list[dict[str, Any]],
450
+ source_fact_tags: list[str] | None = None,
451
+ source_occurred_start: datetime | None = None,
452
+ source_occurred_end: datetime | None = None,
453
+ source_mentioned_at: datetime | None = None,
454
+ perf: ConsolidationPerfLog | None = None,
455
+ ) -> dict[str, Any]:
456
+ """
457
+ Execute an update action on an existing observation.
458
+
459
+ Updates the observation text, adds to history, increments proof_count,
460
+ and updates temporal fields:
461
+ - occurred_start: uses LEAST to keep the earliest start time
462
+ - occurred_end: uses GREATEST to keep the most recent end time
463
+ - mentioned_at: uses GREATEST to keep the most recent mention time
464
+
465
+ SECURITY: Merges source fact's tags into the observation's existing tags.
466
+ This ensures all contributors can see the observation they contributed to.
467
+ For example, if Lisa's observation (tags=['user_lisa']) is updated with
468
+ Mike's fact (tags=['user_mike']), the observation will have both tags.
469
+ """
470
+ learning_id = action.get("learning_id")
471
+ new_text = action.get("text")
472
+ reason = action.get("reason", "Updated with new fact")
473
+
474
+ if not learning_id or not new_text:
475
+ return {"action": "skipped", "reason": "missing_learning_id_or_text"}
476
+
477
+ # Find the observation
478
+ model = next((m for m in observations if str(m["id"]) == learning_id), None)
479
+ if not model:
480
+ return {"action": "skipped", "reason": "learning_not_found"}
481
+
482
+ # Build history entry
483
+ history = list(model.get("history", []))
484
+ history.append(
485
+ {
486
+ "previous_text": model["text"],
487
+ "changed_at": datetime.now(timezone.utc).isoformat(),
488
+ "reason": reason,
489
+ "source_memory_id": str(memory_id),
490
+ }
491
+ )
492
+
493
+ # Update source_memory_ids
494
+ source_ids = list(model.get("source_memory_ids", []))
495
+ source_ids.append(memory_id)
496
+
497
+ # SECURITY: Merge source fact's tags into existing observation tags
498
+ # This ensures all contributors can see the observation they contributed to
499
+ existing_tags = set(model.get("tags", []) or [])
500
+ source_tags = set(source_fact_tags or [])
501
+ merged_tags = list(existing_tags | source_tags) # Union of both tag sets
502
+ if source_tags and source_tags != existing_tags:
503
+ logger.debug(
504
+ f"Security: Merging tags for observation {learning_id}: "
505
+ f"existing={list(existing_tags)}, source={list(source_tags)}, merged={merged_tags}"
506
+ )
507
+
508
+ # Generate new embedding for updated text
509
+ t0 = time.time()
510
+ embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text])
511
+ embedding_str = str(embeddings[0]) if embeddings else None
512
+ if perf:
513
+ perf.record_timing("embedding", time.time() - t0)
514
+
515
+ # Update the observation
516
+ # - occurred_start: LEAST keeps the earliest start time across all source facts
517
+ # - occurred_end: GREATEST keeps the most recent end time across all source facts
518
+ # - mentioned_at: GREATEST keeps the most recent mention time
519
+ # - tags: merged from existing + source fact (for visibility)
520
+ t0 = time.time()
521
+ await conn.execute(
522
+ f"""
523
+ UPDATE {fq_table("memory_units")}
524
+ SET text = $1,
525
+ embedding = $2::vector,
526
+ history = $3,
527
+ source_memory_ids = $4,
528
+ proof_count = $5,
529
+ tags = $10,
530
+ updated_at = now(),
531
+ occurred_start = LEAST(occurred_start, COALESCE($7, occurred_start)),
532
+ occurred_end = GREATEST(occurred_end, COALESCE($8, occurred_end)),
533
+ mentioned_at = GREATEST(mentioned_at, COALESCE($9, mentioned_at))
534
+ WHERE id = $6
535
+ """,
536
+ new_text,
537
+ embedding_str,
538
+ json.dumps(history),
539
+ source_ids,
540
+ len(source_ids),
541
+ uuid.UUID(learning_id),
542
+ source_occurred_start,
543
+ source_occurred_end,
544
+ source_mentioned_at,
545
+ merged_tags,
546
+ )
547
+
548
+ # Create links from memory to observation
549
+ await _create_memory_links(conn, memory_id, uuid.UUID(learning_id))
550
+ if perf:
551
+ perf.record_timing("db_write", time.time() - t0)
552
+
553
+ logger.debug(f"Updated observation {learning_id} with memory {memory_id}")
554
+
555
+ return {"action": "updated", "observation_id": learning_id}
556
+
557
+
558
+ async def _execute_create_action(
559
+ conn: "Connection",
560
+ memory_engine: "MemoryEngine",
561
+ bank_id: str,
562
+ memory_id: uuid.UUID,
563
+ action: dict[str, Any],
564
+ source_fact_tags: list[str] | None = None,
565
+ event_date: datetime | None = None,
566
+ occurred_start: datetime | None = None,
567
+ occurred_end: datetime | None = None,
568
+ mentioned_at: datetime | None = None,
569
+ perf: ConsolidationPerfLog | None = None,
570
+ ) -> dict[str, Any]:
571
+ """
572
+ Execute a create action for a new observation.
573
+
574
+ Creates a new observation with the specified text.
575
+ The text comes directly from the classify LLM - no second LLM call needed.
576
+
577
+ Tags are determined algorithmically (not by LLM):
578
+ - Observations always inherit their source fact's tags
579
+ - This ensures visibility scope is maintained (security)
580
+ """
581
+ text = action.get("text")
582
+
583
+ # Tags are determined algorithmically - always use source fact's tags
584
+ # This ensures private memories create private observations
585
+ tags = source_fact_tags or []
586
+
587
+ if not text:
588
+ return {"action": "skipped", "reason": "missing_text"}
589
+
590
+ # Use text directly from classify - skip the redundant LLM call
591
+ result = await _create_observation_directly(
592
+ conn=conn,
593
+ memory_engine=memory_engine,
594
+ bank_id=bank_id,
595
+ source_memory_id=memory_id,
596
+ observation_text=text, # Text already processed by classify LLM
597
+ tags=tags,
598
+ event_date=event_date,
599
+ occurred_start=occurred_start,
600
+ occurred_end=occurred_end,
601
+ mentioned_at=mentioned_at,
602
+ perf=perf,
603
+ )
604
+
605
+ logger.debug(f"Created observation {result.get('observation_id')} from memory {memory_id} (tags: {tags})")
606
+
607
+ return result
608
+
609
+
610
+ async def _create_memory_links(
611
+ conn: "Connection",
612
+ memory_id: uuid.UUID,
613
+ observation_id: uuid.UUID,
614
+ ) -> None:
615
+ """
616
+ Placeholder for observation link creation.
617
+
618
+ Observations do NOT get any memory_links copied from their source facts.
619
+ Instead, retrieval uses source_memory_ids to traverse:
620
+ - Entity connections: observation → source_memory_ids → unit_entities
621
+ - Semantic similarity: observations have their own embeddings
622
+ - Temporal proximity: observations have their own temporal fields
623
+
624
+ This avoids data duplication and ensures observations are always
625
+ connected via their source facts' relationships.
626
+
627
+ The memory_id and observation_id parameters are kept for interface
628
+ compatibility but no links are created.
629
+ """
630
+ # No links are created - observations rely on source_memory_ids for traversal
631
+ pass
632
+
633
+
634
+ async def _find_related_observations(
635
+ conn: "Connection",
636
+ memory_engine: "MemoryEngine",
637
+ bank_id: str,
638
+ query: str,
639
+ request_context: "RequestContext",
640
+ ) -> list[dict[str, Any]]:
641
+ """
642
+ Find observations related to the given query using optimized recall.
643
+
644
+ IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
645
+ potentially related observations regardless of scope, so the LLM can
646
+ decide on tag routing (same scope update vs cross-scope create).
647
+
648
+ Uses max_tokens to naturally limit observations (no artificial count limit).
649
+ Includes source memories with dates for LLM context.
650
+
651
+ Returns:
652
+ List of related observations with their tags, source memories, and dates
653
+ """
654
+ # Use recall to find related observations with token budget
655
+ # max_tokens naturally limits how many observations are returned
656
+ from ...config import get_config
657
+
658
+ config = get_config()
659
+ recall_result = await memory_engine.recall_async(
660
+ bank_id=bank_id,
661
+ query=query,
662
+ max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
663
+ fact_type=["observation"], # Only retrieve observations
664
+ request_context=request_context,
665
+ _quiet=True, # Suppress logging
666
+ # NO tags parameter - intentionally get ALL observations
667
+ )
668
+
669
+ # If no observations returned, return empty list
670
+ if not recall_result.results:
671
+ return []
672
+
673
+ # Batch fetch all observations in a single query (no artificial limit)
674
+ observation_ids = [uuid.UUID(obs.id) for obs in recall_result.results]
675
+
676
+ rows = await conn.fetch(
677
+ f"""
678
+ SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at,
679
+ occurred_start, occurred_end, mentioned_at
680
+ FROM {fq_table("memory_units")}
681
+ WHERE id = ANY($1) AND bank_id = $2 AND fact_type = 'observation'
682
+ """,
683
+ observation_ids,
684
+ bank_id,
685
+ )
686
+
687
+ # Build results list preserving recall order
688
+ id_to_row = {row["id"]: row for row in rows}
689
+ results = []
690
+
691
+ for obs in recall_result.results:
692
+ obs_id = uuid.UUID(obs.id)
693
+ if obs_id not in id_to_row:
694
+ continue
695
+
696
+ row = id_to_row[obs_id]
697
+ history = row["history"]
698
+ if isinstance(history, str):
699
+ history = json.loads(history)
700
+ elif history is None:
701
+ history = []
702
+
703
+ # Fetch source memories to include their text and dates
704
+ source_memory_ids = row["source_memory_ids"] or []
705
+ source_memories = []
706
+
707
+ if source_memory_ids:
708
+ source_rows = await conn.fetch(
709
+ f"""
710
+ SELECT text, occurred_start, occurred_end, mentioned_at, event_date
711
+ FROM {fq_table("memory_units")}
712
+ WHERE id = ANY($1) AND bank_id = $2
713
+ ORDER BY created_at ASC
714
+ LIMIT 5
715
+ """,
716
+ source_memory_ids[:5], # Limit to first 5 source memories for token efficiency
717
+ bank_id,
718
+ )
719
+
720
+ for src_row in source_rows:
721
+ source_memories.append(
722
+ {
723
+ "text": src_row["text"],
724
+ "occurred_start": src_row["occurred_start"],
725
+ "occurred_end": src_row["occurred_end"],
726
+ "mentioned_at": src_row["mentioned_at"],
727
+ "event_date": src_row["event_date"],
728
+ }
729
+ )
730
+
731
+ results.append(
732
+ {
733
+ "id": row["id"],
734
+ "text": row["text"],
735
+ "proof_count": row["proof_count"] or 1,
736
+ "tags": row["tags"] or [],
737
+ "source_memories": source_memories,
738
+ "occurred_start": row["occurred_start"],
739
+ "occurred_end": row["occurred_end"],
740
+ "mentioned_at": row["mentioned_at"],
741
+ "created_at": row["created_at"],
742
+ "updated_at": row["updated_at"],
743
+ }
744
+ )
745
+
746
+ return results
747
+
748
+
749
+ async def _consolidate_with_llm(
750
+ memory_engine: "MemoryEngine",
751
+ fact_text: str,
752
+ observations: list[dict[str, Any]],
753
+ mission: str,
754
+ ) -> list[dict[str, Any]]:
755
+ """
756
+ Single LLM call to extract durable knowledge and decide on consolidation actions.
757
+
758
+ This handles ALL cases:
759
+ - No related observations: extracts durable knowledge, returns create action
760
+ - Related observations exist: compares and returns update/create actions
761
+ - Purely ephemeral fact: returns empty array
762
+
763
+ Note: Tags are NOT handled by the LLM. They are determined algorithmically:
764
+ - CREATE: observation inherits source fact's tags
765
+ - UPDATE: observation merges source fact's tags with existing tags
766
+
767
+ Returns:
768
+ List of actions, each being:
769
+ - {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
770
+ - {"action": "create", "text": "...", "reason": "..."}
771
+ - [] if fact is purely ephemeral (no durable knowledge)
772
+ """
773
+ # Format observations as JSON with source memories and dates
774
+ if observations:
775
+ obs_list = []
776
+ for obs in observations:
777
+ obs_data = {
778
+ "id": str(obs["id"]),
779
+ "text": obs["text"],
780
+ "proof_count": obs["proof_count"],
781
+ "tags": obs["tags"],
782
+ "created_at": obs["created_at"].isoformat() if obs.get("created_at") else None,
783
+ "updated_at": obs["updated_at"].isoformat() if obs.get("updated_at") else None,
784
+ }
785
+
786
+ # Include temporal info if available
787
+ if obs.get("occurred_start"):
788
+ obs_data["occurred_start"] = obs["occurred_start"].isoformat()
789
+ if obs.get("occurred_end"):
790
+ obs_data["occurred_end"] = obs["occurred_end"].isoformat()
791
+ if obs.get("mentioned_at"):
792
+ obs_data["mentioned_at"] = obs["mentioned_at"].isoformat()
793
+
794
+ # Include source memories (up to 3 for brevity)
795
+ if obs.get("source_memories"):
796
+ obs_data["source_memories"] = [
797
+ {
798
+ "text": sm["text"],
799
+ "event_date": sm["event_date"].isoformat() if sm.get("event_date") else None,
800
+ "occurred_start": sm["occurred_start"].isoformat() if sm.get("occurred_start") else None,
801
+ }
802
+ for sm in obs["source_memories"][:3] # Limit to 3 for token efficiency
803
+ ]
804
+
805
+ obs_list.append(obs_data)
806
+
807
+ observations_text = json.dumps(obs_list, indent=2)
808
+ else:
809
+ observations_text = "[]"
810
+
811
+ # Only include mission section if mission is set and not the default
812
+ mission_section = ""
813
+ if mission and mission != "General memory consolidation":
814
+ mission_section = f"""
815
+ MISSION CONTEXT: {mission}
816
+
817
+ Focus on DURABLE knowledge that serves this mission, not ephemeral state.
818
+ """
819
+
820
+ user_prompt = CONSOLIDATION_USER_PROMPT.format(
821
+ mission_section=mission_section,
822
+ fact_text=fact_text,
823
+ observations_text=observations_text,
824
+ )
825
+
826
+ messages = [
827
+ {"role": "system", "content": CONSOLIDATION_SYSTEM_PROMPT},
828
+ {"role": "user", "content": user_prompt},
829
+ ]
830
+
831
+ try:
832
+ result = await memory_engine._consolidation_llm_config.call(
833
+ messages=messages,
834
+ skip_validation=True, # Raw JSON response
835
+ scope="consolidation",
836
+ )
837
+ # Parse JSON response - should be an array
838
+ if isinstance(result, str):
839
+ result = json.loads(result)
840
+ # Ensure result is a list
841
+ if isinstance(result, list):
842
+ return result
843
+ # Handle legacy single-action format for backward compatibility
844
+ if isinstance(result, dict):
845
+ if result.get("related_ids") and result.get("consolidated_text"):
846
+ # Convert old format to new format
847
+ return [
848
+ {
849
+ "action": "update",
850
+ "learning_id": result["related_ids"][0],
851
+ "text": result["consolidated_text"],
852
+ "reason": result.get("reason", ""),
853
+ }
854
+ ]
855
+ return []
856
+ return []
857
+ except Exception as e:
858
+ logger.warning(f"Error in consolidation LLM call: {e}")
859
+ return []
860
+
861
+
862
+ async def _create_observation_directly(
863
+ conn: "Connection",
864
+ memory_engine: "MemoryEngine",
865
+ bank_id: str,
866
+ source_memory_id: uuid.UUID,
867
+ observation_text: str,
868
+ tags: list[str] | None = None,
869
+ event_date: datetime | None = None,
870
+ occurred_start: datetime | None = None,
871
+ occurred_end: datetime | None = None,
872
+ mentioned_at: datetime | None = None,
873
+ perf: ConsolidationPerfLog | None = None,
874
+ ) -> dict[str, Any]:
875
+ """
876
+ Create an observation directly with pre-processed text (no LLM call).
877
+
878
+ Used when the classify LLM has already provided the learning text.
879
+ This avoids the redundant second LLM call.
880
+ """
881
+ # Generate embedding for the observation (convert to string for pgvector)
882
+ t0 = time.time()
883
+ embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [observation_text])
884
+ embedding_str = str(embeddings[0]) if embeddings else None
885
+ if perf:
886
+ perf.record_timing("embedding", time.time() - t0)
887
+
888
+ # Create the observation as a memory_unit
889
+ now = datetime.now(timezone.utc)
890
+ obs_event_date = event_date or now
891
+ obs_occurred_start = occurred_start or now
892
+ obs_occurred_end = occurred_end or now
893
+ obs_mentioned_at = mentioned_at or now
894
+ obs_tags = tags or []
895
+
896
+ t0 = time.time()
897
+ observation_id = uuid.uuid4()
898
+ row = await conn.fetchrow(
899
+ f"""
900
+ INSERT INTO {fq_table("memory_units")} (
901
+ id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
902
+ tags, event_date, occurred_start, occurred_end, mentioned_at
903
+ )
904
+ VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
905
+ RETURNING id
906
+ """,
907
+ observation_id,
908
+ bank_id,
909
+ observation_text,
910
+ embedding_str,
911
+ [source_memory_id],
912
+ obs_tags,
913
+ obs_event_date,
914
+ obs_occurred_start,
915
+ obs_occurred_end,
916
+ obs_mentioned_at,
917
+ )
918
+
919
+ # Create links between memory and observation (includes entity links, memory_links)
920
+ await _create_memory_links(conn, source_memory_id, observation_id)
921
+ if perf:
922
+ perf.record_timing("db_write", time.time() - t0)
923
+
924
+ logger.debug(f"Created observation {observation_id} from memory {source_memory_id} (tags: {obs_tags})")
925
+
926
+ return {"action": "created", "observation_id": str(row["id"]), "tags": obs_tags}