hindsight-api 0.1.11__py3-none-any.whl → 0.1.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 (47) hide show
  1. hindsight_api/__init__.py +2 -0
  2. hindsight_api/alembic/env.py +24 -1
  3. hindsight_api/alembic/versions/d9f6a3b4c5e2_rename_bank_to_interactions.py +14 -4
  4. hindsight_api/alembic/versions/e0a1b2c3d4e5_disposition_to_3_traits.py +54 -13
  5. hindsight_api/alembic/versions/rename_personality_to_disposition.py +18 -7
  6. hindsight_api/api/http.py +253 -230
  7. hindsight_api/api/mcp.py +14 -3
  8. hindsight_api/config.py +11 -0
  9. hindsight_api/daemon.py +204 -0
  10. hindsight_api/engine/__init__.py +12 -1
  11. hindsight_api/engine/entity_resolver.py +38 -37
  12. hindsight_api/engine/interface.py +592 -0
  13. hindsight_api/engine/llm_wrapper.py +176 -6
  14. hindsight_api/engine/memory_engine.py +1092 -293
  15. hindsight_api/engine/retain/bank_utils.py +13 -12
  16. hindsight_api/engine/retain/chunk_storage.py +3 -2
  17. hindsight_api/engine/retain/fact_storage.py +10 -7
  18. hindsight_api/engine/retain/link_utils.py +17 -16
  19. hindsight_api/engine/retain/observation_regeneration.py +17 -16
  20. hindsight_api/engine/retain/orchestrator.py +2 -3
  21. hindsight_api/engine/retain/types.py +25 -8
  22. hindsight_api/engine/search/graph_retrieval.py +6 -5
  23. hindsight_api/engine/search/mpfp_retrieval.py +8 -7
  24. hindsight_api/engine/search/reranking.py +17 -0
  25. hindsight_api/engine/search/retrieval.py +12 -11
  26. hindsight_api/engine/search/think_utils.py +1 -1
  27. hindsight_api/engine/search/tracer.py +1 -1
  28. hindsight_api/engine/task_backend.py +32 -0
  29. hindsight_api/extensions/__init__.py +66 -0
  30. hindsight_api/extensions/base.py +81 -0
  31. hindsight_api/extensions/builtin/__init__.py +18 -0
  32. hindsight_api/extensions/builtin/tenant.py +33 -0
  33. hindsight_api/extensions/context.py +110 -0
  34. hindsight_api/extensions/http.py +89 -0
  35. hindsight_api/extensions/loader.py +125 -0
  36. hindsight_api/extensions/operation_validator.py +325 -0
  37. hindsight_api/extensions/tenant.py +63 -0
  38. hindsight_api/main.py +97 -17
  39. hindsight_api/mcp_local.py +7 -1
  40. hindsight_api/migrations.py +54 -10
  41. hindsight_api/models.py +15 -0
  42. hindsight_api/pg0.py +1 -1
  43. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/METADATA +1 -1
  44. hindsight_api-0.1.13.dist-info/RECORD +75 -0
  45. hindsight_api-0.1.11.dist-info/RECORD +0 -64
  46. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/WHEEL +0 -0
  47. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/entry_points.txt +0 -0
hindsight_api/api/http.py CHANGED
@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
12
12
  from datetime import datetime
13
13
  from typing import Any
14
14
 
15
- from fastapi import FastAPI, HTTPException, Query
15
+ from fastapi import Depends, FastAPI, Header, HTTPException, Query
16
16
 
17
17
 
18
18
  def _parse_metadata(metadata: Any) -> dict[str, Any]:
@@ -29,13 +29,15 @@ def _parse_metadata(metadata: Any) -> dict[str, Any]:
29
29
  return {}
30
30
 
31
31
 
32
- from pydantic import BaseModel, ConfigDict, Field
32
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
33
33
 
34
34
  from hindsight_api import MemoryEngine
35
35
  from hindsight_api.engine.db_utils import acquire_with_retry
36
- from hindsight_api.engine.memory_engine import Budget
36
+ from hindsight_api.engine.memory_engine import Budget, fq_table
37
37
  from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
38
+ from hindsight_api.extensions import HttpExtension, load_extension
38
39
  from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
40
+ from hindsight_api.models import RequestContext
39
41
 
40
42
  logger = logging.getLogger(__name__)
41
43
 
@@ -289,7 +291,7 @@ class MemoryItem(BaseModel):
289
291
  "metadata": {"source": "slack", "channel": "engineering"},
290
292
  "document_id": "meeting_notes_2024_01_15",
291
293
  }
292
- }
294
+ },
293
295
  )
294
296
 
295
297
  content: str
@@ -298,6 +300,23 @@ class MemoryItem(BaseModel):
298
300
  metadata: dict[str, str] | None = None
299
301
  document_id: str | None = Field(default=None, description="Optional document ID for this memory item.")
300
302
 
303
+ @field_validator("timestamp", mode="before")
304
+ @classmethod
305
+ def validate_timestamp(cls, v):
306
+ if v is None or v == "":
307
+ return None
308
+ if isinstance(v, datetime):
309
+ return v
310
+ if isinstance(v, str):
311
+ try:
312
+ # Try parsing as ISO format
313
+ return datetime.fromisoformat(v.replace("Z", "+00:00"))
314
+ except ValueError as e:
315
+ raise ValueError(
316
+ f"Invalid timestamp/event_date format: '{v}'. Expected ISO format like '2024-01-15T10:30:00' or '2024-01-15T10:30:00Z'"
317
+ ) from e
318
+ raise ValueError(f"timestamp must be a string or datetime, got {type(v).__name__}")
319
+
301
320
 
