hindsight-api 0.0.13__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 (48) hide show
  1. hindsight_api/__init__.py +38 -0
  2. hindsight_api/api/__init__.py +105 -0
  3. hindsight_api/api/http.py +1872 -0
  4. hindsight_api/api/mcp.py +157 -0
  5. hindsight_api/engine/__init__.py +47 -0
  6. hindsight_api/engine/cross_encoder.py +97 -0
  7. hindsight_api/engine/db_utils.py +93 -0
  8. hindsight_api/engine/embeddings.py +113 -0
  9. hindsight_api/engine/entity_resolver.py +575 -0
  10. hindsight_api/engine/llm_wrapper.py +269 -0
  11. hindsight_api/engine/memory_engine.py +3095 -0
  12. hindsight_api/engine/query_analyzer.py +519 -0
  13. hindsight_api/engine/response_models.py +222 -0
  14. hindsight_api/engine/retain/__init__.py +50 -0
  15. hindsight_api/engine/retain/bank_utils.py +423 -0
  16. hindsight_api/engine/retain/chunk_storage.py +82 -0
  17. hindsight_api/engine/retain/deduplication.py +104 -0
  18. hindsight_api/engine/retain/embedding_processing.py +62 -0
  19. hindsight_api/engine/retain/embedding_utils.py +54 -0
  20. hindsight_api/engine/retain/entity_processing.py +90 -0
  21. hindsight_api/engine/retain/fact_extraction.py +1027 -0
  22. hindsight_api/engine/retain/fact_storage.py +176 -0
  23. hindsight_api/engine/retain/link_creation.py +121 -0
  24. hindsight_api/engine/retain/link_utils.py +651 -0
  25. hindsight_api/engine/retain/orchestrator.py +405 -0
  26. hindsight_api/engine/retain/types.py +206 -0
  27. hindsight_api/engine/search/__init__.py +15 -0
  28. hindsight_api/engine/search/fusion.py +122 -0
  29. hindsight_api/engine/search/observation_utils.py +132 -0
  30. hindsight_api/engine/search/reranking.py +103 -0
  31. hindsight_api/engine/search/retrieval.py +503 -0
  32. hindsight_api/engine/search/scoring.py +161 -0
  33. hindsight_api/engine/search/temporal_extraction.py +64 -0
  34. hindsight_api/engine/search/think_utils.py +255 -0
  35. hindsight_api/engine/search/trace.py +215 -0
  36. hindsight_api/engine/search/tracer.py +447 -0
  37. hindsight_api/engine/search/types.py +160 -0
  38. hindsight_api/engine/task_backend.py +223 -0
  39. hindsight_api/engine/utils.py +203 -0
  40. hindsight_api/metrics.py +227 -0
  41. hindsight_api/migrations.py +163 -0
  42. hindsight_api/models.py +309 -0
  43. hindsight_api/pg0.py +425 -0
  44. hindsight_api/web/__init__.py +12 -0
  45. hindsight_api/web/server.py +143 -0
  46. hindsight_api-0.0.13.dist-info/METADATA +41 -0
  47. hindsight_api-0.0.13.dist-info/RECORD +48 -0
  48. hindsight_api-0.0.13.dist-info/WHEEL +4 -0