302
321
  class RetainRequest(BaseModel):
303
322
  """Request model for retain endpoint."""
@@ -337,7 +356,7 @@ class RetainResponse(BaseModel):
337
356
  success: bool
338
357
  bank_id: str
339
358
  items_count: int
340
- async_: bool = Field(
359
+ is_async: bool = Field(
341
360
  alias="async", serialization_alias="async", description="Whether the operation was processed asynchronously"
342
361
  )
343
362
 
@@ -706,7 +725,11 @@ class DeleteResponse(BaseModel):
706
725
  deleted_count: int | None = None
707
726
 
708
727
 
709
- def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
728
+ def create_app(
729
+ memory: MemoryEngine,
730
+ initialize_memory: bool = True,
731
+ http_extension: HttpExtension | None = None,
732
+ ) -> FastAPI:
710
733
  """
711
734
  Create and configure the FastAPI application.
712
735
 
@@ -714,6 +737,8 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
714
737
  memory: MemoryEngine instance (already initialized with required parameters).
715
738
  Migrations are controlled by the MemoryEngine's run_migrations parameter.
716
739
  initialize_memory: Whether to initialize memory system on startup (default: True)
740
+ http_extension: Optional HTTP extension to mount custom endpoints under /extension/.
741
+ If None, attempts to load from HINDSIGHT_API_HTTP_EXTENSION env var.
717
742
 
718
743
  Returns:
719
744
  Configured FastAPI application
@@ -723,6 +748,11 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
723
748
  In that case, you should call memory.initialize() manually before starting the server
724
749
  and memory.close() when shutting down.
725
750
  """
751
+ # Load HTTP extension from environment if not provided
752
+ if http_extension is None:
753
+ http_extension = load_extension("HTTP", HttpExtension)
754
+ if http_extension:
755
+ logging.info(f"Loaded HTTP extension: {http_extension.__class__.__name__}")
726
756
 
727
757
  @asynccontextmanager
728
758
  async def lifespan(app: FastAPI):
@@ -746,8 +776,18 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
746
776
  await memory.initialize()
747
777
  logging.info("Memory system initialized")
748
778
 
779
+ # Call HTTP extension startup hook
780
+ if http_extension:
781
+ await http_extension.on_startup()
782
+ logging.info("HTTP extension started")
783
+
749
784
  yield
750
785
 
786
+ # Call HTTP extension shutdown hook
787
+ if http_extension:
788
+ await http_extension.on_shutdown()
789
+ logging.info("HTTP extension stopped")
790
+
751
791
  # Shutdown: Cleanup memory system
752
792
  await memory.close()
753
793
  logging.info("Memory system closed")
@@ -775,12 +815,36 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
775
815
  # Register all routes
776
816
  _register_routes(app)
777
817
 
818
+ # Mount HTTP extension router if available
819
+ if http_extension:
820
+ extension_router = http_extension.get_router(memory)
821
+ app.include_router(extension_router, prefix="/ext", tags=["Extension"])
822
+ logging.info("HTTP extension router mounted at /ext/")
823
+
778
824
  return app
779
825
 
780
826
 
781
827
  def _register_routes(app: FastAPI):
782
828
  """Register all API routes on the given app instance."""
783
829
 
830
+ def get_request_context(authorization: str | None = Header(default=None)) -> RequestContext:
831
+ """
832
+ Extract request context from Authorization header.
833
+
834
+ Supports:
835
+ - Bearer token: "Bearer <api_key>"
836
+ - Direct API key: "<api_key>"
837
+
838
+ Returns RequestContext with extracted API key (may be None if no auth header).
839
+ """
840
+ api_key = None
841
+ if authorization:
842
+ if authorization.lower().startswith("bearer "):
843
+ api_key = authorization[7:].strip()
844
+ else:
845
+ api_key = authorization.strip()
846
+ return RequestContext(api_key=api_key)
847
+
784
848
  @app.get(
785
849
  "/health",
786
850
  summary="Health check endpoint",
@@ -821,10 +885,12 @@ def _register_routes(app: FastAPI):
821
885
  operation_id="get_graph",
822
886
  tags=["Memory"],
823
887
  )
824
- async def api_graph(bank_id: str, type: str | None = None):
888
+ async def api_graph(
889
+ bank_id: str, type: str | None = None, request_context: RequestContext = Depends(get_request_context)
890
+ ):
825
891
  """Get graph data from database, filtered by bank_id and optionally by type."""
826
892
  try:
827
- data = await app.state.memory.get_graph_data(bank_id, type)
893
+ data = await app.state.memory.get_graph_data(bank_id, type, request_context=request_context)
828
894
  return data
829
895
  except Exception as e:
830
896
  import traceback
@@ -841,7 +907,14 @@ def _register_routes(app: FastAPI):
841
907
  operation_id="list_memories",
842
908
  tags=["Memory"],
843
909
  )
844
- async def api_list(bank_id: str, type: str | None = None, q: str | None = None, limit: int = 100, offset: int = 0):
910
+ async def api_list(
911
+ bank_id: str,
912
+ type: str | None = None,
913
+ q: str | None = None,
914
+ limit: int = 100,
915
+ offset: int = 0,
916
+ request_context: RequestContext = Depends(get_request_context),
917
+ ):
845
918
  """
846
919
  List memory units for table view with optional full-text search.
847
920
 
@@ -857,7 +930,12 @@ def _register_routes(app: FastAPI):
857
930
  """
858
931
  try:
859
932
  data = await app.state.memory.list_memory_units(
860
- bank_id=bank_id, fact_type=type, search_query=q, limit=limit, offset=offset
933
+ bank_id=bank_id,
934
+ fact_type=type,
935
+ search_query=q,
936
+ limit=limit,
937
+ offset=offset,
938
+ request_context=request_context,
861
939
  )
862
940
  return data
863
941
  except Exception as e:
@@ -880,7 +958,9 @@ def _register_routes(app: FastAPI):
880
958
  operation_id="recall_memories",
881
959
  tags=["Memory"],
882
960
  )
883
- async def api_recall(bank_id: str, request: RecallRequest):
961
+ async def api_recall(
962
+ bank_id: str, request: RecallRequest, request_context: RequestContext = Depends(get_request_context)
963
+ ):
884
964
  """Run a recall and return results with trace."""
885
965
  metrics = get_metrics_collector()
886
966
 
@@ -923,6 +1003,7 @@ def _register_routes(app: FastAPI):
923
1003
  max_entity_tokens=max_entity_tokens,
924
1004
  include_chunks=include_chunks,
925
1005
  max_chunk_tokens=max_chunk_tokens,
1006
+ request_context=request_context,
926
1007
  )
927
1008
 
928
1009
  # Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
@@ -995,14 +1076,20 @@ def _register_routes(app: FastAPI):
995
1076
  operation_id="reflect",
996
1077
  tags=["Memory"],
997
1078
  )
998
- async def api_reflect(bank_id: str, request: ReflectRequest):
1079
+ async def api_reflect(
1080
+ bank_id: str, request: ReflectRequest, request_context: RequestContext = Depends(get_request_context)
1081
+ ):
999
1082
  metrics = get_metrics_collector()
1000
1083
 
1001
1084
  try:
1002
1085
  # Use the memory system's reflect_async method (record metrics)
1003
1086
  with metrics.record_operation("reflect", bank_id=bank_id, budget=request.budget.value):
1004
1087
  core_result = await app.state.memory.reflect_async(
1005
- bank_id=bank_id, query=request.query, budget=request.budget, context=request.context
1088
+ bank_id=bank_id,
1089
+ query=request.query,
1090
+ budget=request.budget,
1091
+ context=request.context,
1092
+ request_context=request_context,
1006
1093
  )
1007
1094
 
1008
1095
  # Convert core MemoryFact objects to API ReflectFact objects if facts are requested
@@ -1041,10 +1128,10 @@ def _register_routes(app: FastAPI):
1041
1128
  operation_id="list_banks",
1042
1129
  tags=["Banks"],
1043
1130
  )
1044
- async def api_list_banks():
1131
+ async def api_list_banks(request_context: RequestContext = Depends(get_request_context)):
1045
1132
  """Get list of all banks with their profiles."""
1046
1133
  try:
1047
- banks = await app.state.memory.list_banks()
1134
+ banks = await app.state.memory.list_banks(request_context=request_context)
1048
1135
  return BankListResponse(banks=banks)
1049
1136
  except Exception as e:
1050
1137
  import traceback
@@ -1067,9 +1154,9 @@ def _register_routes(app: FastAPI):
1067
1154
  async with acquire_with_retry(pool) as conn:
1068
1155
  # Get node counts by fact_type