@@ -0,0 +1,1872 @@
1
+ """
2
+ FastAPI application factory and API routes for memory system.
3
+
4
+ This module provides the create_app function to create and configure
5
+ the FastAPI application with all API endpoints.
6
+ """
7
+ import json
8
+ import logging
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Optional, List, Dict, Any, Union
12
+ from datetime import datetime
13
+ from contextlib import asynccontextmanager
14
+
15
+ from fastapi import FastAPI, HTTPException, Query
16
+
17
+
18
+ def _parse_metadata(metadata: Any) -> Dict[str, Any]:
19
+ """Parse metadata that may be a dict, JSON string, or None."""
20
+ if metadata is None:
21
+ return {}
22
+ if isinstance(metadata, dict):
23
+ return metadata
24
+ if isinstance(metadata, str):
25
+ try:
26
+ return json.loads(metadata)
27
+ except json.JSONDecodeError:
28
+ return {}
29
+ return {}
30
+
31
+
32
+ from fastapi.staticfiles import StaticFiles
33
+ from fastapi.responses import FileResponse
34
+ from pydantic import BaseModel, Field, ConfigDict
35
+
36
+ from hindsight_api import MemoryEngine
37
+ from hindsight_api.engine.memory_engine import Budget
38
+ from hindsight_api.engine.db_utils import acquire_with_retry
39
+ from hindsight_api.metrics import get_metrics_collector, initialize_metrics, create_metrics_collector
40
+
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class MetadataFilter(BaseModel):
46
+ """Filter for metadata fields. Matches records where (key=value) OR (key not set) when match_unset=True."""
47
+ model_config = ConfigDict(json_schema_extra={
48
+ "example": {
49
+ "key": "source",
50
+ "value": "slack",
51
+ "match_unset": True
52
+ }
53
+ })
54
+
55
+ key: str = Field(description="Metadata key to filter on")
56
+ value: Optional[str] = Field(default=None, description="Value to match. If None with match_unset=True, matches any record where key is not set.")
57
+ match_unset: bool = Field(default=True, description="If True, also match records where this metadata key is not set")
58
+
59
+
60
+ class EntityIncludeOptions(BaseModel):
61
+ """Options for including entity observations in recall results."""
62
+ max_tokens: int = Field(default=500, description="Maximum tokens for entity observations")
63
+
64
+
65
+ class ChunkIncludeOptions(BaseModel):
66
+ """Options for including chunks in recall results."""
67
+ max_tokens: int = Field(default=8192, description="Maximum tokens for chunks (chunks may be truncated)")
68
+
69
+
70
+ class IncludeOptions(BaseModel):
71
+ """Options for including additional data in recall results."""
72
+ entities: Optional[EntityIncludeOptions] = Field(
73
+ default=EntityIncludeOptions(),
74
+ description="Include entity observations. Set to null to disable entity inclusion."
75
+ )
76
+ chunks: Optional[ChunkIncludeOptions] = Field(
77
+ default=None,
78
+ description="Include raw chunks. Set to {} to enable, null to disable (default: disabled)."
79
+ )
80
+
81
+
82
+ class RecallRequest(BaseModel):
83
+ """Request model for recall endpoint."""
84
+ model_config = ConfigDict(json_schema_extra={
85
+ "example": {
86
+ "query": "What did Alice say about machine learning?",
87
+ "types": ["world", "bank"],
88
+ "budget": "mid",
89
+ "max_tokens": 4096,
90
+ "trace": True,
91
+ "query_timestamp": "2023-05-30T23:40:00",
92
+ "filters": [{"key": "source", "value": "slack", "match_unset": True}],
93
+ "include": {
94
+ "entities": {
95
+ "max_tokens": 500
96
+ }
97
+ }
98
+ }
99
+ })
100
+
101
+ query: str
102
+ types: Optional[List[str]] = Field(default=None, description="List of fact types to recall (defaults to all if not specified)")
103
+ budget: Budget = Budget.MID
104
+ max_tokens: int = 4096
105
+ trace: bool = False
106
+ query_timestamp: Optional[str] = Field(default=None, description="ISO format date string (e.g., '2023-05-30T23:40:00')")
107
+ filters: Optional[List[MetadataFilter]] = Field(default=None, description="Filter by metadata. Multiple filters are ANDed together.")
108
+ include: IncludeOptions = Field(default_factory=IncludeOptions, description="Options for including additional data (entities are included by default)")
109
+
110
+
111
+ class RecallResult(BaseModel):
112
+ """Single recall result item."""
113
+ model_config = {
114
+ "populate_by_name": True,
115
+ "json_schema_extra": {
116
+ "example": {
117
+ "id": "123e4567-e89b-12d3-a456-426614174000",
118
+ "text": "Alice works at Google on the AI team",
119
+ "type": "world",
120
+ "entities": ["Alice", "Google"],
121
+ "context": "work info",
122
+ "occurred_start": "2024-01-15T10:30:00Z",
123
+ "occurred_end": "2024-01-15T10:30:00Z",
124
+ "mentioned_at": "2024-01-15T10:30:00Z",
125
+ "document_id": "session_abc123",
126
+ "metadata": {"source": "slack"},
127
+ "chunk_id": "456e7890-e12b-34d5-a678-901234567890"
128
+ }
129
+ }
130
+ }
131
+
132
+ id: str
133
+ text: str
134
+ type: Optional[str] = None # fact type: world, agent, opinion, observation
135
+ entities: Optional[List[str]] = None # Entity names mentioned in this fact
136
+ context: Optional[str] = None
137
+ occurred_start: Optional[str] = None # ISO format date when the event started
138
+ occurred_end: Optional[str] = None # ISO format date when the event ended
139
+ mentioned_at: Optional[str] = None # ISO format date when the fact was mentioned
140
+ document_id: Optional[str] = None # Document this memory belongs to
141
+ metadata: Optional[Dict[str, str]] = None # User-defined metadata
142
+ chunk_id: Optional[str] = None # Chunk this fact was extracted from
143
+
144
+
145
+ class EntityObservationResponse(BaseModel):
146
+ """An observation about an entity."""
147
+ text: str
148
+ mentioned_at: Optional[str] = None
149
+
150
+
151
+ class EntityStateResponse(BaseModel):
152
+ """Current mental model of an entity."""
153
+ entity_id: str
154
+ canonical_name: str
155
+ observations: List[EntityObservationResponse]
156
+
157
+
158
+ class EntityListItem(BaseModel):
159
+ """Entity list item with summary."""
160
+ model_config = ConfigDict(json_schema_extra={
161
+ "example": {
162
+ "id": "123e4567-e89b-12d3-a456-426614174000",
163
+ "canonical_name": "John",
164
+ "mention_count": 15,
165
+ "first_seen": "2024-01-15T10:30:00Z",
166
+ "last_seen": "2024-02-01T14:00:00Z"
167
+ }
168
+ })
169
+
170
+ id: str
171
+ canonical_name: str
172
+ mention_count: int
173
+ first_seen: Optional[str] = None
174
+ last_seen: Optional[str] = None
175
+ metadata: Optional[Dict[str, Any]] = None
176
+
177
+
178
+ class EntityListResponse(BaseModel):
179
+ """Response model for entity list endpoint."""
180
+ model_config = ConfigDict(json_schema_extra={
181
+ "example": {
182
+ "items": [
183
+ {
184
+ "id": "123e4567-e89b-12d3-a456-426614174000",
185
+ "canonical_name": "John",
186
+ "mention_count": 15,
187
+ "first_seen": "2024-01-15T10:30:00Z",
188
+ "last_seen": "2024-02-01T14:00:00Z"
189
+ }
190
+ ]
191
+ }
192
+ })
193
+
194
+ items: List[EntityListItem]
195
+
196
+
197
+ class EntityDetailResponse(BaseModel):
198
+ """Response model for entity detail endpoint."""
199
+ model_config = ConfigDict(json_schema_extra={
200
+ "example": {
201
+ "id": "123e4567-e89b-12d3-a456-426614174000",
202
+ "canonical_name": "John",
203
+ "mention_count": 15,
204
+ "first_seen": "2024-01-15T10:30:00Z",
205
+ "last_seen": "2024-02-01T14:00:00Z",
206
+ "observations": [
207
+ {"text": "John works at Google", "mentioned_at": "2024-01-15T10:30:00Z"}
208
+ ]
209
+ }
210
+ })
211
+
212
+ id: str
213
+ canonical_name: str
214
+ mention_count: int
215
+ first_seen: Optional[str] = None
216
+ last_seen: Optional[str] = None
217
+ metadata: Optional[Dict[str, Any]] = None
218
+ observations: List[EntityObservationResponse]
219
+
220
+
221
+ class ChunkData(BaseModel):
222
+ """Chunk data for a single chunk."""
223
+ id: str
224
+ text: str
225
+ chunk_index: int
226
+ truncated: bool = Field(default=False, description="Whether the chunk text was truncated due to token limits")
227
+
228
+
229
+ class RecallResponse(BaseModel):
230
+ """Response model for recall endpoints."""
231
+ model_config = ConfigDict(json_schema_extra={
232
+ "example": {
233
+ "results": [
234
+ {
235
+ "id": "123e4567-e89b-12d3-a456-426614174000",
236
+ "text": "Alice works at Google on the AI team",
237
+ "type": "world",
238
+ "entities": ["Alice", "Google"],
239
+ "context": "work info",
240
+ "occurred_start": "2024-01-15T10:30:00Z",
241
+ "occurred_end": "2024-01-15T10:30:00Z",
242
+ "chunk_id": "456e7890-e12b-34d5-a678-901234567890"
243
+ }
244
+ ],
245
+ "trace": {
246
+ "query": "What did Alice say about machine learning?",
247
+ "num_results": 1,
248
+ "time_seconds": 0.123
249
+ },
250
+ "entities": {
251
+ "Alice": {
252
+ "entity_id": "123e4567-e89b-12d3-a456-426614174001",
253
+ "canonical_name": "Alice",
254
+ "observations": [
255
+ {"text": "Alice works at Google on the AI team", "mentioned_at": "2024-01-15T10:30:00Z"}
256
+ ]
257
+ }
258
+ },
259
+ "chunks": {
260
+ "456e7890-e12b-34d5-a678-901234567890": {
261
+ "id": "456e7890-e12b-34d5-a678-901234567890",
262
+ "text": "Alice works at Google on the AI team. She's been there for 3 years...",
263
+ "chunk_index": 0
264
+ }
265
+ }
266
+ }
267
+ })
268
+
269
+ results: List[RecallResult]
270
+ trace: Optional[Dict[str, Any]] = None
271
+ entities: Optional[Dict[str, EntityStateResponse]] = Field(default=None, description="Entity states for entities mentioned in results")
272
+ chunks: Optional[Dict[str, ChunkData]] = Field(default=None, description="Chunks for facts, keyed by chunk_id")
273
+
274
+
275
+ class MemoryItem(BaseModel):
276
+ """Single memory item for retain."""
277
+ model_config = ConfigDict(json_schema_extra={
278
+ "example": {
279
+ "content": "Alice mentioned she's working on a new ML model",
280
+ "timestamp": "2024-01-15T10:30:00Z",
281
+ "context": "team meeting",
282
+ "metadata": {"source": "slack", "channel": "engineering"},
283
+ "document_id": "meeting_notes_2024_01_15"
284
+ }
285
+ })
286
+
287
+ content: str
288
+ timestamp: Optional[datetime] = None
289
+ context: Optional[str] = None
290
+ metadata: Optional[Dict[str, str]] = None
291
+ document_id: Optional[str] = Field(
292
+ default=None,
293
+ description="Optional document ID for this memory item."
294
+ )
295
+
296
+
297
+ class RetainRequest(BaseModel):
298
+ """Request model for retain endpoint."""
299
+ model_config = ConfigDict(json_schema_extra={
300
+ "example": {
301
+ "items": [
302
+ {
303
+ "content": "Alice works at Google",
304
+ "context": "work",
305
+ "document_id": "conversation_123"
306
+ },
307
+ {
308
+ "content": "Bob went hiking yesterday",
309
+ "timestamp": "2024-01-15T10:00:00Z",
310
+ "document_id": "conversation_123"
311
+ }
312
+ ],
313
+ "async": False
314
+ }
315
+ })
316
+
317
+ items: List[MemoryItem]
318
+ async_: bool = Field(
319
+ default=False,
320
+ alias="async",
321
+ description="If true, process asynchronously in background. If false, wait for completion (default: false)"
322
+ )
323
+
324
+
325
+ class RetainResponse(BaseModel):
326
+ """Response model for retain endpoint."""
327
+ model_config = ConfigDict(
328
+ populate_by_name=True,
329
+ json_schema_extra={
330
+ "example": {
331
+ "success": True,
332
+ "bank_id": "user123",
333
+ "items_count": 2,
334
+ "async": False
335
+ }
336
+ }
337
+ )
338
+
339
+ success: bool
340
+ bank_id: str
341
+ items_count: int
342
+ async_: bool = Field(alias="async", serialization_alias="async", description="Whether the operation was processed asynchronously")
343
+
344
+
345
+ class FactsIncludeOptions(BaseModel):
346
+ """Options for including facts (based_on) in reflect results."""
347
+ pass # No additional options needed, just enable/disable
348
+
349
+
350
+ class ReflectIncludeOptions(BaseModel):
351
+ """Options for including additional data in reflect results."""
352
+ facts: Optional[FactsIncludeOptions] = Field(
353
+ default=None,
354
+ description="Include facts that the answer is based on. Set to {} to enable, null to disable (default: disabled)."
355
+ )
356
+
357
+
358
+ class ReflectRequest(BaseModel):
359
+ """Request model for reflect endpoint."""
360
+ model_config = ConfigDict(json_schema_extra={
361
+ "example": {
362
+ "query": "What do you think about artificial intelligence?",
363
+ "budget": "low",
364
+ "context": "This is for a research paper on AI ethics",
365
+ "filters": [{"key": "source", "value": "slack", "match_unset": True}],
366
+ "include": {
367
+ "facts": {}
368
+ }
369
+ }
370
+ })
371
+
372
+ query: str
373
+ budget: Budget = Budget.LOW
374
+ context: Optional[str] = None
375
+ filters: Optional[List[MetadataFilter]] = Field(default=None, description="Filter by metadata. Multiple filters are ANDed together.")
376
+ include: ReflectIncludeOptions = Field(default_factory=ReflectIncludeOptions, description="Options for including additional data (disabled by default)")
377
+
378
+
379
+ class OpinionItem(BaseModel):
380
+ """Model for an opinion with confidence score."""
381
+ text: str
382
+ confidence: float
383
+
384
+
385
+ class ReflectFact(BaseModel):
386
+ """A fact used in think response."""
387
+ model_config = ConfigDict(json_schema_extra={
388
+ "example": {
389
+ "id": "123e4567-e89b-12d3-a456-426614174000",
390
+ "text": "AI is used in healthcare",
391
+ "type": "world",
392
+ "context": "healthcare discussion",
393
+ "occurred_start": "2024-01-15T10:30:00Z",
394
+ "occurred_end": "2024-01-15T10:30:00Z"
395
+ }
396
+ })
397
+
398
+ id: Optional[str] = None
399
+ text: str
400
+ type: Optional[str] = None # fact type: world, agent, opinion
401
+ context: Optional[str] = None
402
+ occurred_start: Optional[str] = None
403
+ occurred_end: Optional[str] = None
404
+
405
+
406
+ class ReflectResponse(BaseModel):
407
+ """Response model for think endpoint."""
408
+ model_config = ConfigDict(json_schema_extra={
409
+ "example": {
410
+ "text": "Based on my understanding, AI is a transformative technology...",
411
+ "based_on": [
412
+ {
413
+ "id": "123",
414
+ "text": "AI is used in healthcare",
415
+ "type": "world"
416
+ },
417
+ {
418
+ "id": "456",
419
+ "text": "I discussed AI applications last week",
420
+ "type": "bank"
421
+ }
422
+ ]
423
+ }
424
+ })
425
+
426
+ text: str
427
+ based_on: List[ReflectFact] = [] # Facts used to generate the response
428
+
429
+
430
+ class BanksResponse(BaseModel):
431
+ """Response model for banks list endpoint."""
432
+ model_config = ConfigDict(json_schema_extra={
433
+ "example": {
434
+ "banks": ["user123", "bank_alice", "bank_bob"]
435
+ }
436
+ })
437
+
438
+ banks: List[str]
439
+
440
+
441
+ class PersonalityTraits(BaseModel):
442
+ """Personality traits based on Big Five model."""
443
+ model_config = ConfigDict(json_schema_extra={
444
+ "example": {
445
+ "openness": 0.8,
446
+ "conscientiousness": 0.6,
447
+ "extraversion": 0.5,
448
+ "agreeableness": 0.7,
449
+ "neuroticism": 0.3,
450
+ "bias_strength": 0.7
451
+ }
452
+ })
453
+
454
+ openness: float = Field(ge=0.0, le=1.0, description="Openness to experience (0-1)")
455
+ conscientiousness: float = Field(ge=0.0, le=1.0, description="Conscientiousness (0-1)")
456
+ extraversion: float = Field(ge=0.0, le=1.0, description="Extraversion (0-1)")
457
+ agreeableness: float = Field(ge=0.0, le=1.0, description="Agreeableness (0-1)")
458
+ neuroticism: float = Field(ge=0.0, le=1.0, description="Neuroticism (0-1)")
459
+ bias_strength: float = Field(ge=0.0, le=1.0, description="How strongly personality influences opinions (0-1)")
460
+
461
+
462
+ class BankProfileResponse(BaseModel):
463
+ """Response model for bank profile."""
464
+ model_config = ConfigDict(json_schema_extra={
465
+ "example": {
466
+ "bank_id": "user123",
467
+ "name": "Alice",
468
+ "personality": {
469
+ "openness": 0.8,
470
+ "conscientiousness": 0.6,
471
+ "extraversion": 0.5,
472
+ "agreeableness": 0.7,
473
+ "neuroticism": 0.3,
474
+ "bias_strength": 0.7
475
+ },
476
+ "background": "I am a software engineer with 10 years of experience in startups"
477
+ }
478
+ })
479
+
480
+ bank_id: str
481
+ name: str
482
+ personality: PersonalityTraits
483
+ background: str
484
+
485
+
486
+ class UpdatePersonalityRequest(BaseModel):
487
+ """Request model for updating personality traits."""
488
+ personality: PersonalityTraits
489
+
490
+
491
+ class AddBackgroundRequest(BaseModel):
492
+ """Request model for adding/merging background information."""
493
+ model_config = ConfigDict(json_schema_extra={
494
+ "example": {
495
+ "content": "I was born in Texas",
496
+ "update_personality": True
497
+ }
498
+ })
499
+
500
+ content: str = Field(description="New background information to add or merge")
501
+ update_personality: bool = Field(
502
+ default=True,
503
+ description="If true, infer Big Five personality traits from the merged background (default: true)"
504
+ )
505
+
506
+
507
+ class BackgroundResponse(BaseModel):
508
+ """Response model for background update."""
509
+ model_config = ConfigDict(json_schema_extra={
510
+ "example": {
511
+ "background": "I was born in Texas. I am a software engineer with 10 years of experience.",
512
+ "personality": {
513
+ "openness": 0.7,
514
+ "conscientiousness": 0.6,
515
+ "extraversion": 0.5,
516
+ "agreeableness": 0.8,
517
+ "neuroticism": 0.4,
518
+ "bias_strength": 0.6
519
+ }
520
+ }
521
+ })
522
+
523
+ background: str
524
+ personality: Optional[PersonalityTraits] = None
525
+
526
+
527
+ class BankListItem(BaseModel):
528
+ """Bank list item with profile summary."""
529
+ bank_id: str
530
+ name: str
531
+ personality: PersonalityTraits
532
+ background: str
533
+ created_at: Optional[str] = None
534
+ updated_at: Optional[str] = None
535
+
536
+
537
+ class BankListResponse(BaseModel):
538
+ """Response model for listing all banks."""
539
+ model_config = ConfigDict(json_schema_extra={
540
+ "example": {
541
+ "banks": [
542
+ {
543
+ "bank_id": "user123",
544
+ "name": "Alice",
545
+ "personality": {
546
+ "openness": 0.5,
547
+ "conscientiousness": 0.5,
548
+ "extraversion": 0.5,
549
+ "agreeableness": 0.5,
550
+ "neuroticism": 0.5,
551
+ "bias_strength": 0.5
552
+ },
553
+ "background": "I am a software engineer",
554
+ "created_at": "2024-01-15T10:30:00Z",
555
+ "updated_at": "2024-01-16T14:20:00Z"
556
+ }
557
+ ]
558
+ }
559
+ })
560
+
561
+ banks: List[BankListItem]
562
+
563
+
564
+ class CreateBankRequest(BaseModel):
565
+ """Request model for creating/updating a bank."""
566
+ model_config = ConfigDict(json_schema_extra={
567
+ "example": {
568
+ "name": "Alice",
569
+ "personality": {
570
+ "openness": 0.8,
571
+ "conscientiousness": 0.6,
572
+ "extraversion": 0.5,
573
+ "agreeableness": 0.7,
574
+ "neuroticism": 0.3,
575
+ "bias_strength": 0.7
576
+ },
577
+ "background": "I am a creative software engineer with 10 years of experience"
578
+ }
579
+ })
580
+
581
+ name: Optional[str] = None
582
+ personality: Optional[PersonalityTraits] = None
583
+ background: Optional[str] = None
584
+
585
+
586
+ class GraphDataResponse(BaseModel):
587
+ """Response model for graph data endpoint."""
588
+ model_config = ConfigDict(json_schema_extra={
589
+ "example": {
590
+ "nodes": [
591
+ {"id": "1", "label": "Alice works at Google", "type": "world"},
592
+ {"id": "2", "label": "Bob went hiking", "type": "world"}
593
+ ],
594
+ "edges": [
595
+ {"from": "1", "to": "2", "type": "semantic", "weight": 0.8}
596
+ ],
597
+ "table_rows": [
598
+ {"id": "abc12345...", "text": "Alice works at Google", "context": "Work info", "date": "2024-01-15 10:30", "entities": "Alice (PERSON), Google (ORGANIZATION)"}
599
+ ],
600
+ "total_units": 2
601
+ }
602
+ })
603
+
604
+ nodes: List[Dict[str, Any]]
605
+ edges: List[Dict[str, Any]]
606
+ table_rows: List[Dict[str, Any]]
607
+ total_units: int
608
+
609
+
610
+ class ListMemoryUnitsResponse(BaseModel):
611
+ """Response model for list memory units endpoint."""
612
+ model_config = ConfigDict(json_schema_extra={
613
+ "example": {
614
+ "items": [
615
+ {
616
+ "id": "550e8400-e29b-41d4-a716-446655440000",
617
+ "text": "Alice works at Google on the AI team",
618
+ "context": "Work conversation",
619
+ "date": "2024-01-15T10:30:00Z",
620
+ "type": "world",
621
+ "entities": "Alice (PERSON), Google (ORGANIZATION)"
622
+ }
623
+ ],
624
+ "total": 150,
625
+ "limit": 100,
626
+ "offset": 0
627
+ }
628
+ })
629
+
630
+ items: List[Dict[str, Any]]
631
+ total: int
632
+ limit: int
633
+ offset: int
634
+
635
+
636
+ class ListDocumentsResponse(BaseModel):
637
+ """Response model for list documents endpoint."""
638
+ model_config = ConfigDict(json_schema_extra={
639
+ "example": {
640
+ "items": [
641
+ {
642
+ "id": "session_1",
643
+ "bank_id": "user123",
644
+ "content_hash": "abc123",
645
+ "created_at": "2024-01-15T10:30:00Z",
646
+ "updated_at": "2024-01-15T10:30:00Z",
647
+ "text_length": 5420,
648
+ "memory_unit_count": 15
649
+ }
650
+ ],
651
+ "total": 50,
652
+ "limit": 100,
653
+ "offset": 0
654
+ }
655
+ })
656
+
657
+ items: List[Dict[str, Any]]
658
+ total: int
659
+ limit: int
660
+ offset: int
661
+
662
+
663
+ class DocumentResponse(BaseModel):
664
+ """Response model for get document endpoint."""
665
+ model_config = ConfigDict(json_schema_extra={
666
+ "example": {
667
+ "id": "session_1",
668
+ "bank_id": "user123",
669
+ "original_text": "Full document text here...",
670
+ "content_hash": "abc123",
671
+ "created_at": "2024-01-15T10:30:00Z",
672
+ "updated_at": "2024-01-15T10:30:00Z",
673
+ "memory_unit_count": 15
674
+ }
675
+ })
676
+
677
+ id: str
678
+ bank_id: str
679
+ original_text: str
680
+ content_hash: Optional[str]
681
+ created_at: str
682
+ updated_at: str
683
+ memory_unit_count: int
684
+
685
+
686
+ class ChunkResponse(BaseModel):
687
+ """Response model for get chunk endpoint."""
688
+ model_config = ConfigDict(json_schema_extra={
689
+ "example": {
690
+ "chunk_id": "user123_session_1_0",
691
+ "document_id": "session_1",
692
+ "bank_id": "user123",
693
+ "chunk_index": 0,
694
+ "chunk_text": "This is the first chunk of the document...",
695
+ "created_at": "2024-01-15T10:30:00Z"
696
+ }
697
+ })
698
+
699
+ chunk_id: str
700
+ document_id: str
701
+ bank_id: str
702
+ chunk_index: int
703
+ chunk_text: str
704
+ created_at: str
705
+
706
+
707
+ class DeleteResponse(BaseModel):
708
+ """Response model for delete operations."""
709
+ model_config = ConfigDict(json_schema_extra={
710
+ "example": {
711
+ "success": True
712
+ }
713
+ })
714
+
715
+ success: bool
716
+
717
+
718
+ def create_app(memory: MemoryEngine, run_migrations: bool = True, initialize_memory: bool = True) -> FastAPI:
719
+ """
720
+ Create and configure the FastAPI application.
721
+
722
+ Args:
723
+ memory: MemoryEngine instance (already initialized with required parameters)
724
+ run_migrations: Whether to run database migrations on startup (default: True)
725
+ initialize_memory: Whether to initialize memory system on startup (default: True)
726
+
727
+ Returns:
728
+ Configured FastAPI application
729
+
730
+ Note:
731
+ When mounting this app as a sub-application, the lifespan events may not fire.
732
+ In that case, you should call memory.initialize() manually before starting the server
733
+ and memory.close() when shutting down.
734
+ """
735
+ @asynccontextmanager
736
+ async def lifespan(app: FastAPI):
737
+ """
738
+ Lifespan context manager for startup and shutdown events.
739
+ Note: This only fires when running the app standalone, not when mounted.
740
+ """
741
+ # Initialize OpenTelemetry metrics
742
+ try:
743
+ prometheus_reader = initialize_metrics(
744
+ service_name="hindsight-api",
745
+ service_version="1.0.0"
746
+ )
747
+ create_metrics_collector()
748
+ app.state.prometheus_reader = prometheus_reader
749
+ logging.info("Metrics initialized - available at /metrics endpoint")
750
+ except Exception as e:
751
+ logging.warning(f"Failed to initialize metrics: {e}. Metrics will be disabled (using no-op collector).")
752
+ app.state.prometheus_reader = None
753
+ # Metrics collector is already initialized as no-op by default
754
+
755
+ # Startup: Initialize database and memory system
756
+ if initialize_memory:
757
+ await memory.initialize()
758
+ logging.info("Memory system initialized")
759
+
760
+ if run_migrations:
761
+ from hindsight_api.migrations import run_migrations as do_migrations
762
+ do_migrations(memory.db_url)
763
+ logging.info("Database migrations applied")
764
+
765
+
766
+
767
+ yield
768
+
769
+ # Shutdown: Cleanup memory system
770
+ await memory.close()
771
+ logging.info("Memory system closed")
772
+
773
+ app = FastAPI(
774
+ title="Hindsight HTTP API",
775
+ version="1.0.0",
776
+ description="HTTP API for Hindsight",
777
+ contact={
778
+ "name": "Memory System",
779
+ },
780
+ license_info={
781
+ "name": "Apache 2.0",
782
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html",
783
+ },
784
+ lifespan=lifespan
785
+ )
786
+
787
+ # IMPORTANT: Set memory on app.state immediately, don't wait for lifespan
788
+ # This is required for mounted sub-applications where lifespan may not fire
789
+ app.state.memory = memory
790
+
791
+ # Register all routes
792
+ _register_routes(app)
793
+
794
+ return app
795
+
796
+
797
+ def _register_routes(app: FastAPI):
798
+ """Register all API routes on the given app instance."""
799
+
800
+ @app.get(
801
+ "/health",
802
+ summary="Health check endpoint",
803
+ description="Checks the health of the API and database connection",
804
+ tags=["Monitoring"]
805
+ )
806
+ async def health_endpoint():
807
+ """
808
+ Health check endpoint that verifies database connectivity.
809
+
810
+ Returns 200 if healthy, 503 if unhealthy.
811
+ """
812
+ from fastapi.responses import JSONResponse
813
+
814
+ health = await app.state.memory.health_check()
815
+ status_code = 200 if health.get("status") == "healthy" else 503
816
+ return JSONResponse(content=health, status_code=status_code)
817
+
818
+ @app.get(
819
+ "/metrics",
820
+ summary="Prometheus metrics endpoint",
821
+ description="Exports metrics in Prometheus format for scraping",
822
+ tags=["Monitoring"]
823
+ )
824
+ async def metrics_endpoint():
825
+ """Return Prometheus metrics."""
826
+ from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
827
+ from fastapi.responses import Response
828
+
829
+ metrics_data = generate_latest()
830
+ return Response(content=metrics_data, media_type=CONTENT_TYPE_LATEST)
831
+
832
+ @app.get(
833
+ "/v1/default/banks/{bank_id}/graph",
834
+ response_model=GraphDataResponse,
835
+ summary="Get memory graph data",
836
+ description="Retrieve graph data for visualization, optionally filtered by type (world/agent/opinion). Limited to 1000 most recent items.",
837
+ operation_id="get_graph"
838
+ )
839
+ async def api_graph(bank_id: str,
840
+ type: Optional[str] = None
841
+ ):
842
+ """Get graph data from database, filtered by bank_id and optionally by type."""
843
+ try:
844
+ data = await app.state.memory.get_graph_data(bank_id, type)
845
+ return data
846
+ except Exception as e:
847
+ import traceback
848
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
849
+ logger.error(f"Error in /v1/default/banks/{bank_id}/graph: {error_detail}")
850
+ raise HTTPException(status_code=500, detail=str(e))
851
+
852
+
853
+ @app.get(
854
+ "/v1/default/banks/{bank_id}/memories/list",
855
+ response_model=ListMemoryUnitsResponse,
856
+ summary="List memory units",
857
+ description="List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC).",
858
+ operation_id="list_memories"
859
+ )
860
+ async def api_list(bank_id: str,
861
+ type: Optional[str] = None,
862
+ q: Optional[str] = None,
863
+ limit: int = 100,
864
+ offset: int = 0
865
+ ):
866
+ """
867
+ List memory units for table view with optional full-text search.
868
+
869
+ Results are ordered by most recent first, using mentioned_at timestamp
870
+ (when the memory was mentioned/learned), falling back to created_at.
871
+
872
+ Args:
873
+ bank_id: Memory Bank ID (from path)
874
+ type: Filter by fact type (world, agent, opinion)
875
+ q: Search query for full-text search (searches text and context)
876
+ limit: Maximum number of results (default: 100)
877
+ offset: Offset for pagination (default: 0)
878
+ """
879
+ try:
880
+ data = await app.state.memory.list_memory_units(
881
+ bank_id=bank_id,
882
+ fact_type=type,
883
+ search_query=q,
884
+ limit=limit,
885
+ offset=offset
886
+ )
887
+ return data
888
+ except Exception as e:
889
+ import traceback
890
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
891
+ logger.error(f"Error in /v1/default/banks/{bank_id}/memories/list: {error_detail}")
892
+ raise HTTPException(status_code=500, detail=str(e))
893
+
894
+
895
+ @app.post(
896
+ "/v1/default/banks/{bank_id}/memories/recall",
897
+ response_model=RecallResponse,
898
+ summary="Recall memory",
899
+ description="""
900
+ Recall memory using semantic similarity and spreading activation.
901
+
902
+ The type parameter is optional and must be one of:
903
+ - 'world': General knowledge about people, places, events, and things that happen
904
+ - 'bank': Memories about what the AI agent did, actions taken, and tasks performed
905
+ - 'opinion': The bank's formed beliefs, perspectives, and viewpoints
906
+
907
+ Set include_entities=true to get entity observations alongside recall results.
908
+ """,
909
+ operation_id="recall_memories"
910
+ )
911
+ async def api_recall(bank_id: str, request: RecallRequest):
912
+ """Run a recall and return results with trace."""
913
+ metrics = get_metrics_collector()
914
+
915
+ try:
916
+ # Validate types
917
+ valid_fact_types = ["world", "bank", "opinion"]
918
+
919
+ # Default to world, agent, opinion if not specified (exclude observation by default)
920
+ fact_types = request.types if request.types else ["world", "bank", "opinion"]
921
+ for ft in fact_types:
922
+ if ft not in valid_fact_types:
923
+ raise HTTPException(
924
+ status_code=400,
925
+ detail=f"Invalid type '{ft}'. Must be one of: {', '.join(valid_fact_types)}"
926
+ )
927
+
928
+ # Parse query_timestamp if provided
929
+ question_date = None
930
+ if request.query_timestamp:
931
+ try:
932
+ question_date = datetime.fromisoformat(request.query_timestamp.replace('Z', '+00:00'))
933
+ except ValueError as e:
934
+ raise HTTPException(
935
+ status_code=400,
936
+ detail=f"Invalid query_timestamp format. Expected ISO format (e.g., '2023-05-30T23:40:00'): {str(e)}"
937
+ )
938
+
939
+ # Determine entity inclusion settings
940
+ include_entities = request.include.entities is not None
941
+ max_entity_tokens = request.include.entities.max_tokens if include_entities else 500
942
+
943
+ # Determine chunk inclusion settings
944
+ include_chunks = request.include.chunks is not None
945
+ max_chunk_tokens = request.include.chunks.max_tokens if include_chunks else 8192
946
+
947
+ # Run recall with tracing (record metrics)
948
+ with metrics.record_operation("recall", bank_id=bank_id, budget=request.budget.value, max_tokens=request.max_tokens):
949
+ core_result = await app.state.memory.recall_async(
950
+ bank_id=bank_id,
951
+ query=request.query,
952
+ budget=request.budget,
953
+ max_tokens=request.max_tokens,
954
+ enable_trace=request.trace,
955
+ fact_type=fact_types,
956
+ question_date=question_date,
957
+ include_entities=include_entities,
958
+ max_entity_tokens=max_entity_tokens,
959
+ include_chunks=include_chunks,
960
+ max_chunk_tokens=max_chunk_tokens
961
+ )
962
+
963
+ # Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
964
+ recall_results = [
965
+ RecallResult(
966
+ id=fact.id,
967
+ text=fact.text,
968
+ type=fact.fact_type,
969
+ entities=fact.entities,
970
+ context=fact.context,
971
+ occurred_start=fact.occurred_start,
972
+ occurred_end=fact.occurred_end,
973
+ mentioned_at=fact.mentioned_at,
974
+ document_id=fact.document_id,
975
+ chunk_id=fact.chunk_id
976
+ )
977
+ for fact in core_result.results
978
+ ]
979
+
980
+ # Convert chunks from engine to HTTP API format
981
+ chunks_response = None
982
+ if core_result.chunks:
983
+ chunks_response = {}
984
+ for chunk_id, chunk_info in core_result.chunks.items():
985
+ chunks_response[chunk_id] = ChunkData(
986
+ id=chunk_id,
987
+ text=chunk_info.chunk_text,
988
+ chunk_index=chunk_info.chunk_index,
989
+ truncated=chunk_info.truncated
990
+ )
991
+
992
+ # Convert core EntityState objects to API EntityStateResponse objects
993
+ entities_response = None
994
+ if core_result.entities:
995
+ entities_response = {}
996
+ for name, state in core_result.entities.items():
997
+ entities_response[name] = EntityStateResponse(
998
+ entity_id=state.entity_id,
999
+ canonical_name=state.canonical_name,
1000
+ observations=[
1001
+ EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
1002
+ for obs in state.observations
1003
+ ]
1004
+ )
1005
+
1006
+ return RecallResponse(
1007
+ results=recall_results,
1008
+ trace=core_result.trace,
1009
+ entities=entities_response,
1010
+ chunks=chunks_response
1011
+ )
1012
+ except HTTPException:
1013
+ raise
1014
+ except Exception as e:
1015
+ import traceback
1016
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1017
+ logger.error(f"Error in /v1/default/banks/{bank_id}/memories/recall: {error_detail}")
1018
+ raise HTTPException(status_code=500, detail=str(e))
1019
+
1020
+
1021
+ @app.post(
1022
+ "/v1/default/banks/{bank_id}/reflect",
1023
+ response_model=ReflectResponse,
1024
+ summary="Reflect and generate answer",
1025
+ description="""
1026
+ Reflect and formulate an answer using bank identity, world facts, and opinions.
1027
+
1028
+ This endpoint:
1029
+ 1. Retrieves agent facts (bank's identity)
1030
+ 2. Retrieves world facts relevant to the query
1031
+ 3. Retrieves existing opinions (bank's perspectives)
1032
+ 4. Uses LLM to formulate a contextual answer
1033
+ 5. Extracts and stores any new opinions formed
1034
+ 6. Returns plain text answer, the facts used, and new opinions
1035
+ """,
1036
+ operation_id="reflect"
1037
+ )
1038
+ async def api_reflect(bank_id: str, request: ReflectRequest):
1039
+ metrics = get_metrics_collector()
1040
+
1041
+ try:
1042
+ # Use the memory system's reflect_async method (record metrics)
1043
+ with metrics.record_operation("reflect", bank_id=bank_id, budget=request.budget.value):
1044
+ core_result = await app.state.memory.reflect_async(
1045
+ bank_id=bank_id,
1046
+ query=request.query,
1047
+ budget=request.budget,
1048
+ context=request.context
1049
+ )
1050
+
1051
+ # Convert core MemoryFact objects to API ReflectFact objects if facts are requested
1052
+ based_on_facts = []
1053
+ if request.include.facts is not None:
1054
+ for fact_type, facts in core_result.based_on.items():
1055
+ for fact in facts:
1056
+ based_on_facts.append(ReflectFact(
1057
+ id=fact.id,
1058
+ text=fact.text,
1059
+ type=fact.fact_type,
1060
+ context=fact.context,
1061
+ occurred_start=fact.occurred_start,
1062
+ occurred_end=fact.occurred_end
1063
+ ))
1064
+
1065
+ return ReflectResponse(
1066
+ text=core_result.text,
1067
+ based_on=based_on_facts,
1068
+ )
1069
+
1070
+ except Exception as e:
1071
+ import traceback
1072
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1073
+ logger.error(f"Error in /v1/default/banks/{bank_id}/reflect: {error_detail}")
1074
+ raise HTTPException(status_code=500, detail=str(e))
1075
+
1076
+
1077
+ @app.get(
1078
+ "/v1/default/banks",
1079
+ response_model=BankListResponse,
1080
+ summary="List all memory banks",
1081
+ description="Get a list of all agents with their profiles",
1082
+ operation_id="list_banks"
1083
+ )
1084
+ async def api_list_banks():
1085
+ """Get list of all banks with their profiles."""
1086
+ try:
1087
+ banks = await app.state.memory.list_banks()
1088
+ return BankListResponse(banks=banks)
1089
+ except Exception as e:
1090
+ import traceback
1091
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1092
+ logger.error(f"Error in /v1/default/banks: {error_detail}")
1093
+ raise HTTPException(status_code=500, detail=str(e))
1094
+
1095
+ @app.get(
1096
+ "/v1/default/banks/{bank_id}/stats",
1097
+ summary="Get statistics for memory bank",
1098
+ description="Get statistics about nodes and links for a specific agent",
1099
+ operation_id="get_agent_stats"
1100
+ )
1101
+ async def api_stats(bank_id: str):
1102
+ """Get statistics about memory nodes and links for a memory bank."""
1103
+ try:
1104
+ pool = await app.state.memory._get_pool()
1105
+ async with acquire_with_retry(pool) as conn:
1106
+ # Get node counts by fact_type
1107
+ node_stats = await conn.fetch(
1108
+ """
1109
+ SELECT fact_type, COUNT(*) as count
1110
+ FROM memory_units
1111
+ WHERE bank_id = $1
1112
+ GROUP BY fact_type
1113
+ """,
1114
+ bank_id
1115
+ )
1116
+
1117
+ # Get link counts by link_type
1118
+ link_stats = await conn.fetch(
1119
+ """
1120
+ SELECT ml.link_type, COUNT(*) as count
1121
+ FROM memory_links ml
1122
+ JOIN memory_units mu ON ml.from_unit_id = mu.id
1123
+ WHERE mu.bank_id = $1
1124
+ GROUP BY ml.link_type
1125
+ """,
1126
+ bank_id
1127
+ )
1128
+
1129
+ # Get link counts by fact_type (from nodes)
1130
+ link_fact_type_stats = await conn.fetch(
1131
+ """
1132
+ SELECT mu.fact_type, COUNT(*) as count
1133
+ FROM memory_links ml
1134
+ JOIN memory_units mu ON ml.from_unit_id = mu.id
1135
+ WHERE mu.bank_id = $1
1136
+ GROUP BY mu.fact_type
1137
+ """,
1138
+ bank_id
1139
+ )
1140
+
1141
+ # Get link counts by fact_type AND link_type
1142
+ link_breakdown_stats = await conn.fetch(
1143
+ """
1144
+ SELECT mu.fact_type, ml.link_type, COUNT(*) as count
1145
+ FROM memory_links ml
1146
+ JOIN memory_units mu ON ml.from_unit_id = mu.id
1147
+ WHERE mu.bank_id = $1
1148
+ GROUP BY mu.fact_type, ml.link_type
1149
+ """,
1150
+ bank_id
1151
+ )
1152
+
1153
+ # Get pending and failed operations counts
1154
+ ops_stats = await conn.fetch(
1155
+ """
1156
+ SELECT status, COUNT(*) as count
1157
+ FROM async_operations
1158
+ WHERE bank_id = $1
1159
+ GROUP BY status
1160
+ """,
1161
+ bank_id
1162
+ )
1163
+ ops_by_status = {row['status']: row['count'] for row in ops_stats}
1164
+ pending_operations = ops_by_status.get('pending', 0)
1165
+ failed_operations = ops_by_status.get('failed', 0)
1166
+
1167
+ # Get document count
1168
+ doc_count_result = await conn.fetchrow(
1169
+ """
1170
+ SELECT COUNT(*) as count
1171
+ FROM documents
1172
+ WHERE bank_id = $1
1173
+ """,
1174
+ bank_id
1175
+ )
1176
+ total_documents = doc_count_result['count'] if doc_count_result else 0
1177
+
1178
+ # Format results
1179
+ nodes_by_type = {row['fact_type']: row['count'] for row in node_stats}
1180
+ links_by_type = {row['link_type']: row['count'] for row in link_stats}
1181
+ links_by_fact_type = {row['fact_type']: row['count'] for row in link_fact_type_stats}
1182
+
1183
+ # Build detailed breakdown: {fact_type: {link_type: count}}
1184
+ links_breakdown = {}
1185
+ for row in link_breakdown_stats:
1186
+ fact_type = row['fact_type']
1187
+ link_type = row['link_type']
1188
+ count = row['count']
1189
+ if fact_type not in links_breakdown:
1190
+ links_breakdown[fact_type] = {}
1191
+ links_breakdown[fact_type][link_type] = count
1192
+
1193
+ total_nodes = sum(nodes_by_type.values())
1194
+ total_links = sum(links_by_type.values())
1195
+
1196
+ return {
1197
+ "bank_id": bank_id,
1198
+ "total_nodes": total_nodes,
1199
+ "total_links": total_links,
1200
+ "total_documents": total_documents,
1201
+ "nodes_by_fact_type": nodes_by_type,
1202
+ "links_by_link_type": links_by_type,
1203
+ "links_by_fact_type": links_by_fact_type,
1204
+ "links_breakdown": links_breakdown,
1205
+ "pending_operations": pending_operations,
1206
+ "failed_operations": failed_operations
1207
+ }
1208
+
1209
+ except Exception as e:
1210
+ import traceback
1211
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1212
+ logger.error(f"Error in /v1/default/banks/{bank_id}/stats: {error_detail}")
1213
+ raise HTTPException(status_code=500, detail=str(e))
1214
+
1215
+ @app.get(
1216
+ "/v1/default/banks/{bank_id}/entities",
1217
+ response_model=EntityListResponse,
1218
+ summary="List entities",
1219
+ description="List all entities (people, organizations, etc.) known by the bank, ordered by mention count.",
1220
+ operation_id="list_entities"
1221
+ )
1222
+ async def api_list_entities(bank_id: str,
1223
+ limit: int = Query(default=100, description="Maximum number of entities to return")
1224
+ ):
1225
+ """List entities for a memory bank."""
1226
+ try:
1227
+ entities = await app.state.memory.list_entities(bank_id, limit=limit)
1228
+ return EntityListResponse(
1229
+ items=[EntityListItem(**e) for e in entities]
1230
+ )
1231
+ except Exception as e:
1232
+ import traceback
1233
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1234
+ logger.error(f"Error in /v1/default/banks/{bank_id}/entities: {error_detail}")
1235
+ raise HTTPException(status_code=500, detail=str(e))
1236
+
1237
+ @app.get(
1238
+ "/v1/default/banks/{bank_id}/entities/{entity_id}",
1239
+ response_model=EntityDetailResponse,
1240
+ summary="Get entity details",
1241
+ description="Get detailed information about an entity including observations (mental model).",
1242
+ operation_id="get_entity"
1243
+ )
1244
+ async def api_get_entity(bank_id: str, entity_id: str):
1245
+ """Get entity details with observations."""
1246
+ try:
1247
+ # First get the entity metadata
1248
+ pool = await app.state.memory._get_pool()
1249
+ async with acquire_with_retry(pool) as conn:
1250
+ entity_row = await conn.fetchrow(
1251
+ """
1252
+ SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
1253
+ FROM entities
1254
+ WHERE bank_id = $1 AND id = $2
1255
+ """,
1256
+ bank_id, uuid.UUID(entity_id)
1257
+ )
1258
+
1259
+ if not entity_row:
1260
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
1261
+
1262
+ # Get observations for the entity
1263
+ observations = await app.state.memory.get_entity_observations(
1264
+ bank_id, entity_id, limit=20
1265
+ )
1266
+
1267
+ return EntityDetailResponse(
1268
+ id=str(entity_row['id']),
1269
+ canonical_name=entity_row['canonical_name'],
1270
+ mention_count=entity_row['mention_count'],
1271
+ first_seen=entity_row['first_seen'].isoformat() if entity_row['first_seen'] else None,
1272
+ last_seen=entity_row['last_seen'].isoformat() if entity_row['last_seen'] else None,
1273
+ metadata=_parse_metadata(entity_row['metadata']),
1274
+ observations=[
1275
+ EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
1276
+ for obs in observations
1277
+ ]
1278
+ )
1279
+ except HTTPException:
1280
+ raise
1281
+ except Exception as e:
1282
+ import traceback
1283
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1284
+ logger.error(f"Error in /v1/default/banks/{bank_id}/entities/{entity_id}: {error_detail}")
1285
+ raise HTTPException(status_code=500, detail=str(e))
1286
+
1287
+ @app.post(
1288
+ "/v1/default/banks/{bank_id}/entities/{entity_id}/regenerate",
1289
+ response_model=EntityDetailResponse,
1290
+ summary="Regenerate entity observations",
1291
+ description="Regenerate observations for an entity based on all facts mentioning it.",
1292
+ operation_id="regenerate_entity_observations"
1293
+ )
1294
+ async def api_regenerate_entity_observations(bank_id: str, entity_id: str):
1295
+ """Regenerate observations for an entity."""
1296
+ try:
1297
+ # First get the entity metadata
1298
+ pool = await app.state.memory._get_pool()
1299
+ async with acquire_with_retry(pool) as conn:
1300
+ entity_row = await conn.fetchrow(
1301
+ """
1302
+ SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
1303
+ FROM entities
1304
+ WHERE bank_id = $1 AND id = $2
1305
+ """,
1306
+ bank_id, uuid.UUID(entity_id)
1307
+ )
1308
+
1309
+ if not entity_row:
1310
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
1311
+
1312
+ # Regenerate observations
1313
+ await app.state.memory.regenerate_entity_observations(
1314
+ bank_id=bank_id,
1315
+ entity_id=entity_id,
1316
+ entity_name=entity_row['canonical_name']
1317
+ )
1318
+
1319
+ # Get updated observations
1320
+ observations = await app.state.memory.get_entity_observations(
1321
+ bank_id, entity_id, limit=20
1322
+ )
1323
+
1324
+ return EntityDetailResponse(
1325
+ id=str(entity_row['id']),
1326
+ canonical_name=entity_row['canonical_name'],
1327
+ mention_count=entity_row['mention_count'],
1328
+ first_seen=entity_row['first_seen'].isoformat() if entity_row['first_seen'] else None,
1329
+ last_seen=entity_row['last_seen'].isoformat() if entity_row['last_seen'] else None,
1330
+ metadata=_parse_metadata(entity_row['metadata']),
1331
+ observations=[
1332
+ EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
1333
+ for obs in observations
1334
+ ]
1335
+ )
1336
+ except HTTPException:
1337
+ raise
1338
+ except Exception as e:
1339
+ import traceback
1340
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1341
+ logger.error(f"Error in /v1/default/banks/{bank_id}/entities/{entity_id}/regenerate: {error_detail}")
1342
+ raise HTTPException(status_code=500, detail=str(e))
1343
+
1344
+ @app.get(
1345
+ "/v1/default/banks/{bank_id}/documents",
1346
+ response_model=ListDocumentsResponse,
1347
+ summary="List documents",
1348
+ description="List documents with pagination and optional search. Documents are the source content from which memory units are extracted.",
1349
+ operation_id="list_documents"
1350
+ )
1351
+ async def api_list_documents(bank_id: str,
1352
+ q: Optional[str] = None,
1353
+ limit: int = 100,
1354
+ offset: int = 0
1355
+ ):
1356
+ """
1357
+ List documents for a memory bank with optional search.
1358
+
1359
+ Args:
1360
+ bank_id: Memory Bank ID (from path)
1361
+ q: Search query (searches document ID and metadata)
1362
+ limit: Maximum number of results (default: 100)
1363
+ offset: Offset for pagination (default: 0)
1364
+ """
1365
+ try:
1366
+ data = await app.state.memory.list_documents(
1367
+ bank_id=bank_id,
1368
+ search_query=q,
1369
+ limit=limit,
1370
+ offset=offset
1371
+ )
1372
+ return data
1373
+ except Exception as e:
1374
+ import traceback
1375
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1376
+ logger.error(f"Error in /v1/default/banks/{bank_id}/documents: {error_detail}")
1377
+ raise HTTPException(status_code=500, detail=str(e))
1378
+
1379
+
1380
+ @app.get(
1381
+ "/v1/default/banks/{bank_id}/documents/{document_id}",
1382
+ response_model=DocumentResponse,
1383
+ summary="Get document details",
1384
+ description="Get a specific document including its original text",
1385
+ operation_id="get_document"
1386
+ )
1387
+ async def api_get_document(bank_id: str,
1388
+ document_id: str
1389
+ ):
1390
+ """
1391
+ Get a specific document with its original text.
1392
+
1393
+ Args:
1394
+ bank_id: Memory Bank ID (from path)
1395
+ document_id: Document ID (from path)
1396
+ """
1397
+ try:
1398
+ document = await app.state.memory.get_document(document_id, bank_id)
1399
+ if not document:
1400
+ raise HTTPException(status_code=404, detail="Document not found")
1401
+ return document
1402
+ except HTTPException:
1403
+ raise
1404
+ except Exception as e:
1405
+ import traceback
1406
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1407
+ logger.error(f"Error in /v1/default/banks/{bank_id}/documents/{document_id}: {error_detail}")
1408
+ raise HTTPException(status_code=500, detail=str(e))
1409
+
1410
+
1411
+ @app.get(
1412
+ "/v1/default/chunks/{chunk_id}",
1413
+ response_model=ChunkResponse,
1414
+ summary="Get chunk details",
1415
+ description="Get a specific chunk by its ID",
1416
+ operation_id="get_chunk"
1417
+ )
1418
+ async def api_get_chunk(chunk_id: str):
1419
+ """
1420
+ Get a specific chunk with its text.
1421
+
1422
+ Args:
1423
+ chunk_id: Chunk ID (from path, format: bank_id_document_id_chunk_index)
1424
+ """
1425
+ try:
1426
+ chunk = await app.state.memory.get_chunk(chunk_id)
1427
+ if not chunk:
1428
+ raise HTTPException(status_code=404, detail="Chunk not found")
1429
+ return chunk
1430
+ except HTTPException:
1431
+ raise
1432
+ except Exception as e:
1433
+ import traceback
1434
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1435
+ logger.error(f"Error in /v1/default/chunks/{chunk_id}: {error_detail}")
1436
+ raise HTTPException(status_code=500, detail=str(e))
1437
+
1438
+
1439
+ @app.delete(
1440
+ "/v1/default/banks/{bank_id}/documents/{document_id}",
1441
+ summary="Delete a document",
1442
+ description="""
1443
+ Delete a document and all its associated memory units and links.
1444
+
1445
+ This will cascade delete:
1446
+ - The document itself
1447
+ - All memory units extracted from this document
1448
+ - All links (temporal, semantic, entity) associated with those memory units
1449
+
1450
+ This operation cannot be undone.
1451
+ """,
1452
+ operation_id="delete_document"
1453
+ )
1454
+ async def api_delete_document(bank_id: str,
1455
+ document_id: str
1456
+ ):
1457
+ """
1458
+ Delete a document and all its associated memory units and links.
1459
+
1460
+ Args:
1461
+ bank_id: Memory Bank ID (from path)
1462
+ document_id: Document ID to delete (from path)
1463
+ """
1464
+ try:
1465
+ result = await app.state.memory.delete_document(document_id, bank_id)
1466
+
1467
+ if result["document_deleted"] == 0:
1468
+ raise HTTPException(status_code=404, detail="Document not found")
1469
+
1470
+ return {
1471
+ "success": True,
1472
+ "message": f"Document '{document_id}' and {result['memory_units_deleted']} associated memory units deleted successfully",
1473
+ "document_id": document_id,
1474
+ "memory_units_deleted": result["memory_units_deleted"]
1475
+ }
1476
+ except HTTPException:
1477
+ raise
1478
+ except Exception as e:
1479
+ import traceback
1480
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1481
+ logger.error(f"Error in /v1/default/banks/{bank_id}/documents/{document_id}: {error_detail}")
1482
+ raise HTTPException(status_code=500, detail=str(e))
1483
+
1484
+
1485
+ @app.get(
1486
+ "/v1/default/banks/{bank_id}/operations",
1487
+ summary="List async operations",
1488
+ description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
1489
+ operation_id="list_operations"
1490
+ )
1491
+ async def api_list_operations(bank_id: str):
1492
+ """List all async operations (pending and failed) for a memory bank."""
1493
+ try:
1494
+ pool = await app.state.memory._get_pool()
1495
+ async with acquire_with_retry(pool) as conn:
1496
+ operations = await conn.fetch(
1497
+ """
1498
+ SELECT operation_id, bank_id, operation_type, created_at, status, error_message, result_metadata
1499
+ FROM async_operations
1500
+ WHERE bank_id = $1
1501
+ ORDER BY created_at DESC
1502
+ """,
1503
+ bank_id
1504
+ )
1505
+
1506
+ return {
1507
+ "bank_id": bank_id,
1508
+ "operations": [
1509
+ {
1510
+ "id": str(row['operation_id']),
1511
+ "task_type": row['operation_type'],
1512
+ "items_count": row['result_metadata'].get('items_count', 0) if row['result_metadata'] else 0,
1513
+ "document_id": row['result_metadata'].get('document_id') if row['result_metadata'] else None,
1514
+ "created_at": row['created_at'].isoformat(),
1515
+ "status": row['status'],
1516
+ "error_message": row['error_message']
1517
+ }
1518
+ for row in operations
1519
+ ]
1520
+ }
1521
+
1522
+ except Exception as e:
1523
+ import traceback
1524
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1525
+ logger.error(f"Error in /v1/default/banks/{bank_id}/operations: {error_detail}")
1526
+ raise HTTPException(status_code=500, detail=str(e))
1527
+
1528
+
1529
+ @app.delete(
1530
+ "/v1/default/banks/{bank_id}/operations/{operation_id}",
1531
+ summary="Cancel a pending async operation",
1532
+ description="Cancel a pending async operation by removing it from the queue",
1533
+ operation_id="cancel_operation"
1534
+ )
1535
+ async def api_cancel_operation(bank_id: str, operation_id: str):
1536
+ """Cancel a pending async operation."""
1537
+ try:
1538
+ # Validate UUID format
1539
+ try:
1540
+ op_uuid = uuid.UUID(operation_id)
1541
+ except ValueError:
1542
+ raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
1543
+
1544
+ pool = await app.state.memory._get_pool()
1545
+ async with acquire_with_retry(pool) as conn:
1546
+ # Check if operation exists and belongs to this memory bank
1547
+ result = await conn.fetchrow(
1548
+ "SELECT bank_id FROM async_operations WHERE id = $1 AND bank_id = $2",
1549
+ op_uuid,
1550
+ bank_id
1551
+ )
1552
+
1553
+ if not result:
1554
+ raise HTTPException(status_code=404, detail=f"Operation {operation_id} not found for memory bank {bank_id}")
1555
+
1556
+ # Delete the operation
1557
+ await conn.execute(
1558
+ "DELETE FROM async_operations WHERE id = $1",
1559
+ op_uuid
1560
+ )
1561
+
1562
+ return {
1563
+ "success": True,
1564
+ "message": f"Operation {operation_id} cancelled",
1565
+ "operation_id": operation_id,
1566
+ "bank_id": bank_id
1567
+ }
1568
+
1569
+ except HTTPException:
1570
+ raise
1571
+ except Exception as e:
1572
+ import traceback
1573
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1574
+ logger.error(f"Error in /v1/default/banks/{bank_id}/operations/{operation_id}: {error_detail}")
1575
+ raise HTTPException(status_code=500, detail=str(e))
1576
+
1577
+
1578
+ @app.get(
1579
+ "/v1/default/banks/{bank_id}/profile",
1580
+ response_model=BankProfileResponse,
1581
+ summary="Get memory bank profile",
1582
+ description="Get personality traits and background for a memory bank. Auto-creates agent with defaults if not exists.",
1583
+ operation_id="get_bank_profile"
1584
+ )
1585
+ async def api_get_bank_profile(bank_id: str):
1586
+ """Get memory bank profile (personality + background)."""
1587
+ try:
1588
+ profile = await app.state.memory.get_bank_profile(bank_id)
1589
+ # Convert PersonalityTraits object to dict for Pydantic
1590
+ personality_dict = profile["personality"].model_dump() if hasattr(profile["personality"], 'model_dump') else dict(profile["personality"])
1591
+ return BankProfileResponse(
1592
+ bank_id=bank_id,
1593
+ name=profile["name"],
1594
+ personality=PersonalityTraits(**personality_dict),
1595
+ background=profile["background"]
1596
+ )
1597
+ except Exception as e:
1598
+ import traceback
1599
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1600
+ logger.error(f"Error in /v1/default/banks/{bank_id}/profile: {error_detail}")
1601
+ raise HTTPException(status_code=500, detail=str(e))
1602
+
1603
+
1604
+ @app.put(
1605
+ "/v1/default/banks/{bank_id}/profile",
1606
+ response_model=BankProfileResponse,
1607
+ summary="Update memory bank personality",
1608
+ description="Update bank's Big Five personality traits and bias strength",
1609
+ operation_id="update_bank_personality"
1610
+ )
1611
+ async def api_update_bank_personality(bank_id: str,
1612
+ request: UpdatePersonalityRequest
1613
+ ):
1614
+ """Update bank personality traits."""
1615
+ try:
1616
+ # Update personality
1617
+ await app.state.memory.update_bank_personality(
1618
+ bank_id,
1619
+ request.personality.model_dump()
1620
+ )
1621
+
1622
+ # Get updated profile
1623
+ profile = await app.state.memory.get_bank_profile(bank_id)
1624
+ personality_dict = profile["personality"].model_dump() if hasattr(profile["personality"], 'model_dump') else dict(profile["personality"])
1625
+ return BankProfileResponse(
1626
+ bank_id=bank_id,
1627
+ name=profile["name"],
1628
+ personality=PersonalityTraits(**personality_dict),
1629
+ background=profile["background"]
1630
+ )
1631
+ except Exception as e:
1632
+ import traceback
1633
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1634
+ logger.error(f"Error in /v1/default/banks/{bank_id}/profile: {error_detail}")
1635
+ raise HTTPException(status_code=500, detail=str(e))
1636
+
1637
+
1638
+ @app.post(
1639
+ "/v1/default/banks/{bank_id}/background",
1640
+ response_model=BackgroundResponse,
1641
+ summary="Add/merge memory bank background",
1642
+ description="Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers personality traits.",
1643
+ operation_id="add_bank_background"
1644
+ )
1645
+ async def api_add_bank_background(bank_id: str,
1646
+ request: AddBackgroundRequest
1647
+ ):
1648
+ """Add or merge bank background information. Optionally infer personality traits."""
1649
+ try:
1650
+ result = await app.state.memory.merge_bank_background(
1651
+ bank_id,
1652
+ request.content,
1653
+ update_personality=request.update_personality
1654
+ )
1655
+
1656
+ response = BackgroundResponse(background=result["background"])
1657
+ if "personality" in result:
1658
+ response.personality = PersonalityTraits(**result["personality"])
1659
+
1660
+ return response
1661
+ except Exception as e:
1662
+ import traceback
1663
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1664
+ logger.error(f"Error in /v1/default/banks/{bank_id}/background: {error_detail}")
1665
+ raise HTTPException(status_code=500, detail=str(e))
1666
+
1667
+
1668
+ @app.put(
1669
+ "/v1/default/banks/{bank_id}",
1670
+ response_model=BankProfileResponse,
1671
+ summary="Create or update memory bank",
1672
+ description="Create a new agent or update existing agent with personality and background. Auto-fills missing fields with defaults.",
1673
+ operation_id="create_or_update_bank"
1674
+ )
1675
+ async def api_create_or_update_bank(bank_id: str,
1676
+ request: CreateBankRequest
1677
+ ):
1678
+ """Create or update an agent with personality and background."""
1679
+ try:
1680
+ # Get existing profile or create with defaults
1681
+ profile = await app.state.memory.get_bank_profile(bank_id)
1682
+
1683
+ # Update name if provided
1684
+ if request.name is not None:
1685
+ pool = await app.state.memory._get_pool()
1686
+ async with acquire_with_retry(pool) as conn:
1687
+ await conn.execute(
1688
+ """
1689
+ UPDATE banks
1690
+ SET name = $2,
1691
+ updated_at = NOW()
1692
+ WHERE bank_id = $1
1693
+ """,
1694
+ bank_id,
1695
+ request.name
1696
+ )
1697
+ profile["name"] = request.name
1698
+
1699
+ # Update personality if provided
1700
+ if request.personality is not None:
1701
+ await app.state.memory.update_bank_personality(
1702
+ bank_id,
1703
+ request.personality.model_dump()
1704
+ )
1705
+ profile["personality"] = request.personality.model_dump()
1706
+
1707
+ # Update background if provided (replace, not merge)
1708
+ if request.background is not None:
1709
+ pool = await app.state.memory._get_pool()
1710
+ async with acquire_with_retry(pool) as conn:
1711
+ await conn.execute(
1712
+ """
1713
+ UPDATE banks
1714
+ SET background = $2,
1715
+ updated_at = NOW()
1716
+ WHERE bank_id = $1
1717
+ """,
1718
+ bank_id,
1719
+ request.background
1720
+ )
1721
+ profile["background"] = request.background
1722
+
1723
+ # Get final profile
1724
+ final_profile = await app.state.memory.get_bank_profile(bank_id)
1725
+ personality_dict = final_profile["personality"].model_dump() if hasattr(final_profile["personality"], 'model_dump') else dict(final_profile["personality"])
1726
+ return BankProfileResponse(
1727
+ bank_id=bank_id,
1728
+ name=final_profile["name"],
1729
+ personality=PersonalityTraits(**personality_dict),
1730
+ background=final_profile["background"]
1731
+ )
1732
+ except Exception as e:
1733
+ import traceback
1734
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1735
+ logger.error(f"Error in /v1/default/banks/{bank_id}: {error_detail}")
1736
+ raise HTTPException(status_code=500, detail=str(e))
1737
+
1738
+
1739
+ @app.post(
1740
+ "/v1/default/banks/{bank_id}/memories",
1741
+ response_model=RetainResponse,
1742
+ summary="Retain memories",
1743
+ description="""
1744
+ Retain memory items with automatic fact extraction.
1745
+
1746
+ This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing
1747
+ via the async parameter.
1748
+
1749
+ Features:
1750
+ - Efficient batch processing
1751
+ - Automatic fact extraction from natural language
1752
+ - Entity recognition and linking
1753
+ - Document tracking with automatic upsert (when document_id is provided on items)
1754
+ - Temporal and semantic linking
1755
+ - Optional asynchronous processing
1756
+
1757
+ The system automatically:
1758
+ 1. Extracts semantic facts from the content
1759
+ 2. Generates embeddings
1760
+ 3. Deduplicates similar facts
1761
+ 4. Creates temporal, semantic, and entity links
1762
+ 5. Tracks document metadata
1763
+
1764
+ When async=true:
1765
+ - Returns immediately after queuing the task
1766
+ - Processing happens in the background
1767
+ - Use the operations endpoint to monitor progress
1768
+
1769
+ When async=false (default):
1770
+ - Waits for processing to complete
1771
+ - Returns after all memories are stored
1772
+
1773
+ Note: If a memory item has a document_id that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). Items with the same document_id are grouped together for efficient processing.
1774
+ """,
1775
+ operation_id="retain_memories"
1776
+ )
1777
+ async def api_retain(bank_id: str, request: RetainRequest):
1778
+ """Retain memories with optional async processing."""
1779
+ metrics = get_metrics_collector()
1780
+
1781
+ try:
1782
+ # Prepare contents for processing
1783
+ contents = []
1784
+ for item in request.items:
1785
+ content_dict = {"content": item.content}
1786
+ if item.timestamp:
1787
+ content_dict["event_date"] = item.timestamp
1788
+ if item.context:
1789
+ content_dict["context"] = item.context
1790
+ if item.metadata:
1791
+ content_dict["metadata"] = item.metadata
1792
+ if item.document_id:
1793
+ content_dict["document_id"] = item.document_id
1794
+ contents.append(content_dict)
1795
+
1796
+ if request.async_:
1797
+ # Async processing: queue task and return immediately
1798
+ operation_id = uuid.uuid4()
1799
+
1800
+ # Insert operation record into database
1801
+ pool = await app.state.memory._get_pool()
1802
+ async with acquire_with_retry(pool) as conn:
1803
+ await conn.execute(
1804
+ """
1805
+ INSERT INTO async_operations (id, bank_id, task_type, items_count)
1806
+ VALUES ($1, $2, $3, $4)
1807
+ """,
1808
+ operation_id,
1809
+ bank_id,
1810
+ 'retain',
1811
+ len(contents)
1812
+ )
1813
+
1814
+ # Submit task to background queue
1815
+ await app.state.memory._task_backend.submit_task({
1816
+ 'type': 'batch_put',
1817
+ 'operation_id': str(operation_id),
1818
+ 'bank_id': bank_id,
1819
+ 'contents': contents
1820
+ })
1821
+
1822
+ logging.info(f"Retain task queued for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}")
1823
+
1824
+ return RetainResponse(
1825
+ success=True,
1826
+ bank_id=bank_id,
1827
+ items_count=len(contents),
1828
+ async_=True
1829
+ )
1830
+ else:
1831
+ # Synchronous processing: wait for completion (record metrics)
1832
+ with metrics.record_operation("retain", bank_id=bank_id):
1833
+ result = await app.state.memory.retain_batch_async(
1834
+ bank_id=bank_id,
1835
+ contents=contents
1836
+ )
1837
+
1838
+ return RetainResponse(
1839
+ success=True,
1840
+ bank_id=bank_id,
1841
+ items_count=len(contents),
1842
+ async_=False
1843
+ )
1844
+ except Exception as e:
1845
+ import traceback
1846
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1847
+ logger.error(f"Error in /v1/default/banks/{bank_id}/memories (retain): {error_detail}")
1848
+ raise HTTPException(status_code=500, detail=str(e))
1849
+
1850
+
1851
+ @app.delete(
1852
+ "/v1/default/banks/{bank_id}/memories",
1853
+ response_model=DeleteResponse,
1854
+ summary="Clear memory bank memories",
1855
+ description="Delete memory units for a memory bank. Optionally filter by type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (personality and background) will be preserved.",
1856
+ operation_id="clear_bank_memories"
1857
+ )
1858
+ async def api_clear_bank_memories(bank_id: str,
1859
+ type: Optional[str] = Query(None, description="Optional fact type filter (world, agent, opinion)")
1860
+ ):
1861
+ """Clear memories for a memory bank, optionally filtered by type."""
1862
+ try:
1863
+ await app.state.memory.delete_bank(bank_id, fact_type=type)
1864
+
1865
+ return DeleteResponse(
1866
+ success=True
1867
+ )
1868
+ except Exception as e:
1869
+ import traceback
1870
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1871
+ logger.error(f"Error in /v1/default/banks/{bank_id}/memories: {error_detail}")
1872
+ raise HTTPException(status_code=500, detail=str(e))