1069
1156
  node_stats = await conn.fetch(
1070
- """
1157
+ f"""
1071
1158
  SELECT fact_type, COUNT(*) as count
1072
- FROM memory_units
1159
+ FROM {fq_table("memory_units")}
1073
1160
  WHERE bank_id = $1
1074
1161
  GROUP BY fact_type
1075
1162
  """,
@@ -1078,10 +1165,10 @@ def _register_routes(app: FastAPI):
1078
1165
 
1079
1166
  # Get link counts by link_type
1080
1167
  link_stats = await conn.fetch(
1081
- """
1168
+ f"""
1082
1169
  SELECT ml.link_type, COUNT(*) as count
1083
- FROM memory_links ml
1084
- JOIN memory_units mu ON ml.from_unit_id = mu.id
1170
+ FROM {fq_table("memory_links")} ml
1171
+ JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
1085
1172
  WHERE mu.bank_id = $1
1086
1173
  GROUP BY ml.link_type
1087
1174
  """,
@@ -1090,10 +1177,10 @@ def _register_routes(app: FastAPI):
1090
1177
 
1091
1178
  # Get link counts by fact_type (from nodes)
1092
1179
  link_fact_type_stats = await conn.fetch(
1093
- """
1180
+ f"""
1094
1181
  SELECT mu.fact_type, COUNT(*) as count
1095
- FROM memory_links ml
1096
- JOIN memory_units mu ON ml.from_unit_id = mu.id
1182
+ FROM {fq_table("memory_links")} ml
1183
+ JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
1097
1184
  WHERE mu.bank_id = $1
1098
1185
  GROUP BY mu.fact_type
1099
1186
  """,
@@ -1102,10 +1189,10 @@ def _register_routes(app: FastAPI):
1102
1189
 
1103
1190
  # Get link counts by fact_type AND link_type
1104
1191
  link_breakdown_stats = await conn.fetch(
1105
- """
1192
+ f"""
1106
1193
  SELECT mu.fact_type, ml.link_type, COUNT(*) as count
1107
- FROM memory_links ml
1108
- JOIN memory_units mu ON ml.from_unit_id = mu.id
1194
+ FROM {fq_table("memory_links")} ml
1195
+ JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
1109
1196
  WHERE mu.bank_id = $1
1110
1197
  GROUP BY mu.fact_type, ml.link_type
1111
1198
  """,
@@ -1114,9 +1201,9 @@ def _register_routes(app: FastAPI):
1114
1201
 
1115
1202
  # Get pending and failed operations counts
1116
1203
  ops_stats = await conn.fetch(
1117
- """
1204
+ f"""
1118
1205
  SELECT status, COUNT(*) as count
1119
- FROM async_operations
1206
+ FROM {fq_table("async_operations")}
1120
1207
  WHERE bank_id = $1
1121
1208
  GROUP BY status
1122
1209
  """,
@@ -1128,9 +1215,9 @@ def _register_routes(app: FastAPI):
1128
1215
 
1129
1216
  # Get document count
1130
1217
  doc_count_result = await conn.fetchrow(
1131
- """
1218
+ f"""
1132
1219
  SELECT COUNT(*) as count
1133
- FROM documents
1220
+ FROM {fq_table("documents")}
1134
1221
  WHERE bank_id = $1
1135
1222
  """,
1136
1223
  bank_id,
@@ -1184,11 +1271,13 @@ def _register_routes(app: FastAPI):
1184
1271
  tags=["Entities"],
1185
1272
  )
1186
1273
  async def api_list_entities(
1187
- bank_id: str, limit: int = Query(default=100, description="Maximum number of entities to return")
1274
+ bank_id: str,
1275
+ limit: int = Query(default=100, description="Maximum number of entities to return"),
1276
+ request_context: RequestContext = Depends(get_request_context),
1188
1277
  ):
1189
1278
  """List entities for a memory bank."""
1190
1279
  try:
1191
- entities = await app.state.memory.list_entities(bank_id, limit=limit)
1280
+ entities = await app.state.memory.list_entities(bank_id, limit=limit, request_context=request_context)
1192
1281
  return EntityListResponse(items=[EntityListItem(**e) for e in entities])
1193
1282
  except Exception as e:
1194
1283
  import traceback
@@ -1205,37 +1294,26 @@ def _register_routes(app: FastAPI):
1205
1294
  operation_id="get_entity",
1206
1295
  tags=["Entities"],
1207
1296
  )
1208
- async def api_get_entity(bank_id: str, entity_id: str):
1297
+ async def api_get_entity(
1298
+ bank_id: str, entity_id: str, request_context: RequestContext = Depends(get_request_context)
1299
+ ):
1209
1300
  """Get entity details with observations."""
1210
1301
  try:
1211
- # First get the entity metadata
1212
- pool = await app.state.memory._get_pool()
1213
- async with acquire_with_retry(pool) as conn:
1214
- entity_row = await conn.fetchrow(
1215
- """
1216
- SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
1217
- FROM entities
1218
- WHERE bank_id = $1 AND id = $2
1219
- """,
1220
- bank_id,
1221
- uuid.UUID(entity_id),
1222
- )
1302
+ entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
1223
1303
 
1224
- if not entity_row:
1304
+ if entity is None:
1225
1305
  raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
1226
1306
 
1227
- # Get observations for the entity
1228
- observations = await app.state.memory.get_entity_observations(bank_id, entity_id, limit=20)
1229
-
1230
1307
  return EntityDetailResponse(
1231
- id=str(entity_row["id"]),
1232
- canonical_name=entity_row["canonical_name"],
1233
- mention_count=entity_row["mention_count"],
1234
- first_seen=entity_row["first_seen"].isoformat() if entity_row["first_seen"] else None,
1235
- last_seen=entity_row["last_seen"].isoformat() if entity_row["last_seen"] else None,
1236
- metadata=_parse_metadata(entity_row["metadata"]),
1308
+ id=entity["id"],
1309
+ canonical_name=entity["canonical_name"],
1310
+ mention_count=entity["mention_count"],
1311
+ first_seen=entity["first_seen"],
1312
+ last_seen=entity["last_seen"],
1313
+ metadata=_parse_metadata(entity["metadata"]),
1237
1314
  observations=[
1238
- EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at) for obs in observations
1315
+ EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
1316
+ for obs in entity["observations"]
1239
1317
  ],
1240
1318
  )
1241
1319
  except HTTPException:
@@ -1255,42 +1333,40 @@ def _register_routes(app: FastAPI):
1255
1333
  operation_id="regenerate_entity_observations",
1256
1334
  tags=["Entities"],
1257
1335
  )
1258
- async def api_regenerate_entity_observations(bank_id: str, entity_id: str):
1336
+ async def api_regenerate_entity_observations(
1337
+ bank_id: str,
1338
+ entity_id: str,
1339
+ request_context: RequestContext = Depends(get_request_context),
1340
+ ):
1259
1341
  """Regenerate observations for an entity."""
1260
1342
  try:
1261
- # First get the entity metadata
1262
- pool = await app.state.memory._get_pool()
1263
- async with acquire_with_retry(pool) as conn:
1264
- entity_row = await conn.fetchrow(
1265
- """
1266
- SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
1267
- FROM entities
1268
- WHERE bank_id = $1 AND id = $2
1269
- """,
1270
- bank_id,
1271
- uuid.UUID(entity_id),
1272
- )
1343
+ # Get the entity to verify it exists and get canonical_name
1344
+ entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
1273
1345
 
1274
- if not entity_row:
1346
+ if entity is None:
1275
1347
  raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
1276
1348
 
1277
1349
  # Regenerate observations
1278
1350
  await app.state.memory.regenerate_entity_observations(
1279
- bank_id=bank_id, entity_id=entity_id, entity_name=entity_row["canonical_name"]
1351
+ bank_id=bank_id,
1352
+ entity_id=entity_id,
1353
+ entity_name=entity["canonical_name"],
1354
+ request_context=request_context,
1280
1355
  )
1281
1356
 
1282
- # Get updated observations
1283
- observations = await app.state.memory.get_entity_observations(bank_id, entity_id, limit=20)
1357
+ # Get updated entity with new observations
1358
+ entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context)
1284
1359
 
1285
1360
  return EntityDetailResponse(
1286
- id=str(entity_row["id"]),
1287
- canonical_name=entity_row["canonical_name"],
1288
- mention_count=entity_row["mention_count"],
1289
- first_seen=entity_row["first_seen"].isoformat() if entity_row["first_seen"] else None,
1290
- last_seen=entity_row["last_seen"].isoformat() if entity_row["last_seen"] else None,
1291
- metadata=_parse_metadata(entity_row["metadata"]),
1361
+ id=entity["id"],
1362
+ canonical_name=entity["canonical_name"],
1363
+ mention_count=entity["mention_count"],
1364
+ first_seen=entity["first_seen"],
1365
+ last_seen=entity["last_seen"],
1366
+ metadata=_parse_metadata(entity["metadata"]),
1292
1367
  observations=[
1293
- EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at) for obs in observations
1368
+ EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
1369
+ for obs in entity["observations"]
1294
1370
  ],
1295
1371
  )
1296
1372
  except HTTPException:
@@ -1310,7 +1386,13 @@ def _register_routes(app: FastAPI):
1310
1386
  operation_id="list_documents",
1311
1387
  tags=["Documents"],
1312
1388
  )
1313
- async def api_list_documents(bank_id: str, q: str | None = None, limit: int = 100, offset: int = 0):
1389
+ async def api_list_documents(
1390
+ bank_id: str,
1391
+ q: str | None = None,
1392
+ limit: int = 100,
1393
+ offset: int = 0,
1394
+ request_context: RequestContext = Depends(get_request_context),
1395
+ ):
1314
1396
  """
1315
1397
  List documents for a memory bank with optional search.
1316
1398
 
@@ -1321,7 +1403,9 @@ def _register_routes(app: FastAPI):
1321
1403
  offset: Offset for pagination (default: 0)
1322
1404
  """
1323
1405
  try:
1324
- data = await app.state.memory.list_documents(bank_id=bank_id, search_query=q, limit=limit, offset=offset)
1406
+ data = await app.state.memory.list_documents(
1407
+ bank_id=bank_id, search_query=q, limit=limit, offset=offset, request_context=request_context
1408
+ )
1325
1409
  return data
1326
1410
  except Exception as e:
1327
1411
  import traceback
@@ -1338,7 +1422,9 @@ def _register_routes(app: FastAPI):
1338
1422
  operation_id="get_document",
1339
1423
  tags=["Documents"],
1340
1424
  )
1341
- async def api_get_document(bank_id: str, document_id: str):
1425
+ async def api_get_document(
1426
+ bank_id: str, document_id: str, request_context: RequestContext = Depends(get_request_context)
1427
+ ):
1342
1428
  """
1343
1429
  Get a specific document with its original text.
1344
1430
 
@@ -1347,7 +1433,7 @@ def _register_routes(app: FastAPI):
1347
1433
  document_id: Document ID (from path)
1348
1434
  """
1349
1435
  try:
1350
- document = await app.state.memory.get_document(document_id, bank_id)
1436
+ document = await app.state.memory.get_document(document_id, bank_id, request_context=request_context)
1351
1437
  if not document:
1352
1438
  raise HTTPException(status_code=404, detail="Document not found")
1353
1439
  return document
@@ -1368,7 +1454,7 @@ def _register_routes(app: FastAPI):
1368
1454
  operation_id="get_chunk",
1369
1455
  tags=["Documents"],
1370
1456
  )
1371
- async def api_get_chunk(chunk_id: str):
1457
+ async def api_get_chunk(chunk_id: str, request_context: RequestContext = Depends(get_request_context)):
1372
1458
  """
1373
1459
  Get a specific chunk with its text.
1374
1460
 
@@ -1376,7 +1462,7 @@ def _register_routes(app: FastAPI):
1376
1462
  chunk_id: Chunk ID (from path, format: bank_id_document_id_chunk_index)
1377
1463
  """
1378
1464
  try:
1379
- chunk = await app.state.memory.get_chunk(chunk_id)
1465
+ chunk = await app.state.memory.get_chunk(chunk_id, request_context=request_context)
1380
1466
  if not chunk:
1381
1467
  raise HTTPException(status_code=404, detail="Chunk not found")
1382
1468
  return chunk
@@ -1401,7 +1487,9 @@ def _register_routes(app: FastAPI):
1401
1487
  operation_id="delete_document",
1402
1488
  tags=["Documents"],
1403
1489
  )
1404
- async def api_delete_document(bank_id: str, document_id: str):
1490
+ async def api_delete_document(
1491
+ bank_id: str, document_id: str, request_context: RequestContext = Depends(get_request_context)
1492
+ ):
1405
1493
  """
1406
1494
  Delete a document and all its associated memory units and links.
1407
1495
 
@@ -1410,7 +1498,7 @@ def _register_routes(app: FastAPI):
1410
1498
  document_id: Document ID to delete (from path)
1411
1499
  """
1412
1500
  try:
1413
- result = await app.state.memory.delete_document(document_id, bank_id)
1501
+ result = await app.state.memory.delete_document(document_id, bank_id, request_context=request_context)
1414
1502
 
1415
1503
  if result["document_deleted"] == 0:
1416
1504
  raise HTTPException(status_code=404, detail="Document not found")
@@ -1437,45 +1525,14 @@ def _register_routes(app: FastAPI):
1437
1525
  operation_id="list_operations",
1438
1526
  tags=["Operations"],
1439
1527
  )
1440
- async def api_list_operations(bank_id: str):
1528
+ async def api_list_operations(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
1441
1529
  """List all async operations (pending and failed) for a memory bank."""
1442
1530
  try:
1443
- pool = await app.state.memory._get_pool()
1444
- async with acquire_with_retry(pool) as conn:
1445
- operations = await conn.fetch(
1446
- """
1447
- SELECT operation_id, bank_id, operation_type, created_at, status, error_message, result_metadata
1448
- FROM async_operations
1449
- WHERE bank_id = $1
1450
- ORDER BY created_at DESC
1451
- """,
1452
- bank_id,
1453
- )
1454
-
1455
- def parse_metadata(metadata):
1456
- """Parse result_metadata which may be a string or dict."""
1457
- if metadata is None:
1458
- return {}
1459
- if isinstance(metadata, str):
1460
- return json.loads(metadata)
1461
- return metadata
1462
-
1463
- return {
1464
- "bank_id": bank_id,
1465
- "operations": [
1466
- {
1467
- "id": str(row["operation_id"]),
1468
- "task_type": row["operation_type"],
1469
- "items_count": parse_metadata(row["result_metadata"]).get("items_count", 0),
1470
- "document_id": parse_metadata(row["result_metadata"]).get("document_id"),
1471
- "created_at": row["created_at"].isoformat(),
1472
- "status": row["status"],
1473
- "error_message": row["error_message"],
1474
- }
1475
- for row in operations
1476
- ],
1477
- }
1478
-
1531
+ operations = await app.state.memory.list_operations(bank_id, request_context=request_context)
1532
+ return {
1533
+ "bank_id": bank_id,
1534
+ "operations": operations,
1535
+ }
1479
1536
  except Exception as e:
1480
1537
  import traceback
1481
1538
 
@@ -1490,39 +1547,21 @@ def _register_routes(app: FastAPI):
1490
1547
  operation_id="cancel_operation",
1491
1548
  tags=["Operations"],
1492
1549
  )
1493
- async def api_cancel_operation(bank_id: str, operation_id: str):
1550
+ async def api_cancel_operation(
1551
+ bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
1552
+ ):
1494
1553
  """Cancel a pending async operation."""
1495
1554
  try:
1496
1555
  # Validate UUID format
1497
1556
  try:
1498
- op_uuid = uuid.UUID(operation_id)
1557
+ uuid.UUID(operation_id)
1499
1558
  except ValueError:
1500
1559
  raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
1501
1560
 
1502
- pool = await app.state.memory._get_pool()
1503
- async with acquire_with_retry(pool) as conn:
1504
- # Check if operation exists and belongs to this memory bank
1505
- result = await conn.fetchrow(
1506
- "SELECT bank_id FROM async_operations WHERE operation_id = $1 AND bank_id = $2", op_uuid, bank_id
1507
- )
1508
-
1509
- if not result:
1510
- raise HTTPException(
1511
- status_code=404, detail=f"Operation {operation_id} not found for memory bank {bank_id}"
1512
- )
1513
-
1514
- # Delete the operation
1515
- await conn.execute("DELETE FROM async_operations WHERE operation_id = $1", op_uuid)
1516
-
1517
- return {
1518
- "success": True,
1519
- "message": f"Operation {operation_id} cancelled",
1520
- "operation_id": operation_id,
1521
- "bank_id": bank_id,
1522
- }
1523
-
1524
- except HTTPException:
1525
- raise
1561
+ result = await app.state.memory.cancel_operation(bank_id, operation_id, request_context=request_context)
1562
+ return result
1563
+ except ValueError as e:
1564
+ raise HTTPException(status_code=404, detail=str(e))
1526
1565
  except Exception as e:
1527
1566
  import traceback
1528
1567
 
@@ -1538,10 +1577,10 @@ def _register_routes(app: FastAPI):
1538
1577
  operation_id="get_bank_profile",
1539
1578
  tags=["Banks"],
1540
1579
  )
1541
- async def api_get_bank_profile(bank_id: str):
1580
+ async def api_get_bank_profile(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
1542
1581
  """Get memory bank profile (disposition + background)."""
1543
1582
  try:
1544
- profile = await app.state.memory.get_bank_profile(bank_id)
1583
+ profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
1545
1584
  # Convert DispositionTraits object to dict for Pydantic
1546
1585
  disposition_dict = (
1547
1586
  profile["disposition"].model_dump()
@@ -1569,14 +1608,18 @@ def _register_routes(app: FastAPI):
1569
1608
  operation_id="update_bank_disposition",
1570
1609
  tags=["Banks"],
1571
1610
  )
1572
- async def api_update_bank_disposition(bank_id: str, request: UpdateDispositionRequest):
1611
+ async def api_update_bank_disposition(
1612
+ bank_id: str, request: UpdateDispositionRequest, request_context: RequestContext = Depends(get_request_context)
1613
+ ):
1573
1614
  """Update bank disposition traits."""
1574
1615
  try:
1575
1616
  # Update disposition
1576
- await app.state.memory.update_bank_disposition(bank_id, request.disposition.model_dump())
1617
+ await app.state.memory.update_bank_disposition(
1618
+ bank_id, request.disposition.model_dump(), request_context=request_context
1619
+ )
1577
1620
 
1578
1621
  # Get updated profile
1579
- profile = await app.state.memory.get_bank_profile(bank_id)
1622
+ profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
1580
1623
  disposition_dict = (
1581
1624
  profile["disposition"].model_dump()
1582
1625
  if hasattr(profile["disposition"], "model_dump")
@@ -1603,11 +1646,13 @@ def _register_routes(app: FastAPI):
1603
1646
  operation_id="add_bank_background",
1604
1647
  tags=["Banks"],
1605
1648
  )
1606
- async def api_add_bank_background(bank_id: str, request: AddBackgroundRequest):
1649
+ async def api_add_bank_background(
1650
+ bank_id: str, request: AddBackgroundRequest, request_context: RequestContext = Depends(get_request_context)
1651
+ ):
1607
1652
  """Add or merge bank background information. Optionally infer disposition traits."""
1608
1653
  try:
1609
1654
  result = await app.state.memory.merge_bank_background(
1610
- bank_id, request.content, update_disposition=request.update_disposition
1655
+ bank_id, request.content, update_disposition=request.update_disposition, request_context=request_context
1611
1656
  )
1612
1657
 
1613
1658
  response = BackgroundResponse(background=result["background"])
@@ -1630,51 +1675,31 @@ def _register_routes(app: FastAPI):
1630
1675
  operation_id="create_or_update_bank",
1631
1676
  tags=["Banks"],
1632
1677
  )
1633
- async def api_create_or_update_bank(bank_id: str, request: CreateBankRequest):
1678
+ async def api_create_or_update_bank(
1679
+ bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context)
1680
+ ):
1634
1681
  """Create or update an agent with disposition and background."""
1635
1682
  try:
1636
- # Get existing profile or create with defaults
1637
- profile = await app.state.memory.get_bank_profile(bank_id)
1638
-
1639
- # Update name if provided
1640
- if request.name is not None:
1641
- pool = await app.state.memory._get_pool()
1642
- async with acquire_with_retry(pool) as conn:
1643
- await conn.execute(
1644
- """
1645
- UPDATE banks
1646
- SET name = $2,
1647
- updated_at = NOW()
1648
- WHERE bank_id = $1
1649
- """,
1650
- bank_id,
1651
- request.name,
1652
- )
1653
- profile["name"] = request.name
1683
+ # Ensure bank exists by getting profile (auto-creates with defaults)
1684
+ await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
1685
+
1686
+ # Update name and/or background if provided
1687
+ if request.name is not None or request.background is not None:
1688
+ await app.state.memory.update_bank(
1689
+ bank_id,
1690
+ name=request.name,
1691
+ background=request.background,
1692
+ request_context=request_context,
1693
+ )
1654
1694
 
1655
1695
  # Update disposition if provided
1656
1696
  if request.disposition is not None:
1657
- await app.state.memory.update_bank_disposition(bank_id, request.disposition.model_dump())
1658
- profile["disposition"] = request.disposition.model_dump()
1659
-
1660
- # Update background if provided (replace, not merge)
1661
- if request.background is not None:
1662
- pool = await app.state.memory._get_pool()
1663
- async with acquire_with_retry(pool) as conn:
1664
- await conn.execute(
1665
- """
1666
- UPDATE banks
1667
- SET background = $2,
1668
- updated_at = NOW()
1669
- WHERE bank_id = $1
1670
- """,
1671
- bank_id,
1672
- request.background,
1673
- )
1674
- profile["background"] = request.background
1697
+ await app.state.memory.update_bank_disposition(
1698
+ bank_id, request.disposition.model_dump(), request_context=request_context
1699
+ )
1675
1700
 
1676
1701
  # Get final profile
1677
- final_profile = await app.state.memory.get_bank_profile(bank_id)
1702
+ final_profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
1678
1703
  disposition_dict = (
1679
1704
  final_profile["disposition"].model_dump()
1680
1705
  if hasattr(final_profile["disposition"], "model_dump")
@@ -1702,10 +1727,10 @@ def _register_routes(app: FastAPI):
1702
1727
  operation_id="delete_bank",
1703
1728
  tags=["Banks"],
1704
1729
  )
1705
- async def api_delete_bank(bank_id: str):
1730
+ async def api_delete_bank(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
1706
1731
  """Delete an entire memory bank and all its data."""
1707
1732
  try:
1708
- result = await app.state.memory.delete_bank(bank_id)
1733
+ result = await app.state.memory.delete_bank(bank_id, request_context=request_context)
1709
1734
  return DeleteResponse(
1710
1735
  success=True,
1711
1736
  message=f"Bank '{bank_id}' and all associated data deleted successfully",
@@ -1745,7 +1770,9 @@ def _register_routes(app: FastAPI):
1745
1770
  operation_id="retain_memories",
1746
1771
  tags=["Memory"],
1747
1772
  )
1748
- async def api_retain(bank_id: str, request: RetainRequest):
1773
+ async def api_retain(
1774
+ bank_id: str, request: RetainRequest, request_context: RequestContext = Depends(get_request_context)
1775
+ ):
1749
1776
  """Retain memories with optional async processing."""
1750
1777
  metrics = get_metrics_collector()
1751
1778
 
@@ -1766,47 +1793,42 @@ def _register_routes(app: FastAPI):
1766
1793
 
1767
1794
  if request.async_:
1768
1795
  # Async processing: queue task and return immediately
1769
- operation_id = uuid.uuid4()
1770
-
1771
- # Insert operation record into database
1772
- pool = await app.state.memory._get_pool()
1773
- async with acquire_with_retry(pool) as conn:
1774
- await conn.execute(
1775
- """
1776
- INSERT INTO async_operations (operation_id, bank_id, operation_type, result_metadata)
1777
- VALUES ($1, $2, $3, $4)
1778
- """,
1779
- operation_id,
1780
- bank_id,
1781
- "retain",
1782
- json.dumps({"items_count": len(contents)}),
1783
- )
1784
-
1785
- # Submit task to background queue
1786
- await app.state.memory._task_backend.submit_task(
1796
+ result = await app.state.memory.submit_async_retain(bank_id, contents, request_context=request_context)
1797
+ return RetainResponse.model_validate(
1787
1798
  {
1788
- "type": "batch_retain",
1789
- "operation_id": str(operation_id),
1799
+ "success": True,
1790
1800
  "bank_id": bank_id,
1791
- "contents": contents,
1801
+ "items_count": result["items_count"],
1802
+ "async": True,
1792
1803
  }
1793
1804
  )
1794
-
1795
- logging.info(
1796
- f"Retain task queued for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}"
1797
- )
1798
-
1799
- return RetainResponse(success=True, bank_id=bank_id, items_count=len(contents), async_=True)
1800
1805
  else:
1801
1806
  # Synchronous processing: wait for completion (record metrics)
1802
1807
  with metrics.record_operation("retain", bank_id=bank_id):
1803
- result = await app.state.memory.retain_batch_async(bank_id=bank_id, contents=contents)
1808
+ result = await app.state.memory.retain_batch_async(
1809
+ bank_id=bank_id, contents=contents, request_context=request_context
1810
+ )
1804
1811
 
1805
- return RetainResponse(success=True, bank_id=bank_id, items_count=len(contents), async_=False)
1812
+ return RetainResponse.model_validate(
1813
+ {"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False}
1814
+ )
1806
1815
  except Exception as e:
1807
1816
  import traceback
1808
1817
 
1809
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
1818
+ # Create a summary of the input for debugging
1819
+ input_summary = []
1820
+ for i, item in enumerate(request.items):
1821
+ content_preview = item.content[:100] + "..." if len(item.content) > 100 else item.content
1822
+ input_summary.append(
1823
+ f" [{i}] content={content_preview!r}, context={item.context}, timestamp={item.timestamp}"
1824
+ )
1825
+ input_debug = "\n".join(input_summary)
1826
+
1827
+ error_detail = (
1828
+ f"{str(e)}\n\n"
1829
+ f"Input ({len(request.items)} items):\n{input_debug}\n\n"
1830
+ f"Traceback:\n{traceback.format_exc()}"
1831
+ )
1810
1832
  logger.error(f"Error in /v1/default/banks/{bank_id}/memories (retain): {error_detail}")
1811
1833
  raise HTTPException(status_code=500, detail=str(e))
1812
1834
 
@@ -1821,10 +1843,11 @@ def _register_routes(app: FastAPI):
1821
1843
  async def api_clear_bank_memories(
1822
1844
  bank_id: str,
1823
1845
  type: str | None = Query(None, description="Optional fact type filter (world, experience, opinion)"),
1846
+ request_context: RequestContext = Depends(get_request_context),
1824
1847
  ):
1825
1848
  """Clear memories for a memory bank, optionally filtered by type."""
1826
1849
  try:
1827
- await app.state.memory.delete_bank(bank_id, fact_type=type)
1850
+ await app.state.memory.delete_bank(bank_id, fact_type=type, request_context=request_context)
1828
1851
 
1829
1852
  return DeleteResponse(success=True)
1830
1853
  except Exception as e: